diff --git a/.ci/Debian12/Dockerfile b/.ci/Debian12/Dockerfile index e3df94ab5..fc756aac2 100644 --- a/.ci/Debian12/Dockerfile +++ b/.ci/Debian12/Dockerfile @@ -18,12 +18,12 @@ RUN apt-get update && \ libssl-dev \ ninja-build \ protobuf-compiler \ + qt6-declarative-dev \ qt6-image-formats-plugins \ qt6-l10n-tools \ qt6-multimedia-dev \ - qt6-declarative-dev \ - qt6-svg-dev \ qt6-shadertools-dev \ + qt6-svg-dev \ qt6-tools-dev \ qt6-tools-dev-tools \ qt6-websockets-dev \ diff --git a/.ci/Debian13/Dockerfile b/.ci/Debian13/Dockerfile index 60e490c98..bdecb56df 100644 --- a/.ci/Debian13/Dockerfile +++ b/.ci/Debian13/Dockerfile @@ -19,12 +19,12 @@ RUN apt-get update && \ libssl-dev \ ninja-build \ protobuf-compiler \ + qt6-declarative-dev \ qt6-image-formats-plugins \ qt6-l10n-tools \ qt6-multimedia-dev \ - qt6-declarative-dev \ - qt6-svg-dev \ qt6-shadertools-dev \ + qt6-svg-dev \ qt6-tools-dev \ qt6-tools-dev-tools \ qt6-websockets-dev \ diff --git a/.ci/Fedora43/Dockerfile b/.ci/Fedora43/Dockerfile index 4005bbf67..463da5a51 100644 --- a/.ci/Fedora43/Dockerfile +++ b/.ci/Fedora43/Dockerfile @@ -9,7 +9,7 @@ RUN dnf install -y \ ninja-build \ openssl-devel \ protobuf-devel \ - qt6-{qtdeclarative,qtshadertools,qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \ + qt6-{qtdeclarative,qtmultimedia,qtshadertools,qtsvg,qttools,qtwebsockets}-devel \ qt6-qtimageformats \ rpm-build \ xz-devel \ diff --git a/.ci/Fedora44/Dockerfile b/.ci/Fedora44/Dockerfile index e0224cdc6..62238e760 100644 --- a/.ci/Fedora44/Dockerfile +++ b/.ci/Fedora44/Dockerfile @@ -9,7 +9,7 @@ RUN dnf install -y \ ninja-build \ openssl-devel \ protobuf-devel \ - qt6-{qtdeclarative,qtshadertools,qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \ + qt6-{qtdeclarative,qtmultimedia,qtshadertools,qtsvg,qttools,qtwebsockets}-devel \ qt6-qtimageformats \ rpm-build \ xz-devel \ diff --git a/.ci/Ubuntu24.04/Dockerfile b/.ci/Ubuntu24.04/Dockerfile index 10adc5e64..715997474 100644 --- a/.ci/Ubuntu24.04/Dockerfile +++ b/.ci/Ubuntu24.04/Dockerfile @@ -18,12 +18,12 @@ RUN apt-get update && \ libssl-dev \ ninja-build \ protobuf-compiler \ + qt6-declarative-dev \ qt6-image-formats-plugins \ qt6-l10n-tools \ qt6-multimedia-dev \ - qt6-declarative-dev \ - qt6-svg-dev \ qt6-shadertools-dev \ + qt6-svg-dev \ qt6-tools-dev \ qt6-tools-dev-tools \ qt6-websockets-dev \ diff --git a/.ci/Ubuntu26.04/Dockerfile b/.ci/Ubuntu26.04/Dockerfile index 1b6cf825f..96dd10763 100644 --- a/.ci/Ubuntu26.04/Dockerfile +++ b/.ci/Ubuntu26.04/Dockerfile @@ -19,12 +19,12 @@ RUN apt-get update && \ libssl-dev \ ninja-build \ protobuf-compiler \ + qt6-declarative-dev \ qt6-image-formats-plugins \ qt6-l10n-tools \ qt6-multimedia-dev \ - qt6-declarative-dev \ - qt6-svg-dev \ qt6-shadertools-dev \ + qt6-svg-dev \ qt6-tools-dev \ qt6-tools-dev-tools \ qt6-websockets-dev \ diff --git a/.ci/compile.sh b/.ci/compile.sh index bd8c900c8..f20432893 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -327,4 +327,32 @@ if [[ $MAKE_PACKAGE ]]; then BUILD_DIR="$BUILD_DIR" .ci/name_build.sh "$PACKAGE_SUFFIX" echo "::endgroup::" fi + + if [[ $RUNNER_OS == Windows ]]; then + echo "::group::Check installer for build-tree artifacts" + cd "$BUILD_DIR" + package="$(find . -maxdepth 1 -type f -name 'Cockatrice-*.exe' -print -quit)" + if [[ ! $package ]]; then + echo "::error file=$0::Could not find installer to inspect" + exit 1 + fi + seven_zip="$(command -v 7z || true)" + if [[ ! $seven_zip ]]; then + seven_zip="/c/Program Files/7-Zip/7z.exe" + fi + if [[ ! -f $seven_zip ]]; then + echo "::warning file=$0::7-Zip not found, skipping installer content check" + else + echo "Inspecting $package" + # Fail the build if the installer contains any path left behind by the MSBuild or + # Qt AUTOMOC tooling (build-tree artifacts must live in the build dir, not the install) + if "$seven_zip" l "$package" | + grep -E "_autogen|\.dir[\\/]|\.tlog|(^|[\\/])x64[\\/]|(^|[\\/])\.qt[\\/]|(^|[\\/])\.qsb[\\/]|(^|[\\/])\.lupdate[\\/]|CMakeFiles"; then + echo "::error file=$0::Installer contains build-tree artifacts" + exit 1 + fi + echo "Installer content is clean" + fi + echo "::endgroup::" + fi fi diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 6ca634389..bd528f245 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -272,8 +272,8 @@ jobs: make_package: 1 override_target: 13 package_suffix: "-macOS13_Intel" - qt_version: 6.11.1 - qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools + qt_version: 6.11.* + qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets soc: Intel type: Release use_ccache: 1 @@ -288,8 +288,8 @@ jobs: make_package: 1 override_target: 14 package_suffix: "-macOS14" - qt_version: 6.11.1 - qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools + qt_version: 6.11.* + qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets soc: Apple type: Release use_ccache: 1 @@ -304,8 +304,8 @@ jobs: make_package: 1 override_target: 15 package_suffix: "-macOS15" - qt_version: 6.11.1 - qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools + qt_version: 6.11.* + qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets soc: Apple type: Release use_ccache: 1 @@ -317,8 +317,8 @@ jobs: ccache_eviction_age: 7d cmake_generator: Ninja - qt_version: 6.11.1 - qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools + qt_version: 6.11.* + qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets soc: Apple type: Debug use_ccache: 1 @@ -332,8 +332,8 @@ jobs: cmake_generator_platform: x64 make_package: 1 package_suffix: "-Win10" - qt_version: 6.11.1 - qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools + qt_version: 6.11.* + qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets type: Release name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }} @@ -368,18 +368,20 @@ jobs: key: ccache-${{ matrix.runner }}_${{ matrix.override_target }}-Xcode${{ matrix.xcode }} path: ${{ env.CCACHE_DIR }} - - name: "Install aqtinstall" + - name: "[macOS] Install aqtinstall" + if: matrix.os == 'macOS' run: pipx install aqtinstall # Resolve given wildcard versions (e.g. Qt 6.6.*) to latest version via aqtinstall to avoid stale caches on new releases - - name: "Resolve latest Qt patch version" + - name: "[macOS] Resolve latest Qt from ${{ matrix.qt_version }} input" + if: matrix.os == 'macOS' env: QT_VERSION: ${{ matrix.qt_version }} id: resolve_qt_version shell: bash run: .ci/resolve_latest_aqt_qt_version.sh "$QT_VERSION" - - name: "[macOS] Restore thin Qt ${{ steps.resolve_qt_version.outputs.version }} libraries" + - name: "[macOS] Restore thin Qt ${{ steps.resolve_qt_version.outputs.version }}" if: matrix.os == 'macOS' id: restore_qt uses: actions/cache/restore@v6 @@ -389,21 +391,22 @@ jobs: # Using jurplel/install-qt-action to install Qt without using brew # Qt build using vcpkg either just fails or takes too long to build - - name: "[macOS] Install fat Qt ${{ steps.resolve_qt_version.outputs.version }}" + - name: "[macOS] Install fat Qt ${{ matrix.qt_version }}" if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true' uses: jurplel/install-qt-action@v4 with: cache: false - dir: ${{ github.workspace }} + # cache-key-prefix: Qt + dir: ${{ github.workspace }} # thinning script depends on this location modules: ${{ matrix.qt_modules }} - version: ${{ steps.resolve_qt_version.outputs.version }} + version: ${{ matrix.qt_version }} - name: "[macOS] Create thin Qt libraries" if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true' run: .ci/thin_macos_qtlib.sh - name: "[macOS] Cache thin Qt libraries" - if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true' + if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true' && github.ref == 'refs/heads/master' uses: actions/cache/save@v6 with: key: ${{ steps.restore_qt.outputs.cache-primary-key }} @@ -415,10 +418,10 @@ jobs: with: # Qt 6.11.0 only works with aqtinstall directly from git until aqtinstall 3.4 is released aqtsource: git+https://github.com/miurahr/aqtinstall.git - cache: true + cache: ${{ github.ref == 'refs/heads/master' }} cache-key-prefix: Qt modules: ${{ matrix.qt_modules }} - version: ${{ steps.resolve_qt_version.outputs.version }} + version: ${{ matrix.qt_version }} - name: "[Windows] Install NSIS" if: matrix.os == 'Windows' @@ -450,7 +453,7 @@ jobs: PACKAGE_SUFFIX: '${{ matrix.package_suffix }}' TARGET_MACOS_VERSION: ${{ matrix.override_target }} USE_CCACHE: ${{ matrix.use_ccache }} - VCPKG_BINARY_SOURCES: 'clear;files,${{ steps.vcpkg-cache.outputs.path }},readwrite' + VCPKG_BINARY_SOURCES: "clear;files,${{ steps.vcpkg-cache.outputs.path }},${{ case(github.ref == 'refs/heads/master', 'readwrite', 'read') }}" VCPKG_DISABLE_METRICS: 1 VCPKG_FEATURE_FLAGS: dependencygraph run: .ci/compile.sh --server --test --vcpkg diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 255e8b045..967d94c58 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -76,7 +76,7 @@ jobs: uses: docker/build-push-action@v7 with: cache-from: type=gha,scope=${{ env.CACHE_SCOPE }} - cache-to: type=gha,mode=max,scope=${{ env.CACHE_SCOPE }} + cache-to: ${{ case(github.ref == 'refs/heads/master', format('type=gha,mode=max,scope={0}', env.CACHE_SCOPE), '') }} context: . platforms: ${{ matrix.platform }} push: false diff --git a/CMakeLists.txt b/CMakeLists.txt index 0da073464..293e25dd9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -293,7 +293,7 @@ if(UNIX) if(CPACK_GENERATOR STREQUAL "RPM") set(CPACK_RPM_PACKAGE_LICENSE "GPLv2") set(CPACK_RPM_MAIN_COMPONENT "cockatrice") - set(CPACK_RPM_PACKAGE_REQUIRES "protobuf, qt6-qttools, qt6-qtsvg, qt6-qtmultimedia, qt6-qtimageformats") + set(CPACK_RPM_PACKAGE_REQUIRES "protobuf, qt6-qtimageformats, qt6-qtmultimedia, qt6-qtsvg, qt6-qttools") set(CPACK_RPM_PACKAGE_GROUP "Amusements/Games") set(CPACK_RPM_PACKAGE_URL "http://github.com/Cockatrice/Cockatrice") # stop directories from making package conflicts @@ -311,7 +311,7 @@ if(UNIX) set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON) set(CPACK_DEBIAN_PACKAGE_SECTION "games") set(CPACK_DEBIAN_PACKAGE_HOMEPAGE "http://github.com/Cockatrice/Cockatrice") - set(CPACK_DEBIAN_PACKAGE_DEPENDS "libqt6multimedia6, libqt6svg6, qt6-qpa-plugins, qt6-image-formats-plugins") + set(CPACK_DEBIAN_PACKAGE_DEPENDS "libqt6multimedia6, libqt6svg6, qt6-image-formats-plugins, qt6-qpa-plugins") set(CPACK_DEBIAN_PACKAGE_RECOMMENDS "libqt6sql6-mysql") # for connecting servatrice to a mysql db endif() endif() diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index 5af116470..b3cbcece8 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -387,19 +387,14 @@ SectionEnd Section "un.Application" UnSecApplication SetShellVarContext all - RMDir /r "$INSTDIR\plugins" - RMDir /r "$INSTDIR\sounds" - RMDir /r "$INSTDIR\themes" - RMDir /r "$INSTDIR\translations" - Delete "$INSTDIR\*.exe" - Delete "$INSTDIR\*.dll" - Delete "$INSTDIR\qt.conf" - Delete "$INSTDIR\qdebug.txt" - Delete "$INSTDIR\servatrice.sql" - Delete "$INSTDIR\servatrice.ini.example" - RMDir "$INSTDIR" - RMDir "$SMPROGRAMS\Cockatrice" + ; Remove the entire application directory so any file that is not part of + ; the installed payload (e.g. build-tree artifacts such as *.dir folders, + ; *_autogen and *.tlog files from a build) cannot survive between an + ; uninstall and a fresh reinstall. + RMDir /r "$INSTDIR" + + RMDir /r "$SMPROGRAMS\Cockatrice" DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" SectionEnd diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 63ccc4e9c..9b31310e6 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -45,11 +45,14 @@ set(cockatrice_SOURCES src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp src/interface/widgets/dialogs/dlg_load_remote_deck.cpp src/interface/widgets/dialogs/dlg_local_game_options.cpp + src/interface/widgets/dialogs/dlg_login_prompt.cpp src/interface/widgets/dialogs/dlg_manage_sets.cpp src/interface/widgets/dialogs/dlg_my_reports.cpp src/interface/widgets/dialogs/dlg_register.cpp src/interface/widgets/dialogs/dlg_report_user.cpp src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp + src/interface/widgets/dialogs/dlg_share_deck.cpp + src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp src/interface/widgets/dialogs/dlg_settings.cpp src/interface/widgets/dialogs/dlg_startup_card_check.cpp src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp @@ -57,6 +60,9 @@ set(cockatrice_SOURCES src/interface/widgets/dialogs/dlg_view_log.cpp src/interface/widgets/dialogs/override_printing_warning.cpp src/interface/widgets/dialogs/tip_of_the_day.cpp + src/interface/widgets/deck_share/deck_share_utils.cpp + src/interface/widgets/deck_share/shared_deck_preview_widget.cpp + src/interface/widgets/deck_share/share_bar_widget.cpp src/filters/deck_filter_string.cpp src/filters/filter_builder.cpp src/filters/filter_tree_model.cpp @@ -163,6 +169,7 @@ set(cockatrice_SOURCES src/interface/palette_editor/palette_grid_widget.cpp src/interface/palette_editor/palette_editor_dialog.cpp src/interface/widgets/cards/additional_info/color_identity_widget.cpp + src/interface/widgets/cards/additional_info/deck_color_identity.cpp src/interface/widgets/cards/additional_info/mana_cost_widget.cpp src/interface/widgets/cards/additional_info/mana_symbol_widget.cpp src/interface/widgets/cards/art_crop_attribution.cpp @@ -317,6 +324,8 @@ set(cockatrice_SOURCES src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_item_widget.cpp src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp + src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.cpp + src/interface/widgets/visual_deck_storage/remote_public_decks_model.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.cpp @@ -388,6 +397,7 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/tab_logs.cpp src/interface/widgets/tabs/tab_message.cpp src/interface/widgets/tabs/tab_moderation.cpp + src/interface/widgets/tabs/tab_public_decks.cpp src/interface/widgets/tabs/tab_report.cpp src/interface/widgets/tabs/tab_replays.cpp src/interface/widgets/tabs/tab_room.cpp @@ -403,6 +413,7 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp src/interface/widgets/onboarding/banner_shader_config.h + src/interface/widgets/onboarding/brand_colors.h src/interface/widgets/onboarding/first_run_wizard.cpp src/interface/widgets/onboarding/first_run_wizard.h src/interface/widgets/onboarding/first_run_wizard_page.cpp @@ -443,6 +454,8 @@ set(cockatrice_SOURCES src/interface/intents/intent_login.h src/interface/intents/intent_open_server_room_by_name.cpp src/interface/intents/intent_open_server_room_by_name.h + src/interface/intents/intent_open_shared_deck.cpp + src/interface/intents/intent_open_shared_deck.h src/interface/intents/url_parser.cpp src/interface/intents/url_parser.h src/interface/widgets/server/user/user_info_popup.cpp @@ -530,6 +543,7 @@ qt6_add_shaders( "src/interface/widgets/onboarding/shaders" FILES src/interface/widgets/onboarding/shaders/brand_banner.frag + src/interface/widgets/onboarding/shaders/brand_plate.frag ) qt6_add_resources( @@ -654,18 +668,35 @@ if(WIN32) set(qtconf_dest_dir .) install( - DIRECTORY "${CMAKE_BINARY_DIR}/${PROJECT_NAME}/${CMAKE_BUILD_TYPE}/" + DIRECTORY "$/" DESTINATION ./ FILES_MATCHING PATTERN "*.dll" + PATTERN "*.pdb" EXCLUDE + PATTERN "*.dir*" EXCLUDE + PATTERN "*_autogen*" EXCLUDE + PATTERN "*.tlog*" EXCLUDE + PATTERN "CMakeFiles*" EXCLUDE + PATTERN "x64*" EXCLUDE + PATTERN ".qt*" EXCLUDE + PATTERN ".qsb*" EXCLUDE + PATTERN ".lupdate*" EXCLUDE ) install( DIRECTORY "${CMAKE_BINARY_DIR}/cockatrice/" DESTINATION ./ FILES_MATCHING - PATTERN "CMakeFiles" EXCLUDE PATTERN "*.ini" + PATTERN "CMakeFiles*" EXCLUDE + PATTERN "*.dir*" EXCLUDE + PATTERN "*_autogen*" EXCLUDE + PATTERN "*.tlog*" EXCLUDE + PATTERN "*.pdb" EXCLUDE + PATTERN "x64*" EXCLUDE + PATTERN ".qt*" EXCLUDE + PATTERN ".qsb*" EXCLUDE + PATTERN ".lupdate*" EXCLUDE ) # Qt plugins: audio, iconengines, imageformats, multimedia, platforms, printsupport, styles, tls @@ -718,10 +749,6 @@ Data = Resources\") " COMPONENT Runtime ) - - if(OPENSSL_FOUND) - install(FILES ${OPENSSL_INCLUDE_DIRS} DESTINATION ./) - endif() endif() if(Qt6LinguistTools_FOUND) diff --git a/cockatrice/cockatrice.qrc b/cockatrice/cockatrice.qrc index e21bdb0be..14cf15b2f 100644 --- a/cockatrice/cockatrice.qrc +++ b/cockatrice/cockatrice.qrc @@ -63,6 +63,8 @@ resources/icons/mana/W.svg resources/backgrounds/home.png + resources/backgrounds/home-dark.png + resources/backgrounds/home-light.png resources/backgrounds/card_triplet.svg resources/backgrounds/placeholder_printing_selector.svg @@ -363,6 +365,8 @@ resources/usericons/pawn_single.svg resources/usericons/pawn_double.svg + resources/usericons/pawn_dev_single.svg + resources/usericons/pawn_dev_double.svg resources/usericons/pawn_donator_single.svg resources/usericons/pawn_donator_double.svg resources/usericons/pawn_judge_single.svg diff --git a/cockatrice/resources/backgrounds/home-dark.png b/cockatrice/resources/backgrounds/home-dark.png new file mode 100644 index 000000000..68f48e2c2 Binary files /dev/null and b/cockatrice/resources/backgrounds/home-dark.png differ diff --git a/cockatrice/resources/backgrounds/home-light.png b/cockatrice/resources/backgrounds/home-light.png new file mode 100644 index 000000000..eaaaba932 Binary files /dev/null and b/cockatrice/resources/backgrounds/home-light.png differ diff --git a/cockatrice/resources/backgrounds/home.png b/cockatrice/resources/backgrounds/home.png index 68f48e2c2..eaaaba932 100644 Binary files a/cockatrice/resources/backgrounds/home.png and b/cockatrice/resources/backgrounds/home.png differ diff --git a/cockatrice/resources/usericons/pawn_dev_double.svg b/cockatrice/resources/usericons/pawn_dev_double.svg new file mode 100644 index 000000000..57ed5c2da --- /dev/null +++ b/cockatrice/resources/usericons/pawn_dev_double.svg @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/cockatrice/resources/usericons/pawn_dev_single.svg b/cockatrice/resources/usericons/pawn_dev_single.svg new file mode 100644 index 000000000..f7c4e7018 --- /dev/null +++ b/cockatrice/resources/usericons/pawn_dev_single.svg @@ -0,0 +1,211 @@ + + + +image/svg+xml + + diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.cpp b/cockatrice/src/game_graphics/board/abstract_card_item.cpp index 1410d0c80..3969b7d03 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.cpp +++ b/cockatrice/src/game_graphics/board/abstract_card_item.cpp @@ -1,6 +1,7 @@ #include "abstract_card_item.h" #include "../../client/settings/cache_settings.h" +#include "../../interface/card_localization.h" #include "../../interface/card_picture_loader/card_picture_loader.h" #include "../game_scene.h" #include "../z_values.h" @@ -26,6 +27,8 @@ AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::displayCardNamesChanged, this, [this] { update(); }); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, + [this] { update(); }); refreshCardInfo(); connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::roundCardCornersChanged, this, @@ -171,7 +174,7 @@ void AbstractCardItem::paintPicture(QPainter *painter, const QSizeF &translatedS if (SettingsCache::instance().debug().getShowCardId()) { prefix = "#" + QString::number(id) + " "; } - nameStr = prefix + cardRef.name; + nameStr = prefix + CardLocalization::displayName(getCardInfo()); } painter->drawText(QRectF(3 * scaleFactor, 3 * scaleFactor, translatedSize.width() - 6 * scaleFactor, translatedSize.height() - 6 * scaleFactor), diff --git a/cockatrice/src/interface/card_localization.h b/cockatrice/src/interface/card_localization.h new file mode 100644 index 000000000..0bfe4a764 --- /dev/null +++ b/cockatrice/src/interface/card_localization.h @@ -0,0 +1,59 @@ +#ifndef COCKATRICE_CARD_LOCALIZATION_H +#define COCKATRICE_CARD_LOCALIZATION_H + +#include "../client/settings/cache_settings.h" + +#include +#include +#include + +namespace CardLocalization +{ +/** + * @brief The language code selected for localized card text and images. + */ +inline QString displayLang() +{ + return SettingsCache::instance().cardsDisplay().getCardLang(); +} + +/** + * @brief Card name in the configured display language, falling back to English. + * @param card The card to display. + * @return The localized name, or an empty string for a null card. + */ +inline QString displayName(const CardInfoPtr &card) +{ + return card.isNull() ? QString() : card->getLocalizedName(displayLang()); +} + +/** + * @brief Card rules text in the configured display language, falling back to English. + * @param card The card to display. + * @return The localized text, or an empty string for a null card. + */ +inline QString displayText(const CardInfoPtr &card) +{ + return card.isNull() ? QString() : card->getLocalizedText(displayLang()); +} + +/** + * @brief Card name in the configured display language, falling back to English. + * @param card The card to display. + */ +inline QString displayName(const CardInfo &card) +{ + return card.getLocalizedName(displayLang()); +} + +/** + * @brief Card rules text in the configured display language, falling back to English. + * @param card The card to display. + */ +inline QString displayText(const CardInfo &card) +{ + return card.getLocalizedText(displayLang()); +} +} // namespace CardLocalization + +#endif // COCKATRICE_CARD_LOCALIZATION_H \ No newline at end of file diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp index 8c81d641d..b8a54761a 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp @@ -8,7 +8,7 @@ #include #include #include -#include +#include #include #include #include @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -37,8 +38,10 @@ CardPictureLoader::CardPictureLoader() : QObject(nullptr) &CardPictureLoader::picsPathChanged); connect(&SettingsCache::instance().downloads(), &DownloadSettings::picDownloadChanged, this, &CardPictureLoader::picDownloadChanged); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, + &CardPictureLoader::cardLangChanged); - qRegisterMetaType(); + qRegisterMetaType("ExactCard"); connect(worker, &CardPictureLoaderWorker::imageLoaded, this, &CardPictureLoader::imageLoaded); statusBar = new CardPictureLoaderStatusBar(nullptr); @@ -206,7 +209,49 @@ void CardPictureLoader::imageLoaded(const ExactCard &card, const QImage &image) card.emitPixmapUpdated(); } -void CardPictureLoader::saveCardImageToLocalStorage(const ExactCard &card, const QPixmap &pixmap) +void CardPictureLoader::deleteAllLocalOverrides(const ExactCard &card) +{ + const QString picsRoot = SettingsCache::instance().paths().getPicsPath(); + if (picsRoot.isEmpty() || !card) { + return; + } + + QDir baseDir(picsRoot); + if (!baseDir.cd("downloadedPics")) { + return; + } + + const QString name = card.getInfo().getCorrectedName(); + + QString set, collector, uuid; + auto printing = card.getPrinting(); + if (printing.getSet()) { + set = printing.getSet()->getCorrectedShortName(); + collector = printing.getProperty("num"); + uuid = printing.getUuid(); + } + + for (const auto &scheme : CardPictureLoaderLocalSchemes::exportSchemes()) { + QString rel = CardPictureLoaderLocalSchemes::expandPattern(scheme.pattern, name, set, collector, uuid); + + if (rel.isEmpty()) { + continue; + } + + rel += ".png"; + rel = QDir::cleanPath(rel); + + QString fullPath = baseDir.filePath(rel); + + if (QFile::exists(fullPath)) { + QFile::remove(fullPath); + } + } +} + +void CardPictureLoader::saveCardImageToLocalStorage(const ExactCard &card, + const QPixmap &pixmap, + const bool allowOverwrite) { if (pixmap.isNull() || !card) { return; @@ -266,8 +311,9 @@ void CardPictureLoader::saveCardImageToLocalStorage(const ExactCard &card, const QFileInfo outInfo(baseDir.filePath(relativePath)); - // Do not overwrite existing files - if (outInfo.exists()) { + // Automatic cache writes (FILESYSTEM_CACHE) must never clobber an explicit user override. + // Only the explicit override paths pass allowOverwrite == true. + if (!allowOverwrite && outInfo.exists()) { return; } @@ -288,6 +334,122 @@ void CardPictureLoader::saveCardImageToLocalStorage(const ExactCard &card, const } } +void CardPictureLoader::installPrintingOverrideOnLoad(const ExactCard &originalCard, const ExactCard &overrideCard) +{ + // Overriding a card with itself is the reset case, not a real override: every code path below + // would re-enter itself through emitPixmapUpdated(). Reject it outright. + if (originalCard == overrideCard) { + return; + } + + CardInfoPtr cardPtr = overrideCard.getCardPtr(); + if (!cardPtr) { + return; + } + + // Heap-allocate so the lambda can capture it before the connection is made + auto *connectionHandle = new QMetaObject::Connection; + + *connectionHandle = + connect(cardPtr.data(), &CardInfo::pixmapUpdated, cardPtr.data(), + [originalCard, overrideCard, connectionHandle, this](const PrintingInfo &printing) { + // All printings share the same CardInfo, so ignore updates triggered by any + // other printing (e.g., the original card re-loading from disk). + if (printing != overrideCard.getPrinting()) { + return; + } + + QPixmap pixmap; + if (QPixmapCache::find(overrideCard.getPixmapCacheKey(), &pixmap) && !pixmap.isNull()) { + // The override art has resolved — persist it and reflect it immediately. + // Retire the connection before emitting so the refresh can't re-enter. + saveCardImageToLocalStorage(originalCard, pixmap, /*allowOverwrite=*/true); + + QObject::disconnect(*connectionHandle); + delete connectionHandle; + + QPixmapCache::clear(); + originalCard.emitPixmapUpdated(); + return; + } + + // The art could not be resolved. Keep the connection armed so a late resolution + // still lands, and surface a visible refusal instead of a silent no-op. An + // override already on disk is left untouched and simply re-displayed. + QPixmapCache::clear(); + if (!hasLocalOverrides(originalCard)) { + QPixmap refusedPixmap; + getCardBackLoadingFailedPixmap(refusedPixmap, QSize(480, 672)); + QPixmapCache::insert(originalCard.getPixmapCacheKey(), refusedPixmap); + } + originalCard.emitPixmapUpdated(); + }); + + // Now enqueue; if the image is already loading (deduplicated in the worker), + // the signal will still fire when it completes + CardPictureLoader::getInstance().worker->enqueueImageLoad(overrideCard); +} + +void CardPictureLoader::installPrintingOverride(const ExactCard &originalCard, const ExactCard &overrideCard) +{ + // Same guard as installPrintingOverrideOnLoad: self-override is the reset case. + if (originalCard == overrideCard) { + return; + } + + QPixmap pixmap; + const QString key = overrideCard.getPixmapCacheKey(); + + if (QPixmapCache::find(key, &pixmap) && !pixmap.isNull()) { + // Already cached — save immediately; the caller refreshes the card. + saveCardImageToLocalStorage(originalCard, pixmap, /*allowOverwrite=*/true); + return; + } + + // Cache miss or previously failed load — enqueue load and wait for the signal. + installPrintingOverrideOnLoad(originalCard, overrideCard); +} + +bool CardPictureLoader::hasLocalOverrides(const ExactCard &card) +{ + const QString picsRoot = SettingsCache::instance().paths().getPicsPath(); + if (picsRoot.isEmpty() || !card) { + return false; + } + + QDir baseDir(picsRoot); + if (!baseDir.cd("downloadedPics")) { + return false; + } + + const QString name = card.getInfo().getCorrectedName(); + + QString set, collector, uuid; + const PrintingInfo printing = card.getPrinting(); + if (printing.getSet()) { + set = printing.getSet()->getCorrectedShortName(); + collector = printing.getProperty("num"); + uuid = printing.getUuid(); + } + + for (const auto &scheme : CardPictureLoaderLocalSchemes::exportSchemes()) { + QString rel = CardPictureLoaderLocalSchemes::expandPattern(scheme.pattern, name, set, collector, uuid); + + if (rel.isEmpty()) { + continue; + } + + rel += ".png"; + rel = QDir::cleanPath(rel); + + if (QFile::exists(baseDir.filePath(rel))) { + return true; + } + } + + return false; +} + void CardPictureLoader::clearPixmapCache() { QPixmapCache::clear(); @@ -327,31 +489,11 @@ void CardPictureLoader::picsPathChanged() QPixmapCache::clear(); } -bool CardPictureLoader::hasCustomArt() +void CardPictureLoader::cardLangChanged() { - auto picsPath = SettingsCache::instance().paths().getPicsPath(); - QDirIterator it(picsPath, QDir::Dirs | QDir::NoDotAndDotDot); - - // Check if there is at least one non-directory file in the pics path, other - // than in the "downloadedPics" subdirectory. - while (it.hasNext()) { -#if (QT_VERSION >= QT_VERSION_CHECK(6, 3, 0)) - QFileInfo dir(it.nextFileInfo()); -#else - // nextFileInfo() is only available in Qt 6.3+, for previous versions, we build - // the QFileInfo from a QString which requires more system calls. - QFileInfo dir(it.next()); -#endif - - if (it.fileName() == "downloadedPics") { - continue; - } - - QDirIterator subIt(it.filePath(), QDir::Files, QDirIterator::Subdirectories | QDirIterator::FollowSymlinks); - if (subIt.hasNext()) { - return true; - } - } - - return false; + // Localized images are fetched via a different URL, but the in-memory + // pixmap cache is keyed by card name/uuid, so drop everything cached + // (including failure timestamps) to force a reload in the new language. + QPixmapCache::clear(); + failedAt.clear(); } diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader.h index 5c3ac84a3..0a4934e6d 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader.h @@ -97,10 +97,17 @@ public: static void cacheCardPixmaps(const QList &cards); /** - * @brief Check if the user has custom card art in the picsPath directory. - * @return True if any custom art exists. + * @brief Check if a local override image already exists for the card. + * @param card The card to check. + * @return True if the card has at least one locally stored override image. */ - static bool hasCustomArt(); + static bool hasLocalOverrides(const ExactCard &card); + + /** + * @brief Removes all locally stored override images for the card. + * @param card The card to remove the override images of. + */ + static void deleteAllLocalOverrides(const ExactCard &card); /** * @brief Clears the in-memory QPixmap cache for all cards. @@ -120,7 +127,9 @@ public slots: * @param image Loaded QImage. */ void imageLoaded(const ExactCard &card, const QImage &image); - void saveCardImageToLocalStorage(const ExactCard &card, const QPixmap &pixmap); + void saveCardImageToLocalStorage(const ExactCard &card, const QPixmap &pixmap, bool allowOverwrite = false); + void installPrintingOverride(const ExactCard &originalCard, const ExactCard &overrideCard); + void installPrintingOverrideOnLoad(const ExactCard &originalCard, const ExactCard &overrideCard); private slots: /** @@ -134,6 +143,12 @@ private slots: * Clears the QPixmap cache to reload images. */ void picsPathChanged(); + + /** + * @brief Triggered when the card language setting changes. + * Clears the in-memory picture caches so images reload in the new language. + */ + void cardLangChanged(); }; #endif diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp index 39621839a..c82fca403 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp @@ -94,6 +94,10 @@ QImage CardPictureLoaderLocal::tryLoadCardImageFromDisk(const QString &setName, candidatePaths << picsPath + "/downloadedPics/" + setName + "/" + nameVariant; } + // Non-set-folder export schemes (e.g., Name_Set_Collector) write straight into + // downloadedPics/; check there as a fallback so local overrides round-trip. + candidatePaths << picsPath + "/downloadedPics/" + nameVariant; + for (const QString &path : candidatePaths) { QFileInfo fileInfo(path); QDir dir = fileInfo.dir(); @@ -105,7 +109,8 @@ QImage CardPictureLoaderLocal::tryLoadCardImageFromDisk(const QString &setName, QStringList files = dir.entryList(QDir::Files); for (const QString &file : files) { - if (!file.startsWith(baseName)) { + QFileInfo fi(file); + if (fi.completeBaseName() != baseName) { continue; } diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp index 34092f361..d8779863e 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp @@ -17,12 +17,13 @@ #include static constexpr int MAX_REQUESTS_PER_SEC = 10; -static constexpr int MIN_HOST_QUOTA = 1; ///< Floor for the per-host request allowance -static constexpr qint64 QUOTA_RECOVER_MS = 60000; ///< Idle time before a reduced quota starts recovering +static constexpr int MIN_HOST_QUOTA = 1; ///< Floor for the per-host request allowance +static constexpr qint64 QUOTA_RECOVER_MS = 60000; ///< Idle time before a reduced quota starts recovering +static constexpr int DISPATCH_INTERVAL_MS = 100; ///< Pacing between individual network requests +static constexpr qint64 QUOTA_RESET_INTERVAL_MS = 1000; ///< Interval at which the request quota resets CardPictureLoaderWorker::CardPictureLoaderWorker() - : QObject(nullptr), picDownload(SettingsCache::instance().downloads().getPicDownload()), - requestQuota(MAX_REQUESTS_PER_SEC) + : QObject(nullptr), picDownload(SettingsCache::instance().downloads().getPicDownload()) { networkManager = new QNetworkAccessManager(this); // We need a timeout to ensure requests don't hang indefinitely in case of @@ -60,11 +61,18 @@ CardPictureLoaderWorker::CardPictureLoaderWorker() pictureLoaderThread->start(QThread::LowPriority); moveToThread(pictureLoaderThread); + // QTimer value members are not QObject children, so moveToThread on the worker doesn't move + // them. They must live in the worker's thread to be started from the slot code that runs there. + requestTimer.moveToThread(pictureLoaderThread); + dispatchTimer.moveToThread(pictureLoaderThread); + connect(this, &CardPictureLoaderWorker::imageLoadEnqueued, this, &CardPictureLoaderWorker::handleImageLoadEnqueued); connect(&requestTimer, &QTimer::timeout, this, &CardPictureLoaderWorker::resetRequestQuota); - requestTimer.setInterval(1000); - requestTimer.start(); + requestTimer.setInterval(static_cast(QUOTA_RESET_INTERVAL_MS)); + + connect(&dispatchTimer, &QTimer::timeout, this, &CardPictureLoaderWorker::dispatchQueuedRequest); + dispatchTimer.setInterval(DISPATCH_INTERVAL_MS); } CardPictureLoaderWorker::~CardPictureLoaderWorker() @@ -99,6 +107,14 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture // Check for cached redirects QUrl cachedRedirect = getCachedRedirect(url); if (!cachedRedirect.isEmpty()) { + // The redirect target is a different host, which may itself be in 429 backoff; hand the + // entry back to its worker so it waits the backoff out instead of dispatching straight + // onto the backed-off host. + if (CardPictureLoaderWorkerWork::rateLimiter().isRateLimited(cachedRedirect.host(), + QDateTime::currentDateTime())) { + worker->scheduleDeferredRetry(cachedRedirect.host()); + return nullptr; + } emit imageRequestSucceeded(url); return makeRequest(cachedRedirect, worker); } @@ -128,8 +144,6 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture void CardPictureLoaderWorker::resetRequestQuota() { - requestQuota = MAX_REQUESTS_PER_SEC; - QDateTime now = QDateTime::currentDateTime(); for (auto it = hostRequestQuota.begin(); it != hostRequestQuota.end(); ++it) { if (!hostLast429.contains(it.key()) || now.msecsTo(hostLast429.value(it.key())) < -QUOTA_RECOVER_MS) { @@ -137,31 +151,97 @@ void CardPictureLoaderWorker::resetRequestQuota() } } - for (const auto &request : requestLoadQueue) { - const QString host = request.first.host(); - hostQuotaRemaining.insert(host, hostRequestQuota.value(host, MAX_REQUESTS_PER_SEC)); - } + // Forget the per-second allowances; each host's allowance is re-seeded lazily from its + // reduced sustained quota the first time it is dispatched in the new second, so a host that + // enters the queue mid-second no longer falls through to a fresh full quota. + hostQuotaRemaining.clear(); - processQueuedRequests(); + updateTimerState(); } void CardPictureLoaderWorker::processQueuedRequests() { - while (requestQuota > 0 && processSingleRequest()) { - --requestQuota; + // QTimer must be started from the thread it lives in; if this public slot is ever reached from + // another thread, replay it on the worker's event loop instead of letting start() fail silently. + if (thread() != QThread::currentThread()) { + QMetaObject::invokeMethod(this, &CardPictureLoaderWorker::processQueuedRequests, Qt::QueuedConnection); + return; + } + updateTimerState(); +} + +void CardPictureLoaderWorker::dispatchQueuedRequest() +{ + if (requestLoadQueue.isEmpty()) { + // All queued requests have been dispatched; stop the pacing timers. + updateTimerState(); + return; + } + + if (!processSingleRequest()) { + // No queued host currently has allowance left in this second; wait for the quota reset. + dispatchTimer.stop(); + } +} + +void CardPictureLoaderWorker::updateTimerState() +{ + // Never restart an active timer: that would reset the pacing countdown and a burst of enqueues + // could keep starving the dispatcher, so only (re)start a timer that has actually stopped. + if (requestLoadQueue.isEmpty()) { + dispatchTimer.stop(); + // Forget per-second allowances once nothing is pending: a stale zero would otherwise delay + // the next single request by a full quota-reset interval. + hostQuotaRemaining.clear(); + } else if (!dispatchTimer.isActive()) { + dispatchTimer.start(); + } + + // The quota timer resets allowances every second and is also the only thing that heals a host + // after a 429 (see resetRequestQuota). It must keep ticking while work is queued or a host is + // still recovering below the ceiling, and only winds down once no host needs recovery anymore. + // Keeping it alive during such idle periods lets reduced quotas recover as intended. + bool hostRecovering = false; + for (auto it = hostRequestQuota.cbegin(); it != hostRequestQuota.cend(); ++it) { + if (it.value() < MAX_REQUESTS_PER_SEC) { + hostRecovering = true; + break; + } + } + + if (!requestLoadQueue.isEmpty() || hostRecovering) { + if (!requestTimer.isActive()) { + requestTimer.start(); + } + } else if (requestTimer.isActive()) { + requestTimer.stop(); } } bool CardPictureLoaderWorker::processSingleRequest() { + QDateTime now = QDateTime::currentDateTime(); for (int i = 0; i < requestLoadQueue.size(); ++i) { const auto &request = requestLoadQueue.at(i); - QString host = request.first.host(); - int allowance = hostQuotaRemaining.value(host, MAX_REQUESTS_PER_SEC); + const QString host = request.first.host(); + // Don't dispatch requests to a host that is currently in its 429 backoff; hand the entry + // back to its worker so it can wait the backoff out or fall through to another source, + // instead of leaving it parked in the queue with no reply pending. + if (CardPictureLoaderWorkerWork::rateLimiter().isRateLimited(host, now)) { + auto entry = requestLoadQueue.takeAt(i); + entry.second->startNextPicDownload(); + return true; + } + // Seed the allowance now so a host that was rate limited gets its reduced + // allowance instead of a fresh full quota mid-second. + if (!hostQuotaRemaining.contains(host)) { + hostQuotaRemaining.insert(host, hostRequestQuota.value(host, MAX_REQUESTS_PER_SEC)); + } + int allowance = hostQuotaRemaining.value(host); if (allowance > 0) { hostQuotaRemaining.insert(host, allowance - 1); - makeRequest(request.first, request.second); - requestLoadQueue.removeAt(i); + auto entry = requestLoadQueue.takeAt(i); + makeRequest(entry.first, entry.second); return true; } } diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.h index d1c519b7a..2a13e847c 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.h @@ -86,9 +86,12 @@ public slots: */ QNetworkReply *makeRequest(const QUrl &url, CardPictureLoaderWorkerWork *workThread); - /** @brief Processes all queued requests respecting the request quota. */ + /** @brief Ensures the pacing and quota-reset timers reflect the current queue and recovery state. */ void processQueuedRequests(); + /** @brief Chooses a request from the queue and starts it, respecting the quota and pacing. */ + void dispatchQueuedRequest(); + /** * @brief Processes a single queued request. * @return true if a request was processed, false if queue is empty. @@ -118,8 +121,8 @@ private: bool picDownload; ///< Whether downloading images from network is enabled QQueue> requestLoadQueue; ///< Queue of pending network requests - int requestQuota; ///< Remaining requests allowed per second QTimer requestTimer; ///< Timer to reset the request quota + QTimer dispatchTimer; ///< Timer pacing individual network requests QHash hostRequestQuota; ///< Sustained per-host request allowance QHash hostQuotaRemaining; ///< Per-host allowance left in the current second QHash hostLast429; ///< When each host was last rate limited @@ -139,6 +142,9 @@ private: /** @brief Removes stale redirect entries older than TTL. */ void cleanStaleEntries(); + /** @brief Starts or stops the pacing and quota-reset timers to match the queue and recovery state. */ + void updateTimerState(); + private slots: /** @brief Resets the request quota for rate-limiting. */ void resetRequestQuota(); diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp index 66c56337c..b70207ff4 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp @@ -22,6 +22,11 @@ static const QStringList MD5_BLACKLIST = { "fbc7d763c08771c260b39e2115414eeb" // Current card back hash }; +const ServerRateLimiter &CardPictureLoaderWorkerWork::rateLimiter() +{ + return s_rateLimiter; +} + CardPictureLoaderWorkerWork::CardPictureLoaderWorkerWork(const CardPictureLoaderWorker *worker, const ExactCard &toLoad) : QObject(nullptr), cardToDownload(CardPictureToLoad(toLoad)), picDownload(SettingsCache::instance().downloads().getPicDownload()) @@ -168,7 +173,7 @@ void CardPictureLoaderWorkerWork::handleFailedReply(const QNetworkReply *reply) << "PictureLoader: [card: " << cardToDownload.getCard().getName() << " set: " << cardToDownload.getSetName() << "]: Too many requests from " << host << ", backing off until " << backoffUntil.toString(Qt::ISODate) << ", retrying the same url"; - scheduleDeferredRetry(); + scheduleDeferredRetry(host); } else { qCWarning(CardPictureLoaderWorkerWorkLog).nospace() << "PictureLoader: [card: " << cardToDownload.getCard().getName() @@ -273,14 +278,16 @@ QImage CardPictureLoaderWorkerWork::tryLoadImageFromReply(QNetworkReply *reply) return imgReader.read(); } -void CardPictureLoaderWorkerWork::scheduleDeferredRetry() +void CardPictureLoaderWorkerWork::scheduleDeferredRetry(const QString &preferredHost) { QDateTime now = QDateTime::currentDateTime(); - // Prefer waiting on the current URL's server so we retry the same source. - QString currentHost = QUrl(cardToDownload.getCurrentUrl()).host(); - QDateTime backoffUntil = s_rateLimiter.deadline(currentHost); - if (!s_rateLimiter.isRateLimited(currentHost, now)) { + // Prefer waiting on the server that is actually blocking the request: callers hand in the + // rate-limited host when it differs from the current URL (e.g. a cached redirect target still + // in backoff), otherwise fall back to the current URL's server so we retry the same source. + QString waitHost = preferredHost.isEmpty() ? QUrl(cardToDownload.getCurrentUrl()).host() : preferredHost; + QDateTime backoffUntil = s_rateLimiter.deadline(waitHost); + if (!s_rateLimiter.isRateLimited(waitHost, now)) { backoffUntil = s_rateLimiter.earliestDeadline(now); } diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.h index 1e56a4373..c5aa07d10 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.h @@ -10,6 +10,7 @@ #include #include #include +#include #include #include #include @@ -43,6 +44,30 @@ public: CardPictureToLoad cardToDownload; ///< The card and associated URLs to try downloading + /** @brief Shared per-server 429 backoff state. */ + static const ServerRateLimiter &rateLimiter(); + + /** + * @brief Starts downloading the next URL for this card. + * + * Skips URLs whose server is currently in 429 backoff, either waiting the + * backoff out or falling through to the other configured sources. Also used by + * the dispatch machinery to hand an entry back after it was removed from the + * request queue when its host turned out to be backed off. + */ + void startNextPicDownload(); + + /** + * @brief Schedules a deferred retry after the relevant server backoff expires. + * @param preferredHost The server that is actually blocking the request, or an empty + * string to use the current URL's server + * + * Waits on the blocking server's backoff deadline, otherwise on the earliest active + * backoff. If no servers are in backoff, concludes with failure. Otherwise resets the + * CardPictureToLoad indices and retries after the backoff period. + */ + void scheduleDeferredRetry(const QString &preferredHost = {}); + public slots: /** * @brief Handles a finished network reply for the card image. @@ -55,9 +80,6 @@ private: static ServerRateLimiter s_rateLimiter; ///< Shared per-server 429 backoff state - /** @brief Starts downloading the next URL for this card. */ - void startNextPicDownload(); - /** @brief Called when all URLs have been exhausted or download failed. */ void picDownloadFailed(); @@ -82,16 +104,6 @@ private: */ void concludeImageLoad(const QImage &image); - /** - * @brief Schedules a deferred retry after the relevant server backoff expires. - * - * Waits on the current URL's server when it is the reason we are blocked, - * otherwise on the earliest active backoff. If no servers are in backoff, - * concludes with failure. Otherwise resets the CardPictureToLoad indices and - * retries after the backoff period. - */ - void scheduleDeferredRetry(); - private slots: /** @brief Updates the picDownload setting when it changes. */ void picDownloadChanged(); diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp index 5f4ff0bbd..7cb502e92 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp @@ -94,7 +94,8 @@ void CardPictureToLoad::populateSetUrls() } } - for (const QString &urlTemplate : urlTemplates) { + const QStringList orderedTemplates = urlTemplates; + for (const QString &urlTemplate : orderedTemplates) { QString transformedUrl = transformUrl(urlTemplate); if (!transformedUrl.isEmpty()) { @@ -282,8 +283,15 @@ QString CardPictureToLoad::transformUrl(const QString &urlTemplate) const } // language setting - transformMap["!sflang!"] = QString(QCoreApplication::translate( - "PictureLoader", "en", "code for scryfall's language property, not available for all languages")); + const QString cardLang = SettingsCache::instance().cardsDisplay().getCardLang(); + transformMap["!sflang!"] = cardLang; + + // The localized printing's own id is unknown, so Scryfall must resolve it by + // its translated name (see populateSetUrls); expose that name for the + // `/cards/named` template. + if (cardLang != "en") { + transformMap["!localizedName!"] = card.getInfo().getLocalizedName(cardLang); + } QString transformedUrl = urlTemplate; for (const QString &prop : transformMap.keys()) { diff --git a/cockatrice/src/interface/intents/contexts/context_open_deck.h b/cockatrice/src/interface/intents/contexts/context_open_deck.h new file mode 100644 index 000000000..03dca088e --- /dev/null +++ b/cockatrice/src/interface/intents/contexts/context_open_deck.h @@ -0,0 +1,14 @@ +#ifndef COCKATRICE_CONTEXT_OPEN_DECK_H +#define COCKATRICE_CONTEXT_OPEN_DECK_H + +#include "context_connect_to_server.h" + +#include + +struct ContextOpenDeck +{ + ContextConnectToServer serverContext; + QString shareToken; +}; + +#endif // COCKATRICE_CONTEXT_OPEN_DECK_H diff --git a/cockatrice/src/interface/intents/intent.cpp b/cockatrice/src/interface/intents/intent.cpp index c02a89f35..db0d13b2c 100644 --- a/cockatrice/src/interface/intents/intent.cpp +++ b/cockatrice/src/interface/intents/intent.cpp @@ -2,10 +2,11 @@ Intent::Intent(QObject *parent) : QObject(parent) { - // An intent is done as soon as it reports success or failure. Deleting it - // also tears down its dependency chain and disconnects any signal wiring. + // An intent is done as soon as it reports success, failure, or cancellation. + // Deleting it also tears down its dependency chain and disconnects any signal wiring. connect(this, &Intent::finished, this, &QObject::deleteLater); connect(this, &Intent::failed, this, &QObject::deleteLater); + connect(this, &Intent::cancelled, this, &QObject::deleteLater); } Intent::~Intent() = default; @@ -27,6 +28,7 @@ void Intent::runDependency(Intent *dependency) this->execute(); }); connect(dependency, &Intent::failed, this, &Intent::failed); + connect(dependency, &Intent::cancelled, this, &Intent::cancelled); dependency->execute(); } @@ -46,3 +48,11 @@ void Intent::emitFailed(const QString &reason) emit failed(reason); } } + +void Intent::emitCancelled() +{ + if (!completed) { + completed = true; + emit cancelled(); + } +} diff --git a/cockatrice/src/interface/intents/intent.h b/cockatrice/src/interface/intents/intent.h index 125900ecd..5d9fdd3d6 100644 --- a/cockatrice/src/interface/intents/intent.h +++ b/cockatrice/src/interface/intents/intent.h @@ -16,6 +16,7 @@ public: signals: void finished(); void failed(QString reason); + void cancelled(); protected: // --- Subclasses must implement these --- @@ -29,6 +30,7 @@ protected: // Emit the outcome exactly once; ignore late signals after the intent is done. void emitFinished(); void emitFailed(const QString &reason); + void emitCancelled(); private: bool completed = false; diff --git a/cockatrice/src/interface/intents/intent_join_server_game.cpp b/cockatrice/src/interface/intents/intent_join_server_game.cpp index 205c4dc70..b22b200ed 100644 --- a/cockatrice/src/interface/intents/intent_join_server_game.cpp +++ b/cockatrice/src/interface/intents/intent_join_server_game.cpp @@ -19,13 +19,15 @@ bool IntentJoinServerGame::checkPrecondition() const if (remoteClient->getStatus() != ClientStatus::StatusLoggedIn) { return false; } - // peerPort() reflects the actual TCP peer, which may differ from the - // configured server port (e.g. when connecting through a proxy), so only - // the hostname is compared here. - if (remoteClient->peerName() != context->roomContext.serverContext.hostname) { + // serverName()/serverPort() reflect the server the client was configured + // to connect to, which may differ from the actual TCP peer (e.g. when + // connecting through a proxy), so compare those configured values. A link + // naming the same host on another port is a different server and must not + // reuse the session there. + if (remoteClient->serverName().compare(context->roomContext.serverContext.hostname, Qt::CaseInsensitive) != 0) { return false; } - if (QString::number(remoteClient->peerPort()) != context->roomContext.serverContext.port) { + if (QString::number(remoteClient->serverPort()) != context->roomContext.serverContext.port) { return false; } diff --git a/cockatrice/src/interface/intents/intent_login.cpp b/cockatrice/src/interface/intents/intent_login.cpp index ff871fd03..7beb63e1d 100644 --- a/cockatrice/src/interface/intents/intent_login.cpp +++ b/cockatrice/src/interface/intents/intent_login.cpp @@ -1,9 +1,14 @@ #include "intent_login.h" #include "../../client/settings/cache_settings.h" +#include "../widgets/dialogs/dlg_login_prompt.h" #include "libcockatrice/settings/servers_settings.h" -IntentGetLoginCredentials::IntentGetLoginCredentials(ContextConnectToServer *_context) : Intent(), context(_context) +#include + +IntentGetLoginCredentials::IntentGetLoginCredentials(ContextConnectToServer *_context, + bool _promptForMissingCredentials) + : Intent(), context(_context), promptForMissingCredentials(_promptForMissingCredentials) { } @@ -29,5 +34,46 @@ void IntentGetLoginCredentials::onPreconditionSatisfied() void IntentGetLoginCredentials::onPreconditionNotSatisfied() { - emitFailed(tr("No saved credentials for this server")); + // MainWindow::applyStartupDestination runs this intent on every launch for + // users whose startup tab is Server / Server Room; keep that path quiet, as + // it was before the link-driven sign-in dialog existed. + if (!promptForMissingCredentials) { + emitFailed(tr("No saved credentials for this server")); + return; + } + + // No credentials saved for the target server: ask the user for them. They + // opt into saving them so later links to the same server connect directly. + const QString serverText = context->hostname + ":" + context->port; + DlgLoginPrompt dialog(serverText); + // ApplicationModal: the dialog has no parent (the intent is not a widget), + // so WindowModal would not actually block any other window. + dialog.setWindowModality(Qt::ApplicationModal); + + if (dialog.exec() != QDialog::Accepted) { + emitCancelled(); + return; + } + + context->username = dialog.username(); + context->password = dialog.password(); + + if (dialog.savePassword() && !context->username.isEmpty()) { + ServersSettings &servers = SettingsCache::instance().servers(); + // The host may already be saved under a friendly name (e.g. a public-server + // list entry) with no credentials; reuse that name instead of overwriting + // it with the raw hostname when addNewServer updates the entry in place. + QString saveName = context->hostname; + const int existingIndex = servers.findServerIndex(context->hostname, context->port); + if (existingIndex >= 0) { + saveName = + servers.getValue(QString("saveName%1").arg(existingIndex), "server", "server_details").toString(); + if (saveName.isEmpty()) { + saveName = context->hostname; + } + } + servers.addNewServer(saveName, context->hostname, context->port, context->username, context->password, true); + } + + emitFinished(); } diff --git a/cockatrice/src/interface/intents/intent_login.h b/cockatrice/src/interface/intents/intent_login.h index c7fec92b7..8ffd91a0a 100644 --- a/cockatrice/src/interface/intents/intent_login.h +++ b/cockatrice/src/interface/intents/intent_login.h @@ -9,7 +9,10 @@ class IntentGetLoginCredentials : public Intent Q_OBJECT public: - IntentGetLoginCredentials(ContextConnectToServer *_context); + // When promptForMissingCredentials is false (the default) a server without + // saved credentials fails silently; only intent chains from cockatrice:// + // links opt into the interactive sign-in dialog. + explicit IntentGetLoginCredentials(ContextConnectToServer *_context, bool _promptForMissingCredentials = false); protected: bool checkPrecondition() const override; @@ -18,6 +21,7 @@ protected: private: ContextConnectToServer *context; + bool promptForMissingCredentials; }; #endif // COCKATRICE_INTENT_LOGIN_H diff --git a/cockatrice/src/interface/intents/intent_open_shared_deck.cpp b/cockatrice/src/interface/intents/intent_open_shared_deck.cpp new file mode 100644 index 000000000..016de63e6 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_open_shared_deck.cpp @@ -0,0 +1,208 @@ +#include "intent_open_shared_deck.h" + +#include "../deck_loader/deck_loader.h" +#include "../widgets/dialogs/dlg_shared_decks_preview.h" +#include "../widgets/tabs/tab_supervisor.h" +#include "intent_connect_to_server.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +IntentOpenSharedDeck::IntentOpenSharedDeck(TabSupervisor *_tabSupervisor, + RemoteClient *_remoteClient, + const CardDatabaseQuerier *_querier, + std::unique_ptr _context) + : Intent(), tabSupervisor(_tabSupervisor), remoteClient(_remoteClient), querier(_querier), + context(_context.release()) +{ + downloadTimer = new QTimer(this); + downloadTimer->setSingleShot(true); + downloadTimer->setInterval(15000); + connect(downloadTimer, &QTimer::timeout, this, &IntentOpenSharedDeck::onDownloadTimeout); +} + +bool IntentOpenSharedDeck::checkPrecondition() const +{ + if (remoteClient->getStatus() != ClientStatus::StatusLoggedIn) { + return false; + } + // serverName()/serverPort() reflect the server the client was configured + // to connect to, which may differ from the actual TCP peer (e.g. when + // connecting through a proxy), so compare those configured values. The + // share token must be resolved against the host the link named — a link to + // the same host on another port is a different server. + if (remoteClient->serverName().compare(context->serverContext.hostname, Qt::CaseInsensitive) != 0) { + return false; + } + return QString::number(remoteClient->serverPort()) == context->serverContext.port; +} + +void IntentOpenSharedDeck::onPreconditionSatisfied() +{ + // Resolve the share token to its items first; a share can contain more than + // one deck, and each item is downloaded by id. Time the round trip like the + // downloads, so a silent server cannot hang the chain forever. + listPhase = true; + downloadTimer->start(); + + Command_DeckShareList cmd; + cmd.set_token(context->shareToken.toStdString()); + + PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &IntentOpenSharedDeck::listShareFinished); + remoteClient->sendCommand(pend); +} + +void IntentOpenSharedDeck::onPreconditionNotSatisfied() +{ + runDependency(new IntentConnectToServer(remoteClient, &context->serverContext)); +} + +void IntentOpenSharedDeck::listShareFinished(const Response &response, const CommandContainer & /* commandContainer */) +{ + downloadTimer->stop(); + listPhase = false; + + if (response.response_code() != Response::RespOk) { + emitFailed(tr("The shared deck could not be found or has expired")); + return; + } + + const Response_DeckShareList &resp = response.GetExtension(Response_DeckShareList::ext); + if (resp.items_size() == 0) { + emitFailed(tr("The shared deck is empty")); + return; + } + + QList items; + items.reserve(resp.items_size()); + for (const ServerInfo_DeckShareItem &item : resp.items()) { + items.append(item); + itemNames.insert(item.id(), QString::fromStdString(item.name())); + } + + const QString serverText = context->serverContext.hostname + ":" + context->serverContext.port; + + // Ask the user which decks to open before downloading anything. + previewDialog = new DlgSharedDecksPreview(tabSupervisor, querier, QString::fromStdString(resp.name()), + resp.expires_at(), serverText, items); + connect(previewDialog, &DlgSharedDecksPreview::openRequested, this, &IntentOpenSharedDeck::startDownloads); + connect(previewDialog, &DlgSharedDecksPreview::cancelled, this, &IntentOpenSharedDeck::emitCancelled); + connect(previewDialog, &DlgSharedDecksPreview::cancelled, previewDialog, &QWidget::deleteLater); + previewDialog->show(); + previewDialog->raise(); + previewDialog->activateWindow(); +} + +void IntentOpenSharedDeck::startDownloads(const QList &itemIds) +{ + pendingItemIds = itemIds; + totalItems = itemIds.size(); + completedItems = 0; + loadedDecks.clear(); + downloadNextItem(); +} + +void IntentOpenSharedDeck::downloadNextItem() +{ + if (pendingItemIds.isEmpty()) { + finishAll(); + return; + } + + currentItemId = pendingItemIds.takeFirst(); + downloadTimer->start(); + + Command_DeckShareDownload cmd; + cmd.set_token(context->shareToken.toStdString()); + cmd.set_item_id(currentItemId); + + PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &IntentOpenSharedDeck::downloadShareFinished); + remoteClient->sendCommand(pend); +} + +void IntentOpenSharedDeck::downloadShareFinished(const Response &response, + const CommandContainer & /* commandContainer */) +{ + downloadTimer->stop(); + + QString failureReason; + if (response.response_code() != Response::RespOk) { + failureReason = tr("Failed to download the shared deck"); + } else { + const Response_DeckShareDownload &resp = response.GetExtension(Response_DeckShareDownload::ext); + const QString deckString = QString::fromStdString(resp.deck()); + if (deckString.isEmpty()) { + failureReason = tr("The shared deck is empty"); + } else { + std::optional deckOpt = + DeckLoader::loadFromRemote(deckString, LoadedDeck::LoadInfo::NON_REMOTE_ID); + if (!deckOpt) { + failureReason = tr("The shared deck could not be loaded"); + } else { + loadedDecks.append(deckOpt.value()); + ++completedItems; + previewDialog->setDownloadProgress(completedItems, totalItems, + itemNames.value(currentItemId, tr("Unknown deck"))); + downloadNextItem(); + return; + } + } + } + + onItemFailure(failureReason); +} + +void IntentOpenSharedDeck::onItemFailure(const QString &reason) +{ + downloadTimer->stop(); + + if (loadedDecks.isEmpty()) { + previewDialog->deleteLater(); + emitFailed(reason); + return; + } + + const int downloadedCount = loadedDecks.size(); + const QMessageBox::StandardButton answer = QMessageBox::question( + previewDialog, tr("Open shared decks"), + tr("Could not download the deck \"%1\".\n\n%n deck(s) were already downloaded. Open them?", "", downloadedCount) + .arg(itemNames.value(currentItemId, tr("Unknown deck"))), + QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); + + if (answer == QMessageBox::Yes) { + finishAll(); + } else { + previewDialog->deleteLater(); + emitCancelled(); + } +} + +void IntentOpenSharedDeck::onDownloadTimeout() +{ + // The list phase has no preview dialog yet to report progress into; fail the + // whole intent instead of letting the shared deck hang in limbo. + if (listPhase) { + emitFailed(tr("Timed out while loading the shared deck")); + return; + } + onItemFailure(tr("Timed out while downloading the shared deck")); +} + +void IntentOpenSharedDeck::finishAll() +{ + previewDialog->deleteLater(); + for (const LoadedDeck &deck : loadedDecks) { + tabSupervisor->openDeckInNewTab(deck); + } + emitFinished(); +} diff --git a/cockatrice/src/interface/intents/intent_open_shared_deck.h b/cockatrice/src/interface/intents/intent_open_shared_deck.h new file mode 100644 index 000000000..87812bdea --- /dev/null +++ b/cockatrice/src/interface/intents/intent_open_shared_deck.h @@ -0,0 +1,60 @@ +#ifndef COCKATRICE_INTENT_OPEN_SHARED_DECK_H +#define COCKATRICE_INTENT_OPEN_SHARED_DECK_H + +#include "contexts/context_open_deck.h" +#include "intent.h" +#include "remote_client.h" + +#include +#include +#include +#include + +class TabSupervisor; +struct LoadedDeck; +class CardDatabaseQuerier; +class DlgSharedDecksPreview; +class QTimer; + +class IntentOpenSharedDeck : public Intent +{ + Q_OBJECT + +public: + IntentOpenSharedDeck(TabSupervisor *_tabSupervisor, + RemoteClient *_remoteClient, + const CardDatabaseQuerier *_querier, + std::unique_ptr _context); + +protected: + bool checkPrecondition() const override; + void onPreconditionSatisfied() override; + void onPreconditionNotSatisfied() override; + +private slots: + void listShareFinished(const Response &response, const CommandContainer &commandContainer); + void downloadShareFinished(const Response &response, const CommandContainer &commandContainer); + void onDownloadTimeout(); + +private: + void startDownloads(const QList &itemIds); + void downloadNextItem(); + void onItemFailure(const QString &reason); + void finishAll(); + + TabSupervisor *tabSupervisor; + RemoteClient *remoteClient; + const CardDatabaseQuerier *querier; + QScopedPointer context; + DlgSharedDecksPreview *previewDialog = nullptr; + QTimer *downloadTimer; + QMap itemNames; + QList pendingItemIds; + QList loadedDecks; + bool listPhase = true; + int currentItemId = 0; + int totalItems = 0; + int completedItems = 0; +}; + +#endif // COCKATRICE_INTENT_OPEN_SHARED_DECK_H diff --git a/cockatrice/src/interface/intents/url_parser.cpp b/cockatrice/src/interface/intents/url_parser.cpp index 509390611..707863354 100644 --- a/cockatrice/src/interface/intents/url_parser.cpp +++ b/cockatrice/src/interface/intents/url_parser.cpp @@ -1,19 +1,28 @@ #include "url_parser.h" +#include "../../client/settings/cache_settings.h" #include "../widgets/tabs/tab_room.h" #include "../widgets/tabs/tab_supervisor.h" #include "../window_main.h" #include "contexts/context_join_game.h" +#include "contexts/context_open_deck.h" +#include "intent.h" #include "intent_join_server_game.h" #include "intent_login.h" +#include "intent_open_shared_deck.h" #include +#include #include #include #include +#include #include +#include #include +inline Q_LOGGING_CATEGORY(UrlParserLog, "url_parser"); + IntentUrlParser::IntentUrlParser(QObject *parent, MainWindow *_mainWindow) : QObject(parent), mainWindow(_mainWindow) { } @@ -29,16 +38,33 @@ void IntentUrlParser::handle(const QString &urlStr) const QString action = url.host(); QUrlQuery query(url); + qCDebug(UrlParserLog) << "Parsing intent URL, action:" << action; + + PendingIntentChain chain; + Intent *firstIntent = nullptr; if (action == "joingame") { - handleJoinGame(query); + firstIntent = createJoinGameIntent(query, chain); } else if (action == "opendeck") { - // handleOpenDeck(query); + firstIntent = createOpenDeckIntent(query, chain); } else { qWarning() << "Unknown intent:" << action; } + + if (firstIntent == nullptr) { + // The link was invalid or the user declined the confirm: nothing runs. + // Report the idle state when no other chain is queued so that a startup + // launch (which skipped its own connection for this URL) falls back to it. + if (!chainRunning && pendingChains.isEmpty()) { + emit urlChainFinished(mainWindow->getRemoteClient()->getStatus() == StatusLoggedIn); + } + return; + } + + pendingChains.append(chain); + startNextChain(); } -void IntentUrlParser::handleJoinGame(const QUrlQuery &query) +Intent *IntentUrlParser::createJoinGameIntent(const QUrlQuery &query, PendingIntentChain &chain) { auto showError = [this](const QString &message) { QMessageBox::warning(mainWindow, tr("Open game"), message); }; @@ -49,21 +75,21 @@ void IntentUrlParser::handleJoinGame(const QUrlQuery &query) if (ctx->roomContext.serverContext.hostname.isEmpty()) { showError(tr("Missing or empty hostname in the game link")); - return; + return nullptr; } bool ok = false; ctx->roomContext.serverContext.port.toUShort(&ok); if (!ok) { showError(tr("Invalid or missing port in the game link")); - return; + return nullptr; } ctx->roomContext.roomId = query.queryItemValue("roomid").toInt(&ok); if (!ok) { showError(tr("Invalid or missing room id in the game link")); - return; + return nullptr; } ok = false; @@ -71,7 +97,7 @@ void IntentUrlParser::handleJoinGame(const QUrlQuery &query) if (!ok) { showError(tr("Invalid or missing game id in the game link")); - return; + return nullptr; } const QString gameDescription = query.queryItemValue("game", QUrl::FullyDecoded); @@ -80,24 +106,33 @@ void IntentUrlParser::handleJoinGame(const QUrlQuery &query) const QMessageBox::StandardButton answer = QMessageBox::question( mainWindow, tr("Join game"), message, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); if (answer != QMessageBox::Yes) { - return; + return nullptr; } + RemoteClient *client = mainWindow->getRemoteClient(); + ContextConnectToServer *serverContext = &ctx->roomContext.serverContext; + // The join game intent owns the context and the credential lookup; once the // chain finishes (or fails) it deletes the whole tree. - ContextConnectToServer *serverContext = &ctx->roomContext.serverContext; - auto joinGameIntent = - new IntentJoinServerGame(mainWindow->getTabSupervisor(), mainWindow->getRemoteClient(), std::move(ctx)); + auto joinGameIntent = new IntentJoinServerGame(mainWindow->getTabSupervisor(), client, std::move(ctx)); joinGameIntent->setParent(this); - - auto getLoginCredentialsIntent = new IntentGetLoginCredentials(serverContext); - getLoginCredentialsIntent->setParent(joinGameIntent); - - connect(getLoginCredentialsIntent, &Intent::finished, joinGameIntent, &Intent::execute); - connect(getLoginCredentialsIntent, &Intent::failed, joinGameIntent, &Intent::failed); + chain.intents.append(joinGameIntent); connect(joinGameIntent, &Intent::failed, this, [showError](const QString &reason) { showError(reason); }); - getLoginCredentialsIntent->execute(); + Intent *firstIntent = joinGameIntent; + if (!isConnectedTo(serverContext->hostname, serverContext->port)) { + auto getLoginCredentialsIntent = + new IntentGetLoginCredentials(serverContext, /*promptForMissingCredentials=*/true); + getLoginCredentialsIntent->setParent(joinGameIntent); + chain.intents.insert(0, getLoginCredentialsIntent); + + connect(getLoginCredentialsIntent, &Intent::finished, joinGameIntent, &Intent::execute); + connect(getLoginCredentialsIntent, &Intent::failed, joinGameIntent, &Intent::failed); + connect(getLoginCredentialsIntent, &Intent::cancelled, joinGameIntent, &Intent::cancelled); + firstIntent = getLoginCredentialsIntent; + } + + return firstIntent; } QString IntentUrlParser::generateJoinGameMessage(const ContextJoinGame &context, const QString &gameDescription) @@ -134,3 +169,270 @@ QString IntentUrlParser::generateJoinGameMessage(const ContextJoinGame &context, .arg(gameDescription, gameIdStr, roomTab->getRoomName(), server) : tr("Join game \"%1\" (#%2) on %3?").arg(gameDescription, gameIdStr, server); } + +Intent *IntentUrlParser::createOpenDeckIntent(const QUrlQuery &query, PendingIntentChain &chain) +{ + auto showError = [this](const QString &message) { + QMessageBox::warning(mainWindow, tr("Open shared deck"), message); + }; + + auto ctx = std::make_unique(); + + ctx->serverContext.hostname = query.queryItemValue("hostname"); + ctx->serverContext.port = query.queryItemValue("port"); + ctx->shareToken = query.queryItemValue("share"); + + qCDebug(UrlParserLog) << "Open-deck intent: host" << ctx->serverContext.hostname << "port" + << ctx->serverContext.port << "token length" << ctx->shareToken.length(); + + if (ctx->serverContext.hostname.isEmpty()) { + showError(tr("Missing or empty hostname in the share link")); + return nullptr; + } + + bool ok = false; + const quint16 port = ctx->serverContext.port.toUShort(&ok); + if (!ok || port == 0) { + showError(tr("Invalid or missing port in the share link")); + return nullptr; + } + + if (ctx->shareToken.isEmpty()) { + showError(tr("Missing or empty share value in the share link")); + return nullptr; + } + + RemoteClient *client = mainWindow->getRemoteClient(); + + // The open deck download needs a connection to the link's server. Ask before + // taking the session anywhere it isn't already, naming the host we would + // connect to. Remember the link's target when it moves us away from a live + // session so a failed or cancelled chain can restore the session it left. + // The hostname is link-supplied and percent-decoded, so escape it: QMessageBox + // renders AutoText, and markup in a hostname would otherwise flip the whole + // prompt to rich text and let a link pad the message the user is shown. + const bool alreadyConnected = isConnectedTo(ctx->serverContext.hostname, ctx->serverContext.port); + if (!alreadyConnected) { + const QString target = + QStringLiteral("%1:%2").arg(ctx->serverContext.hostname.toHtmlEscaped(), ctx->serverContext.port); + + if (client->getStatus() == StatusLoggedIn) { + const QString current = + QStringLiteral("%1:%2").arg(client->serverName(), QString::number(client->serverPort())); + const QMessageBox::StandardButton answer = QMessageBox::question( + mainWindow, tr("Open shared deck"), + tr("Opening this share link connects you to %1 instead of %2.\n\nContinue?").arg(target, current), + QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); + if (answer != QMessageBox::Yes) { + return nullptr; + } + chain.migrationTargetHost = ctx->serverContext.hostname; + chain.migrationTargetPort = ctx->serverContext.port; + chain.pendingRestore = true; + } else { + // Fresh connection is harmless to wander away from, but a server the + // client has never been configured for deserves a harder warning (no + // by default) so a stray link cannot silently steer the client there. + const bool knownHost = SettingsCache::instance().servers().findHostIndex(ctx->serverContext.hostname) >= 0; + const QMessageBox::StandardButton answer = + knownHost + ? QMessageBox::question(mainWindow, tr("Open shared deck"), + tr("Opening this share link connects you to %1.\n\nContinue?").arg(target)) + : QMessageBox::warning(mainWindow, tr("Open shared deck"), + tr("Opening this share link connects you to %1, a server you have " + "never connected to before.\n\nContinue?") + .arg(target), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + if (answer != QMessageBox::Yes) { + return nullptr; + } + } + } + + ContextConnectToServer *serverContext = &ctx->serverContext; + + // The open deck intent owns the context and the credential lookup; once + // the chain finishes (or fails) it deletes the whole tree. + auto openDeckIntent = + new IntentOpenSharedDeck(mainWindow->getTabSupervisor(), client, CardDatabaseManager::query(), std::move(ctx)); + openDeckIntent->setParent(this); + chain.intents.append(openDeckIntent); + connect(openDeckIntent, &Intent::failed, this, [showError](const QString &reason) { showError(reason); }); + + Intent *firstIntent = openDeckIntent; + if (!isConnectedTo(serverContext->hostname, serverContext->port)) { + auto getLoginCredentialsIntent = + new IntentGetLoginCredentials(serverContext, /*promptForMissingCredentials=*/true); + getLoginCredentialsIntent->setParent(openDeckIntent); + chain.intents.insert(0, getLoginCredentialsIntent); + + connect(getLoginCredentialsIntent, &Intent::finished, openDeckIntent, &Intent::execute); + connect(getLoginCredentialsIntent, &Intent::failed, openDeckIntent, &Intent::failed); + connect(getLoginCredentialsIntent, &Intent::cancelled, openDeckIntent, &Intent::cancelled); + firstIntent = getLoginCredentialsIntent; + } + + return firstIntent; +} + +bool IntentUrlParser::isConnectedTo(const QString &hostname, const QString &port) const +{ + // serverName() reflects the server the client was configured to connect to, + // which may differ from the actual TCP peer (e.g. when connecting through a + // proxy), so compare the configured host and port — exactly what a link + // names. A link to the same host on another port is a different server and + // must not silently reuse an existing session there. + RemoteClient *client = mainWindow->getRemoteClient(); + return client->getStatus() == StatusLoggedIn && client->serverName().compare(hostname, Qt::CaseInsensitive) == 0 && + QString::number(client->serverPort()) == port; +} + +void IntentUrlParser::startNextChain() +{ + if (chainRunning || pendingChains.isEmpty()) { + return; + } + chainRunning = true; + + PendingIntentChain &chain = pendingChains.first(); + if (chain.intents.isEmpty()) { + pendingChains.removeFirst(); + chainRunning = false; + startNextChain(); + return; + } + + // Snapshot the session this chain moves away from now that it actually + // runs. Chains are parsed while earlier ones are still queued, so a capture + // at parse time would follow whichever server the chain before it settled + // on, not the one the user is really on when this link is handled. + if (chain.pendingRestore) { + RemoteClient *client = mainWindow->getRemoteClient(); + chain.previousServerHost = client->serverName(); + chain.previousServerPort = QString::number(client->serverPort()); + } + + // Only the last intent completes the chain; its terminal signal ends the + // whole run. Cancellation of an intermediate intent (e.g. declined login + // prompt) is forwarded onto the last intent in the chain builders above. + Intent *finalIntent = chain.intents.last(); + connect(finalIntent, &Intent::finished, this, [this]() { chainEnded(true); }); + connect(finalIntent, &Intent::failed, this, [this]() { chainEnded(false); }); + connect(finalIntent, &Intent::cancelled, this, [this]() { chainEnded(false); }); + // Backstop: if the final intent is destroyed without emitting a terminal + // signal (e.g. a network error dropped it while running), end the chain so + // later links are not queued and dropped for the rest of the session. + chainBackstopConnection = connect(finalIntent, &QObject::destroyed, this, &IntentUrlParser::onChainIntentDestroyed); + + chain.intents.first()->execute(); +} + +void IntentUrlParser::chainEnded(bool chainSucceeded) +{ + chainRunning = false; + QObject::disconnect(chainBackstopConnection); + + const PendingIntentChain chain = pendingChains.takeFirst(); + + // Only a failed or cancelled chain restores the session the link migrated + // away from; a successful one leaves the user where they are. + if (chain.pendingRestore && !chainSucceeded) { + restorePreviousServer(chain); + } + + startNextChain(); + + // Only report the terminal state once the queue has fully drained, so a + // queued follow-up link keeps the startup fallback out of the picture. + if (!chainRunning && pendingChains.isEmpty()) { + emit urlChainFinished(mainWindow->getRemoteClient()->getStatus() == StatusLoggedIn); + } +} + +void IntentUrlParser::onChainIntentDestroyed() +{ + if (!chainRunning) { + return; + } + qCWarning(UrlParserLog) << "Share-link intent destroyed without a terminal signal; ending its chain"; + chainEnded(false); +} + +void IntentUrlParser::restorePreviousServer(const PendingIntentChain &chain) +{ + if (chain.previousServerHost.isEmpty()) { + return; + } + + RemoteClient *client = mainWindow->getRemoteClient(); + const ClientStatus status = client->getStatus(); + + // A failed/cancelled chain can fire while the client is still settling the + // in-flight connection attempt (wrong password, connect timeout). Only + // decide once the client has settled into logged-in or disconnected; + // deciding mid-connect would strand the user offline from their previous + // server. + if (status == StatusDisconnected || status == StatusLoggedIn) { + restoreToPreviousServer(chain); + return; + } + auto waitConnection = std::make_shared(); + *waitConnection = connect(client, &RemoteClient::statusChanged, this, [this, chain, client, waitConnection]() { + const ClientStatus settled = client->getStatus(); + if (settled == StatusDisconnected || settled == StatusLoggedIn) { + QObject::disconnect(*waitConnection); + restoreToPreviousServer(chain); + } + }); +} + +void IntentUrlParser::restoreToPreviousServer(const PendingIntentChain &chain) +{ + RemoteClient *client = mainWindow->getRemoteClient(); + + // Back on the previous server already → nothing to undo. + if (client->serverName().compare(chain.previousServerHost, Qt::CaseInsensitive) == 0 && + QString::number(client->serverPort()) == chain.previousServerPort) { + return; + } + + // When logged in somewhere, only intervene if that somewhere is the server + // the link moved us to; if the user went elsewhere on their own, leave them. + if (client->getStatus() == StatusLoggedIn) { + const bool onMigrationTarget = + client->serverName().compare(chain.migrationTargetHost, Qt::CaseInsensitive) == 0 && + QString::number(client->serverPort()) == chain.migrationTargetPort; + if (!onMigrationTarget) { + return; + } + + ServersSettings &servers = SettingsCache::instance().servers(); + const int index = servers.findServerIndex(chain.previousServerHost, chain.previousServerPort); + if (index >= 0 && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) { + const QString username = + servers.getValue(QString("username%1").arg(index), "server", "server_details").toString(); + const QString password = + servers.getValue(QString("password%1").arg(index), "server", "server_details").toString(); + client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), username, password); + return; + } + client->disconnectFromServer(); + return; + } + + if (client->getStatus() != StatusDisconnected) { + return; + } + + // The link's connection attempt failed: reconnect to the previous server + // when credentials are saved, otherwise stay offline. + ServersSettings &servers = SettingsCache::instance().servers(); + const int index = servers.findServerIndex(chain.previousServerHost, chain.previousServerPort); + if (index >= 0 && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) { + const QString username = + servers.getValue(QString("username%1").arg(index), "server", "server_details").toString(); + const QString password = + servers.getValue(QString("password%1").arg(index), "server", "server_details").toString(); + client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), username, password); + } +} diff --git a/cockatrice/src/interface/intents/url_parser.h b/cockatrice/src/interface/intents/url_parser.h index 6d705e013..ea29fed53 100644 --- a/cockatrice/src/interface/intents/url_parser.h +++ b/cockatrice/src/interface/intents/url_parser.h @@ -1,10 +1,46 @@ #ifndef COCKATRICE_URL_PARSER_H #define COCKATRICE_URL_PARSER_H + +#include #include #include +class Intent; class MainWindow; struct ContextJoinGame; + +/** + * @brief One queued intent chain with the session-migration bookkeeping for it. + * + * The restore fields are per-chain on purpose: chains are parsed while earlier + * ones are still queued, so parser-wide state would let one chain's failure + * consume the restore data another chain recorded. + */ +struct PendingIntentChain +{ + QList intents; + + // Snapshot of the session in place when this chain started running, so a + // queued chain follows whichever server the chain before it settled on. + QString previousServerHost; + QString previousServerPort; + + // Recorded at parse time when the user confirmed migrating away from a live + // session to the host/port named by the link. + QString migrationTargetHost; + QString migrationTargetPort; + bool pendingRestore = false; +}; + +/** + * @brief Parses cockatrice:// links and runs them as serialized intent chains. + * + * Links are parsed by action (joingame/opendeck) and translated into an intent + * chain. Chains are queued and run one at a time: a document can hand multiple + * links to the window while an earlier chain still connects, and running two + * connect chains concurrently tears the connection down. urlChainFinished is + * emitted once the queue has fully drained. + */ class IntentUrlParser : public QObject { Q_OBJECT @@ -12,12 +48,28 @@ class IntentUrlParser : public QObject public: IntentUrlParser(QObject *parent, MainWindow *mainWindow); void handle(const QString &urlStr); - void handleJoinGame(const QUrlQuery &query); + +signals: + /** @brief Emitted when the last queued chain ended; carries whether the client is logged in. */ + void urlChainFinished(bool connected); private: + Intent *createJoinGameIntent(const QUrlQuery &query, PendingIntentChain &chain); + Intent *createOpenDeckIntent(const QUrlQuery &query, PendingIntentChain &chain); QString generateJoinGameMessage(const ContextJoinGame &context, const QString &gameDescription); + [[nodiscard]] bool isConnectedTo(const QString &hostname, const QString &port) const; + void startNextChain(); + void chainEnded(bool chainSucceeded); + void onChainIntentDestroyed(); + void restorePreviousServer(const PendingIntentChain &chain); + void restoreToPreviousServer(const PendingIntentChain &chain); MainWindow *mainWindow; + QList pendingChains; + bool chainRunning = false; + // Disconnects the destroyed-signal backstop once a chain ends, so an old + // intent's deferred deletion cannot end the chain that runs after it. + QMetaObject::Connection chainBackstopConnection; }; #endif // COCKATRICE_URL_PARSER_H diff --git a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp index 9cde72c01..d5a168708 100644 --- a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp +++ b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp @@ -1,6 +1,5 @@ #include "palette_editor_dialog.h" -#include "../../client/settings/cache_settings.h" #include "../theme_manager.h" #include "palette_generator.h" #include "palette_grid_widget.h" @@ -11,31 +10,11 @@ #include #include #include -#include #include -#include #include -#include #include #include -#include #include -#include - -// Probe whether a directory is truly writable by trying to create and remove a -// temporary file. QFileInfo::isWritable() on a directory is unreliable (notably -// on Windows where UAC VirtualStore can make a system dir appear writable). -static bool isDirReallyWritable(const QString &dirPath) -{ - const QString probe = QDir(dirPath).absoluteFilePath(".cockatrice_write_test"); - QFile f(probe); - if (!f.open(QIODevice::WriteOnly)) { - return false; - } - f.close(); - f.remove(); - return true; -} PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QString &_themeName, QWidget *parent) : QDialog(parent), themeDirPath(_themeDirPath), themeName(_themeName) @@ -46,14 +25,7 @@ PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QSt // Resolve a writable directory for saving. Built-in (Default / Fusion) and // other read-only theme directories must be customised in the user-writable // themes directory; otherwise the write would fail or be lost on upgrade. - if (!themeDirPath.isEmpty() && isDirReallyWritable(themeDirPath)) { - saveDir = themeDirPath; - } else { - saveDir = QDir(SettingsCache::instance().paths().getThemesPath()).absoluteFilePath(themeName); - if (!QDir().mkpath(saveDir)) { - qWarning() << "Failed to create palette save directory:" << saveDir; - } - } + saveDir = ThemeManager::writableThemeDir(themeName); // Load both scheme configs upfront so switching is instant loadSchemes(); @@ -214,7 +186,7 @@ void PaletteEditorDialog::retranslateUi() resetBtn->setToolTip(tr("Discard unsaved edits and restore the last saved palette")); saveBtn->setToolTip(tr("Write palette-%1.toml and reload the theme").arg(loadedScheme.toLower())); - if (saveDir.isEmpty() || !isDirReallyWritable(saveDir)) { + if (saveDir.isEmpty() || !ThemeManager::isDirReallyWritable(saveDir)) { saveBtn->setEnabled(false); saveBtn->setToolTip(tr("Cannot save: this theme has no writable directory")); } @@ -297,7 +269,7 @@ void PaletteEditorDialog::onSave() if (it.key() == loadedScheme) { continue; } - if (it.value().colors == savedConfig.value(it.key()).colors) { + if (it.value() == savedConfig.value(it.key())) { continue; } if (!ThemeManager::commitPalette(saveDir, it.key(), it.value())) { @@ -308,7 +280,7 @@ void PaletteEditorDialog::onSave() } // Commit the active scheme last so the global colour scheme matches. - if (workingConfig[loadedScheme].colors != savedConfig.value(loadedScheme).colors) { + if (workingConfig[loadedScheme] != savedConfig.value(loadedScheme)) { if (!ThemeManager::commitPalette(saveDir, loadedScheme, workingConfig[loadedScheme])) { QMessageBox::warning(this, tr("Save failed"), tr("Could not write %1 to:\n%2").arg(PaletteConfig::fileName(loadedScheme), saveDir)); diff --git a/cockatrice/src/interface/palette_editor/palette_generator.cpp b/cockatrice/src/interface/palette_editor/palette_generator.cpp index d30dd14f1..822e57250 100644 --- a/cockatrice/src/interface/palette_editor/palette_generator.cpp +++ b/cockatrice/src/interface/palette_editor/palette_generator.cpp @@ -150,6 +150,17 @@ PaletteConfig fromAccent(const QColor &accent, int intensity, const QString &sch cfg.colors[CG::Disabled][CR::HighlightedText] = disText; cfg.colors[CG::Inactive][CR::HighlightedText] = dark ? Qt::white : Qt::black; + // Accent: same primary hue as Highlight, so palettes derived from a + // QuickSetup accent always carry a matching Accent role. +#if QT_VERSION >= QT_VERSION_CHECK(6, 6, 0) + set3(CR::Accent, hl, disText, hl); +#endif + + // Application role colors: Strong tracks the primary accent, while Soft is + // the lightened, desaturated companion used for button-gradient highlights. + cfg.appColors[AppColor::AccentStrong] = hl; + cfg.appColors[AppColor::AccentSoft] = hsl(accent.lightness() + 60, qRound(accent.hslSaturation() * 70 / 100.0)); + // BrightText QColor bright; if (achromatic) { diff --git a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp index 67294cd98..97d28b731 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp @@ -1,5 +1,7 @@ #include "palette_grid_widget.h" +#include "../theme_manager.h" + #include #include #include @@ -45,6 +47,11 @@ static const QMap ROLE_DESCRIPTIONS = { {QPalette::Shadow, QT_TR_NOOP("Very dark shadow colour")}, }; +static const QMap APP_ROLE_DESCRIPTIONS = { + {AppColor::AccentStrong, QT_TR_NOOP("Vivid primary accent (e.g. home-tab button gradient start)")}, + {AppColor::AccentSoft, QT_TR_NOOP("Lightened, desaturated accent (e.g. home-tab button gradient end)")}, +}; + PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent) { scroll = new QScrollArea(this); @@ -122,6 +129,46 @@ void PaletteGridWidget::buildGrid(QWidget *host) grid->addWidget(btn, row + 1, col + 1, Qt::AlignHCenter | Qt::AlignVCenter); } } + + // Application color section: one ColorButton per role below the role grid. + // These are not tied to a color group, so a single button spans the row. + QMetaEnum appEnum = QMetaEnum::fromType(); + + const int appHeaderRow = roles.size() + 1; + + auto *appHeader = new QLabel(tr("App colors"), host); + appHeader->setToolTip(tr("Application-specific colors layered on top of the Qt palette")); + QFont appHeaderFont = appHeader->font(); + appHeaderFont.setBold(true); + appHeader->setFont(appHeaderFont); + appHeader->setAutoFillBackground(true); + appHeader->setContentsMargins(4, 4, 4, 4); + grid->addWidget(appHeader, appHeaderRow, 0, 1, 4); + headerLabels.append(appHeader); + + for (int i = 0; i < appEnum.keyCount(); ++i) { + auto role = static_cast(appEnum.value(i)); + const int row = appHeaderRow + 1 + i; + + if (i % 2 == 0) { + for (int col = 0; col < 4; ++col) { + auto *shade = new QWidget(host); + shade->setAutoFillBackground(true); + grid->addWidget(shade, row, col); + rowShadeWidgets.push_back(shade); + } + } + + auto *label = new QLabel(QString(appEnum.valueToKey(role)), host); + label->setToolTip(APP_ROLE_DESCRIPTIONS.value(role, {})); + label->setContentsMargins(4, 2, 8, 2); + grid->addWidget(label, row, 0); + + auto *btn = new ColorButton(host); + connect(btn, &ColorButton::colorChanged, this, [this] { emit paletteChanged(); }); + appColorButtons[role] = btn; + grid->addWidget(btn, row, 1, Qt::AlignHCenter | Qt::AlignVCenter); + } } void PaletteGridWidget::changeEvent(QEvent *e) @@ -166,6 +213,16 @@ void PaletteGridWidget::loadPalette(const PaletteConfig &cfg) colorButtons[group][role]->setColor(color); } } + + QMetaEnum appEnum = QMetaEnum::fromType(); + for (int i = 0; i < appEnum.keyCount(); ++i) { + auto role = static_cast(appEnum.value(i)); + QColor color = cfg.appColors.value(role); + if (!color.isValid()) { + color = themeManager->appColor(role); + } + appColorButtons[role]->setColor(color); + } } PaletteConfig PaletteGridWidget::currentPaletteConfig() const @@ -176,5 +233,12 @@ PaletteConfig PaletteGridWidget::currentPaletteConfig() const cfg.colors[group][role] = colorButtons[group][role]->getColor(); } } + + QMetaEnum appEnum = QMetaEnum::fromType(); + for (int i = 0; i < appEnum.keyCount(); ++i) { + auto role = static_cast(appEnum.value(i)); + cfg.appColors[role] = appColorButtons[role]->getColor(); + } + return cfg; } \ No newline at end of file diff --git a/cockatrice/src/interface/palette_editor/palette_grid_widget.h b/cockatrice/src/interface/palette_editor/palette_grid_widget.h index 1a665971a..77cbf1c62 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.h +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.h @@ -31,6 +31,7 @@ private: void refreshChromePalettes(); QMap> colorButtons; + QMap appColorButtons; QScrollArea *scroll; QWidget *gridHost; QVBoxLayout *layout; diff --git a/cockatrice/src/interface/pixel_map_generator.cpp b/cockatrice/src/interface/pixel_map_generator.cpp index e74e86471..b70dc576f 100644 --- a/cockatrice/src/interface/pixel_map_generator.cpp +++ b/cockatrice/src/interface/pixel_map_generator.cpp @@ -16,7 +16,6 @@ #define DEFAULT_COLOR_MODERATOR_LEFT "#ffffff"; #define DEFAULT_COLOR_MODERATOR_RIGHT "#000000"; #define DEFAULT_COLOR_ADMIN "#ff2701"; -#define DEFAULT_COLOR_DEVELOPER "#B8B8B8" /** * Clamps an svg render size so that rendering does not exceed a multiple of the requested size. @@ -362,6 +361,10 @@ static QString getIconType(const bool isBuddy, const UserLevelFlags &userLevelFl return "pawn_judge"; } + if (userLevelFlags.testFlag(ServerInfo_User::IsDeveloper)) { + return "pawn_dev"; + } + if (!privLevel.isEmpty() && privLevel.toLower() != "none") { return QString("pawn_%1").arg(privLevel.toLower()); } @@ -382,8 +385,6 @@ QIcon UserLevelPixmapGenerator::generateIconDefault(int height, if (userLevel.testFlag(ServerInfo_User::IsAdmin)) { colorLeft = DEFAULT_COLOR_ADMIN; - } else if (userLevel.testFlag(ServerInfo_User::IsDeveloper)) { - colorLeft = DEFAULT_COLOR_DEVELOPER; } else if (userLevel.testFlag(ServerInfo_User::IsModerator)) { colorLeft = DEFAULT_COLOR_MODERATOR_LEFT; colorRight = DEFAULT_COLOR_MODERATOR_RIGHT; diff --git a/cockatrice/src/interface/theme_config.cpp b/cockatrice/src/interface/theme_config.cpp index 3c43c467d..8293a82cf 100644 --- a/cockatrice/src/interface/theme_config.cpp +++ b/cockatrice/src/interface/theme_config.cpp @@ -16,7 +16,7 @@ QString ThemeConfig::toIni() const out += "[Appearance]\n"; out += QString("ColorScheme = %1\n").arg(colorScheme.isEmpty() ? "System" : colorScheme); out += "\n[Style]\n"; - out += QString("Name = %1\n").arg(styleName.isEmpty() ? "Default" : styleName); + out += QString("Name = %1\n").arg(styleName.isEmpty() ? "System" : styleName); return out; } @@ -96,7 +96,7 @@ bool ThemeConfig::save(const QString &themeDirPath) const bool PaletteConfig::hasPalette() const { - return !colors.isEmpty(); + return !colors.isEmpty() || !appColors.isEmpty(); } QString PaletteConfig::toToml() const @@ -133,6 +133,24 @@ QString PaletteConfig::toToml() const out += "\n"; } + if (!appColors.isEmpty()) { + QMetaEnum appEnum = QMetaEnum::fromType(); + + out += "[AppColors]\n"; + + for (auto it = appColors.cbegin(); it != appColors.cend(); ++it) { + const char *roleName = appEnum.valueToKey(it.key()); + + if (!roleName) { + continue; + } + + out += QString("%1 = %2\n").arg(QString(roleName), -20).arg(it.value().name(QColor::HexArgb)); + } + + out += "\n"; + } + return out; } @@ -152,6 +170,7 @@ PaletteConfig PaletteConfig::fromFile(const QString &filePath) } QMetaEnum roleEnum = QMetaEnum::fromType(); + QMetaEnum appEnum = QMetaEnum::fromType(); QString currentSection; QPalette::ColorGroup currentGroup = QPalette::Active; @@ -202,6 +221,26 @@ PaletteConfig PaletteConfig::fromFile(const QString &filePath) } } + QColor color(value); + + if (!color.isValid()) { + continue; + } + + if (currentSection.compare("AppColors", Qt::CaseInsensitive) == 0) { + if (key.startsWith("AppColor::")) { + key = key.mid(10); + } + + int appRoleInt = appEnum.keyToValue(key.toUtf8().constData()); + + if (appRoleInt >= 0) { + cfg.appColors[static_cast(appRoleInt)] = color; + } + + continue; + } + if (!currentSection.startsWith("Palette", Qt::CaseInsensitive)) { continue; } @@ -216,11 +255,7 @@ PaletteConfig PaletteConfig::fromFile(const QString &filePath) continue; } - QColor color(value); - - if (color.isValid()) { - cfg.colors[currentGroup][static_cast(roleInt)] = color; - } + cfg.colors[currentGroup][static_cast(roleInt)] = color; } return cfg; diff --git a/cockatrice/src/interface/theme_config.h b/cockatrice/src/interface/theme_config.h index 07bf55b7a..567aeccda 100644 --- a/cockatrice/src/interface/theme_config.h +++ b/cockatrice/src/interface/theme_config.h @@ -3,9 +3,25 @@ #include #include +#include #include #include +// Application-specific color roles, layered on top of the fixed QPalette role +// set. Stored in the same palette-.toml under an [AppColors] section +// and editable from the palette editor, so theme authors can control colors +// beyond what Qt's palette can express. +namespace AppColor +{ +Q_NAMESPACE +enum Role +{ + AccentStrong, + AccentSoft, +}; +Q_ENUM_NS(Role) +} // namespace AppColor + struct ThemeConfig { QString colorScheme; @@ -21,7 +37,16 @@ struct ThemeConfig struct PaletteConfig { QMap> colors; + QMap appColors; + bool operator==(const PaletteConfig &rhs) const + { + return colors == rhs.colors && appColors == rhs.appColors; + } + bool operator!=(const PaletteConfig &rhs) const + { + return !(*this == rhs); + } bool hasPalette() const; QString toToml() const; diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index 12c8fad2c..d86ed77f9 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -21,7 +22,7 @@ #include #include -#define NONE_THEME_NAME "Default" +#define SYSTEM_THEME_NAME "System" #define FUSION_THEME_NAME "Fusion" #define STYLE_CSS_NAME "style.css" #define HANDZONE_BG_NAME "handzone" @@ -95,7 +96,7 @@ struct PaletteColorInfo static QString usableDefaultStyle(const QString &style) { // The Windows 11 native style is broken: when the OS default - // ("Default" theme selection) would use it, fall back to the Vista style. + // ("System" theme selection) would use it, fall back to the Vista style. // Explicitly choosing "windows11" in a theme is still honored. return style.compare("windows11", Qt::CaseInsensitive) == 0 ? QStringLiteral("windowsvista") : style; } @@ -118,10 +119,16 @@ ThemeManager::ThemeManager(QObject *parent) : QObject(parent) void ThemeManager::ensureThemeDirectoryExists() { - if (SettingsCache::instance().getThemeName().isEmpty() || - !getAvailableThemes().contains(SettingsCache::instance().getThemeName())) { + auto &settings = SettingsCache::instance(); + + // Migrate the old "Default" theme name to "System" + if (settings.getThemeName() == "Default") { + settings.setThemeName(SYSTEM_THEME_NAME); + } + + if (settings.getThemeName().isEmpty() || !getAvailableThemes().contains(settings.getThemeName())) { qCInfo(ThemeManagerLog) << "Theme name not set, setting default value"; - SettingsCache::instance().setThemeName(NONE_THEME_NAME); + settings.setThemeName(FUSION_THEME_NAME); } } @@ -184,11 +191,32 @@ QString ThemeManager::assetPath(QStringView prefix) const return resolvedPlain.isEmpty() ? prefix.toString() : resolvedPlain; } -bool ThemeManager::isBuiltInTheme() +// Probe whether a directory is truly writable by trying to create and remove a +// temporary file. QFileInfo::isWritable() on a directory is unreliable (notably +// on Windows where UAC VirtualStore can make a system dir appear writable). +bool ThemeManager::isDirReallyWritable(const QString &dirPath) { - const auto themeName = SettingsCache::instance().getThemeName(); + const QString probe = QDir(dirPath).absoluteFilePath(".cockatrice_write_test"); + QFile f(probe); + if (!f.open(QIODevice::WriteOnly)) { + return false; + } + f.close(); + f.remove(); + return true; +} - return themeName == NONE_THEME_NAME || themeName == FUSION_THEME_NAME; +QString ThemeManager::writableThemeDir(const QString &themeName) +{ + // All theme writes go to the user themes directory regardless of whether + // the resolved (system) theme directory happens to be writable. Even when a + // write would succeed in-place, routing it to the user directory keeps the + // install intact and guarantees changes survive upgrades. + const QString dirPath = QDir(SettingsCache::instance().paths().getThemesPath()).absoluteFilePath(themeName); + if (!QDir().mkpath(dirPath)) { + qWarning() << "Failed to create theme save directory:" << dirPath; + } + return dirPath; } // System (read-only) themes location, relative to the application binary. @@ -213,9 +241,7 @@ QStringMap &ThemeManager::getAvailableThemes() // load themes from user profile dir dir.setPath(SettingsCache::instance().paths().getThemesPath()); - // add default value - availableThemes.insert(NONE_THEME_NAME, dir.absoluteFilePath("Default")); - + availableThemes.insert(SYSTEM_THEME_NAME, dir.absoluteFilePath("System")); availableThemes.insert(FUSION_THEME_NAME, dir.absoluteFilePath("Fusion")); for (QString themeName : dir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot, QDir::Name)) { @@ -331,7 +357,7 @@ bool ThemeManager::commitPalette(const QString &themeDirPath, const QString &col void ThemeManager::setColorScheme(const QString &scheme) { - const QString dirPath = getAvailableThemes().value(SettingsCache::instance().getThemeName()); + const QString dirPath = writableThemeDir(SettingsCache::instance().getThemeName()); ThemeConfig cfg = ThemeConfig::fromThemeDir(dirPath); cfg.colorScheme = scheme; @@ -342,7 +368,7 @@ void ThemeManager::setColorScheme(const QString &scheme) void ThemeManager::setStyleName(const QString &styleName) { - const QString dirPath = getAvailableThemes().value(SettingsCache::instance().getThemeName()); + const QString dirPath = writableThemeDir(SettingsCache::instance().getThemeName()); ThemeConfig cfg = ThemeConfig::fromThemeDir(dirPath); cfg.styleName = styleName; @@ -373,7 +399,7 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName, Q_UNUSED(activeScheme) #endif QString styleName = themeCfg.styleName; - if (styleName.isEmpty() || styleName.compare("Default", Qt::CaseInsensitive) == 0) { + if (styleName.isEmpty() || styleName.compare("System", Qt::CaseInsensitive) == 0) { if (themeName == FUSION_THEME_NAME) { styleName = "Fusion"; } else { @@ -416,6 +442,8 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName, qApp->setPalette(base); qApp->setStyle(style); + currentAppColors = palCfg.appColors; + // Force every widget to re-polish and repaint immediately rather than // waiting for natural expose events, which produces a patchwork of old // and new colours during a live preview. @@ -428,6 +456,35 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName, style->polish(widget); widget->update(); } + + emit paletteChanged(); +} + +QColor ThemeManager::appColor(AppColor::Role role) const +{ + const auto it = currentAppColors.constFind(role); + if (it != currentAppColors.constEnd()) { + return it.value(); + } + + // QPalette::Accent was introduced in Qt 6.6 and several shipped palettes + // set it to a value barely distinguishable from Window, so it is not a + // reliable accent source. The selection highlight is the stable accent + // (Accent defaults to Highlight when unset), and deriving from it + // unconditionally keeps every Qt version rendering identically. + const QColor accent = qApp->palette().color(QPalette::Active, QPalette::Highlight); + + if (role == AppColor::AccentSoft) { + constexpr int SOFT_SATURATION_PERCENT = 70; + constexpr int SOFT_LIGHTNESS_OFFSET = 60; + + // Light end of the gradient: same hue, softened and lightened + return QColor::fromHsl(qMax(0, accent.hslHue()), + qBound(0, qRound(accent.hslSaturation() * SOFT_SATURATION_PERCENT / 100.0), 255), + qBound(0, accent.lightness() + SOFT_LIGHTNESS_OFFSET, 255)); + } + + return accent; } void ThemeManager::themeChangedSlot() @@ -464,8 +521,19 @@ void ThemeManager::themeChangedSlot() // ── Load palette: custom first, then theme default ──────────────────── PaletteConfig palette = PaletteConfig::fromScheme(dirPath, activeScheme); - if (!palette.hasPalette()) { - palette = ThemeManager::loadDefaultPaletteConfig(dirPath, themeName, activeScheme); + const PaletteConfig themeDefault = ThemeManager::loadDefaultPaletteConfig(dirPath, themeName, activeScheme); + if (palette.hasPalette()) { + // A custom palette written before [AppColors] existed carries no app + // colors; merge the theme's shipped defaults so the identity colors + // survive (hasPalette() counts an app-colors-only file as a palette, + // so those are kept wholesale and never reach here empty). + for (auto it = themeDefault.appColors.cbegin(); it != themeDefault.appColors.cend(); ++it) { + if (!palette.appColors.contains(it.key())) { + palette.appColors.insert(it.key(), it.value()); + } + } + } else { + palette = themeDefault; } applyStyleAndPalette(themeName, themeCfg, palette, activeScheme); diff --git a/cockatrice/src/interface/theme_manager.h b/cockatrice/src/interface/theme_manager.h index ac35042a0..aadb38ee9 100644 --- a/cockatrice/src/interface/theme_manager.h +++ b/cockatrice/src/interface/theme_manager.h @@ -50,6 +50,7 @@ private: QString currentThemePath; std::array brushes; QStringMap availableThemes; + QMap currentAppColors; /* Internal cache for multiple backgrounds */ @@ -65,7 +66,16 @@ protected: const QString &activeScheme); public: - bool isBuiltInTheme(); + // Resolves the directory to write theme changes to for the given theme + // name. The resolved theme dir (user or system) is used when writable; + // read-only system themes fall back to the user themes directory, creating + // it if needed, so customisations never get lost on upgrade. + static QString writableThemeDir(const QString &themeName); + // Probe whether a directory is truly writable by trying to create and remove + // a temporary file. QFileInfo::isWritable() on a directory is unreliable + // (notably on Windows where UAC VirtualStore can make a system dir appear + // writable). + static bool isDirReallyWritable(const QString &dirPath); // Explicit color scheme of the theme: theme.cfg's ColorScheme setting // (Dark/Light), falling back to the OS color scheme when it is "System". bool isDarkMode(const QString &themeDirPath) const; @@ -115,12 +125,17 @@ public: void reloadCurrentTheme(); void previewPalette(const PaletteConfig &cfg, const QString &scheme); + // Resolves an application color role: the theme's stored [AppColors] value + // when present, otherwise a palette-accent-derived fallback. + QColor appColor(AppColor::Role role) const; + QBrush &getBgBrush(Role zone); QBrush getExtraBgBrush(Role zone, int zoneId = 0); protected slots: void themeChangedSlot(); signals: void themeChanged(); + void paletteChanged(); }; extern ThemeManager *themeManager; diff --git a/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp b/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp index 1ea1bcb10..2199faf30 100644 --- a/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp @@ -85,7 +85,7 @@ void ColorIdentityWidget::resizeEvent(QResizeEvent *event) } lastWidth = totalWidth; - const int totalHeight = totalWidth / 6; // Set height to 1/4 of the width + const int totalHeight = qMax(0, totalWidth / 6); // Set height to 1/4 of the width setFixedHeight(totalHeight); const int count = layout->count(); @@ -97,6 +97,10 @@ void ColorIdentityWidget::resizeEvent(QResizeEvent *event) const int availableWidth = totalWidth - (spacing * (count - 1)); const int iconSize = qMin(availableWidth / count, totalHeight); // Ensure icons fit within the new height + if (iconSize <= 0) { + lastIconSize = iconSize; + return; + } if (iconSize == lastIconSize) { return; } diff --git a/cockatrice/src/interface/widgets/cards/additional_info/deck_color_identity.cpp b/cockatrice/src/interface/widgets/cards/additional_info/deck_color_identity.cpp new file mode 100644 index 000000000..62b01511e --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/additional_info/deck_color_identity.cpp @@ -0,0 +1,37 @@ +#include "deck_color_identity.h" + +#include +#include +#include +#include + +QString getDeckColorIdentity(const DeckList &deck, const CardDatabaseQuerier *db) +{ + const QStringList cardList = deck.getCardList({DECK_ZONE_MAIN, DECK_ZONE_SIDE}); + if (cardList.isEmpty()) { + return {}; + } + + QSet colorSet; // A set to collect unique color symbols (e.g., W, U, B, R, G) + + for (const QString &cardName : cardList) { + CardInfoPtr currentCard = db->getCardInfo(cardName); + if (currentCard) { + const QString colors = currentCard->getColors(); // returns something like "WUB" + for (const QChar &color : colors) { + colorSet.insert(color); + } + } + } + + // Ensure the color identity is in WUBRG order + QString colorIdentity; + const QString wubrgOrder = "WUBRG"; + for (const QChar &color : wubrgOrder) { + if (colorSet.contains(color)) { + colorIdentity.append(color); + } + } + + return colorIdentity; +} diff --git a/cockatrice/src/interface/widgets/cards/additional_info/deck_color_identity.h b/cockatrice/src/interface/widgets/cards/additional_info/deck_color_identity.h new file mode 100644 index 000000000..04294cb1c --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/additional_info/deck_color_identity.h @@ -0,0 +1,20 @@ +#ifndef COCKATRICE_DECK_COLOR_IDENTITY_H +#define COCKATRICE_DECK_COLOR_IDENTITY_H + +#include + +class CardDatabaseQuerier; +class DeckList; + +/** + * @brief Computes the color identity of a deck (e.g. "WUBRG") from the color + * symbols of all cards in the main deck and sideboard, ordered WUBRG. + * + * Shared as a free function so the deck storage previews and the deck share + * dialog compute identities identically. + * + * @param db Card database used to look up card color symbols. + */ +QString getDeckColorIdentity(const DeckList &deck, const CardDatabaseQuerier *db); + +#endif // COCKATRICE_DECK_COLOR_IDENTITY_H diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp index de622bdc8..2dd21e78a 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp @@ -74,6 +74,8 @@ CardInfoPictureWidget::CardInfoPictureWidget(QWidget *parent, const bool _hoverT update(); }); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, + &CardInfoPictureWidget::updatePixmap); } /** diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp index c5cb59b3b..000a88b2f 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp @@ -133,12 +133,14 @@ void CardInfoPictureWithTextOverlayWidget::paintEvent(QPaintEvent *event) path.addRoundedRect(glowRect, radius, radius); // Soft outer glow - QColor glowColor(0, 150, 255, 80); // subtle blu + QColor glowColor = palette().color(QPalette::Highlight); + glowColor.setAlpha(80); painter.setPen(QPen(glowColor, 6)); painter.drawPath(path); // Thin inner border for crispness - QColor borderColor(0, 150, 255, 200); + QColor borderColor = palette().color(QPalette::Highlight); + borderColor.setAlpha(200); painter.setPen(QPen(borderColor, 2)); painter.drawRoundedRect(pixmapRect, radius, radius); diff --git a/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp index c6af5320b..e98c3c02a 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp @@ -1,6 +1,7 @@ #include "card_info_text_widget.h" #include "../../../game_graphics/board/card_item.h" +#include "../../card_localization.h" #include #include @@ -10,7 +11,7 @@ #include #include -CardInfoTextWidget::CardInfoTextWidget(QWidget *parent) : QFrame(parent), info(nullptr) +CardInfoTextWidget::CardInfoTextWidget(QWidget *parent) : QFrame(parent) { propsLabel = new QLabel; propsLabel->setOpenExternalLinks(false); @@ -39,6 +40,12 @@ CardInfoTextWidget::CardInfoTextWidget(QWidget *parent) : QFrame(parent), info(n grid->setRowStretch(1, 1); retranslateUi(); + + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, [this] { + if (currentCard) { + setCard(currentCard); + } + }); } void CardInfoTextWidget::setTexts(const QString &propsText, const QString &textText) @@ -60,7 +67,7 @@ void CardInfoTextWidget::setCard(const ExactCard &exactCard) QString text = ""; text += QString("") - .arg(tr("Name:"), card->getName().toHtmlEscaped()); + .arg(tr("Name:"), CardLocalization::displayName(card).toHtmlEscaped()); if (!exactCard.getPrinting().isEmpty()) { QString setShort = exactCard.getPrinting().getSet()->getShortName().toHtmlEscaped(); @@ -94,7 +101,8 @@ void CardInfoTextWidget::setCard(const ExactCard &exactCard) } text += "
%1%2
"; - setTexts(text, card->getText()); + setTexts(text, CardLocalization::displayText(card)); + currentCard = exactCard; } void CardInfoTextWidget::setInvalidCardName(const QString &cardName) diff --git a/cockatrice/src/interface/widgets/cards/card_info_text_widget.h b/cockatrice/src/interface/widgets/cards/card_info_text_widget.h index a9c29da37..683be5ab5 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_text_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_info_text_widget.h @@ -23,7 +23,7 @@ private: QLabel *propsLabel; QScrollArea *propsScroll; QTextEdit *textLabel; - CardInfoPtr info; + ExactCard currentCard; ///< Last card set, re-rendered when the card language changes. void setTexts(const QString &propsText, const QString &textText); public: diff --git a/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.cpp b/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.cpp index 1614836fc..147143e7f 100644 --- a/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.cpp @@ -27,18 +27,23 @@ DeckPreviewCardPictureWidget::DeckPreviewCardPictureWidget(QWidget *parent, const QColor &textColor, const QColor &outlineColor, const int fontSize, - const Qt::Alignment alignment) + const Qt::Alignment alignment, + const bool _emitClickImmediately) : CardInfoPictureWithTextOverlayWidget(parent, hoverToZoomEnabled, raiseOnEnter, textColor, outlineColor, fontSize, - alignment) + alignment), + emitClickImmediately(_emitClickImmediately) { singleClickTimer = new QTimer(this); singleClickTimer->setSingleShot(true); - connect(singleClickTimer, &QTimer::timeout, this, [this]() { emit imageClicked(lastMouseEvent, this); }); + connect(singleClickTimer, &QTimer::timeout, this, [this]() { + emit imageClicked(lastMouseEvent, this); + emit imageSingleClicked(); + }); connect(&SettingsCache::instance().visualDeckStorage(), &VisualDeckStorageSettings::visualDeckStorageSelectionAnimationChanged, this, &CardInfoPictureWidget::setRaiseOnEnterEnabled); @@ -47,8 +52,13 @@ DeckPreviewCardPictureWidget::DeckPreviewCardPictureWidget(QWidget *parent, void DeckPreviewCardPictureWidget::mousePressEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) { - lastMouseEvent = event; - singleClickTimer->start(QApplication::doubleClickInterval()); + if (emitClickImmediately) { + emit imageClicked(event, this); + emit imageSingleClicked(); + } else { + lastMouseEvent = event; + singleClickTimer->start(QApplication::doubleClickInterval()); + } } else { emit imageClicked(event, this); event->accept(); @@ -58,7 +68,14 @@ void DeckPreviewCardPictureWidget::mousePressEvent(QMouseEvent *event) void DeckPreviewCardPictureWidget::mouseDoubleClickEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) { - singleClickTimer->stop(); // Prevent single-click logic - emit imageDoubleClicked(lastMouseEvent, this); + if (emitClickImmediately) { + // Do not report a second single click for the second press of the + // double-click; the consumer maps the double-click to select+open. + lastMouseEvent = event; + emit imageDoubleClicked(event, this); + } else { + singleClickTimer->stop(); // Prevent single-click logic + emit imageDoubleClicked(lastMouseEvent, this); + } } } diff --git a/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.h b/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.h index 154e938aa..7571bc256 100644 --- a/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.h +++ b/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.h @@ -20,21 +20,38 @@ class DeckPreviewCardPictureWidget final : public CardInfoPictureWithTextOverlay Q_OBJECT public: + /** + * @brief Constructs a DeckPreviewCardPictureWidget. + * @param parent The parent widget. + * @param hoverToZoomEnabled If this widget will spawn a larger widget when hovered over. + * @param raiseOnEnter If the widget raises its border when the mouse enters. + * @param textColor The color of the overlay text. + * @param outlineColor The color of the outline around the text. + * @param fontSize The font size of the overlay text. + * @param alignment The alignment of the text within the overlay. + * @param emitClickImmediately If true, a left click is reported immediately on click + * instead of after the double-click interval. Use this for selection surfaces + * where reacting to a double-click (select-and-open) would needlessly delay the + * single-click feedback. The double-click signal is still emitted. + */ explicit DeckPreviewCardPictureWidget(QWidget *parent, bool hoverToZoomEnabled = false, bool raiseOnEnter = false, const QColor &textColor = Qt::white, const QColor &outlineColor = Qt::black, int fontSize = 12, - Qt::Alignment alignment = Qt::AlignCenter); + Qt::Alignment alignment = Qt::AlignCenter, + bool _emitClickImmediately = false); signals: void imageClicked(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); + void imageSingleClicked(); void imageDoubleClicked(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); private: QTimer *singleClickTimer; QMouseEvent *lastMouseEvent = nullptr; // Store the last mouse event + bool emitClickImmediately; protected: void mousePressEvent(QMouseEvent *event) override; diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp index a3653f03e..4b141e255 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp @@ -345,7 +345,9 @@ ExactCard DeckEditorDeckDockWidget::getCurrentCard() if (!current.isValid()) { return {}; } - const QString cardName = current.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString(); + // The display role holds the localized card name; the edit role always carries the + // canonical English name needed to look the card up in the database. + const QString cardName = current.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); const QString cardProviderID = current.siblingAtColumn(DeckListModelColumns::CARD_PROVIDER_ID).data().toString(); const QModelIndex gparent = current.parent().parent(); @@ -518,8 +520,11 @@ void DeckEditorDeckDockWidget::syncBannerCardComboBoxSelectionWithDeck() void DeckEditorDeckDockWidget::setSelectedIndex(const QModelIndex &newCardIndex, bool preserveWidgetFocus) { + const QModelIndex proxyIndex = proxy->mapFromSource(newCardIndex); + deckView->clearSelection(); - deckView->setCurrentIndex(newCardIndex); + deckView->setCurrentIndex(proxyIndex); + deckView->scrollTo(proxyIndex); recursiveExpand(newCardIndex); if (!preserveWidgetFocus) { diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp index e563729a4..cfe989e9f 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp @@ -1,13 +1,20 @@ #include "deck_state_manager.h" +#include "../../../client/settings/cache_settings.h" + #include #include #include +#include DeckStateManager::DeckStateManager(QObject *parent) : QObject(parent), deckList(QSharedPointer(new DeckList)), deckListModel(new DeckListModel(this, deckList)), historyManager(new DeckListHistoryManager(this)) { + deckListModel->setDisplayLanguage(SettingsCache::instance().cardsDisplay().getCardLang()); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, deckListModel, + [this](const QString &lang) { deckListModel->setDisplayLanguage(lang); }); + connect(historyManager, &DeckListHistoryManager::undoRedoStateChanged, this, [this] { setModified(true); emit historyChanged(); @@ -260,7 +267,10 @@ bool DeckStateManager::swapCardAtIndex(const QModelIndex &idx) return false; } - QString cardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString(); + // The display role holds the localized card name; the edit role always carries the + // canonical English name needed to look the card up in the database. + QString displayCardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString(); + QString cardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); QString providerId = idx.siblingAtColumn(DeckListModelColumns::CARD_PROVIDER_ID).data().toString(); QModelIndex gparent = idx.parent().parent(); @@ -277,7 +287,7 @@ bool DeckStateManager::swapCardAtIndex(const QModelIndex &idx) QString reason = tr("Moved to %1 1 × \"%2\" (%3)") // .arg(otherZoneName) - .arg(cardName) + .arg(displayCardName) .arg(providerId); return modifyDeck(reason, [&idx, &cardName, &providerId, &otherZoneName](auto model) { @@ -291,9 +301,8 @@ bool DeckStateManager::removeCardAtIndex(const QModelIndex &idx) return false; } - QString cardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString(); - - QString reason = tr("Removed \"%1\" (all copies)").arg(cardName); + QString reason = + tr("Removed \"%1\" (all copies)").arg(idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString()); return modifyDeck(reason, [&idx](auto model) { return model->removeRow(idx.row(), idx.parent()); }); } diff --git a/cockatrice/src/interface/widgets/deck_share/deck_share_utils.cpp b/cockatrice/src/interface/widgets/deck_share/deck_share_utils.cpp new file mode 100644 index 000000000..4343f0aab --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_share/deck_share_utils.cpp @@ -0,0 +1,59 @@ +#include "deck_share_utils.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace DeckShareUtils +{ + +QString buildShareLink(const AbstractClient *client, const QString &token) +{ + QUrl url; + url.setScheme(QStringLiteral("cockatrice")); + url.setHost(QStringLiteral("opendeck")); + + QUrlQuery query; + query.addQueryItem(QStringLiteral("share"), token); + query.addQueryItem(QStringLiteral("hostname"), client->serverName()); + query.addQueryItem(QStringLiteral("port"), QString::number(client->serverPort())); + url.setQuery(query); + + return url.toString(QUrl::FullyEncoded); +} + +QString copyShareLinkToClipboard(const QString &link) +{ + QGuiApplication::clipboard()->setText(link); + return link; +} + +QString formatShareExpiry(const QDateTime &expiry) +{ + return QLocale().toString(expiry.toLocalTime(), QLocale::ShortFormat); +} + +ShareResponse handleShareResponse(const AbstractClient *client, const Response &response) +{ + const Response_DeckShareCreate &resp = response.GetExtension(Response_DeckShareCreate::ext); + const QString token = QString::fromStdString(resp.token()); + + const QString link = buildShareLink(client, token); + copyShareLinkToClipboard(link); + +#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0) + const QDateTime expiry = QDateTime::fromSecsSinceEpoch(resp.expires_at(), QTimeZone::UTC); +#else + const QDateTime expiry = QDateTime::fromSecsSinceEpoch(resp.expires_at(), Qt::UTC); +#endif + + return {link, expiry}; +} + +} // namespace DeckShareUtils diff --git a/cockatrice/src/interface/widgets/deck_share/deck_share_utils.h b/cockatrice/src/interface/widgets/deck_share/deck_share_utils.h new file mode 100644 index 000000000..c9f7afa8b --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_share/deck_share_utils.h @@ -0,0 +1,59 @@ +/** + * @file deck_share_utils.h + * @ingroup DeckShareWidgets + */ +//! \todo Document this file. + +#ifndef DECK_SHARE_UTILS_H +#define DECK_SHARE_UTILS_H + +#include +#include + +class AbstractClient; +class Response; + +/** + * @brief Shared helpers for creating temporary deck shares. + */ +namespace DeckShareUtils +{ + +/** + * @brief The outcome of a successful share-create response. + */ +struct ShareResponse +{ + QString link; ///< The share link that was copied to the clipboard. + QDateTime expiry; ///< When the share expires (UTC). +}; + +/** + * @brief Builds the cockatrice:// link for a freshly created deck share. + * @param client Used to embed the target server's hostname and port. + * @param token The share token from Response_DeckShareCreate. + */ +QString buildShareLink(const AbstractClient *client, const QString &token); + +/** + * @brief Copies the share link to the clipboard. + * @return The link that was copied. + */ +QString copyShareLinkToClipboard(const QString &link); + +/** + * @brief Formats the expiration timestamp for a share. + */ +QString formatShareExpiry(const QDateTime &expiry); + +/** + * @brief Handles a successful Response_DeckShareCreate: builds the share link, + * copies it to the clipboard, and derives the share expiry. + * @param client Used to embed the target server's hostname and port. + * @param response The successful response carrying the share token and expiry. + */ +ShareResponse handleShareResponse(const AbstractClient *client, const Response &response); + +} // namespace DeckShareUtils + +#endif // DECK_SHARE_UTILS_H diff --git a/cockatrice/src/interface/widgets/deck_share/share_bar_widget.cpp b/cockatrice/src/interface/widgets/deck_share/share_bar_widget.cpp new file mode 100644 index 000000000..5aca343ca --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_share/share_bar_widget.cpp @@ -0,0 +1,77 @@ +#include "share_bar_widget.h" + +#include +#include +#include +#include + +ShareBarWidget::ShareBarWidget(QWidget *parent) : QWidget(parent) +{ + auto *layout = new QHBoxLayout(this); + layout->setContentsMargins(12, 10, 12, 10); + layout->setSpacing(8); + + hintLabel = new QLabel(this); + hintLabel->setWordWrap(true); + + nameEdit = new QLineEdit(this); + nameEdit->setMaximumWidth(260); + + countLabel = new QLabel(this); + + cancelButton = new QPushButton(this); + connect(cancelButton, &QPushButton::clicked, this, &ShareBarWidget::cancelRequested); + + createButton = new QPushButton(this); + createButton->setDefault(true); + connect(createButton, &QPushButton::clicked, this, &ShareBarWidget::createRequested); + + layout->addWidget(hintLabel, 1); + layout->addWidget(nameEdit); + layout->addWidget(countLabel); + layout->addStretch(); + layout->addWidget(cancelButton); + layout->addWidget(createButton); + + setLayout(layout); + + retranslateUi(); +} + +void ShareBarWidget::retranslateUi() +{ + nameEdit->setPlaceholderText(tr("Share name")); + cancelButton->setText(tr("Cancel")); + createButton->setText(tr("Create share link")); +} + +QString ShareBarWidget::name() const +{ + return nameEdit->text().trimmed(); +} + +void ShareBarWidget::setName(const QString &value) +{ + nameEdit->setText(value); +} + +void ShareBarWidget::setCountText(const QString &text) +{ + countLabel->setText(text); +} + +void ShareBarWidget::setHintText(const QString &text, bool visible) +{ + hintLabel->setText(text); + hintLabel->setVisible(visible); +} + +void ShareBarWidget::setCreateEnabled(bool enabled) +{ + createButton->setEnabled(enabled); +} + +void ShareBarWidget::focusName() +{ + nameEdit->setFocus(); +} diff --git a/cockatrice/src/interface/widgets/deck_share/share_bar_widget.h b/cockatrice/src/interface/widgets/deck_share/share_bar_widget.h new file mode 100644 index 000000000..ede9fb41b --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_share/share_bar_widget.h @@ -0,0 +1,63 @@ +/** + * @file share_bar_widget.h + * @ingroup DeckShareWidgets + */ +//! \todo Document this file. + +#ifndef SHARE_BAR_WIDGET_H +#define SHARE_BAR_WIDGET_H + +#include + +class QLabel; +class QLineEdit; +class QPushButton; + +/** + * @brief The activated toolbar used to create a temporary deck share. + * + * A single reusable component shared by the local visual deck storage and the + * remote server deck storage tabs, so the share workflow renders identically in + * both places. It owns its own widgets, strings, and layout; the owning tab only + * sets the count/hint text and reacts to the create/cancel signals. + */ +class ShareBarWidget final : public QWidget +{ + Q_OBJECT + +public: + explicit ShareBarWidget(QWidget *parent = nullptr); + + void retranslateUi(); + + /** @return The trimmed name entered by the user. */ + [[nodiscard]] QString name() const; + + /** @brief Resets the name field to the given default. */ + void setName(const QString &name); + + /** @brief Sets the selected-count summary label text. */ + void setCountText(const QString &text); + + /** @brief Sets the explainer hint text, showing it when @p visible is true. */ + void setHintText(const QString &text, bool visible); + + /** @brief Enables or disables the create-share-link button (guards double submission). */ + void setCreateEnabled(bool enabled); + + /** @brief Moves keyboard focus to the name field. */ + void focusName(); + +signals: + void createRequested(); + void cancelRequested(); + +private: + QLabel *hintLabel; + QLineEdit *nameEdit; + QLabel *countLabel; + QPushButton *cancelButton; + QPushButton *createButton; +}; + +#endif // SHARE_BAR_WIDGET_H diff --git a/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.cpp b/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.cpp new file mode 100644 index 000000000..21ec80e08 --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.cpp @@ -0,0 +1,140 @@ +#include "shared_deck_preview_widget.h" + +#include "../cards/additional_info/color_identity_widget.h" +#include "../cards/deck_preview_card_picture_widget.h" + +#include +#include +#include +#include +#include +#include +#include + +SharedDeckPreviewWidget::SharedDeckPreviewWidget(QWidget *parent, + const CardDatabaseQuerier *querier, + const QString &deckName, + const QString &bannerCardName, + const QString &colorIdentity, + const QString &gameFormat, + const QString &deckToolTip) + : QWidget(parent) +{ + bannerCardDisplayWidget = + new DeckPreviewCardPictureWidget(this, false, false, Qt::white, Qt::black, 12, Qt::AlignCenter, true); + bannerCardDisplayWidget->setScaleFactor(100); + const ExactCard bannerCard = bannerCardName.isEmpty() ? ExactCard() : querier->getCard(CardRef{bannerCardName, {}}); + bannerCardDisplayWidget->setCard(bannerCard); + bannerCardDisplayWidget->setOverlayText(deckName); + setToolTip(deckToolTip.isEmpty() ? deckName : deckToolTip); + setFocusPolicy(Qt::StrongFocus); + setBaseAccessibleName(deckName); + + colorIdentityWidget = new ColorIdentityWidget(this, colorIdentity); + colorIdentityWidget->setVisible(!colorIdentity.isEmpty()); + + // gameFormat is server-supplied and the QLabel renders AutoText, so escape it. + gameFormatLabel = new QLabel(gameFormat.toHtmlEscaped(), this); + gameFormatLabel->setAlignment(Qt::AlignCenter); + gameFormatLabel->setVisible(!gameFormat.isEmpty()); + + selectionCheckBox = new QCheckBox(this); + selectionCheckBox->setToolTip(tr("Select this deck")); + // The tile itself is focusable (Space/Enter toggles); keep the checkbox + // from creating a second tab stop per tile. + selectionCheckBox->setFocusPolicy(Qt::NoFocus); + + // Selection frame reused from the deck-preview selection covenant: a + // palette(highlight) border around the banner card, shown while selected. + selectionFrame = new QFrame(bannerCardDisplayWidget); + selectionFrame->setAttribute(Qt::WA_TransparentForMouseEvents); + selectionFrame->setStyleSheet(QStringLiteral( + "QFrame { border: 2px solid palette(highlight); border-radius: 4px; background: transparent; }")); + selectionFrame->setVisible(false); + + auto *selectionRow = new QHBoxLayout; + selectionRow->addWidget(selectionCheckBox); + selectionRow->addStretch(1); + + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addLayout(selectionRow); + layout->addWidget(bannerCardDisplayWidget, 0, Qt::AlignHCenter); + layout->addWidget(colorIdentityWidget, 0, Qt::AlignHCenter); + layout->addWidget(gameFormatLabel, 0, Qt::AlignHCenter); + setLayout(layout); + + connect(selectionCheckBox, &QCheckBox::toggled, this, [this](bool checked) { + updateSelectionVisual(checked); + emit selectionToggled(checked); + }); + connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageClicked, this, + &SharedDeckPreviewWidget::toggleSelection); + connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageDoubleClicked, this, + &SharedDeckPreviewWidget::activate); +} + +bool SharedDeckPreviewWidget::isSelected() const +{ + return selectionCheckBox->isChecked(); +} + +void SharedDeckPreviewWidget::setSelected(bool selected) +{ + if (isSelected() == selected) { + return; + } + selectionCheckBox->setChecked(selected); +} + +void SharedDeckPreviewWidget::updateSelectionVisual(bool selected) +{ + selectionFrame->setVisible(selected); + selectionFrame->raise(); + if (selected) { + setAccessibleName(baseAccessibleName + tr(" (selected)")); + } else { + setAccessibleName(baseAccessibleName); + } +} + +void SharedDeckPreviewWidget::setBaseAccessibleName(const QString &name) +{ + baseAccessibleName = name; + setAccessibleName(name); +} + +void SharedDeckPreviewWidget::toggleSelection() +{ + setSelected(!isSelected()); +} + +void SharedDeckPreviewWidget::activate() +{ + setSelected(true); + emit activated(); +} + +void SharedDeckPreviewWidget::resizeEvent(QResizeEvent *event) +{ + QWidget::resizeEvent(event); + updateSelectionFrameGeometry(); +} + +void SharedDeckPreviewWidget::updateSelectionFrameGeometry() +{ + if (selectionFrame == nullptr || bannerCardDisplayWidget == nullptr) { + return; + } + selectionFrame->setGeometry(bannerCardDisplayWidget->rect().adjusted(1, 1, -1, -1)); +} + +void SharedDeckPreviewWidget::keyPressEvent(QKeyEvent *event) +{ + if (event->key() == Qt::Key_Space || event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) { + toggleSelection(); + event->accept(); + return; + } + QWidget::keyPressEvent(event); +} diff --git a/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.h b/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.h new file mode 100644 index 000000000..bd4c4ee5f --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.h @@ -0,0 +1,77 @@ +/** + * @file shared_deck_preview_widget.h + * @ingroup DeckShareWidgets + */ +//! \todo Document this file. + +#ifndef SHARED_DECK_PREVIEW_WIDGET_H +#define SHARED_DECK_PREVIEW_WIDGET_H + +#include + +class ColorIdentityWidget; +class DeckPreviewCardPictureWidget; +class QCheckBox; +class QFrame; +class QKeyEvent; +class QLabel; +class QResizeEvent; +class CardDatabaseQuerier; + +/** + * @brief A selectable preview tile for a deck that has no local file. + * + * Renders a banner card picture (looked up by name in the card database), the + * deck name, color identity and game format. Used to preview decks shared via a + * cockatrice:// link (metadata from Command_DeckShareList) and the deck + * currently open in the deck editor. + * + * Selection follows the deck-preview covenant: the tile reports its click + * immediately (no double-click interval delay), a palette(highlight) frame + * marks the selected tile, and Space/Enter toggles selection from the keyboard. + * A double click selects the tile and emits activated() so the caller can open + * just that deck. + */ +class SharedDeckPreviewWidget : public QWidget +{ + Q_OBJECT + +public: + explicit SharedDeckPreviewWidget(QWidget *parent, + const CardDatabaseQuerier *querier, + const QString &deckName, + const QString &bannerCardName, + const QString &colorIdentity, + const QString &gameFormat = QString(), + const QString &deckToolTip = QString()); + + [[nodiscard]] bool isSelected() const; + void setSelected(bool selected); + + void setBaseAccessibleName(const QString &name); + +signals: + void selectionToggled(bool selected); + void activated(); + +protected: + void resizeEvent(QResizeEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; + +private slots: + void toggleSelection(); + void activate(); + +private: + void updateSelectionVisual(bool selected); + void updateSelectionFrameGeometry(); + + DeckPreviewCardPictureWidget *bannerCardDisplayWidget; + ColorIdentityWidget *colorIdentityWidget; + QLabel *gameFormatLabel; + QCheckBox *selectionCheckBox; + QFrame *selectionFrame; + QString baseAccessibleName; +}; + +#endif // SHARED_DECK_PREVIEW_WIDGET_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.cpp new file mode 100644 index 000000000..86647ef26 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.cpp @@ -0,0 +1,50 @@ +#include "dlg_login_prompt.h" + +#include +#include +#include +#include +#include +#include + +DlgLoginPrompt::DlgLoginPrompt(const QString &serverText, QWidget *parent) : QDialog(parent) +{ + setWindowTitle(tr("Sign in")); + + auto *mainLayout = new QVBoxLayout(this); + mainLayout->addWidget( + new QLabel(tr("This link requires you to be signed in.\nSign in to %1:").arg(serverText), this)); + + auto *formLayout = new QFormLayout; + usernameEdit = new QLineEdit(this); + passwordEdit = new QLineEdit(this); + passwordEdit->setEchoMode(QLineEdit::Password); + formLayout->addRow(tr("Username:"), usernameEdit); + formLayout->addRow(tr("Password:"), passwordEdit); + mainLayout->addLayout(formLayout); + + savePasswordCheckBox = new QCheckBox(tr("Save password for this server"), this); + mainLayout->addWidget(savePasswordCheckBox); + + auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + mainLayout->addWidget(buttonBox); + + usernameEdit->setFocus(); +} + +QString DlgLoginPrompt::username() const +{ + return usernameEdit->text().trimmed(); +} + +QString DlgLoginPrompt::password() const +{ + return passwordEdit->text(); +} + +bool DlgLoginPrompt::savePassword() const +{ + return savePasswordCheckBox->isChecked(); +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.h b/cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.h new file mode 100644 index 000000000..e47924058 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.h @@ -0,0 +1,40 @@ +/** + * @file dlg_login_prompt.h + * @ingroup ConnectionDialogs + */ +//! \todo Document this file. + +#ifndef DLG_LOGIN_PROMPT_H +#define DLG_LOGIN_PROMPT_H + +#include + +class QCheckBox; +class QLineEdit; + +/** + * @brief Small sign-in dialog used when a cockatrice:// link needs credentials + * that are not saved for the target server. + * + * The entered name and password are handed to the intent chain; when the user + * opts to save them, they are stored in the server settings so that later links + * to the same server connect seamlessly. + */ +class DlgLoginPrompt : public QDialog +{ + Q_OBJECT + +public: + explicit DlgLoginPrompt(const QString &serverText, QWidget *parent = nullptr); + + [[nodiscard]] QString username() const; + [[nodiscard]] QString password() const; + [[nodiscard]] bool savePassword() const; + +private: + QLineEdit *usernameEdit; + QLineEdit *passwordEdit; + QCheckBox *savePasswordCheckBox; +}; + +#endif // DLG_LOGIN_PROMPT_H diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp index fb559fc4b..581920dbc 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp @@ -416,6 +416,11 @@ void DlgSettings::setTab(int index) } } +AbstractSettingsPage *DlgSettings::page(SettingsPage which) const +{ + return pages.value(static_cast(which)); +} + void DlgSettings::updateLanguage() { qApp->removeTranslator(translator); // NOLINT(cppcoreguidelines-pro-type-static-cast-downcast) diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_settings.h b/cockatrice/src/interface/widgets/dialogs/dlg_settings.h index b700f7af9..845c0b4d6 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.h @@ -54,6 +54,7 @@ public: explicit DlgSettings(QWidget *parent = nullptr); void setTab(int index); + AbstractSettingsPage *page(SettingsPage which) const; private slots: void onTabClicked(int index); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.cpp new file mode 100644 index 000000000..897d32062 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.cpp @@ -0,0 +1,96 @@ +#include "dlg_share_deck.h" + +#include "../../../client/settings/cache_settings.h" +#include "../cards/additional_info/deck_color_identity.h" +#include "../deck_share/deck_share_utils.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +DlgShareDeck::DlgShareDeck(AbstractClient *_client, const QSharedPointer &_deck, QWidget *_parent) + : QDialog(_parent), client(_client), deck(_deck), shareTimeoutTimer(new QTimer(this)) +{ + setWindowTitle(tr("Share deck")); + + auto *layout = new QVBoxLayout(this); + + nameEdit = new QLineEdit(this); + nameEdit->setText(tr("Shared deck")); + + auto *form = new QFormLayout; + form->addRow(tr("Share name:"), nameEdit); + layout->addLayout(form); + + auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Create share link")); + buttonBox->button(QDialogButtonBox::Cancel)->setText(tr("Cancel")); + connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgShareDeck::actShare); + connect(buttonBox, &QDialogButtonBox::rejected, this, &DlgShareDeck::reject); + this->buttonBox = buttonBox; + layout->addWidget(buttonBox); + + shareTimeoutTimer->setSingleShot(true); + shareTimeoutTimer->setInterval( + static_cast((static_cast(SettingsCache::instance().network().getTimeOut()) + 1) * + SettingsCache::instance().network().getKeepAlive() * 1000)); + connect(shareTimeoutTimer, &QTimer::timeout, this, &DlgShareDeck::onShareTimeout); +} + +void DlgShareDeck::actShare() +{ + buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false); + + Command_DeckShareCreate cmd; + cmd.set_name(nameEdit->text().trimmed().toStdString()); + if (cmd.name().empty()) { + cmd.set_name(tr("Shared deck").toStdString()); + } + + DeckShareItem *item = cmd.add_items(); + item->set_deck_list(deck->writeToString_Native().toStdString()); + item->set_color_identity(getDeckColorIdentity(*deck, CardDatabaseManager::query()).toStdString()); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &DlgShareDeck::shareFinished); + client->sendCommand(pend); + shareTimeoutTimer->start(); +} + +void DlgShareDeck::shareFinished(const Response &response, const CommandContainer & /*commandContainer*/) +{ + shareTimeoutTimer->stop(); + if (response.response_code() != Response::RespOk) { + buttonBox->button(QDialogButtonBox::Ok)->setEnabled(true); + QMessageBox::critical(this, tr("Share deck"), + tr("Failed to create the share link (server response code %1).") + .arg(QString::number(static_cast(response.response_code())))); + return; + } + + const DeckShareUtils::ShareResponse share = DeckShareUtils::handleShareResponse(client, response); + + QMessageBox::information(this, tr("Share deck"), + tr("Share link created and copied to the clipboard:\n\n%1\n\n" + "The share expires on %2.") + .arg(share.link, DeckShareUtils::formatShareExpiry(share.expiry))); + accept(); +} + +void DlgShareDeck::onShareTimeout() +{ + buttonBox->button(QDialogButtonBox::Ok)->setEnabled(true); + QMessageBox::warning(this, tr("Share deck"), tr("The server did not respond in time. Try again.")); +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.h b/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.h new file mode 100644 index 000000000..162fa3677 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.h @@ -0,0 +1,46 @@ +/** + * @file dlg_share_deck.h + * @ingroup Dialogs + */ +//! \todo Document this file. + +#ifndef DLG_SHARE_DECK_H +#define DLG_SHARE_DECK_H + +#include +#include + +class AbstractClient; +class CommandContainer; +class DeckList; +class QDialogButtonBox; +class QLineEdit; +class QTimer; +class Response; + +/** + * @brief Slim dialog to create a temporary share for the deck open in the editor. + * + * Asks for a share name, sends Command_DeckShareCreate for the single inline + * deck, and copies the resulting link to the clipboard. + */ +class DlgShareDeck : public QDialog +{ + Q_OBJECT +public: + DlgShareDeck(AbstractClient *_client, const QSharedPointer &_deck, QWidget *parent = nullptr); + +private slots: + void actShare(); + void shareFinished(const Response &response, const CommandContainer &commandContainer); + void onShareTimeout(); + +private: + AbstractClient *client; + QSharedPointer deck; + QLineEdit *nameEdit; + QDialogButtonBox *buttonBox; + QTimer *shareTimeoutTimer; +}; + +#endif // DLG_SHARE_DECK_H diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp new file mode 100644 index 000000000..cd7ecdcdd --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp @@ -0,0 +1,181 @@ +#include "dlg_shared_decks_preview.h" + +#include "../deck_share/shared_deck_preview_widget.h" +#include "../general/layout_containers/flow_widget.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +DlgSharedDecksPreview::DlgSharedDecksPreview(QWidget *parent, + const CardDatabaseQuerier *querier, + const QString &shareName, + qint64 expiresAt, + const QString &serverText, + const QList &items) + : QDialog(parent) +{ + setWindowTitle(tr("Open shared decks")); + resize(700, 500); + + auto *mainLayout = new QVBoxLayout(this); + + // shareName and serverText come from the share server, so escape them: the + // QLabels render AutoText and markup would otherwise be shown as rich text. + auto *titleLabel = + new QLabel(tr("Share: %1").arg((shareName.isEmpty() ? tr("Untitled") : shareName).toHtmlEscaped()), this); + QFont titleFont = titleLabel->font(); + titleFont.setBold(true); + titleFont.setPointSize(titleFont.pointSize() + 2); + titleLabel->setFont(titleFont); + mainLayout->addWidget(titleLabel); + + if (!serverText.isEmpty()) { + mainLayout->addWidget(new QLabel(tr("From %1").arg(serverText.toHtmlEscaped()), this)); + } + + if (expiresAt > 0) { + const QString expiryText = QDateTime::fromSecsSinceEpoch(expiresAt).toLocalTime().toString(Qt::TextDate); + mainLayout->addWidget(new QLabel(tr("This share link expires on %1").arg(expiryText), this)); + } + + downloadStatusLabel = new QLabel(this); + downloadStatusLabel->setVisible(false); + mainLayout->addWidget(downloadStatusLabel); + + flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); + mainLayout->addWidget(flowWidget, 1); + + for (const ServerInfo_DeckShareItem &item : items) { + QStringList tags; + for (const auto &tag : item.tags()) { + tags.append(QString::fromStdString(tag)); + } + + auto *tile = new SharedDeckPreviewWidget( + this, querier, QString::fromStdString(item.name()), QString::fromStdString(item.banner_card()), + QString::fromStdString(item.color_identity()), QString::fromStdString(item.game_format()), tags.join(", ")); + flowWidget->addNavigableWidget(tile); + tiles.append(tile); + itemIds.append(item.id()); + } + + if (tiles.size() == 1) { + tiles.first()->setSelected(true); + } + + auto *buttonBox = new QDialogButtonBox(this); + openSelectedButton = buttonBox->addButton(tr("Open selected"), QDialogButtonBox::AcceptRole); + openAllButton = buttonBox->addButton(tr("Open all"), QDialogButtonBox::ActionRole); + buttonBox->addButton(tr("Cancel"), QDialogButtonBox::RejectRole); + mainLayout->addWidget(buttonBox); + + connect(buttonBox, &QDialogButtonBox::rejected, this, [this]() { + onCancel(); + close(); + }); + + // Esc calls QDialog::reject() directly (which hides the dialog without a + // close event), so route it through the same guarded cancel as the button. + connect(this, &QDialog::rejected, this, [this]() { + onCancel(); + close(); + }); + + connect(openSelectedButton, &QPushButton::clicked, this, &DlgSharedDecksPreview::openSelected); + connect(buttonBox, &QDialogButtonBox::clicked, this, [this, buttonBox](QAbstractButton *button) { + if (buttonBox->buttonRole(button) == QDialogButtonBox::ActionRole) { + openAll(); + } + }); + + for (SharedDeckPreviewWidget *tile : tiles) { + connect(tile, &SharedDeckPreviewWidget::selectionToggled, this, + &DlgSharedDecksPreview::updateOpenSelectedEnabled); + } + for (int i = 0; i < tiles.size(); ++i) { + const int itemId = itemIds.at(i); + // Double-clicking a tile selects it and opens just that deck. + connect(tiles.at(i), &SharedDeckPreviewWidget::activated, this, [this, itemId]() { + resultEmitted = true; + setDownloading(true); + emit openRequested(QList{itemId}); + }); + } + updateOpenSelectedEnabled(); +} + +QList DlgSharedDecksPreview::selectedItemIds() const +{ + QList selectedIds; + for (int i = 0; i < tiles.size(); ++i) { + if (tiles.at(i)->isSelected()) { + selectedIds.append(itemIds.at(i)); + } + } + return selectedIds; +} + +void DlgSharedDecksPreview::openSelected() +{ + const QList selectedIds = selectedItemIds(); + if (selectedIds.isEmpty()) { + return; + } + resultEmitted = true; + setDownloading(true); + emit openRequested(selectedIds); +} + +void DlgSharedDecksPreview::openAll() +{ + resultEmitted = true; + setDownloading(true); + emit openRequested(itemIds); +} + +void DlgSharedDecksPreview::setDownloading(bool downloading) +{ + if (downloadInProgress == downloading) { + return; + } + downloadInProgress = downloading; + downloadStatusLabel->setVisible(downloading); + for (SharedDeckPreviewWidget *tile : tiles) { + tile->setEnabled(!downloading); + } + openSelectedButton->setEnabled(!downloading); + openAllButton->setEnabled(!downloading); +} + +void DlgSharedDecksPreview::setDownloadProgress(int done, int total, const QString ¤tDeckName) +{ + if (!downloadInProgress) { + return; + } + downloadStatusLabel->setText(tr("Downloading deck %1 of %2: %3").arg(done).arg(total).arg(currentDeckName)); +} + +void DlgSharedDecksPreview::updateOpenSelectedEnabled() +{ + openSelectedButton->setEnabled(!selectedItemIds().isEmpty()); +} + +void DlgSharedDecksPreview::onCancel() +{ + if (!resultEmitted || downloadInProgress) { + resultEmitted = true; + emit cancelled(); + } +} + +void DlgSharedDecksPreview::closeEvent(QCloseEvent *event) +{ + onCancel(); + QDialog::closeEvent(event); +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.h b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.h new file mode 100644 index 000000000..31820e3a0 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.h @@ -0,0 +1,68 @@ +#ifndef COCKATRICE_DLG_SHARED_DECKS_PREVIEW_H +#define COCKATRICE_DLG_SHARED_DECKS_PREVIEW_H + +#include +#include + +class FlowWidget; +class QCloseEvent; +class QLabel; +class QPushButton; +class ServerInfo_DeckShareItem; +class SharedDeckPreviewWidget; +class CardDatabaseQuerier; + +/** + * @brief Non-modal preview of the decks contained in a shared-deck link. + * + * Lets the user pick which of the shared decks to open before anything is + * downloaded. Emits openRequested with the ids of the chosen decks, or + * cancelled when the user closes the dialog without choosing. Once the user + * picks, the dialog switches into a "downloading" state: the tiles and open + * buttons are disabled, a progress label shows the current download and Cancel + * stays functional so the download can be aborted. + */ +class DlgSharedDecksPreview : public QDialog +{ + Q_OBJECT + +public: + explicit DlgSharedDecksPreview(QWidget *parent, + const CardDatabaseQuerier *querier, + const QString &shareName, + qint64 expiresAt, + const QString &serverText, + const QList &items); + + void setDownloadProgress(int done, int total, const QString ¤tDeckName); + +public slots: + void setDownloading(bool downloading); + +signals: + void openRequested(const QList &itemIds); + void cancelled(); + +protected: + void closeEvent(QCloseEvent *event) override; + +private slots: + void openSelected(); + void openAll(); + void updateOpenSelectedEnabled(); + void onCancel(); + +private: + QList selectedItemIds() const; + + FlowWidget *flowWidget; + QList tiles; + QList itemIds; + QPushButton *openSelectedButton; + QPushButton *openAllButton; + QLabel *downloadStatusLabel; + bool resultEmitted = false; + bool downloadInProgress = false; +}; + +#endif // COCKATRICE_DLG_SHARED_DECKS_PREVIEW_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/general/home_tab_button_color.h b/cockatrice/src/interface/widgets/general/home_tab_button_color.h index 1550b57e7..b45bd7a92 100644 --- a/cockatrice/src/interface/widgets/general/home_tab_button_color.h +++ b/cockatrice/src/interface/widgets/general/home_tab_button_color.h @@ -11,8 +11,8 @@ namespace HomeTabButtonColor */ enum Source { - Automatic, ///< Extract color from background, or use theme color if no background - FromBackground, ///< Always extract color from background + FromThemeColors, ///< Use the theme's identity accent colors + FromBackground, ///< Extract colour from the background image }; struct Entry @@ -23,7 +23,7 @@ struct Entry inline QList all() { - static QList entries = {{Automatic, QT_TR_NOOP("Automatic")}, + static QList entries = {{FromThemeColors, QT_TR_NOOP("From theme colors")}, {FromBackground, QT_TR_NOOP("Extract from background")}}; return entries; @@ -33,12 +33,12 @@ inline QList all() * Safely converts an int into the corresponding Source. * * @param value The int value - * @return The Source. Returns Source::Automatic if the value is not within range + * @return The Source. Returns Source::FromThemeColors if the value is not within range */ inline Source intToSource(int value) { if (value > FromBackground) { - return Automatic; // default + return FromThemeColors; // default } return static_cast(value); diff --git a/cockatrice/src/interface/widgets/general/home_widget.cpp b/cockatrice/src/interface/widgets/general/home_widget.cpp index 0d030b973..648d315f9 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.cpp +++ b/cockatrice/src/interface/widgets/general/home_widget.cpp @@ -11,6 +11,8 @@ #include "home_tab_button_color.h" #include +#include +#include #include #include #include @@ -21,8 +23,7 @@ #include HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor) - : QWidget(parent), tabSupervisor(_tabSupervisor), background(themePixmap(QStringLiteral("backgrounds/home"))), - overlay(themePixmap(QStringLiteral("cockatrice"))) + : QWidget(parent), tabSupervisor(_tabSupervisor), background(themePixmap(QStringLiteral("backgrounds/home"))) { layout = new QGridLayout(this); @@ -54,6 +55,8 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor) // Lambda is cleaner to read than overloading this connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabDisplayCardNameChanged, this, [this] { repaint(); }); + connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabBackgroundDimChanged, this, + [this] { repaint(); }); connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, &HomeWidget::initializeBackgroundFromSource); connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, @@ -61,12 +64,18 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor) // Scheme flips (light/dark/system with an OS switch) fire on themeManager, // not on SettingsCache::themeChanged, so re-resolve the variant background. connect(themeManager, &ThemeManager::themeChanged, this, &HomeWidget::initializeBackgroundFromSource); + connect(themeManager, &ThemeManager::paletteChanged, this, &HomeWidget::updateButtonsToBackgroundColor); + connect(themeManager, &ThemeManager::paletteChanged, this, &HomeWidget::updateLogoOverlay); connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabButtonColorChanged, this, &HomeWidget::updateButtonsToBackgroundColor); } void HomeWidget::initializeBackgroundFromSource() { + // The featured logo is theme/scheme-derived too; reload it alongside the + // background so a theme or appearance switch doesn't leave it stale. + updateLogoOverlay(); + if (CardDatabaseManager::getInstance()->getLoadStatus() != LoadStatus::Ok) { connect(CardDatabaseManager::getInstance(), &CardDatabase::cardDatabaseLoadingFinished, this, &HomeWidget::initializeBackgroundFromSource); @@ -105,32 +114,24 @@ void HomeWidget::loadBackgroundSourceDeck() backgroundSourceDeck = deckOpt.has_value() ? deckOpt.value().deckList : DeckList(); } -static bool isDefaultBackgroundAndTheme() +static QPair paletteDerivedButtonColors() { - QString sourceId = SettingsCache::instance().appearance().getHomeTabBackgroundSource(); - return themeManager->isBuiltInTheme() && BackgroundSources::fromId(sourceId) == BackgroundSources::Theme; + return {themeManager->appColor(AppColor::AccentStrong), themeManager->appColor(AppColor::AccentSoft)}; } QPair HomeWidget::determineButtonColor() const { - static QPair defaultColor = {QColor::fromRgb(20, 140, 60), QColor::fromRgb(120, 200, 80)}; - auto colorSource = HomeTabButtonColor::intToSource(SettingsCache::instance().appearance().getHomeTabButtonColorSourceIndex()); switch (colorSource) { - case HomeTabButtonColor::Automatic: { - if (isDefaultBackgroundAndTheme()) { - return defaultColor; - } else { - return extractDominantColors(background); - } - } + case HomeTabButtonColor::FromThemeColors: + return paletteDerivedButtonColors(); case HomeTabButtonColor::FromBackground: return extractDominantColors(background); } - return defaultColor; + return paletteDerivedButtonColors(); } void HomeWidget::setRandomCard(ExactCard &newCard) @@ -234,10 +235,10 @@ QGroupBox *HomeWidget::createButtons() QVBoxLayout *boxLayout = new QVBoxLayout; boxLayout->setAlignment(Qt::AlignHCenter); - QLabel *logoLabel = new QLabel; - logoLabel->setPixmap(overlay.scaledToWidth(200, Qt::SmoothTransformation)); + logoLabel = new QLabel; logoLabel->setAlignment(Qt::AlignCenter); boxLayout->addWidget(logoLabel); + updateLogoOverlay(); boxLayout->addSpacing(25); connectButton = new HomeStyledButton("Connect/Play", gradientColors); @@ -365,13 +366,15 @@ void HomeWidget::paintEvent(QPaintEvent *event) painter.drawPixmap(topLeft, toDraw); } - // Draw translucent black overlay with rounded corners - QRectF overlayRect(5, 5, width() - 10, height() - 10); - QPainterPath roundedRectPath; - roundedRectPath.addRoundedRect(overlayRect, 20, 20); + if (SettingsCache::instance().appearance().getHomeTabBackgroundDim()) { + // Draw translucent black overlay with rounded corners + QRectF overlayRect(5, 5, width() - 10, height() - 10); + QPainterPath roundedRectPath; + roundedRectPath.addRoundedRect(overlayRect, 20, 20); - QColor semiTransparentBlack(0, 0, 0, static_cast(255 * 0.33)); - painter.fillPath(roundedRectPath, semiTransparentBlack); + QColor semiTransparentBlack(0, 0, 0, static_cast(255 * 0.33)); + painter.fillPath(roundedRectPath, semiTransparentBlack); + } // Card name overlay (above the attribution, bottom-right) QString cardName; @@ -436,3 +439,56 @@ void HomeWidget::paintEvent(QPaintEvent *event) QWidget::paintEvent(event); } + +void HomeWidget::updateLogoOverlay() +{ + // Emulate cockatrice.svg in Qt rather than rendering the baked-in SVG. + // The SVG has no separate plate: the gradient fills the bird's silhouette + // paths (light #c9fd62/AccentSoft at the top-left, dark #139740/AccentStrong + // toward the bottom-right — the SVG's linearGradient4265-7-8 stops along + // its userSpaceOnUse axis), and the white highlight path + // (cockatrice-logo-white) sits on top. So we paint that gradient clipped to + // the full logo silhouette (the full-color logo's alpha), then overlay the + // white mark. Colours stay fully theme-driven and independent of the static + // greens baked into the SVG. + const QColor strong = themeManager->appColor(AppColor::AccentStrong); + const QColor soft = themeManager->appColor(AppColor::AccentSoft); + + const QPixmap silhouette = themePixmap(QStringLiteral("cockatrice")).scaledToWidth(200, Qt::SmoothTransformation); + const QPixmap whiteMark = + themePixmap(QStringLiteral("cockatrice-logo-white")).scaledToWidth(200, Qt::SmoothTransformation); + if (silhouette.isNull() || whiteMark.isNull()) { + return; + } + + QPixmap composite(silhouette.size()); + composite.fill(Qt::transparent); + + { + QPainter painter(&composite); + painter.setRenderHint(QPainter::Antialiasing); + painter.setRenderHint(QPainter::SmoothPixmapTransform); + + // Recreate cockatrice.svg's own gradient geometry (linearGradient + // 4265-7-8, userSpaceOnUse): light AccentSoft at S=(-8.097,-97.746), + // dark AccentStrong at E=(162.455,295.208), on the SVG's 300x300 + // canvas. Scale those coordinates to this composite's size. + const qreal scale = composite.width() / 300.0; + QLinearGradient gradient(QPointF(-8.097, -97.746) * scale, QPointF(162.455, 295.208) * scale); + gradient.setColorAt(0.0, soft); + gradient.setColorAt(1.0, strong); + painter.fillRect(composite.rect(), gradient); + + // Clip the gradient to the full logo silhouette exactly as the SVG's + // gradient paths are confined to the bird. + painter.setCompositionMode(QPainter::CompositionMode_DestinationIn); + painter.drawPixmap(0, 0, silhouette); + + painter.setCompositionMode(QPainter::CompositionMode_SourceOver); + painter.drawPixmap(0, 0, whiteMark); + } + + if (logoLabel) { + logoLabel->setPixmap(composite); + } +} diff --git a/cockatrice/src/interface/widgets/general/home_widget.h b/cockatrice/src/interface/widgets/general/home_widget.h index 9df0d7b6a..1cadc4a67 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.h +++ b/cockatrice/src/interface/widgets/general/home_widget.h @@ -15,6 +15,9 @@ #include #include +class QGridLayout; +class QLabel; + class HomeWidget : public QWidget { @@ -41,13 +44,14 @@ private: QPixmap background; CardInfoPictureArtCropWidget *backgroundSourceCard = nullptr; DeckList backgroundSourceDeck; - QPixmap overlay; + QLabel *logoLabel = nullptr; QPair gradientColors; HomeStyledButton *connectButton; void setRandomCard(ExactCard &newCard); void loadBackgroundSourceDeck(); QPair determineButtonColor() const; + void updateLogoOverlay(); }; #endif // HOME_WIDGET_H diff --git a/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp b/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp index 025f457bd..01c9ac34e 100644 --- a/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp +++ b/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp @@ -7,6 +7,7 @@ #include "flow_widget.h" #include +#include #include #include #include @@ -80,13 +81,35 @@ FlowWidget::FlowWidget(QWidget *parent, /** * @brief Adds a widget to the flow layout within the FlowWidget. * + * Plain widgets are not filtered for arrow keys: intercepting them would steal + * Up/Down/Left/Right from controls that use them (combo boxes, spin boxes + * etc.). Widgets that want keyboard navigation between flow items must be + * added via addNavigableWidget instead. + * * @param widget_to_add The widget to add to the flow layout. */ -void FlowWidget::addWidget(QWidget *widget_to_add) const +void FlowWidget::addWidget(QWidget *widget_to_add) { flowLayout->addWidget(widget_to_add); } +/** + * @brief Adds a widget and routes its arrow keys to FlowWidget focus navigation. + * + * The widget is filtered for arrow-key events so keyboard navigation between + * the flow items keeps working even when the flow sits inside a QScrollArea, + * which swallows arrow keys before they can reach FlowWidget::keyPressEvent. + * Only widgets added through this method are affected; anything that needs its + * own arrow keys should use plain addWidget. + * + * @param widget_to_add The widget to add to the flow layout. + */ +void FlowWidget::addNavigableWidget(QWidget *widget_to_add) +{ + widget_to_add->installEventFilter(this); + flowLayout->addWidget(widget_to_add); +} + void FlowWidget::insertWidgetAtIndex(QWidget *toInsert, int index) { flowLayout->insertWidgetAtIndex(toInsert, index); @@ -177,6 +200,66 @@ QLayoutItem *FlowWidget::itemAt(int index) const return flowLayout->itemAt(index); } +void FlowWidget::keyPressEvent(QKeyEvent *event) +{ + if (moveFocus(event)) { + event->accept(); + return; + } + QWidget::keyPressEvent(event); +} + +bool FlowWidget::eventFilter(QObject *watched, QEvent *event) +{ + if (event->type() == QEvent::KeyPress && moveFocus(static_cast(event))) { + return true; + } + return QWidget::eventFilter(watched, event); +} + +bool FlowWidget::moveFocus(QKeyEvent *event) +{ + // Keyboard navigation between the flow items: arrow keys move focus just + // like clicking the sibling tiles would. Only items that can take keyboard + // focus (e.g. the deck-preview tiles in shared-deck links) are visited. + const bool moveForward = event->key() == Qt::Key_Right || event->key() == Qt::Key_Down; + const bool moveBackward = event->key() == Qt::Key_Left || event->key() == Qt::Key_Up; + if (!moveForward && !moveBackward) { + return false; + } + + QList focusableItems; + for (int i = 0; i < flowLayout->count(); ++i) { + QWidget *item = flowLayout->itemAt(i)->widget(); + if (item != nullptr && (item->focusPolicy() & Qt::TabFocus)) { + focusableItems.append(item); + } + } + + if (focusableItems.isEmpty()) { + return false; + } + + int currentIndex = -1; + for (int i = 0; i < focusableItems.size(); ++i) { + if (focusableItems.at(i)->hasFocus()) { + currentIndex = i; + break; + } + } + + const int delta = moveForward ? 1 : -1; + int nextIndex; + if (currentIndex < 0) { + nextIndex = moveForward ? 0 : focusableItems.size() - 1; + } else { + nextIndex = (currentIndex + delta + focusableItems.size()) % focusableItems.size(); + } + focusableItems.value(nextIndex)->setFocus(); + event->accept(); + return true; +} + int FlowWidget::count() const { return flowLayout->count(); diff --git a/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.h b/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.h index a232336d8..4d52db3f1 100644 --- a/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.h +++ b/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.h @@ -11,6 +11,7 @@ #include "../../../layouts/flow_layout.h" #include +#include #include #include #include @@ -28,7 +29,8 @@ public: Qt::ScrollBarPolicy horizontalPolicy, Qt::ScrollBarPolicy verticalPolicy); - void addWidget(QWidget *widget_to_add) const; + void addWidget(QWidget *widget_to_add); + void addNavigableWidget(QWidget *widget_to_add); void insertWidgetAtIndex(QWidget *toInsert, int index); void removeWidget(QWidget *widgetToRemove) const; void clearLayout(); @@ -43,9 +45,15 @@ public slots: void setSpacing(int hSpacing, int vSpacing); protected: + bool eventFilter(QObject *watched, QEvent *event) override; void resizeEvent(QResizeEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; private: + /// @brief Moves keyboard focus to an adjacent flow item for an arrow-key event. + /// @return True when the event was an arrow key and was handled. + bool moveFocus(QKeyEvent *event); + Qt::Orientation flowDirection; QHBoxLayout *mainLayout; FlowLayout *flowLayout; diff --git a/cockatrice/src/interface/widgets/menus/deck_editor_menu.cpp b/cockatrice/src/interface/widgets/menus/deck_editor_menu.cpp index f057680be..6e27b4e42 100644 --- a/cockatrice/src/interface/widgets/menus/deck_editor_menu.cpp +++ b/cockatrice/src/interface/widgets/menus/deck_editor_menu.cpp @@ -28,6 +28,9 @@ DeckEditorMenu::DeckEditorMenu(AbstractTabDeckEditor *parent) : QMenu(parent), d aSaveDeckAs = new QAction(QString(), this); connect(aSaveDeckAs, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::actSaveDeckAs); + aShareDeck = new QAction(QString(), this); + connect(aShareDeck, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::actShareDeck); + aLoadDeckFromClipboard = new QAction(QString(), this); connect(aLoadDeckFromClipboard, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::actLoadDeckFromClipboard); @@ -96,6 +99,7 @@ DeckEditorMenu::DeckEditorMenu(AbstractTabDeckEditor *parent) : QMenu(parent), d addMenu(loadRecentDeckMenu); addAction(aSaveDeck); addAction(aSaveDeckAs); + addAction(aShareDeck); addSeparator(); addAction(aLoadDeckFromClipboard); addMenu(editDeckInClipboardMenu); @@ -120,6 +124,7 @@ void DeckEditorMenu::setSaveStatus(bool newStatus) { aSaveDeck->setEnabled(newStatus); aSaveDeckAs->setEnabled(newStatus); + aShareDeck->setEnabled(newStatus); aSaveDeckToClipboard->setEnabled(newStatus); aSaveDeckToClipboardNoSetInfo->setEnabled(newStatus); aSaveDeckToClipboardRaw->setEnabled(newStatus); @@ -157,6 +162,7 @@ void DeckEditorMenu::retranslateUi() aClearRecents->setText(tr("Clear")); aSaveDeck->setText(tr("&Save deck")); aSaveDeckAs->setText(tr("Save deck &as...")); + aShareDeck->setText(tr("Share deck...")); aLoadDeckFromClipboard->setText(tr("Load deck from cl&ipboard...")); diff --git a/cockatrice/src/interface/widgets/menus/deck_editor_menu.h b/cockatrice/src/interface/widgets/menus/deck_editor_menu.h index eff9257bb..08ce7bba1 100644 --- a/cockatrice/src/interface/widgets/menus/deck_editor_menu.h +++ b/cockatrice/src/interface/widgets/menus/deck_editor_menu.h @@ -21,7 +21,8 @@ public: QAction *aNewDeck, *aLoadDeck, *aClearRecents, *aSaveDeck, *aSaveDeckAs, *aLoadDeckFromClipboard, *aEditDeckInClipboard, *aEditDeckInClipboardRaw, *aSaveDeckToClipboard, *aSaveDeckToClipboardNoSetInfo, *aSaveDeckToClipboardRaw, *aSaveDeckToClipboardRawNoSetInfo, *aPrintDeck, *aLoadDeckFromWebsite, - *aExportDeckDecklist, *aExportDeckDecklistXyz, *aAnalyzeDeckDeckstats, *aAnalyzeDeckTappedout, *aClose; + *aExportDeckDecklist, *aExportDeckDecklistXyz, *aAnalyzeDeckDeckstats, *aAnalyzeDeckTappedout, *aShareDeck, + *aClose; QMenu *loadRecentDeckMenu, *analyzeDeckMenu, *editDeckInClipboardMenu, *saveDeckToClipboardMenu; void setSaveStatus(bool newStatus); diff --git a/cockatrice/src/interface/widgets/onboarding/banner_shader_config.h b/cockatrice/src/interface/widgets/onboarding/banner_shader_config.h index 32f3e89c0..6008044ff 100644 --- a/cockatrice/src/interface/widgets/onboarding/banner_shader_config.h +++ b/cockatrice/src/interface/widgets/onboarding/banner_shader_config.h @@ -38,6 +38,10 @@ class BannerShaderConfig : public QObject Q_PROPERTY(QColor colorA READ colorA WRITE setColorA NOTIFY colorAChanged) Q_PROPERTY(QColor colorB READ colorB WRITE setColorB NOTIFY colorBChanged) Q_PROPERTY(QColor accent READ accent WRITE setAccent NOTIFY accentChanged) + Q_PROPERTY(QColor glowColor READ glowColor WRITE setGlowColor NOTIFY glowColorChanged) + Q_PROPERTY(QColor brandStrong READ brandStrong WRITE setBrandStrong NOTIFY brandStrongChanged) + Q_PROPERTY(QColor brandSoft READ brandSoft WRITE setBrandSoft NOTIFY brandSoftChanged) + Q_PROPERTY(qreal vignetteMin READ vignetteMin WRITE setVignetteMin NOTIFY vignetteMinChanged) Q_PROPERTY(bool logoVisible READ logoVisible WRITE setLogoVisible NOTIFY logoVisibleChanged) Q_PROPERTY(qreal logoGlow READ logoGlow WRITE setLogoGlow NOTIFY logoGlowChanged) @@ -185,6 +189,54 @@ public: } } + QColor glowColor() const + { + return m_glowColor; + } + void setGlowColor(const QColor &c) + { + if (c != m_glowColor) { + m_glowColor = c; + emit glowColorChanged(); + } + } + + QColor brandStrong() const + { + return m_brandStrong; + } + void setBrandStrong(const QColor &c) + { + if (c != m_brandStrong) { + m_brandStrong = c; + emit brandStrongChanged(); + } + } + + QColor brandSoft() const + { + return m_brandSoft; + } + void setBrandSoft(const QColor &c) + { + if (c != m_brandSoft) { + m_brandSoft = c; + emit brandSoftChanged(); + } + } + + qreal vignetteMin() const + { + return m_vignetteMin; + } + void setVignetteMin(qreal v) + { + if (v != m_vignetteMin) { + m_vignetteMin = v; + emit vignetteMinChanged(); + } + } + bool logoVisible() const { return m_logoVisible; @@ -222,6 +274,10 @@ signals: void colorAChanged(); void colorBChanged(); void accentChanged(); + void glowColorChanged(); + void brandStrongChanged(); + void brandSoftChanged(); + void vignetteMinChanged(); void logoVisibleChanged(); void logoGlowChanged(); @@ -239,9 +295,16 @@ private: bool m_frontIsA = true; + // Curated fallback seed values -- BannerHost overwrites these with + // palette-derived colours (see shader_banner_widget.cpp) before the first + // paint, so they only matter as a safe pre-first-apply default. QColor m_colorA{0x1A, 0x1A, 0x20}; QColor m_colorB{0x0E, 0x0E, 0x12}; QColor m_accent{0x8B, 0xDD, 0x6B}; + QColor m_glowColor{Qt::white}; + QColor m_brandStrong{0x13, 0x97, 0x40}; + QColor m_brandSoft{0xC9, 0xFD, 0x62}; + qreal m_vignetteMin = 0.62; bool m_logoVisible = false; qreal m_logoGlow = 1.0; diff --git a/cockatrice/src/interface/widgets/onboarding/brand_colors.h b/cockatrice/src/interface/widgets/onboarding/brand_colors.h new file mode 100644 index 000000000..bf173270b --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/brand_colors.h @@ -0,0 +1,15 @@ +#ifndef BRAND_COLORS_H +#define BRAND_COLORS_H + +#include + +/** @brief Cockatrice brand green. + * + * Single source of truth for the onboarding brand accent: it backs the + * banner's shader-accent uniform as the curated fallback when the active + * palette resolves no usable Highlight, and it preseads the wizard's + * QuickSetupPanel so a freshly generated palette keeps the brand identity + * until the user picks their own look. */ +inline const QColor kCockatriceBrandGreen(0x8B, 0xDD, 0x6B); + +#endif // BRAND_COLORS_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.cpp index 3293b19ac..797fe4425 100644 --- a/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.cpp +++ b/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.cpp @@ -14,10 +14,31 @@ #include #include #include +#include #include #include #include +namespace +{ +/** @brief A theme's shipped identity accent, immune to any user- or auto- + * generated palette that may currently be masking appColor(). */ +QColor themeIdentityAccent(const QString &themeDirPath, const QString &themeName) +{ + for (const QString &scheme : {QStringLiteral("Light"), QStringLiteral("Dark")}) { + const PaletteConfig cfg = ThemeManager::loadDefaultPaletteConfig(themeDirPath, themeName, scheme); + if (cfg.appColors.contains(AppColor::AccentStrong)) { + return cfg.appColors.value(AppColor::AccentStrong); + } + const QColor highlight = cfg.colors.value(QPalette::Active).value(QPalette::Highlight); + if (highlight.isValid()) { + return highlight; + } + } + return {}; +} +} // namespace + ThemeSetupPage::ThemeSetupPage(QWidget *parent) : FirstRunWizardPage(parent) { themeCombo = new QComboBox(this); @@ -30,6 +51,15 @@ ThemeSetupPage::ThemeSetupPage(QWidget *parent) : FirstRunWizardPage(parent) quickSetupPanel = new QuickSetupPanel(this); + // Seed the picker from the current theme's own identity accent rather than + // a hardcoded brand green: Plasma seeds violet, Fusion green, etc., and it + // is immune to stale generated palettes that may mask appColor(). This was + // initially a brand-green workaround from before Fusion became the default. + // setAccentColor blocks signals, so this never triggers a generation. + lastSeededTheme = SettingsCache::instance().getThemeName(); + quickSetupPanel->setAccentColor( + themeIdentityAccent(themeManager->getAvailableThemes().value(lastSeededTheme), lastSeededTheme)); + connect(themeCombo, QOverload::of(&QComboBox::currentIndexChanged), this, &ThemeSetupPage::onThemeChanged); connect(schemeCombo, QOverload::of(&QComboBox::currentIndexChanged), this, &ThemeSetupPage::onSchemeChanged); connect(quickSetupPanel, &QuickSetupPanel::valueChanged, this, &ThemeSetupPage::onGenerateFromAccent); @@ -47,7 +77,8 @@ ThemeSetupPage::ThemeSetupPage(QWidget *parent) : FirstRunWizardPage(parent) // Mirrors AppearanceSettingsPage's identical listener for the combo-sync // half of this. connect(themeManager, &ThemeManager::themeChanged, this, [this] { - const QString newDir = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName()); + const QString newTheme = SettingsCache::instance().getThemeName(); + const QString newDir = themeManager->getAvailableThemes().value(newTheme); const ThemeConfig cfg = ThemeConfig::fromThemeDir(newDir); const QString current = cfg.colorScheme; @@ -56,6 +87,14 @@ ThemeSetupPage::ThemeSetupPage(QWidget *parent) : FirstRunWizardPage(parent) schemeCombo->setCurrentIndex(idx >= 0 ? idx : 0); schemeCombo->blockSignals(false); + // Keep the picker's accent in step with the theme's own identity; the + // swatch seeded at construction would otherwise stay stale (e.g. green + // from a previous theme) when the user toggles themes. + if (newTheme != lastSeededTheme) { + lastSeededTheme = newTheme; + quickSetupPanel->setAccentColor(themeIdentityAccent(newDir, newTheme)); + } + maybeAutoGeneratePalette(); }); @@ -158,8 +197,14 @@ void ThemeSetupPage::maybeAutoGeneratePalette() const QString dirPath = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName()); const QString scheme = resolvedScheme(); + // The theme dir may resolve to the user profile even for built-in themes + // (getAvailableThemes gives the user copy precedence), so consult the + // shipped palette too -- both via loadDefaultPaletteConfig's system fallback. + // Without it, scheme flips regenerate a fresh palette from the picker accent + // and clobber the curated colours the theme explicitly ships. if (PaletteConfig::fromScheme(dirPath, scheme).hasPalette() || - PaletteConfig::fromDefault(dirPath, scheme).hasPalette()) { + ThemeManager::loadDefaultPaletteConfig(dirPath, SettingsCache::instance().getThemeName(), scheme) + .hasPalette()) { return; // theme already has something real to show -- leave it alone } diff --git a/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.h b/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.h index d1f84c1b9..16a3c9a5d 100644 --- a/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.h +++ b/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.h @@ -53,6 +53,9 @@ private: QComboBox *homeTabBackgroundCombo; bool paletteDirty = false; + + /// Theme whose identity accent currently seeds the picker. + QString lastSeededTheme; }; #endif // THEME_SETUP_PAGE_H diff --git a/cockatrice/src/interface/widgets/onboarding/qml/BrandBanner.qml b/cockatrice/src/interface/widgets/onboarding/qml/BrandBanner.qml index f1a385cad..d94e15280 100644 --- a/cockatrice/src/interface/widgets/onboarding/qml/BrandBanner.qml +++ b/cockatrice/src/interface/widgets/onboarding/qml/BrandBanner.qml @@ -16,6 +16,8 @@ Item { property vector4d uColorA: Qt.vector4d(bannerConfig.colorA.r, bannerConfig.colorA.g, bannerConfig.colorA.b, 1.0) property vector4d uColorB: Qt.vector4d(bannerConfig.colorB.r, bannerConfig.colorB.g, bannerConfig.colorB.b, 1.0) property vector4d uAccent: Qt.vector4d(bannerConfig.accent.r, bannerConfig.accent.g, bannerConfig.accent.b, 1.0) + property vector4d uGlowColor: Qt.vector4d(bannerConfig.glowColor.r, bannerConfig.glowColor.g, bannerConfig.glowColor.b, 1.0) + property real uVignetteMin: bannerConfig.vignetteMin property real uLogoGlow: bannerConfig.logoGlow fragmentShader: "qrc:/onboarding/shaders/brand_banner.frag.qsb" } @@ -33,30 +35,62 @@ Item { property vector4d uColorA: Qt.vector4d(bannerConfig.colorA.r, bannerConfig.colorA.g, bannerConfig.colorA.b, 1.0) property vector4d uColorB: Qt.vector4d(bannerConfig.colorB.r, bannerConfig.colorB.g, bannerConfig.colorB.b, 1.0) property vector4d uAccent: Qt.vector4d(bannerConfig.accent.r, bannerConfig.accent.g, bannerConfig.accent.b, 1.0) + property vector4d uGlowColor: Qt.vector4d(bannerConfig.glowColor.r, bannerConfig.glowColor.g, bannerConfig.glowColor.b, 1.0) + property real uVignetteMin: bannerConfig.vignetteMin property real uLogoGlow: bannerConfig.logoGlow fragmentShader: "qrc:/onboarding/shaders/brand_banner.frag.qsb" } - // The hero logo itself — breathes cleanly over a 0.5–1.0 opacity range - Image { - id: logo - anchors.centerIn: parent + // The white logo sits at full opacity on top of the static gradient plate — + // no glow, no breathing. The plate matches home_widget's QPainter composite. + Item { + id: logoHost visible: bannerConfig.logoVisible - source: "qrc:/resources/cockatrice-logo-white.svg" + anchors.centerIn: parent width: root.height * 0.6 - height: width * (sourceSize.height > 0 ? sourceSize.height / Math.max(sourceSize.width, 1) : 1) - fillMode: Image.PreserveAspectFit - smooth: true - opacity: 0.5 + 0.5 * bannerConfig.logoGlow - sourceSize: Qt.size(256, 256) + height: width - Behavior on opacity { NumberAnimation { duration: 300; easing.type: Easing.InOutSine } } + // The full-color logo renders beneath the white mark and is consumed as + // a texture (layer.enabled) by the plate shader's silhouette mask, so + // the gradient is clipped to the bird exactly as the SVG's gradient + // paths are. It is never drawn to the screen itself. + Image { + id: silhouetteMask + anchors.fill: parent + source: "qrc:/resources/cockatrice.svg" + sourceSize: Qt.size(256, 256) + fillMode: Image.PreserveAspectFit + smooth: true + visible: false + layer.enabled: true + layer.smooth: true + } - transform: Scale { - origin.x: logo.width / 2 - origin.y: logo.height / 2 - xScale: 0.94 + 0.06 * bannerConfig.logoGlow - yScale: 0.94 + 0.06 * bannerConfig.logoGlow + // The logo's gradient plate, drawn behind the mark: a linear + // AccentSoft (light) -> AccentStrong (dark) sheet along the same + // top-left -> bottom-right userSpaceOnUse axis the baked-in SVG used, + // clipped to the bird silhouette via uSilhouette. The white highlight + // path above is theme independent. Sized to the logo itself — no + // rounded badge, matching home_widget's QPainter composite. Static. + // Small ShaderEffect, Qt 6.4-safe. + ShaderEffect { + id: brandPlate + anchors.fill: parent + property vector4d uStrong: Qt.vector4d(bannerConfig.brandStrong.r, bannerConfig.brandStrong.g, + bannerConfig.brandStrong.b, 1.0) + property vector4d uSoft: Qt.vector4d(bannerConfig.brandSoft.r, bannerConfig.brandSoft.g, + bannerConfig.brandSoft.b, 1.0) + property var uSilhouette: silhouetteMask + fragmentShader: "qrc:/onboarding/shaders/brand_plate.frag.qsb" + } + + Image { + id: logoImage + anchors.fill: parent + source: "qrc:/resources/cockatrice-logo-white.svg" + sourceSize: Qt.size(256, 256) + fillMode: Image.PreserveAspectFit + smooth: true } } -} +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp index fd1fb2a98..0b9f65783 100644 --- a/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp +++ b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp @@ -1,7 +1,10 @@ #include "shader_banner_widget.h" +#include "../../theme_manager.h" #include "banner_shader_config.h" +#include "brand_colors.h" +#include #include #include #include @@ -11,11 +14,98 @@ namespace { -// Near-black base palette -- the background is dark and quiet so the green -// accent stands out. -constexpr QRgb kColorA = 0x1A1A20; -constexpr QRgb kColorB = 0x0E0E12; -constexpr QRgb kAccent = 0x8BDD6B; +// Curated near-black stage -- used only when the active palette resolves no +// usable window colour. Matches the banner's original design (dark and quiet +// so the accent stands out) and satisfies design-plans §2.1's "identity +// survives a bare palette". +constexpr QRgb kFallbackColorA = 0x1A1A20; +constexpr QRgb kFallbackColorB = 0x0E0E12; + +struct SuggestedColors +{ + QColor colorA; + QColor colorB; + QColor accent; + QColor glowColor; + QColor brandStrong; + QColor brandSoft; + qreal vignetteMin = 0.62; +}; + +SuggestedColors suggestedBannerColors() +{ + const QPalette &pal = qApp->palette(); + const QColor window = pal.color(QPalette::Active, QPalette::Window); + // Identity accent: the theme's [AppColors] AccentStrong, which appColor() + // resolves to QPalette::Highlight when a theme doesn't pin AccentStrong. + // Reading bare Highlight ignored curated accent tokens (Plasma's violet + // vs Default's green) whenever a palette didn't set the role itself. + const QColor accentStrong = themeManager->appColor(AppColor::AccentStrong); + if (!window.isValid() || !accentStrong.isValid()) { + return {QColor(kFallbackColorA), + QColor(kFallbackColorB), + kCockatriceBrandGreen, + QColor(Qt::white), + kCockatriceBrandGreen, + QColor(0xC9, 0xFD, 0x62), + 0.62}; + } + + // The theme's brand pair: AccentStrong is the deep green, AccentSoft the + // lime. These two appColors form the logo's "surrounding gradient" (deep + // core grading out to the soft, brand-toned glow) on both the banner and + // the home screen. + const QColor brandStrong = accentStrong; + const QColor brandSoft = themeManager->appColor(AppColor::AccentSoft); + + // Dress the stage for the scheme so the banner never fights the + // surrounding window in either mode. Dark palettes keep the original + // quiet near-black stage (lightness 29 → 16) with the theme's window + // hue; light palettes get a pastel "frosted accent" treatment built from + // the accent hue instead of a plain near-white copy: a coloured wash that + // clearly belongs to the theme. + const qreal luma = 0.299 * window.red() + 0.587 * window.green() + 0.114 * window.blue(); + const bool lightStage = luma > 115.0; + if (lightStage) { + const int hue = accentStrong.hslHue(); + // Achromatic accents (grey) get a neutral near-white stage instead. + const int stageSat = hue < 0 ? 0 : 64; + const int hueSafe = hue < 0 ? 0 : hue; + // Depth is what stops a light stage reading as a washed-out near-white + // copy of the page behind the banner: deepen the lower pastel band and + // raise saturation so the hue is clearly present while staying frosted. + auto pastel = [hueSafe, stageSat](int lightness) { return QColor::fromHsl(hueSafe, stageSat, lightness); }; + auto pastelLower = [hueSafe](int lightness) { return QColor::fromHsl(hueSafe, 76, lightness); }; + // Brightness-lifted accent for additive glows: the raw accent on a + // light stage must be mid-bright to read instead of washing out, so + // lift lightness and saturation together. + const int accentLightness = qBound(158, accentStrong.lightness() + 82, 198); + const int accentSaturation = hue < 0 ? 0 : qMax(accentStrong.hslSaturation(), 180); + const QColor liftedAccent = + hue < 0 ? accentStrong : QColor::fromHsl(hueSafe, accentSaturation, accentLightness); + // The centre glow (and logo tint in QML) uses the deep accent itself: + // a coloured halo/fill behind the logo instead of a white or black one. + return {pastel(214), pastelLower(186), liftedAccent, accentStrong, brandStrong, brandSoft, 0.80}; + } + + // Dark stage: force the window hue down to the banner's curated darkness, + // scaling saturation away so chromatic palettes tint it without going + // muddy. The accent is the bright, brand-driven tone (hue from the accent + // itself, never the -- often grey -- window), and it drives both the + // embers/fog and the logo glow so the mark tints like the light stage. + auto stage = [&window](int lightness) { + const int hue = window.hslHue(); + const int saturation = hue < 0 ? 0 : qBound(0, qRound(window.hslSaturation() * (lightness / 40.0)), 255); + return QColor::fromHsl(hue, saturation, lightness); + }; + const int accentHue = accentStrong.hslHue(); + const int accentHueSafe = accentHue < 0 ? 0 : accentHue; + const int accentLightness = qBound(150, accentStrong.lightness() + 70, 185); + const int accentSaturation = accentHue < 0 ? 0 : qMax(accentStrong.hslSaturation(), 160); + const QColor accent = + accentHue < 0 ? accentStrong : QColor::fromHsl(accentHueSafe, accentSaturation, accentLightness); + return {stage(29), stage(16), accent, accent, brandStrong, brandSoft, 0.62}; +} } // namespace class GradientFallbackWidget : public QWidget @@ -23,15 +113,27 @@ class GradientFallbackWidget : public QWidget public: using QWidget::QWidget; + void setColors(const QColor &a, const QColor &b) + { + if (a != colorA || b != colorB) { + colorA = a; + colorB = b; + } + } + protected: void paintEvent(QPaintEvent *) override { QPainter painter(this); QLinearGradient gradient(0, 0, width(), height()); - gradient.setColorAt(0.0, QColor(kColorA)); - gradient.setColorAt(1.0, QColor(kColorB)); + gradient.setColorAt(0.0, colorA); + gradient.setColorAt(1.0, colorB); painter.fillRect(rect(), gradient); } + +private: + QColor colorA{QColor(kFallbackColorA)}; + QColor colorB{QColor(kFallbackColorB)}; }; BannerHost::BannerHost(QWidget *parent) : QWidget(parent) @@ -62,6 +164,9 @@ BannerHost::BannerHost(QWidget *parent) : QWidget(parent) connect(&clock, &QTimer::timeout, this, &BannerHost::tick); clock.setInterval(16); // ~60fps; the shader itself is cheap, this is just a wall clock + connect(themeManager, &ThemeManager::themeChanged, this, &BannerHost::applyThemeColors); + applyThemeColors(); + applyMotifPreset(currentMotif); updateAspect(); } @@ -123,9 +228,9 @@ void BannerHost::applyMotifPreset(Motif motif) const Preset p = presetFor(motif); - config->setColorA(QColor(kColorA)); - config->setColorB(QColor(kColorB)); - config->setAccent(QColor(kAccent)); + config->setColorA(bannerColorA); + config->setColorB(bannerColorB); + config->setAccent(bannerAccent); config->setLogoVisible(motif == Motif::Welcome); if (isFirstApply) { @@ -183,8 +288,34 @@ void BannerHost::hideEvent(QHideEvent *event) clock.stop(); } +void BannerHost::applyThemeColors() +{ + const SuggestedColors colors = suggestedBannerColors(); + bannerColorA = colors.colorA; + bannerColorB = colors.colorB; + bannerAccent = colors.accent; + + if (usingFallback) { + fallback->setColors(bannerColorA, bannerColorB); + fallback->update(); + } else if (config) { + config->setColorA(bannerColorA); + config->setColorB(bannerColorB); + config->setAccent(bannerAccent); + config->setGlowColor(colors.glowColor); + config->setBrandStrong(colors.brandStrong); + config->setBrandSoft(colors.brandSoft); + config->setVignetteMin(colors.vignetteMin); + } +} + void BannerHost::tick() { + // Palette previews (e.g. accent drags in the wizard's QuickSetupPanel) + // apply qApp->palette() without firing themeChanged, so re-derive here; + // BannerShaderConfig's setters are equality-guarded, so this is a no-op + // unless the colours actually changed. + applyThemeColors(); if (config) { qreal t = elapsed.elapsed() / 1000.0; config->setTime(t); diff --git a/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.h b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.h index 2e230ad7f..ac47b1141 100644 --- a/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.h +++ b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.h @@ -1,6 +1,7 @@ #ifndef SHADER_BANNER_WIDGET_H #define SHADER_BANNER_WIDGET_H +#include #include #include #include @@ -53,6 +54,7 @@ protected: private slots: void tick(); void onSceneGraphFailed(); + void applyThemeColors(); private: struct Preset @@ -73,6 +75,12 @@ private: BannerShaderConfig *config = nullptr; GradientFallbackWidget *fallback = nullptr; + // Palette-derived banner colours -- the theme's window hue forced down to + // the banner's curated darkness, plus the theme's Highlight as accent. + QColor bannerColorA; + QColor bannerColorB; + QColor bannerAccent; + QTimer clock; QElapsedTimer elapsed; Motif currentMotif = Motif::Welcome; diff --git a/cockatrice/src/interface/widgets/onboarding/shaders/brand_banner.frag b/cockatrice/src/interface/widgets/onboarding/shaders/brand_banner.frag index 508bd4bc4..2bf6d0abf 100644 --- a/cockatrice/src/interface/widgets/onboarding/shaders/brand_banner.frag +++ b/cockatrice/src/interface/widgets/onboarding/shaders/brand_banner.frag @@ -28,6 +28,8 @@ layout(std140, binding = 0) uniform buf vec4 uColorA; vec4 uColorB; vec4 uAccent; + vec4 uGlowColor; + float uVignetteMin; float uLogoGlow; }; @@ -119,7 +121,7 @@ vec3 backgroundField(vec2 uv, float time) // Accent-coloured fog layer: flowNoise peaks above 0.6 contribute accent float fog = flowNoise(uv * 0.8 + vec2(100.0, 50.0), time * 0.02); - col += uAccent.rgb * max(fog - 0.6, 0.0) * 0.10; + col += uAccent.rgb * max(fog - 0.6, 0.0) * 0.14; return col; } @@ -136,14 +138,17 @@ vec3 motifWelcome(vec2 uv, vec3 bg, float t) vec2 center = vec2(asp * 0.5, 0.5); float cDist = length(ac - center); - // Centre bloom at logo position; intensity scales with uLogoGlow + // Centre bloom at logo position; intensity scales with uLogoGlow. The + // QML brandGlow halo now supplies the primary logo surround (the two + // brand appColors), so this shader bloom is deliberately kept as a subtle + // ambience rather than a competing glow. float centreLight = bloom(cDist, 0.08 * asp, 0.40 * asp); - col += centreLight * 0.20 * uLogoGlow; + col += uGlowColor.rgb * centreLight * 0.20 * uLogoGlow; // Flow-noise shimmer gated by Gaussian mask at centre float shimmer = flowNoise(ac * 0.8 + vec2(55.0, 33.0), t * 0.05) * 0.5 + 0.5; float shimmerMask = exp(-(cDist * cDist) / (0.18 * asp * 0.18 * asp)); - col += shimmer * shimmerMask * 0.04 * uLogoGlow; + col += uGlowColor.rgb * shimmer * shimmerMask * 0.04 * uLogoGlow; // 48 ember particles: hash-seeded position, speed, size, brightness. // Embers within a distance threshold of centre are deflected into an @@ -162,8 +167,8 @@ vec3 motifWelcome(vec2 uv, vec3 bg, float t) float pX = baseX * asp + sin(t * driftFreq + fi * 1.7) * driftAmp * asp; float pY = fract(baseY + t * riseSpeed); - float size = 0.006 + hash21(vec2(fi * 2.9, uSeed * 4.7)) * 0.012; - float bright = 0.15 + hash21(vec2(fi * 6.1, uSeed * 0.9)) * 0.30; + float size = 0.010 + hash21(vec2(fi * 2.9, uSeed * 4.7)) * 0.014; + float bright = 0.18 + hash21(vec2(fi * 6.1, uSeed * 0.9)) * 0.32; // Fade out near top/bottom edges float edgeFade = smoothstep(0.0, 0.12, pY) * smoothstep(1.0, 0.88, pY); @@ -232,7 +237,7 @@ vec3 motifCardDatabase(vec2 uv, vec3 bg, float t) // Semi-transparent dark fill float fill = smoothstep(0.015, -0.005, d); - col = mix(col, uColorB.rgb * 0.55, fill * 0.50); + col = mix(col, uColorB.rgb * 0.60, fill * 0.62); // Accent outline float edge = smoothstep(0.035, 0.0, abs(d)); @@ -309,7 +314,7 @@ vec3 motifAccount(vec2 uv, vec3 bg, float t) // Node glow via bloom; intensity modulated by pulse float dist = length(ac - pos); - col += uAccent.rgb * bloom(dist, 0.018, 0.08) * mix(0.20, 0.45, pulse); + col += uAccent.rgb * bloom(dist, 0.018, 0.08) * mix(0.30, 0.55, pulse); } // Edges: connect nodes within a radius threshold @@ -323,19 +328,19 @@ vec3 motifAccount(vec2 uv, vec3 bg, float t) vec2 ba = nodePos[j] - nodePos[i]; float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); float lineDist = length(pa - ba * h); - col += uAccent.rgb * smoothstep(0.010, 0.0, lineDist) * strength * 0.10; + col += uAccent.rgb * smoothstep(0.010, 0.0, lineDist) * strength * 0.14; } } } // Central bloom at banner centre float cDist = length(ac - center); - col += uAccent.rgb * bloom(cDist, 0.04, 0.25) * 0.12; + col += uAccent.rgb * bloom(cDist, 0.04, 0.25) * 0.20; // Periodic expanding ring from centre float ripplePhase = t * 0.4; float rippleDist = abs(cDist - fract(ripplePhase) * asp * 0.7); - col += uAccent.rgb * smoothstep(0.02, 0.0, rippleDist) * 0.10; + col += uAccent.rgb * smoothstep(0.02, 0.0, rippleDist) * 0.14; return col; } @@ -456,6 +461,8 @@ void main() else if (uMode < 4.5) col = motifPreferences(uv, bg, t); else col = motifFinish(uv, bg, t); - col *= mix(0.62, 1.0, vignette(uv)); + // Corner vignette; uVignetteMin is scheme-driven (0.62 on dark stages, + // gentler on light ones so near-white corners don't go muddy grey). + col *= mix(uVignetteMin, 1.0, vignette(uv)); fragColor = vec4(col, 1.0) * qt_Opacity; } diff --git a/cockatrice/src/interface/widgets/onboarding/shaders/brand_plate.frag b/cockatrice/src/interface/widgets/onboarding/shaders/brand_plate.frag new file mode 100644 index 000000000..43b798c9f --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/shaders/brand_plate.frag @@ -0,0 +1,42 @@ +#version 440 + +// The logo's gradient plate, drawn by us rather than the baked-in SVG: a +// linear blend between the two brand appColors (light AccentSoft at the +// top-left grading to dark AccentStrong at the bottom-right, mirroring +// cockatrice.svg's linearGradient4265-7-8 userSpaceOnUse axis), clipped to +// the bird's full silhouette via the full-color logo's alpha (uSilhouette). +// The white highlight path (cockatrice-logo-white) is overlaid in QML on top, +// exactly as the SVG stacks its white path over the gradient paths. Fully +// static: no glow, no breathing — the plate just sits there like the home +// widget's QPainter composite. + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(std140, binding = 0) uniform buf +{ + mat4 qt_Matrix; + float qt_Opacity; + vec4 uStrong; + vec4 uSoft; +}; + +// The full-color logo's alpha channel acts as the silhouette mask: the +// gradient only appears inside the bird, exactly like the SVG's gradient paths. +layout(binding = 1) uniform sampler2D uSilhouette; + +void main() +{ + // Recreate cockatrice.svg's own gradient geometry (linearGradient4265-7-8, + // userSpaceOnUse): light AccentSoft at the start point S=(-8.097,-97.746), + // dark AccentStrong at the end E=(162.455,295.208), on the SVG's 300x300 + // canvas. Normalized to UV space, V=E-S=(0.5685,1.3098), so + // t = dot(uv - S_norm, V)/|V|^2 with S_norm=(-0.0270,-0.3258). + float t = clamp(dot(qt_TexCoord0 - vec2(-0.02699, -0.32582), vec2(0.56851, 1.30985)) / 2.03891, 0.0, 1.0); + vec3 color = mix(uSoft.rgb, uStrong.rgb, t); + + // Anti-aliased silhouette clip from the full-color logo's alpha. + float alpha = texture(uSilhouette, qt_TexCoord0).a; + + fragColor = vec4(color * alpha, alpha) * qt_Opacity; +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp index 0b77ca185..e8816847e 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp @@ -1,13 +1,19 @@ #include "printing_selector_card_overlay_widget.h" #include "../../../client/settings/cache_settings.h" +#include "../../card_picture_loader/card_picture_loader.h" #include "../cards/card_info_picture_widget.h" #include "printing_selector_card_display_widget.h" +#include +#include #include #include #include +#include #include +#include +#include #include #include #include @@ -15,6 +21,49 @@ #include #include +namespace +{ +/** + * @brief Places the preview beside the highlighted action inside the given screen. + * + * Side-aware: hugs the side of the action that has room, aligned with its row, then + * clamps every edge so the preview always lands fully on-screen on first show. + */ +QPoint +previewPositionNear(const QRect &actionRect, const QSize &labelSize, const QRect &screenGeometry, int previewOffset) +{ + const bool rightFits = actionRect.right() + previewOffset + labelSize.width() <= screenGeometry.right(); + const bool leftFits = actionRect.left() - previewOffset - labelSize.width() >= screenGeometry.left(); + + int x; + if (rightFits) { + x = actionRect.right() + previewOffset; + } else if (leftFits) { + x = actionRect.left() - previewOffset - labelSize.width(); + } else { + x = actionRect.left(); + } + x = qMax(screenGeometry.left(), x); + x = qMin(screenGeometry.right() - labelSize.width() + 1, x); + + const bool belowFits = actionRect.bottom() + previewOffset + labelSize.height() <= screenGeometry.bottom(); + const bool aboveFits = actionRect.top() - previewOffset - labelSize.height() >= screenGeometry.top(); + + int y; + if (belowFits) { + y = actionRect.bottom() + previewOffset; + } else if (aboveFits) { + y = actionRect.top() - previewOffset - labelSize.height(); + } else { + y = actionRect.top(); + } + y = qMax(screenGeometry.top(), y); + y = qMin(screenGeometry.bottom() - labelSize.height() + 1, y); + + return {x, y}; +} +} // namespace + /** * @brief Constructs a PrintingSelectorCardOverlayWidget for displaying a card overlay. * @@ -50,6 +99,31 @@ PrintingSelectorCardOverlayWidget::PrintingSelectorCardOverlayWidget(QWidget *pa initializePinBadge(); + // Parent the preview to this overlay so it is destroyed with it (Qt::ToolTip keeps + // it a frameless, non-activating top-level window despite the parent). + cardOverridePreviewLabel = new QLabel(this, Qt::ToolTip); + cardOverridePreviewLabel->setWindowFlag(Qt::FramelessWindowHint); + cardOverridePreviewLabel->setAttribute(Qt::WA_ShowWithoutActivating); + cardOverridePreviewLabel->setScaledContents(true); + cardOverridePreviewLabel->hide(); + + // While the preview is visible, keep it honest: when the hovered printing's art + // resolves (all alternate printings share the root card's CardInfo), redraw it in place. + if (rootCard.getCardPtr()) { + connect(rootCard.getCardPtr().data(), &CardInfo::pixmapUpdated, this, [this] { + if (cardOverridePreviewLabel->isVisible()) { + refreshPreview(); + } + }); + } + + // Alt-Tab / app-inactive must not strand the floating preview. + connect(qApp, &QGuiApplication::applicationStateChanged, this, [this](Qt::ApplicationState state) { + if (state != Qt::ApplicationActive) { + hidePreview(); + } + }); + // Update when this overlay emits cardPreferenceChanged or when size/scale changes connect(this, &PrintingSelectorCardOverlayWidget::cardPreferenceChanged, this, &PrintingSelectorCardOverlayWidget::updatePinBadgeVisibility); @@ -185,19 +259,23 @@ void PrintingSelectorCardOverlayWidget::leaveEvent(QEvent *event) } /** - * @brief Creates and shows a custom context menu when the right mouse button is clicked. + * @brief Creates and shows the card-overlay context menu. * - * The context menu includes an option to show related cards, which displays a submenu with actions - * for each related card. When an action is triggered, the card information is updated, and the - * printing selector is shown. + * The menu includes the card art preference (Pin/Unpin Printing), the Image Overrides + * submenu (Load Custom Image, Clear Custom Image, and one entry per alternate printing with a + * live preview), and the Show Related cards submenu. * - * @param point The position of the mouse when the right-click occurred. + * @param point The local position the menu should pop at. */ void PrintingSelectorCardOverlayWidget::customMenu(QPoint point) { QMenu menu; - auto *preferenceMenu = new QMenu(tr("Preference")); + hidePreview(); // Clear any preview state left over from a previous menu run. + + // Submenus are owned by the stack-allocated top-level menu (addMenu() does not + // transfer ownership). + auto *preferenceMenu = new QMenu(tr("Preference"), &menu); menu.addMenu(preferenceMenu); const auto &preferredProviderId = @@ -219,8 +297,66 @@ void PrintingSelectorCardOverlayWidget::customMenu(QPoint point) }); } + menu.addSeparator(); + + auto *overrideMenu = new QMenu(tr("Image Overrides"), &menu); + + auto *loadCustomAction = overrideMenu->addAction(tr("Load Custom Image...")); + auto *clearOverrideAction = overrideMenu->addAction(tr("Clear Custom Image")); + + // Nothing to clear on a card that has no local override yet. + clearOverrideAction->setEnabled(CardPictureLoader::hasLocalOverrides(rootCard)); + + overrideMenu->addSeparator(); + + const auto &allSets = rootCard.getInfo().getSets(); + + for (const auto &set : allSets) { + for (const auto &printing : set) { + if (printing == rootCard.getPrinting()) { + continue; + } + + // The submenu is already scoped to this card, so the rows lead with set + + // collector; only printings with a distinct display name add their own name. + const CardSetPtr cardSet = printing.getSet(); + if (!cardSet) { + continue; + } + + QString label = tr("%1 %2").arg(cardSet->getCorrectedShortName(), printing.getProperty("num")); + + auto *action = overrideMenu->addAction(label); + + ExactCard overrideCard(rootCard.getCardPtr(), printing); + action->setData(QVariant::fromValue(overrideCard)); + + connect(action, &QAction::triggered, this, [this, overrideCard]() { + CardPictureLoader::getInstance().installPrintingOverride(rootCard, overrideCard); + QPixmapCache::clear(); + rootCard.emitPixmapUpdated(); // refresh the overlay art in place, like the other paths + }); + } + } + + connect(clearOverrideAction, &QAction::triggered, this, [this]() { + CardPictureLoader::deleteAllLocalOverrides(rootCard); + QPixmapCache::clear(); + rootCard.emitPixmapUpdated(); // force UI refresh + }); + + connect(loadCustomAction, &QAction::triggered, this, &PrintingSelectorCardOverlayWidget::loadCustomImage); + + connect(overrideMenu, &QMenu::hovered, this, &PrintingSelectorCardOverlayWidget::showPreviewForAction); + connect(overrideMenu, &QMenu::aboutToHide, this, &PrintingSelectorCardOverlayWidget::hidePreview); + connect(overrideMenu, &QMenu::triggered, this, &PrintingSelectorCardOverlayWidget::hidePreview); + + menu.addMenu(overrideMenu); + + menu.addSeparator(); + // filling out the related cards submenu - auto *relatedMenu = new QMenu(tr("Show Related cards")); + auto *relatedMenu = new QMenu(tr("Show Related cards"), &menu); menu.addMenu(relatedMenu); auto relatedCards = rootCard.getInfo().getAllRelatedCards(); if (relatedCards.isEmpty()) { @@ -235,7 +371,11 @@ void PrintingSelectorCardOverlayWidget::customMenu(QPoint point) }); } } + // The preview anchors itself to this popup's global geometry while it is open, so the + // pointer must stay valid for the whole exec() and be dropped before the stack unwinds. + previewSourceMenu = overrideMenu; menu.exec(this->mapToGlobal(point)); + previewSourceMenu = nullptr; } /** @@ -291,3 +431,136 @@ void PrintingSelectorCardOverlayWidget::initializePinBadge() pinBadge->setVisible(false); pinBadge->raise(); } + +/** + * @brief Asks for an image file and installs it as the card's custom art. + * + * Unreadable files answer with a visible warning instead of a silent no-op. + */ +void PrintingSelectorCardOverlayWidget::loadCustomImage() +{ + QString filePath = QFileDialog::getOpenFileName(this, tr("Select Card Image"), QString(), + tr("Images (*.png *.jpg *.jpeg *.webp)")); + + if (filePath.isEmpty()) { + return; + } + + QPixmap pixmap(filePath); + if (pixmap.isNull()) { + // No silent paths: a file that cannot be read answers visibly instead of a no-op. + QMessageBox::warning(this, tr("Load Custom Image"), tr("The selected file could not be read as an image.")); + return; + } + + CardPictureLoader::getInstance().saveCardImageToLocalStorage(rootCard, pixmap, true); + + QPixmapCache::clear(); + rootCard.emitPixmapUpdated(); +} + +/** + * @brief Shows the hover preview for a highlighted printing entry in the Image Overrides submenu. + * + * QMenu::hovered fires on keyboard highlight too, so the preview appears when arrows walk + * onto a printing entry, not only under the mouse. + * + * Non-printing entries (e.g., Load Custom Image, Clear Custom Image) hide the preview. + * + * @param action The action that was highlighted. + */ +void PrintingSelectorCardOverlayWidget::showPreviewForAction(QAction *action) +{ + if (!action) { + hidePreview(); + return; + } + + const QVariant data = action->data(); + + if (!data.canConvert()) { + hidePreview(); + return; + } + + const ExactCard previewCard = qvariant_cast(data); + if (previewCard.isEmpty()) { + hidePreview(); + return; + } + + hoveredOverrideCard = previewCard; + hoveredOverrideAction = action; + refreshPreview(); +} + +/** + * @brief Renders the hover preview for the currently highlighted printing. + * + * The preview shows the loading placeholder while the art is pending and swaps in the real + * art when it resolves. The label is positioned against its already-resized geometry so the + * first-ever show at the screen's edges stays fully on-screen. + */ +void PrintingSelectorCardOverlayWidget::refreshPreview() +{ + if (hoveredOverrideCard.isEmpty()) { + hidePreview(); + return; + } + + constexpr QSize previewSize(240, 336); + constexpr int previewOffset = 20; + + QPixmap pixmap; + CardPictureLoader::getPixmap(pixmap, hoveredOverrideCard, previewSize); + + if (pixmap.isNull()) { + // Keep the preview honest while loading: show the loading placeholder instead of a void. + // Fetch at the logical size and let the label scale it, so the placeholder matches the + // real art's footprint rather than doubling on HiDPI displays. + CardPictureLoader::getCardBackLoadingInProgressPixmap(pixmap, previewSize); + } + + cardOverridePreviewLabel->setPixmap(pixmap); + // QPixmap::size() is physical pixels; the label layout must use the device-independent size + // so the preview keeps a constant footprint across DPI settings (QScreen geometry is logical). + const QSize labelSize = pixmap.deviceIndependentSize().toSize(); + cardOverridePreviewLabel->resize(labelSize); + + // Anchor the preview to the walked submenu popup rather than QCursor::pos(), which is idle + // under keyboard-only operation: a keyboard-highlighted row must preview at the same place as + // a hovered one. The mouse path is unchanged in effect — the popup sits under the cursor, so + // the preview stays beside the row in both modalities. + const QMenu *popup = previewSourceMenu; + if (!popup || !popup->isVisible() || !hoveredOverrideAction) { + hidePreview(); + return; + } + + const QRect popupGeometry = popup->geometry(); + const QRect actionRectLocal = popup->actionGeometry(hoveredOverrideAction); + const QRect actionRect(popupGeometry.topLeft() + actionRectLocal.topLeft(), actionRectLocal.size()); + + QScreen *screen = QGuiApplication::screenAt(popupGeometry.center()); + if (!screen) { + hidePreview(); + return; + } + const QRect &screenGeometry = screen->geometry(); + + cardOverridePreviewLabel->move(previewPositionNear(actionRect, labelSize, screenGeometry, previewOffset)); + cardOverridePreviewLabel->show(); +} + +/** + * @brief Hides the hover preview and forgets the currently highlighted printing. + */ +void PrintingSelectorCardOverlayWidget::hidePreview() +{ + hoveredOverrideCard = ExactCard(); + hoveredOverrideAction = nullptr; + + if (cardOverridePreviewLabel) { + cardOverridePreviewLabel->hide(); + } +} diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.h b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.h index 228393c9c..fbcfa9230 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.h +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.h @@ -13,6 +13,9 @@ #include +class QAction; +class QMenu; + class PrintingSelectorCardOverlayWidget : public QWidget { Q_OBJECT @@ -43,11 +46,19 @@ private slots: private: void initializePinBadge(); + void loadCustomImage(); + void showPreviewForAction(QAction *action); + void refreshPreview(); + void hidePreview(); CardInfoPictureWidget *cardInfoPicture; AllZonesCardAmountWidget *allZonesCardAmountWidget; QLabel *pinBadge = nullptr; AbstractTabDeckEditor *deckEditor; ExactCard rootCard; + QLabel *cardOverridePreviewLabel = nullptr; + ExactCard hoveredOverrideCard; + QMenu *previewSourceMenu = nullptr; + QAction *hoveredOverrideAction = nullptr; }; #endif // PRINTING_SELECTOR_CARD_OVERLAY_WIDGET_H diff --git a/cockatrice/src/interface/widgets/server/game_selector.cpp b/cockatrice/src/interface/widgets/server/game_selector.cpp index a8bf54e91..659325987 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector.cpp @@ -369,7 +369,7 @@ void GameSelector::joinGame(const ServerInfo_Game &game, const bool asSpectator, return; } - bool overrideRestrictions = !tabSupervisor->getAdminLocked(); + bool overrideRestrictions = tabSupervisor->canOverrideGameRestrictions(); // Joining a full game without override privileges silently becomes a // spectator join, so ask first instead of surprising the player. @@ -462,7 +462,7 @@ void GameSelector::enableButtonsForIndex(const QModelIndex ¤t) } const ServerInfo_Game &game = gameListModel->getGame(current.data(Qt::UserRole).toInt()); - bool overrideRestrictions = !tabSupervisor->getAdminLocked(); + bool overrideRestrictions = tabSupervisor->canOverrideGameRestrictions(); spectateButton->setEnabled(game.spectators_allowed() || overrideRestrictions); joinButton->setEnabled(game.player_count() < game.max_players() || overrideRestrictions); diff --git a/cockatrice/src/interface/widgets/server/remote/remote_decklist_tree_widget.cpp b/cockatrice/src/interface/widgets/server/remote/remote_decklist_tree_widget.cpp index a6add3fca..df9dfbf53 100644 --- a/cockatrice/src/interface/widgets/server/remote/remote_decklist_tree_widget.cpp +++ b/cockatrice/src/interface/widgets/server/remote/remote_decklist_tree_widget.cpp @@ -113,7 +113,7 @@ int RemoteDeckList_TreeModel::rowCount(const QModelIndex &parent) const int RemoteDeckList_TreeModel::columnCount(const QModelIndex & /*parent*/) const { - return 3; + return 4; } QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) const @@ -121,7 +121,7 @@ QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) cons if (!index.isValid()) { return QVariant(); } - if (index.column() >= 3) { + if (index.column() >= 4) { return QVariant(); } @@ -134,12 +134,29 @@ QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) cons switch (index.column()) { case 0: return node->getName(); + case 3: + // Report the node's own bit, not the inherited effective + // state, so it stays in step with what publishing toggles. + if (node->isPublic()) { + return tr("Public"); + } + return isEffectivelyPublic(node) ? tr("Public (inherited)") : tr("Private"); default: return QVariant(); } } case Qt::DecorationRole: return index.column() == 0 ? dirIcon : QVariant(); + case Qt::ToolTipRole: + if (index.column() == 3) { + if (node->isPublic()) { + return tr("This folder is visible to other users"); + } + return isEffectivelyPublic(node) + ? tr("This folder is private, but a parent folder is public (inherited).") + : tr("This folder is only visible to you"); + } + return QVariant(); default: return QVariant(); } @@ -153,6 +170,13 @@ QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) cons return file->getId(); case 2: return file->getUploadTime(); + case 3: + // Report the node's own bit, not the inherited effective + // state, so it stays in step with what publishing toggles. + if (file->isPublic()) { + return tr("Public"); + } + return isEffectivelyPublic(file) ? tr("Public (inherited)") : tr("Private"); default: return QVariant(); } @@ -161,6 +185,16 @@ QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) cons return index.column() == 0 ? fileIcon : QVariant(); case Qt::TextAlignmentRole: return index.column() == 1 ? Qt::AlignRight : Qt::AlignLeft; + case Qt::ToolTipRole: + if (index.column() == 3) { + if (file->isPublic()) { + return tr("This deck is visible to other users"); + } + return isEffectivelyPublic(file) + ? tr("This deck is private, but a parent folder is public (inherited).") + : tr("This deck is only visible to you"); + } + return QVariant(); default: return QVariant(); } @@ -183,6 +217,8 @@ QVariant RemoteDeckList_TreeModel::headerData(int section, Qt::Orientation orien return tr("ID"); case 2: return tr("Upload time"); + case 3: + return tr("Visibility"); default: return QVariant(); } @@ -239,13 +275,14 @@ void RemoteDeckList_TreeModel::addFileToTree(const ServerInfo_DeckStorage_TreeIt time.setSecsSinceEpoch(fileInfo.creation_time()); beginInsertRows(nodeToIndex(parent), parent->size(), parent->size()); - parent->append(new FileNode(QString::fromStdString(file.name()), file.id(), time, parent)); + parent->append(new FileNode(QString::fromStdString(file.name()), file.id(), time, parent, fileInfo.is_public())); endInsertRows(); } void RemoteDeckList_TreeModel::addFolderToTree(const ServerInfo_DeckStorage_TreeItem &folder, DirectoryNode *parent) { DirectoryNode *newItem = addNamedFolderToTree(QString::fromStdString(folder.name()), parent); + newItem->setIsPublic(folder.folder().is_public()); const ServerInfo_DeckStorage_Folder &folderInfo = folder.folder(); const int folderItemsSize = folderInfo.items_size(); for (int i = 0; i < folderItemsSize; ++i) { @@ -285,6 +322,21 @@ void RemoteDeckList_TreeModel::refreshTree() client->sendCommand(pend); } +bool RemoteDeckList_TreeModel::isEffectivelyPublic(const Node *node) const +{ + if (node == nullptr || node == root) { + return false; + } + const Node *current = node; + while (current != nullptr) { + if (current->isPublic()) { + return true; + } + current = current->getParent(); + } + return false; +} + void RemoteDeckList_TreeModel::clearTree() { beginResetModel(); diff --git a/cockatrice/src/interface/widgets/server/remote/remote_decklist_tree_widget.h b/cockatrice/src/interface/widgets/server/remote/remote_decklist_tree_widget.h index 3dd91d7a4..2cf09aff7 100644 --- a/cockatrice/src/interface/widgets/server/remote/remote_decklist_tree_widget.h +++ b/cockatrice/src/interface/widgets/server/remote/remote_decklist_tree_widget.h @@ -27,9 +27,11 @@ public: protected: DirectoryNode *parent; QString name; + bool publicFlag; public: - explicit Node(const QString &_name, DirectoryNode *_parent = nullptr) : parent(_parent), name(_name) + explicit Node(const QString &_name, DirectoryNode *_parent = nullptr) + : parent(_parent), name(_name), publicFlag(false) { } virtual ~Node() = default; @@ -41,6 +43,14 @@ public: { return name; } + [[nodiscard]] bool isPublic() const + { + return publicFlag; + } + void setIsPublic(bool _public) + { + publicFlag = _public; + } }; class DirectoryNode : public Node, public QList { @@ -59,9 +69,14 @@ public: QDateTime uploadTime; public: - FileNode(const QString &_name, int _id, const QDateTime &_uploadTime, DirectoryNode *_parent = nullptr) + FileNode(const QString &_name, + int _id, + const QDateTime &_uploadTime, + DirectoryNode *_parent = nullptr, + bool _isPublic = false) : Node(_name, _parent), id(_id), uploadTime(_uploadTime) { + setIsPublic(_isPublic); } [[nodiscard]] int getId() const { @@ -109,6 +124,11 @@ public: { return root; } + /** + * @brief Whether a node is visible to other users (own flag or inherited + * from any ancestor folder). + */ + [[nodiscard]] bool isEffectivelyPublic(const Node *node) const; void addFileToTree(const ServerInfo_DeckStorage_TreeItem &file, DirectoryNode *parent); void addFolderToTree(const ServerInfo_DeckStorage_TreeItem &folder, DirectoryNode *parent); DirectoryNode *addNamedFolderToTree(const QString &name, DirectoryNode *parent); diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp index 0d2267a63..f95ad88e4 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp @@ -37,6 +37,7 @@ UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent, aDetails = new QAction(QString(), this); aChat = new QAction(QString(), this); aShowGames = new QAction(QString(), this); + aViewPublicDecks = new QAction(QString(), this); aAddToBuddyList = new QAction(QString(), this); aRemoveFromBuddyList = new QAction(QString(), this); aAddToIgnoreList = new QAction(QString(), this); @@ -64,6 +65,7 @@ void UserContextMenu::retranslateUi() aDetails->setText(tr("User &details")); aChat->setText(tr("Private &chat")); aShowGames->setText(tr("Show this user's &games")); + aViewPublicDecks->setText(tr("View this user's &public decks")); aAddToBuddyList->setText(tr("Add to &buddy list")); aRemoveFromBuddyList->setText(tr("Remove from &buddy list")); aAddToIgnoreList->setText(tr("Add to &ignore list")); @@ -376,6 +378,9 @@ void UserContextMenu::showContextMenu(const QPoint &pos, } menu->addAction(aDetails); menu->addAction(aShowGames); + if (userLevel.testFlag(ServerInfo_User::IsRegistered)) { + menu->addAction(aViewPublicDecks); + } menu->addAction(aChat); const QList inviteOptions = inviteOptionsForUser(userName); if (!inviteOptions.isEmpty()) { @@ -455,6 +460,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos, aChat->setEnabled(anotherUser && online && !userListProxy->isUserIgnored(userName)); aShowGames->setEnabled(online); aReport->setEnabled(anotherUser); + aViewPublicDecks->setEnabled(anotherUser); aAddToBuddyList->setEnabled(anotherUser); aRemoveFromBuddyList->setEnabled(anotherUser); aAddToIgnoreList->setEnabled(anotherUser); @@ -481,6 +487,8 @@ void UserContextMenu::showContextMenu(const QPoint &pos, execChat(userName); } else if (actionClicked == aShowGames) { execShowGames(userName); + } else if (actionClicked == aViewPublicDecks) { + execViewPublicDecks(userName); } else if (actionClicked == aAddToBuddyList) { execAddToBuddy(userName); } else if (actionClicked == aRemoveFromBuddyList) { @@ -604,6 +612,11 @@ void UserContextMenu::execShowGames(const QString &userName) client->sendCommand(pend); } +void UserContextMenu::execViewPublicDecks(const QString &userName) +{ + tabSupervisor->openTabPublicDecks(userName); +} + void UserContextMenu::execAddToBuddy(const QString &userName) { Command_AddToList cmd; diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.h b/cockatrice/src/interface/widgets/server/user/user_context_menu.h index 6abbc057a..0922eae94 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.h +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.h @@ -37,6 +37,7 @@ private: QAction *aUserName; QAction *aDetails; QAction *aShowGames; + QAction *aViewPublicDecks; QAction *aChat; QAction *aAddToBuddyList, *aRemoveFromBuddyList; QAction *aAddToIgnoreList, *aRemoveFromIgnoreList; @@ -111,6 +112,7 @@ public: void execInvite(const QString &userName); void execDetails(const QString &userName); void execShowGames(const QString &userName); + void execViewPublicDecks(const QString &userName); void execAddToBuddy(const QString &userName); void execRemoveFromBuddy(const QString &userName); void execAddToIgnore(const QString &userName); diff --git a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp index c8494f095..f161f0f19 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp @@ -59,8 +59,8 @@ AppearanceSettingsPage::AppearanceSettingsPage() connect(&schemeCombo, &QComboBox::currentIndexChanged, this, [this] { themeManager->setColorScheme(schemeCombo.currentData().toString()); }); - // Qt widget style; "Default" lets the application decide - styleCombo.addItem(tr("Default"), QStringLiteral("Default")); + // Qt widget style; "System" lets the application decide + styleCombo.addItem(tr("System"), QStringLiteral("System")); for (const QString &key : QStyleFactory::keys()) { styleCombo.addItem(key, key); } @@ -132,6 +132,10 @@ AppearanceSettingsPage::AppearanceSettingsPage() connect(&homeTabDisplayCardNameCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.appearance(), &AppearanceSettings::setHomeTabDisplayCardName); + homeTabBackgroundDimCheckBox.setChecked(settings.appearance().getHomeTabBackgroundDim()); + connect(&homeTabBackgroundDimCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.appearance(), + &AppearanceSettings::setHomeTabBackgroundDim); + for (const auto &entry : HomeTabButtonColor::all()) { homeTabButtonColorSourceBox.addItem(QObject::tr(entry.trKey)); } @@ -150,6 +154,7 @@ AppearanceSettingsPage::AppearanceSettingsPage() homeTabGrid->addWidget(&homeTabDisplayCardNameCheckBox, 2, 0, 1, 2); homeTabGrid->addWidget(&homeTabButtonColorSourceLabel, 3, 0); homeTabGrid->addWidget(&homeTabButtonColorSourceBox, 3, 1); + homeTabGrid->addWidget(&homeTabBackgroundDimCheckBox, 4, 0, 1, 2); homeTabGroupBox = new QGroupBox; homeTabGroupBox->setLayout(homeTabGrid); @@ -508,9 +513,12 @@ void AppearanceSettingsPage::retranslateUi() homeTabBackgroundShuffleFrequencyLabel.setText(tr("Home tab background shuffle frequency:")); homeTabBackgroundShuffleFrequencySpinBox.setSpecialValueText(tr("Disabled")); homeTabDisplayCardNameCheckBox.setText(tr("Display card name of background in bottom right")); + homeTabBackgroundDimCheckBox.setText(tr("Dim the home tab background")); + homeTabBackgroundDimCheckBox.setToolTip( + tr("Draw a translucent overlay over the home tab background so buttons and text stand out")); homeTabButtonColorSourceLabel.setText(tr("Home tab button color:")); homeTabButtonColorSourceBox.setToolTip( - tr("Automatic: extract from background if present, otherwise use theme default")); + tr("Use the theme's identity accent colors, or extract colors from the background image")); playmatGroupBox->setTitle(tr("Playmat settings")); playmatVisibilityLabel.setText(tr("Playmat visibility:")); diff --git a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h index 8db71ff8f..bec4cd72f 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h @@ -41,6 +41,7 @@ private: QLabel homeTabBackgroundShuffleFrequencyLabel; QSpinBox homeTabBackgroundShuffleFrequencySpinBox; QCheckBox homeTabDisplayCardNameCheckBox; + QCheckBox homeTabBackgroundDimCheckBox; QLabel homeTabButtonColorSourceLabel; QComboBox homeTabButtonColorSourceBox; diff --git a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp index 62b06fb60..36436f8a3 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp @@ -1,15 +1,21 @@ #include "general_settings_page.h" #include "../../../client/settings/cache_settings.h" +#include "../interface/card_picture_loader/card_picture_loader.h" #include "../main.h" #include "../server/user/user_info_connection.h" #include "update/client/release_channel.h" #include +#include #include #include #include +#include #include +#include +#include +#include #include #include #include @@ -46,10 +52,27 @@ GeneralSettingsPage::GeneralSettingsPage() connect(&languageBox, qOverload(&QComboBox::currentIndexChanged), this, &GeneralSettingsPage::languageBoxChanged); + // card text & images language, independent of the UI language + cardLanguageBox.addItem(tr("English"), "en"); + for (const QString &code : CardLocalization::supportedLanguages()) { + cardLanguageBox.addItem(CardLocalization::languageDisplayName(code), code); + } + const int cardLangIndex = cardLanguageBox.findData(SettingsCache::instance().cardsDisplay().getCardLang()); + cardLanguageBox.setCurrentIndex(cardLangIndex < 0 ? 0 : cardLangIndex); + + connect(&cardLanguageBox, qOverload(&QComboBox::currentIndexChanged), this, + &GeneralSettingsPage::cardLanguageBoxChanged); + auto *languageGrid = new QGridLayout; languageGrid->addWidget(&languageLabel, 0, 0); languageGrid->addWidget(&languageBox, 0, 1); - languageGrid->addWidget(&advertiseTranslationPageLabel, 1, 1, Qt::AlignRight); + languageGrid->addWidget(&cardLanguageLabel, 1, 0); + languageGrid->addWidget(&cardLanguageBox, 1, 1); + languageGrid->addWidget(&cardLanguageNoteLabel, 2, 1); + languageGrid->addWidget(&advertiseTranslationPageLabel, 3, 1, Qt::AlignRight); + + cardLanguageNoteLabel.setWordWrap(true); + cardLanguageNoteLabel.setAlignment(Qt::AlignLeft | Qt::AlignVCenter); languageGroupBox = new QGroupBox; languageGroupBox->setLayout(languageGrid); @@ -412,6 +435,52 @@ void GeneralSettingsPage::languageBoxChanged(int index) SettingsCache::instance().personal().setLang(languageBox.itemData(index).toString()); } +void GeneralSettingsPage::cardLanguageBoxChanged(int index) +{ + const QString lang = cardLanguageBox.itemData(index).toString(); + SettingsCache::instance().cardsDisplay().setCardLang(lang); + + // Switching to a non-default language only takes effect after the card + // database is re-imported with that language selected; English data is always + // present, so switching back to English needs no prompt. + if (lang == "en") { + return; + } + + // The binary cache does not track the language its entries were imported in, + // and the downloaded pictures were fetched with English art names, so both are + // stale until Oracle re-imports the database in the new language: drop them. + QFile::remove(SettingsCache::instance().getCardDatabasePath() + ".cache"); + CardPictureLoader::clearNetworkCache(); + CardPictureLoader::clearPixmapCache(); + + // Art is resolved by the translated card name for non-English languages, so the + // matching Scryfall URL is added to the top of the download list. It stays + // visible in the deck editor settings, where it can be removed or reordered. + const bool localizedUrlAdded = SettingsCache::instance().downloads().addLocalizedScryfallUrl(); + + QString message = tr("

The card database only contains English card data. To see cards in %1, " + "Oracle must run once with this language selected and re-import the card " + "database.

" + "

The cached database and the downloaded card pictures have been cleared, so a " + "re-import is picked up without stale entries.

") + .arg(cardLanguageBox.itemText(index)); + if (localizedUrlAdded) { + message += tr("

The Scryfall URL that resolves card art by translated name was added to the top of your " + "download list. You can remove or reorder it any time.

"); + } + message += tr("

Run Oracle now?

"); + + const QMessageBox::StandardButton answer = QMessageBox::question( + this, tr("Card text & images language changed"), message, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); + + // The answer only controls whether Oracle starts right away; the caches stay + // cleared so the next import or launch rebuilds them in the new language. + if (answer == QMessageBox::Yes) { + emit cardDatabaseUpdateRequested(); + } +} + void GeneralSettingsPage::updateStartupServerControlsVisibility() { const int index = startupTabSelector.currentIndex(); @@ -429,6 +498,10 @@ void GeneralSettingsPage::retranslateUi() languageGroupBox->setTitle(tr("Language settings")); languageLabel.setText(tr("Language:")); + cardLanguageBox.setItemText(0, tr("English")); + cardLanguageLabel.setText(tr("Card text & images language:")); + cardLanguageNoteLabel.setText( + tr("Foreign card names, text and art apply after you update the card database (Oracle).")); advertiseTranslationPageLabel.setText( QString("%2").arg(WIKI_TRANSLATION_FAQ).arg(tr("How to help with translations"))); diff --git a/cockatrice/src/interface/widgets/settings_page/general_settings_page.h b/cockatrice/src/interface/widgets/settings_page/general_settings_page.h index e0c1a47bf..7afba158f 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.h @@ -23,6 +23,10 @@ public: static QStringList findQmFiles(); static QString languageName(const QString &lang); +signals: + /// Request to re-import the card database with the newly selected card language + void cardDatabaseUpdateRequested(); + private slots: void deckPathButtonClicked(); void filtersPathButtonClicked(); @@ -33,6 +37,7 @@ private slots: void tokenDatabasePathButtonClicked(); void resetAllPathsClicked(); void languageBoxChanged(int index); + void cardLanguageBoxChanged(int index); void updateStartupServerControlsVisibility(); private: @@ -46,6 +51,10 @@ private: QComboBox languageBox; QLabel advertiseTranslationPageLabel; + QLabel cardLanguageLabel; + QComboBox cardLanguageBox; + QLabel cardLanguageNoteLabel; + QLabel updateReleaseChannelLabel; QComboBox updateReleaseChannelBox; QCheckBox startupUpdateCheckCheckBox; diff --git a/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp b/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp index f80649eba..6423c581b 100644 --- a/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp +++ b/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp @@ -11,6 +11,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" +#include "../cards/additional_info/deck_color_identity.h" #include "../client/network/interfaces/deck_stats_interface.h" #include "../client/network/interfaces/tapped_out_interface.h" #include "../deck_editor/deck_state_manager.h" @@ -19,6 +20,7 @@ #include "../interface/widgets/dialogs/dlg_load_deck.h" #include "../interface/widgets/dialogs/dlg_load_deck_from_clipboard.h" #include "../interface/widgets/dialogs/dlg_load_deck_from_website.h" +#include "../interface/widgets/dialogs/dlg_share_deck.h" #include "../utility/visibility_change_listener.h" #include "tab_supervisor.h" @@ -323,6 +325,7 @@ bool AbstractTabDeckEditor::actSaveDeck() Command_DeckUpload cmd; cmd.set_deck_id(static_cast(loadedDeck.lastLoadInfo.remoteDeckId)); cmd.set_deck_list(deckString.toStdString()); + cmd.set_color_identity(getDeckColorIdentity(loadedDeck.deckList, CardDatabaseManager::query()).toStdString()); PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd); connect(pend, &PendingCommand::finished, this, &AbstractTabDeckEditor::saveDeckRemoteFinished); @@ -382,6 +385,27 @@ bool AbstractTabDeckEditor::actSaveDeckAs() return true; } +/** + * @brief Opens the deck share dialog with the current deck preselected. + */ +void AbstractTabDeckEditor::actShareDeck() +{ + AbstractClient *client = tabSupervisor->getServerClient(); + if (client->getStatus() != StatusLoggedIn) { + QMessageBox::information(this, tr("Share deck"), tr("You must be connected to the server to share a deck.")); + return; + } + + const QSharedPointer deck = deckStateManager->getDeckListShared(); + if (deck->isBlankDeck()) { + QMessageBox::information(this, tr("Share deck"), tr("The deck is empty. Add cards before sharing it.")); + return; + } + + DlgShareDeck shareDialog(client, deck, this); + shareDialog.exec(); +} + /** * @brief Callback for remote deck save completion. * @param response Server response. diff --git a/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.h b/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.h index a3cda2bfc..dae8f5b0a 100644 --- a/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.h +++ b/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.h @@ -214,6 +214,9 @@ protected slots: /** @brief Saves the current deck under a new name. */ virtual bool actSaveDeckAs(); + /** @brief Opens the deck share dialog for the current deck. */ + void actShareDeck(); + /** @brief Loads a deck from the clipboard. */ virtual void actLoadDeckFromClipboard(); diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp index 66b68d823..657ef3dbe 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp @@ -1,5 +1,6 @@ #include "archidekt_api_response_deck_display_widget.h" +#include "../../../../../../client/settings/cache_settings.h" #include "../../../../../deck_loader/card_node_function.h" #include "../../../../../deck_loader/deck_loader.h" #include "../../../../cards/card_size_widget.h" @@ -10,6 +11,7 @@ #include #include +#include ArchidektApiResponseDeckDisplayWidget::ArchidektApiResponseDeckDisplayWidget(QWidget *parent, ArchidektApiResponseDeck _response, @@ -120,6 +122,9 @@ ArchidektApiResponseDeckDisplayWidget::ArchidektApiResponseDeckDisplayWidget(QWi } model = new DeckListModel(this); + model->setDisplayLanguage(SettingsCache::instance().cardsDisplay().getCardLang()); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, model, + [this](const QString &lang) { model->setDisplayLanguage(lang); }); connect(model, &DeckListModel::modelReset, this, &ArchidektApiResponseDeckDisplayWidget::decklistModelReset); auto decklist = QSharedPointer(new DeckList); diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index f3535d850..62769d0e3 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -3,35 +3,54 @@ #include "../../../client/settings/cache_settings.h" #include "../../deck_loader/deck_loader.h" #include "../../pixel_map_generator.h" +#include "../cards/additional_info/deck_color_identity.h" +#include "../deck_share/deck_share_utils.h" +#include "../deck_share/share_bar_widget.h" #include "../interface/widgets/server/remote/remote_decklist_tree_widget.h" #include "../interface/widgets/utility/get_text_with_max.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 #include #include #include +#include #include #include +#include #include #include +namespace +{ +// How long to wait after the last visibility change before reading back the +// Public/Private column, in milliseconds. +constexpr int VISIBILITY_REFRESH_DELAY = 500; +} // namespace + TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, AbstractClient *_client, const ServerInfo_User *currentUserInfo) @@ -92,8 +111,34 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, serverDirView = new RemoteDeckList_TreeWidget(client); connect(serverDirView, &QTreeView::doubleClicked, this, &TabDeckStorage::actRemoteDoubleClick); + connect(serverDirView->selectionModel(), &QItemSelectionModel::selectionChanged, this, + [this] { onServerSelectionChanged(); }); + + // Share bar for creating a share link from the selected server decks/folders. + shareBar = new ShareBarWidget(this); + connect(shareBar, &ShareBarWidget::createRequested, this, &TabDeckStorage::actShareSelection); + connect(shareBar, &ShareBarWidget::cancelRequested, this, &TabDeckStorage::cancelShareDecks); + shareBar->setVisible(false); + + shareTimeoutTimer = new QTimer(this); + shareTimeoutTimer->setSingleShot(true); + shareTimeoutTimer->setInterval( + static_cast((static_cast(SettingsCache::instance().network().getTimeOut()) + 1) * + SettingsCache::instance().network().getKeepAlive() * 1000)); + connect(shareTimeoutTimer, &QTimer::timeout, this, &TabDeckStorage::onShareFromTreeTimeout); + + // Restartable single-shot refresh for the Public/Private column. It is + // armed with the full network timeout when a publish is sent (so a dropped + // reply still drains once) and re-armed with the short delay every time a + // reply lands, so the drain cannot fire while a slow round trip is still in + // flight. Either way the tree is re-read once things quiet down. + visibilityRefreshTimer = new QTimer(this); + visibilityRefreshTimer->setSingleShot(true); + visibilityRefreshTimer->setInterval(VISIBILITY_REFRESH_DELAY); + connect(visibilityRefreshTimer, &QTimer::timeout, this, &TabDeckStorage::onVisibilityRefreshTimeout); QVBoxLayout *rightVbox = new QVBoxLayout; + rightVbox->addWidget(shareBar); rightVbox->addWidget(serverDirView); rightVbox->addLayout(rightToolBarLayout); rightGroupBox = new QGroupBox; @@ -139,6 +184,14 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, aDeleteRemoteDeck->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); connect(aDeleteRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteRemoteDeck); + aShareDecks = new QAction(this); + aShareDecks->setIcon(themePixmap(QStringLiteral("icons/share"))); + connect(aShareDecks, &QAction::triggered, this, &TabDeckStorage::actShareDecks); + + aPublishDeck = new QAction(this); + aPublishDeck->setIcon(QPixmap("theme:icons/lock")); + connect(aPublishDeck, &QAction::triggered, this, &TabDeckStorage::actPublishDeck); + // Add actions to toolbars leftToolBar->addAction(aOpenLocalDeck); leftToolBar->addAction(aRenameLocal); @@ -150,6 +203,8 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, rightToolBar->addAction(aOpenRemoteDeck); rightToolBar->addAction(aDownload); + rightToolBar->addAction(aShareDecks); + rightToolBar->addAction(aPublishDeck); rightToolBar->addAction(aNewFolder); rightToolBar->addAction(aDeleteRemoteDeck); @@ -178,7 +233,13 @@ void TabDeckStorage::retranslateUi() aNewFolder->setText(tr("New folder")); aDeleteLocalDeck->setText(tr("Delete")); aDeleteRemoteDeck->setText(tr("Delete")); + aShareDecks->setText(tr("Share decks")); + aPublishDeck->setText(tr("Publish/unpublish deck")); aOpenDecksFolder->setText(tr("Open decks folder")); + shareBar->retranslateUi(); + if (shareBar->isVisible()) { + onServerSelectionChanged(); + } } QString TabDeckStorage::getTargetPath() const @@ -211,6 +272,8 @@ void TabDeckStorage::handleConnected(const ServerInfo_User &userInfo) void TabDeckStorage::handleConnectionChanged(ClientStatus status) { if (status == StatusDisconnected) { + visibilityRefreshTimer->stop(); + visibilityRefreshStarted = false; setRemoteEnabled(false); } } @@ -220,12 +283,15 @@ void TabDeckStorage::setRemoteEnabled(bool enabled) aUpload->setEnabled(enabled); aOpenRemoteDeck->setEnabled(enabled); aDownload->setEnabled(enabled); + aShareDecks->setEnabled(enabled); + aPublishDeck->setEnabled(enabled); aNewFolder->setEnabled(enabled); aDeleteRemoteDeck->setEnabled(enabled); if (enabled) { serverDirView->refreshTree(); } else { + setShareModeEnabled(false); serverDirView->clearTree(); } } @@ -347,6 +413,8 @@ void TabDeckStorage::uploadDeck(const QString &filePath, const QString &targetPa cmd.set_path(targetPath.toStdString()); cmd.set_deck_list(deckString.toStdString()); + cmd.set_color_identity(getDeckColorIdentity(deck, CardDatabaseManager::query()).toStdString()); + PendingCommand *pend = client->prepareSessionCommand(cmd); connect(pend, &PendingCommand::finished, this, &TabDeckStorage::uploadFinished); client->sendCommand(pend); @@ -626,3 +694,241 @@ void TabDeckStorage::deleteFolderFinished(const Response &response, const Comman serverDirView->removeNode(toDelete); } } + +void TabDeckStorage::actShareDecks() +{ + setShareModeEnabled(true); +} + +void TabDeckStorage::cancelShareDecks() +{ + setShareModeEnabled(false); +} + +void TabDeckStorage::setShareModeEnabled(bool enabled) +{ + shareBar->setVisible(enabled); + if (enabled) { + shareBar->setCreateEnabled(true); + shareBar->setName(tr("Shared decks")); + onServerSelectionChanged(); + shareBar->focusName(); + } else { + // Abandon any in-flight request: otherwise the timer keeps running and a late + // response reports the share as created after the user already backed out. + shareTimeoutTimer->stop(); + shareInFlightSeq = 0; + serverDirView->clearSelection(); + } +} + +void TabDeckStorage::onServerSelectionChanged() +{ + if (!shareBar->isVisible()) { + return; + } + const auto selection = serverDirView->getCurrentSelection(); + int folders = 0; + int files = 0; + for (const auto *node : selection) { + if (dynamic_cast(node)) { + ++folders; + } else { + ++files; + } + } + + QString hint; + if (folders > 1) { + hint = tr("Only one folder can be shared at a time."); + } else if (folders > 0 && files > 0) { + hint = tr("Share either a folder or decks, not both."); + } else if (folders == 0 && files == 0) { + hint = tr("Select folders or decks in the tree to share."); + } + shareBar->setHintText(hint, !hint.isEmpty()); + + QStringList parts; + if (folders > 0) { + parts << tr("%n folder(s)", "", folders); + } + if (files > 0) { + parts << tr("%n deck(s)", "", files); + } + shareBar->setCountText(parts.isEmpty() ? tr("No decks selected") + : tr("Selected: %1").arg(parts.join(QStringLiteral(", ")))); +} + +void TabDeckStorage::actShareSelection() +{ + const auto selection = serverDirView->getCurrentSelection(); + QString sharedFolder; + bool hasFile = false; + bool hasFolder = false; + for (const auto *node : selection) { + if (const auto *dirNode = dynamic_cast(node)) { + hasFolder = true; + if (!sharedFolder.isEmpty()) { + showShareNotice(tr("Only one folder can be shared at a time."), true); + return; + } + sharedFolder = dirNode->getPath(); + } else { + hasFile = true; + } + } + + if (hasFile && hasFolder) { + showShareNotice(tr("Share either a folder or decks, not both."), true); + return; + } + if (hasFolder && sharedFolder.isEmpty()) { + showShareNotice(tr("The root folder cannot be shared."), true); + return; + } + + Command_DeckShareCreate cmd; + cmd.set_name(shareBar->name().toStdString()); + if (cmd.name().empty()) { + cmd.set_name(tr("Shared decks").toStdString()); + } + + if (!sharedFolder.isEmpty()) { + cmd.set_folder_path(sharedFolder.toStdString()); + } else { + for (const auto *node : selection) { + if (const auto *fileNode = dynamic_cast(node)) { + DeckShareItem *item = cmd.add_items(); + item->set_deck_id(fileNode->getId()); + } + } + } + + if (cmd.items_size() == 0 && cmd.folder_path().empty()) { + showShareNotice(tr("Select decks to share."), true); + return; + } + + shareBar->setCreateEnabled(false); + const int seq = ++shareRequestSeq; + shareInFlightSeq = seq; + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, + [this, seq](const Response &response, const CommandContainer &commandContainer) { + if (shareInFlightSeq != seq) { + return; // the user cancelled or a newer request superseded this one + } + shareInFlightSeq = 0; + shareFromTreeFinished(response, commandContainer); + }); + client->sendCommand(pend); + shareTimeoutTimer->start(); +} + +void TabDeckStorage::shareFromTreeFinished(const Response &response, const CommandContainer & /*commandContainer*/) +{ + shareTimeoutTimer->stop(); + shareBar->setCreateEnabled(true); + if (response.response_code() != Response::RespOk) { + qWarning() << "failed to create deck share:" << response.response_code(); + showShareNotice(tr("Failed to create the share link (server response code %1).") + .arg(QString::number(static_cast(response.response_code()))), + true); + return; + } + const DeckShareUtils::ShareResponse share = DeckShareUtils::handleShareResponse(client, response); + + showShareNotice( + tr("Share link copied to the clipboard.\nExpires on %1.").arg(DeckShareUtils::formatShareExpiry(share.expiry))); + setShareModeEnabled(false); +} + +void TabDeckStorage::showShareNotice(const QString &message, bool warning) +{ + QMessageBox box(warning ? QMessageBox::Warning : QMessageBox::Information, tr("Share link"), message, + QMessageBox::Ok, this); + box.exec(); +} + +void TabDeckStorage::onShareFromTreeTimeout() +{ + if (shareInFlightSeq == 0) { + return; // share mode was left while the request was still outstanding + } + shareInFlightSeq = 0; + shareBar->setCreateEnabled(true); + showShareNotice(tr("The server did not respond in time. Try again."), true); +} + +void TabDeckStorage::actPublishDeck() +{ + visibilityFailures.clear(); + // Arm the drain with the full network timeout so a lost reply still costs + // one refresh instead of a dead column; each reply shrinks it to the short + // delay below, so a slow round trip is never drained before it lands. + const int visibilityFailSafeDelay = + static_cast((static_cast(SettingsCache::instance().network().getTimeOut()) + 1) * + SettingsCache::instance().network().getKeepAlive() * 1000); + + const auto selection = serverDirView->getCurrentSelection(); + for (const auto *node : selection) { + Command_DeckSetVisibility cmd; + if (const auto *fileNode = dynamic_cast(node)) { + cmd.set_deck_id(fileNode->getId()); + } else if (const auto *dirNode = dynamic_cast(node)) { + const QString path = dirNode->getPath(); + if (path.isEmpty()) { + continue; // the root folder cannot be published + } + cmd.set_folder_path(path.toStdString()); + } else { + continue; + } + // Toggle the node's own visibility bit (what the server persists); the + // effective visibility shown by the column may additionally be inherited + // from a parent folder. + cmd.set_is_public(!node->isPublic()); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabDeckStorage::setVisibilityFinished); + visibilityRefreshStarted = true; + visibilityRefreshTimer->setInterval(visibilityFailSafeDelay); + visibilityRefreshTimer->start(); + client->sendCommand(pend); + } +} + +void TabDeckStorage::setVisibilityFinished(const Response &r, const CommandContainer & /*commandContainer*/) +{ + if (r.response_code() == Response::RespOk) { + if (visibilityRefreshStarted) { + visibilityRefreshTimer->setInterval(VISIBILITY_REFRESH_DELAY); + visibilityRefreshTimer->start(); + } + return; + } + + // Collect batch failures and surface them once, when publishing quiets + // down, instead of stacking one modal dialog per rejected node. + const QString message = tr("Failed to change deck visibility on server (response code %1).") + .arg(QString::number(static_cast(r.response_code()))); + if (visibilityRefreshStarted) { + visibilityFailures.append(message); + visibilityRefreshTimer->setInterval(VISIBILITY_REFRESH_DELAY); + visibilityRefreshTimer->start(); + } else { + QMessageBox::critical(this, tr("Error"), message); + } +} + +void TabDeckStorage::onVisibilityRefreshTimeout() +{ + visibilityRefreshStarted = false; + if (!visibilityFailures.isEmpty()) { + QMessageBox::critical( + this, tr("Error"), + tr("Failed to change the visibility of %n selected deck(s).", "", visibilityFailures.size())); + visibilityFailures.clear(); + } + serverDirView->refreshTree(); +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h index a863e0625..f8d585880 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h @@ -11,6 +11,7 @@ #include "../interface/widgets/server/remote/remote_decklist_tree_widget.h" #include "tab.h" +#include #include struct LoadedDeck; @@ -22,8 +23,10 @@ class QToolBar; class QTreeWidget; class QTreeWidgetItem; class QGroupBox; +class QTimer; class CommandContainer; class Response; +class ShareBarWidget; class TabDeckStorage : public Tab { @@ -35,14 +38,25 @@ private: QToolBar *leftToolBar, *rightToolBar; RemoteDeckList_TreeWidget *serverDirView; QGroupBox *leftGroupBox, *rightGroupBox; + ShareBarWidget *shareBar; + QTimer *shareTimeoutTimer; + int shareRequestSeq = 0; + int shareInFlightSeq = 0; QAction *aOpenLocalDeck, *aRenameLocal, *aUpload, *aNewLocalFolder, *aDeleteLocalDeck; QAction *aOpenDecksFolder; - QAction *aOpenRemoteDeck, *aDownload, *aNewFolder, *aDeleteRemoteDeck; + QAction *aOpenRemoteDeck, *aDownload, *aShareDecks, *aPublishDeck, *aNewFolder, *aDeleteRemoteDeck; + bool visibilityRefreshStarted = false; + QTimer *visibilityRefreshTimer; + QStringList visibilityFailures; QString getTargetPath() const; void setRemoteEnabled(bool enabled); + void showShareNotice(const QString &message, bool warning = false); + + void setShareModeEnabled(bool enabled); + void uploadDeck(const QString &filePath, const QString &targetPath); void deleteRemoteDeck(const RemoteDeckList_TreeModel::Node *node); @@ -75,6 +89,17 @@ private slots: void actNewFolder(); void newFolderFinished(const Response &response, const CommandContainer &commandContainer); + void actShareDecks(); + void actShareSelection(); + void cancelShareDecks(); + void onServerSelectionChanged(); + void shareFromTreeFinished(const Response &r, const CommandContainer &commandContainer); + void onShareFromTreeTimeout(); + + void actPublishDeck(); + void setVisibilityFinished(const Response &r, const CommandContainer &commandContainer); + void onVisibilityRefreshTimeout(); + void actDeleteRemoteDeck(); void deleteFolderFinished(const Response &response, const CommandContainer &commandContainer); void deleteDeckFinished(const Response &response, const CommandContainer &commandContainer); diff --git a/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp new file mode 100644 index 000000000..35389d3bf --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp @@ -0,0 +1,251 @@ +#include "tab_public_decks.h" + +#include "../../../client/settings/cache_settings.h" +#include "../../deck_loader/deck_loader.h" +#include "../general/layout_containers/flow_widget.h" +#include "../visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h" +#include "../visual_deck_storage/deck_preview/public_deck_preview_widget.h" +#include "../visual_deck_storage/remote_public_decks_model.h" +#include "../visual_deck_storage/visual_deck_storage_quick_settings_widget.h" +#include "../visual_deck_storage/visual_deck_storage_search_widget.h" +#include "../visual_deck_storage/visual_deck_storage_tag_filter_widget.h" +#include "tab_supervisor.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +TabPublicDecks::TabPublicDecks(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &_userName) + : Tab(_tabSupervisor), client(_client), userName(_userName) +{ + model = new RemotePublicDecksModel(client, this); + cardSize = SettingsCache::instance().cardsDisplay().getVisualDeckStorageCardSize(); + + titleLabel = new QLabel(tr("Public decks of %1").arg(userName.toHtmlEscaped()), this); + QFont titleFont = titleLabel->font(); + titleFont.setBold(true); + titleLabel->setFont(titleFont); + + auto *headerLayout = new QHBoxLayout; + headerLayout->addWidget(titleLabel); + headerLayout->addStretch(1); + + // Filter/toolbar row, matching the Visual Deck Storage: color identity filter + // first, the search bar stretching in the middle, and the quick settings + // cogwheel at the end. The card size slider lives inside the cogwheel popup. + emptyLabel = new QLabel(tr("This user has not published any decks."), this); + emptyLabel->setAlignment(Qt::AlignCenter); + emptyLabel->setVisible(false); + + statusLabel = new QLabel(this); + statusLabel->setAlignment(Qt::AlignCenter); + statusLabel->setVisible(false); + + flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); + flowWidget->setSpacing(8, 8); + + colorIdentityFilter = new DeckPreviewColorIdentityFilterWidget(this); + searchWidget = new VisualDeckStorageSearchWidget(this); + refreshButton = new QToolButton(this); + refreshButton->setIcon(QPixmap("theme:icons/reload")); + refreshButton->setFixedSize(32, 32); + quickSettingsWidget = new VisualDeckStorageQuickSettingsWidget(this); + quickSettingsWidget->setPublicDecksMode(true); + + auto *filterLayout = new QHBoxLayout; + filterLayout->addWidget(colorIdentityFilter); + filterLayout->addWidget(searchWidget, 1); + filterLayout->addWidget(refreshButton); + filterLayout->addWidget(quickSettingsWidget); + + tagFilterWidget = new VisualDeckStorageTagFilterWidget(this); + tagFilterWidget->setAllTagsProvider([this] { return model->allTags(); }); + updateTagsVisibility(quickSettingsWidget->getShowTagFilter()); + + auto *layout = new QVBoxLayout; + layout->addLayout(headerLayout); + layout->addLayout(filterLayout); + layout->addWidget(tagFilterWidget); + layout->addWidget(statusLabel); + layout->addWidget(emptyLabel); + layout->addWidget(flowWidget, 1); + + auto *mainWidget = new QWidget(this); + mainWidget->setLayout(layout); + setCentralWidget(mainWidget); + + connect(refreshButton, &QToolButton::clicked, this, [this] { model->refresh(userName); }); + connect(model, &QAbstractItemModel::modelReset, this, &TabPublicDecks::rebuildGrid); + connect(model, &RemotePublicDecksModel::loadingChanged, this, &TabPublicDecks::updateLoadingState); + connect(model, &RemotePublicDecksModel::loadFailed, this, [this](const QString &message) { + lastFailureMessage = message; + statusLabel->setText(message); + statusLabel->setVisible(true); + flowWidget->setVisible(false); + emptyLabel->setVisible(false); + }); + connect(searchWidget, &VisualDeckStorageSearchWidget::searchTextChanged, this, + [this](const QString &text) { model->setSearchText(text); }); + connect(colorIdentityFilter, &DeckPreviewColorIdentityFilterWidget::activeColorsChanged, this, + &TabPublicDecks::updateColorFilter); + connect(colorIdentityFilter, &DeckPreviewColorIdentityFilterWidget::filterModeChanged, this, + &TabPublicDecks::updateColorFilter); + connect(tagFilterWidget, &VisualDeckStorageTagFilterWidget::filterChanged, this, &TabPublicDecks::updateTagFilter); + connect(quickSettingsWidget, &VisualDeckStorageQuickSettingsWidget::cardSizeChanged, this, + &TabPublicDecks::updateCardSize); + connect(quickSettingsWidget, &VisualDeckStorageQuickSettingsWidget::showTagFilterChanged, this, + &TabPublicDecks::updateTagsVisibility); + + retranslateUi(); + model->refresh(userName); +} + +QString TabPublicDecks::getTabText() const +{ + return tr("Public decks of %1").arg(userName); +} + +void TabPublicDecks::retranslateUi() +{ + // The username is another user's data, so escape it for the AutoText QLabel. + titleLabel->setText(tr("Public decks of %1").arg(userName.toHtmlEscaped())); + // The same choice rebuildGrid makes, so a language change does not swap + // the "no match" variant for the "nothing published" one. + emptyLabel->setText(model->totalCount() > 0 ? tr("No decks match your filters.") + : tr("This user has not published any decks.")); + refreshButton->setToolTip(tr("Refresh")); + refreshButton->setAccessibleName(tr("Refresh")); + quickSettingsWidget->setToolTip(tr("Public Decks Settings")); + // Re-show whatever the status label is showing so a language change picks up + // the new language or, for a failure message, at least does not hide it. + if (model->isLoading()) { + updateLoadingState(true); + } else if (!lastFailureMessage.isEmpty()) { + statusLabel->setText(lastFailureMessage); + statusLabel->setVisible(true); + flowWidget->setVisible(false); + emptyLabel->setVisible(false); + } else { + updateLoadingState(false); + } + emit tabTextChanged(this, getTabText()); +} + +bool TabPublicDecks::closeRequest() +{ + emit closing(this); + return Tab::closeRequest(); +} + +void TabPublicDecks::rebuildGrid() +{ + flowWidget->clearLayout(); + + const int count = model->rowCount(); + if (count == 0) { + emptyLabel->setText(model->totalCount() > 0 ? tr("No decks match your filters.") + : tr("This user has not published any decks.")); + } + emptyLabel->setVisible(count == 0); + for (int i = 0; i < count; ++i) { + auto *tile = new PublicDeckPreviewWidget(flowWidget, model->entryAt(i)); + tile->setScaleFactor(cardSize); + connect(tile, &PublicDeckPreviewWidget::openDeckRequested, this, &TabPublicDecks::openDeck); + flowWidget->addWidget(tile); + } + + // The deck set changed, so the tag filter chips are re-gathered from it. + tagFilterWidget->refreshTags(); +} + +void TabPublicDecks::updateColorFilter() +{ + model->setColorFilter(colorIdentityFilter->getFilterMode(), colorIdentityFilter->getActiveColors()); +} + +void TabPublicDecks::updateTagFilter() +{ + const QStringList selectedTags = tagFilterWidget->selectedTags(); + const QStringList excludedTags = tagFilterWidget->excludedTags(); + model->setTagFilter(QSet(selectedTags.cbegin(), selectedTags.cend()), + QSet(excludedTags.cbegin(), excludedTags.cend())); + tagFilterWidget->refreshTags(); +} + +void TabPublicDecks::updateTagsVisibility(bool visible) +{ + tagFilterWidget->setVisible(visible); +} + +void TabPublicDecks::updateLoadingState(bool loading) +{ + if (loading) { + // A new attempt is under way, so the previously shown failure, if any, + // no longer describes the current state. + lastFailureMessage.clear(); + statusLabel->setText(tr("Loading public decks…")); + statusLabel->setVisible(true); + flowWidget->setVisible(false); + emptyLabel->setVisible(false); + } else { + statusLabel->setVisible(false); + flowWidget->setVisible(true); + } +} + +void TabPublicDecks::updateCardSize(int scale) +{ + cardSize = scale; + applyCardSize(scale); +} + +void TabPublicDecks::applyCardSize(int scale) +{ + const auto tiles = flowWidget->findChildren(); + for (PublicDeckPreviewWidget *tile : tiles) { + tile->setScaleFactor(scale); + } + flowWidget->setMinimumSizeToMaxSizeHint(); +} + +void TabPublicDecks::openDeck(int deckId) +{ + Command_DeckDownloadPublic cmd; + cmd.set_deck_id(deckId); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabPublicDecks::openDeckFinished); + client->sendCommand(pend); +} + +void TabPublicDecks::openDeckFinished(const Response &response, const CommandContainer & /*commandContainer*/) +{ + if (response.response_code() != Response::RespOk) { + QMessageBox::warning(this, tr("Open public deck"), + tr("Failed to open the public deck (server response code %1).") + .arg(QString::number(static_cast(response.response_code())))); + return; + } + + const Response_DeckDownload &resp = response.GetExtension(Response_DeckDownload::ext); + std::optional deckOpt = + DeckLoader::loadFromRemote(QString::fromStdString(resp.deck()), LoadedDeck::LoadInfo::NON_REMOTE_ID); + if (!deckOpt) { + QMessageBox::warning(this, tr("Open public deck"), tr("The public deck could not be parsed.")); + return; + } + + tabSupervisor->openDeckInNewTab(deckOpt.value()); +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_public_decks.h b/cockatrice/src/interface/widgets/tabs/tab_public_decks.h new file mode 100644 index 000000000..492fdeafa --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_public_decks.h @@ -0,0 +1,80 @@ +/** + * @file tab_public_decks.h + * @ingroup Tabs + */ + +#ifndef TAB_PUBLIC_DECKS_H +#define TAB_PUBLIC_DECKS_H + +#include "tab.h" + +class AbstractClient; +class CommandContainer; +class DeckPreviewColorIdentityFilterWidget; +class FlowWidget; +class PublicDeckPreviewWidget; +class QLabel; +class QToolButton; +class RemotePublicDecksModel; +class Response; +class VisualDeckStorageQuickSettingsWidget; +class VisualDeckStorageSearchWidget; +class VisualDeckStorageTagFilterWidget; + +/** + * @brief A visual grid of the public decks published by another user. + * + * The grid is rendered from the preview metadata the server stores for the + * decks, so browsing costs no downloads; the deck list is fetched via + * Command_DeckDownloadPublic only when the user opens a deck. Multiple users + * can be browsed simultaneously; each gets its own tab. + */ +class TabPublicDecks final : public Tab +{ + Q_OBJECT + +public: + TabPublicDecks(TabSupervisor *tabSupervisor, AbstractClient *client, const QString &userName); + + [[nodiscard]] QString getTabText() const override; + void retranslateUi() override; + bool closeRequest() override; + + [[nodiscard]] QString getUserName() const + { + return userName; + } + +signals: + void closing(TabPublicDecks *tab); + +private slots: + void openDeck(int deckId); + void openDeckFinished(const Response &response, const CommandContainer &commandContainer); + void updateColorFilter(); + void updateTagFilter(); + void updateCardSize(int scale); + void updateTagsVisibility(bool visible); + void updateLoadingState(bool loading); + +private: + void rebuildGrid(); + void applyCardSize(int scale); + + AbstractClient *client; + QString userName; + RemotePublicDecksModel *model; + FlowWidget *flowWidget; + VisualDeckStorageSearchWidget *searchWidget; + DeckPreviewColorIdentityFilterWidget *colorIdentityFilter; + VisualDeckStorageTagFilterWidget *tagFilterWidget; + QToolButton *refreshButton; + VisualDeckStorageQuickSettingsWidget *quickSettingsWidget; + QLabel *titleLabel; + QLabel *statusLabel; + QLabel *emptyLabel; + QString lastFailureMessage; ///< Last load-failure text, re-shown on retranslate. + int cardSize = 100; +}; + +#endif // TAB_PUBLIC_DECKS_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_server.cpp b/cockatrice/src/interface/widgets/tabs/tab_server.cpp index 13a77e957..fca32094c 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_server.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_server.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -185,25 +186,37 @@ void TabServer::processServerMessageEvent(const Event_ServerMessage &event) void TabServer::joinRoom(int id, bool setCurrent) { TabRoom *room = tabSupervisor->getRoomTabs().value(id); - if (!room) { - Command_JoinRoom cmd; - cmd.set_room_id(id); - - PendingCommand *pend = client->prepareSessionCommand(cmd); - pend->setExtraData(setCurrent); - connect(pend, &PendingCommand::finished, this, - [this, id](const Response &r, const CommandContainer &c, const QVariant &v) { - joinRoomFinished(r, c, v, id); - }); - - client->sendCommand(pend); - + if (room) { + if (setCurrent) { + tabSupervisor->setCurrentWidget((QWidget *)room); + } return; } - if (setCurrent) { - tabSupervisor->setCurrentWidget((QWidget *)room); + auto pendingIt = pendingRoomJoins.find(id); + if (pendingIt != pendingRoomJoins.end()) { + // A join for this room is already in flight: the room tab opens when its response + // arrives. Fold the new request into the pending one so that, for example, clicking + // a room the selector is auto-joining does not send a second Command_JoinRoom - the + // server would reject that duplicate with RespContextError. + if (setCurrent) { + pendingIt.value() = true; + } + return; } + + pendingRoomJoins.insert(id, setCurrent); + + Command_JoinRoom cmd; + cmd.set_room_id(id); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + pend->setExtraData(setCurrent); + connect( + pend, &PendingCommand::finished, this, + [this, id](const Response &r, const CommandContainer &c, const QVariant &v) { joinRoomFinished(r, c, v, id); }); + + client->sendCommand(pend); } void TabServer::joinRoomFinished(const Response &r, @@ -211,34 +224,72 @@ void TabServer::joinRoomFinished(const Response &r, const QVariant &extraData, int roomId) { + const bool setCurrent = pendingRoomJoins.value(roomId, extraData.toBool()); + pendingRoomJoins.remove(roomId); + const bool healedJoin = healedRoomJoins.contains(roomId); + healedRoomJoins.remove(roomId); + switch (r.response_code()) { case Response::RespOk: break; case Response::RespNameNotFound: - QMessageBox::critical(this, tr("Error"), - tr("Failed to join the server room: it doesn't exist on the server.")); + if (setCurrent) { + QMessageBox::critical(this, tr("Error"), + tr("Failed to join the server room: it doesn't exist on the server.")); + } emit roomJoinFailed(roomId); return; case Response::RespContextError: - QMessageBox::critical( - this, tr("Error"), - tr("The server thinks you are in the server room but your client is unable to display it. " - "Try restarting your client.")); - emit roomJoinFailed(roomId); + if (healedJoin) { + // The rejoin below was already answered and the server still rejects the join, so + // the stale-membership heal cannot help: surface the error. The guard was already + // released above so a later user-initiated join may try a fresh heal. + if (setCurrent) { + QMessageBox::critical( + this, tr("Error"), + tr("The server thinks you are in the server room but your client is unable to display it. " + "Try restarting your client.")); + } + emit roomJoinFailed(roomId); + return; + } + // The server already had us registered in the room even though no tab was open, + // usually because two join attempts for the same room overlapped. Leaving and + // rejoining makes the server reply with a fresh RespOk so the tab is displayed + // without requiring a client restart. The guard above covers exactly the rejoin that + // leaveAndRejoinRoom triggers, so a server that keeps replying with RespContextError + // gets one heal attempt per join instead of an endless recursion. + healedRoomJoins.insert(roomId); + leaveAndRejoinRoom(roomId, setCurrent); return; case Response::RespUserLevelTooLow: - QMessageBox::critical(this, tr("Error"), - tr("You do not have the required permission to join this server room.")); + if (setCurrent) { + QMessageBox::critical(this, tr("Error"), + tr("You do not have the required permission to join this server room.")); + } emit roomJoinFailed(roomId); return; default: - QMessageBox::critical( - this, tr("Error"), - tr("Failed to join the server room due to an unknown error: %1.").arg(r.response_code())); + if (setCurrent) { + QMessageBox::critical( + this, tr("Error"), + tr("Failed to join the server room due to an unknown error: %1.").arg(r.response_code())); + } emit roomJoinFailed(roomId); return; } const Response_JoinRoom &resp = r.GetExtension(Response_JoinRoom::ext); - emit roomJoined(resp.room_info(), extraData.toBool()); + emit roomJoined(resp.room_info(), setCurrent); +} + +void TabServer::leaveAndRejoinRoom(int roomId, bool setCurrent) +{ + // Clear the stale room membership server-side. The leave is sent before the rejoin below, + // so the server no longer considers us a member by the time the join arrives. The leave + // response is intentionally not awaited: commands are processed in send order on the + // connection, and a failed leave (RespNotInRoom) only means the membership was already gone. + client->sendCommand(client->prepareRoomCommand(Command_LeaveRoom(), roomId)); + + joinRoom(roomId, setCurrent); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_server.h b/cockatrice/src/interface/widgets/tabs/tab_server.h index c10b7945b..121ff814d 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_server.h +++ b/cockatrice/src/interface/widgets/tabs/tab_server.h @@ -10,6 +10,8 @@ #include "tab.h" #include +#include +#include #include #include @@ -58,10 +60,17 @@ private slots: int roomId); private: + void leaveAndRejoinRoom(int roomId, bool setCurrent); + AbstractClient *client; RoomSelector *roomSelector; QTextBrowser *serverInfoBox; bool shouldEmitUpdate = false; + /** Room ids with a join command in flight, mapped to whether the tab should be focused once it opens. */ + QHash pendingRoomJoins; + /** Room ids for which a stale-membership heal (leave + rejoin) is currently in flight. Released as soon as the + * rejoin has been answered, so a heal is attempted at most once per join. */ + QSet healedRoomJoins; public: TabServer(TabSupervisor *_tabSupervisor, AbstractClient *_client); diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 462aa420b..c9bda7703 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -21,6 +21,7 @@ #include "tab_logs.h" #include "tab_message.h" #include "tab_moderation.h" +#include "tab_public_decks.h" #include "tab_replays.h" #include "tab_report.h" #include "tab_room.h" @@ -274,6 +275,10 @@ void TabSupervisor::retranslateUi() while (gameIterator.hasNext()) { tabs.append(gameIterator.next().value()); } + QMapIterator publicDecksIterator(publicDecksTabs); + while (publicDecksIterator.hasNext()) { + tabs.append(publicDecksIterator.next().value()); + } QListIterator replayIterator(replayTabs); while (replayIterator.hasNext()) { tabs.append(replayIterator.next()); @@ -626,6 +631,10 @@ void TabSupervisor::stop() tabsToDelete << i.value(); } + for (auto i = publicDecksTabs.cbegin(), end = publicDecksTabs.cend(); i != end; ++i) { + tabsToDelete << i.value(); + } + for (const auto tab : tabsToDelete) { tab->close(); } @@ -672,7 +681,7 @@ void TabSupervisor::actTabVisualDeckStorage(bool checked) void TabSupervisor::openTabVisualDeckStorage() { - tabVisualDeckStorage = new TabDeckStorageVisual(this); + tabVisualDeckStorage = new TabDeckStorageVisual(this, client); myAddTab(tabVisualDeckStorage, aTabVisualDeckStorage); connect(tabVisualDeckStorage, &QObject::destroyed, this, [this] { tabVisualDeckStorage = nullptr; @@ -1037,6 +1046,30 @@ void TabSupervisor::roomLeft(TabRoom *tab) removeTab(indexOf(tab)); } +void TabSupervisor::openTabPublicDecks(const QString &userName) +{ + if (auto *existing = publicDecksTabs.value(userName, nullptr)) { + setCurrentWidget(existing); + return; + } + + auto *tab = new TabPublicDecks(this, client, userName); + connect(tab, &TabPublicDecks::closing, this, &TabSupervisor::publicDecksClosed); + myAddTab(tab); + publicDecksTabs.insert(userName, tab); + setCurrentWidget(tab); +} + +void TabSupervisor::publicDecksClosed(TabPublicDecks *tab) +{ + if (tab == currentWidget()) { + emit setMenu(); + } + + publicDecksTabs.remove(tab->getUserName()); + removeTab(indexOf(tab)); +} + void TabSupervisor::switchToFirstAvailableNetworkTab() { if (!roomTabs.isEmpty()) { @@ -1451,6 +1484,11 @@ bool TabSupervisor::getAdminLocked() const return tabAdmin->getLocked(); } +bool TabSupervisor::canOverrideGameRestrictions() const +{ + return !getAdminLocked() || (userInfo->user_level() & ServerInfo_User::IsJudge); +} + void TabSupervisor::processNotifyUserEvent(const Event_NotifyUser &event) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h index aec1d7418..066c84a77 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h @@ -47,6 +47,7 @@ class TabAccount; class TabDeckEditor; class TabDeveloper; class TabLog; +class TabPublicDecks; class RoomEvent; class GameEventContainer; class Event_GameJoined; @@ -114,6 +115,7 @@ private: QMap gameTabs; QList replayTabs; QMap messageTabs; + QMap publicDecksTabs; QList deckEditorTabs; bool isLocalGame; @@ -152,6 +154,10 @@ public: return userInfo; } [[nodiscard]] AbstractClient *getClient() const; + [[nodiscard]] AbstractClient *getServerClient() const + { + return client; + } [[nodiscard]] UserListManager *getUserListManager() const { return userListManager; @@ -171,6 +177,7 @@ public: [[nodiscard]] QList getGameInviteLinksForRoom(int roomId) const; void sendInviteToUser(const QString &userName, const QString &inviteText); [[nodiscard]] bool getAdminLocked() const; + [[nodiscard]] bool canOverrideGameRestrictions() const; void closeEvent(QCloseEvent *event) override; bool switchToGameTabIfAlreadyExists(const int gameId); static void actShowPopup(const QString &message); @@ -198,6 +205,7 @@ public slots: void actTabReplays(bool checked); void openTabServer(); void addRoomTab(const ServerInfo_Room &info, bool setCurrent); + void openTabPublicDecks(const QString &userName); private slots: void refreshShortcuts(); @@ -230,6 +238,7 @@ private slots: void localGameJoined(const Event_GameJoined &event); void gameLeft(TabGame *tab); void roomLeft(TabRoom *tab); + void publicDecksClosed(TabPublicDecks *tab); TabMessage *addMessageTab(const QString &userName, bool focus); void replayLeft(TabGame *tab); void processUserLeft(const QString &userName); diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp b/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp index 0cbcb641a..03df76b03 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp @@ -1,25 +1,78 @@ #include "tab_deck_storage_visual.h" +#include "../../../../client/settings/cache_settings.h" +#include "../../../deck_loader/deck_loader.h" +#include "../../cards/additional_info/deck_color_identity.h" +#include "../../deck_share/deck_share_utils.h" #include "../../interface/widgets/visual_deck_storage/visual_deck_storage_widget.h" #include "../tab_supervisor.h" #include -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include -TabDeckStorageVisual::TabDeckStorageVisual(TabSupervisor *_tabSupervisor) - : Tab(_tabSupervisor), visualDeckStorageWidget(new VisualDeckStorageWidget(this)) +TabDeckStorageVisual::TabDeckStorageVisual(TabSupervisor *_tabSupervisor, AbstractClient *_client) + : Tab(_tabSupervisor), visualDeckStorageWidget(new VisualDeckStorageWidget(this)), client(_client), + shareTimeoutTimer(new QTimer(this)) { connect(this, &TabDeckStorageVisual::openDeckEditor, tabSupervisor, &TabSupervisor::openDeckInNewTab); connect(visualDeckStorageWidget, &VisualDeckStorageWidget::deckLoadRequested, this, &TabDeckStorageVisual::actOpenLocalDeck); connect(visualDeckStorageWidget, &VisualDeckStorageWidget::openDeckEditor, this, &TabDeckStorageVisual::openDeckEditor); + connect(visualDeckStorageWidget, &VisualDeckStorageWidget::shareDeckRequested, this, + &TabDeckStorageVisual::actShareDeck); + connect(visualDeckStorageWidget, &VisualDeckStorageWidget::shareSelectionChanged, this, + &TabDeckStorageVisual::onShareSelectionChanged); + connect(visualDeckStorageWidget, &VisualDeckStorageWidget::shareRequested, this, [this] { + if (shareDeckAvailable) { + enterShareMode(); + } + }); auto *widget = new QWidget(this); auto *layout = new QVBoxLayout(widget); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); widget->setLayout(layout); this->setCentralWidget(widget); layout->addWidget(visualDeckStorageWidget); + + shareBar = new ShareBarWidget(this); + connect(shareBar, &ShareBarWidget::createRequested, this, &TabDeckStorageVisual::actShareSelected); + connect(shareBar, &ShareBarWidget::cancelRequested, this, &TabDeckStorageVisual::exitShareMode); + + layout->insertWidget(0, shareBar); + shareBar->setVisible(false); + + connect(client, &AbstractClient::statusChanged, this, &TabDeckStorageVisual::handleConnectionChanged); + shareDeckAvailable = (client->getStatus() == StatusLoggedIn); + visualDeckStorageWidget->setShareAvailable(shareDeckAvailable); + + shareTimeoutTimer->setSingleShot(true); + shareTimeoutTimer->setInterval( + static_cast((static_cast(SettingsCache::instance().network().getTimeOut()) + 1) * + SettingsCache::instance().network().getKeepAlive() * 1000)); + connect(shareTimeoutTimer, &QTimer::timeout, this, &TabDeckStorageVisual::onShareTimeout); + + retranslateUi(); +} + +void TabDeckStorageVisual::retranslateUi() +{ + visualDeckStorageWidget->retranslateUi(); + shareBar->retranslateUi(); + if (shareBar->isVisible()) { + updateShareHint(); + } } void TabDeckStorageVisual::actOpenLocalDeck(const QString &filePath) @@ -33,3 +86,144 @@ void TabDeckStorageVisual::actOpenLocalDeck(const QString &filePath) emit openDeckEditor(deckOpt.value()); } + +void TabDeckStorageVisual::enterShareMode(const QStringList &preselectFiles) +{ + if (!shareDeckAvailable) { + return; // sharing is gated on being logged in + } + shareBar->setCreateEnabled(true); + visualDeckStorageWidget->setShareSelectable(true); + visualDeckStorageWidget->setShareSelectedFiles(preselectFiles); + shareBar->setName(tr("Shared decks")); + shareBar->setVisible(true); + updateShareHint(); + shareBar->focusName(); +} + +void TabDeckStorageVisual::exitShareMode() +{ + // Abandon any in-flight request: otherwise the timer keeps running and a late + // response reports the share as created after the user already backed out. + shareTimeoutTimer->stop(); + shareInFlightSeq = 0; + visualDeckStorageWidget->setShareSelectable(false); + visualDeckStorageWidget->clearShareSelection(); + shareBar->setVisible(false); +} + +void TabDeckStorageVisual::actShareDeck(const QString &filePath) +{ + if (!shareDeckAvailable) { + QMessageBox::information(this, tr("Share deck"), tr("You must be connected to the server to share a deck.")); + return; + } + enterShareMode({filePath}); +} + +void TabDeckStorageVisual::onShareSelectionChanged() +{ + if (shareBar->isVisible()) { + updateShareHint(); + } +} + +void TabDeckStorageVisual::updateShareHint() +{ + const int count = visualDeckStorageWidget->selectedFilePaths().size(); + shareBar->setCountText(tr("%n deck(s)", "", count)); + if (count == 0) { + shareBar->setHintText(tr("Click deck tiles to select the decks you want to share."), true); + } else if (count == 1) { + shareBar->setHintText(tr("One deck selected. Create the link to share it with other players."), true); + } else { + shareBar->setHintText(tr("%n decks selected. Create the link to share them with other players.", "", count), + true); + } +} + +void TabDeckStorageVisual::actShareSelected() +{ + const QStringList filePaths = visualDeckStorageWidget->selectedFilePaths(); + if (filePaths.isEmpty()) { + QMessageBox::warning(this, tr("Share decks"), tr("Select at least one deck to share.")); + return; + } + + Command_DeckShareCreate cmd; + cmd.set_name(shareBar->name().toStdString()); + if (cmd.name().empty()) { + cmd.set_name(tr("Shared decks").toStdString()); + } + + for (const QString &filePath : filePaths) { + std::optional deckOpt = + DeckLoader::loadFromFile(filePath, DeckFileFormat::getFormatFromName(filePath), true); + if (!deckOpt) { + QMessageBox::warning(this, tr("Share decks"), tr("Unable to load deck file %1").arg(filePath)); + return; + } + DeckShareItem *item = cmd.add_items(); + item->set_deck_list(deckOpt->deckList.writeToString_Native().toStdString()); + item->set_color_identity(getDeckColorIdentity(deckOpt->deckList, CardDatabaseManager::query()).toStdString()); + } + + shareBar->setCreateEnabled(false); + const int seq = ++shareRequestSeq; + shareInFlightSeq = seq; + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, + [this, seq](const Response &response, const CommandContainer &commandContainer) { + if (shareInFlightSeq != seq) { + return; // the user cancelled or a newer request superseded this one + } + shareInFlightSeq = 0; + shareFinished(response, commandContainer); + }); + client->sendCommand(pend); + shareTimeoutTimer->start(); +} + +void TabDeckStorageVisual::shareFinished(const Response &response, const CommandContainer & /*commandContainer*/) +{ + shareTimeoutTimer->stop(); + shareBar->setCreateEnabled(true); + if (response.response_code() != Response::RespOk) { + showShareNotice(tr("Failed to create the share link (server response code %1).") + .arg(QString::number(static_cast(response.response_code()))), + true); + return; + } + + const DeckShareUtils::ShareResponse share = DeckShareUtils::handleShareResponse(client, response); + + showShareNotice( + tr("Share link copied to the clipboard.\nExpires on %1.").arg(DeckShareUtils::formatShareExpiry(share.expiry))); + exitShareMode(); +} + +void TabDeckStorageVisual::handleConnectionChanged(ClientStatus status) +{ + shareDeckAvailable = (status == StatusLoggedIn); + visualDeckStorageWidget->setShareAvailable(shareDeckAvailable); + if (!shareDeckAvailable && shareBar->isVisible()) { + exitShareMode(); + } +} + +void TabDeckStorageVisual::showShareNotice(const QString &message, bool warning) +{ + QMessageBox box(warning ? QMessageBox::Warning : QMessageBox::Information, tr("Share link"), message, + QMessageBox::Ok, this); + box.exec(); +} + +void TabDeckStorageVisual::onShareTimeout() +{ + if (shareInFlightSeq == 0) { + return; // share mode was left while the request was still outstanding + } + shareInFlightSeq = 0; + shareBar->setCreateEnabled(true); + showShareNotice(tr("The server did not respond in time. Try again."), true); +} diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.h b/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.h index d3f64e23d..7cd4e7d17 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.h +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.h @@ -7,14 +7,19 @@ #ifndef TAB_DECK_STORAGE_VISUAL_H #define TAB_DECK_STORAGE_VISUAL_H +#include "../../deck_share/share_bar_widget.h" #include "../tab.h" +#include +#include + struct LoadedDeck; class AbstractClient; class CommandContainer; class DeckPreviewWidget; class QFileSystemModel; class QGroupBox; +class QTimer; class QToolBar; class QTreeView; class QTreeWidget; @@ -26,23 +31,55 @@ class TabDeckStorageVisual final : public Tab { Q_OBJECT public: - explicit TabDeckStorageVisual(TabSupervisor *_tabSupervisor); - void retranslateUi() override - { - } + explicit TabDeckStorageVisual(TabSupervisor *_tabSupervisor, AbstractClient *_client); + void retranslateUi() override; + [[nodiscard]] QString getTabText() const override { return tr("Visual Deck Storage"); } + /** + * @brief Enters share-selection mode, optionally preselecting the given deck files. + */ + void enterShareMode(const QStringList &preselectFiles = {}); + + /** + * @brief Leaves share-selection mode and clears the selection. + */ + void exitShareMode(); + + [[nodiscard]] bool isShareModeActive() const + { + return shareBar->isVisible(); + } + public slots: void actOpenLocalDeck(const QString &filePath); + void actShareDeck(const QString &filePath); signals: void openDeckEditor(const LoadedDeck &deck); +private slots: + void actShareSelected(); + void shareFinished(const Response &response, const CommandContainer &commandContainer); + void onShareTimeout(); + void onShareSelectionChanged(); + void handleConnectionChanged(ClientStatus status); + private: + void showShareNotice(const QString &message, bool warning = false); + void updateShareHint(); + VisualDeckStorageWidget *visualDeckStorageWidget; + + ShareBarWidget *shareBar = nullptr; + AbstractClient *client; + QTimer *shareTimeoutTimer; + int shareRequestSeq = 0; + int shareInFlightSeq = 0; + bool shareDeckAvailable = false; }; #endif diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.cpp index fd529ff69..d1a780c38 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.cpp @@ -1,11 +1,10 @@ #include "deck_preview_color_identity_filter_widget.h" #include "../../cards/additional_info/mana_symbol_widget.h" -#include "../visual_deck_storage_widget.h" #include -DeckPreviewColorIdentityFilterWidget::DeckPreviewColorIdentityFilterWidget(VisualDeckStorageWidget *parent) +DeckPreviewColorIdentityFilterWidget::DeckPreviewColorIdentityFilterWidget(QWidget *parent) : QWidget(parent), layout(new QHBoxLayout(this)) { setLayout(layout); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h index def45de66..d54984bcf 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h @@ -14,14 +14,12 @@ #include #include -class VisualDeckStorageWidget; - class DeckPreviewColorIdentityFilterWidget : public QWidget { Q_OBJECT public: - explicit DeckPreviewColorIdentityFilterWidget(VisualDeckStorageWidget *parent); + explicit DeckPreviewColorIdentityFilterWidget(QWidget *parent = nullptr); void retranslateUi(); /** diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp index db466b77a..a7fef5031 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp @@ -48,6 +48,7 @@ QSize DeckPreviewTagDisplayWidget::sizeHint() const void DeckPreviewTagDisplayWidget::mousePressEvent(QMouseEvent *event) { + const TagState previousState = state; switch (event->button()) { case Qt::LeftButton: setState(state != TagState::Selected ? TagState::Selected : TagState::NotSelected); @@ -62,7 +63,12 @@ void DeckPreviewTagDisplayWidget::mousePressEvent(QMouseEvent *event) break; } - emit tagClicked(); + // Only announce a change when the state was actually toggled, so a click that falls + // through the switch (e.g. a button the widget does not react to) does not drive a + // full tag-filter update and layout pass for nothing. + if (state != previousState) { + emit tagClicked(); + } QWidget::mousePressEvent(event); } diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.h index 980741c5d..df2a6b404 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.h @@ -48,7 +48,10 @@ public: signals: /** - * @brief Emitted when the tag is clicked. + * @brief Emitted when a click toggles the chip's selection/exclusion state. + * + * Not emitted for clicks that leave the state unchanged. Connected handlers use + * this as the trigger to update filters built from selectedTags()/excludedTags(). */ void tagClicked(); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp index 876fbf6ad..53fe32314 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp @@ -11,6 +11,7 @@ #include "deck_preview_deck_tags_display_widget.h" #include +#include #include #include #include @@ -27,7 +28,7 @@ DeckPreviewWidget::DeckPreviewWidget(QWidget *_parent, VisualDeckStorageWidget *_visualDeckStorageWidget, VisualDeckStorageModel *_model, const QString &_filePath) - : QWidget(_parent), visualDeckStorageWidget(_visualDeckStorageWidget), model(_model), filePath(_filePath) + : QWidget(_parent), filePath(_filePath), visualDeckStorageWidget(_visualDeckStorageWidget), model(_model) { layout = new QVBoxLayout(this); setLayout(layout); @@ -36,6 +37,8 @@ DeckPreviewWidget::DeckPreviewWidget(QWidget *_parent, new DeckPreviewCardPictureWidget(this, false, visualDeckStorageWidget->deckPreviewSelectionAnimationEnabled); pictureWidget->setFontSize(24); connect(pictureWidget, &DeckPreviewCardPictureWidget::imageClicked, this, &DeckPreviewWidget::imageClickedEvent); + connect(pictureWidget, &DeckPreviewCardPictureWidget::imageSingleClicked, this, + &DeckPreviewWidget::imageSingleClicked); connect(pictureWidget, &DeckPreviewCardPictureWidget::imageDoubleClicked, this, &DeckPreviewWidget::imageDoubleClickedEvent); bannerCardDisplayWidget = pictureWidget; @@ -99,6 +102,15 @@ DeckPreviewWidget::DeckPreviewWidget(QWidget *_parent, // to keep the resize handler from searching the widget tree on every layout pass. fixedWidthChildren = {bannerCardDisplayWidget, colorIdentityWidget, deckTagsDisplayWidget, bannerCardLabel, bannerCardComboBox}; + + // Child of the banner widget so the frame tracks the banner's selection animation + // (which animates the banner's position) instead of staying at a stale static offset. + selectionFrame = new QFrame(bannerCardDisplayWidget); + selectionFrame->setAttribute(Qt::WA_TransparentForMouseEvents); + selectionFrame->setStyleSheet(QStringLiteral( + "QFrame { border: 2px solid palette(highlight); border-radius: 4px; background: transparent; }")); + selectionFrame->setVisible(false); + bannerCardDisplayWidget->installEventFilter(this); } void DeckPreviewWidget::retranslateUi() @@ -122,6 +134,63 @@ void DeckPreviewWidget::resizeEvent(QResizeEvent *event) for (QWidget *widget : fixedWidthChildren) { widget->setMaximumWidth(width); } + updateSelectionFrameGeometry(); +} + +bool DeckPreviewWidget::eventFilter(QObject *watched, QEvent *event) +{ + if (watched == bannerCardDisplayWidget && (event->type() == QEvent::Resize || event->type() == QEvent::Move)) { + updateSelectionFrameGeometry(); + } + return QWidget::eventFilter(watched, event); +} + +void DeckPreviewWidget::setShareSelectable(bool selectable) +{ + shareSelectable = selectable; + if (!selectable) { + setShareSelected(false); + } + updateSelectionStyle(); +} + +void DeckPreviewWidget::setShareSelected(bool selected) +{ + if (shareSelected == selected) { + return; + } + shareSelected = selected; + updateSelectionStyle(); + emit shareSelectionToggled(selected); +} + +bool DeckPreviewWidget::isShareSelected() const +{ + return shareSelected; +} + +bool DeckPreviewWidget::isShareSelectable() const +{ + return shareSelectable; +} + +void DeckPreviewWidget::updateSelectionFrameGeometry() +{ + if (selectionFrame == nullptr || bannerCardDisplayWidget == nullptr) { + return; + } + // Frame is a child of the banner, so it is positioned in banner coordinates and + // tracks the banner's selection animation automatically. A small inset keeps the + // highlight visible around the card art without occluding it. + selectionFrame->setGeometry(bannerCardDisplayWidget->rect().adjusted(1, 1, -1, -1)); + selectionFrame->raise(); +} + +void DeckPreviewWidget::updateSelectionStyle() +{ + if (selectionFrame != nullptr) { + selectionFrame->setVisible(shareSelectable && isShareSelected()); + } } void DeckPreviewWidget::enterEvent(QEnterEvent *event) @@ -337,10 +406,20 @@ void DeckPreviewWidget::imageClickedEvent(QMouseEvent *event, DeckPreviewCardPic } } +void DeckPreviewWidget::imageSingleClicked() +{ + if (isShareSelectable()) { + setShareSelected(!isShareSelected()); + } +} + void DeckPreviewWidget::imageDoubleClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance) { Q_UNUSED(event); Q_UNUSED(instance); + if (isShareSelectable()) { + return; // in share mode a double click would just toggle a single selection + } emit deckLoadRequested(filePath); } @@ -365,6 +444,9 @@ QMenu *DeckPreviewWidget::createRightClickMenu() } }); + connect(menu->addAction(tr("Share deck...")), &QAction::triggered, this, + [this] { emit shareDeckRequested(filePath); }); + connect(menu->addAction(tr("Edit Tags")), &QAction::triggered, deckTagsDisplayWidget, &DeckPreviewDeckTagsDisplayWidget::openTagEditDlg); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h index 7bb69f9b9..f4909eda8 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h @@ -17,6 +17,7 @@ #include class QEnterEvent; +class QFrame; class QLabel; class QMenu; class QMouseEvent; @@ -41,9 +42,19 @@ public: */ DeckPreviewCardPictureWidget *bannerCardDisplayWidget; + /** @brief The path of the deck file backing this preview. */ + QString filePath; + + void setShareSelectable(bool selectable); + void setShareSelected(bool selected); + [[nodiscard]] bool isShareSelected() const; + [[nodiscard]] bool isShareSelectable() const; + signals: void deckLoadRequested(const QString &filePath); void openDeckEditor(const LoadedDeck &deck); + void shareDeckRequested(const QString &filePath); + void shareSelectionToggled(bool selected); public slots: /** @@ -66,6 +77,7 @@ public slots: protected: void enterEvent(QEnterEvent *event) override; void resizeEvent(QResizeEvent *event) override; + bool eventFilter(QObject *watched, QEvent *event) override; private: [[nodiscard]] int row() const; @@ -76,6 +88,7 @@ private: QMenu *createRightClickMenu(); void addSetBannerCardMenu(QMenu *menu); void imageClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); + void imageSingleClicked(); void imageDoubleClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); void actRenameDeck(); @@ -84,7 +97,6 @@ private: VisualDeckStorageWidget *visualDeckStorageWidget; VisualDeckStorageModel *model; - QString filePath; QVBoxLayout *layout; ColorIdentityWidget *colorIdentityWidget; DeckPreviewDeckTagsDisplayWidget *deckTagsDisplayWidget; @@ -92,6 +104,12 @@ private: QComboBox *bannerCardComboBox; QList fixedWidthChildren; ///< Children clamped to the picture width on resize. int lastKnownBannerWidth = -1; ///< The picture width last applied to the children. + QFrame *selectionFrame = nullptr; + bool shareSelectable = false; + bool shareSelected = false; + + void updateSelectionStyle(); + void updateSelectionFrameGeometry(); }; class NoScrollFilter : public QObject diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.cpp new file mode 100644 index 000000000..b49d6f9f4 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.cpp @@ -0,0 +1,167 @@ +#include "public_deck_preview_widget.h" + +#include "../../../../client/settings/cache_settings.h" +#include "../../cards/additional_info/color_identity_widget.h" +#include "../../cards/deck_preview_card_picture_widget.h" +#include "../../general/layout_containers/flow_widget.h" +#include "deck_preview_tag_display_widget.h" + +#include +#include +#include +#include +#include +#include +#include + +PublicDeckPreviewWidget::PublicDeckPreviewWidget(QWidget *parent, const RemotePublicDecksModel::DeckEntry &entry) + : QWidget(parent) +{ + bannerCardDisplayWidget = new DeckPreviewCardPictureWidget(this); + bannerCardDisplayWidget->setFontSize(24); + + // The whole tile is a single focusable, keyboard-operable control: Tab lands + // on it and Space/Enter opens the deck, mirroring the shared-deck preview tile. + setFocusPolicy(Qt::StrongFocus); + + uploadTimeLabel = new QLabel(this); + uploadTimeLabel->setAlignment(Qt::AlignHCenter); + + colorIdentityWidget = new ColorIdentityWidget(this); + + tagsFlowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); + tagsFlowWidget->setSpacing(3, 3); + + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(bannerCardDisplayWidget); + layout->addWidget(uploadTimeLabel); + layout->addWidget(colorIdentityWidget); + layout->addWidget(tagsFlowWidget); + setLayout(layout); + + connect(&SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::visualDeckStorageShowColorIdentityChanged, this, + &PublicDeckPreviewWidget::updateColorIdentityVisibility); + connect(&SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::visualDeckStorageShowTagsOnDeckPreviewsChanged, this, + &PublicDeckPreviewWidget::updateTagsVisibility); + connect(&SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::visualDeckStorageShowUploadTimeChanged, this, + &PublicDeckPreviewWidget::updateUploadTimeVisibility); + + setEntry(entry); + + connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageClicked, this, + &PublicDeckPreviewWidget::imageClickedEvent); + connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageDoubleClicked, this, + &PublicDeckPreviewWidget::imageDoubleClickedEvent); + + // resizeEvent clamps every child to the banner picture's width, so collect them + // once here to keep the resize handler from searching the widget tree on every pass. + fixedWidthChildren = {bannerCardDisplayWidget, uploadTimeLabel, colorIdentityWidget, tagsFlowWidget}; +} + +void PublicDeckPreviewWidget::resizeEvent(QResizeEvent *event) +{ + QWidget::resizeEvent(event); + if (bannerCardDisplayWidget == nullptr) { + return; + } + + const int width = bannerCardDisplayWidget->width(); + if (width == lastKnownBannerWidth) { + return; + } + lastKnownBannerWidth = width; + + for (QWidget *widget : fixedWidthChildren) { + widget->setMaximumWidth(width); + } +} + +void PublicDeckPreviewWidget::setEntry(const RemotePublicDecksModel::DeckEntry &entry) +{ + deckId = entry.id; + + hasColorIdentity = !entry.colorIdentity.isEmpty(); + colorIdentityWidget->setColorIdentity(entry.colorIdentity); + updateColorIdentityVisibility(); + + const ExactCard bannerCard = + entry.bannerCardName.isEmpty() + ? ExactCard() + : CardDatabaseManager::query()->getCard(CardRef{entry.bannerCardName, entry.bannerCardProvider}); + bannerCardDisplayWidget->setCard(bannerCard); + + // The deck name is the overlay text on the banner, like the local preview. + bannerCardDisplayWidget->setOverlayText(entry.name); + // The deck name comes from another user's record, and Qt tooltips are + // rendered as AutoText, so escape and bound it to keep it readable text + // (the overlay painted onto the banner is already a plain painter draw). + setToolTip(entry.name.left(200).toHtmlEscaped()); + setBaseAccessibleName(entry.name); + + tagsFlowWidget->clearLayout(); + for (const QString &tag : entry.tags) { + auto *chip = new DeckPreviewTagDisplayWidget(tagsFlowWidget, tag); + chip->setAttribute(Qt::WA_TransparentForMouseEvents); + tagsFlowWidget->addWidget(chip); + } + hasTags = !entry.tags.isEmpty(); + updateTagsVisibility(); + + uploadTimeLabel->setText(tr("Uploaded %1").arg(entry.uploadTime.toString(Qt::TextDate))); + hasUploadTime = !entry.uploadTime.isNull(); + updateUploadTimeVisibility(); +} + +void PublicDeckPreviewWidget::updateColorIdentityVisibility() +{ + colorIdentityWidget->setVisible( + hasColorIdentity && SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowColorIdentity()); +} + +void PublicDeckPreviewWidget::updateTagsVisibility() +{ + tagsFlowWidget->setVisible( + hasTags && SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowTagsOnDeckPreviews()); +} + +void PublicDeckPreviewWidget::updateUploadTimeVisibility() +{ + uploadTimeLabel->setVisible(hasUploadTime && + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowUploadTime()); +} + +void PublicDeckPreviewWidget::keyPressEvent(QKeyEvent *event) +{ + if (event->key() == Qt::Key_Space || event->key() == Qt::Key_Enter || event->key() == Qt::Key_Return) { + event->accept(); + emit openDeckRequested(deckId); + return; + } + QWidget::keyPressEvent(event); +} + +void PublicDeckPreviewWidget::setBaseAccessibleName(const QString &name) +{ + baseAccessibleName = name; + setAccessibleName(name); +} + +void PublicDeckPreviewWidget::setScaleFactor(int scale) +{ + bannerCardDisplayWidget->setScaleFactor(scale); +} + +void PublicDeckPreviewWidget::imageClickedEvent(QMouseEvent * /*event*/, DeckPreviewCardPictureWidget * /*instance*/) +{ + // Reserved: clicking could show a card popup for the banner card. +} + +void PublicDeckPreviewWidget::imageDoubleClickedEvent(QMouseEvent * /*event*/, + DeckPreviewCardPictureWidget * /*instance*/) +{ + emit openDeckRequested(deckId); +} diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.h new file mode 100644 index 000000000..a0e5dd43a --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.h @@ -0,0 +1,75 @@ +/** + * @file public_deck_preview_widget.h + * @ingroup VisualDeckPreviewWidgets + */ + +#ifndef PUBLIC_DECK_PREVIEW_WIDGET_H +#define PUBLIC_DECK_PREVIEW_WIDGET_H + +#include "../remote_public_decks_model.h" + +#include +#include +#include + +class ColorIdentityWidget; +class DeckPreviewCardPictureWidget; +class FlowWidget; +class QKeyEvent; +class QLabel; +class QMouseEvent; +class QResizeEvent; + +/** + * @brief A preview tile for a public deck published by another user. + * + * Renders the banner card picture (looked up by name/provider in the card + * database) with the deck name overlaid, the color identity, the deck's tags + * (read-only) and its upload time, all from the metadata the server stores for + * the deck, so no deck list is downloaded until the user actually opens the + * deck. Double-clicking the banner requests opening it. + */ +class PublicDeckPreviewWidget final : public QWidget +{ + Q_OBJECT + +public: + explicit PublicDeckPreviewWidget(QWidget *parent, const RemotePublicDecksModel::DeckEntry &entry); + + void setEntry(const RemotePublicDecksModel::DeckEntry &entry); + + /** @brief Sets the accessible name announced to assistive technologies. */ + void setBaseAccessibleName(const QString &name); + + /** @brief Scales the banner card picture, mirroring the Visual Deck Storage. */ + void setScaleFactor(int scale); + +signals: + void openDeckRequested(int deckId); + +protected: + void resizeEvent(QResizeEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; + +private slots: + void imageClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); + void imageDoubleClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); + void updateColorIdentityVisibility(); + void updateTagsVisibility(); + void updateUploadTimeVisibility(); + +private: + int deckId = 0; + QString baseAccessibleName; + bool hasColorIdentity = false; + bool hasTags = false; + bool hasUploadTime = false; + int lastKnownBannerWidth = 0; + QList fixedWidthChildren; + DeckPreviewCardPictureWidget *bannerCardDisplayWidget; + ColorIdentityWidget *colorIdentityWidget; + FlowWidget *tagsFlowWidget; + QLabel *uploadTimeLabel; +}; + +#endif // PUBLIC_DECK_PREVIEW_WIDGET_H diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/remote_public_decks_model.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/remote_public_decks_model.cpp new file mode 100644 index 000000000..a1cabd729 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/remote_public_decks_model.cpp @@ -0,0 +1,220 @@ +#include "remote_public_decks_model.h" + +#include "../../../client/settings/cache_settings.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +RemotePublicDecksModel::RemotePublicDecksModel(AbstractClient *_client, QObject *parent) + : QAbstractListModel(parent), client(_client) +{ + // The ping sweep can drop a pending command without ever emitting finished, + // so loading must not be a latch: time it out and clear it when the client + // goes away, or the tab is stuck on the loading state for the session. + loadingTimeoutTimer = new QTimer(this); + loadingTimeoutTimer->setSingleShot(true); + loadingTimeoutTimer->setInterval( + static_cast((static_cast(SettingsCache::instance().network().getTimeOut()) + 1) * + SettingsCache::instance().network().getKeepAlive() * 1000)); + connect(loadingTimeoutTimer, &QTimer::timeout, this, &RemotePublicDecksModel::onLoadingTimeout); + connect(client, &AbstractClient::statusChanged, this, [this](ClientStatus status) { + if (status == StatusDisconnected) { + loadingTimeoutTimer->stop(); + setLoading(false); + } + }); +} + +int RemotePublicDecksModel::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : visibleIndices.size(); +} + +QVariant RemotePublicDecksModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= visibleIndices.size()) { + return QVariant(); + } + if (role == Qt::DisplayRole || role == Qt::ToolTipRole) { + return decks.at(visibleIndices.at(index.row())).name; + } + return QVariant(); +} + +RemotePublicDecksModel::DeckEntry RemotePublicDecksModel::entryAt(int row) const +{ + if (row < 0 || row >= visibleIndices.size()) { + return DeckEntry{}; + } + return decks.at(visibleIndices.at(row)); +} + +void RemotePublicDecksModel::setSearchText(const QString &text) +{ + searchText = text.trimmed(); + rebuildVisibleIndices(); +} + +void RemotePublicDecksModel::setColorFilter(VisualDeckStorageSortFilterProxyModel::FilterMode mode, + const QSet &colors) +{ + colorFilterMode = mode; + activeColors = colors; + rebuildVisibleIndices(); +} + +void RemotePublicDecksModel::setTagFilter(const QSet &selected, const QSet &excluded) +{ + includedTags = selected; + excludedTags = excluded; + rebuildVisibleIndices(); +} + +QSet RemotePublicDecksModel::allTags() const +{ + QSet all; + for (const DeckEntry &entry : decks) { + all.unite(QSet(entry.tags.cbegin(), entry.tags.cend())); + } + return all; +} + +void RemotePublicDecksModel::rebuildVisibleIndices() +{ + QList newIndices; + newIndices.reserve(decks.size()); + for (int row = 0; row < decks.size(); ++row) { + const DeckEntry &entry = decks.at(row); + + if (!searchText.isEmpty() && !entry.name.contains(searchText, Qt::CaseInsensitive)) { + continue; + } + + if (!activeColors.isEmpty()) { + const QString &identity = entry.colorIdentity; + if (!colorIdentityMatches(colorFilterMode, activeColors, identity)) { + continue; + } + } + + if (!includedTags.isEmpty()) { + const QSet entryTags(entry.tags.cbegin(), entry.tags.cend()); + bool hasAll = std::all_of(includedTags.begin(), includedTags.end(), + [&entryTags](const QString &tag) { return entryTags.contains(tag); }); + if (!hasAll) { + continue; + } + } + + if (!excludedTags.isEmpty() && std::any_of(excludedTags.begin(), excludedTags.end(), + [&entry](const QString &tag) { return entry.tags.contains(tag); })) { + continue; + } + + newIndices.append(row); + } + + beginResetModel(); + visibleIndices = newIndices; + endResetModel(); +} + +void RemotePublicDecksModel::refresh(const QString &userName) +{ + if (loading) { + return; + } + // Every refresh captures its own request id so a reply that lands after its + // loading timeout (the reverse of the ping sweep dropping the command) is + // recognised as stale: it must not stop the newer request's timer or paint + // the grid with out-of-date data. + const int seq = ++requestSequence; + setLoading(true); + loadingTimeoutTimer->start(); + Command_DeckListOtherUser cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, + [this, seq](const Response &response, const CommandContainer &commandContainer) { + if (seq != requestSequence) { + return; // a newer refresh superseded this one + } + decksReceived(response, commandContainer); + }); + client->sendCommand(pend); +} + +void RemotePublicDecksModel::onLoadingTimeout() +{ + setLoading(false); + emit loadFailed(tr("The server did not respond in time. Try again.")); +} + +void RemotePublicDecksModel::clear() +{ + decks.clear(); + rebuildVisibleIndices(); +} + +void RemotePublicDecksModel::setLoading(bool value) +{ + if (loading == value) { + return; + } + loading = value; + emit loadingChanged(loading); +} + +void RemotePublicDecksModel::decksReceived(const Response &response, const CommandContainer & /*commandContainer*/) +{ + setLoading(false); + loadingTimeoutTimer->stop(); + if (response.response_code() != Response::RespOk) { + emit loadFailed(tr("Failed to load the user's public decks (server response code %1).") + .arg(QString::number(static_cast(response.response_code())))); + return; + } + + const Response_DeckList &resp = response.GetExtension(Response_DeckList::ext); + decks.clear(); + addFolder(resp.root()); + rebuildVisibleIndices(); +} + +void RemotePublicDecksModel::addFolder(const ServerInfo_DeckStorage_Folder &folder) +{ + const int itemCount = folder.items_size(); + for (int i = 0; i < itemCount; ++i) { + addTreeItem(folder.items(i)); + } +} + +void RemotePublicDecksModel::addTreeItem(const ServerInfo_DeckStorage_TreeItem &item) +{ + if (item.has_folder()) { + addFolder(item.folder()); + return; + } + + const ServerInfo_DeckStorage_File &file = item.file(); + DeckEntry entry; + entry.id = item.id(); + entry.name = QString::fromStdString(item.name()); + entry.uploadTime = QDateTime::fromSecsSinceEpoch(file.creation_time()); + entry.bannerCardName = QString::fromStdString(file.banner_card_name()); + entry.bannerCardProvider = QString::fromStdString(file.banner_card_provider()); + entry.colorIdentity = QString::fromStdString(file.color_identity()); + QStringList tags; + for (const auto &tag : file.tags()) { + tags.append(QString::fromStdString(tag)); + } + entry.tags = tags; + decks.append(entry); +} diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/remote_public_decks_model.h b/cockatrice/src/interface/widgets/visual_deck_storage/remote_public_decks_model.h new file mode 100644 index 000000000..eeb606442 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/remote_public_decks_model.h @@ -0,0 +1,129 @@ +/** + * @file remote_public_decks_model.h + * @ingroup DeckStorageWidgets + */ + +#ifndef REMOTE_PUBLIC_DECKS_MODEL_H +#define REMOTE_PUBLIC_DECKS_MODEL_H + +#include "visual_deck_storage_sort_filter_proxy_model.h" + +#include +#include +#include +#include +#include + +class AbstractClient; +class CommandContainer; +class QTimer; +class Response; +class ServerInfo_DeckStorage_Folder; +class ServerInfo_DeckStorage_TreeItem; + +/** + * @brief Flat, read-only list of the public decks published by another user. + * + * Fetches the target user's public decks via Command_DeckListOtherUser and + * flattens the response tree into entries carrying the preview metadata stored + * on the server (banner card name/provider and color identity). No deck list is + * downloaded until the user actually opens a deck. + * + * Name and color-identity filtering is applied against this metadata, mirroring + * the Visual Deck Storage's filter semantics, so the grid can be narrowed like + * the local deck storage. + */ +class RemotePublicDecksModel : public QAbstractListModel +{ + Q_OBJECT + +public: + struct DeckEntry + { + int id = 0; + QString name; + QDateTime uploadTime; + QString bannerCardName; + QString bannerCardProvider; + QString colorIdentity; + QStringList tags; + }; + + /** + * @brief The color identity filter mode, shared with the Visual Deck Storage. + */ + using FilterMode = VisualDeckStorageSortFilterProxyModel::FilterMode; + + explicit RemotePublicDecksModel(AbstractClient *client, QObject *parent = nullptr); + + [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; + [[nodiscard]] QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + /** @brief Fetches the public decks of another user, replacing the current contents. */ + void refresh(const QString &userName); + void clear(); + + /** @brief Sets a case-insensitive substring filter on the deck name. */ + void setSearchText(const QString &text); + + /** @brief Sets the active color identity filter and mode. */ + void setColorFilter(FilterMode mode, const QSet &colors); + + /** @brief Filters decks by required (`selected`) and forbidden (`excluded`) tags. */ + void setTagFilter(const QSet &selected, const QSet &excluded); + + /** @brief All tags present across all loaded decks, for building filter chips. */ + [[nodiscard]] QSet allTags() const; + + /** @brief The number of decks after filtering. */ + [[nodiscard]] int filteredCount() const + { + return visibleIndices.size(); + } + + /** @brief The number of decks before filtering. */ + [[nodiscard]] int totalCount() const + { + return decks.size(); + } + + /** @brief True while a refresh request is in flight and the grid has no data yet. */ + [[nodiscard]] bool isLoading() const + { + return loading; + } + + [[nodiscard]] DeckEntry entryAt(int row) const; + +signals: + /** @brief Emitted when a refresh starts, completes, or fails (see loading()). */ + void loadingChanged(bool loading); + + /** @brief Emitted when the last refresh failed; contains a user-facing message. */ + void loadFailed(const QString &message); + +private slots: + void decksReceived(const Response &response, const CommandContainer &commandContainer); + void onLoadingTimeout(); + +private: + void addFolder(const ServerInfo_DeckStorage_Folder &folder); + void addTreeItem(const ServerInfo_DeckStorage_TreeItem &item); + void rebuildVisibleIndices(); + void setLoading(bool value); + + AbstractClient *client; + QTimer *loadingTimeoutTimer; + QList decks; + QList visibleIndices; ///< Row indices into `decks` that pass the current filters. + bool loading = false; + int requestSequence = 0; ///< Monotonically increases per refresh; only the newest request may update the grid. + + QString searchText; + VisualDeckStorageSortFilterProxyModel::FilterMode colorFilterMode = VisualDeckStorageSortFilterProxyModel::Includes; + QSet activeColors; + QSet includedTags; + QSet excludedTags; +}; + +#endif // REMOTE_PUBLIC_DECKS_MODEL_H diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp index 22d73b604..911f3dee9 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp @@ -126,6 +126,11 @@ void VisualDeckStorageFolderDisplayWidget::continueDeckPass() const bool matches = index.data(VisualDeckStorageRoles::FilterMatchRole).toBool(); deckPreviewWidget->setVisible(matches); + if (!matches) { + // A deck that no longer matches the filters is dropped from the selection so its + // highlight cannot linger on an invisible preview or be counted in the share. + deckPreviewWidget->setShareSelected(false); + } if (matches) { ++visibleDeckCount; } @@ -209,6 +214,11 @@ DeckPreviewWidget *VisualDeckStorageFolderDisplayWidget::createDeckPreviewWidget &VisualDeckStorageWidget::deckLoadRequested); connect(deckPreviewWidget, &DeckPreviewWidget::openDeckEditor, visualDeckStorageWidget, &VisualDeckStorageWidget::openDeckEditor); + connect(deckPreviewWidget, &DeckPreviewWidget::shareDeckRequested, visualDeckStorageWidget, + &VisualDeckStorageWidget::shareDeckRequested); + connect(deckPreviewWidget, &DeckPreviewWidget::shareSelectionToggled, visualDeckStorageWidget, + &VisualDeckStorageWidget::shareSelectionChanged); + deckPreviewWidget->setShareSelectable(visualDeckStorageWidget->isShareSelectable()); connect(visualDeckStorageWidget->settings(), &VisualDeckStorageQuickSettingsWidget::cardSizeChanged, deckPreviewWidget->bannerCardDisplayWidget, &CardInfoPictureWidget::setScaleFactor); deckPreviewWidget->bannerCardDisplayWidget->setScaleFactor(visualDeckStorageWidget->settings()->getCardSize()); @@ -216,6 +226,18 @@ DeckPreviewWidget *VisualDeckStorageFolderDisplayWidget::createDeckPreviewWidget return deckPreviewWidget; } +void VisualDeckStorageFolderDisplayWidget::setShareSelectable(bool selectable) +{ + const auto previews = flowWidget->findChildren(); + for (DeckPreviewWidget *preview : previews) { + preview->setShareSelectable(selectable); + } + const auto subFolders = findChildren(); + for (VisualDeckStorageFolderDisplayWidget *subFolder : subFolders) { + subFolder->setShareSelectable(selectable); + } +} + /** * @brief Creates, removes and keeps in sync the subfolder widgets of this folder. * diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.h index 257ce1778..5a81457b0 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.h @@ -51,6 +51,7 @@ public slots: */ void scheduleReconcile(); void updateShowFolders(bool enabled); + void setShareSelectable(bool selectable); signals: /** diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp index 5d0006539..daca55fc8 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp @@ -1,6 +1,7 @@ #include "visual_deck_storage_model.h" #include "../../deck_loader/deck_loader.h" +#include "../cards/additional_info/deck_color_identity.h" #include #include @@ -119,8 +120,6 @@ DeckScanResult scanDeckDirectory(const QString &deckPath) } } // namespace -static QString computeColorIdentity(const LoadedDeck &deck); - VisualDeckStorageModel::VisualDeckStorageModel(QObject *parent) : QAbstractListModel(parent) { } @@ -306,7 +305,7 @@ void VisualDeckStorageModel::beginLoad(int row) } // Color identity walks every card through the database, so compute it here to // keep the completion handler on the UI thread cheap. - const QString colorIdentity = computeColorIdentity(*deck); + const QString colorIdentity = getDeckColorIdentity(deck->deckList, CardDatabaseManager::query()); return DeckLoadResult{std::move(*deck), QFileInfo(filePath).lastModified(), colorIdentity}; })); } @@ -367,40 +366,6 @@ void VisualDeckStorageModel::drainPendingLoads() } } -/** - * @brief Computes the color identity of a deck in WUBRG order. - */ -static QString computeColorIdentity(const LoadedDeck &deck) -{ - QStringList cardList = deck.deckList.getCardList({DECK_ZONE_MAIN, DECK_ZONE_SIDE}); - if (cardList.isEmpty()) { - return {}; - } - - QSet colorSet; // A set to collect unique color symbols (e.g., W, U, B, R, G) - - for (const QString &cardName : cardList) { - CardInfoPtr currentCard = CardDatabaseManager::query()->getCardInfo(cardName); - if (currentCard) { - const QString colors = currentCard->getColors(); // Something like "WUB" - for (const QChar &color : colors) { - colorSet.insert(color); - } - } - } - - // Ensure the color identity is in WUBRG order - QString colorIdentity; - const QString wubrgOrder = "WUBRG"; - for (const QChar &color : wubrgOrder) { - if (colorSet.contains(color)) { - colorIdentity.append(color); - } - } - - return colorIdentity; -} - /** * @brief Recomputes all derived metadata of a row from its loaded deck. */ @@ -414,7 +379,7 @@ void VisualDeckStorageModel::recomputeDeckMetadata(DeckPreviewData &data, bool r data.lastLoaded = QDateTime::fromString(deckList.getLastLoadedTimestamp()); data.bannerCard = deckList.getBannerCard(); if (recomputeColorIdentity) { - data.colorIdentity = computeColorIdentity(data.deck); + data.colorIdentity = getDeckColorIdentity(data.deck.deckList, CardDatabaseManager::query()); } } diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.cpp index 478431703..c19ac0fec 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.cpp @@ -50,6 +50,15 @@ VisualDeckStorageQuickSettingsWidget::VisualDeckStorageQuickSettingsWidget(QWidg &SettingsCache::instance().visualDeckStorage(), &VisualDeckStorageSettings::setVisualDeckStorageShowTagsOnDeckPreviews); + // show upload time on DeckPreviewWidget checkbox + showUploadTimeCheckBox = new QCheckBox(this); + showUploadTimeCheckBox->setChecked( + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowUploadTime()); + connect(showUploadTimeCheckBox, &QCheckBox::QT_STATE_CHANGED, this, + &VisualDeckStorageQuickSettingsWidget::showUploadTimeChanged); + connect(showUploadTimeCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::setVisualDeckStorageShowUploadTime); + // show banner card selector checkbox showBannerCardComboBoxCheckBox = new QCheckBox(this); showBannerCardComboBoxCheckBox->setChecked( @@ -94,7 +103,7 @@ VisualDeckStorageQuickSettingsWidget::VisualDeckStorageQuickSettingsWidget(QWidg unusedColorIdentityOpacityLayout->addWidget(unusedColorIdentitiesOpacitySpinBox); // tooltip selector - auto deckPreviewTooltipWidget = new QWidget(this); + deckPreviewTooltipWidget = new QWidget(this); deckPreviewTooltipLabel = new QLabel(deckPreviewTooltipWidget); deckPreviewTooltipComboBox = new QComboBox(deckPreviewTooltipWidget); @@ -128,6 +137,7 @@ VisualDeckStorageQuickSettingsWidget::VisualDeckStorageQuickSettingsWidget(QWidg this->addSettingsWidget(showTagFilterCheckBox); this->addSettingsWidget(showColorIdentityCheckBox); this->addSettingsWidget(showTagsOnDeckPreviewsCheckBox); + this->addSettingsWidget(showUploadTimeCheckBox); this->addSettingsWidget(showBannerCardComboBoxCheckBox); this->addSettingsWidget(drawUnusedColorIdentitiesCheckBox); this->addSettingsWidget(unusedColorIdentityOpacityWidget); @@ -145,6 +155,7 @@ void VisualDeckStorageQuickSettingsWidget::retranslateUi() showTagFilterCheckBox->setText(tr("Show Tag Filter")); showColorIdentityCheckBox->setText(tr("Show Color Identity")); showTagsOnDeckPreviewsCheckBox->setText(tr("Show Tags On Deck Previews")); + showUploadTimeCheckBox->setText(tr("Show Upload Time")); showBannerCardComboBoxCheckBox->setText(tr("Show Banner Card Selection Option")); drawUnusedColorIdentitiesCheckBox->setText(tr("Draw unused Color Identities")); unusedColorIdentitiesOpacityLabel->setText(tr("Unused Color Identities Opacity")); @@ -155,6 +166,14 @@ void VisualDeckStorageQuickSettingsWidget::retranslateUi() deckPreviewTooltipComboBox->setItemText(1, tr("Filepath")); } +void VisualDeckStorageQuickSettingsWidget::setPublicDecksMode(bool enabled) +{ + const bool hidden = enabled; + showFoldersCheckBox->setVisible(!hidden); + showBannerCardComboBoxCheckBox->setVisible(!hidden); + deckPreviewTooltipWidget->setVisible(!hidden); +} + bool VisualDeckStorageQuickSettingsWidget::getShowFolders() const { return showFoldersCheckBox->isChecked(); @@ -185,6 +204,11 @@ bool VisualDeckStorageQuickSettingsWidget::getShowTagsOnDeckPreviews() const return showTagsOnDeckPreviewsCheckBox->isChecked(); } +bool VisualDeckStorageQuickSettingsWidget::getShowUploadTime() const +{ + return showUploadTimeCheckBox->isChecked(); +} + int VisualDeckStorageQuickSettingsWidget::getUnusedColorIdentitiesOpacity() const { return unusedColorIdentitiesOpacitySpinBox->value(); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.h index ea4330a15..fc250cb20 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.h @@ -27,10 +27,12 @@ class VisualDeckStorageQuickSettingsWidget : public SettingsButtonWidget QCheckBox *showBannerCardComboBoxCheckBox; QCheckBox *showTagFilterCheckBox; QCheckBox *showTagsOnDeckPreviewsCheckBox; + QCheckBox *showUploadTimeCheckBox; QLabel *unusedColorIdentitiesOpacityLabel; QSpinBox *unusedColorIdentitiesOpacitySpinBox; QLabel *deckPreviewTooltipLabel; QComboBox *deckPreviewTooltipComboBox; + QWidget *deckPreviewTooltipWidget; CardSizeWidget *cardSizeWidget; public: @@ -46,6 +48,15 @@ public: explicit VisualDeckStorageQuickSettingsWidget(QWidget *parent = nullptr); + /** + * @brief Hides the controls that do not apply to the public decks tab. + * + * The public decks tab reuses this widget for its quick settings menu but + * has no folders, banner selection or per-deck tooltip, so those controls + * are hidden while every shared key keeps syncing with SettingsCache. + */ + void setPublicDecksMode(bool enabled); + void retranslateUi(); [[nodiscard]] bool getShowFolders() const; @@ -54,6 +65,7 @@ public: [[nodiscard]] bool getShowBannerCardComboBox() const; [[nodiscard]] bool getShowTagFilter() const; [[nodiscard]] bool getShowTagsOnDeckPreviews() const; + [[nodiscard]] bool getShowUploadTime() const; [[nodiscard]] int getUnusedColorIdentitiesOpacity() const; [[nodiscard]] TooltipType getDeckPreviewTooltip() const; [[nodiscard]] int getCardSize() const; @@ -65,6 +77,7 @@ signals: void showBannerCardComboBoxChanged(bool enabled); void showTagFilterChanged(bool enabled); void showTagsOnDeckPreviewsChanged(bool enabled); + void showUploadTimeChanged(bool enabled); void unusedColorIdentitiesOpacityChanged(int opacity); void deckPreviewTooltipChanged(TooltipType tooltip); void cardSizeChanged(int scale); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp index c05da1cb3..4b3a1ac29 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp @@ -11,6 +11,34 @@ VisualDeckStorageSortFilterProxyModel::VisualDeckStorageSortFilterProxyModel(QOb setDynamicSortFilter(false); } +bool colorIdentityMatches(VisualDeckStorageSortFilterProxyModel::FilterMode mode, + const QSet &colors, + const QString &identity) +{ + switch (mode) { + case VisualDeckStorageSortFilterProxyModel::ExactMatch: { + QSet activeColorSet; + for (const QChar &color : colors) { + activeColorSet.insert(color.toUpper()); + } + + QSet colorIdentitySet; + for (const QChar &color : identity) { + colorIdentitySet.insert(color.toUpper()); + } + + return activeColorSet == colorIdentitySet; + } + case VisualDeckStorageSortFilterProxyModel::Includes: + return std::all_of(colors.begin(), colors.end(), + [&identity](const QChar &color) { return identity.contains(color); }); + case VisualDeckStorageSortFilterProxyModel::Excludes: + return std::none_of(colors.begin(), colors.end(), + [&identity](const QChar &color) { return identity.contains(color); }); + } + return false; +} + void VisualDeckStorageSortFilterProxyModel::setSourceModel(QAbstractItemModel *model) { if (QAbstractItemModel *oldModel = sourceModel()) { @@ -255,34 +283,7 @@ void VisualDeckStorageSortFilterProxyModel::updateColorMatches() for (int row = 0; row < count; ++row) { const QString colorIdentity = source->dataForRow(row).colorIdentity; - - bool matches = true; - switch (colorFilterMode) { - case ExactMatch: { - QSet activeColorSet; - for (const QChar &color : activeColors) { - activeColorSet.insert(color.toUpper()); - } - - QSet colorIdentitySet; - for (const QChar &color : colorIdentity) { - colorIdentitySet.insert(color.toUpper()); - } - - matches = activeColorSet == colorIdentitySet; - break; - } - case Includes: - matches = std::all_of(activeColors.begin(), activeColors.end(), - [&colorIdentity](const QChar &color) { return colorIdentity.contains(color); }); - break; - case Excludes: - matches = std::none_of(activeColors.begin(), activeColors.end(), - [&colorIdentity](const QChar &color) { return colorIdentity.contains(color); }); - break; - } - - colorMatches[row] = matches; + colorMatches[row] = colorIdentityMatches(colorFilterMode, activeColors, colorIdentity); } } diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.h index 7e771f6a9..a6c40a2d7 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.h @@ -102,4 +102,14 @@ private: QList colorMatches; ///< Per-row color identity match. }; +/** + * @brief Whether an identity string matches the active color-identity filter. + * + * The single source of truth for the color identity matching rule, shared by + * the Visual Deck Storage proxy and the remote public decks model. + */ +[[nodiscard]] bool colorIdentityMatches(VisualDeckStorageSortFilterProxyModel::FilterMode mode, + const QSet &colors, + const QString &identity); + #endif // VISUAL_DECK_STORAGE_SORT_FILTER_PROXY_MODEL_H diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp index ba52cf8e9..6f954b01b 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp @@ -2,14 +2,10 @@ #include "../general/layout_containers/flow_widget.h" #include "deck_preview/deck_preview_tag_display_widget.h" -#include "visual_deck_storage_model.h" -#include "visual_deck_storage_sort_filter_proxy_model.h" -#include "visual_deck_storage_widget.h" #include -VisualDeckStorageTagFilterWidget::VisualDeckStorageTagFilterWidget(VisualDeckStorageWidget *_parent) - : QWidget(_parent), parent(_parent) +VisualDeckStorageTagFilterWidget::VisualDeckStorageTagFilterWidget(QWidget *parent) : QWidget(parent) { setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); @@ -25,99 +21,72 @@ VisualDeckStorageTagFilterWidget::VisualDeckStorageTagFilterWidget(VisualDeckSto layout->addWidget(flowWidget); } +void VisualDeckStorageTagFilterWidget::setAllTagsProvider(const std::function()> &provider) +{ + allTagsProvider = provider; +} + void VisualDeckStorageTagFilterWidget::showEvent(QShowEvent *event) { QWidget::showEvent(event); refreshTags(); } -/** - * @brief The tags of all decks currently accepted by the proxy model. - */ -QSet VisualDeckStorageTagFilterWidget::gatherAllTags() const -{ - QSet allTags; - auto *proxy = parent->proxyModel(); - - for (int proxyRow = 0; proxyRow < proxy->rowCount(); ++proxyRow) { - const QModelIndex index = proxy->index(proxyRow, 0); - if (!index.data(VisualDeckStorageRoles::FilterMatchRole).toBool()) { - continue; - } - const QStringList deckTags = index.data(VisualDeckStorageRoles::TagsRole).toStringList(); - for (const QString &tag : deckTags) { - allTags.insert(tag); - } - } - - return allTags; -} - void VisualDeckStorageTagFilterWidget::refreshTags() { - QSet allTags = gatherAllTags(); - removeTagsNotInList(allTags); - addTagsIfNotPresent(allTags); - sortTags(); -} + const QSet allTags = allTagsProvider ? allTagsProvider() : QSet(); -void VisualDeckStorageTagFilterWidget::removeTagsNotInList(const QSet &tags) -{ + // Existing chips survive if their tag is still part of the deck set, or if the chip + // is currently selected/excluded. Everything else is dropped. Dropped chips must NOT + // be re-added to the layout afterwards: they are reparented to nullptr and scheduled + // for a deferred delete (QWidget::setParent(nullptr) also hides them), and the flow + // layout would keep a dangling reference to them once the deletion runs on the next + // event-loop cycle. + QList chips; for (DeckPreviewTagDisplayWidget *tagWidget : findChildren()) { - const QString &tagName = tagWidget->getTagName(); - - // Keep the tag widget if it is either selected or excluded - if (!tags.contains(tagName) && tagWidget->getState() == TagState::NotSelected) { + if (tagWidget->getState() != TagState::NotSelected || allTags.contains(tagWidget->getTagName())) { + chips.append(tagWidget); + } else { flowWidget->removeWidget(tagWidget); + tagWidget->setParent(nullptr); tagWidget->deleteLater(); } } -} -void VisualDeckStorageTagFilterWidget::addTagsIfNotPresent(const QSet &tags) -{ - for (const QString &tag : tags) { - addTagIfNotPresent(tag); + // Add chips for tags that are not shown yet. + QSet existingTags; + for (DeckPreviewTagDisplayWidget *tagWidget : chips) { + existingTags.insert(tagWidget->getTagName()); } -} - -void VisualDeckStorageTagFilterWidget::addTagIfNotPresent(const QString &tag) -{ - // Check if the tag already exists in the flow widget - bool tagExists = false; - for (DeckPreviewTagDisplayWidget *tagWidget : findChildren()) { - if (tagWidget->getTagName() == tag) { - tagExists = true; - break; + for (const QString &tag : allTags) { + if (!existingTags.contains(tag)) { + auto *newTagWidget = new DeckPreviewTagDisplayWidget(this, tag); + connect(newTagWidget, &DeckPreviewTagDisplayWidget::tagClicked, this, + &VisualDeckStorageTagFilterWidget::filterChanged); + flowWidget->addWidget(newTagWidget); + chips.append(newTagWidget); } } - // If the tag doesn't exist, add a new DeckPreviewTagDisplayWidget - if (!tagExists) { - auto *newTagWidget = new DeckPreviewTagDisplayWidget(this, tag); - connect(newTagWidget, &DeckPreviewTagDisplayWidget::tagClicked, parent, - &VisualDeckStorageWidget::updateTagFilter); - flowWidget->addWidget(newTagWidget); - } -} - -void VisualDeckStorageTagFilterWidget::sortTags() -{ - // Get all tag widgets - QList tagWidgets = findChildren(); - - // Sort widgets by tag name - std::sort(tagWidgets.begin(), tagWidgets.end(), [](DeckPreviewTagDisplayWidget *a, DeckPreviewTagDisplayWidget *b) { - return a->getTagName().toLower() < b->getTagName().toLower(); + // Sort, but skip the full remove/re-add when the order already matches the layout. + // FlowWidget inherits QLayout::removeWidget's linear scan, so rebuilding an unchanged + // order would be quadratic plus a full relayout on every chip click and load batch. + std::sort(chips.begin(), chips.end(), [](DeckPreviewTagDisplayWidget *a, DeckPreviewTagDisplayWidget *b) { + const QString aName = a->getTagName(); + const QString bName = b->getTagName(); + const int compared = aName.compare(bName, Qt::CaseInsensitive); + return compared != 0 ? compared < 0 : aName < bName; }); - - // Clear and re-add widgets in sorted order - for (DeckPreviewTagDisplayWidget *tagWidget : tagWidgets) { + if (chips == currentChipOrder) { + return; + } + for (DeckPreviewTagDisplayWidget *tagWidget : chips) { flowWidget->removeWidget(tagWidget); } - for (DeckPreviewTagDisplayWidget *tagWidget : tagWidgets) { + for (DeckPreviewTagDisplayWidget *tagWidget : chips) { flowWidget->addWidget(tagWidget); } + currentChipOrder = chips; } QStringList VisualDeckStorageTagFilterWidget::selectedTags() const diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.h index 337c053c7..5e3cb398c 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.h @@ -9,26 +9,28 @@ #include #include #include +#include +class DeckPreviewTagDisplayWidget; class FlowWidget; -class VisualDeckStorageWidget; + class VisualDeckStorageTagFilterWidget : public QWidget { Q_OBJECT - VisualDeckStorageWidget *parent; FlowWidget *flowWidget; - - [[nodiscard]] QSet gatherAllTags() const; - void removeTagsNotInList(const QSet &tags); - void addTagsIfNotPresent(const QSet &tags); - void addTagIfNotPresent(const QString &tag); - void sortTags(); + std::function()> allTagsProvider; + QList currentChipOrder; public: - explicit VisualDeckStorageTagFilterWidget(VisualDeckStorageWidget *_parent); + explicit VisualDeckStorageTagFilterWidget(QWidget *parent = nullptr); [[nodiscard]] QStringList getAllKnownTags() const; + /** + * @brief Sets a provider for the full set of tags to draw chips from. + */ + void setAllTagsProvider(const std::function()> &provider); + /** * @brief The tags currently in "selected" state. */ @@ -39,9 +41,18 @@ public: */ [[nodiscard]] QStringList excludedTags() const; +signals: + /** + * Emitted when a chip's selection or exclusion state changes. + * + * The chip only emits when its state actually changed, so this fires once per + * effective toggle rather than on every click. + */ + void filterChanged(); + public slots: /** - * @brief Rebuilds the tag chips from the tags of the currently visible decks. + * @brief Rebuilds the tag chips from the currently available tags. */ void refreshTags(); void showEvent(QShowEvent *event) override; diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp index da7ddc368..bd167ec5b 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp @@ -48,6 +48,12 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare refreshButton->setFixedSize(32, 32); connect(refreshButton, &QPushButton::clicked, this, &VisualDeckStorageWidget::refreshIfPossible); + shareButton = new QToolButton(this); + shareButton->setIcon(themePixmap(QStringLiteral("icons/share"))); + shareButton->setFixedSize(32, 32); + shareButton->setVisible(false); + connect(shareButton, &QPushButton::clicked, this, &VisualDeckStorageWidget::shareRequested); + quickSettingsWidget = new VisualDeckStorageQuickSettingsWidget(this); connect(quickSettingsWidget, &VisualDeckStorageQuickSettingsWidget::showFoldersChanged, this, &VisualDeckStorageWidget::updateShowFolders); @@ -58,10 +64,14 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare searchAndSortLayout->addWidget(sortWidget); searchAndSortLayout->addWidget(searchWidget); searchAndSortLayout->addWidget(refreshButton); + searchAndSortLayout->addWidget(shareButton); searchAndSortLayout->addWidget(quickSettingsWidget); // tag filter box tagFilterWidget = new VisualDeckStorageTagFilterWidget(this); + tagFilterWidget->setAllTagsProvider([this] { return gatherVisibleTags(); }); + connect(tagFilterWidget, &VisualDeckStorageTagFilterWidget::filterChanged, this, + &VisualDeckStorageWidget::updateTagFilter); updateTagsVisibility(SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowTagFilter()); deckPreviewSelectionAnimationEnabled = @@ -155,11 +165,71 @@ void VisualDeckStorageWidget::retranslateUi() databaseLoadIndicator->setText(tr("Loading database ...")); refreshButton->setToolTip(tr("Refresh loaded files")); + shareButton->setToolTip(tr("Select decks to share")); quickSettingsWidget->setToolTip(tr("Visual Deck Storage Settings")); sortWidget->retranslateUi(); } +void VisualDeckStorageWidget::setShareSelectable(bool selectable) +{ + if (shareSelectable == selectable) { + return; + } + shareSelectable = selectable; + if (folderWidget != nullptr) { + folderWidget->setShareSelectable(selectable); + } + emit shareSelectionChanged(); +} + +bool VisualDeckStorageWidget::isShareSelectable() const +{ + return shareSelectable; +} + +QStringList VisualDeckStorageWidget::selectedFilePaths() const +{ + QStringList selectedPaths; + if (folderWidget != nullptr) { + const auto previews = folderWidget->findChildren(); + for (DeckPreviewWidget *preview : previews) { + // Filtered-out previews stay alive hidden in their sorted place, so only decks the + // user can actually see are part of the share. + if (preview->isVisible() && preview->isShareSelected()) { + selectedPaths.append(preview->filePath); + } + } + } + return selectedPaths; +} + +void VisualDeckStorageWidget::clearShareSelection() +{ + if (folderWidget != nullptr) { + const auto previews = folderWidget->findChildren(); + for (DeckPreviewWidget *preview : previews) { + preview->setShareSelected(false); + } + } +} + +void VisualDeckStorageWidget::setShareAvailable(bool available) +{ + shareButton->setVisible(available); + shareButton->setEnabled(available); +} + +void VisualDeckStorageWidget::setShareSelectedFiles(const QStringList &paths) +{ + if (folderWidget != nullptr) { + const auto previews = folderWidget->findChildren(); + for (DeckPreviewWidget *preview : previews) { + preview->setShareSelected(paths.contains(preview->filePath)); + } + } +} + /** * Gets a const pointer to the quick settings so that the values can be accessed. */ @@ -217,6 +287,25 @@ void VisualDeckStorageWidget::updateTagFilter() tagFilterWidget->refreshTags(); } +/** + * @brief The tags of all decks currently accepted by the proxy model. + */ +QSet VisualDeckStorageWidget::gatherVisibleTags() const +{ + QSet allTags; + for (int proxyRow = 0; proxyRow < storageProxyModel->rowCount(); ++proxyRow) { + const QModelIndex index = storageProxyModel->index(proxyRow, 0); + if (!index.data(VisualDeckStorageRoles::FilterMatchRole).toBool()) { + continue; + } + const QStringList deckTags = index.data(VisualDeckStorageRoles::TagsRole).toStringList(); + for (const QString &tag : deckTags) { + allTags.insert(tag); + } + } + return allTags; +} + /** * Pushes the color identity filter widget's state into the proxy model. */ diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h index fe6389414..dfb715bea 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h @@ -33,6 +33,12 @@ public: explicit VisualDeckStorageWidget(QWidget *parent); void refreshIfPossible(); void retranslateUi(); + void setShareSelectable(bool selectable); + [[nodiscard]] bool isShareSelectable() const; + [[nodiscard]] QStringList selectedFilePaths() const; + void setShareSelectedFiles(const QStringList &paths); + void clearShareSelection(); + void setShareAvailable(bool available); VisualDeckStorageTagFilterWidget *tagFilterWidget; bool deckPreviewSelectionAnimationEnabled; @@ -63,6 +69,9 @@ public slots: signals: void deckLoadRequested(const QString &filePath); void openDeckEditor(const LoadedDeck &deck); + void shareDeckRequested(const QString &filePath); + void shareSelectionChanged(); + void shareRequested(); protected: void resizeEvent(QResizeEvent *event) override; @@ -70,6 +79,7 @@ protected: private: void reapplySortAndFilters(); + [[nodiscard]] QSet gatherVisibleTags() const; private: QVBoxLayout *layout; @@ -80,12 +90,14 @@ private: VisualDeckStorageSearchWidget *searchWidget; DeckPreviewColorIdentityFilterWidget *deckPreviewColorIdentityFilterWidget; QToolButton *refreshButton; + QToolButton *shareButton; VisualDeckStorageQuickSettingsWidget *quickSettingsWidget; QScrollArea *scrollArea; VisualDeckStorageFolderDisplayWidget *folderWidget = nullptr; VisualDeckStorageModel *storageModel = nullptr; VisualDeckStorageSortFilterProxyModel *storageProxyModel = nullptr; QTimer *refreshTimer = nullptr; ///< Coalesces the re-apply/refresh burst following a batch of deck loads. + bool shareSelectable = false; }; #endif // VISUAL_DECK_STORAGE_WIDGET_H diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 3daaeb8d3..595d38d5b 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -33,6 +33,7 @@ #include "../interface/widgets/dialogs/dlg_update.h" #include "../interface/widgets/dialogs/dlg_view_log.h" #include "../interface/widgets/onboarding/first_run_wizard.h" +#include "../interface/widgets/settings_page/general_settings_page.h" #include "../interface/widgets/tabs/tab_game.h" #include "../interface/widgets/tabs/tab_server.h" #include "../interface/widgets/tabs/tab_supervisor.h" @@ -246,6 +247,8 @@ void MainWindow::actFullScreen(bool checked) void MainWindow::actSettings() { DlgSettings dlg(this); + auto *generalPage = qobject_cast(dlg.page(DlgSettings::GeneralPage)); + connect(generalPage, &GeneralSettingsPage::cardDatabaseUpdateRequested, this, &MainWindow::actCheckCardUpdates); dlg.exec(); } @@ -509,6 +512,7 @@ MainWindow::MainWindow(QWidget *parent) connectionController = new ConnectionController(this, this); urlParser = new IntentUrlParser(this, this); + connect(urlParser, &IntentUrlParser::urlChainFinished, this, &MainWindow::onUrlChainFinished); createActions(); createMenus(); @@ -704,6 +708,12 @@ void MainWindow::applyStartupDestination() return; } + // A cockatrice:// link owns the startup connection while its chain runs; + // connecting here would race (and tear down) the link's own connection. + if (skipStartupAutoConnect) { + return; + } + const int destination = SettingsCache::instance().tabs().getStartupTabIndex(); if (destination != StartupTab::StartupTabServer && destination != StartupTab::StartupTabServerRoom) { return; @@ -725,6 +735,7 @@ void MainWindow::applyStartupDestination() connect(credentials, &Intent::finished, connector, &Intent::execute); connect(credentials, &Intent::failed, this, &MainWindow::startupDestinationFailed); + connect(credentials, &Intent::cancelled, this, [this]() { startupDestinationFailed(tr("Sign-in cancelled")); }); connect(connector, &Intent::finished, this, [this, destination, serverContext]() { onStartupDestinationConnected(destination, *serverContext); }); connect(connector, &Intent::failed, this, &MainWindow::startupDestinationFailed); @@ -876,18 +887,7 @@ void MainWindow::changeEvent(QEvent *event) } else if (event->type() == QEvent::ActivationChange) { if (isActiveWindow() && !bHasActivated) { bHasActivated = true; - if (!connectTo.isEmpty()) { - qCInfo(WindowMainStartupAutoconnectLog) << "Command line connect to " << connectTo; - connectionController->connectToServerDirect(connectTo.host(), connectTo.port(), connectTo.userName(), - connectTo.password()); - } else if (SettingsCache::instance().servers().getAutoConnect() && - !SettingsCache::instance().debug().getLocalGameOnStartup() && - !startupDestinationConnectsToServer()) { - qCInfo(WindowMainStartupAutoconnectLog) << "Attempting auto-connect..."; - DlgConnect dlg(this); - connectionController->connectToServerDirect(dlg.getHost(), static_cast(dlg.getPort()), - dlg.getPlayerName(), dlg.getPassword()); - } + attemptStartupAutoConnect(); } } @@ -913,6 +913,59 @@ void MainWindow::handleCockatriceLink(const QString &url) urlParser->handle(url); } +void MainWindow::attemptStartupAutoConnect() +{ + if (startupAutoConnectAttempted || skipStartupAutoConnect) { + return; + } + startupAutoConnectAttempted = true; + + if (!connectTo.isEmpty()) { + qCInfo(WindowMainStartupAutoconnectLog) << "Command line connect to " << connectTo; + connectionController->connectToServerDirect(connectTo.host(), connectTo.port(), connectTo.userName(), + connectTo.password()); + } else if (SettingsCache::instance().servers().getAutoConnect() && + !SettingsCache::instance().debug().getLocalGameOnStartup() && !startupDestinationConnectsToServer()) { + qCInfo(WindowMainStartupAutoconnectLog) << "Attempting auto-connect..."; + DlgConnect dlg(this); + connectionController->connectToServerDirect(dlg.getHost(), static_cast(dlg.getPort()), + dlg.getPlayerName(), dlg.getPassword()); + } +} + +void MainWindow::onUrlChainFinished(bool connected) +{ + // A cockatrice:// link owns the startup connection while it runs. When its + // chain ended without connecting (declined, invalid, offline), fall back to + // the startup connection so the activation launch still behaves like a + // normal launch. + if (connected) { + // The launch link connected, so the startup fallback has served its + // purpose: drop the skip so a later mid-session link that ends declined + // or offline cannot silently fire auto-connect or applyStartupDestination + // again. + skipStartupAutoConnect = false; + return; + } + + if (!skipStartupAutoConnect || getRemoteClient()->getStatus() != StatusDisconnected) { + return; + } + + if (startupDestinationConnectsToServer()) { + // Users whose startup tab is a Server / Server Room connect through the + // startup destination, not through auto-connect; retry that instead. + qCInfo(WindowMainStartupAutoconnectLog) << "URL chain ended without a connection; retrying startup destination"; + skipStartupAutoConnect = false; + applyStartupDestination(); + return; + } + + qCInfo(WindowMainStartupAutoconnectLog) << "URL chain ended without a connection; retrying startup connect"; + skipStartupAutoConnect = false; + attemptStartupAutoConnect(); +} + void MainWindow::cardDatabaseLoadingFailed() { if (askedForDbUpdater) { diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index 08481fd36..9e45a4c3e 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -80,6 +80,7 @@ public slots: void actCheckClientUpdates(); void actConnect(); void actExit(); + void handleCockatriceLink(const QString &url); private slots: void updateTabMenu(const QList &newMenuList); void statusChanged(ClientStatus _status); @@ -97,7 +98,7 @@ private slots: void actOpenSettingsFolder(); void actShow(); void showWindowIfHidden(); - void handleCockatriceLink(const QString &url); + void onUrlChainFinished(bool connected); void cardUpdateError(QProcess::ProcessError err); void cardUpdateFinished(int exitCode, QProcess::ExitStatus exitStatus); @@ -126,6 +127,8 @@ private slots: void startupDestinationFailed(const QString &reason); [[nodiscard]] bool startupDestinationConnectsToServer() const; + void attemptStartupAutoConnect(); + private: static const QString appName; static const QStringList fileNameFilters; @@ -164,6 +167,8 @@ private: LagMonitor lagMonitor; ///< watches the main thread for event loop stalls LatencyStatusWidget *latencyStatus = nullptr; ///< status bar widget with live round-trip stats and history graph bool bHasActivated, askedForDbUpdater; + bool skipStartupAutoConnect = false; + bool startupAutoConnectAttempted = false; QProcess *cardUpdateProcess; QByteArray cardUpdateOutputBuffer; DlgViewLog *logviewDialog; @@ -177,6 +182,16 @@ public: { connectTo = QUrl(QString("cockatrice://%1").arg(url)); } + // When set, the window's own startup connection (--connect or auto-connect + // on first activation) is skipped. Used for activation launches: the intent + // chain triggered by a cockatrice:// URL owns the connection, and letting + // auto-connect race against it caused two connectToServer calls to tear + // each other down. onUrlChainFinished() clears this and retries the startup + // connection when the link's chain ended without connecting. + void setSkipStartupAutoConnect(bool skip) + { + skipStartupAutoConnect = skip; + } ~MainWindow() override; RemoteClient *getRemoteClient() const diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp index 829e08742..b9c30d1ad 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -26,7 +26,6 @@ #include "client/url_scheme_event_filter.h" #include "database/interface/settings_card_preference_provider.h" #include "interface/intents/intent_open_local_deck.h" -#include "interface/intents/url_parser.h" #include "interface/logger.h" #include "interface/pixel_map_generator.h" #include "interface/theme_manager.h" @@ -45,6 +44,8 @@ #include #include #include +#include +#include #include #include #include @@ -177,6 +178,18 @@ QString const generateClientID() return strClientID; } +static QString redactActivationUrl(const QString &url) +{ + // Activation URLs carry secrets in their query string (e.g. the deck share + // token); log only the scheme and the action (cockatrice://opendeck), never + // the parameters. + if (!url.startsWith(QStringLiteral("cockatrice://"))) { + return url; + } + const QUrl parsed(url); + return parsed.scheme() + "://" + parsed.host(); +} + int main(int argc, char *argv[]) { #ifdef Q_OS_WIN @@ -273,10 +286,17 @@ int main(int argc, char *argv[]) SingleInstanceManager instance; if (hasActivationFiles) { + QStringList redactedFiles; + redactedFiles.reserve(startupFiles.size()); + for (const QString &file : startupFiles) { + redactedFiles.append(redactActivationUrl(file)); + } + qCInfo(MainLog) << "Activation launch, files:" << redactedFiles; // Activation launch: hand off to the primary instance if one is // running, otherwise become the primary ourselves. Do this before // constructing the main window so a hand-off exits cheaply. if (!instance.tryRun(startupFiles)) { + qInfo() << "Handed off to a running instance, exiting"; // Sent successfully → exit return 0; } @@ -325,10 +345,30 @@ int main(int argc, char *argv[]) MainWindow ui; + // A URL launch must own the connection: the intent chain triggered by the + // URL connects to the server named in the URL, so the window's own startup + // auto-connect must not race against it (two connectToServer calls tear + // each other down via doDisconnectFromServer). + bool hasUrlActivation = std::any_of(startupFiles.begin(), startupFiles.end(), [](const QString &file) { + return file.startsWith(QStringLiteral("cockatrice://")); + }); +#ifdef Q_OS_MAC + // On macOS the launch can arrive through the URL scheme instead of as a + // positional argument (captured in pendingMacUrls); count those too or the + // window would auto-connect into the link's own connection attempt. + hasUrlActivation = hasUrlActivation || + std::any_of(pendingMacUrls.cbegin(), pendingMacUrls.cend(), + [](const QString &url) { return url.startsWith(QStringLiteral("cockatrice://")); }); +#endif + ui.setSkipStartupAutoConnect(hasUrlActivation); + auto handleActivation = [&ui](const QString &file) { if (file.startsWith("cockatrice://")) { - auto urlParser = new IntentUrlParser(&ui, &ui); - urlParser->handle(file); + qCInfo(MainLog) << "Handling URL activation:" << redactActivationUrl(file); + // Route through the window's persistent url parser: it serializes + // link chains so activations handed over while another chain is + // still connecting do not connect concurrently. + ui.handleCockatriceLink(file); } else if (QFileInfo(file).exists()) { auto openDeckIntent = new IntentOpenLocalDeck(ui.getTabSupervisor(), file); QObject::connect(openDeckIntent, &Intent::failed, &ui, [&ui](const QString &reason) { diff --git a/cockatrice/src/single_instance_manager.cpp b/cockatrice/src/single_instance_manager.cpp index aca23160c..eff0e6586 100644 --- a/cockatrice/src/single_instance_manager.cpp +++ b/cockatrice/src/single_instance_manager.cpp @@ -2,6 +2,14 @@ #include +namespace +{ +// Sent by the primary instance after it has read a forwarded payload. Without +// an acknowledgment, a second instance cannot tell a live primary apart from a +// stale socket left behind by a process that is still shutting down. +const QByteArray ACK_MESSAGE = QByteArrayLiteral("COCKATRICE_ACK"); +} // namespace + SingleInstanceManager::SingleInstanceManager(QObject *parent) : QObject(parent) { } @@ -20,9 +28,15 @@ bool SingleInstanceManager::tryRun(const QStringList &filesToSend) } serverName = QStringLiteral("CockatriceSingleInstance-%1").arg(userName); - // Hand off to an already-running primary instance if one exists. - if (forwardToPrimary(filesToSend)) { - return false; + // Hand off to an already-running primary instance if one exists. Never steal + // the socket of a busy primary: it is alive and will act on the payload. + switch (forwardToPrimary(filesToSend)) { + case ForwardResult::Delivered: + return false; + case ForwardResult::PrimaryBusy: + return false; + case ForwardResult::NoPrimary: + break; } // No primary instance is currently reachable, so become the primary. @@ -35,12 +49,18 @@ bool SingleInstanceManager::tryRun(const QStringList &filesToSend) // Another instance may have started while we were probing; hand off to it // instead of stealing its socket. - if (forwardToPrimary(filesToSend)) { - return false; + switch (forwardToPrimary(filesToSend)) { + case ForwardResult::Delivered: + return false; + case ForwardResult::PrimaryBusy: + return false; + case ForwardResult::NoPrimary: + break; } - // The socket is stale (left over by a crashed instance): remove it and - // retry. If that still fails, another instance just took the name. + // The socket is stale (left over by a crashed instance), so no primary is + // holding it: remove it and retry. If that still fails, another instance + // just took the name. QLocalServer::removeServer(serverName); if (server->listen(serverName)) { return true; @@ -50,12 +70,12 @@ bool SingleInstanceManager::tryRun(const QStringList &filesToSend) return false; } -bool SingleInstanceManager::forwardToPrimary(const QStringList &filesToSend) +SingleInstanceManager::ForwardResult SingleInstanceManager::forwardToPrimary(const QStringList &filesToSend) { QLocalSocket socket; socket.connectToServer(serverName); if (!socket.waitForConnected(200)) { - return false; + return ForwardResult::NoPrimary; } // Serialize payload with length prefix @@ -72,7 +92,23 @@ bool SingleInstanceManager::forwardToPrimary(const QStringList &filesToSend) socket.flush(); socket.waitForBytesWritten(1000); - return true; + // A plain launch has nothing for the primary to act on, so there is nothing + // to acknowledge. Waiting here would block the new instance for seconds if + // the primary is busy in a modal dialog, so only the activation path (which + // needs the ACK to avoid stealing a live primary's socket) waits below. + if (filesToSend.isEmpty()) { + return ForwardResult::Delivered; + } + + // Only report a successful hand-off once the primary has acknowledged that + // it actually read the payload. A socket that connects but is still working + // on an earlier payload is alive but busy, not dead: give it more room + // before giving up, so a slow handler does not make a live primary look + // dead (which would lead to stealing its socket). + if (!socket.waitForReadyRead(1000) && !socket.waitForReadyRead(4000)) { + return ForwardResult::PrimaryBusy; + } + return socket.readAll() == ACK_MESSAGE ? ForwardResult::Delivered : ForwardResult::PrimaryBusy; } void SingleInstanceManager::handleNewConnection() @@ -111,12 +147,23 @@ void SingleInstanceManager::handleNewConnection() QStringList files; payloadStream >> files; - emit filesReceived(files); + // Acknowledge receipt as soon as the payload is parsed, before the + // primary starts handling it. The handlers run synchronously and can + // take longer than the sender's readiness timeout (e.g. a modal + // confirmation box), which would otherwise make a live primary look + // dead and cause duplicate handling. + socket->write(ACK_MESSAGE); + socket->flush(); - // Reset buffer (single message use-case) + // Drop the payload from the buffer before handling it: the handlers + // run synchronously and can spin a nested event loop (e.g. a modal + // dialog) that re-reads this socket, which would re-parse and re-emit + // the same files. buffer->clear(); *expectedSize = 0; + emit filesReceived(files); + socket->disconnectFromServer(); return; } diff --git a/cockatrice/src/single_instance_manager.h b/cockatrice/src/single_instance_manager.h index 55bff0e80..d3884a60f 100644 --- a/cockatrice/src/single_instance_manager.h +++ b/cockatrice/src/single_instance_manager.h @@ -23,7 +23,14 @@ private slots: void handleNewConnection(); private: - bool forwardToPrimary(const QStringList &filesToSend); + enum class ForwardResult + { + Delivered, // a live primary acknowledged the payload + NoPrimary, // no connectable primary socket exists + PrimaryBusy // a primary exists but did not acknowledge in time + }; + + ForwardResult forwardToPrimary(const QStringList &filesToSend); QString serverName; QLocalServer *server = nullptr; diff --git a/cockatrice/themes/CMakeLists.txt b/cockatrice/themes/CMakeLists.txt index 577977551..70344d95a 100644 --- a/cockatrice/themes/CMakeLists.txt +++ b/cockatrice/themes/CMakeLists.txt @@ -2,7 +2,7 @@ # # add themes subfolders -set(defthemes Default Fabric Fusion Leather Plasma VelvetMarble) +set(defthemes Fabric Fusion Leather Plasma VelvetMarble System) if(UNIX) if(APPLE) diff --git a/cockatrice/themes/Fabric/backgrounds/home-dark.png b/cockatrice/themes/Fabric/backgrounds/home-dark.png new file mode 100644 index 000000000..8d9514626 Binary files /dev/null and b/cockatrice/themes/Fabric/backgrounds/home-dark.png differ diff --git a/cockatrice/themes/Fabric/backgrounds/home-light.png b/cockatrice/themes/Fabric/backgrounds/home-light.png new file mode 100644 index 000000000..78f51a686 Binary files /dev/null and b/cockatrice/themes/Fabric/backgrounds/home-light.png differ diff --git a/cockatrice/themes/Fabric/palette-default-dark.toml b/cockatrice/themes/Fabric/palette-default-dark.toml new file mode 100644 index 000000000..cad18921a --- /dev/null +++ b/cockatrice/themes/Fabric/palette-default-dark.toml @@ -0,0 +1,74 @@ +# Fabric — dark identity palette (navy-cloth chrome) +[Palette] +WindowText = #e8ecf2 +Button = #2b374a +Light = #3a4759 +Midlight = #333f50 +Dark = #141a23 +Mid = #252c38 +Text = #e8ecf2 +BrightText = #8ec2ff +ButtonText = #f2f4f8 +Base = #222c3d +Window = #18202e +Shadow = #0b0f15 +Highlight = #4a82e6 +HighlightedText = #ffffff +Link = #7fb0f2 +LinkVisited = #a98ad9 +AlternateBase = #1d2636 +ToolTipBase = #2b3546 +ToolTipText = #f2f4f8 +PlaceholderText = #6ee8ecf2 +Accent = #4a82e6 + +[Palette.Disabled] +WindowText = #9d9d9d +Button = #18202e +Light = #3a4759 +Midlight = #333f50 +Dark = #141a23 +Mid = #252c38 +Text = #9d9d9d +BrightText = #8ec2ff +ButtonText = #787878 +Base = #18202e +Window = #18202e +Shadow = #0b0f15 +Highlight = #222d40 +HighlightedText = #9d9d9d +Link = #308cc6 +LinkVisited = #b450ff +AlternateBase = #1d2636 +ToolTipBase = #2b3546 +ToolTipText = #f2f4f8 +PlaceholderText = #6ee8ecf2 +Accent = #9d9d9d + +[Palette.Inactive] +WindowText = #e8ecf2 +Button = #2b374a +Light = #3a4759 +Midlight = #333f50 +Dark = #141a23 +Mid = #252c38 +Text = #e8ecf2 +BrightText = #8ec2ff +ButtonText = #f2f4f8 +Base = #222c3d +Window = #18202e +Shadow = #0b0f15 +Highlight = #222d40 +HighlightedText = #ffffff +Link = #7fb0f2 +LinkVisited = #a98ad9 +AlternateBase = #1d2636 +ToolTipBase = #2b3546 +ToolTipText = #f2f4f8 +PlaceholderText = #6ee8ecf2 +Accent = #4a82e6 + +[AppColors] +AccentStrong = #3a6fd6 +AccentSoft = #8fb8f2 + diff --git a/cockatrice/themes/Fabric/palette-default-light.toml b/cockatrice/themes/Fabric/palette-default-light.toml new file mode 100644 index 000000000..f5151af3e --- /dev/null +++ b/cockatrice/themes/Fabric/palette-default-light.toml @@ -0,0 +1,74 @@ +# Fabric — light identity palette (navy-cloth chrome) +[Palette] +WindowText = #1c2330 +Button = #c8d6ea +Light = #ffffff +Midlight = #b9c6dc +Dark = #8fa0bd +Mid = #a3b3cd +Text = #1c2330 +BrightText = #1d4fa0 +ButtonText = #141c29 +Base = #f6f8fc +Window = #dbe3ef +Shadow = #5a6a85 +Highlight = #2f6fd6 +HighlightedText = #ffffff +Link = #2555b0 +LinkVisited = #6a4bb8 +AlternateBase = #e9eef7 +ToolTipBase = #e9eff8 +ToolTipText = #141c29 +PlaceholderText = #7a1c2330 +Accent = #2f6fd6 + +[Palette.Disabled] +WindowText = #787878 +Button = #dbe3ef +Light = #ffffff +Midlight = #b9c6dc +Dark = #8fa0bd +Mid = #a3b3cd +Text = #787878 +BrightText = #1d4fa0 +ButtonText = #969696 +Base = #dbe3ef +Window = #dbe3ef +Shadow = #5a6a85 +Highlight = #cdd8e9 +HighlightedText = #787878 +Link = #0000ff +LinkVisited = #ff00ff +AlternateBase = #e9eef7 +ToolTipBase = #e9eff8 +ToolTipText = #141c29 +PlaceholderText = #7a1c2330 +Accent = #787878 + +[Palette.Inactive] +WindowText = #1c2330 +Button = #c8d6ea +Light = #ffffff +Midlight = #b9c6dc +Dark = #8fa0bd +Mid = #a3b3cd +Text = #1c2330 +BrightText = #1d4fa0 +ButtonText = #141c29 +Base = #f6f8fc +Window = #dbe3ef +Shadow = #5a6a85 +Highlight = #cdd8e9 +HighlightedText = #000000 +Link = #2555b0 +LinkVisited = #6a4bb8 +AlternateBase = #e9eef7 +ToolTipBase = #e9eff8 +ToolTipText = #141c29 +PlaceholderText = #7a1c2330 +Accent = #2f6fd6 + +[AppColors] +AccentStrong = #3a6fd6 +AccentSoft = #8fb8f2 + diff --git a/cockatrice/themes/Fabric/theme.cfg b/cockatrice/themes/Fabric/theme.cfg new file mode 100644 index 000000000..55b916e71 --- /dev/null +++ b/cockatrice/themes/Fabric/theme.cfg @@ -0,0 +1,5 @@ +[Appearance] +ColorScheme = System + +[Style] +Name = Fusion \ No newline at end of file diff --git a/cockatrice/themes/Fabric/zones/handzone-light.png b/cockatrice/themes/Fabric/zones/handzone-light.png new file mode 100644 index 000000000..a87f9e292 Binary files /dev/null and b/cockatrice/themes/Fabric/zones/handzone-light.png differ diff --git a/cockatrice/themes/Fabric/zones/handzone.png b/cockatrice/themes/Fabric/zones/handzone.png index 25d22b070..a5b72286c 100644 Binary files a/cockatrice/themes/Fabric/zones/handzone.png and b/cockatrice/themes/Fabric/zones/handzone.png differ diff --git a/cockatrice/themes/Fabric/zones/playerzone-light.png b/cockatrice/themes/Fabric/zones/playerzone-light.png new file mode 100644 index 000000000..05da3ea9b Binary files /dev/null and b/cockatrice/themes/Fabric/zones/playerzone-light.png differ diff --git a/cockatrice/themes/Fabric/zones/playerzone.png b/cockatrice/themes/Fabric/zones/playerzone.png index 728fccdfa..7fa977249 100644 Binary files a/cockatrice/themes/Fabric/zones/playerzone.png and b/cockatrice/themes/Fabric/zones/playerzone.png differ diff --git a/cockatrice/themes/Fabric/zones/stackzone-light.png b/cockatrice/themes/Fabric/zones/stackzone-light.png new file mode 100644 index 000000000..dcd6dc045 Binary files /dev/null and b/cockatrice/themes/Fabric/zones/stackzone-light.png differ diff --git a/cockatrice/themes/Fabric/zones/stackzone.png b/cockatrice/themes/Fabric/zones/stackzone.png index 89e2080f8..9046aa049 100644 Binary files a/cockatrice/themes/Fabric/zones/stackzone.png and b/cockatrice/themes/Fabric/zones/stackzone.png differ diff --git a/cockatrice/themes/Fabric/zones/tablezone-light.png b/cockatrice/themes/Fabric/zones/tablezone-light.png new file mode 100644 index 000000000..cb297e7e1 Binary files /dev/null and b/cockatrice/themes/Fabric/zones/tablezone-light.png differ diff --git a/cockatrice/themes/Fabric/zones/tablezone.png b/cockatrice/themes/Fabric/zones/tablezone.png index 6a2ce68e1..8048a4cc1 100644 Binary files a/cockatrice/themes/Fabric/zones/tablezone.png and b/cockatrice/themes/Fabric/zones/tablezone.png differ diff --git a/cockatrice/themes/Fusion/palette-default-dark.toml b/cockatrice/themes/Fusion/palette-default-dark.toml index c1d83a4cd..b180e9b63 100644 --- a/cockatrice/themes/Fusion/palette-default-dark.toml +++ b/cockatrice/themes/Fusion/palette-default-dark.toml @@ -11,15 +11,15 @@ ButtonText = #ffffffff Base = #ff2d2d2d Window = #ff1e1e1e Shadow = #ff000000 -Highlight = #ff148c3c +Highlight = #ff139740 HighlightedText = #ffffffff -Link = #ff00f652 -LinkVisited = #ff00d346 +Link = #ffc9fd62 +LinkVisited = #ff9ad43e AlternateBase = #ff353535 ToolTipBase = #ff3c3c3c ToolTipText = #ffd4d4d4 PlaceholderText = #80ffffff -Accent = #ff00d346 +Accent = #ffc9fd62 [Palette.Disabled] WindowText = #ff9d9d9d @@ -34,7 +34,7 @@ ButtonText = #ff9d9d9d Base = #ff1e1e1e Window = #ff1e1e1e Shadow = #ff000000 -Highlight = #ff148c3c +Highlight = #ff139740 HighlightedText = #ffffffff Link = #ff308cc6 LinkVisited = #ffff00ff @@ -59,11 +59,15 @@ Window = #ff1e1e1e Shadow = #ff000000 Highlight = #ff1e1e1e HighlightedText = #ffffffff -Link = #ff00f652 -LinkVisited = #ff00d346 +Link = #ffc9fd62 +LinkVisited = #ff9ad43e AlternateBase = #ff353535 ToolTipBase = #ff3c3c3c ToolTipText = #ffd4d4d4 PlaceholderText = #80ffffff Accent = #ff1e1e1e + +[AppColors] +AccentStrong = #ff139740 +AccentSoft = #ffc9fd62 diff --git a/cockatrice/themes/Fusion/palette-default-light.toml b/cockatrice/themes/Fusion/palette-default-light.toml index 86c41be78..5a1163a2b 100644 --- a/cockatrice/themes/Fusion/palette-default-light.toml +++ b/cockatrice/themes/Fusion/palette-default-light.toml @@ -11,15 +11,15 @@ ButtonText = #ff000000 Base = #ffffffff Window = #fff0f0f0 Shadow = #ff696969 -Highlight = #ff148c3c +Highlight = #ff139740 HighlightedText = #ffffffff -Link = #ff0d5f28 -LinkVisited = #ff08401b +Link = #ff0e6b31 +LinkVisited = #ff0a521f AlternateBase = #ffe9e7e3 ToolTipBase = #ffffffdc ToolTipText = #ff000000 PlaceholderText = #80000000 -Accent = #ff107532 +Accent = #ff139740 [Palette.Disabled] WindowText = #ff787878 @@ -34,7 +34,7 @@ ButtonText = #ff787878 Base = #fff0f0f0 Window = #fff0f0f0 Shadow = #ff000000 -Highlight = #ff148c3c +Highlight = #ff139740 HighlightedText = #ffffffff Link = #ff0000ff LinkVisited = #ffff00ff @@ -59,11 +59,15 @@ Window = #fff0f0f0 Shadow = #ff696969 Highlight = #fff0f0f0 HighlightedText = #ff000000 -Link = #ff0d5f28 -LinkVisited = #ff08401b +Link = #ff0e6b31 +LinkVisited = #ff0a521f AlternateBase = #ffe9e7e3 ToolTipBase = #ffffffdc ToolTipText = #ff000000 PlaceholderText = #80000000 Accent = #fff0f0f0 + +[AppColors] +AccentStrong = #ff139740 +AccentSoft = #ffc9fd62 diff --git a/cockatrice/themes/Leather/backgrounds/home-dark.png b/cockatrice/themes/Leather/backgrounds/home-dark.png new file mode 100644 index 000000000..20d16a27f Binary files /dev/null and b/cockatrice/themes/Leather/backgrounds/home-dark.png differ diff --git a/cockatrice/themes/Leather/backgrounds/home-light.png b/cockatrice/themes/Leather/backgrounds/home-light.png new file mode 100644 index 000000000..ee1da361d Binary files /dev/null and b/cockatrice/themes/Leather/backgrounds/home-light.png differ diff --git a/cockatrice/themes/Leather/palette-default-dark.toml b/cockatrice/themes/Leather/palette-default-dark.toml new file mode 100644 index 000000000..b0db0e90a --- /dev/null +++ b/cockatrice/themes/Leather/palette-default-dark.toml @@ -0,0 +1,74 @@ +# Leather — dark identity palette (black-brown chrome with brass accents) +[Palette] +WindowText = #eee9e2 +Button = #332d26 +Light = #4a4239 +Midlight = #3d3730 +Dark = #131009 +Mid = #26221c +Text = #eee9e2 +BrightText = #e3c27e +ButtonText = #f5f1ea +Base = #26221d +Window = #1d1a16 +Shadow = #0d0b07 +Highlight = #4a5f8f +HighlightedText = #ffffff +Link = #cfa263 +LinkVisited = #9a7db8 +AlternateBase = #211d19 +ToolTipBase = #37302a +ToolTipText = #f5f1ea +PlaceholderText = #6eeee9e2 +Accent = #c9995a + +[Palette.Disabled] +WindowText = #9d9d9d +Button = #1d1a16 +Light = #4a4239 +Midlight = #3d3730 +Dark = #131009 +Mid = #26221c +Text = #9d9d9d +BrightText = #e3c27e +ButtonText = #787878 +Base = #1d1a16 +Window = #1d1a16 +Shadow = #0d0b07 +Highlight = #2d2822 +HighlightedText = #9d9d9d +Link = #308cc6 +LinkVisited = #b450ff +AlternateBase = #211d19 +ToolTipBase = #37302a +ToolTipText = #f5f1ea +PlaceholderText = #6eeee9e2 +Accent = #9d9d9d + +[Palette.Inactive] +WindowText = #eee9e2 +Button = #332d26 +Light = #4a4239 +Midlight = #3d3730 +Dark = #131009 +Mid = #26221c +Text = #eee9e2 +BrightText = #e3c27e +ButtonText = #f5f1ea +Base = #26221d +Window = #1d1a16 +Shadow = #0d0b07 +Highlight = #2d2822 +HighlightedText = #ffffff +Link = #cfa263 +LinkVisited = #9a7db8 +AlternateBase = #211d19 +ToolTipBase = #37302a +ToolTipText = #f5f1ea +PlaceholderText = #6eeee9e2 +Accent = #c9995a + +[AppColors] +AccentStrong = #b7823b +AccentSoft = #e4c58f + diff --git a/cockatrice/themes/Leather/palette-default-light.toml b/cockatrice/themes/Leather/palette-default-light.toml new file mode 100644 index 000000000..ff122db76 --- /dev/null +++ b/cockatrice/themes/Leather/palette-default-light.toml @@ -0,0 +1,74 @@ +# Leather — light identity palette (black-brown chrome with brass accents) +[Palette] +WindowText = #2a2114 +Button = #dfcbb0 +Light = #ffffff +Midlight = #c6b08c +Dark = #a18a64 +Mid = #b29a74 +Text = #2a2114 +BrightText = #7a531c +ButtonText = #1f1710 +Base = #fdf8ee +Window = #f0e2cb +Shadow = #5d4d33 +Highlight = #34508c +HighlightedText = #ffffff +Link = #8a5e1e +LinkVisited = #6b5190 +AlternateBase = #f5ebd7 +ToolTipBase = #fff7e8 +ToolTipText = #1f1710 +PlaceholderText = #7a2a2114 +Accent = #a5712f + +[Palette.Disabled] +WindowText = #787878 +Button = #f0e2cb +Light = #ffffff +Midlight = #c6b08c +Dark = #a18a64 +Mid = #b29a74 +Text = #787878 +BrightText = #7a531c +ButtonText = #969696 +Base = #f0e2cb +Window = #f0e2cb +Shadow = #5d4d33 +Highlight = #ecd9bb +HighlightedText = #787878 +Link = #0000ff +LinkVisited = #ff00ff +AlternateBase = #f5ebd7 +ToolTipBase = #fff7e8 +ToolTipText = #1f1710 +PlaceholderText = #7a2a2114 +Accent = #787878 + +[Palette.Inactive] +WindowText = #2a2114 +Button = #dfcbb0 +Light = #ffffff +Midlight = #c6b08c +Dark = #a18a64 +Mid = #b29a74 +Text = #2a2114 +BrightText = #7a531c +ButtonText = #1f1710 +Base = #fdf8ee +Window = #f0e2cb +Shadow = #5d4d33 +Highlight = #ecd9bb +HighlightedText = #000000 +Link = #8a5e1e +LinkVisited = #6b5190 +AlternateBase = #f5ebd7 +ToolTipBase = #fff7e8 +ToolTipText = #1f1710 +PlaceholderText = #7a2a2114 +Accent = #a5712f + +[AppColors] +AccentStrong = #b7823b +AccentSoft = #e4c58f + diff --git a/cockatrice/themes/Leather/theme.cfg b/cockatrice/themes/Leather/theme.cfg new file mode 100644 index 000000000..55b916e71 --- /dev/null +++ b/cockatrice/themes/Leather/theme.cfg @@ -0,0 +1,5 @@ +[Appearance] +ColorScheme = System + +[Style] +Name = Fusion \ No newline at end of file diff --git a/cockatrice/themes/Leather/zones/handzone-light.png b/cockatrice/themes/Leather/zones/handzone-light.png new file mode 100644 index 000000000..b2c9bba34 Binary files /dev/null and b/cockatrice/themes/Leather/zones/handzone-light.png differ diff --git a/cockatrice/themes/Leather/zones/handzone.png b/cockatrice/themes/Leather/zones/handzone.png index aba879683..5f803df74 100644 Binary files a/cockatrice/themes/Leather/zones/handzone.png and b/cockatrice/themes/Leather/zones/handzone.png differ diff --git a/cockatrice/themes/Leather/zones/playerzone-light.png b/cockatrice/themes/Leather/zones/playerzone-light.png new file mode 100644 index 000000000..a26f96a5e Binary files /dev/null and b/cockatrice/themes/Leather/zones/playerzone-light.png differ diff --git a/cockatrice/themes/Leather/zones/playerzone.png b/cockatrice/themes/Leather/zones/playerzone.png index 4f42ee41d..cccbb3fd3 100644 Binary files a/cockatrice/themes/Leather/zones/playerzone.png and b/cockatrice/themes/Leather/zones/playerzone.png differ diff --git a/cockatrice/themes/Leather/zones/stackzone-light.png b/cockatrice/themes/Leather/zones/stackzone-light.png new file mode 100644 index 000000000..117c738f7 Binary files /dev/null and b/cockatrice/themes/Leather/zones/stackzone-light.png differ diff --git a/cockatrice/themes/Leather/zones/stackzone.png b/cockatrice/themes/Leather/zones/stackzone.png index c02e2284a..d9cff892b 100644 Binary files a/cockatrice/themes/Leather/zones/stackzone.png and b/cockatrice/themes/Leather/zones/stackzone.png differ diff --git a/cockatrice/themes/Leather/zones/tablezone-light.png b/cockatrice/themes/Leather/zones/tablezone-light.png new file mode 100644 index 000000000..912cbfb94 Binary files /dev/null and b/cockatrice/themes/Leather/zones/tablezone-light.png differ diff --git a/cockatrice/themes/Leather/zones/tablezone.png b/cockatrice/themes/Leather/zones/tablezone.png index 152f4f7e9..8af4c853d 100644 Binary files a/cockatrice/themes/Leather/zones/tablezone.png and b/cockatrice/themes/Leather/zones/tablezone.png differ diff --git a/cockatrice/themes/Plasma/backgrounds/home-dark.png b/cockatrice/themes/Plasma/backgrounds/home-dark.png new file mode 100644 index 000000000..ce1469e2b Binary files /dev/null and b/cockatrice/themes/Plasma/backgrounds/home-dark.png differ diff --git a/cockatrice/themes/Plasma/backgrounds/home-light.png b/cockatrice/themes/Plasma/backgrounds/home-light.png new file mode 100644 index 000000000..2e9000bd6 Binary files /dev/null and b/cockatrice/themes/Plasma/backgrounds/home-light.png differ diff --git a/cockatrice/themes/Plasma/palette-default-dark.toml b/cockatrice/themes/Plasma/palette-default-dark.toml new file mode 100644 index 000000000..222456f4f --- /dev/null +++ b/cockatrice/themes/Plasma/palette-default-dark.toml @@ -0,0 +1,74 @@ +# Plasma — dark identity palette (electric violet chrome with cyan sparks) +[Palette] +WindowText = #eeebff +Button = #2a2440 +Light = #453d63 +Midlight = #383252 +Dark = #0e0c16 +Mid = #211e31 +Text = #eeebff +BrightText = #7fd7ff +ButtonText = #f6f4ff +Base = #1e1a2e +Window = #151220 +Shadow = #08070e +Highlight = #6a4df0 +HighlightedText = #ffffff +Link = #37c7e8 +LinkVisited = #9b7aff +AlternateBase = #1a1726 +ToolTipBase = #2c2745 +ToolTipText = #f6f4ff +PlaceholderText = #6eeeebff +Accent = #6a4df0 + +[Palette.Disabled] +WindowText = #9d9d9d +Button = #151220 +Light = #453d63 +Midlight = #383252 +Dark = #0e0c16 +Mid = #211e31 +Text = #9d9d9d +BrightText = #7fd7ff +ButtonText = #787878 +Base = #151220 +Window = #151220 +Shadow = #08070e +Highlight = #211c32 +HighlightedText = #9d9d9d +Link = #308cc6 +LinkVisited = #b450ff +AlternateBase = #1a1726 +ToolTipBase = #2c2745 +ToolTipText = #f6f4ff +PlaceholderText = #6eeeebff +Accent = #9d9d9d + +[Palette.Inactive] +WindowText = #eeebff +Button = #2a2440 +Light = #453d63 +Midlight = #383252 +Dark = #0e0c16 +Mid = #211e31 +Text = #eeebff +BrightText = #7fd7ff +ButtonText = #f6f4ff +Base = #1e1a2e +Window = #151220 +Shadow = #08070e +Highlight = #211c32 +HighlightedText = #ffffff +Link = #37c7e8 +LinkVisited = #9b7aff +AlternateBase = #1a1726 +ToolTipBase = #2c2745 +ToolTipText = #f6f4ff +PlaceholderText = #6eeeebff +Accent = #6a4df0 + +[AppColors] +AccentStrong = #5b3ee0 +AccentSoft = #a28bf7 + diff --git a/cockatrice/themes/Plasma/palette-default-light.toml b/cockatrice/themes/Plasma/palette-default-light.toml new file mode 100644 index 000000000..d54db31d2 --- /dev/null +++ b/cockatrice/themes/Plasma/palette-default-light.toml @@ -0,0 +1,74 @@ +# Plasma — light identity palette (electric violet chrome with cyan sparks) +[Palette] +WindowText = #1d1830 +Button = #cfc6f0 +Light = #ffffff +Midlight = #b7adde +Dark = #9589c8 +Mid = #a49ad2 +Text = #1d1830 +BrightText = #10406e +ButtonText = #120d26 +Base = #f9f7ff +Window = #e3dcf7 +Shadow = #554c85 +Highlight = #5a3ee2 +HighlightedText = #ffffff +Link = #0f8fb5 +LinkVisited = #6a45d6 +AlternateBase = #ece8fa +ToolTipBase = #f5f3ff +ToolTipText = #120d26 +PlaceholderText = #7a1d1830 +Accent = #5a3ee2 + +[Palette.Disabled] +WindowText = #787878 +Button = #e3dcf7 +Light = #ffffff +Midlight = #b7adde +Dark = #9589c8 +Mid = #a49ad2 +Text = #787878 +BrightText = #10406e +ButtonText = #969696 +Base = #e3dcf7 +Window = #e3dcf7 +Shadow = #554c85 +Highlight = #d6ccf3 +HighlightedText = #787878 +Link = #0000ff +LinkVisited = #ff00ff +AlternateBase = #ece8fa +ToolTipBase = #f5f3ff +ToolTipText = #120d26 +PlaceholderText = #7a1d1830 +Accent = #787878 + +[Palette.Inactive] +WindowText = #1d1830 +Button = #cfc6f0 +Light = #ffffff +Midlight = #b7adde +Dark = #9589c8 +Mid = #a49ad2 +Text = #1d1830 +BrightText = #10406e +ButtonText = #120d26 +Base = #f9f7ff +Window = #e3dcf7 +Shadow = #554c85 +Highlight = #d6ccf3 +HighlightedText = #000000 +Link = #0f8fb5 +LinkVisited = #6a45d6 +AlternateBase = #ece8fa +ToolTipBase = #f5f3ff +ToolTipText = #120d26 +PlaceholderText = #7a1d1830 +Accent = #5a3ee2 + +[AppColors] +AccentStrong = #5b3ee0 +AccentSoft = #a28bf7 + diff --git a/cockatrice/themes/Plasma/theme.cfg b/cockatrice/themes/Plasma/theme.cfg new file mode 100644 index 000000000..55b916e71 --- /dev/null +++ b/cockatrice/themes/Plasma/theme.cfg @@ -0,0 +1,5 @@ +[Appearance] +ColorScheme = System + +[Style] +Name = Fusion \ No newline at end of file diff --git a/cockatrice/themes/Plasma/zones/handzone-light.png b/cockatrice/themes/Plasma/zones/handzone-light.png new file mode 100644 index 000000000..89fa7e6d4 Binary files /dev/null and b/cockatrice/themes/Plasma/zones/handzone-light.png differ diff --git a/cockatrice/themes/Plasma/zones/handzone.png b/cockatrice/themes/Plasma/zones/handzone.png index 543571d8a..82debb405 100644 Binary files a/cockatrice/themes/Plasma/zones/handzone.png and b/cockatrice/themes/Plasma/zones/handzone.png differ diff --git a/cockatrice/themes/Plasma/zones/playerzone-light.png b/cockatrice/themes/Plasma/zones/playerzone-light.png new file mode 100644 index 000000000..9034818f2 Binary files /dev/null and b/cockatrice/themes/Plasma/zones/playerzone-light.png differ diff --git a/cockatrice/themes/Plasma/zones/playerzone.png b/cockatrice/themes/Plasma/zones/playerzone.png index eebcd1f84..4623524f6 100644 Binary files a/cockatrice/themes/Plasma/zones/playerzone.png and b/cockatrice/themes/Plasma/zones/playerzone.png differ diff --git a/cockatrice/themes/Plasma/zones/stackzone-light.png b/cockatrice/themes/Plasma/zones/stackzone-light.png new file mode 100644 index 000000000..2607b3389 Binary files /dev/null and b/cockatrice/themes/Plasma/zones/stackzone-light.png differ diff --git a/cockatrice/themes/Plasma/zones/stackzone.png b/cockatrice/themes/Plasma/zones/stackzone.png index 1845c7091..144d991f3 100644 Binary files a/cockatrice/themes/Plasma/zones/stackzone.png and b/cockatrice/themes/Plasma/zones/stackzone.png differ diff --git a/cockatrice/themes/Plasma/zones/tablezone-light.png b/cockatrice/themes/Plasma/zones/tablezone-light.png new file mode 100644 index 000000000..dcf2ef95e Binary files /dev/null and b/cockatrice/themes/Plasma/zones/tablezone-light.png differ diff --git a/cockatrice/themes/Plasma/zones/tablezone.png b/cockatrice/themes/Plasma/zones/tablezone.png index 8f998d4bb..4dc36ec2d 100644 Binary files a/cockatrice/themes/Plasma/zones/tablezone.png and b/cockatrice/themes/Plasma/zones/tablezone.png differ diff --git a/cockatrice/themes/Default/palette-default-dark.toml b/cockatrice/themes/System/palette-default-dark.toml similarity index 96% rename from cockatrice/themes/Default/palette-default-dark.toml rename to cockatrice/themes/System/palette-default-dark.toml index 3ee174a2f..e101935b4 100644 --- a/cockatrice/themes/Default/palette-default-dark.toml +++ b/cockatrice/themes/System/palette-default-dark.toml @@ -61,3 +61,7 @@ ToolTipBase = #ffffffdc ToolTipText = #ff000000 PlaceholderText = #6effffff + +[AppColors] +AccentStrong = #ff148c3c +AccentSoft = #ff78c850 diff --git a/cockatrice/themes/System/palette-default-light.toml b/cockatrice/themes/System/palette-default-light.toml new file mode 100644 index 000000000..14c215cdf --- /dev/null +++ b/cockatrice/themes/System/palette-default-light.toml @@ -0,0 +1,67 @@ +[Palette] +WindowText = #ff000000 +Button = #fff0f0f0 +Light = #ffffffff +Midlight = #ffe3e3e3 +Dark = #ffa0a0a0 +Mid = #ffa0a0a0 +Text = #ff000000 +BrightText = #ffffffff +ButtonText = #ff000000 +Base = #ffffffff +Window = #fff0f0f0 +Shadow = #ff696969 +HighlightedText = #ffffffff +Link = #ff0d5f28 +LinkVisited = #ff08401b +AlternateBase = #ffe9e7e3 +ToolTipBase = #ffffffdc +ToolTipText = #ff000000 +PlaceholderText = #80000000 + +[Palette.Disabled] +WindowText = #ff787878 +Button = #fff0f0f0 +Light = #ffffffff +Midlight = #fff7f7f7 +Dark = #ffa0a0a0 +Mid = #ffa0a0a0 +Text = #ff787878 +BrightText = #ffffffff +ButtonText = #ff787878 +Base = #fff0f0f0 +Window = #fff0f0f0 +Shadow = #ff000000 +HighlightedText = #ffffffff +Link = #ff0000ff +LinkVisited = #ffff00ff +AlternateBase = #fff7f7f7 +ToolTipBase = #ffffffdc +ToolTipText = #ff000000 +PlaceholderText = #80000000 + +[Palette.Inactive] +WindowText = #ff000000 +Button = #fff0f0f0 +Light = #ffffffff +Midlight = #ffe3e3e3 +Dark = #ffa0a0a0 +Mid = #ffa0a0a0 +Text = #ff000000 +BrightText = #ffffffff +ButtonText = #ff000000 +Base = #ffffffff +Window = #fff0f0f0 +Shadow = #ff696969 +HighlightedText = #ff000000 +Link = #ff0d5f28 +LinkVisited = #ff08401b +AlternateBase = #ffe9e7e3 +ToolTipBase = #ffffffdc +ToolTipText = #ff000000 +PlaceholderText = #80000000 + + +[AppColors] +AccentStrong = #ff148c3c +AccentSoft = #ff78c850 \ No newline at end of file diff --git a/cockatrice/themes/Default/theme.cfg b/cockatrice/themes/System/theme.cfg similarity index 73% rename from cockatrice/themes/Default/theme.cfg rename to cockatrice/themes/System/theme.cfg index d2016a238..4b756a5e8 100644 --- a/cockatrice/themes/Default/theme.cfg +++ b/cockatrice/themes/System/theme.cfg @@ -2,4 +2,4 @@ ColorScheme = Light [Style] -Name = Default +Name = System diff --git a/cockatrice/themes/VelvetMarble/backgrounds/home-dark.png b/cockatrice/themes/VelvetMarble/backgrounds/home-dark.png new file mode 100644 index 000000000..34738090f Binary files /dev/null and b/cockatrice/themes/VelvetMarble/backgrounds/home-dark.png differ diff --git a/cockatrice/themes/VelvetMarble/backgrounds/home-light.png b/cockatrice/themes/VelvetMarble/backgrounds/home-light.png new file mode 100644 index 000000000..de783e086 Binary files /dev/null and b/cockatrice/themes/VelvetMarble/backgrounds/home-light.png differ diff --git a/cockatrice/themes/VelvetMarble/palette-default-dark.toml b/cockatrice/themes/VelvetMarble/palette-default-dark.toml new file mode 100644 index 000000000..496943931 --- /dev/null +++ b/cockatrice/themes/VelvetMarble/palette-default-dark.toml @@ -0,0 +1,74 @@ +# VelvetMarble — dark identity palette (charcoal-velvet chrome with slate marble) +[Palette] +WindowText = #e8eaee +Button = #262a31 +Light = #3a3f47 +Midlight = #31363d +Dark = #0e1013 +Mid = #1f2329 +Text = #e8eaee +BrightText = #c3d8f2 +ButtonText = #f2f3f6 +Base = #1b1e23 +Window = #131519 +Shadow = #08090b +Highlight = #8fa6c0 +HighlightedText = #101318 +Link = #a9c6e8 +LinkVisited = #b9a8d9 +AlternateBase = #171a1f +ToolTipBase = #2d323a +ToolTipText = #f2f3f6 +PlaceholderText = #6ee8eaee +Accent = #8fa6c0 + +[Palette.Disabled] +WindowText = #9d9d9d +Button = #131519 +Light = #3a3f47 +Midlight = #31363d +Dark = #0e1013 +Mid = #1f2329 +Text = #9d9d9d +BrightText = #c3d8f2 +ButtonText = #787878 +Base = #131519 +Window = #131519 +Shadow = #08090b +Highlight = #1f2229 +HighlightedText = #9d9d9d +Link = #308cc6 +LinkVisited = #b450ff +AlternateBase = #171a1f +ToolTipBase = #2d323a +ToolTipText = #f2f3f6 +PlaceholderText = #6ee8eaee +Accent = #9d9d9d + +[Palette.Inactive] +WindowText = #e8eaee +Button = #262a31 +Light = #3a3f47 +Midlight = #31363d +Dark = #0e1013 +Mid = #1f2329 +Text = #e8eaee +BrightText = #c3d8f2 +ButtonText = #f2f3f6 +Base = #1b1e23 +Window = #131519 +Shadow = #08090b +Highlight = #1f2229 +HighlightedText = #ffffff +Link = #a9c6e8 +LinkVisited = #b9a8d9 +AlternateBase = #171a1f +ToolTipBase = #2d323a +ToolTipText = #f2f3f6 +PlaceholderText = #6ee8eaee +Accent = #8fa6c0 + +[AppColors] +AccentStrong = #54687e +AccentSoft = #9db0c4 + diff --git a/cockatrice/themes/VelvetMarble/palette-default-light.toml b/cockatrice/themes/VelvetMarble/palette-default-light.toml new file mode 100644 index 000000000..5323fd320 --- /dev/null +++ b/cockatrice/themes/VelvetMarble/palette-default-light.toml @@ -0,0 +1,74 @@ +# VelvetMarble — light identity palette (charcoal-velvet chrome with slate marble) +[Palette] +WindowText = #1b1d21 +Button = #c7d1dd +Light = #ffffff +Midlight = #b3becb +Dark = #93a0b1 +Mid = #a1adbc +Text = #1b1d21 +BrightText = #26465f +ButtonText = #121418 +Base = #f7f9fb +Window = #d9e0ea +Shadow = #57626f +Highlight = #54687e +HighlightedText = #ffffff +Link = #2e4d6b +LinkVisited = #5d4a78 +AlternateBase = #e6ebf2 +ToolTipBase = #f2f4f6 +ToolTipText = #121418 +PlaceholderText = #7a1b1d21 +Accent = #54687e + +[Palette.Disabled] +WindowText = #787878 +Button = #d9e0ea +Light = #ffffff +Midlight = #b3becb +Dark = #93a0b1 +Mid = #a1adbc +Text = #787878 +BrightText = #26465f +ButtonText = #969696 +Base = #d9e0ea +Window = #d9e0ea +Shadow = #57626f +Highlight = #ccd5e3 +HighlightedText = #787878 +Link = #0000ff +LinkVisited = #ff00ff +AlternateBase = #e6ebf2 +ToolTipBase = #f2f4f6 +ToolTipText = #121418 +PlaceholderText = #7a1b1d21 +Accent = #787878 + +[Palette.Inactive] +WindowText = #1b1d21 +Button = #c7d1dd +Light = #ffffff +Midlight = #b3becb +Dark = #93a0b1 +Mid = #a1adbc +Text = #1b1d21 +BrightText = #26465f +ButtonText = #121418 +Base = #f7f9fb +Window = #d9e0ea +Shadow = #57626f +Highlight = #ccd5e3 +HighlightedText = #000000 +Link = #2e4d6b +LinkVisited = #5d4a78 +AlternateBase = #e6ebf2 +ToolTipBase = #f2f4f6 +ToolTipText = #121418 +PlaceholderText = #7a1b1d21 +Accent = #54687e + +[AppColors] +AccentStrong = #54687e +AccentSoft = #9db0c4 + diff --git a/cockatrice/themes/VelvetMarble/theme.cfg b/cockatrice/themes/VelvetMarble/theme.cfg new file mode 100644 index 000000000..55b916e71 --- /dev/null +++ b/cockatrice/themes/VelvetMarble/theme.cfg @@ -0,0 +1,5 @@ +[Appearance] +ColorScheme = System + +[Style] +Name = Fusion \ No newline at end of file diff --git a/cockatrice/themes/VelvetMarble/zones/handzone-light.png b/cockatrice/themes/VelvetMarble/zones/handzone-light.png new file mode 100644 index 000000000..b21e78e61 Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/handzone-light.png differ diff --git a/cockatrice/themes/VelvetMarble/zones/handzone.jpg b/cockatrice/themes/VelvetMarble/zones/handzone.jpg deleted file mode 100644 index 2ec9b37fe..000000000 Binary files a/cockatrice/themes/VelvetMarble/zones/handzone.jpg and /dev/null differ diff --git a/cockatrice/themes/VelvetMarble/zones/handzone.png b/cockatrice/themes/VelvetMarble/zones/handzone.png new file mode 100644 index 000000000..3e2b7ebea Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/handzone.png differ diff --git a/cockatrice/themes/VelvetMarble/zones/playerzone-light.png b/cockatrice/themes/VelvetMarble/zones/playerzone-light.png new file mode 100644 index 000000000..630aac6eb Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/playerzone-light.png differ diff --git a/cockatrice/themes/VelvetMarble/zones/playerzone.jpg b/cockatrice/themes/VelvetMarble/zones/playerzone.jpg deleted file mode 100644 index dd13cab78..000000000 Binary files a/cockatrice/themes/VelvetMarble/zones/playerzone.jpg and /dev/null differ diff --git a/cockatrice/themes/VelvetMarble/zones/playerzone.png b/cockatrice/themes/VelvetMarble/zones/playerzone.png new file mode 100644 index 000000000..dcf21ba38 Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/playerzone.png differ diff --git a/cockatrice/themes/VelvetMarble/zones/stackzone-light.png b/cockatrice/themes/VelvetMarble/zones/stackzone-light.png new file mode 100644 index 000000000..62027827c Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/stackzone-light.png differ diff --git a/cockatrice/themes/VelvetMarble/zones/stackzone.jpg b/cockatrice/themes/VelvetMarble/zones/stackzone.jpg deleted file mode 100644 index b63aa0902..000000000 Binary files a/cockatrice/themes/VelvetMarble/zones/stackzone.jpg and /dev/null differ diff --git a/cockatrice/themes/VelvetMarble/zones/stackzone.png b/cockatrice/themes/VelvetMarble/zones/stackzone.png new file mode 100644 index 000000000..a47f7bf9d Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/stackzone.png differ diff --git a/cockatrice/themes/VelvetMarble/zones/tablezone-light.png b/cockatrice/themes/VelvetMarble/zones/tablezone-light.png new file mode 100644 index 000000000..5a9014f9d Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/tablezone-light.png differ diff --git a/cockatrice/themes/VelvetMarble/zones/tablezone.jpg b/cockatrice/themes/VelvetMarble/zones/tablezone.jpg deleted file mode 100644 index 9f511491a..000000000 Binary files a/cockatrice/themes/VelvetMarble/zones/tablezone.jpg and /dev/null differ diff --git a/cockatrice/themes/VelvetMarble/zones/tablezone.png b/cockatrice/themes/VelvetMarble/zones/tablezone.png new file mode 100644 index 000000000..1523d0821 Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/tablezone.png differ diff --git a/doc/carddatabase_v4/cards.xsd b/doc/carddatabase_v4/cards.xsd index 59ca3e560..82aed32ab 100644 --- a/doc/carddatabase_v4/cards.xsd +++ b/doc/carddatabase_v4/cards.xsd @@ -35,6 +35,13 @@ + + + + + + + @@ -49,6 +56,13 @@ + + + + + + + diff --git a/libcockatrice_card/CMakeLists.txt b/libcockatrice_card/CMakeLists.txt index 081e6fd05..15388ecfc 100644 --- a/libcockatrice_card/CMakeLists.txt +++ b/libcockatrice_card/CMakeLists.txt @@ -5,6 +5,7 @@ set(CMAKE_AUTORCC ON) set(HEADERS libcockatrice/card/card_info.h libcockatrice/card/card_info_comparator.h + libcockatrice/card/card_localization.h libcockatrice/card/lazy_properties_hash.h libcockatrice/card/database/card_database.h libcockatrice/card/database/card_database_loader.h @@ -27,6 +28,7 @@ add_library( ${MOC_SOURCES} libcockatrice/card/card_info.cpp libcockatrice/card/card_info_comparator.cpp + libcockatrice/card/card_localization.cpp libcockatrice/card/lazy_properties_hash.cpp libcockatrice/card/database/card_database.cpp libcockatrice/card/database/card_database_cache.cpp diff --git a/libcockatrice_card/libcockatrice/card/card_info.cpp b/libcockatrice_card/libcockatrice/card/card_info.cpp index 786e17950..56c737793 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.cpp +++ b/libcockatrice_card/libcockatrice/card/card_info.cpp @@ -40,8 +40,11 @@ CardInfo::CardInfo(const QString &_name, const QList &_relatedCards, const QList &_reverseRelatedCards, SetToPrintingsMap _sets, - const UiAttributes _uiAttributes) - : name(_name), text(_text), isToken(_isToken), properties(LazyPropertiesHash(_properties)), + const UiAttributes _uiAttributes, + QMap _localizedNames, + QMap _localizedTexts) + : name(_name), text(_text), isToken(_isToken), localizedNames(std::move(_localizedNames)), + localizedTexts(std::move(_localizedTexts)), properties(LazyPropertiesHash(_properties)), relatedCards(_relatedCards), reverseRelatedCards(_reverseRelatedCards), setsToPrintings(std::move(_sets)), uiAttributes(_uiAttributes) { @@ -59,8 +62,11 @@ CardInfo::CardInfo(const QString &_name, SetToPrintingsMap _sets, const UiAttributes _uiAttributes, QString _simpleName, - QSet _altNames) + QSet _altNames, + QMap _localizedNames, + QMap _localizedTexts) : name(_name), simpleName(std::move(_simpleName)), text(_text), isToken(_isToken), + localizedNames(std::move(_localizedNames)), localizedTexts(std::move(_localizedTexts)), properties(LazyPropertiesHash(_propertiesBlob)), relatedCards(_relatedCards), reverseRelatedCards(_reverseRelatedCards), setsToPrintings(std::move(_sets)), uiAttributes(_uiAttributes), altNames(std::move(_altNames)) @@ -83,10 +89,12 @@ CardInfoPtr CardInfo::newInstance(const QString &_name, const QList &_relatedCards, const QList &_reverseRelatedCards, SetToPrintingsMap _sets, - const UiAttributes _uiAttributes) + const UiAttributes _uiAttributes, + QMap _localizedNames, + QMap _localizedTexts) { - CardInfoPtr ptr( - new CardInfo(_name, _text, _isToken, _properties, _relatedCards, _reverseRelatedCards, _sets, _uiAttributes)); + CardInfoPtr ptr(new CardInfo(_name, _text, _isToken, _properties, _relatedCards, _reverseRelatedCards, _sets, + _uiAttributes, std::move(_localizedNames), std::move(_localizedTexts))); ptr->setSmartPointer(ptr); for (const auto &printings : _sets) { @@ -109,11 +117,13 @@ CardInfoPtr CardInfo::newInstance(const QString &_name, const UiAttributes _uiAttributes, QString _simpleName, QSet _altNames, - bool _appendToSets) + bool _appendToSets, + QMap _localizedNames, + QMap _localizedTexts) { CardInfoPtr ptr(new CardInfo(_name, _text, _isToken, std::move(_propertiesBlob), _relatedCards, _reverseRelatedCards, _sets, _uiAttributes, std::move(_simpleName), - std::move(_altNames))); + std::move(_altNames), std::move(_localizedNames), std::move(_localizedTexts))); ptr->setSmartPointer(ptr); if (_appendToSets) { diff --git a/libcockatrice_card/libcockatrice/card/card_info.h b/libcockatrice_card/libcockatrice/card/card_info.h index 392dc3849..17894cec5 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.h +++ b/libcockatrice_card/libcockatrice/card/card_info.h @@ -77,6 +77,9 @@ private: QString text; ///< Text description or rules text of the card. bool isToken; ///< Whether this card is a token or not. + QMap localizedNames; ///< Localized card names, keyed by language code. + QMap localizedTexts; ///< Localized rules text, keyed by language code. + LazyPropertiesHash properties; ///< Key-value store of dynamic card properties. QList relatedCards; ///< Forward references to related cards. @@ -100,6 +103,8 @@ public: * @param _reverseRelatedCards Backward references to related cards. * @param _sets Map of set names to printing information. * @param _uiAttributes Attributes that affect display and game logic + * @param _localizedNames Localized card names, keyed by language code. + * @param _localizedTexts Localized rules text, keyed by language code. */ explicit CardInfo(const QString &_name, const QString &_text, @@ -108,7 +113,9 @@ public: const QList &_relatedCards, const QList &_reverseRelatedCards, SetToPrintingsMap _sets, - UiAttributes _uiAttributes); + UiAttributes _uiAttributes, + QMap _localizedNames = {}, + QMap _localizedTexts = {}); /** * @brief Constructs a CardInfo from a cache snapshot with precomputed derived @@ -130,6 +137,8 @@ public: * @param _uiAttributes Attributes that affect display and game logic. * @param _simpleName Precomputed simplified name. * @param _altNames Precomputed alternate names. + * @param _localizedNames Localized card names, keyed by language code. + * @param _localizedTexts Localized rules text, keyed by language code. */ explicit CardInfo(const QString &_name, const QString &_text, @@ -140,7 +149,9 @@ public: SetToPrintingsMap _sets, UiAttributes _uiAttributes, QString _simpleName, - QSet _altNames); + QSet _altNames, + QMap _localizedNames = {}, + QMap _localizedTexts = {}); /** * @brief Copy constructor for CardInfo. @@ -151,7 +162,8 @@ public: */ CardInfo(const CardInfo &other) : QObject(other.parent()), name(other.name), simpleName(other.simpleName), text(other.text), - isToken(other.isToken), properties(other.properties), relatedCards(other.relatedCards), + isToken(other.isToken), localizedNames(other.localizedNames), localizedTexts(other.localizedTexts), + properties(other.properties), relatedCards(other.relatedCards), reverseRelatedCards(other.reverseRelatedCards), reverseRelatedCardsToMe(other.reverseRelatedCardsToMe), setsToPrintings(other.setsToPrintings), uiAttributes(other.uiAttributes), setsNames(other.setsNames), altNames(other.altNames) @@ -179,6 +191,8 @@ public: * @param _reverseRelatedCards Reverse relationships. * @param _sets Printing information per set. * @param _uiAttributes Attributes that affect display and game logic + * @param _localizedNames Localized card names, keyed by language code. + * @param _localizedTexts Localized rules text, keyed by language code. * @return Shared pointer to the new CardInfo instance. */ static CardInfoPtr newInstance(const QString &_name, @@ -188,7 +202,9 @@ public: const QList &_relatedCards, const QList &_reverseRelatedCards, SetToPrintingsMap _sets, - UiAttributes _uiAttributes); + UiAttributes _uiAttributes, + QMap _localizedNames = {}, + QMap _localizedTexts = {}); /** * @brief Creates a new instance from a cache snapshot with precomputed @@ -208,6 +224,8 @@ public: * its CardSets. Pass false when building cards in parallel so the * (non-thread-safe) set membership is populated in a later * single-threaded pass. + * @param _localizedNames Localized card names, keyed by language code. + * @param _localizedTexts Localized rules text, keyed by language code. * @return Shared pointer to the new CardInfo instance. */ static CardInfoPtr newInstance(const QString &_name, @@ -220,7 +238,9 @@ public: UiAttributes _uiAttributes, QString _simpleName, QSet _altNames, - bool _appendToSets = true); + bool _appendToSets = true, + QMap _localizedNames = {}, + QMap _localizedTexts = {}); /** * @brief Clones the current CardInfo instance. @@ -270,6 +290,96 @@ public: text = _text; emit cardInfoChanged(smartThis); } + + /** + * @brief Returns the card name in the given language, falling back to the + * English name when no localization is available. + * + * @param lang Language code (e.g. "de", "ja", "zhs"). + * @return The localized name, or the English name as fallback. + */ + [[nodiscard]] const QString &getLocalizedName(const QString &lang) const + { + const auto it = localizedNames.constFind(lang); + return it != localizedNames.constEnd() ? it.value() : name; + } + + /** + * @brief Returns the rules text in the given language, falling back to the + * English text when no localization is available. + * + * @param lang Language code (e.g. "de", "ja", "zhs"). + * @return The localized text, or the English text as fallback. + */ + [[nodiscard]] const QString &getLocalizedText(const QString &lang) const + { + const auto it = localizedTexts.constFind(lang); + return it != localizedTexts.constEnd() ? it.value() : text; + } + + /** + * @brief Returns the localized card names keyed by language code. + * + * Only languages that have an entry are present; there is no English + * fallback in this map. + */ + [[nodiscard]] const QMap &getLocalizedNames() const + { + return localizedNames; + } + + /** + * @brief Returns the localized rules text keyed by language code. + * + * Only languages that have an entry are present; there is no English + * fallback in this map. + */ + [[nodiscard]] const QMap &getLocalizedTexts() const + { + return localizedTexts; + } + + /** + * @brief Sets the card name for the given language. + * + * @param lang Language code. + * @param _localizedName The localized card name. + */ + void setLocalizedName(const QString &lang, const QString &_localizedName) + { + if (localizedNames.value(lang) == _localizedName) { + return; + } + localizedNames.insert(lang, _localizedName); + emit cardInfoChanged(smartThis); + } + + /** + * @brief Sets the rules text for the given language. + * + * @param lang Language code. + * @param _localizedText The localized rules text. + */ + void setLocalizedText(const QString &lang, const QString &_localizedText) + { + if (localizedTexts.value(lang) == _localizedText) { + return; + } + localizedTexts.insert(lang, _localizedText); + emit cardInfoChanged(smartThis); + } + + /** + * @brief Returns the language codes for which this card has a localized + * name or rules text. + */ + [[nodiscard]] QStringList localizationLanguages() const + { + QStringList languages = localizedNames.keys(); + languages.append(localizedTexts.keys()); + languages.removeDuplicates(); + return languages; + } [[nodiscard]] bool getIsToken() const { return isToken; diff --git a/libcockatrice_card/libcockatrice/card/card_localization.cpp b/libcockatrice_card/libcockatrice/card/card_localization.cpp new file mode 100644 index 000000000..03c6c659b --- /dev/null +++ b/libcockatrice_card/libcockatrice/card/card_localization.cpp @@ -0,0 +1,38 @@ +#include "card_localization.h" + +#include +#include +#include + +namespace CardLocalization +{ +const QStringList &supportedLanguages() +{ + static const QStringList languages = {"cs", "de", "es", "fr", "it", "ja", "ko", "pt", "ru", "zhs", "zht", "he"}; + return languages; +} + +QString languageDisplayName(const QString &lang) +{ + static const QHash displayNames = { + {"cs", "Česky (Czech)"}, + {"de", "Deutsch (German)"}, + {"es", "Español (Spanish)"}, + {"fr", "Français (French)"}, + {"it", "Italiano (Italian)"}, + {"ja", "日本語 (Japanese)"}, + {"ko", "한국어 (Korean)"}, + {"pt", "Português (Portuguese)"}, + {"ru", "Русский (Russian)"}, + {"he", "עברית (Hebrew)"}, + {"zhs", "简体中文 (Chinese Simplified)"}, + {"zht", "繁體中文 (Chinese Traditional)"}, + }; + const QString displayName = displayNames.value(lang); + if (!displayName.isEmpty()) { + return displayName; + } + const QString nativeName = QLocale(lang).nativeLanguageName(); + return nativeName.isEmpty() ? lang : nativeName; +} +} // namespace CardLocalization \ No newline at end of file diff --git a/libcockatrice_card/libcockatrice/card/card_localization.h b/libcockatrice_card/libcockatrice/card/card_localization.h new file mode 100644 index 000000000..a9c8d28ea --- /dev/null +++ b/libcockatrice_card/libcockatrice/card/card_localization.h @@ -0,0 +1,43 @@ +#ifndef CARD_LOCALIZATION_H +#define CARD_LOCALIZATION_H + +#include +#include + +/** + * @namespace CardLocalization + * @ingroup Cards + * + * @brief Shared language metadata for localized card text and images. + * + * Lists the language codes Cockatrice can display localized card data for and + * provides human-readable names. The list is shared between Oracle (which + * imports the selected language's card data) and the client settings UI (which + * offers the language choice). + */ +namespace CardLocalization +{ +/** + * @brief Language codes for which localized card data can be imported/displayed. + * + * Matches the languages Scryfall can serve localized card images for. "en" is + * always available as the default/fallback and is not listed here. + * + * @return The list of supported language codes. + */ +[[nodiscard]] const QStringList &supportedLanguages(); + +/** + * @brief Human-readable name for a language code. + * + * Follows the same "native name (English name)" format the UI language list + * uses (e.g. "日本語 (Japanese)"), so the English fallback is always visible. + * + * @param lang Language code (e.g. "de", "ja", "zhs"). + * @return The language's native name with its English name in parentheses, or + * the code itself if it cannot be resolved. + */ +[[nodiscard]] QString languageDisplayName(const QString &lang); +} // namespace CardLocalization + +#endif // CARD_LOCALIZATION_H \ No newline at end of file diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp index 2b27f50f8..3165ff871 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp @@ -17,7 +17,7 @@ namespace { constexpr quint32 CACHE_MAGIC = 0x43445243; // "CDRC" -constexpr quint32 CACHE_VERSION = 2; +constexpr quint32 CACHE_VERSION = 3; // ---- Primitives ----------------------------------------------------------- @@ -71,6 +71,35 @@ QDate readDate(QDataStream &in) return d; } +void writeStringMap(QDataStream &out, const QMap &map) +{ + out << static_cast(map.size()); + for (auto it = map.constBegin(); it != map.constEnd(); ++it) { + writeString(out, it.key()); + writeString(out, it.value()); + } +} + +QMap readStringMap(QDataStream &in) +{ + QMap map; + quint32 count = 0; + in >> count; + if (in.status() != QDataStream::Ok) { + return map; + } + for (quint32 i = 0; i < count; ++i) { + QString key = readString(in); + QString value = readString(in); + if (in.status() != QDataStream::Ok) { + map.clear(); + return map; + } + map.insert(key, value); + } + return map; +} + // ---- CardRelation ---------------------------------------------------------- void writeRelation(QDataStream &out, const CardRelation *rel) @@ -195,6 +224,10 @@ void writeCard(QDataStream &out, const CardInfoPtr &card) for (const CardRelation *rel : reverse) { writeRelation(out, rel); } + + // localized card data + writeStringMap(out, card->getLocalizedNames()); + writeStringMap(out, card->getLocalizedTexts()); } CardInfoPtr readCard(QDataStream &in, const SetNameMap &sets) @@ -268,8 +301,19 @@ CardInfoPtr readCard(QDataStream &in, const SetNameMap &sets) reverse.append(readRelation(in)); } - return CardInfo::newInstance(name, text, isToken, propertiesBlob, related, reverse, cardSets, ui, simpleName, - altNames, false); + const QMap localizedNames = readStringMap(in); + if (in.status() != QDataStream::Ok) { + return nullptr; + } + const QMap localizedTexts = readStringMap(in); + if (in.status() != QDataStream::Ok) { + return nullptr; + } + + CardInfoPtr card = CardInfo::newInstance(name, text, isToken, propertiesBlob, related, reverse, cardSets, ui, + simpleName, altNames, false, localizedNames, localizedTexts); + + return card; } // ---- FormatRules ----------------------------------------------------------- diff --git a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp index ec460d685..19e972a7a 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp @@ -273,6 +273,8 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) QString name = QString(""); QString text = QString(""); QHash properties; + QMap localizedNames; + QMap localizedTexts; QList relatedCards, reverseRelatedCards; auto _sets = SetToPrintingsMap(); int tableRow = 0; @@ -298,6 +300,44 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) // generic properties } else if (xmlName == "prop") { properties = loadCardPropertiesFromXml(xml); + // localized card data + } else if (xmlName == "localizations") { + while (!xml.atEnd()) { + if (xml.readNextStartElement()) { + const QString elementName = xml.name().toString(); + if (elementName == "localization") { + const QString lang = xml.attributes().value("lang").toString(); + QString localizedName; + QString localizedText; + while (!xml.atEnd()) { + if (xml.readNext() == QXmlStreamReader::EndElement) { + break; + } + if (xml.isStartElement()) { + const QString childName = xml.name().toString(); + QString value = xml.readElementText(QXmlStreamReader::IncludeChildElements); + if (childName == "name") { + localizedName = value; + } else if (childName == "text") { + localizedText = value; + } + } + } + if (!lang.isEmpty()) { + if (!localizedName.isEmpty()) { + localizedNames.insert(lang, localizedName); + } + if (!localizedText.isEmpty()) { + localizedTexts.insert(lang, localizedText); + } + } + } else { + xml.skipCurrentElement(); + } + } else { + break; + } + } // positioning info } else if (xmlName == "tablerow") { tableRow = xml.readElementText(QXmlStreamReader::IncludeChildElements).toInt(); @@ -399,8 +439,9 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) .landscapeOrientation = landscapeOrientation, .tableRow = tableRow, .upsideDownArt = upsideDown}; - CardInfoPtr newCard = CardInfo::newInstance(name, text, isToken, properties, relatedCards, - reverseRelatedCards, _sets, attributes); + CardInfoPtr newCard = + CardInfo::newInstance(name, text, isToken, properties, relatedCards, reverseRelatedCards, _sets, + attributes, std::move(localizedNames), std::move(localizedTexts)); if (targetData) { // Mirror CardDatabase::addCard: if a card with this name already // exists, merge the new printings into it instead of replacing. @@ -517,6 +558,26 @@ static QXmlStreamWriter &operator<<(QXmlStreamWriter &xml, const CardInfoPtr &in } xml.writeEndElement(); + // localized card data + const QStringList localizedLanguages = info->localizationLanguages(); + if (!localizedLanguages.isEmpty()) { + xml.writeStartElement("localizations"); + const QMap &localizedNames = info->getLocalizedNames(); + const QMap &localizedTexts = info->getLocalizedTexts(); + for (const QString &lang : localizedLanguages) { + xml.writeStartElement("localization"); + xml.writeAttribute("lang", lang); + if (localizedNames.contains(lang)) { + xml.writeTextElement("name", localizedNames.value(lang)); + } + if (localizedTexts.contains(lang)) { + xml.writeTextElement("text", localizedTexts.value(lang)); + } + xml.writeEndElement(); + } + xml.writeEndElement(); + } + // sets for (const auto &printings : info->getSets()) { for (const PrintingInfo &set : printings) { diff --git a/libcockatrice_deck_list/CMakeLists.txt b/libcockatrice_deck_list/CMakeLists.txt index c7a54a390..0c487466c 100644 --- a/libcockatrice_deck_list/CMakeLists.txt +++ b/libcockatrice_deck_list/CMakeLists.txt @@ -11,6 +11,7 @@ set(HEADERS libcockatrice/deck_list/deck_list_history_manager.h libcockatrice/deck_list/deck_list_node_tree.h libcockatrice/deck_list/deck_list_memento.h + libcockatrice/deck_list/deck_list_plain_text_parser.h libcockatrice/deck_list/playmat_resolver.h libcockatrice/deck_list/sideboard_plan.h ) @@ -27,6 +28,7 @@ add_library( libcockatrice/deck_list/deck_list.cpp libcockatrice/deck_list/deck_list_history_manager.cpp libcockatrice/deck_list/deck_list_node_tree.cpp + libcockatrice/deck_list/deck_list_plain_text_parser.cpp libcockatrice/deck_list/playmat_resolver.cpp libcockatrice/deck_list/sideboard_plan.cpp ) diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp index 1a3876cd3..90f63c09d 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp @@ -1,6 +1,7 @@ #include "deck_list.h" #include "deck_list_memento.h" +#include "deck_list_plain_text_parser.h" #include "tree/abstract_deck_list_node.h" #include "tree/deck_list_card_node.h" #include "tree/inner_deck_list_node.h" @@ -8,26 +9,138 @@ #include #include #include -#include #include #include #include -#if QT_VERSION < 0x050600 -// qHash on QRegularExpression was added in 5.6, FIX IT -uint qHash(const QRegularExpression &key, uint seed) noexcept -{ - return qHash(key.pattern(), seed); // call qHash on pattern QString instead -} -#endif - static const QString CURRENT_SIDEBOARD_PLAN_KEY = ""; +/** + * @brief Parses a floating point XML attribute into a clamped playmat parameter. + * + * Falls back to @p fallback when the attribute is missing or malformed, so + * malformed deck files cannot produce degenerate art rectangles (e.g. a zoom + * of 0 dividing by zero). + * + * @param valueString Raw attribute text. + * @param fallback Value used when the text cannot be parsed. + * @param min Lower clamp bound. + * @param max Upper clamp bound. + * @return The parsed value clamped to [min, max], or @p fallback. + */ +static double parseClampedParam(const QString &valueString, double fallback, double min, double max) +{ + bool ok = false; + const double value = valueString.toDouble(&ok); + if (!ok) { + return fallback; + } + return qBound(min, value, max); +} + +/** + * @brief Reads a `bannerCard` element from the XML stream. + * + * @param xml Reader positioned at the element. + * @return The referenced card. + */ +static CardRef readBannerCard(QXmlStreamReader *xml) +{ + QString providerId = xml->attributes().value("providerId").toString(); + QString cardName = xml->readElementText(); + return {cardName, providerId}; +} + +/** + * @brief Reads a `playmatCard` element from the XML stream. + * + * Attribute values are read before readElementText consumes the element, and + * the params are clamped to the same ranges as the settings dialog and the + * remote player-properties path so malformed deck files cannot produce + * degenerate art rectangles (e.g. a zoom of 0 dividing by zero). + * + * @param xml Reader positioned at the element. + * @return The referenced card plus its clamped positioning parameters. + */ +static PlaymatInfo readPlaymatCard(QXmlStreamReader *xml) +{ + QString providerId = xml->attributes().value("providerId").toString(); + QString marginLStr = xml->attributes().value("marginPctL").toString(); + QString marginRStr = xml->attributes().value("marginPctR").toString(); + QString vOffStr = xml->attributes().value("verticalOffset").toString(); + QString zoomStr = xml->attributes().value("zoom").toString(); + QString cardName = xml->readElementText(); + + return { + .card = {cardName, providerId}, + .params = {.marginPctL = parseClampedParam(marginLStr, 0.07, 0.0, 0.95), + .marginPctR = parseClampedParam(marginRStr, 0.07, 0.0, 0.95), + .verticalOffset = parseClampedParam(vOffStr, 0.33, 0.0, 1.0), + .zoom = parseClampedParam(zoomStr, 1.0, 0.1, 4.0)}, + }; +} + bool DeckList::Metadata::isEmpty() const { return name.isEmpty() && comments.isEmpty() && bannerCard.isEmpty() && tags.isEmpty() && playmat.card.isEmpty(); } +bool DeckList::Metadata::readElement(QXmlStreamReader *xml, const QString &childName) +{ + if (childName == "lastLoadedTimestamp") { + lastLoadedTimestamp = xml->readElementText(); + } else if (childName == "deckname") { + name = xml->readElementText(); + } else if (childName == "format") { + gameFormat = xml->readElementText(); + } else if (childName == "comments") { + comments = xml->readElementText(); + } else if (childName == "bannerCard") { + bannerCard = readBannerCard(xml); + } else if (childName == "playmatCard") { + playmat = readPlaymatCard(xml); + } else if (childName == "tags") { + tags.clear(); // Clear existing tags + while (xml->readNextStartElement()) { + if (xml->name().toString() == "tag") { + tags.append(xml->readElementText()); + } + } + } else { + return false; + } + return true; +} + +void DeckList::Metadata::write(QXmlStreamWriter *xml) const +{ + xml->writeTextElement("lastLoadedTimestamp", lastLoadedTimestamp); + xml->writeTextElement("deckname", name); + xml->writeTextElement("format", gameFormat); + xml->writeStartElement("bannerCard"); + xml->writeAttribute("providerId", bannerCard.providerId); + xml->writeCharacters(bannerCard.name); + xml->writeEndElement(); + if (!playmat.card.isEmpty()) { + xml->writeStartElement("playmatCard"); + xml->writeAttribute("providerId", playmat.card.providerId); + xml->writeAttribute("marginPctL", QString::number(playmat.params.marginPctL, 'f', 4)); + xml->writeAttribute("marginPctR", QString::number(playmat.params.marginPctR, 'f', 4)); + xml->writeAttribute("verticalOffset", QString::number(playmat.params.verticalOffset, 'f', 4)); + xml->writeAttribute("zoom", QString::number(playmat.params.zoom, 'f', 4)); + xml->writeCharacters(playmat.card.name); + xml->writeEndElement(); + } + xml->writeTextElement("comments", comments); + + // Write tags + xml->writeStartElement("tags"); + for (const QString &tag : tags) { + xml->writeTextElement("tag", tag); + } + xml->writeEndElement(); +} + DeckList::DeckList() { } @@ -62,56 +175,10 @@ bool DeckList::readElement(QXmlStreamReader *xml) { const QString childName = xml->name().toString(); if (xml->isStartElement()) { - if (childName == "lastLoadedTimestamp") { - metadata.lastLoadedTimestamp = xml->readElementText(); - } else if (childName == "deckname") { - metadata.name = xml->readElementText(); - } else if (childName == "format") { - metadata.gameFormat = xml->readElementText(); - } else if (childName == "comments") { - metadata.comments = xml->readElementText(); - } else if (childName == "bannerCard") { - QString providerId = xml->attributes().value("providerId").toString(); - QString cardName = xml->readElementText(); - metadata.bannerCard = {cardName, providerId}; - } else if (childName == "playmatCard") { - QString providerId = xml->attributes().value("providerId").toString(); - bool ok; - QString marginLStr = xml->attributes().value("marginPctL").toString(); - QString marginRStr = xml->attributes().value("marginPctR").toString(); - QString vOffStr = xml->attributes().value("verticalOffset").toString(); - QString zoomStr = xml->attributes().value("zoom").toString(); - QString cardName = xml->readElementText(); - PlaymatInfo playmat; - playmat.card = {cardName, providerId}; - // Clamp to the same ranges as the settings dialog and the remote - // player-properties path so malformed deck files cannot produce - // degenerate art rectangles (e.g. a zoom of 0 dividing by zero). - playmat.params.marginPctL = qBound(0.0, marginLStr.toDouble(&ok), 0.95); - if (!ok) { - playmat.params.marginPctL = 0.07; - } - playmat.params.marginPctR = qBound(0.0, marginRStr.toDouble(&ok), 0.95); - if (!ok) { - playmat.params.marginPctR = 0.07; - } - playmat.params.verticalOffset = qBound(0.0, vOffStr.toDouble(&ok), 1.0); - if (!ok) { - playmat.params.verticalOffset = 0.33; - } - playmat.params.zoom = qBound(0.1, zoomStr.toDouble(&ok), 4.0); - if (!ok) { - playmat.params.zoom = 1.0; - } - metadata.playmat = playmat; - } else if (childName == "tags") { - metadata.tags.clear(); // Clear existing tags - while (xml->readNextStartElement()) { - if (xml->name().toString() == "tag") { - metadata.tags.append(xml->readElementText()); - } - } - } else if (childName == "zone") { + if (metadata.readElement(xml, childName)) { + return true; + } + if (childName == "zone") { tree.readZoneElement(xml); } else if (childName == "sideboard_plan") { SideboardPlan newSideboardPlan; @@ -125,41 +192,12 @@ bool DeckList::readElement(QXmlStreamReader *xml) return true; } -static void writeMetadata(QXmlStreamWriter *xml, const DeckList::Metadata &metadata) -{ - xml->writeTextElement("lastLoadedTimestamp", metadata.lastLoadedTimestamp); - xml->writeTextElement("deckname", metadata.name); - xml->writeTextElement("format", metadata.gameFormat); - xml->writeStartElement("bannerCard"); - xml->writeAttribute("providerId", metadata.bannerCard.providerId); - xml->writeCharacters(metadata.bannerCard.name); - xml->writeEndElement(); - if (!metadata.playmat.card.isEmpty()) { - xml->writeStartElement("playmatCard"); - xml->writeAttribute("providerId", metadata.playmat.card.providerId); - xml->writeAttribute("marginPctL", QString::number(metadata.playmat.params.marginPctL, 'f', 4)); - xml->writeAttribute("marginPctR", QString::number(metadata.playmat.params.marginPctR, 'f', 4)); - xml->writeAttribute("verticalOffset", QString::number(metadata.playmat.params.verticalOffset, 'f', 4)); - xml->writeAttribute("zoom", QString::number(metadata.playmat.params.zoom, 'f', 4)); - xml->writeCharacters(metadata.playmat.card.name); - xml->writeEndElement(); - } - xml->writeTextElement("comments", metadata.comments); - - // Write tags - xml->writeStartElement("tags"); - for (const QString &tag : metadata.tags) { - xml->writeTextElement("tag", tag); - } - xml->writeEndElement(); -} - void DeckList::write(QXmlStreamWriter *xml) const { xml->writeStartElement("cockatrice_deck"); xml->writeAttribute("version", "1"); - writeMetadata(xml, metadata); + metadata.write(xml); // Write zones tree.write(xml); @@ -172,6 +210,27 @@ void DeckList::write(QXmlStreamWriter *xml) const xml->writeEndElement(); // Close "cockatrice_deck" } +bool DeckList::seekToNextElement(QXmlStreamReader *xml) +{ + while (!xml->atEnd()) { + xml->readNext(); + if (xml->isStartElement()) { + return true; + } + } + return false; +} + +void DeckList::readDeckBody(QXmlStreamReader *xml) +{ + while (!xml->atEnd()) { + xml->readNext(); + if (!readElement(xml)) { + break; + } + } +} + bool DeckList::loadFromXml(QXmlStreamReader *xml) { if (xml->error()) { @@ -180,19 +239,11 @@ bool DeckList::loadFromXml(QXmlStreamReader *xml) } cleanList(); - while (!xml->atEnd()) { - xml->readNext(); - if (xml->isStartElement()) { - if (xml->name().toString() != "cockatrice_deck") { - return false; - } - while (!xml->atEnd()) { - xml->readNext(); - if (!readElement(xml)) { - break; - } - } + while (seekToNextElement(xml)) { + if (xml->name().toString() != "cockatrice_deck") { + return false; } + readDeckBody(xml); } refreshDeckHash(); if (xml->error()) { @@ -248,160 +299,12 @@ bool DeckList::loadFromStream_Plain(QTextStream &in, bool preserveMetadata, const std::function &cardNameNormalizer) { - const QRegularExpression reCardLine(R"(^\s*[\w\[\(\{].*$)", QRegularExpression::UseUnicodePropertiesOption); - const QRegularExpression reEmpty("^\\s*$"); - const QRegularExpression reComment(R"([\w\[\(\{].*$)", QRegularExpression::UseUnicodePropertiesOption); - const QRegularExpression reSBMark("^\\s*sb:\\s*(.+)", QRegularExpression::CaseInsensitiveOption); - const QRegularExpression reSBComment("^sideboard\\b.*$", QRegularExpression::CaseInsensitiveOption); - const QRegularExpression reDeckComment("^((main)?deck(list)?|mainboard)\\b", - QRegularExpression::CaseInsensitiveOption); - - // Regex for advanced card parsing - const QRegularExpression reMultiplier(R"(^[xX\(\[]*(\d+)[xX\*\)\]]* ?(.+))"); - - // Regex for extracting set code and collector number with attached symbols - const QRegularExpression reHyphenFormat(R"(\((\w{3,})\)\s+(\w{3,})-(\d+[^\w\s]*))"); - const QRegularExpression reRegularFormat(R"(\((\w{3,})\)\s+(\d+[^\w\s]*))"); - - cleanList(preserveMetadata); - - auto inputs = in.readAll().trimmed().split('\n'); - auto max_line = inputs.size(); - - // Start at the first empty line before the first card line - auto deckStart = inputs.indexOf(reCardLine); - if (deckStart == -1) { - if (inputs.indexOf(reComment) == -1) { - return false; // Input is empty - } - deckStart = max_line; - } else { - deckStart = inputs.lastIndexOf(reEmpty, deckStart); - if (deckStart == -1) { - deckStart = 0; - } + if (!preserveMetadata) { + metadata = {}; } - - // find sideboard position, if marks are used this won't be needed - int sBStart = -1; - if (inputs.indexOf(reSBMark, deckStart) == -1) { - sBStart = inputs.indexOf(reSBComment, deckStart); - if (sBStart == -1) { - sBStart = inputs.indexOf(reEmpty, deckStart + 1); - if (sBStart == -1) { - sBStart = max_line; - } - auto nextCard = inputs.indexOf(reCardLine, sBStart + 1); - if (inputs.indexOf(reEmpty, nextCard + 1) != -1) { - sBStart = max_line; - } - } - } - - int index = 0; - QRegularExpressionMatch match; - - // Parse name and comments - while (index < deckStart) { - const auto ¤t = inputs.at(index++); - if (!current.contains(reEmpty)) { - match = reComment.match(current); - metadata.name = match.captured(); - break; - } - } - while (index < deckStart) { - const auto ¤t = inputs.at(index++); - if (!current.contains(reEmpty)) { - match = reComment.match(current); - metadata.comments += match.captured() + '\n'; - } - } - metadata.comments.chop(1); - - // Discard empty lines - while (index < max_line && inputs.at(index).contains(reEmpty)) { - ++index; - } - - // Discard line if it starts with deck or mainboard, all cards until the sideboard starts are in the mainboard - if (inputs.at(index).contains(reDeckComment)) { - ++index; - } - - // Parse decklist - for (; index < max_line; ++index) { - // check if line is a card - match = reCardLine.match(inputs.at(index)); - if (!match.hasMatch()) { - continue; - } - - QString cardName = match.captured().simplified(); - bool sideboard = false; - - // Sideboard detection - if (sBStart < 0) { - match = reSBMark.match(cardName); - if (match.hasMatch()) { - sideboard = true; - cardName = match.captured(1); - } - } else { - if (index == sBStart) { - continue; - } - sideboard = index > sBStart; - } - - // Extract set code, collector number, and foil - QString setCode; - QString collectorNumber; - bool isFoil = false; - - // Check for foil status at the end of the card name - if (cardName.endsWith("*F*", Qt::CaseInsensitive)) { - isFoil = true; - cardName.chop(3); // Remove the "*F*" from the card name - } - Q_UNUSED(isFoil); - - // Attempt to match the hyphen-separated format (PLST-2094) - match = reHyphenFormat.match(cardName); - if (match.hasMatch()) { - setCode = match.captured(2).toUpper(); - collectorNumber = match.captured(3); - cardName = cardName.left(match.capturedStart()).trimmed(); - } else { - // Attempt to match the regular format (PLST) 2094 - match = reRegularFormat.match(cardName); - if (match.hasMatch()) { - setCode = match.captured(1).toUpper(); - collectorNumber = match.captured(2); - cardName = cardName.left(match.capturedStart()).trimmed(); - } - } - - // check if a specific amount is mentioned - int amount = 1; - match = reMultiplier.match(cardName); - if (match.hasMatch()) { - amount = match.captured(1).toInt(); - cardName = match.captured(2); - } - - // Normalize the card name - cardName = cardNameNormalizer(cardName); - - // Determine the zone (mainboard/sideboard) - QString zoneName = sideboard ? DECK_ZONE_SIDE : DECK_ZONE_MAIN; - - // make new entry in decklist - tree.addCard(cardName, amount, zoneName, -1, setCode, collectorNumber); - } - + bool ok = DeckListPlainText::parse(in, cardNameNormalizer, metadata, tree); refreshDeckHash(); - return true; + return ok; } bool DeckList::loadFromFile_Plain(QIODevice *device, const std::function &cardNameNormalizer) diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h index 475d99560..199d3a9a8 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h @@ -76,6 +76,23 @@ public: * @brief Checks if all values (except for lastLoadedTimestamp) in the metadata is empty. */ bool isEmpty() const; + + /** + * @brief Reads a single deck metadata element from a Cockatrice deck XML stream. + * + * @param xml Reader positioned at the element. + * @param childName Name of the current element. + * @return true if a metadata element was consumed, false if @p childName is + * not a metadata element. + */ + bool readElement(QXmlStreamReader *xml, const QString &childName); + + /** + * @brief Writes the deck metadata section of a Cockatrice deck XML file. + * + * @param xml Writer to append the metadata elements to. + */ + void write(QXmlStreamWriter *xml) const; }; private: @@ -89,6 +106,22 @@ private: */ mutable QString cachedDeckHash; + /** @name XML load helpers */ + ///@{ + /** + * @brief Advances to the next element in the XML stream. + * @param xml Reader to advance past non-element tokens. + * @return true when a start element was reached, false at end of stream. + */ + bool seekToNextElement(QXmlStreamReader *xml); + + /** + * @brief Reads the contents of a `cockatrice_deck` element into this deck. + * @param xml Reader positioned at the deck element, stopped at its end. + */ + void readDeckBody(QXmlStreamReader *xml); + ///@} + public: /** @name Metadata setters */ ///@{ diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.cpp index acf4707ab..b6d0687b4 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.cpp @@ -14,42 +14,32 @@ void DeckListHistoryManager::clear() emit undoRedoStateChanged(); } -void DeckListHistoryManager::undo(DeckList *deck) +void DeckListHistoryManager::restoreAndSwap(QStack &source, + QStack &target, + DeckList *deck) { - if (undoStack.isEmpty()) { + if (source.isEmpty()) { return; } - // Peek at the memento we are going to restore - const DeckListMemento &mementoToRestore = undoStack.top(); + // The reason is read before the source is popped. + const QString reason = source.top().getReason(); - // Save current state for redo - DeckListMemento currentState = deck->createMemento(mementoToRestore.getReason()); - redoStack.push(currentState); + // Save the current state so the opposite direction can return to it. + target.push(deck->createMemento(reason)); - // Pop the last state from undo stack and restore it - DeckListMemento memento = undoStack.pop(); - deck->restoreMemento(memento); + // Apply the state we are moving to. + deck->restoreMemento(source.pop()); emit undoRedoStateChanged(); } +void DeckListHistoryManager::undo(DeckList *deck) +{ + restoreAndSwap(undoStack, redoStack, deck); +} + void DeckListHistoryManager::redo(DeckList *deck) { - if (redoStack.isEmpty()) { - return; - } - - // Peek at the memento we are going to restore - const DeckListMemento &mementoToRestore = redoStack.top(); - - // Save current state for undo - DeckListMemento currentState = deck->createMemento(mementoToRestore.getReason()); - undoStack.push(currentState); - - // Pop the next state from redo stack and restore it - DeckListMemento memento = redoStack.pop(); - deck->restoreMemento(memento); - - emit undoRedoStateChanged(); + restoreAndSwap(redoStack, undoStack, deck); } diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.h index e6bd27e2d..6acdb199e 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.h @@ -47,6 +47,13 @@ public: } private: + /** + * @brief Moves one state from @p source to @p target, applying it to @p deck. + * + * Used by both undo (undoStack -> redoStack) and redo (redoStack -> undoStack). + */ + void restoreAndSwap(QStack &source, QStack &target, DeckList *deck); + QStack undoStack; QStack redoStack; }; diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp index 91fe1874b..66f228d19 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp @@ -7,6 +7,84 @@ static constexpr int MAX_DECK_SIZE = 1e5; +namespace +{ + +/** + * @brief Expands card nodes into one lowercase name entry per copy. + * + * @param nodes The card nodes to expand. + * @param prefix Optional prefix prepended to every entry (e.g. "SB:" for the sideboard). + * @return One entry per copy, in node order. + */ +QStringList cardNodesToCopies(const QList &nodes, const QString &prefix = {}) +{ + QStringList result; + for (auto node : nodes) { + for (int i = 0; i < node->getNumber(); ++i) { + result.append(prefix + node->getName().toLower()); + } + } + return result; +} + +/** + * @brief Packs the first five bytes of a SHA-1 digest into a compact base-32 number. + * + * The bytes are placed in most-significant-byte-first order (byte 0 shifted by 32, + * byte 4 unshifted) so decks that only differ in their low-order hash bytes still + * produce distinct identifiers. + * + * @return The 8-character base-32 representation of the packed number. + */ +QString encodeDeckHash(const QByteArray &digest) +{ + quint64 number = 0; + for (int i = 0; i < 5; ++i) { + number |= static_cast(static_cast(digest[i])) << (32 - 8 * i); + } + return QString::number(number, 32).rightJustified(8, '0'); +} + +/** + * @brief Collects every card node in @p node's subtree, in tree order. + * + * @return The collected card nodes. + */ +QList collectCardsRecursive(const InnerDecklistNode *node) +{ + QList result; + for (int i = 0; i < node->size(); i++) { + if (auto *card = dynamic_cast(node->at(i))) { + result.append(card); + } else if (auto *inner = dynamic_cast(node->at(i))) { + result.append(collectCardsRecursive(inner)); + } + } + return result; +} + +/** + * @brief Invokes @p func on every card in @p node's subtree. + * + * Cards nested in custom zones are reported with their top-level @p boardZone + * so that callers can classify cards by board (main/side/maybeboard/tokens). + */ +void forEachCardInNode(InnerDecklistNode *boardZone, + InnerDecklistNode *node, + const std::function &func) +{ + for (int i = 0; i < node->size(); i++) { + if (auto *card = dynamic_cast(node->at(i))) { + func(boardZone, card); + } else if (auto *inner = dynamic_cast(node->at(i))) { + forEachCardInNode(boardZone, inner, func); + } + } +} + +} // namespace + DecklistNodeTree::DecklistNodeTree() : root(new InnerDecklistNode()) { } @@ -43,19 +121,8 @@ QList DecklistNodeTree::getCardNodes(const QSet result; - std::function collectCards = [&collectCards, - &result](const InnerDecklistNode *node) { - for (int i = 0; i < node->size(); i++) { - if (auto *card = dynamic_cast(node->at(i))) { - result.append(card); - } else if (auto *inner = dynamic_cast(node->at(i))) { - collectCards(inner); - } - } - }; - for (auto *zoneNode : getZoneNodes(restrictToZones)) { - collectCards(zoneNode); + result.append(collectCardsRecursive(zoneNode)); } return result; @@ -83,25 +150,11 @@ QString DecklistNodeTree::computeDeckHash() const auto mainDeckNodes = getCardNodes({DECK_ZONE_MAIN}); auto sideDeckNodes = getCardNodes({DECK_ZONE_SIDE}); - static auto nodesToCardList = [](const QList &nodes, const QString &prefix = {}) { - QStringList result; - for (auto node : nodes) { - for (int i = 0; i < node->getNumber(); ++i) { - result.append(prefix + node->getName().toLower()); - } - } - return result; - }; - - QStringList cardList = nodesToCardList(mainDeckNodes) + nodesToCardList(sideDeckNodes, "SB:"); + QStringList cardList = cardNodesToCopies(mainDeckNodes) + cardNodesToCopies(sideDeckNodes, "SB:"); cardList.sort(); QByteArray deckHashArray = QCryptographicHash::hash(cardList.join(";").toUtf8(), QCryptographicHash::Sha1); - quint64 number = (((quint64)(unsigned char)deckHashArray[0]) << 32) + - (((quint64)(unsigned char)deckHashArray[1]) << 24) + - (((quint64)(unsigned char)deckHashArray[2] << 16)) + - (((quint64)(unsigned char)deckHashArray[3]) << 8) + (quint64)(unsigned char)deckHashArray[4]; - return QString::number(number, 32).rightJustified(8, '0'); + return encodeDeckHash(deckHashArray); } void DecklistNodeTree::write(QXmlStreamWriter *xml) const @@ -148,12 +201,7 @@ bool DecklistNodeTree::deleteNode(AbstractDecklistNode *node, InnerDecklistNode int index = rootNode->indexOf(node); if (index != -1) { delete rootNode->takeAt(index); - - // Empty custom zones are kept while empty board zones get pruned. - if (rootNode->empty() && rootNode->getParent() == root) { - deleteNode(rootNode, rootNode->getParent()); - } - + pruneEmptyBoardZone(rootNode); return true; } @@ -169,24 +217,18 @@ bool DecklistNodeTree::deleteNode(AbstractDecklistNode *node, InnerDecklistNode return false; } +void DecklistNodeTree::pruneEmptyBoardZone(InnerDecklistNode *container) +{ + if (container->isEmpty() && container->getParent() == root) { + deleteNode(container, container->getParent()); + } +} + void DecklistNodeTree::forEachCard(const std::function &func) const { - // Cards nested in custom zones are reported with their top-level board zone - // so that callers can classify cards by board (main/side/maybeboard/tokens). - std::function walk = [&func, &walk](InnerDecklistNode *boardZone, - InnerDecklistNode *node) { - for (int i = 0; i < node->size(); i++) { - if (auto *card = dynamic_cast(node->at(i))) { - func(boardZone, card); - } else if (auto *inner = dynamic_cast(node->at(i))) { - walk(boardZone, inner); - } - } - }; - for (int i = 0; i < root->size(); i++) { if (auto *zone = dynamic_cast(root->at(i))) { - walk(zone, zone); + forEachCardInNode(zone, zone, func); } } } @@ -197,7 +239,7 @@ void DecklistNodeTree::forEachCard(const std::functionsize(); i++) { auto *node = dynamic_cast(root->at(i)); diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h index 5d91cd233..2c66b34ff 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h @@ -144,9 +144,17 @@ public: private: // Helpers for traversing the tree - InnerDecklistNode *getZoneObjFromName(const QString &zoneName) const; + InnerDecklistNode *getZoneObjFromName(const QString &zoneName); InnerDecklistNode *findBoardZone(const QString &boardZoneName) const; InnerDecklistNode *findOrCreateBoardZone(const QString &boardZoneName); + + /** + * @brief Recursively removes @p container when it is an empty board zone. + * + * Empty custom zones are kept while empty board zones get pruned, so a + * board zone disappears once its last card or custom zone goes away. + */ + void pruneEmptyBoardZone(InnerDecklistNode *container); }; #endif // COCKATRICE_DECKLIST_NODE_TREE_H diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.cpp new file mode 100644 index 000000000..de50d743b --- /dev/null +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.cpp @@ -0,0 +1,172 @@ +#include "deck_list_plain_text_parser.h" + +#include "deck_list_node_tree.h" +#include "tree/inner_deck_list_node.h" + +#include +#include + +namespace DeckListPlainText +{ + +bool parse(QTextStream &in, + const std::function &cardNameNormalizer, + DeckList::Metadata &metadata, + DecklistNodeTree &tree) +{ + tree.clear(); + + static const QRegularExpression reCardLine(R"(^\s*[\w\[\(\{].*$)", QRegularExpression::UseUnicodePropertiesOption); + static const QRegularExpression reEmpty("^\\s*$"); + static const QRegularExpression reComment(R"([\w\[\(\{].*$)", QRegularExpression::UseUnicodePropertiesOption); + static const QRegularExpression reSBMark("^\\s*sb:\\s*(.+)", QRegularExpression::CaseInsensitiveOption); + static const QRegularExpression reSBComment("^sideboard\\b.*$", QRegularExpression::CaseInsensitiveOption); + static const QRegularExpression reDeckComment("^((main)?deck(list)?|mainboard)\\b", + QRegularExpression::CaseInsensitiveOption); + + // Regex for advanced card parsing + static const QRegularExpression reMultiplier(R"(^[xX\(\[]*(\d+)[xX\*\)\]]* ?(.+))"); + + // Regex for extracting set code and collector number with attached symbols + static const QRegularExpression reHyphenFormat(R"(\((\w{3,})\)\s+(\w{3,})-(\d+[^\w\s]*))"); + static const QRegularExpression reRegularFormat(R"(\((\w{3,})\)\s+(\d+[^\w\s]*))"); + + auto inputs = in.readAll().trimmed().split('\n'); + auto max_line = inputs.size(); + + // Start at the first empty line before the first card line + auto deckStart = inputs.indexOf(reCardLine); + if (deckStart == -1) { + if (inputs.indexOf(reComment) == -1) { + return false; // Input is empty + } + deckStart = max_line; + } else { + deckStart = inputs.lastIndexOf(reEmpty, deckStart); + if (deckStart == -1) { + deckStart = 0; + } + } + + // find sideboard position, if marks are used this won't be needed + int sBStart = -1; + if (inputs.indexOf(reSBMark, deckStart) == -1) { + sBStart = inputs.indexOf(reSBComment, deckStart); + if (sBStart == -1) { + sBStart = inputs.indexOf(reEmpty, deckStart + 1); + if (sBStart == -1) { + sBStart = max_line; + } + auto nextCard = inputs.indexOf(reCardLine, sBStart + 1); + if (inputs.indexOf(reEmpty, nextCard + 1) != -1) { + sBStart = max_line; + } + } + } + + int index = 0; + QRegularExpressionMatch match; + + // Parse name and comments + while (index < deckStart) { + const auto ¤t = inputs.at(index++); + if (!current.contains(reEmpty)) { + match = reComment.match(current); + metadata.name = match.captured(); + break; + } + } + while (index < deckStart) { + const auto ¤t = inputs.at(index++); + if (!current.contains(reEmpty)) { + match = reComment.match(current); + metadata.comments += match.captured() + '\n'; + } + } + metadata.comments.chop(1); + + // Discard empty lines + while (index < max_line && inputs.at(index).contains(reEmpty)) { + ++index; + } + + // Discard line if it starts with deck or mainboard, all cards until the sideboard starts are in the mainboard + if (inputs.at(index).contains(reDeckComment)) { + ++index; + } + + // Parse decklist + for (; index < max_line; ++index) { + // check if line is a card + match = reCardLine.match(inputs.at(index)); + if (!match.hasMatch()) { + continue; + } + + QString cardName = match.captured().simplified(); + bool sideboard = false; + + // Sideboard detection + if (sBStart < 0) { + match = reSBMark.match(cardName); + if (match.hasMatch()) { + sideboard = true; + cardName = match.captured(1); + } + } else { + if (index == sBStart) { + continue; + } + sideboard = index > sBStart; + } + + // Extract set code, collector number, and foil + QString setCode; + QString collectorNumber; + bool isFoil = false; + + // Check for foil status at the end of the card name + if (cardName.endsWith("*F*", Qt::CaseInsensitive)) { + isFoil = true; + cardName.chop(3); // Remove the "*F*" from the card name + } + Q_UNUSED(isFoil); + + // Attempt to match the hyphen-separated format (PLST-2094) + match = reHyphenFormat.match(cardName); + if (match.hasMatch()) { + setCode = match.captured(2).toUpper(); + collectorNumber = match.captured(3); + cardName = cardName.left(match.capturedStart()).trimmed(); + } else { + // Attempt to match the regular format (PLST) 2094 + match = reRegularFormat.match(cardName); + if (match.hasMatch()) { + setCode = match.captured(1).toUpper(); + collectorNumber = match.captured(2); + cardName = cardName.left(match.capturedStart()).trimmed(); + } + } + + // check if a specific amount is mentioned + int amount = 1; + match = reMultiplier.match(cardName); + if (match.hasMatch()) { + amount = match.captured(1).toInt(); + cardName = match.captured(2); + } + + // Normalize the card name + cardName = cardNameNormalizer(cardName); + + // Determine the zone (mainboard/sideboard) + QString zoneName = sideboard ? DECK_ZONE_SIDE : DECK_ZONE_MAIN; + + // make new entry in decklist + tree.addCard(cardName, amount, zoneName, -1, setCode, collectorNumber); + } + + return true; +} + +} // namespace DeckListPlainText \ No newline at end of file diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.h new file mode 100644 index 000000000..e0f456a18 --- /dev/null +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.h @@ -0,0 +1,33 @@ +#ifndef COCKATRICE_DECK_LIST_PLAIN_TEXT_PARSER_H +#define COCKATRICE_DECK_LIST_PLAIN_TEXT_PARSER_H + +#include "deck_list.h" + +#include +#include + +class QTextStream; + +namespace DeckListPlainText +{ + +/** + * @brief Parses a plain-text deck list into a tree and its metadata. + * + * Clears the tree first, then fills both from the text. + * + * @param in The text to load + * @param cardNameNormalizer Function that takes the parsed card name string + * in the text and returns the name to store + * @param metadata Deck metadata written by the parser + * @param tree Deck tree the parser adds cards to + * @return False if the input was empty, true otherwise. + */ +bool parse(QTextStream &in, + const std::function &cardNameNormalizer, + DeckList::Metadata &metadata, + DecklistNodeTree &tree); + +} // namespace DeckListPlainText + +#endif // COCKATRICE_DECK_LIST_PLAIN_TEXT_PARSER_H \ No newline at end of file diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/sideboard_plan.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/sideboard_plan.cpp index a76fed619..855062c18 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/sideboard_plan.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/sideboard_plan.cpp @@ -2,6 +2,32 @@ #include +namespace +{ + +void readMoveCardToZone(QXmlStreamReader *xml, QList &moveList) +{ + MoveCard_ToZone move; + while (!xml->atEnd()) { + xml->readNext(); + const QString childName = xml->name().toString(); + if (xml->isStartElement()) { + if (childName == "card_name") { + move.set_card_name(xml->readElementText().toStdString()); + } else if (childName == "start_zone") { + move.set_start_zone(xml->readElementText().toStdString()); + } else if (childName == "target_zone") { + move.set_target_zone(xml->readElementText().toStdString()); + } + } else if (xml->isEndElement() && (childName == "move_card_to_zone")) { + moveList.append(move); + return; + } + } +} + +} // namespace + SideboardPlan::SideboardPlan(const QString &_name, const QList &_moveList) : name(_name), moveList(_moveList) { @@ -21,23 +47,7 @@ bool SideboardPlan::readElement(QXmlStreamReader *xml) if (childName == "name") { name = xml->readElementText(); } else if (childName == "move_card_to_zone") { - MoveCard_ToZone m; - while (!xml->atEnd()) { - xml->readNext(); - const QString childName2 = xml->name().toString(); - if (xml->isStartElement()) { - if (childName2 == "card_name") { - m.set_card_name(xml->readElementText().toStdString()); - } else if (childName2 == "start_zone") { - m.set_start_zone(xml->readElementText().toStdString()); - } else if (childName2 == "target_zone") { - m.set_target_zone(xml->readElementText().toStdString()); - } - } else if (xml->isEndElement() && (childName2 == "move_card_to_zone")) { - moveList.append(m); - break; - } - } + readMoveCardToZone(xml, moveList); } } else if (xml->isEndElement() && (childName == "sideboard_plan")) { return true; diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.cpp index 7200ede5f..685238d53 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.cpp @@ -34,17 +34,6 @@ bool AbstractDecklistCardNode::compareName(AbstractDecklistNode *other) const } } -int AbstractDecklistCardNode::readElement(QXmlStreamReader *xml, int /* limit */) -{ - while (!xml->atEnd()) { - xml->readNext(); - if (xml->isEndElement() && xml->name().toString() == "card") { - return 0; - } - } - return 0; -} - void AbstractDecklistCardNode::writeElement(QXmlStreamWriter *xml) { xml->writeEmptyElement("card"); diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.h b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.h index 52dd56529..0942a4601 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.h @@ -134,15 +134,6 @@ public: */ bool compareName(AbstractDecklistNode *other) const; - /** - * @brief Deserialize this node’s properties from XML. - * @param xml QXmlStreamReader positioned at the element. - * @return true if parsing succeeded. - * - * This supports loading deck files from Cockatrice’s XML format. - */ - int readElement(QXmlStreamReader *xml, int limit) override; - /** * @brief Serialize this node’s properties to XML. * @param xml Writer to append this node’s XML element. diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_node.h b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_node.h index 9c4290db0..38d1d44b8 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_node.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_node.h @@ -179,11 +179,10 @@ public: /** * @name XML serialization - * These methods support reading and writing decks from/to + * This method supports writing this node and its children to the * Cockatrice deck XML format. * @{ */ - virtual int readElement(QXmlStreamReader *xml, int limit) = 0; virtual void writeElement(QXmlStreamWriter *xml) = 0; /// @} }; diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp index d082b3cca..86f7f5363 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp @@ -151,25 +151,29 @@ bool InnerDecklistNode::compareName(AbstractDecklistNode *other) const } } +int InnerDecklistNode::readCardElement(QXmlStreamReader *xml, int remainingBudget) +{ + const int amount = qMin(xml->attributes().value("number").toString().toInt(), remainingBudget); + new DecklistCardNode(xml->attributes().value("name").toString(), amount, this, -1, + xml->attributes().value("setShortName").toString(), + xml->attributes().value("collectorNumber").toString(), + xml->attributes().value("uuid").toString()); + return amount; +} + int InnerDecklistNode::readElement(QXmlStreamReader *xml, int limit) { int totalCards = 0; while (!xml->atEnd()) { xml->readNext(); const QString childName = xml->name().toString(); + const int remainingBudget = limit - totalCards; if (xml->isStartElement()) { if (childName == "zone") { auto *newZone = new InnerDecklistNode(xml->attributes().value("name").toString(), this); - totalCards += newZone->readElement(xml, limit - totalCards); + totalCards += newZone->readElement(xml, remainingBudget); } else if (childName == "card") { - int amount = xml->attributes().value("number").toString().toInt(); - amount = qMin(amount, limit - totalCards); - auto *newCard = new DecklistCardNode(xml->attributes().value("name").toString(), amount, this, -1, - xml->attributes().value("setShortName").toString(), - xml->attributes().value("collectorNumber").toString(), - xml->attributes().value("uuid").toString()); - totalCards += amount; - totalCards += newCard->readElement(xml, limit - totalCards); + totalCards += readCardElement(xml, remainingBudget); } } else if (xml->isEndElement() && (childName == "zone")) { return totalCards; @@ -188,31 +192,35 @@ void InnerDecklistNode::writeElement(QXmlStreamWriter *xml) xml->writeEndElement(); // zone } -QVector> InnerDecklistNode::sort(Qt::SortOrder order) +QVector> InnerDecklistNode::indexedSnapshot() const +{ + QVector> snapshot(size()); + for (int i = size() - 1; i >= 0; --i) { + snapshot[i].first = i; + snapshot[i].second = at(i); + } + return snapshot; +} + +QVector> InnerDecklistNode::applySortedOrder(const QVector> &sorted) { QVector> result(size()); - - // Initialize temporary list with contents of current list - QVector> tempList(size()); for (int i = size() - 1; i >= 0; --i) { - tempList[i].first = i; - tempList[i].second = at(i); + result[i].first = sorted[i].first; + result[i].second = i; + replace(i, sorted[i].second); } + return result; +} + +QVector> InnerDecklistNode::sort(Qt::SortOrder order) +{ + auto snapshot = indexedSnapshot(); - // Sort temporary list auto cmp = [order](const auto &a, const auto &b) { return (order == Qt::AscendingOrder) ? (b.second->compare(a.second)) : (a.second->compare(b.second)); }; + std::sort(snapshot.begin(), snapshot.end(), cmp); - std::sort(tempList.begin(), tempList.end(), cmp); - - // Map old indexes to new indexes and - // copy temporary list to the current one - for (int i = size() - 1; i >= 0; --i) { - result[i].first = tempList[i].first; - result[i].second = i; - replace(i, tempList[i].second); - } - - return result; + return applySortedOrder(snapshot); } diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h index 0d454c11e..8404d7116 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h @@ -223,19 +223,45 @@ public: */ QVector> sort(Qt::SortOrder order = Qt::AscendingOrder); +private: + /** + * @brief Snapshots the current children as (old index, node) pairs. + */ + QVector> indexedSnapshot() const; + + /** + * @brief Replaces this node's children with @p sorted and maps old indexes to new ones. + * + * @return A list of (old index, new index) pairs for each reordered child. + */ + QVector> applySortedOrder(const QVector> &sorted); + +public: /** * @brief Deserialize this node and its children from XML. * @param xml Reader positioned at this element. * @param limit The maximum amount of cards to read * @return the amount of cards found */ - int readElement(QXmlStreamReader *xml, int limit) override; + int readElement(QXmlStreamReader *xml, int limit); /** * @brief Serialize this node and its children to XML. * @param xml Writer to append elements to. */ void writeElement(QXmlStreamWriter *xml) override; + +private: + /** + * @brief Reads a single `card` element and appends it to this node. + * + * The card's quantity is capped at @p remainingBudget so a malicious or + * oversized deck file cannot push the total card count past the deck size + * limit. + * + * @return The amount of cards actually added. + */ + int readCardElement(QXmlStreamReader *xml, int remainingBudget); }; #endif // COCKATRICE_INNER_DECK_LIST_NODE_H diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h index 3f2cbbe8e..304ab3cb7 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h @@ -1,6 +1,8 @@ #ifndef COCKATRICE_INTERFACE_CARDS_DISPLAY_SETTINGS_PROVIDER_H #define COCKATRICE_INTERFACE_CARDS_DISPLAY_SETTINGS_PROVIDER_H +#include + class ICardsDisplaySettingsProvider { public: @@ -26,6 +28,7 @@ public: [[nodiscard]] virtual int getEDHRecCardSize() const = 0; [[nodiscard]] virtual int getArchidektPreviewSize() const = 0; [[nodiscard]] virtual int getSampleHandSize() const = 0; + [[nodiscard]] virtual QString getCardLang() const = 0; }; #endif // COCKATRICE_INTERFACE_CARDS_DISPLAY_SETTINGS_PROVIDER_H diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp index 76afca0c4..a5628a844 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp @@ -28,6 +28,16 @@ DeckListModel::~DeckListModel() delete root; } +void DeckListModel::setDisplayLanguage(const QString &lang) +{ + if (displayLang == lang) { + return; + } + displayLang = lang; + emit layoutAboutToBeChanged(); + emit layoutChanged(); +} + /** * @brief Extract the value from the card that is used for the group criteria. * @param info Pointer to card information. @@ -181,8 +191,15 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const switch (index.column()) { case DeckListModelColumns::CARD_AMOUNT: return card->getNumber(); - case DeckListModelColumns::CARD_NAME: + case DeckListModelColumns::CARD_NAME: { + if (role == Qt::DisplayRole) { + CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(card->getName()); + if (info) { + return info->getLocalizedName(displayLang); + } + } return card->getName(); + } case DeckListModelColumns::CARD_SET: return card->getCardSetShortName(); case DeckListModelColumns::CARD_COLLECTOR_NUMBER: diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h index 09600ca67..ce6d7f8cb 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h @@ -287,6 +287,16 @@ public: explicit DeckListModel(QObject *parent, const QSharedPointer &deckList); ~DeckListModel() override; + /** + * @brief Selects the language code for localized card names in the display role. + * + * The model never reads global settings itself; callers wire this to the card + * language setting (including reacting to its changes) rather than the model + * querying it. + * @param lang Language code; "en" shows the canonical English names. + */ + void setDisplayLanguage(const QString &lang); + /** * @brief Returns the root index of the model. * @return QModelIndex representing the root node. @@ -408,6 +418,7 @@ private: DeckListModelGroupCriteria::Type activeGroupCriteria = DeckListModelGroupCriteria::MAIN_TYPE; int lastKnownColumn; /**< Last column used for sorting. */ Qt::SortOrder lastKnownOrder; /**< Last known sort order. */ + QString displayLang = "en"; /**< Language code for localized card names in the display role. */ InnerDecklistNode *createNodeIfNeeded(const QString &name, InnerDecklistNode *parent); QModelIndex nodeToIndex(AbstractDecklistNode *node) const; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp index 799b1e7ee..131ff1077 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp @@ -465,7 +465,7 @@ Response::ResponseCode Server_Game::checkJoin(ServerInfo_User *user, if (asJudge && !(user->user_level() & ServerInfo_User::IsJudge)) { return Response::RespUserLevelTooLow; } - if (!(overrideRestrictions && (user->user_level() & ServerInfo_User::IsModerator))) { + if (!(overrideRestrictions && (user->user_level() & (ServerInfo_User::IsModerator | ServerInfo_User::IsJudge)))) { if ((_password != password) && !(spectator && !spectatorsNeedPassword)) { return Response::RespWrongPassword; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index 0791f8c14..bc4814d5e 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -15,9 +15,17 @@ set(PROTO_FILES command_deck_del.proto command_deck_del_dir.proto command_deck_download.proto + command_deck_download_public.proto command_deck_list.proto + command_deck_list_other_user.proto command_deck_new_dir.proto command_deck_select.proto + command_deck_set_visibility.proto + command_deck_share_create.proto + command_deck_share_download.proto + command_deck_share_list.proto + command_deck_share_list_mine.proto + command_deck_share_remove.proto command_deck_upload.proto command_del_counter.proto command_delete_arrow.proto @@ -137,6 +145,10 @@ set(PROTO_FILES response_card_art_rule_entry.proto response_deck_download.proto response_deck_list.proto + response_deck_share_create.proto + response_deck_share_download.proto + response_deck_share_list.proto + response_deck_share_list_mine.proto response_deck_upload.proto response_dump_zone.proto response_forgotpasswordrequest.proto @@ -175,6 +187,8 @@ set(PROTO_FILES serverinfo_cardcounter.proto serverinfo_chat_message.proto serverinfo_counter.proto + serverinfo_deck_share_item.proto + serverinfo_deck_share_summary.proto serverinfo_deckstorage.proto serverinfo_game.proto serverinfo_gametype.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_download_public.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_download_public.proto new file mode 100644 index 000000000..a5592ef51 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_download_public.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_DeckDownloadPublic { + extend SessionCommand { + optional Command_DeckDownloadPublic ext = 1031; + } + optional uint32 deck_id = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_list_other_user.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_list_other_user.proto new file mode 100644 index 000000000..2459608e2 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_list_other_user.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_DeckListOtherUser { + extend SessionCommand { + optional Command_DeckListOtherUser ext = 1029; + } + optional string user_name = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_set_visibility.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_set_visibility.proto new file mode 100644 index 000000000..3ada87973 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_set_visibility.proto @@ -0,0 +1,13 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_DeckSetVisibility { + extend SessionCommand { + optional Command_DeckSetVisibility ext = 1030; + } + // Set the public visibility of a single deck (mutually exclusive with folder_path). + optional uint32 deck_id = 1; + // Set the public visibility of a folder (all decks under it inherit). + optional string folder_path = 2; + optional bool is_public = 3; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_create.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_create.proto new file mode 100644 index 000000000..9cebf8eae --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_create.proto @@ -0,0 +1,24 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message DeckShareItem { + // Reference an existing deck in the sharer's personal deck storage. + // Mutually exclusive with deck_list. + optional uint32 deck_id = 1; + // Inline deck content in the native format. + // Mutually exclusive with deck_id. + optional string deck_list = 2; + // Color identity of the deck (e.g. "WUBRG"), computed by the sharing client. + optional string color_identity = 3; +} + +message Command_DeckShareCreate { + extend SessionCommand { + optional Command_DeckShareCreate ext = 1026; + } + optional string name = 1; + repeated DeckShareItem items = 2; + // Path of a folder in the sharer's personal deck storage. When set, all + // decks in that folder are shared (resolved by the server). + optional string folder_path = 3; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_download.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_download.proto new file mode 100644 index 000000000..251a662b3 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_download.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_DeckShareDownload { + extend SessionCommand { + optional Command_DeckShareDownload ext = 1028; + } + optional string token = 1; + optional uint32 item_id = 2; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_list.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_list.proto new file mode 100644 index 000000000..b75fd65f9 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_list.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_DeckShareList { + extend SessionCommand { + optional Command_DeckShareList ext = 1027; + } + optional string token = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_list_mine.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_list_mine.proto new file mode 100644 index 000000000..75bc30714 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_list_mine.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "session_commands.proto"; + +// Requests the list of share bundles created by the calling user, so they can +// be reviewed and revoked before they expire. +message Command_DeckShareListMine { + extend SessionCommand { + optional Command_DeckShareListMine ext = 1032; + } +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_remove.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_remove.proto new file mode 100644 index 000000000..348996da9 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_remove.proto @@ -0,0 +1,11 @@ +syntax = "proto2"; +import "session_commands.proto"; + +// Revokes one of the calling user's own share bundles. The referenced items +// are removed by cascade. +message Command_DeckShareRemove { + extend SessionCommand { + optional Command_DeckShareRemove ext = 1033; + } + optional uint32 share_id = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_upload.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_upload.proto index 63d9c80ef..1a5d44e9e 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_upload.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_upload.proto @@ -8,4 +8,10 @@ message Command_DeckUpload { optional string path = 1; // to upload a new deck optional uint32 deck_id = 2; // to replace an existing deck optional string deck_list = 3; + optional bool is_public = 4; // mark the deck public on upload (publish) + // The server derives the banner card and tags from deck_list, so clients only + // need to send the color identity, which cannot be computed server-side. + reserved 5, 6, 8; + reserved "banner_card_name", "banner_card_provider", "tags"; + optional string color_identity = 7; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto index 42a42fcc0..caf9febde 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto @@ -81,6 +81,10 @@ message Response { REPLAY_LIST = 1100; // Response listing replays REPLAY_DOWNLOAD = 1101; // Response for replay download REPLAY_GET_CODE = 1102; // Response containing replay code + DECK_SHARE_CREATE = 1103; // Response to deck share creation + DECK_SHARE_LIST = 1104; // Response listing shared decks + DECK_SHARE_DOWNLOAD = 1105; // Response for shared deck download + DECK_SHARE_LIST_MINE = 1106; // Response listing the caller's own shares CARD_ART_RULE_LIST = 1200; // Response containing a list of card art rules } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_create.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_create.proto new file mode 100644 index 000000000..574c02eb6 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_create.proto @@ -0,0 +1,11 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_DeckShareCreate { + extend Response { + optional Response_DeckShareCreate ext = 1103; + } + optional string token = 1; + optional uint64 expires_at = 2; + optional uint32 item_count = 3; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_download.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_download.proto new file mode 100644 index 000000000..def0ccbe6 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_download.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_DeckShareDownload { + extend Response { + optional Response_DeckShareDownload ext = 1105; + } + optional string deck = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_list.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_list.proto new file mode 100644 index 000000000..3edffa8ac --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_list.proto @@ -0,0 +1,12 @@ +syntax = "proto2"; +import "response.proto"; +import "serverinfo_deck_share_item.proto"; + +message Response_DeckShareList { + extend Response { + optional Response_DeckShareList ext = 1104; + } + optional string name = 1; + optional uint64 expires_at = 2; + repeated ServerInfo_DeckShareItem items = 3; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_list_mine.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_list_mine.proto new file mode 100644 index 000000000..e3cbf83b9 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_list_mine.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "response.proto"; +import "serverinfo_deck_share_summary.proto"; + +message Response_DeckShareListMine { + extend Response { + optional Response_DeckShareListMine ext = 1106; + } + repeated ServerInfo_DeckShareSummary shares = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deck_share_item.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deck_share_item.proto new file mode 100644 index 000000000..bac025419 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deck_share_item.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; + +message ServerInfo_DeckShareItem { + optional uint32 id = 1; + optional string name = 2; + repeated string tags = 3; + optional string banner_card = 4; + optional string game_format = 5; + optional string color_identity = 6; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deck_share_summary.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deck_share_summary.proto new file mode 100644 index 000000000..43273a54f --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deck_share_summary.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; + +// A share bundle created by a user, as reported by a "list my shares" query. +message ServerInfo_DeckShareSummary { + optional uint32 id = 1; + optional string name = 2; + optional uint64 creation_time = 3; + optional uint64 expires_at = 4; + optional uint32 item_count = 5; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deckstorage.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deckstorage.proto index 16e3f28e3..b04d676d8 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deckstorage.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deckstorage.proto @@ -1,10 +1,21 @@ syntax = "proto2"; message ServerInfo_DeckStorage_File { optional uint32 creation_time = 1; + optional bool is_public = 2; + // Preview metadata computed by the uploading client, so other clients can + // render this deck (e.g. in a visual storage grid) without downloading the + // full deck list. Empty for decks uploaded before the metadata columns. + optional string banner_card_name = 3; + optional string banner_card_provider = 4; + optional string color_identity = 5; + // Tag names associated with the deck. Empty for decks uploaded before the + // tags column existed. + repeated string tags = 6; } message ServerInfo_DeckStorage_Folder { repeated ServerInfo_DeckStorage_TreeItem items = 1; + optional bool is_public = 2; } message ServerInfo_DeckStorage_TreeItem { diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto index fee8c36a8..4b7fe9c85 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto @@ -28,6 +28,14 @@ message SessionCommand { FORGOT_PASSWORD_CHALLENGE = 1023; REQUEST_PASSWORD_SALT = 1024; SET_CARD_ART_PARAMS = 1025; + DECK_SHARE_CREATE = 1026; + DECK_SHARE_LIST = 1027; + DECK_SHARE_DOWNLOAD = 1028; + DECK_LIST_OTHER_USER = 1029; + DECK_SET_VISIBILITY = 1030; + DECK_DOWNLOAD_PUBLIC = 1031; + DECK_SHARE_LIST_MINE = 1032; + DECK_SHARE_REMOVE = 1033; REPLAY_LIST = 1100; REPLAY_DOWNLOAD = 1101; REPLAY_MODIFY_MATCH = 1102; diff --git a/libcockatrice_settings/libcockatrice/settings/appearance_settings.cpp b/libcockatrice_settings/libcockatrice/settings/appearance_settings.cpp index 2f19d6224..839e33be4 100644 --- a/libcockatrice_settings/libcockatrice/settings/appearance_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/appearance_settings.cpp @@ -70,6 +70,17 @@ void AppearanceSettings::setHomeTabDisplayCardName(bool _displayCardName) emit homeTabDisplayCardNameChanged(); } +bool AppearanceSettings::getHomeTabBackgroundDim() const +{ + return getValue("homeTabBackgroundDim", QString(), QString(), true).toBool(); +} + +void AppearanceSettings::setHomeTabBackgroundDim(bool _dimBackground) +{ + setValue(_dimBackground, "homeTabBackgroundDim"); + emit homeTabBackgroundDimChanged(); +} + int AppearanceSettings::getHomeTabButtonColorSourceIndex() const { return getValue("homeTabButtonColorSource", "", "", 0).toInt(); diff --git a/libcockatrice_settings/libcockatrice/settings/appearance_settings.h b/libcockatrice_settings/libcockatrice/settings/appearance_settings.h index 3a63f0df0..a4504798e 100644 --- a/libcockatrice_settings/libcockatrice/settings/appearance_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/appearance_settings.h @@ -27,6 +27,8 @@ public: void setHomeTabBackgroundShuffleFrequency(int _frequency); [[nodiscard]] bool getHomeTabDisplayCardName() const; void setHomeTabDisplayCardName(bool _displayCardName); + [[nodiscard]] bool getHomeTabBackgroundDim() const; + void setHomeTabBackgroundDim(bool _dimBackground); [[nodiscard]] int getHomeTabButtonColorSourceIndex() const; void setHomeTabButtonColorSourceIndex(int index); @@ -36,6 +38,7 @@ signals: void homeTabBackgroundSourceChanged(); void homeTabBackgroundShuffleFrequencyChanged(); void homeTabDisplayCardNameChanged(); + void homeTabBackgroundDimChanged(); void homeTabButtonColorChanged(); public: diff --git a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp index f528a7c4b..b8eafca6c 100644 --- a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp @@ -105,6 +105,11 @@ int CardsDisplaySettings::getSampleHandSize() const return getValue("sampleHandSize", "cards", "cardSize", 7).toInt(); } +QString CardsDisplaySettings::getCardLang() const +{ + return getValue("cardLang", QString(), QString(), "en").toString(); +} + void CardsDisplaySettings::setDisplayCardNames(bool _displayCardNames) { setValue(_displayCardNames, "displayCardNames"); @@ -224,3 +229,16 @@ void CardsDisplaySettings::setSampleHandSize(int _sampleHandSize) setValue(_sampleHandSize, "sampleHandSize", "cards", "cardSize"); emit sampleHandSizeChanged(_sampleHandSize); } + +void CardsDisplaySettings::setCardLang(const QString &_cardLang) +{ + if (_cardLang == getCardLang()) { + return; + } + setValue(_cardLang, "cardLang"); + // Flush to disk immediately: the Oracle tool is a separate process that + // reads this value to decide which foreignData to import, so it must not + // observe a stale (pre-change) value. + sync(); + emit cardLangChanged(_cardLang); +} diff --git a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h index dbafa32ae..0b47ce490 100644 --- a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h @@ -31,6 +31,7 @@ public: [[nodiscard]] int getEDHRecCardSize() const override; [[nodiscard]] int getArchidektPreviewSize() const override; [[nodiscard]] int getSampleHandSize() const override; + [[nodiscard]] QString getCardLang() const override; void setDisplayCardNames(bool _displayCardNames); void setRoundCardCorners(bool _roundCardCorners); @@ -52,6 +53,7 @@ public: void setEDHRecCardSize(int _edhrecCardSize); void setArchidektPreviewCardSize(int _archidektPreviewCardSize); void setSampleHandSize(int _sampleHandSize); + void setCardLang(const QString &_cardLang); signals: void displayCardNamesChanged(); @@ -68,6 +70,7 @@ signals: void edhRecCardSizeChanged(); void archidektPreviewSizeChanged(); void sampleHandSizeChanged(int amount); + void cardLangChanged(const QString &lang); public: explicit CardsDisplaySettings(const QString &settingPath, QObject *parent = nullptr); diff --git a/libcockatrice_settings/libcockatrice/settings/download_settings.cpp b/libcockatrice_settings/libcockatrice/settings/download_settings.cpp index cfa1c054e..eb73e58ee 100644 --- a/libcockatrice_settings/libcockatrice/settings/download_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/download_settings.cpp @@ -4,11 +4,14 @@ const QStringList DownloadSettings::DEFAULT_DOWNLOAD_URLS = { "https://cards.scryfall.io/large/!prop:side!/!set:uuid_substr_0_1!/!set:uuid_substr_1_1!/!set:uuid!.jpg", - "https://api.scryfall.com/cards/!set:uuid!?format=image&face=!prop:side!", - "https://api.scryfall.com/cards/multiverse/!set:muid!?format=image", + "https://api.scryfall.com/cards/!set:uuid!?format=image&face=!prop:side!&lang=!sflang!", + "https://api.scryfall.com/cards/multiverse/!set:muid!?format=image&lang=!sflang!", "https://gatherer.wizards.com/Handlers/Image.ashx?multiverseid=!set:muid!&type=card", "https://gatherer.wizards.com/Handlers/Image.ashx?name=!name!&type=card"}; +const QString DownloadSettings::SCRYFALL_NAMED_LOCALIZED_URL = + "https://api.scryfall.com/cards/named?fuzzy=!localizedName!&lang=!sflang!&format=image&face=!prop:side!"; + DownloadSettings::DownloadSettings(const QString &settingPath, QObject *parent = nullptr) : SettingsManager(settingPath + "downloads.ini", "downloads", QString(), parent) { @@ -29,6 +32,18 @@ void DownloadSettings::resetToDefaultURLs() setValue(QVariant::fromValue(DEFAULT_DOWNLOAD_URLS), "urls"); } +bool DownloadSettings::addLocalizedScryfallUrl() +{ + const QStringList urls = getAllURLs(); + if (urls.contains(SCRYFALL_NAMED_LOCALIZED_URL)) { + return false; + } + QStringList updated = urls; + updated.prepend(SCRYFALL_NAMED_LOCALIZED_URL); + setDownloadUrls(updated); + return true; +} + bool DownloadSettings::getPicDownload() const { return getValue("pictureDownload", QString(), QString(), true).toBool(); diff --git a/libcockatrice_settings/libcockatrice/settings/download_settings.h b/libcockatrice_settings/libcockatrice/settings/download_settings.h index a3a6f4ca9..ae49884f0 100644 --- a/libcockatrice_settings/libcockatrice/settings/download_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/download_settings.h @@ -15,6 +15,7 @@ class DownloadSettings : public SettingsManager friend class SettingsCache; static const QStringList DEFAULT_DOWNLOAD_URLS; + static const QString SCRYFALL_NAMED_LOCALIZED_URL; public: explicit DownloadSettings(const QString &, QObject *); @@ -22,6 +23,7 @@ public: QStringList getAllURLs() const; void setDownloadUrls(const QStringList &downloadURLs); void resetToDefaultURLs(); + [[nodiscard]] bool addLocalizedScryfallUrl(); [[nodiscard]] bool getPicDownload() const; void setPicDownload(bool _picDownload); [[nodiscard]] bool getDownloadSpoilersStatus() const; diff --git a/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp b/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp index 811b0c842..436e260c5 100644 --- a/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp @@ -171,7 +171,12 @@ void ServersSettings::addNewServer(const QString &saveName, bool savePassword, const QString &site) { - if (updateExistingServer(saveName, serv, port, username, password, savePassword, site)) { + // Match the exact host-plus-port server the caller is adding, so a link or + // public-server list entry cannot clobber the port (and credentials) of an + // unrelated entry that happens to share the same hostname. + const int existingIndex = findServerIndex(serv, port); + if (existingIndex >= 0) { + updateServerFields(existingIndex, saveName, username, password, savePassword, site); return; } @@ -271,22 +276,7 @@ bool ServersSettings::updateExistingServer(QString saveName, for (int i = 0; i <= size; ++i) { if (serv == getValue(QString("server%1").arg(i), "server", "server_details").toString()) { setValue(port, QString("port%1").arg(i), "server", "server_details"); - if (!username.isEmpty()) { - setValue(username, QString("username%1").arg(i), "server", "server_details"); - } - - if (savePassword && !password.isEmpty()) { - setValue(password, QString("password%1").arg(i), "server", "server_details"); - } else { - setValue(QString(), QString("password%1").arg(i), "server", "server_details"); - } - - if (!site.isEmpty()) { - setValue(site, QString("site%1").arg(i), "server", "server_details"); - } - - setValue(savePassword, QString("savePassword%1").arg(i), "server", "server_details"); - setValue(saveName, QString("saveName%1").arg(i), "server", "server_details"); + updateServerFields(i, saveName, username, password, savePassword, site); return true; } @@ -294,6 +284,31 @@ bool ServersSettings::updateExistingServer(QString saveName, return false; } +void ServersSettings::updateServerFields(int index, + const QString &saveName, + const QString &username, + const QString &password, + bool savePassword, + const QString &site) +{ + if (!username.isEmpty()) { + setValue(username, QString("username%1").arg(index), "server", "server_details"); + } + + if (savePassword && !password.isEmpty()) { + setValue(password, QString("password%1").arg(index), "server", "server_details"); + } else { + setValue(QString(), QString("password%1").arg(index), "server", "server_details"); + } + + if (!site.isEmpty()) { + setValue(site, QString("site%1").arg(index), "server", "server_details"); + } + + setValue(savePassword, QString("savePassword%1").arg(index), "server", "server_details"); + setValue(saveName, QString("saveName%1").arg(index), "server", "server_details"); +} + int ServersSettings::findServerIndex(const QString &host, const QString &port) const { int size = getValue("totalServers", "server", "server_details").toInt(); @@ -310,6 +325,21 @@ int ServersSettings::findServerIndex(const QString &host, const QString &port) c return -1; } +int ServersSettings::findHostIndex(const QString &host) const +{ + int size = getValue("totalServers", "server", "server_details").toInt(); + + for (int i = 0; i <= size; ++i) { + QString storedHost = getValue(QString("server%1").arg(i), "server", "server_details").toString(); + + if (storedHost.compare(host, Qt::CaseInsensitive) == 0) { + return i; + } + } + + return -1; +} + bool ServersSettings::hasUsername(const QString &host, const QString &port) const { int index = findServerIndex(host, port); diff --git a/libcockatrice_settings/libcockatrice/settings/servers_settings.h b/libcockatrice_settings/libcockatrice/settings/servers_settings.h index f9803a158..93651c813 100644 --- a/libcockatrice_settings/libcockatrice/settings/servers_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/servers_settings.h @@ -61,7 +61,14 @@ public: QString password, bool savePassword, QString site = QString()); + void updateServerFields(int index, + const QString &saveName, + const QString &username, + const QString &password, + bool savePassword, + const QString &site); int findServerIndex(const QString &host, const QString &port) const; + int findHostIndex(const QString &host) const; bool hasUsername(const QString &host, const QString &port) const; bool hasCredentials(const QString &host, const QString &port) const; bool hasLoginData(const QString &host, const QString &port) const; diff --git a/libcockatrice_settings/libcockatrice/settings/settings_migration.cpp b/libcockatrice_settings/libcockatrice/settings/settings_migration.cpp index 37dc9a0a0..f6e107dad 100644 --- a/libcockatrice_settings/libcockatrice/settings/settings_migration.cpp +++ b/libcockatrice_settings/libcockatrice/settings/settings_migration.cpp @@ -374,7 +374,11 @@ static void migrateAppearanceSettings(const QString &settingsPath, QSettings &gl QSettings appearanceIni(settingsPath + "appearance.ini", QSettings::IniFormat); for (auto it = appearanceKeyMap.constBegin(); it != appearanceKeyMap.constEnd(); ++it) { if (globalIni.contains(it.key())) { - appearanceIni.setValue(it.value(), globalIni.value(it.key())); + QVariant value = globalIni.value(it.key()); + if (it.key() == "theme/name" && value.toString() == "Default") { + value = "System"; + } + appearanceIni.setValue(it.value(), value); } } } diff --git a/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.cpp b/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.cpp index 1b21af58e..3320598d8 100644 --- a/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.cpp @@ -130,6 +130,11 @@ bool VisualDeckStorageSettings::getVisualDeckStorageShowTagsOnDeckPreviews() con return getValue("showTagsOnDeckPreviews", "interface", "visualDeckStorage", true).toBool(); } +bool VisualDeckStorageSettings::getVisualDeckStorageShowUploadTime() const +{ + return getValue("showUploadTime", "interface", "visualDeckStorage", true).toBool(); +} + bool VisualDeckStorageSettings::getVisualDeckStorageDrawUnusedColorIdentities() const { return getValue("drawUnusedColorIdentities", "interface", "visualDeckStorage", true).toBool(); @@ -220,6 +225,12 @@ void VisualDeckStorageSettings::setVisualDeckStorageShowTagsOnDeckPreviews(bool emit visualDeckStorageShowTagsOnDeckPreviewsChanged(_showTags); } +void VisualDeckStorageSettings::setVisualDeckStorageShowUploadTime(bool value) +{ + setValue(value, "showUploadTime", "interface", "visualDeckStorage"); + emit visualDeckStorageShowUploadTimeChanged(value); +} + void VisualDeckStorageSettings::setVisualDeckStorageDrawUnusedColorIdentities(bool _draw) { setValue(_draw, "drawUnusedColorIdentities", "interface", "visualDeckStorage"); diff --git a/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.h b/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.h index fd2a76663..9bc9d4172 100644 --- a/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.h @@ -20,6 +20,7 @@ public: [[nodiscard]] bool getVisualDeckStorageShowColorIdentity() const override; [[nodiscard]] bool getVisualDeckStorageShowBannerCardComboBox() const override; [[nodiscard]] bool getVisualDeckStorageShowTagsOnDeckPreviews() const override; + [[nodiscard]] bool getVisualDeckStorageShowUploadTime() const; [[nodiscard]] bool getVisualDeckStorageDrawUnusedColorIdentities() const override; [[nodiscard]] int getVisualDeckStorageUnusedColorIdentitiesOpacity() const override; [[nodiscard]] int getVisualDeckStorageTooltipType() const override; @@ -38,6 +39,7 @@ public: void setVisualDeckStorageShowColorIdentity(bool value); void setVisualDeckStorageShowBannerCardComboBox(bool _showBannerCardComboBox); void setVisualDeckStorageShowTagsOnDeckPreviews(bool _showTags); + void setVisualDeckStorageShowUploadTime(bool value); void setVisualDeckStorageDrawUnusedColorIdentities(bool _draw); void setVisualDeckStorageUnusedColorIdentitiesOpacity(int _opacity); void setVisualDeckStorageTooltipType(int value); @@ -54,6 +56,7 @@ signals: void visualDeckStorageShowColorIdentityChanged(bool _visible); void visualDeckStorageShowBannerCardComboBoxChanged(bool _visible); void visualDeckStorageShowTagsOnDeckPreviewsChanged(bool _visible); + void visualDeckStorageShowUploadTimeChanged(bool _visible); void visualDeckStorageDrawUnusedColorIdentitiesChanged(bool _visible); void visualDeckStorageUnusedColorIdentitiesOpacityChanged(bool value); void visualDeckStorageInGameChanged(bool enabled); diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index 392184b6e..a942870b7 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -213,10 +213,19 @@ if(WIN32) list(APPEND libSearchDirs ${QT_LIBRARY_DIR}) install( - DIRECTORY "${CMAKE_BINARY_DIR}/${PROJECT_NAME}/${CMAKE_BUILD_TYPE}/" + DIRECTORY "$/" DESTINATION ./ FILES_MATCHING PATTERN "*.dll" + PATTERN "*.pdb" EXCLUDE + PATTERN "*.dir*" EXCLUDE + PATTERN "*_autogen*" EXCLUDE + PATTERN "*.tlog*" EXCLUDE + PATTERN "CMakeFiles*" EXCLUDE + PATTERN "x64*" EXCLUDE + PATTERN ".qt*" EXCLUDE + PATTERN ".qsb*" EXCLUDE + PATTERN ".lupdate*" EXCLUDE ) # Qt plugins: iconengines, platforms, styles, tls (Qt6) diff --git a/oracle/src/oracleimporter.cpp b/oracle/src/oracleimporter.cpp index fdf11ef02..88b522197 100644 --- a/oracle/src/oracleimporter.cpp +++ b/oracle/src/oracleimporter.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include @@ -22,8 +23,9 @@ static const QList kSingletonCounts = {{1, "legal"}, {0, "banned"} SplitCardPart::SplitCardPart(const QString &_name, const QString &_text, const QHash &_properties, - const PrintingInfo &_printingInfo) - : name(_name), text(_text), properties(_properties), printingInfo(_printingInfo) + const PrintingInfo &_printingInfo, + const QString &_localizedText) + : name(_name), text(_text), localizedText(_localizedText), properties(_properties), printingInfo(_printingInfo) { } @@ -33,6 +35,42 @@ OracleImporter::OracleImporter(QObject *parent) : QObject(parent) { } +void OracleImporter::setCardLang(const QString &lang) +{ + cardLang = lang.trimmed().toLower(); + localizationEnabled = cardLang != "en" && CardLocalization::supportedLanguages().contains(cardLang); +} + +/** + * @brief Maps the MTGJSON foreignData language names to the short codes used by + * Scryfall and stored in cards.xml (e.g. "German" -> "de"). + * @param language The language name found in the MTGJSON foreignData entries. + * @return The short language code, or an empty string if unknown. + */ +static QString mtgjsonLanguageToCode(const QString &language) +{ + static const QHash map = { + {"Chinese Simplified", "zhs"}, + {"Chinese Traditional", "zht"}, + {"English", "en"}, + {"French", "fr"}, + {"German", "de"}, + {"Greek", "grc"}, + {"Ancient Greek", "grc"}, + {"Hebrew", "he"}, + {"Italian", "it"}, + {"Japanese", "ja"}, + {"Korean", "ko"}, + {"Latin", "la"}, + {"Phyrexian", "ph"}, + {"Portuguese (Brazil)", "pt"}, + {"Russian", "ru"}, + {"Sanskrit", "sa"}, + {"Spanish", "es"}, + }; + return map.value(language); +} + static CardSet::Priority getSetPriority(const QString &setType, const QString &shortName) { if (!setTypePriorities.contains(setType.toLower())) { @@ -254,6 +292,101 @@ static QString getJsonString(const QJsonObject &obj, const QString &key) return obj.value(key).toVariant().toString(); } +static QString normalizeCardName(QString name) +{ + // Mirror of the name cleanup applied in addCard(), so collected localization + // keys line up with the card map keys (Æ → AE, curly apostrophe → straight). + name = name.replace("Æ", "AE"); + name = name.replace("’", "'"); + return name; +} + +static QString matchingForeignEntryText(const QJsonObject &card, const QString &cardLang) +{ + // Multi-face cards (split/aftermath/adventure/prepare) expose each face as a + // separate card object, each with its own foreignData entry carrying that + // face's rules text; single-face cards carry the full text in one entry. + const QJsonArray foreignData = card.value("foreignData").toArray(); + for (const QJsonValue &entryValue : foreignData) { + const QJsonObject entry = entryValue.toObject(); + // MTGJSON reports languages by long-form name ("German"); match the + // short code ("de") that Scryfall and cards.xml use. + if (mtgjsonLanguageToCode(getJsonString(entry, "language")) == cardLang) { + return getJsonString(entry, "text"); + } + } + return QString(); +} + +void OracleImporter::collectForeignData(const QString &cardKey, + const CardSetPtr ¤tSet, + const QJsonObject &card, + bool collectText) +{ + if (!localizationEnabled) { + return; + } + + LocalizedCardEntry incoming; + bool found = false; + const QJsonArray foreignData = card.value("foreignData").toArray(); + for (const QJsonValue &entryValue : foreignData) { + const QJsonObject entry = entryValue.toObject(); + // MTGJSON reports languages by long-form name ("German"); match the + // short code ("de") that Scryfall and cards.xml use. + if (mtgjsonLanguageToCode(getJsonString(entry, "language")) != cardLang) { + continue; + } + incoming.name = getJsonString(entry, "name"); + incoming.text = collectText ? getJsonString(entry, "text") : QString(); + found = true; + break; + } + if (!found) { + return; + } + + // Prefer the entry from the highest-priority set (lower enum value = more + // authoritative); printings of equal priority keep the first one seen. + incoming.priority = currentSet->getPriority(); + const auto existing = localizedEntries.constFind(cardKey); + if (existing == localizedEntries.constEnd() || incoming.priority < existing->priority) { + localizedEntries.insert(cardKey, incoming); + } +} + +void OracleImporter::applyLocalizedData() +{ + if (!localizationEnabled) { + return; + } + for (auto it = localizedEntries.constBegin(); it != localizedEntries.constEnd(); ++it) { + CardInfoPtr card = cards.value(it.key()); + if (card.isNull()) { + continue; + } + const LocalizedCardEntry &entry = it.value(); + if (!entry.name.isEmpty()) { + card->setLocalizedName(cardLang, entry.name); + } + if (!entry.text.isEmpty()) { + card->setLocalizedText(cardLang, entry.text); + } + } + localizedEntries.clear(); + + for (auto it = splitLocalizedTexts.constBegin(); it != splitLocalizedTexts.constEnd(); ++it) { + CardInfoPtr card = cards.value(it.key()); + if (card.isNull()) { + continue; + } + if (!it.value().text.isEmpty()) { + card->setLocalizedText(cardLang, it.value().text); + } + } + splitLocalizedTexts.clear(); +} + int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJsonArray &cardsList) { // mtgjson name => xml name @@ -397,13 +530,21 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson // split cards are considered a single card, enqueue for later merging if (layout == "split" || layout == "aftermath" || layout == "adventure" || layout == "prepare") { auto _faceName = getJsonString(card, "faceName"); - SplitCardPart split(_faceName, text, properties, printingInfo); + // MTGJSON exposes each face as a separate card object, each with its + // own foreignData entry holding that face's rules text; collect it so + // the per-face texts can be joined the same way as the English text. + const QString faceLocalizedText = + localizationEnabled ? matchingForeignEntryText(card, cardLang) : QString(); + SplitCardPart split(_faceName, text, properties, printingInfo, faceLocalizedText); auto found_iter = splitCards.find(name + numProperty); if (found_iter == splitCards.end()) { splitCards.insert(name + numProperty, {{split}, name}); } else { found_iter->first.append(split); } + // MTGJSON's foreignData name is the joined name present on every + // face, so collect the name once. + collectForeignData(normalizeCardName(name), currentSet, card, false); } else { // relations QList relatedCards; @@ -446,6 +587,8 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson } } + collectForeignData(normalizeCardName(name + numComponent), currentSet, card); + CardInfoPtr newCard = addCard(name + numComponent, text, isToken, std::move(properties), relatedCards, printingInfo); numCards++; @@ -459,6 +602,8 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson QList, QString>> partsAndNames = splitCards.values(); for (auto [splitCardParts, name] : partsAndNames) { QString text; + QString localizedText; + bool localizedTextComplete = true; QHash properties; PrintingInfo printingInfo; @@ -468,6 +613,21 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson } text.append(tmp.getText()); + // Build the cardLang text by joining each face's translated text with + // the same separator as the English text. Any face missing a complete + // translation abandons the whole join, falling back to the English text. + if (localizedTextComplete) { + const QString partLocalizedText = tmp.getLocalizedText(); + if (partLocalizedText.isEmpty()) { + localizedTextComplete = false; + } else { + if (!localizedText.isEmpty()) { + localizedText.append(splitCardTextSeparator); + } + localizedText.append(partLocalizedText); + } + } + if (properties.isEmpty()) { properties = tmp.getProperties(); printingInfo = tmp.getPrintingInfo(); @@ -500,6 +660,18 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson } } CardInfoPtr newCard = addCard(name, text, isToken, std::move(properties), {}, printingInfo); + if (localizationEnabled && localizedTextComplete && !localizedText.isEmpty()) { + // Same priority policy as collectForeignData(): the joined text from the + // highest-priority set seen so far wins, applied once all printings are in. + LocalizedCardEntry entry; + entry.text = localizedText; + entry.priority = currentSet->getPriority(); + const QString entryKey = normalizeCardName(name); + const auto existing = splitLocalizedTexts.constFind(entryKey); + if (existing == splitLocalizedTexts.constEnd() || entry.priority < existing->priority) { + splitLocalizedTexts.insert(entryKey, entry); + } + } numCards++; } @@ -654,6 +826,8 @@ int OracleImporter::startImport() emit setIndexChanged(numCardsInSet, setIndex, curSetToParse.getLongName()); } + applyLocalizedData(); + emit setIndexChanged(0, setIndex, QString()); // total number of sets @@ -679,4 +853,6 @@ void OracleImporter::clear() cards.clear(); allSets.clear(); rawSetsData.clear(); + localizedEntries.clear(); + splitLocalizedTexts.clear(); } diff --git a/oracle/src/oracleimporter.h b/oracle/src/oracleimporter.h index 748056772..c8d3a19bf 100644 --- a/oracle/src/oracleimporter.h +++ b/oracle/src/oracleimporter.h @@ -107,7 +107,8 @@ public: SplitCardPart(const QString &_name, const QString &_text, const QHash &_properties, - const PrintingInfo &_printingInfo); + const PrintingInfo &_printingInfo, + const QString &_localizedText = QString()); inline const QString &getName() const { return name; @@ -116,6 +117,13 @@ public: { return text; } + /** + * @brief The cardLang rules text of this face's foreignData entry, if any. + */ + inline const QString &getLocalizedText() const + { + return localizedText; + } inline const QHash &getProperties() const { return properties; @@ -128,10 +136,18 @@ public: private: QString name; QString text; + QString localizedText; QHash properties; PrintingInfo printingInfo; }; +struct LocalizedCardEntry +{ + QString name; + QString text; + CardSet::Priority priority = CardSet::PriorityLowest; +}; + class OracleImporter : public QObject { Q_OBJECT @@ -171,12 +187,53 @@ private: */ QAtomicInt importCancelled; + /** + * The ISO-639 language code whose foreignData is imported; "en" by default. + */ + QString cardLang = "en"; + + /** + * Whether cardLang is a supported language other than English, so per-card + * foreignData scanning can be skipped entirely when disabled. + */ + bool localizationEnabled = false; + + /** + * Localized name/text collected per imported card key while parsing sets, + * applied to the CardInfo objects by applyLocalizedData() once all + * printings have been seen so the best-priority one wins. + */ + QMap localizedEntries; + + /** + * cardLang rules text collected for split-card names while parsing sets, + * applied by applyLocalizedData(). Kept apart from localizedEntries because + * MTGJSON emits each split face as its own card object with the joined name + * on every foreignData entry: names and the per-face text join have different + * completeness and must not overwrite each other under the same key. + */ + QMap splitLocalizedTexts; + CardInfoPtr addCard(QString name, const QString &text, bool isToken, QHash properties, const QList &relatedCards, const PrintingInfo &printingInfo); + + /** + * Records the first foreignData entry matching cardLang for the given card + * key, keeping the entry from the highest-priority set seen so far. + * + * Multi-face cards (split, adventure, aftermath, prepare) pass collectText = + * false: MTGJSON emits one foreignData entry per face with the same joined + * name but only that face's text, so the name is collected here while the + * per-face texts are joined during the split-card merge. + */ + void collectForeignData(const QString &cardKey, + const CardSetPtr ¤tSet, + const QJsonObject &card, + bool collectText = true); signals: void setIndexChanged(int cardsImported, int setIndex, const QString &setName); void dataReadProgress(int bytesRead, int totalBytes); @@ -195,6 +252,15 @@ public: { progressReporting = enabled; } + /** + * Selects the ISO-639 language code whose foreignData is imported. + * English (the default) and unsupported codes disable localization. + */ + void setCardLang(const QString &lang); + const QString &getCardLang() const + { + return cardLang; + } /** * Scans the given JSON document for set metadata. Takes the data by value so * the wizard can hand over its decompressed buffer without copying it. @@ -212,6 +278,12 @@ public: { importCancelled.storeRelease(1); } + /** + * Applies the collected localized names/texts to the imported cards. + * Called automatically at the end of startImport(); exposed separately so + * tests can drive it after importing sets directly. + */ + void applyLocalizedData(); bool saveToFile(const QString &fileName, const QString &sourceUrl, const QString &sourceVersion); int importCardsFromSet(const CardSetPtr ¤tSet, const QJsonArray &cardsList); /** diff --git a/oracle/src/oraclewizard.cpp b/oracle/src/oraclewizard.cpp index dd3ac6459..9ce0d7b51 100644 --- a/oracle/src/oraclewizard.cpp +++ b/oracle/src/oraclewizard.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include OracleWizard::OracleWizard(QWidget *parent) : QWizard(parent) @@ -37,6 +38,9 @@ OracleWizard::OracleWizard(QWidget *parent) : QWizard(parent) connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &OracleWizard::updateLanguage); importer = new OracleImporter(this); + // Import card text in the language the client displays, if supported: + // foreignData for any other language is never imported. + importer->setCardLang(SettingsCache::instance().cardsDisplay().getCardLang()); nam = new QNetworkAccessManager(this); diff --git a/oracle/src/pagetemplates.cpp b/oracle/src/pagetemplates.cpp index 4a27fef30..0ffeabbb3 100644 --- a/oracle/src/pagetemplates.cpp +++ b/oracle/src/pagetemplates.cpp @@ -112,7 +112,7 @@ bool SimpleDownloadFilePage::validatePage() return false; } - progressLabel->setText(tr("Downloading (0MB)")); + progressLabel->setText(tr("Downloading (0 MB)")); // show an infinite progressbar progressBar->setMaximum(0); progressBar->setMinimum(0); diff --git a/servatrice/CMakeLists.txt b/servatrice/CMakeLists.txt index 68e422d8c..21f71a908 100644 --- a/servatrice/CMakeLists.txt +++ b/servatrice/CMakeLists.txt @@ -184,10 +184,19 @@ if(WIN32) set(qtconf_dest_dir .) install( - DIRECTORY "${CMAKE_BINARY_DIR}/${PROJECT_NAME}/${CMAKE_BUILD_TYPE}/" + DIRECTORY "$/" DESTINATION ./ FILES_MATCHING PATTERN "*.dll" + PATTERN "*.pdb" EXCLUDE + PATTERN "*.dir*" EXCLUDE + PATTERN "*_autogen*" EXCLUDE + PATTERN "*.tlog*" EXCLUDE + PATTERN "CMakeFiles*" EXCLUDE + PATTERN "x64*" EXCLUDE + PATTERN ".qt*" EXCLUDE + PATTERN ".qsb*" EXCLUDE + PATTERN ".lupdate*" EXCLUDE ) # Qt plugins: platforms, sqldrivers, tls (Qt6) diff --git a/servatrice/migrations/servatrice_0036_to_0037.sql b/servatrice/migrations/servatrice_0036_to_0037.sql new file mode 100644 index 000000000..576ab53b4 --- /dev/null +++ b/servatrice/migrations/servatrice_0036_to_0037.sql @@ -0,0 +1,71 @@ +-- Servatrice db migration from version 36 to version 37 + +-- Deck sharing (temporary share links + permanent public decks). +-- +-- This feature was developed behind several intermediate migrations that have +-- never shipped, so they are folded into this single 36 -> 37 migration: +-- temporary share links, permanent public-deck visibility, preview metadata, +-- and per-deck tags. + +-- 1. Temporary deck shares: a named bundle of decks that can be fetched by +-- anyone who knows the (unguessable) token, until the share expires. +CREATE TABLE IF NOT EXISTS `cockatrice_deck_share` ( + `id` int(7) unsigned zerofill NOT NULL auto_increment, + `token` varchar(64) COLLATE utf8mb4_bin NOT NULL, + `name` varchar(64) NOT NULL, + `created_by` int(7) unsigned NULL, + `created_at` datetime NOT NULL, + `expires_at` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `token` (`token`), + KEY `expires_at` (`expires_at`), + FOREIGN KEY(`created_by`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + +-- Individual decks inside a share bundle. Content is materialized at share +-- time so expiring/deleting a share can cascade cleanly. The metadata columns +-- are nullable because Qt's MySQL driver binds empty QStrings as NULL when +-- using prepared statements. +CREATE TABLE IF NOT EXISTS `cockatrice_deck_share_item` ( + `id` int(7) unsigned zerofill NOT NULL auto_increment, + `share_id` int(7) unsigned zerofill NOT NULL, + `name` varchar(50) NOT NULL, + `tags` text NULL, + `banner_card` varchar(255) NULL, + `game_format` varchar(50) NULL, + `color_identity` varchar(5) NULL, + `content` text NOT NULL, + `position` int(7) NOT NULL, + PRIMARY KEY (`id`), + KEY `share_id` (`share_id`), + FOREIGN KEY(`share_id`) REFERENCES `cockatrice_deck_share`(`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + +-- 2. Permanent deck sharing: add public visibility flags to the deck storage +-- tables. A deck is visible to other users if it is marked public, or if any +-- ancestor folder is marked public (inherited). Existing decks default to +-- private, so the upgrade does not expose any data. +ALTER TABLE `cockatrice_decklist_files` + ADD COLUMN `is_public` tinyint(1) NOT NULL DEFAULT 0 AFTER `content`; + +ALTER TABLE `cockatrice_decklist_folders` + ADD COLUMN `is_public` tinyint(1) NOT NULL DEFAULT 0 AFTER `name`; + +-- 3. Per-deck preview metadata so clients can render another user's public +-- decks (e.g. in a visual deck storage grid) without downloading each deck +-- list. The metadata is derived by the server from the deck content (the color +-- identity is supplied by the uploading client); decks uploaded before this +-- migration have empty values until they are re-uploaded. +ALTER TABLE `cockatrice_decklist_files` + ADD COLUMN `banner_card_name` varchar(255) NULL AFTER `is_public`, + ADD COLUMN `banner_card_provider` varchar(32) NULL AFTER `banner_card_name`, + ADD COLUMN `color_identity` varchar(5) NULL AFTER `banner_card_provider`; + +-- 4. Per-deck tags for public decks. The server renders the deck's own tags +-- into a JSON array, so another user's public decks can filter by tag without +-- downloading each deck list. Decks uploaded before this migration have NULL +-- tags until they are re-uploaded. +ALTER TABLE `cockatrice_decklist_files` + ADD COLUMN `tags` text NULL AFTER `color_identity`; + +UPDATE cockatrice_schema_version SET version=37 WHERE version=36; diff --git a/servatrice/servatrice.ini.example b/servatrice/servatrice.ini.example index 23eca3f1b..ccd4f3c3f 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -452,3 +452,24 @@ ssl_cert=ssl_cert.pem ; Filename of the private key for the server-to-server certificate ssl_key=ssl_key.pem + + +[deck_share] + +; How many days a created deck share link remains valid before it expires. +; Default: 7 +expiry_days=7 + +; How often (in minutes) the server checks for and removes expired deck shares. +; A value of 0 disables the automatic cleanup. +; Default: 60 +cleanup_interval=60 + +; Maximum number of decks a single share link can contain. +; Default: 50 +max_decks_per_share=50 + +; Maximum number of share links a single user may create per day. +; A value of 0 disables the limit. +; Default: 50 +max_shares_per_day=50 diff --git a/servatrice/servatrice.sql b/servatrice/servatrice.sql index cfb1ef5d8..4fbc1d8bd 100644 --- a/servatrice/servatrice.sql +++ b/servatrice/servatrice.sql @@ -20,7 +20,7 @@ CREATE TABLE IF NOT EXISTS `cockatrice_schema_version` ( PRIMARY KEY (`version`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; -INSERT INTO cockatrice_schema_version VALUES(36); +INSERT INTO cockatrice_schema_version VALUES(37); -- users and user data tables CREATE TABLE IF NOT EXISTS `cockatrice_users` ( @@ -66,16 +66,56 @@ CREATE TABLE IF NOT EXISTS `cockatrice_decklist_files` ( `name` varchar(50) NOT NULL, `upload_time` datetime NOT NULL, `content` text NOT NULL, + `is_public` tinyint(1) NOT NULL DEFAULT 0, + `banner_card_name` varchar(255) NULL, + `banner_card_provider` varchar(32) NULL, + `color_identity` varchar(5) NULL, + `tags` text NULL, PRIMARY KEY (`id`), KEY `FolderPlusUser` (`id_folder`,`id_user`), FOREIGN KEY(`id_user`) REFERENCES `cockatrice_users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; +-- Temporary deck shares: a named bundle of decks that can be fetched by +-- anyone who knows the (unguessable) token, until the share expires. +CREATE TABLE IF NOT EXISTS `cockatrice_deck_share` ( + `id` int(7) unsigned zerofill NOT NULL auto_increment, + `token` varchar(64) COLLATE utf8mb4_bin NOT NULL, + `name` varchar(64) NOT NULL, + `created_by` int(7) unsigned NULL, + `created_at` datetime NOT NULL, + `expires_at` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `token` (`token`), + KEY `expires_at` (`expires_at`), + FOREIGN KEY(`created_by`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + +-- Individual decks inside a share bundle. Content is materialized at share +-- time so expiring/deleting a share can cascade cleanly. The metadata columns +-- are nullable because Qt's MySQL driver binds empty QStrings as NULL when +-- using prepared statements. +CREATE TABLE IF NOT EXISTS `cockatrice_deck_share_item` ( + `id` int(7) unsigned zerofill NOT NULL auto_increment, + `share_id` int(7) unsigned zerofill NOT NULL, + `name` varchar(50) NOT NULL, + `tags` text NULL, + `banner_card` varchar(255) NULL, + `game_format` varchar(50) NULL, + `color_identity` varchar(5) NULL, + `content` text NOT NULL, + `position` int(7) NOT NULL, + PRIMARY KEY (`id`), + KEY `share_id` (`share_id`), + FOREIGN KEY(`share_id`) REFERENCES `cockatrice_deck_share`(`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `cockatrice_decklist_folders` ( `id` int(7) unsigned zerofill NOT NULL auto_increment, `id_parent` int(7) unsigned zerofill NOT NULL, `id_user` int(7) unsigned NULL, `name` varchar(30) NOT NULL, + `is_public` tinyint(1) NOT NULL DEFAULT 0, PRIMARY KEY (`id`), KEY `ParentPlusUser` (`id_parent`,`id_user`), FOREIGN KEY(`id_user`) REFERENCES `cockatrice_users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE diff --git a/servatrice/src/deck_tag_serialization.h b/servatrice/src/deck_tag_serialization.h new file mode 100644 index 000000000..162b0604d --- /dev/null +++ b/servatrice/src/deck_tag_serialization.h @@ -0,0 +1,39 @@ +#ifndef DECK_TAG_SERIALIZATION_H +#define DECK_TAG_SERIALIZATION_H + +#include +#include +#include +#include +#include + +/** + * @brief Encodes deck tags as a compact JSON array for storage in a text column. + * + * Deck tags are stored as JSON (rather than a delimited string) so tag names may + * contain any character, and decoded uniformly everywhere they are read. + */ +inline QString serializeDeckTags(const QStringList &tags) +{ + QJsonArray array; + for (const QString &tag : tags) { + array.append(tag); + } + return QString::fromUtf8(QJsonDocument(array).toJson(QJsonDocument::Compact)); +} + +/** @brief Decodes deck tags previously written by serializeDeckTags. */ +inline QStringList deserializeDeckTags(const QString &serialized) +{ + QStringList tags; + if (serialized.isEmpty()) { + return tags; + } + const QJsonArray array = QJsonDocument::fromJson(serialized.toUtf8()).array(); + for (const QJsonValue &tag : array) { + tags.append(tag.toString()); + } + return tags; +} + +#endif // DECK_TAG_SERIALIZATION_H diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp index 26352ccd7..3dd7ab510 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -437,6 +437,14 @@ bool Servatrice::initServer() statusUpdateClock->start(getServerStatusUpdateTime()); } + deckShareCleanupClock = new QTimer(this); + connect(deckShareCleanupClock, SIGNAL(timeout()), this, SLOT(cleanupExpiredDeckShares())); + const int deckShareCleanupInterval = getDeckShareCleanupInterval(); + if (deckShareCleanupInterval > 0) { + qDebug() << "Starting deck share cleanup clock, interval" << deckShareCleanupInterval << "ms"; + deckShareCleanupClock->start(deckShareCleanupInterval); + } + // SOCKET SERVER if (getNumberOfTCPPools() > 0) { gameServer = @@ -655,6 +663,11 @@ void Servatrice::setRequiredFeatures(const QString &featureList) qDebug() << "Set required client features to:" << serverRequiredFeatureList; } +void Servatrice::cleanupExpiredDeckShares() +{ + servatriceDatabaseInterface->cleanupExpiredDeckShares(); +} + void Servatrice::statusUpdate() { if (!servatriceDatabaseInterface->checkSql()) { @@ -1067,6 +1080,27 @@ int Servatrice::getServerStatusUpdateTime() const return settingsCache->value("server/statusupdate", 15000).toInt(); } +int Servatrice::getDeckShareExpiryDays() const +{ + return qMax(1, settingsCache->value("deck_share/expiry_days", 7).toInt()); +} + +int Servatrice::getDeckShareCleanupInterval() const +{ + // default: every 60 minutes + return settingsCache->value("deck_share/cleanup_interval", 60).toInt() * 60000; +} + +int Servatrice::getDeckShareMaxDecksPerShare() const +{ + return settingsCache->value("deck_share/max_decks_per_share", 50).toInt(); +} + +int Servatrice::getDeckShareMaxSharesPerDay() const +{ + return settingsCache->value("deck_share/max_shares_per_day", 50).toInt(); +} + int Servatrice::getNumberOfTCPPools() const { return settingsCache->value("server/number_pools", 1).toInt(); diff --git a/servatrice/src/servatrice.h b/servatrice/src/servatrice.h index 8d964a52b..f39a44d51 100644 --- a/servatrice/src/servatrice.h +++ b/servatrice/src/servatrice.h @@ -146,6 +146,7 @@ public: private slots: void statusUpdate(); void shutdownTimeout(); + void cleanupExpiredDeckShares(); protected: void doSendIslMessage(const IslMessage &msg, int _serverId) override; @@ -159,6 +160,7 @@ private: AuthenticationMethod authenticationMethod; DatabaseType databaseType; QTimer *pingClock, *statusUpdateClock; + QTimer *deckShareCleanupClock; Servatrice_GameServer *gameServer; Servatrice_WebsocketGameServer *websocketGameServer; Servatrice_IslServer *islServer; @@ -276,6 +278,10 @@ public: int getMaxGameInactivityTime() const override; int getMaxPlayerInactivityTime() const override; int getClientKeepAlive() const override; + int getDeckShareExpiryDays() const; + int getDeckShareCleanupInterval() const; + int getDeckShareMaxDecksPerShare() const; + int getDeckShareMaxSharesPerDay() const; int getMaxUsersPerAddress() const; int getMessageCountingInterval() const override; int getMaxMessageCountPerInterval() const override; diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index c4c7046c3..bb67d78eb 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -1,5 +1,6 @@ #include "servatrice_database_interface.h" +#include "deck_tag_serialization.h" #include "servatrice.h" #include "serversocketinterface.h" #include "settingscache.h" @@ -7,11 +8,13 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -99,12 +102,59 @@ bool Servatrice_DatabaseInterface::openDatabase() return false; } + if (sqlDatabase.driverName() != "QMYSQL") { + qCCritical(DatabaseInterfaceLog) + << poolStr + << "Error opening database: connection is not a MySQL/MariaDB database, Servatrice only " + "supports the QMYSQL driver (actual driver:" + << sqlDatabase.driverName() << ")."; + return false; + } + + bool strictModeCheckOk = false; + const bool strictModeEnabled = isStrictModeEnabled(strictModeCheckOk); + if (!strictModeCheckOk) { + qCCritical(DatabaseInterfaceLog) << poolStr + << "Error opening database: unable to determine whether MySQL/MariaDB strict " + "mode is enabled"; + return false; + } + if (strictModeEnabled) { + qCCritical(DatabaseInterfaceLog) << poolStr + << "Error opening database: MySQL/MariaDB strict mode is enabled, which " + "breaks most Servatrice database operations. Please disable strict mode " + "by removing STRICT_TRANS_TABLES and STRICT_ALL_TABLES from sql_mode, " + "for example by adding 'sql_mode=NO_ENGINE_SUBSTITUTION' under [mysqld] " + "in your my.cnf (or my.ini on Windows) and restarting the database " + "server."; + return false; + } + // reset all prepared statements qDeleteAll(preparedStatements); preparedStatements.clear(); return true; } +bool Servatrice_DatabaseInterface::isStrictModeEnabled(bool &ok) const +{ + ok = true; + + QSqlQuery query(sqlDatabase); + if (!query.exec("SELECT @@GLOBAL.sql_mode")) { + ok = false; + return false; + } + + const QStringList modes = query.next() ? query.value(0).toString().split(',') : QStringList(); + for (const QString &mode : modes) { + if (mode.trimmed() == "STRICT_TRANS_TABLES" || mode.trimmed() == "STRICT_ALL_TABLES") { + return true; + } + } + return false; +} + bool Servatrice_DatabaseInterface::checkSql() { if (!sqlDatabase.isValid()) { @@ -1031,6 +1081,183 @@ DeckList *Servatrice_DatabaseInterface::getDeckFromDatabase(int deckId, int user return deck; } +bool Servatrice_DatabaseInterface::createDeckShare(const QString &token, + const QString &name, + int userId, + const QList &items, + int expiryDays, + qint64 &expiresAt) +{ + checkSql(); + + if (items.isEmpty()) { + return false; + } + + if (!sqlDatabase.transaction()) { + return false; + } + + QSqlQuery *query = prepareQuery("insert into {prefix}_deck_share (token, name, created_by, created_at, expires_at) " + "values (:token, :name, :created_by, NOW(), DATE_ADD(NOW(), INTERVAL :days DAY))"); + query->bindValue(":token", token); + query->bindValue(":name", name); + query->bindValue(":created_by", userId < 1 ? QVariant() : userId); + query->bindValue(":days", expiryDays); + if (!execSqlQuery(query)) { + // A failed execSqlQuery has already closed and reopened the connection, + // which implicitly discards the transaction; rollback below is a no-op. + sqlDatabase.rollback(); + return false; + } + + const int shareId = query->lastInsertId().toInt(); + + // Read the expiry back from the database so the value returned to the client + // matches the server clock rather than being approximated client-side. + QSqlQuery *expiryQuery = prepareQuery("select UNIX_TIMESTAMP(expires_at) from {prefix}_deck_share where id = :id"); + expiryQuery->bindValue(":id", shareId); + if (!execSqlQuery(expiryQuery) || !expiryQuery->next()) { + // See the note above: after a failed execSqlQuery the transaction is + // already gone because the connection was torn down. + sqlDatabase.rollback(); + return false; + } + expiresAt = expiryQuery->value(0).toLongLong(); + + for (int i = 0; i < items.size(); ++i) { + const DeckShareItemRecord &item = items.at(i); + QSqlQuery *itemQuery = prepareQuery("insert into {prefix}_deck_share_item (share_id, name, tags, banner_card, " + "game_format, color_identity, content, position) values (:share_id, :name, " + ":tags, :banner_card, :game_format, :color_identity, :content, :position)"); + itemQuery->bindValue(":share_id", shareId); + itemQuery->bindValue(":name", item.name); + itemQuery->bindValue(":tags", serializeDeckTags(item.tags)); + itemQuery->bindValue(":banner_card", item.bannerCard); + itemQuery->bindValue(":game_format", item.gameFormat); + itemQuery->bindValue(":color_identity", item.colorIdentity); + itemQuery->bindValue(":content", item.content); + itemQuery->bindValue(":position", i); + if (!execSqlQuery(itemQuery)) { + // See the note above: the transaction is already gone after the + // reconnect performed by a failed execSqlQuery. + sqlDatabase.rollback(); + return false; + } + } + + if (!sqlDatabase.commit()) { + sqlDatabase.rollback(); + return false; + } + return true; +} + +bool Servatrice_DatabaseInterface::getDeckShareList(const QString &token, + QString &name, + qint64 &expiresAt, + QList &items) +{ + checkSql(); + + QSqlQuery *query = + prepareQuery("select id, name, UNIX_TIMESTAMP(expires_at) from {prefix}_deck_share where token = " + ":token and expires_at > now()"); + query->bindValue(":token", token); + execSqlQuery(query); + if (!query->next()) { + return false; + } + + const int shareId = query->value(0).toInt(); + name = query->value(1).toString(); + expiresAt = query->value(2).toLongLong(); + items.clear(); + + QSqlQuery *itemQuery = + prepareQuery("select id, name, tags, banner_card, game_format, color_identity from {prefix}_deck_share_item " + "where share_id = :share_id order by position"); + itemQuery->bindValue(":share_id", shareId); + execSqlQuery(itemQuery); + while (itemQuery->next()) { + DeckShareItemRecord item; + item.id = itemQuery->value(0).toInt(); + item.name = itemQuery->value(1).toString(); + item.tags = deserializeDeckTags(itemQuery->value(2).toString()); + item.bannerCard = itemQuery->value(3).toString(); + item.gameFormat = itemQuery->value(4).toString(); + item.colorIdentity = itemQuery->value(5).toString(); + items.append(item); + } + + return true; +} + +bool Servatrice_DatabaseInterface::getDeckShareItem(const QString &token, int itemId, QString &content) +{ + checkSql(); + + QSqlQuery *query = prepareQuery("select i.content from {prefix}_deck_share_item i join {prefix}_deck_share s on " + "s.id = i.share_id where s.token = :token and s.expires_at > now() and i.id = :id"); + query->bindValue(":token", token); + query->bindValue(":id", itemId); + execSqlQuery(query); + if (!query->next()) { + return false; + } + + content = query->value(0).toString(); + return true; +} + +void Servatrice_DatabaseInterface::cleanupExpiredDeckShares() +{ + checkSql(); + + QSqlQuery *query = prepareQuery("delete from {prefix}_deck_share where expires_at < now()"); + execSqlQuery(query); +} + +bool Servatrice_DatabaseInterface::getDeckSharesForUser(int userId, QList &shares) +{ + checkSql(); + + QSqlQuery *query = prepareQuery("select s.id, s.name, UNIX_TIMESTAMP(s.created_at), " + "UNIX_TIMESTAMP(s.expires_at), count(i.id) from {prefix}_deck_share s left join " + "{prefix}_deck_share_item i on i.share_id = s.id " + "where s.created_by = :created_by and s.expires_at > now() " + "group by s.id, s.name, s.created_at, s.expires_at order by s.created_at desc"); + query->bindValue(":created_by", userId); + if (!execSqlQuery(query)) { + return false; + } + + shares.clear(); + while (query->next()) { + DeckShareSummaryRecord summary; + summary.id = query->value(0).toInt(); + summary.name = query->value(1).toString(); + summary.creationTime = query->value(2).toLongLong(); + summary.expiresAt = query->value(3).toLongLong(); + summary.itemCount = query->value(4).toInt(); + shares.append(summary); + } + return true; +} + +bool Servatrice_DatabaseInterface::deleteDeckShare(int shareId, int userId) +{ + checkSql(); + + QSqlQuery *query = prepareQuery("delete from {prefix}_deck_share where id = :id and created_by = :created_by"); + query->bindValue(":id", shareId); + query->bindValue(":created_by", userId); + if (!execSqlQuery(query)) { + return false; + } + return query->numRowsAffected() > 0; +} + void Servatrice_DatabaseInterface::logMessage(const int senderId, const QString &senderName, const QString &senderIp, diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index a891c7a3d..ce51a2474 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -13,10 +13,32 @@ #include #include -#define DATABASE_SCHEMA_VERSION 36 +#define DATABASE_SCHEMA_VERSION 37 class Servatrice; +/** @brief Metadata of a single deck inside a temporary deck share bundle. */ +struct DeckShareItemRecord +{ + int id = -1; ///< Database id, used for downloads. + QString name; ///< Deck name. + QStringList tags; ///< Deck tags. + QString bannerCard; ///< Banner card name (deck image). + QString gameFormat; ///< Game format the deck was built for. + QString colorIdentity; ///< Color identity, e.g. "WUBRG". + QString content; ///< Deck content (native format); empty in list queries. +}; + +/** @brief Summary of a share bundle owned by a user. */ +struct DeckShareSummaryRecord +{ + int id = -1; ///< Database id, used for revocation. + QString name; ///< Share name. + qint64 creationTime = 0; ///< Unix timestamp at which the share was created. + qint64 expiresAt = 0; ///< Unix timestamp at which the share expires. + int itemCount = 0; ///< Number of decks in the bundle. +}; + class Servatrice_DatabaseInterface : public Server_DatabaseInterface { Q_OBJECT @@ -32,6 +54,7 @@ private: bool checkUserIsIpBanned(const QString &ipAddress, QString &banReason, int &banSecondsRemaining); /** Must be called after checkSql and server is known to be in auth mode. */ bool checkUserIsNameBanned(QString const &userName, QString &banReason, int &banSecondsRemaining); + bool isStrictModeEnabled(bool &ok) const; protected: AuthenticationResult checkUserPassword(Server_ProtocolHandler *handler, @@ -79,6 +102,37 @@ public: const QList &replayList) override; DeckList *getDeckFromDatabase(int deckId, int userId) override; + /** + * @brief Creates a new temporary deck share bundle. + * @param expiresAt Receives the actual expiry read back from the database. + * @return false on failure. + */ + bool createDeckShare(const QString &token, + const QString &name, + int userId, + const QList &items, + int expiryDays, + qint64 &expiresAt); + /** @brief Lists the share bundles created by a user, newest first. */ + bool getDeckSharesForUser(int userId, QList &shares); + /** + * @brief Deletes one of a user's own share bundles (cascades to its items). + * @return false if no such bundle belongs to the user. + */ + bool deleteDeckShare(int shareId, int userId); + /** + * @brief Looks up a valid (non-expired) share bundle by token. + * @return false if the token is unknown or expired. + */ + bool getDeckShareList(const QString &token, QString &name, qint64 &expiresAt, QList &items); + /** + * @brief Fetches the content of one item of a valid share bundle. + * @return false if the token is unknown/expired or the item does not belong to the bundle. + */ + bool getDeckShareItem(const QString &token, int itemId, QString &content); + /** @brief Deletes all expired share bundles (cascades to their items). */ + void cleanupExpiredDeckShares(); + int getNextGameId() override; int getNextReplayId() override; int getActiveUserCount(QString connectionType = QString()) override; diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index aeffd7081..c82a8dd73 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -20,6 +20,7 @@ #include "serversocketinterface.h" +#include "deck_tag_serialization.h" #include "email_parser.h" #include "main.h" #include "servatrice.h" @@ -35,10 +36,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -47,8 +50,16 @@ #include #include #include +#include #include +#include #include +#include +#include +#include +#include +#include +#include #include #include #include @@ -81,6 +92,10 @@ #include #include #include +#include +#include +#include +#include #include #include #include @@ -277,6 +292,12 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm return cmdRemoveFromList(cmd.GetExtension(Command_RemoveFromList::ext), rc); case SessionCommand::DECK_LIST: return cmdDeckList(cmd.GetExtension(Command_DeckList::ext), rc); + case SessionCommand::DECK_LIST_OTHER_USER: + return cmdDeckListOtherUser(cmd.GetExtension(Command_DeckListOtherUser::ext), rc); + case SessionCommand::DECK_SET_VISIBILITY: + return cmdDeckSetVisibility(cmd.GetExtension(Command_DeckSetVisibility::ext), rc); + case SessionCommand::DECK_DOWNLOAD_PUBLIC: + return cmdDeckDownloadPublic(cmd.GetExtension(Command_DeckDownloadPublic::ext), rc); case SessionCommand::DECK_NEW_DIR: return cmdDeckNewDir(cmd.GetExtension(Command_DeckNewDir::ext), rc); case SessionCommand::DECK_DEL_DIR: @@ -320,6 +341,16 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm return cmdAccountImage(cmd.GetExtension(Command_AccountImage::ext), rc); case SessionCommand::SET_CARD_ART_PARAMS: return cmdSetCardArtParams(cmd.GetExtension(Command_SetCardArtParams::ext), rc); + case SessionCommand::DECK_SHARE_CREATE: + return cmdDeckShareCreate(cmd.GetExtension(Command_DeckShareCreate::ext), rc); + case SessionCommand::DECK_SHARE_LIST: + return cmdDeckShareList(cmd.GetExtension(Command_DeckShareList::ext), rc); + case SessionCommand::DECK_SHARE_LIST_MINE: + return cmdDeckShareListMine(cmd.GetExtension(Command_DeckShareListMine::ext), rc); + case SessionCommand::DECK_SHARE_REMOVE: + return cmdDeckShareRemove(cmd.GetExtension(Command_DeckShareRemove::ext), rc); + case SessionCommand::DECK_SHARE_DOWNLOAD: + return cmdDeckShareDownload(cmd.GetExtension(Command_DeckShareDownload::ext), rc); case SessionCommand::ACCOUNT_PASSWORD: return cmdAccountPassword(cmd.GetExtension(Command_AccountPassword::ext), rc); case SessionCommand::REQUEST_PASSWORD_SALT: @@ -566,46 +597,73 @@ int AbstractServerSocketInterface::getDeckPathId(const QString &path) return getDeckPathId(0, path.split("/")); } -bool AbstractServerSocketInterface::deckListHelper(int folderId, ServerInfo_DeckStorage_Folder *folder) +bool AbstractServerSocketInterface::deckListHelper(int folderId, + ServerInfo_DeckStorage_Folder *folder, + int userId, + bool inheritedPublic, + bool publicOnly) { - QSqlQuery *query = sqlInterface->prepareQuery( - "select id, name from {prefix}_decklist_folders where id_parent = :id_parent and id_user = :id_user"); + QSqlQuery *query = sqlInterface->prepareQuery("select id, name, is_public from {prefix}_decklist_folders where " + "id_parent = :id_parent and id_user = :id_user"); query->bindValue(":id_parent", folderId); - query->bindValue(":id_user", userInfo->id()); + query->bindValue(":id_user", userId); if (!sqlInterface->execSqlQuery(query)) { return false; } - QMap results; + QList>> folderRows; while (query->next()) { - results[query->value(0).toInt()] = query->value(1).toString(); + folderRows.append({query->value(0).toInt(), {query->value(1).toString(), query->value(2).toBool()}}); } + std::sort(folderRows.begin(), folderRows.end(), [](const auto &a, const auto &b) { return a.first < b.first; }); + + for (const auto &[folderIdValue, folderInfo] : folderRows) { + const QString name = folderInfo.first; + const bool ownPublic = folderInfo.second; + const bool effectivePublic = inheritedPublic || ownPublic; - for (int key : results.keys()) { ServerInfo_DeckStorage_TreeItem *newItem = folder->add_items(); - newItem->set_id(key); - newItem->set_name(results.value(key).toStdString()); + newItem->set_id(folderIdValue); + newItem->set_name(name.toStdString()); + newItem->mutable_folder()->set_is_public(ownPublic); - if (!deckListHelper(newItem->id(), newItem->mutable_folder())) { + if (!deckListHelper(newItem->id(), newItem->mutable_folder(), userId, effectivePublic, publicOnly)) { return false; } + + if (publicOnly && !effectivePublic && newItem->mutable_folder()->items_size() == 0) { + folder->mutable_items()->RemoveLast(); + } } - query = sqlInterface->prepareQuery("select id, name, upload_time from {prefix}_decklist_files where id_folder = " - ":id_folder and id_user = :id_user"); + query = sqlInterface->prepareQuery("select id, name, upload_time, is_public, banner_card_name, " + "banner_card_provider, color_identity, tags from {prefix}_decklist_files where " + "id_folder = :id_folder and id_user = :id_user"); query->bindValue(":id_folder", folderId); - query->bindValue(":id_user", userInfo->id()); + query->bindValue(":id_user", userId); if (!sqlInterface->execSqlQuery(query)) { return false; } while (query->next()) { + const bool ownPublic = query->value(3).toBool(); + if (publicOnly && !(inheritedPublic || ownPublic)) { + continue; + } + ServerInfo_DeckStorage_TreeItem *newItem = folder->add_items(); newItem->set_id(query->value(0).toInt()); newItem->set_name(query->value(1).toString().toStdString()); ServerInfo_DeckStorage_File *newFile = newItem->mutable_file(); newFile->set_creation_time(query->value(2).toDateTime().toSecsSinceEpoch()); + newFile->set_is_public(ownPublic); + newFile->set_banner_card_name(query->value(4).toString().toStdString()); + newFile->set_banner_card_provider(query->value(5).toString().toStdString()); + newFile->set_color_identity(query->value(6).toString().toStdString()); + for (const QString &tag : deserializeDeckTags(query->value(7).toString())) { + newFile->add_tags(tag.toStdString()); + } } return true; @@ -626,7 +684,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckList(const Command_ Response_DeckList *re = new Response_DeckList; ServerInfo_DeckStorage_Folder *root = re->mutable_root(); - if (!deckListHelper(0, root)) { + if (!deckListHelper(0, root, userInfo->id(), false, false)) { return Response::RespContextError; } @@ -634,6 +692,160 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckList(const Command_ return Response::RespOk; } +Response::ResponseCode AbstractServerSocketInterface::cmdDeckListOtherUser(const Command_DeckListOtherUser &cmd, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + sqlInterface->checkSql(); + + const QString userName = nameFromStdString(cmd.user_name()); + const int userId = sqlInterface->getUserIdInDB(userName); + if (userId == -1) { + return Response::RespNameNotFound; + } + + Response_DeckList *re = new Response_DeckList; + ServerInfo_DeckStorage_Folder *root = re->mutable_root(); + + if (!deckListHelper(0, root, userId, false, true)) { + return Response::RespContextError; + } + + rc.setResponseExtension(re); + return Response::RespOk; +} + +int AbstractServerSocketInterface::getDeckOwnerId(int deckId) +{ + QSqlQuery *query = sqlInterface->prepareQuery("select id_user from {prefix}_decklist_files where id = :id"); + query->bindValue(":id", deckId); + if (!sqlInterface->execSqlQuery(query)) { + return -1; + } + if (!query->next()) { + return -1; + } + return query->value(0).toInt(); +} + +bool AbstractServerSocketInterface::isDeckEffectivelyPublic(int deckId) +{ + QSqlQuery *query = + sqlInterface->prepareQuery("select is_public, id_folder from {prefix}_decklist_files where id = :id"); + query->bindValue(":id", deckId); + if (!sqlInterface->execSqlQuery(query)) { + return false; + } + if (!query->next()) { + return false; + } + if (query->value(0).toBool()) { + return true; + } + + int folderId = query->value(1).toInt(); + int guard = 0; + while (folderId != 0 && guard < 100) { + QSqlQuery *folderQuery = + sqlInterface->prepareQuery("select is_public, id_parent from {prefix}_decklist_folders where id = :id"); + folderQuery->bindValue(":id", folderId); + if (!sqlInterface->execSqlQuery(folderQuery)) { + return false; + } + if (!folderQuery->next()) { + return false; + } + if (folderQuery->value(0).toBool()) { + return true; + } + folderId = folderQuery->value(1).toInt(); + ++guard; + } + return false; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdDeckSetVisibility(const Command_DeckSetVisibility &cmd, + ResponseContainer & /*rc*/) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + sqlInterface->checkSql(); + + if (cmd.has_deck_id()) { + QSqlQuery *query = + sqlInterface->prepareQuery("select 1 from {prefix}_decklist_files where id = :id and id_user = :id_user"); + query->bindValue(":id", cmd.deck_id()); + query->bindValue(":id_user", userInfo->id()); + sqlInterface->execSqlQuery(query); + if (!query->next()) { + return Response::RespNameNotFound; + } + + query = sqlInterface->prepareQuery("update {prefix}_decklist_files set is_public = :is_public where id = :id " + "and id_user = :id_user"); + query->bindValue(":is_public", cmd.is_public() ? 1 : 0); + query->bindValue(":id", cmd.deck_id()); + query->bindValue(":id_user", userInfo->id()); + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespContextError; + } + } else if (cmd.has_folder_path()) { + const int folderId = getDeckPathId(nameFromStdString(cmd.folder_path())); + if (folderId == -1 || folderId == 0) { + return Response::RespNameNotFound; + } + + QSqlQuery *query = + sqlInterface->prepareQuery("update {prefix}_decklist_folders set is_public = :is_public where id = :id " + "and id_user = :id_user"); + query->bindValue(":is_public", cmd.is_public() ? 1 : 0); + query->bindValue(":id", folderId); + query->bindValue(":id_user", userInfo->id()); + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespContextError; + } + } else { + return Response::RespInvalidData; + } + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdDeckDownloadPublic(const Command_DeckDownloadPublic &cmd, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + sqlInterface->checkSql(); + + const int deckId = cmd.deck_id(); + const int ownerId = getDeckOwnerId(deckId); + if (ownerId == -1 || !isDeckEffectivelyPublic(deckId)) { + return Response::RespNameNotFound; + } + + DeckList *deck; + try { + deck = sqlInterface->getDeckFromDatabase(deckId, ownerId); + } catch (Response::ResponseCode &r) { + return r; + } + + Response_DeckDownload *re = new Response_DeckDownload; + re->set_deck(deck->writeToString_Native().toStdString()); + rc.setResponseExtension(re); + delete deck; + + return Response::RespOk; +} + Response::ResponseCode AbstractServerSocketInterface::cmdDeckNewDir(const Command_DeckNewDir &cmd, ResponseContainer & /*rc*/) { @@ -742,6 +954,22 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckDel(const Command_D return Response::RespOk; } +namespace +{ +/** @brief Keeps only the WUBRG colors from a color identity string, deduplicated. */ +QString sanitizeColorIdentity(const QString &colorIdentity) +{ + QString sanitized; + for (const QChar &color : colorIdentity) { + const QChar upper = color.toUpper(); + if (QStringLiteral("WUBRG").contains(upper) && !sanitized.contains(upper)) { + sanitized.append(upper); + } + } + return sanitized; +} +} // namespace + Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Command_DeckUpload &cmd, ResponseContainer &rc) { @@ -766,6 +994,14 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman deckName = "Unnamed deck"; } + // The server derives the banner card and tags from the deck itself. Only the + // color identity must come from the client, since the server has no card + // database to compute it. All values are bounded to the column sizes. + const QString bannerCardName = deck.getBannerCard().name.left(255); + const QString bannerCardProvider = deck.getBannerCard().providerId.left(32); + const QString tagsJson = serializeDeckTags(deck.getTags()); + const QString colorIdentity = sanitizeColorIdentity(nameFromStdString(cmd.color_identity())); + if (cmd.has_path()) { int folderId = getDeckPathId(nameFromStdString(cmd.path())); if (folderId == -1) { @@ -774,38 +1010,74 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman QSqlQuery *query = sqlInterface->prepareQuery("insert into {prefix}_decklist_files (id_folder, id_user, name, upload_time, " - "content) values(:id_folder, :id_user, :name, NOW(), :content)"); + "content, is_public, banner_card_name, banner_card_provider, color_identity, " + "tags) values(:id_folder, :id_user, :name, NOW(), :content, :is_public, " + ":banner_card_name, :banner_card_provider, :color_identity, :tags)"); query->bindValue(":id_folder", folderId); query->bindValue(":id_user", userInfo->id()); query->bindValue(":name", deckName); query->bindValue(":content", deckStr); - sqlInterface->execSqlQuery(query); + query->bindValue(":is_public", cmd.has_is_public() && cmd.is_public() ? 1 : 0); + query->bindValue(":banner_card_name", bannerCardName); + query->bindValue(":banner_card_provider", bannerCardProvider); + query->bindValue(":color_identity", colorIdentity); + query->bindValue(":tags", tagsJson); + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespContextError; + } Response_DeckUpload *re = new Response_DeckUpload; ServerInfo_DeckStorage_TreeItem *fileInfo = re->mutable_new_file(); fileInfo->set_id(query->lastInsertId().toInt()); fileInfo->set_name(deckName.toStdString()); fileInfo->mutable_file()->set_creation_time(QDateTime::currentDateTime().toSecsSinceEpoch()); + fileInfo->mutable_file()->set_is_public(cmd.has_is_public() && cmd.is_public()); rc.setResponseExtension(re); } else if (cmd.has_deck_id()) { - QSqlQuery *query = - sqlInterface->prepareQuery("update {prefix}_decklist_files set name=:name, upload_time=NOW(), " - "content=:content where id = :id_deck and id_user = :id_user"); + QString updateQuery = "update {prefix}_decklist_files set name=:name, upload_time=NOW(), content=:content, " + "banner_card_name=:banner_card_name, banner_card_provider=:banner_card_provider, " + "color_identity=:color_identity, tags=:tags"; + if (cmd.has_is_public()) { + updateQuery += ", is_public=:is_public"; + } + updateQuery += " where id = :id_deck and id_user = :id_user"; + + QSqlQuery *query = sqlInterface->prepareQuery(updateQuery); query->bindValue(":id_deck", cmd.deck_id()); query->bindValue(":id_user", userInfo->id()); query->bindValue(":name", deckName); query->bindValue(":content", deckStr); - sqlInterface->execSqlQuery(query); + query->bindValue(":banner_card_name", bannerCardName); + query->bindValue(":banner_card_provider", bannerCardProvider); + query->bindValue(":color_identity", colorIdentity); + query->bindValue(":tags", tagsJson); + if (cmd.has_is_public()) { + query->bindValue(":is_public", cmd.is_public() ? 1 : 0); + } + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespContextError; + } if (query->numRowsAffected() == 0) { return Response::RespNameNotFound; } + QSqlQuery *visibilityQuery = + sqlInterface->prepareQuery("select is_public from {prefix}_decklist_files where id = :id and " + "id_user = :id_user"); + visibilityQuery->bindValue(":id", cmd.deck_id()); + visibilityQuery->bindValue(":id_user", userInfo->id()); + if (!sqlInterface->execSqlQuery(visibilityQuery)) { + return Response::RespContextError; + } + const bool isPublic = visibilityQuery->next() && visibilityQuery->value(0).toBool(); + Response_DeckUpload *re = new Response_DeckUpload; ServerInfo_DeckStorage_TreeItem *fileInfo = re->mutable_new_file(); fileInfo->set_id(cmd.deck_id()); fileInfo->set_name(deckName.toStdString()); fileInfo->mutable_file()->set_creation_time(QDateTime::currentDateTime().toSecsSinceEpoch()); + fileInfo->mutable_file()->set_is_public(isPublic); rc.setResponseExtension(re); } else { return Response::RespInvalidData; @@ -836,6 +1108,248 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckDownload(const Comm return Response::RespOk; } +namespace +{ +/** @brief Builds a cryptographically random, URL-safe share token. */ +QString generateShareToken() +{ + QByteArray bytes(32, Qt::Uninitialized); + QRandomGenerator::system()->fillRange(reinterpret_cast(bytes.data()), bytes.size() / sizeof(quint32)); + return QString::fromLatin1(bytes.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals)); +} + +/** @brief Extracts the share metadata for a deck, materializing its content. */ +DeckShareItemRecord makeShareItemFromDeck(const DeckList &deck, const QString &colorIdentity) +{ + DeckShareItemRecord item; + item.name = deck.getName(); + if (item.name.isEmpty()) { + item.name = "Unnamed deck"; + } + item.tags = deck.getTags(); + item.bannerCard = deck.getBannerCard().name; + item.gameFormat = deck.getGameFormat(); + item.colorIdentity = sanitizeColorIdentity(colorIdentity); + item.content = deck.writeToString_Native(); + return item; +} +} // namespace + +Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareCreate(const Command_DeckShareCreate &cmd, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + sqlInterface->checkSql(); + + const int maxItems = servatrice->getDeckShareMaxDecksPerShare(); + if (maxItems > 0 && cmd.items_size() > maxItems) { + return Response::RespInvalidData; + } + + // Per-user rate limit so share links cannot be used to build an unbounded + // word-of-mouth leak of public decks. + const int maxSharesPerDay = servatrice->getDeckShareMaxSharesPerDay(); + if (maxSharesPerDay > 0) { + QSqlQuery *countQuery = sqlInterface->prepareQuery("select count(*) from {prefix}_deck_share where " + "created_by = :created_by and created_at >= " + "DATE_SUB(NOW(), INTERVAL 1 DAY)"); + countQuery->bindValue(":created_by", userInfo->id()); + if (!sqlInterface->execSqlQuery(countQuery) || !countQuery->next()) { + return Response::RespContextError; + } + if (countQuery->value(0).toInt() >= maxSharesPerDay) { + return Response::RespTooManyRequests; + } + } + + QList items; + if (cmd.items_size() > 0) { + for (const DeckShareItem &shareItem : cmd.items()) { + if (shareItem.has_deck_list()) { + DeckList deck; + if (!deck.loadFromString_Native(fileFromStdString(shareItem.deck_list()))) { + return Response::RespContextError; + } + items.append(makeShareItemFromDeck(deck, nameFromStdString(shareItem.color_identity()))); + } else if (shareItem.has_deck_id()) { + DeckList *deck; + try { + deck = sqlInterface->getDeckFromDatabase(shareItem.deck_id(), userInfo->id()); + } catch (Response::ResponseCode &r) { + return r; + } + items.append(makeShareItemFromDeck(*deck, nameFromStdString(shareItem.color_identity()))); + delete deck; + } else { + return Response::RespInvalidData; + } + } + } else if (cmd.has_folder_path()) { + const int folderId = getDeckPathId(nameFromStdString(cmd.folder_path())); + if (folderId == -1) { + return Response::RespNameNotFound; + } + + // Drain the deck list before resolving each deck: getDeckFromDatabase + // issues its own query on the same cached statement set. + QSqlQuery *query = + sqlInterface->prepareQuery("select id, color_identity from {prefix}_decklist_files where id_folder = " + ":id_folder and id_user = :id_user"); + query->bindValue(":id_folder", folderId); + query->bindValue(":id_user", userInfo->id()); + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespContextError; + } + QList> deckRows; + while (query->next()) { + deckRows.append({query->value(0).toInt(), query->value(1).toString()}); + } + for (const auto &[deckId, colorIdentity] : deckRows) { + DeckList *deck; + try { + deck = sqlInterface->getDeckFromDatabase(deckId, userInfo->id()); + } catch (Response::ResponseCode &r) { + return r; + } + items.append(makeShareItemFromDeck(*deck, colorIdentity)); + delete deck; + } + } else { + return Response::RespInvalidData; + } + + if (items.isEmpty() || (maxItems > 0 && items.size() > maxItems)) { + return Response::RespInvalidData; + } + + QString shareName = nameFromStdString(cmd.name()); + if (shareName.isEmpty()) { + shareName = "Shared decks"; + } + + const QString token = generateShareToken(); + qint64 expiresAt = 0; + if (!sqlInterface->createDeckShare(token, shareName, userInfo->id(), items, servatrice->getDeckShareExpiryDays(), + expiresAt)) { + return Response::RespInvalidData; + } + + Response_DeckShareCreate *re = new Response_DeckShareCreate; + re->set_token(token.toStdString()); + re->set_expires_at(expiresAt); + re->set_item_count(items.size()); + rc.setResponseExtension(re); + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareListMine(const Command_DeckShareListMine & /*cmd*/, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + sqlInterface->checkSql(); + + QList shares; + if (!sqlInterface->getDeckSharesForUser(userInfo->id(), shares)) { + return Response::RespContextError; + } + + Response_DeckShareListMine *re = new Response_DeckShareListMine; + for (const DeckShareSummaryRecord &share : shares) { + ServerInfo_DeckShareSummary *summary = re->add_shares(); + summary->set_id(share.id); + summary->set_name(share.name.toStdString()); + summary->set_creation_time(share.creationTime); + summary->set_expires_at(share.expiresAt); + summary->set_item_count(share.itemCount); + } + rc.setResponseExtension(re); + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareRemove(const Command_DeckShareRemove &cmd, + ResponseContainer & /*rc*/) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + if (!cmd.has_share_id()) { + return Response::RespInvalidData; + } + + sqlInterface->checkSql(); + + if (!sqlInterface->deleteDeckShare(cmd.share_id(), userInfo->id())) { + return Response::RespNameNotFound; + } + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareList(const Command_DeckShareList &cmd, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + sqlInterface->checkSql(); + + QString name; + qint64 expiresAt = 0; + QList items; + if (!sqlInterface->getDeckShareList(nameFromStdString(cmd.token()), name, expiresAt, items)) { + return Response::RespNameNotFound; + } + + Response_DeckShareList *re = new Response_DeckShareList; + re->set_name(name.toStdString()); + re->set_expires_at(expiresAt); + for (const DeckShareItemRecord &item : items) { + ServerInfo_DeckShareItem *itemInfo = re->add_items(); + itemInfo->set_id(item.id); + itemInfo->set_name(item.name.toStdString()); + for (const QString &tag : item.tags) { + itemInfo->add_tags(tag.toStdString()); + } + itemInfo->set_banner_card(item.bannerCard.toStdString()); + itemInfo->set_game_format(item.gameFormat.toStdString()); + itemInfo->set_color_identity(item.colorIdentity.toStdString()); + } + rc.setResponseExtension(re); + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareDownload(const Command_DeckShareDownload &cmd, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + sqlInterface->checkSql(); + + QString content; + if (!sqlInterface->getDeckShareItem(nameFromStdString(cmd.token()), cmd.item_id(), content)) { + return Response::RespNameNotFound; + } + + Response_DeckShareDownload *re = new Response_DeckShareDownload; + re->set_deck(content.toStdString()); + rc.setResponseExtension(re); + + return Response::RespOk; +} + Response::ResponseCode AbstractServerSocketInterface::cmdReplayList(const Command_ReplayList & /*cmd*/, ResponseContainer &rc) { diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index b464e6a9b..36b900d28 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -45,11 +45,19 @@ class ServerInfo_DeckStorage_Folder; class Command_AddToList; class Command_RemoveFromList; class Command_DeckList; +class Command_DeckListOtherUser; class Command_DeckNewDir; class Command_DeckDelDir; class Command_DeckDel; class Command_DeckDownload; +class Command_DeckDownloadPublic; class Command_DeckUpload; +class Command_DeckSetVisibility; +class Command_DeckShareCreate; +class Command_DeckShareList; +class Command_DeckShareListMine; +class Command_DeckShareRemove; +class Command_DeckShareDownload; class Command_ReplayList; class Command_ReplayDownload; class Command_ReplayModifyMatch; @@ -97,8 +105,16 @@ private: Response::ResponseCode cmdRemoveFromList(const Command_RemoveFromList &cmd, ResponseContainer &rc); int getDeckPathId(int basePathId, QStringList path); int getDeckPathId(const QString &path); - bool deckListHelper(int folderId, ServerInfo_DeckStorage_Folder *folder); + bool deckListHelper(int folderId, + ServerInfo_DeckStorage_Folder *folder, + int userId, + bool inheritedPublic, + bool publicOnly); + int getDeckOwnerId(int deckId); + bool isDeckEffectivelyPublic(int deckId); Response::ResponseCode cmdDeckList(const Command_DeckList &cmd, ResponseContainer &rc); + Response::ResponseCode cmdDeckListOtherUser(const Command_DeckListOtherUser &cmd, ResponseContainer &rc); + Response::ResponseCode cmdDeckSetVisibility(const Command_DeckSetVisibility &cmd, ResponseContainer &rc); Response::ResponseCode cmdDeckNewDir(const Command_DeckNewDir &cmd, ResponseContainer &rc); void deckDelDirHelper(int basePathId); void sendServerMessage(const QString userName, const QString message); @@ -107,6 +123,12 @@ private: Response::ResponseCode cmdDeckUpload(const Command_DeckUpload &cmd, ResponseContainer &rc); DeckList *getDeckFromDatabase(int deckId); Response::ResponseCode cmdDeckDownload(const Command_DeckDownload &cmd, ResponseContainer &rc); + Response::ResponseCode cmdDeckDownloadPublic(const Command_DeckDownloadPublic &cmd, ResponseContainer &rc); + Response::ResponseCode cmdDeckShareCreate(const Command_DeckShareCreate &cmd, ResponseContainer &rc); + Response::ResponseCode cmdDeckShareList(const Command_DeckShareList &cmd, ResponseContainer &rc); + Response::ResponseCode cmdDeckShareListMine(const Command_DeckShareListMine &cmd, ResponseContainer &rc); + Response::ResponseCode cmdDeckShareRemove(const Command_DeckShareRemove &cmd, ResponseContainer &rc); + Response::ResponseCode cmdDeckShareDownload(const Command_DeckShareDownload &cmd, ResponseContainer &rc); Response::ResponseCode cmdReplayList(const Command_ReplayList &cmd, ResponseContainer &rc); Response::ResponseCode cmdReplayDownload(const Command_ReplayDownload &cmd, ResponseContainer &rc); Response::ResponseCode cmdReplayModifyMatch(const Command_ReplayModifyMatch &cmd, ResponseContainer &rc); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4f5dc88eb..7bb834d7e 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -12,10 +12,12 @@ add_test(NAME server_card_counter_test COMMAND server_card_counter_test) add_test(NAME server_counter_test COMMAND server_counter_test) add_test(NAME server_rate_limiter_test COMMAND server_rate_limiter_test) add_test(NAME server_developer_role_test COMMAND server_developer_role_test) +add_test(NAME server_game_join_test COMMAND server_game_join_test) add_test(NAME warning_categories_test COMMAND warning_categories_test) add_test(NAME lag_monitor_test COMMAND lag_monitor_test) add_test(NAME latency_tracker_test COMMAND latency_tracker_test) add_test(NAME metrics_registry_test COMMAND metrics_registry_test) +add_test(NAME loader_local_matching_test COMMAND loader_local_matching_test) add_test(NAME deck_hash_performance_test COMMAND deck_hash_performance_test) set_tests_properties(deck_hash_performance_test PROPERTIES TIMEOUT 15) @@ -33,11 +35,23 @@ add_executable(server_card_counter_test server_card_counter_test.cpp) add_executable(server_counter_test server_counter_test.cpp) add_executable(server_rate_limiter_test server_rate_limiter_test.cpp) add_executable(server_developer_role_test server_developer_role_test.cpp) +add_executable(server_game_join_test server_game_join_test.cpp) add_executable(warning_categories_test warning_categories_test.cpp) add_executable(lag_monitor_test ${CMAKE_SOURCE_DIR}/cockatrice/src/client/lag_monitor.cpp lag_monitor_test.cpp) target_include_directories(lag_monitor_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src) add_executable(latency_tracker_test latency_tracker_test.cpp) add_executable(metrics_registry_test ../servatrice/src/metrics_registry.cpp metrics_registry_test.cpp) +add_executable( + loader_local_matching_test + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp + ${VERSION_STRING_CPP} + loader_local_matching_test.cpp +) +target_include_directories(loader_local_matching_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src) find_package(GTest) @@ -75,10 +89,12 @@ if(NOT GTEST_FOUND) add_dependencies(server_counter_test gtest) add_dependencies(server_rate_limiter_test gtest) add_dependencies(server_developer_role_test gtest) + add_dependencies(server_game_join_test gtest) add_dependencies(warning_categories_test gtest) add_dependencies(lag_monitor_test gtest) add_dependencies(latency_tracker_test gtest) add_dependencies(metrics_registry_test gtest) + add_dependencies(loader_local_matching_test gtest) endif() include_directories(${GTEST_INCLUDE_DIRS}) @@ -114,6 +130,10 @@ target_link_libraries( server_developer_role_test libcockatrice_network libcockatrice_rng Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} ) +target_link_libraries( + server_game_join_test libcockatrice_network_server_remote libcockatrice_rng Threads::Threads ${GTEST_BOTH_LIBRARIES} + ${TEST_QT_MODULES} +) target_link_libraries( warning_categories_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} ) @@ -123,6 +143,9 @@ target_link_libraries( ) target_include_directories(metrics_registry_test PRIVATE ${CMAKE_SOURCE_DIR}/servatrice/src) target_link_libraries(metrics_registry_test ${TEST_QT_MODULES} Threads::Threads ${GTEST_BOTH_LIBRARIES}) +target_link_libraries( + loader_local_matching_test libcockatrice_settings Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} +) add_subdirectory(card_zone_algorithms) add_subdirectory(carddatabase) diff --git a/tests/carddatabase/carddatabase_test.cpp b/tests/carddatabase/carddatabase_test.cpp index 3fa0e3834..392f6395a 100644 --- a/tests/carddatabase/carddatabase_test.cpp +++ b/tests/carddatabase/carddatabase_test.cpp @@ -2,9 +2,14 @@ #include "test_card_database_path_provider.h" #include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include #include #include - namespace { @@ -33,6 +38,49 @@ TEST(CardDatabaseTest, LoadXml) ASSERT_EQ(0, db->query()->getAllMainCardTypes().size()) << "Types not empty after clear"; ASSERT_EQ(NotLoaded, db->getLoadStatus()) << "Incorrect status after clear"; } + +TEST(CardDatabaseTest, Xml4LocalizedDataRoundTrip) +{ + NoopCardSetPriorityController controller; + CardSetPtr set = + CardSet::newInstance(&controller, "TST", "Test Set", "expansion", QDate(), CardSet::PriorityPrimary); + + QHash props; + props["manacost"] = "1R"; + PrintingInfo printing(set, LazyPropertiesHash(props)); + SetToPrintingsMap setsInfo; + setsInfo["TST"].append(printing); + + CardInfo::UiAttributes attributes = {.tableRow = 1}; + CardInfoPtr card = + CardInfo::newInstance("Lightning Bolt", "Deal 3 damage.", false, {}, {}, {}, setsInfo, attributes); + card->setLocalizedName("de", "Blitzschlag"); + card->setLocalizedText("de", "Blitzschlag fügt 3 Schadenspunkte zu."); + + SetNameMap sets; + sets.insert("TST", set); + CardNameMap cards; + cards.insert("Lightning Bolt", card); + + QTemporaryDir tempDir; + const QString fileName = tempDir.filePath("cards.xml"); + NoopCardPreferenceProvider prefProvider; + CockatriceXml4Parser writer(&prefProvider, &controller); + ASSERT_TRUE(writer.saveToFile({}, sets, cards, fileName)); + + CardDatabaseData data; + CockatriceXml4Parser parser(&prefProvider, &controller); + QFile file(fileName); + ASSERT_TRUE(file.open(QIODevice::ReadOnly)); + parser.parseFileInto(file, data); + + CardInfoPtr loaded = data.cards.value("Lightning Bolt"); + ASSERT_FALSE(loaded.isNull()); + ASSERT_EQ(loaded->getName(), "Lightning Bolt"); + ASSERT_EQ(loaded->getLocalizedName("de"), "Blitzschlag"); + ASSERT_EQ(loaded->getLocalizedText("de"), "Blitzschlag fügt 3 Schadenspunkte zu."); + ASSERT_EQ(loaded->getLocalizedText("fr"), "Deal 3 damage."); +} } // namespace int main(int argc, char **argv) diff --git a/tests/loader_local_matching_test.cpp b/tests/loader_local_matching_test.cpp new file mode 100644 index 000000000..7ffa7157d --- /dev/null +++ b/tests/loader_local_matching_test.cpp @@ -0,0 +1,187 @@ +#include "client/settings/cache_settings.h" +#include "interface/card_picture_loader/card_picture_loader_local.h" + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +/** + * @brief Builds an ExactCard with the requested identity fields. + * + * Mirrors how the client constructs cards: the set short name feeds tryLoad()'s + * setName, and the "num" printing property feeds the collector number. + */ +ExactCard cardFor(const QString &name, const QString &setShortName, const QString &collectorNumber) +{ + CardSetPtr set; + if (!setShortName.isEmpty()) { + set = CardSet::newInstance(new NoopCardSetPriorityController(), setShortName, setShortName); + } + + LazyPropertiesHash properties; + if (!collectorNumber.isEmpty()) { + properties.insert("num", collectorNumber); + } + + return ExactCard(CardInfo::newInstance(name), PrintingInfo(set, properties)); +} + +class LocalMatcherTest : public ::testing::Test +{ +protected: + QTemporaryDir tempDir; ///< Sandboxed "pics" root for every test. + CardPictureLoaderLocal *loader = nullptr; ///< Constructed per test against the sandboxed paths. + + QString picsPath() const + { + return tempDir.path() + "/pics"; + } + + void SetUp() override + { + // The loader ctor snapshots the global picture paths once, so point them at the + // sandbox before constructing it. + SettingsCache::instance().paths().setPicsPath(picsPath()); + SettingsCache::instance().paths().setCustomPicsPath(picsPath() + "/CUSTOM/"); + + loader = new CardPictureLoaderLocal(nullptr); + } + + void TearDown() override + { + delete loader; + loader = nullptr; + } + + /** + * @brief Writes a valid 1x1 PNG under the sandboxed pics path. + */ + void writePngUnderPics(const QString &relativePath, const QColor &color = Qt::red) + { + const QString fullPath = picsPath() + "/" + relativePath; + ASSERT_TRUE(QDir().mkpath(QFileInfo(fullPath).absolutePath())); + + QImage image(1, 1, QImage::Format_RGB32); + image.fill(color); + + QImageWriter writer(fullPath, "PNG"); + ASSERT_TRUE(writer.write(image)); + } +}; + +TEST_F(LocalMatcherTest, ExactMatchBareFileWinsOverSuffixedCompanion) +{ + writePngUnderPics("downloadedPics/TestCard.png"); + writePngUnderPics("downloadedPics/TestCard (1).png"); + + const QImage image = loader->tryLoad(cardFor("TestCard", "", "")); + + EXPECT_FALSE(image.isNull()) << "The bare TestCard.png must be picked over its suffixed companion"; +} + +TEST_F(LocalMatcherTest, SuffixedFileWithoutExactMatchIsNotLoaded) +{ + // The pre-refactor prefix match would have accepted "TestCard (1).png" for "TestCard". + writePngUnderPics("downloadedPics/TestCard (1).png"); + + const QImage image = loader->tryLoad(cardFor("TestCard", "", "")); + + EXPECT_TRUE(image.isNull()) << "A suffixed file must not satisfy an exact card-name lookup"; +} + +TEST_F(LocalMatcherTest, SetFolderLookupIgnoresSuffixedFiles) +{ + writePngUnderPics("M10/TestCard (1).png"); + + const QImage image = loader->tryLoad(cardFor("TestCard", "M10", "")); + + EXPECT_TRUE(image.isNull()) << "Set-folder lookups must also require an exact name match"; +} + +TEST_F(LocalMatcherTest, SetFolderLookupStillResolvesExactFile) +{ + writePngUnderPics("M10/TestCard.png"); + writePngUnderPics("M10/TestCard (1).png"); + + const QImage image = loader->tryLoad(cardFor("TestCard", "M10", "")); + + EXPECT_FALSE(image.isNull()) << "The exact file in the set folder must still resolve"; +} + +TEST_F(LocalMatcherTest, RootDownloadedPicsFallbackResolvesSchemeFilename) +{ + // Non-set-folder export schemes (Name_Set_Collector) write straight into downloadedPics/. + writePngUnderPics("downloadedPics/TestCard_M10_1.png"); + + const QImage image = loader->tryLoad(cardFor("TestCard", "M10", "1")); + + EXPECT_FALSE(image.isNull()) << "downloadedPics/TestCard_M10_1.png must resolve via the root fallback"; +} + +TEST_F(LocalMatcherTest, RootFallbackResolvesDashSeparatedVariant) +{ + writePngUnderPics("downloadedPics/TestCard-M10-1.png"); + + const QImage image = loader->tryLoad(cardFor("TestCard", "M10", "1")); + + EXPECT_FALSE(image.isNull()) << "The dash-separated import variant must resolve via the root fallback"; +} + +TEST_F(LocalMatcherTest, DownloadedPicsSetSubfolderStillResolves) +{ + writePngUnderPics("downloadedPics/M10/TestCard_M10_1.png"); + + const QImage image = loader->tryLoad(cardFor("TestCard", "M10", "1")); + + EXPECT_FALSE(image.isNull()) << "The set-subfolder export scheme must keep resolving"; +} + +TEST_F(LocalMatcherTest, SetFolderCandidateTakesPrecedenceOverRootFallback) +{ + writePngUnderPics("M10/TestCard_M10_1.png", Qt::red); + writePngUnderPics("downloadedPics/TestCard_M10_1.png", Qt::blue); + + const QImage image = loader->tryLoad(cardFor("TestCard", "M10", "1")); + + ASSERT_FALSE(image.isNull()); + EXPECT_EQ(image.pixelColor(0, 0), QColor(Qt::red)) << "The set-folder candidate must be preferred"; +} + +} // namespace + +int main(int argc, char **argv) +{ + // Redirect SettingsCache reads/writes (app-data location) away from the real user profile. + QStandardPaths::setTestModeEnabled(true); + + // Some CI containers run as a uid without a passwd entry (e.g. GitHub's docker + // runner), so HOME resolves to "/" and the test-mode qttest data dir cannot be + // created. SettingsCache's QSettings then silently drops every write, reads come + // back empty, and the paths the loader searches are "". Give the test a writable + // HOME for the duration of the run so settings behave like on a normal machine. + QTemporaryDir home; + if (home.isValid()) { + qputenv("HOME", home.path().toLocal8Bit()); + } + + QCoreApplication app(argc, argv); + QLoggingCategory::setFilterRules("card_picture_loader.*=false\nsettings_cache.*=false"); + + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} \ No newline at end of file diff --git a/tests/oracle/oracle_importer_test.cpp b/tests/oracle/oracle_importer_test.cpp index 66c7bdbe1..f66616e37 100644 --- a/tests/oracle/oracle_importer_test.cpp +++ b/tests/oracle/oracle_importer_test.cpp @@ -72,6 +72,18 @@ protected: return card; } + // Helper: build a single MTGJSON foreignData entry + QJsonObject makeForeignEntry(const QString &language, const QString &name, const QString &text) + { + QJsonObject entry; + entry["language"] = language; + entry["name"] = name; + if (!text.isEmpty()) { + entry["text"] = text; + } + return entry; + } + NoopCardSetPriorityController *controller; OracleImporter *importer; CardSetPtr set; @@ -872,6 +884,243 @@ TEST_F(OracleImporterTest, DisablingProgressReportingSuppressesScanEmissions) ASSERT_GT(emissions, 0); } +// Localized card text tests +// ============================================================================ + +TEST_F(OracleImporterTest, ImportsLocalizedTextForRequestedLanguage) +{ + QJsonObject card = makeCard("Lightning Bolt"); + card["foreignData"] = + QJsonArray{makeForeignEntry("German", "Blitzschlag", "Blitzschlag fügt 3 Schadenspunkte zu.")}; + QJsonArray cards{card}; + + importer->setCardLang("de"); + importer->importCardsFromSet(set, cards); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getLocalizedName("de"), "Blitzschlag"); + ASSERT_EQ(result->getLocalizedText("de"), "Blitzschlag fügt 3 Schadenspunkte zu."); + // English identity untouched + ASSERT_EQ(result->getName(), "Lightning Bolt"); + ASSERT_EQ(result->getText(), "Rules text."); +} + +TEST_F(OracleImporterTest, ImportsLocalizedNameAndTextForMultiFaceCards) +{ + // MTGJSON reports multi-face cards (adventure/split/aftermath/prepare) as one + // card object per face; every face carries the joined name but only its own + // face's rules text in foreignData. The importer joins the per-face texts with + // the same separator as the English merge. + QJsonObject front = makeCard("Disruptive Stormbrood // Petty Revenge"); + front["layout"] = "adventure"; + front["faceName"] = "Disruptive Stormbrood"; + front["side"] = "a"; + front["foreignData"] = QJsonArray{ + makeForeignEntry("German", "Disruptive Stormbrood // Kleinliche Rache", + "Fliegend\nWenn diese Kreatur ins Spiel kommt, zerstöre bis zu ein Artefakt oder eine " + "Verzauberung deiner Wahl.")}; + QJsonObject back = makeCard("Disruptive Stormbrood // Petty Revenge"); + back["layout"] = "adventure"; + back["faceName"] = "Petty Revenge"; + back["side"] = "b"; + back["text"] = "Destroy target creature."; + back["foreignData"] = QJsonArray{makeForeignEntry("German", "Disruptive Stormbrood // Kleinliche Rache", + "Zerstöre eine Kreatur deiner Wahl mit Stärke 3 oder weniger.")}; + QJsonArray cards{front, back}; + + importer->setCardLang("de"); + importer->importCardsFromSet(set, cards); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Disruptive Stormbrood // Petty Revenge"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getLocalizedName("de"), "Disruptive Stormbrood // Kleinliche Rache"); + ASSERT_EQ(result->getLocalizedText("de"), + "Fliegend\nWenn diese Kreatur ins Spiel kommt, zerstöre bis zu ein Artefakt oder eine Verzauberung " + "deiner Wahl.\n\n---\n\nZerstöre eine Kreatur deiner Wahl mit Stärke 3 oder weniger."); + // English identity untouched + ASSERT_EQ(result->getName(), "Disruptive Stormbrood // Petty Revenge"); + ASSERT_EQ(result->getText(), "Rules text.\n\n---\n\nDestroy target creature."); +} + +TEST_F(OracleImporterTest, MultiFaceCardsWithoutCompleteForeignTextKeepEnglishText) +{ + // Both faces must carry a foreignData text for the joined text; otherwise the + // rules text stays English while the localized name (from a later complete + // printing) is still applied. + QJsonObject front = makeCard("Wear // Tear"); + front["layout"] = "split"; + front["faceName"] = "Wear"; + front["side"] = "a"; + front["foreignData"] = QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Verschleiß-Text.")}; + QJsonObject back = makeCard("Wear // Tear"); + back["layout"] = "split"; + back["faceName"] = "Tear"; + back["side"] = "b"; + back["text"] = "Tear rules text."; + back["foreignData"] = QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "")}; + QJsonArray cards{front, back}; + + importer->setCardLang("de"); + importer->importCardsFromSet(set, cards); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Wear // Tear"); + ASSERT_FALSE(result.isNull()); + // The joined name is still applied. + ASSERT_EQ(result->getLocalizedName("de"), "Verschleiß // Zerrreißung"); + // The incomplete text must not become the card's localized text. + ASSERT_TRUE(result->getLocalizedTexts().isEmpty()); + ASSERT_EQ(result->getText(), "Rules text.\n\n---\n\nTear rules text."); +} + +TEST_F(OracleImporterTest, DefaultLanguageSkipsForeignData) +{ + QJsonObject card = makeCard("Lightning Bolt"); + card["foreignData"] = + QJsonArray{makeForeignEntry("German", "Blitzschlag", "Blitzschlag fügt 3 Schadenspunkte zu.")}; + QJsonArray cards{card}; + + // cardLang defaults to "en" — foreignData must never be imported + importer->importCardsFromSet(set, cards); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_TRUE(result->getLocalizedNames().isEmpty()); + ASSERT_TRUE(result->getLocalizedTexts().isEmpty()); +} + +TEST_F(OracleImporterTest, UnsupportedLanguageSkipsForeignData) +{ + QJsonObject card = makeCard("Lightning Bolt"); + card["foreignData"] = QJsonArray{makeForeignEntry("xx", "Kochanie", "Grzmot uderza.")}; + QJsonArray cards{card}; + + importer->setCardLang("xx"); + importer->importCardsFromSet(set, cards); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_TRUE(result->getLocalizedNames().isEmpty()); +} + +TEST_F(OracleImporterTest, NonMatchingLanguageNotCollected) +{ + QJsonObject card = makeCard("Lightning Bolt"); + card["foreignData"] = QJsonArray{makeForeignEntry("French", "Éclair", "L'Éclair inflige 3 blessures.")}; + QJsonArray cards{card}; + + importer->setCardLang("de"); + importer->importCardsFromSet(set, cards); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_TRUE(result->getLocalizedNames().isEmpty()); +} + +TEST_F(OracleImporterTest, HigherPrioritySetWinsForReprint) +{ + // First printing in a reprint set, then another in a (more authoritative) + // core set: the core set's German text must win even though it was seen later. + QJsonObject reprintCard = makeCard("Lightning Bolt"); + reprintCard["foreignData"] = QJsonArray{makeForeignEntry("German", "Blitzschlag", "Älterer deutscher Text.")}; + CardSetPtr reprintSet = + CardSet::newInstance(controller, "TS2", "Second Set", QString(), QDate(), CardSet::PriorityReprint); + importer->setCardLang("de"); + importer->importCardsFromSet(reprintSet, QJsonArray{reprintCard}); + + QJsonObject primaryCard = makeCard("Lightning Bolt"); + primaryCard["foreignData"] = + QJsonArray{makeForeignEntry("German", "Blitzschlag", "Blitzschlag fügt 3 Schadenspunkte zu.")}; + CardSetPtr primarySet = + CardSet::newInstance(controller, "TS3", "Third Set", QString(), QDate(), CardSet::PriorityPrimary); + importer->importCardsFromSet(primarySet, QJsonArray{primaryCard}); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getLocalizedName("de"), "Blitzschlag"); + ASSERT_EQ(result->getLocalizedText("de"), "Blitzschlag fügt 3 Schadenspunkte zu."); + ASSERT_EQ(importer->getCardList().size(), 1); +} + +TEST_F(OracleImporterTest, HigherPrioritySplitSetWinsForReprint) +{ + // Split cards print each face as its own card object; the joined text is + // collected per set with the same priority policy as single-face cards, so a + // reprint set's German text must yield to the core set's even when reprints + // are imported first. + QJsonObject reprintFront = makeCard("Wear // Tear"); + reprintFront["layout"] = "split"; + reprintFront["faceName"] = "Wear"; + reprintFront["side"] = "a"; + reprintFront["foreignData"] = + QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Wear alter Text.")}; + QJsonObject reprintBack = makeCard("Wear // Tear"); + reprintBack["layout"] = "split"; + reprintBack["faceName"] = "Tear"; + reprintBack["side"] = "b"; + reprintBack["foreignData"] = + QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Tear alter Text.")}; + CardSetPtr reprintSet = + CardSet::newInstance(controller, "TS2", "Second Set", QString(), QDate(), CardSet::PriorityReprint); + importer->setCardLang("de"); + importer->importCardsFromSet(reprintSet, QJsonArray{reprintFront, reprintBack}); + + QJsonObject primaryFront = makeCard("Wear // Tear"); + primaryFront["layout"] = "split"; + primaryFront["faceName"] = "Wear"; + primaryFront["side"] = "a"; + primaryFront["foreignData"] = + QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Wear neuer Text.")}; + QJsonObject primaryBack = makeCard("Wear // Tear"); + primaryBack["layout"] = "split"; + primaryBack["faceName"] = "Tear"; + primaryBack["side"] = "b"; + primaryBack["foreignData"] = + QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Tear neuer Text.")}; + CardSetPtr primarySet = + CardSet::newInstance(controller, "TS3", "Third Set", QString(), QDate(), CardSet::PriorityPrimary); + importer->importCardsFromSet(primarySet, QJsonArray{primaryFront, primaryBack}); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Wear // Tear"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getLocalizedName("de"), "Verschleiß // Zerrreißung"); + ASSERT_EQ(result->getLocalizedText("de"), "Wear neuer Text.\n\n---\n\nTear neuer Text."); + ASSERT_EQ(importer->getCardList().size(), 1); +} + +TEST_F(OracleImporterTest, StartImportAppliesLocalizedData) +{ + QJsonObject card = makeCard("Lightning Bolt"); + card["foreignData"] = + QJsonArray{makeForeignEntry("Portuguese (Brazil)", "Raio", "Raio causa 3 de dano a qualquer alvo.")}; + QJsonObject dataSet; + dataSet["code"] = "tst"; + dataSet["name"] = "Test Set"; + dataSet["type"] = "expansion"; + dataSet["releaseDate"] = "2024-01-01"; + dataSet["cards"] = QJsonArray{card}; + + QJsonObject root; + root["data"] = QJsonObject{{"TST", dataSet}}; + + importer->setCardLang("pt"); + ASSERT_TRUE(importer->readSetsFromByteArray(QJsonDocument(root).toJson(QJsonDocument::Compact))); + ASSERT_EQ(importer->startImport(), 1); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getLocalizedName("pt"), "Raio"); + ASSERT_EQ(result->getLocalizedText("pt"), "Raio causa 3 de dano a qualquer alvo."); +} + int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); diff --git a/tests/server_game_join_test.cpp b/tests/server_game_join_test.cpp new file mode 100644 index 000000000..c84e4e066 --- /dev/null +++ b/tests/server_game_join_test.cpp @@ -0,0 +1,174 @@ +/** @file server_game_join_test.cpp + * @brief Tests for the moderator/judge game-entry restriction override in Server_Game::checkJoin. + * @ingroup Tests + */ + +#include "game/server_game.h" +#include "server.h" +#include "server_database_interface.h" +#include "server_room.h" + +#include +#include +#include + +RNG_Abstract *rng = nullptr; // referenced by the server_remote library + +namespace +{ + +class MockDatabaseInterface : public Server_DatabaseInterface +{ +public: + AuthenticationResult checkUserPassword(Server_ProtocolHandler *, + const QString &, + const QString &, + const QString &, + QString &, + int &, + bool) override + { + return NotLoggedIn; + } + int getNextReplayId() override + { + return 1; + } + int getNextGameId() override + { + return 1; + } + int getActiveUserCount(QString) override + { + return 0; + } + ServerInfo_User getUserData(const QString &, bool) override + { + return ServerInfo_User(); + } +}; + +class FakeServer : public Server +{ +public: + FakeServer() + { + setDatabaseInterface(new MockDatabaseInterface()); + } +}; + +class GameJoinOverrideTest : public ::testing::Test +{ +protected: + FakeServer server; + Server_Room room{0, 0, "", "", "", "", false, "", {}, &server}; + ServerInfo_User creator; + ServerInfo_User plainUser; + ServerInfo_User unregisteredJudge; + ServerInfo_User moderator; + ServerInfo_User judge; + Server_Game *game = nullptr; + + void SetUp() override + { + creator.set_name("creator"); + creator.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered); + plainUser.set_name("plain-user"); + plainUser.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered); + unregisteredJudge.set_name("unregistered-judge"); + unregisteredJudge.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsJudge); + moderator.set_name("moderator"); + moderator.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered | + ServerInfo_User::IsModerator); + judge.set_name("judge"); + judge.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered | ServerInfo_User::IsJudge); + } + + void TearDown() override + { + delete game; + } + + Server_Game *makeGame(bool passwordProtected, bool onlyRegistered, bool onlyBuddies, bool spectatorsAllowed) + { + GameConfig config{.creatorInfo = creator, + .gameId = 1, + .description = QString(), + .password = passwordProtected ? "secret" : QString(), + .maxPlayers = 2, + .gameTypes = QList(), + .onlyBuddies = onlyBuddies, + .onlyRegistered = onlyRegistered, + .spectatorsAllowed = spectatorsAllowed, + .spectatorsNeedPassword = true, + .spectatorsCanTalk = false, + .spectatorsSeeEverything = false, + .startingLifeTotal = 20, + .shareDecklistsOnLoad = false}; + return new Server_Game(config, &room); + } +}; + +TEST_F(GameJoinOverrideTest, StaffBypassPasswordRestriction) +{ + game = makeGame(true, false, false, true); + + // A plain user cannot override the password even with the override flag set. + EXPECT_EQ(game->checkJoin(&plainUser, "wrong", false, true, false), Response::RespWrongPassword); + // Moderators and judges may enter any game regardless of the password. + EXPECT_EQ(game->checkJoin(&moderator, "wrong", false, true, false), Response::RespOk); + EXPECT_EQ(game->checkJoin(&judge, "wrong", false, true, false), Response::RespOk); + // Without the override flag judges are still subject to the password. + EXPECT_EQ(game->checkJoin(&judge, "wrong", false, false, true), Response::RespWrongPassword); + EXPECT_EQ(game->checkJoin(&judge, "secret", false, false, true), Response::RespOk); +} + +TEST_F(GameJoinOverrideTest, StaffBypassRegisteredOnlyRestriction) +{ + game = makeGame(false, true, false, true); + + // Without the override flag the only-registered restriction still applies. + EXPECT_EQ(game->checkJoin(&unregisteredJudge, QString(), false, false, false), Response::RespUserLevelTooLow); + // An unregistered judge may enter when overriding restrictions. + EXPECT_EQ(game->checkJoin(&unregisteredJudge, QString(), false, true, false), Response::RespOk); +} + +TEST_F(GameJoinOverrideTest, StaffBypassBuddiesOnlyRestriction) +{ + game = makeGame(false, false, true, true); + + // A plain user who is not on the creator's buddy list gets rejected. + EXPECT_EQ(game->checkJoin(&plainUser, QString(), false, true, false), Response::RespOnlyBuddies); + // Moderators and judges bypass the buddies-only restriction. + EXPECT_EQ(game->checkJoin(&moderator, QString(), false, true, false), Response::RespOk); + EXPECT_EQ(game->checkJoin(&judge, QString(), false, true, false), Response::RespOk); +} + +TEST_F(GameJoinOverrideTest, StaffBypassSpectatorsNotAllowedRestriction) +{ + game = makeGame(false, false, false, false); + + // A plain user cannot spectate when the game disallows spectators. + EXPECT_EQ(game->checkJoin(&plainUser, QString(), true, false, false), Response::RespSpectatorsNotAllowed); + // Moderators and judges may spectate any game regardless of the password + // and the spectator restriction. + EXPECT_EQ(game->checkJoin(&moderator, "wrong", true, true, false), Response::RespOk); + EXPECT_EQ(game->checkJoin(&judge, "wrong", true, true, false), Response::RespOk); +} + +TEST_F(GameJoinOverrideTest, JudgeOverrideDoesNotGrantJudgeJoinToPlainUser) +{ + game = makeGame(false, false, false, true); + + // joining with join_as_judge still requires the judge flag even when overriding. + EXPECT_EQ(game->checkJoin(&plainUser, QString(), false, true, true), Response::RespUserLevelTooLow); + EXPECT_EQ(game->checkJoin(&judge, QString(), false, true, true), Response::RespOk); +} + +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} \ No newline at end of file diff --git a/tests/settings/settings_defaults_test.cpp b/tests/settings/settings_defaults_test.cpp index eda778ae9..0341b2b99 100644 --- a/tests/settings/settings_defaults_test.cpp +++ b/tests/settings/settings_defaults_test.cpp @@ -386,6 +386,19 @@ TEST_F(SettingsDefaultsTest, Appearance_HomeTabDisplayCardName_Default) ASSERT_EQ(s.getHomeTabDisplayCardName(), true); } +TEST_F(SettingsDefaultsTest, Appearance_HomeTabBackgroundDim_Default) +{ + AppearanceSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getHomeTabBackgroundDim(), true); +} + +TEST_F(SettingsDefaultsTest, Appearance_HomeTabBackgroundDim_SetAndGet) +{ + AppearanceSettings s(settingsPath, nullptr); + s.setHomeTabBackgroundDim(false); + ASSERT_EQ(s.getHomeTabBackgroundDim(), false); +} + // --- InterfaceSettings --- TEST_F(SettingsDefaultsTest, Interface_ShowStatusBar_Default) @@ -549,6 +562,19 @@ TEST_F(SettingsDefaultsTest, CardsDisplay_ArrowDrawAnimation_Default) ASSERT_EQ(s.getArrowDrawAnimation(), true); } +TEST_F(SettingsDefaultsTest, CardsDisplay_CardLang_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getCardLang(), QString("en")); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_CardLang_SetAndGet) +{ + CardsDisplaySettings s(settingsPath, nullptr); + s.setCardLang("de"); + ASSERT_EQ(s.getCardLang(), QString("de")); +} + // --- VisualDeckStorageSettings --- TEST_F(SettingsDefaultsTest, VisualDeckStorage_SortingOrder_Default) @@ -577,6 +603,19 @@ TEST_F(SettingsDefaultsTest, VisualDeckStorage_DefaultTagsList_SetAndGet) ASSERT_EQ(s.getVisualDeckStorageDefaultTagsList(), custom); } +TEST_F(SettingsDefaultsTest, VisualDeckStorage_ShowUploadTime_Default) +{ + VisualDeckStorageSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getVisualDeckStorageShowUploadTime(), true); +} + +TEST_F(SettingsDefaultsTest, VisualDeckStorage_ShowUploadTime_SetAndGet) +{ + VisualDeckStorageSettings s(settingsPath, nullptr); + s.setVisualDeckStorageShowUploadTime(false); + ASSERT_EQ(s.getVisualDeckStorageShowUploadTime(), false); +} + } // namespace int main(int argc, char **argv)