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 4a83b260e..3ebbc489d 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 }} @@ -413,10 +416,10 @@ jobs: if: matrix.os == 'Windows' uses: jurplel/install-qt-action@v4 with: - 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' @@ -448,7 +451,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..dd45f0039 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -345,6 +345,16 @@ ${If} $PortableMode = 0 WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "VersionMajor" "@CPACK_PACKAGE_VERSION_MAJOR@" WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "VersionMinor" "@CPACK_PACKAGE_VERSION_MINOR@" + ; --- Register .cod file type --- + WriteRegStr HKCR ".cod" "" "Cockatrice" + WriteRegStr HKCR "Cockatrice" "" "Cockatrice Deck File" + WriteRegStr HKCR "Cockatrice\shell\open\command" "" '"$INSTDIR\cockatrice.exe" "%1"' + + ; --- Register custom URI protocol --- + WriteRegStr HKCR "cockatrice" "" "URL: Cockatrice Protocol" + WriteRegStr HKCR "cockatrice" "URL Protocol" "" + WriteRegStr HKCR "cockatrice\shell\open\command" "" '"$INSTDIR\cockatrice.exe" "%1"' + IfFileExists "$INSTDIR\vc_redist.x86.exe" VcRedist86Exists PastVcRedist86Check VcRedist86Exists: ExecWait '"$INSTDIR\vc_redist.x86.exe" /passive /norestart' @@ -387,21 +397,34 @@ 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" + StrCpy $R1 0 - RMDir "$SMPROGRAMS\Cockatrice" + ; Record whether this was a portable install before the directory (and the + ; portable.dat marker inside it) is removed below. + IfFileExists "$INSTDIR\portable.dat" 0 +2 + StrCpy $R1 1 + + ; 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" + + ; Only remove the file/protocol associations if we registered them (i.e. the + ; install was not portable) and .cod is still owned by Cockatrice, so we don't + ; clobber a .cod association installed by another application. + ${If} $R1 == 0 + ReadRegStr $0 HKCR ".cod" "" + ${If} $0 == "Cockatrice" + DeleteRegKey HKCR ".cod" + DeleteRegKey HKCR "Cockatrice" + DeleteRegKey HKCR "cockatrice" + ${EndIf} + ${EndIf} SectionEnd ; unselected because it is /o diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 4263fc6e2..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 @@ -444,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 @@ -656,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 @@ -720,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 14cf15b2f..4ec3d2385 100644 --- a/cockatrice/cockatrice.qrc +++ b/cockatrice/cockatrice.qrc @@ -62,9 +62,9 @@ resources/icons/mana/U.svg resources/icons/mana/W.svg - resources/backgrounds/home.png - resources/backgrounds/home-dark.png - resources/backgrounds/home-light.png + resources/backgrounds/home.jpg + resources/backgrounds/home-dark.jpg + resources/backgrounds/home-light.jpg resources/backgrounds/card_triplet.svg resources/backgrounds/placeholder_printing_selector.svg diff --git a/cockatrice/resources/backgrounds/home-dark.jpg b/cockatrice/resources/backgrounds/home-dark.jpg new file mode 100644 index 000000000..53d3406dd Binary files /dev/null and b/cockatrice/resources/backgrounds/home-dark.jpg differ diff --git a/cockatrice/resources/backgrounds/home-dark.png b/cockatrice/resources/backgrounds/home-dark.png deleted file mode 100644 index 68f48e2c2..000000000 Binary files a/cockatrice/resources/backgrounds/home-dark.png and /dev/null differ diff --git a/cockatrice/resources/backgrounds/home-light.jpg b/cockatrice/resources/backgrounds/home-light.jpg new file mode 100644 index 000000000..112146e4f Binary files /dev/null and b/cockatrice/resources/backgrounds/home-light.jpg differ diff --git a/cockatrice/resources/backgrounds/home-light.png b/cockatrice/resources/backgrounds/home-light.png deleted file mode 100644 index eaaaba932..000000000 Binary files a/cockatrice/resources/backgrounds/home-light.png and /dev/null differ diff --git a/cockatrice/resources/backgrounds/home.jpg b/cockatrice/resources/backgrounds/home.jpg new file mode 100644 index 000000000..112146e4f Binary files /dev/null and b/cockatrice/resources/backgrounds/home.jpg differ diff --git a/cockatrice/resources/backgrounds/home.png b/cockatrice/resources/backgrounds/home.png deleted file mode 100644 index eaaaba932..000000000 Binary files a/cockatrice/resources/backgrounds/home.png and /dev/null differ diff --git a/cockatrice/src/client/settings/cache_settings.h b/cockatrice/src/client/settings/cache_settings.h index 23cdb4dbf..7a90d82f1 100644 --- a/cockatrice/src/client/settings/cache_settings.h +++ b/cockatrice/src/client/settings/cache_settings.h @@ -58,33 +58,33 @@ signals: void themeChanged(); private: - QSettings *settings; - ShortcutsSettings *shortcutsSettings; - CardDatabaseSettings *cardDatabaseSettings; - ServersSettings *serversSettings; - MessageSettings *messageSettings; - GameFiltersSettings *gameFiltersSettings; - LayoutsSettings *layoutsSettings; - DownloadSettings *downloadSettings; - RecentsSettings *recentsSettings; - CardOverrideSettings *cardOverrideSettings; - DebugSettings *debugSettings; - CardCounterSettings *cardCounterSettings; - TabsSettings *tabsSettings; - SoundSettings *soundSettings; - GameSettings *gameSettings; - ChatSettings *chatSettings; - CacheStorageSettings *cacheStorageSettings; - UpdatesSettings *updatesSettings; - PersonalSettings *personalSettings; - CardsDisplaySettings *cardsDisplaySettings; - InterfaceSettings *interfaceSettings; - DeckEditorSettings *deckEditorSettings; - PathsSettings *pathsSettings; - VisualDeckStorageSettings *visualDeckStorageSettings; - AppearanceSettings *appearanceSettings; - NetworkSettings *networkSettings; - CommanderBracketSettings *commanderBracketSettings; + QSettings *settings = nullptr; + ShortcutsSettings *shortcutsSettings = nullptr; + CardDatabaseSettings *cardDatabaseSettings = nullptr; + ServersSettings *serversSettings = nullptr; + MessageSettings *messageSettings = nullptr; + GameFiltersSettings *gameFiltersSettings = nullptr; + LayoutsSettings *layoutsSettings = nullptr; + DownloadSettings *downloadSettings = nullptr; + RecentsSettings *recentsSettings = nullptr; + CardOverrideSettings *cardOverrideSettings = nullptr; + DebugSettings *debugSettings = nullptr; + CardCounterSettings *cardCounterSettings = nullptr; + TabsSettings *tabsSettings = nullptr; + SoundSettings *soundSettings = nullptr; + GameSettings *gameSettings = nullptr; + ChatSettings *chatSettings = nullptr; + CacheStorageSettings *cacheStorageSettings = nullptr; + UpdatesSettings *updatesSettings = nullptr; + PersonalSettings *personalSettings = nullptr; + CardsDisplaySettings *cardsDisplaySettings = nullptr; + InterfaceSettings *interfaceSettings = nullptr; + DeckEditorSettings *deckEditorSettings = nullptr; + PathsSettings *pathsSettings = nullptr; + VisualDeckStorageSettings *visualDeckStorageSettings = nullptr; + AppearanceSettings *appearanceSettings = nullptr; + NetworkSettings *networkSettings = nullptr; + CommanderBracketSettings *commanderBracketSettings = nullptr; QString themeName; diff --git a/cockatrice/src/filters/deck_filter_string.cpp b/cockatrice/src/filters/deck_filter_string.cpp index 4abb8210c..a2b7519a8 100644 --- a/cockatrice/src/filters/deck_filter_string.cpp +++ b/cockatrice/src/filters/deck_filter_string.cpp @@ -43,6 +43,12 @@ NumericValue <- [0-9]+ static std::once_flag init; +// The peglib parser is a single permanent object, so the rule actions below cannot see +// per-instance state. The card language that the nested [[card name]] search matches +// against is passed through this thread-local context, which is live only while a +// DeckFilterString is being parsed, and copied into the nested FilterString closures. +thread_local CardSearchLanguage deckSearchLanguageContext; + static void setupParserRules() { // plumbing @@ -116,7 +122,7 @@ static void setupParserRules() // actual functionality search["DeckContentQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter { - auto cardFilter = FilterString(std::any_cast(sv[0])); + auto cardFilter = FilterString(std::any_cast(sv[0]), deckSearchLanguageContext); auto numberMatcher = sv.size() > 1 ? std::any_cast(sv[1]) : [](int count) { return count > 0; }; return [=](const DeckSearchData &data) -> bool { @@ -186,7 +192,7 @@ DeckFilterString::DeckFilterString() _error = "Not initialized"; } -DeckFilterString::DeckFilterString(const QString &expr) +DeckFilterString::DeckFilterString(const QString &expr, const CardSearchLanguage &searchLanguage) { QByteArray ba = expr.simplified().toUtf8(); @@ -199,6 +205,8 @@ DeckFilterString::DeckFilterString(const QString &expr) return; } + deckSearchLanguageContext = searchLanguage; + search.set_logger([&](size_t /*ln*/, size_t col, const std::string &msg) { _error = QString("Error at position %1: %2").arg(col).arg(QString::fromStdString(msg)); }); diff --git a/cockatrice/src/filters/deck_filter_string.h b/cockatrice/src/filters/deck_filter_string.h index 90a6a17eb..5b1419004 100644 --- a/cockatrice/src/filters/deck_filter_string.h +++ b/cockatrice/src/filters/deck_filter_string.h @@ -12,6 +12,7 @@ #include #include #include +#include inline Q_LOGGING_CATEGORY(DeckFilterStringLog, "deck_filter_string"); @@ -35,7 +36,7 @@ class DeckFilterString { public: DeckFilterString(); - explicit DeckFilterString(const QString &expr); + explicit DeckFilterString(const QString &expr, const CardSearchLanguage &searchLanguage = {}); bool check(const DeckSearchData &data) const { return filter(data); diff --git a/cockatrice/src/game_graphics/dialogs/dlg_create_token.cpp b/cockatrice/src/game_graphics/dialogs/dlg_create_token.cpp index b311d2ebd..6d8ad0534 100644 --- a/cockatrice/src/game_graphics/dialogs/dlg_create_token.cpp +++ b/cockatrice/src/game_graphics/dialogs/dlg_create_token.cpp @@ -16,11 +16,13 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -88,6 +90,17 @@ DlgCreateToken::DlgCreateToken(const QStringList &_predefinedTokens, QWidget *pa cardDatabaseDisplayModel = new TokenDisplayModel(this); cardDatabaseDisplayModel->setSourceModel(cardDatabaseModel); + const auto applyCardSearchLanguage = [this]() { + const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay(); + cardDatabaseDisplayModel->setSearchLanguage(CardSearchLanguage{ + cardsDisplay.getCardLang(), static_cast(cardsDisplay.getCardSearchLanguage())}); + }; + applyCardSearchLanguage(); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, + applyCardSearchLanguage); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardSearchLanguageChanged, this, + applyCardSearchLanguage); + chooseTokenFromAllRadioButton = new QRadioButton(tr("Show &all tokens")); connect(chooseTokenFromAllRadioButton, &QRadioButton::toggled, this, &DlgCreateToken::actChooseTokenFromAll); chooseTokenFromDeckRadioButton = new QRadioButton(tr("Show tokens from this &deck")); diff --git a/cockatrice/src/game_graphics/zones/view_zone.cpp b/cockatrice/src/game_graphics/zones/view_zone.cpp index baf7b8b30..5bd5d262f 100644 --- a/cockatrice/src/game_graphics/zones/view_zone.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone.cpp @@ -1,5 +1,6 @@ #include "view_zone.h" +#include "../../client/settings/cache_settings.h" #include "../../game/player/player_actions.h" #include "../../game/player/player_logic.h" #include "../../game/zones/view_zone_logic.h" @@ -11,11 +12,13 @@ #include #include #include +#include #include #include #include #include #include +#include /** * @param parent the parent QGraphicsWidget containing the reveal zone @@ -253,7 +256,10 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca void ZoneViewZone::setFilterString(const QString &_filterString) { - filterString = FilterString(_filterString); + const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay(); + filterString = FilterString( + _filterString, CardSearchLanguage{cardsDisplay.getCardLang(), + static_cast(cardsDisplay.getCardSearchLanguage())}); reorganizeCards(); } diff --git a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp index c13b79fea..fa6733413 100644 --- a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include namespace @@ -168,6 +169,12 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player, } connect(&searchEdit, &QLineEdit::textChanged, zone, &ZoneViewZone::setFilterString); + + const auto applyCardSearchLanguage = [this] { zone->setFilterString(searchEdit.text()); }; + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, + applyCardSearchLanguage); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardSearchLanguageChanged, this, + applyCardSearchLanguage); } setLayout(vbox); 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 b8a54761a..8e34942e1 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -43,6 +44,7 @@ CardPictureLoader::CardPictureLoader() : QObject(nullptr) qRegisterMetaType("ExactCard"); connect(worker, &CardPictureLoaderWorker::imageLoaded, this, &CardPictureLoader::imageLoaded); + connect(worker, &CardPictureLoaderWorker::networkCacheCleared, this, &CardPictureLoader::networkCacheCleared); statusBar = new CardPictureLoaderStatusBar(nullptr); QMainWindow *mainWindow = qobject_cast(QApplication::activeWindow()); @@ -58,7 +60,18 @@ CardPictureLoader::CardPictureLoader() : QObject(nullptr) CardPictureLoader::~CardPictureLoader() { - worker->deleteLater(); + if (worker) { + // Capture the thread first: shutdownThread() blocks until the worker has been freed by the + // finished() -> deleteLater chain, after which the worker pointer must not be dereferenced. + QThread *pictureLoaderThread = worker->workerThread(); + const bool stopped = worker->shutdownThread(); + worker = nullptr; + // Deleting a QThread that is still running is undefined behaviour, so only free it once the + // bounded wait in shutdownThread() confirmed that it stopped. + if (stopped) { + delete pictureLoaderThread; + } + } } void CardPictureLoader::getCardBackPixmap(QPixmap &pixmap, QSize size) @@ -457,7 +470,17 @@ void CardPictureLoader::clearPixmapCache() void CardPictureLoader::clearNetworkCache() { - getInstance().worker->clearNetworkCache(); + // During teardown the worker is released before this singleton, so a queued clear may still + // arrive with no worker left to run it. + CardPictureLoaderWorker *worker = getInstance().worker; + if (!worker) { + return; + } + // The disk cache and redirect cache are owned by the worker thread, so the clear has to run + // there. Invoke it asynchronously to keep the GUI responsive while the worker may be walking + // the user's picture directories or recursively deleting the cache directory; callers that + // need to know when it is done can listen for networkCacheCleared(). + QMetaObject::invokeMethod(worker, &CardPictureLoaderWorker::clearNetworkCache, Qt::QueuedConnection); } void CardPictureLoader::cacheCardPixmaps(const QList &cards) 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 0a4934e6d..6224befbf 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader.h @@ -117,6 +117,9 @@ public: public slots: /** * @brief Clears the network disk cache of the worker. + * + * The clear runs on the worker thread, so this returns before it has completed; connect to + * networkCacheCleared() to act once it is done. */ static void clearNetworkCache(); @@ -131,6 +134,10 @@ public slots: void installPrintingOverride(const ExactCard &originalCard, const ExactCard &overrideCard); void installPrintingOverrideOnLoad(const ExactCard &originalCard, const ExactCard &overrideCard); +signals: + /** @brief Emitted after the worker has finished clearing the network and redirect caches. */ + void networkCacheCleared(); + private slots: /** * @brief Triggered when the user changes the picture download settings. 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 c82fca403..12165e8f3 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 @@ -101,7 +101,7 @@ QImage CardPictureLoaderLocal::tryLoadCardImageFromDisk(const QString &setName, for (const QString &path : candidatePaths) { QFileInfo fileInfo(path); QDir dir = fileInfo.dir(); - QString baseName = fileInfo.fileName(); + QString baseName = fileInfo.completeBaseName(); if (!dir.exists()) { 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..0fd6a42d5 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 @@ -16,13 +16,16 @@ #include #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 MAX_REQUESTS_PER_SEC = DownloadSettings::DEFAULT_HOST_REQUEST_LIMIT; +static constexpr int MIN_HOST_QUOTA = DownloadSettings::MIN_HOST_REQUEST_LIMIT; +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 +static constexpr int THREAD_SHUTDOWN_WAIT_MS = 5000; ///< Bounded wait for the worker thread to stop at exit CardPictureLoaderWorker::CardPictureLoaderWorker() : QObject(nullptr), picDownload(SettingsCache::instance().downloads().getPicDownload()), - requestQuota(MAX_REQUESTS_PER_SEC) + hostRequestLimits(SettingsCache::instance().downloads().getHostRequestLimits()) { networkManager = new QNetworkAccessManager(this); // We need a timeout to ensure requests don't hang indefinitely in case of @@ -57,20 +60,59 @@ CardPictureLoaderWorker::CardPictureLoaderWorker() localLoader = new CardPictureLoaderLocal(this); pictureLoaderThread = new QThread; + // The worker object frees itself once its thread finishes, so no event loop is left + // running and the QThread is never destroyed while still executing. + connect(pictureLoaderThread, &QThread::finished, this, &QObject::deleteLater); 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); + + connect(&SettingsCache::instance().downloads(), &DownloadSettings::hostRequestLimitsChanged, this, + [this] { hostRequestLimits = SettingsCache::instance().downloads().getHostRequestLimits(); }); } CardPictureLoaderWorker::~CardPictureLoaderWorker() { saveRedirectCache(); - pictureLoaderThread->deleteLater(); +} + +bool CardPictureLoaderWorker::shutdownThread() +{ + // The finished() -> deleteLater chain (wired in the constructor) frees this worker as soon as + // its event loop exits, so nothing - not even a member read - may run once wait() returns. + // QThread::quit() and QThread::wait() are thread-safe and may be called from the owning thread. + QThread *thread = pictureLoaderThread; + if (!thread) { + return true; + } + thread->quit(); + // Only an unbounded wait() would guarantee the thread stops, but this runs from a function-local + // static destructor after main() has returned, with no UI left to interrupt a worker stuck in a + // slow slot or on a stalled filesystem. Bound the wait and leave such a thread to the OS rather + // than hanging the process forever. + if (!thread->wait(THREAD_SHUTDOWN_WAIT_MS)) { + qCWarning(CardPictureLoaderWorkerLog) << "Picture loader worker thread did not stop within" + << THREAD_SHUTDOWN_WAIT_MS << "ms; leaving it to be torn down by the OS"; + return false; + } + return true; +} + +QThread *CardPictureLoaderWorker::workerThread() const +{ + return pictureLoaderThread; } void CardPictureLoaderWorker::queueRequest(const QUrl &url, CardPictureLoaderWorkerWork *worker) @@ -99,7 +141,17 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture // Check for cached redirects QUrl cachedRedirect = getCachedRedirect(url); if (!cachedRedirect.isEmpty()) { + // The status bar still needs to reclaim this URL's widget even when we hand the request back + // for a deferred retry instead of dispatching it onto the network. emit imageRequestSucceeded(url); + // 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; + } return makeRequest(cachedRedirect, worker); } @@ -110,67 +162,258 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture // Cached entries are served straight from the disk cache even when picture downloads are // enabled: re-fetching an already-cached image would burn the rate limit for nothing. Only a // genuine cache miss goes to the network, and only when downloads are enabled. - bool useNetworkCache = static_cast( - SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) == - CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE && - (cache->metaData(url).isValid() || !picDownload); + bool useNetworkCache = !requestTouchesNetwork(url); req.setAttribute(QNetworkRequest::CacheLoadControlAttribute, useNetworkCache ? QNetworkRequest::AlwaysCache : QNetworkRequest::AlwaysNetwork); QNetworkReply *reply = networkManager->get(req); - // Connect reply handling - connect(reply, &QNetworkReply::finished, worker, [reply, worker] { worker->handleNetworkReply(reply); }); + // Track in-flight replies per host so the unlocked fast path can bound how many requests it + // issues at once, instead of creating replies that time out before Qt opens a connection. + const QString host = url.host(); + hostInFlight.insert(host, hostInFlight.value(host) + 1); + + // Release the in-flight slot when the reply is destroyed, not when it emits `finished`, and use + // the worker (not the work object) as the context object: a reply can go away without ever + // finishing (aborted, or a work object deleted while a reply is still pending), and a connection + // bound to that work object's lifetime would then never run, permanently shrinking the fast + // path's concurrency until it wedges. This way the slot is released exactly once. + connect(reply, &QObject::destroyed, this, + [this, host] { hostInFlight.insert(host, qMax(0, hostInFlight.value(host) - 1)); }); + + // Connect reply handling; the work object is the context so its handler dies with it. + connect(reply, &QNetworkReply::finished, worker, [worker, reply] { worker->handleNetworkReply(reply); }); return reply; } void CardPictureLoaderWorker::resetRequestQuota() { - requestQuota = MAX_REQUESTS_PER_SEC; - QDateTime now = QDateTime::currentDateTime(); - for (auto it = hostRequestQuota.begin(); it != hostRequestQuota.end(); ++it) { + for (auto it = hostRequestQuota.begin(); it != hostRequestQuota.end();) { if (!hostLast429.contains(it.key()) || now.msecsTo(hostLast429.value(it.key())) < -QUOTA_RECOVER_MS) { - it.value() = qMin(MAX_REQUESTS_PER_SEC, it.value() + 1); + if (hostAllowanceCeiling(it.key()) == DownloadSettings::UNLIMITED_HOST_QUOTA) { + // A developer-unlocked host that fell back after a 429 recovers towards the default + // allowance; once it gets there it becomes unlocked (fast-path) again. + if (it.value() + 1 >= DownloadSettings::DEFAULT_HOST_REQUEST_LIMIT) { + it = hostRequestQuota.erase(it); + continue; + } + it.value() += 1; + } else { + // Recover towards the host's effective allowance ceiling, which may be + // lowered by the user's per-host request limits. + it.value() = qMin(hostAllowanceCeiling(it.key()), it.value() + 1); + } } + ++it; } - 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; + } + + QDateTime now = QDateTime::currentDateTime(); + bool dispatched = false; + // Set while an unlocked host still has queued work blocked only by the in-flight cap; the + // timer must keep running so it gets another try as soon as a slot frees. A host blocked by + // its 429 backoff instead waits for the next quota-reset tick to restart the dispatcher. + bool unlockedCapped = false; + + // Unlocked hosts (developer cap UNLIMITED_HOST_QUOTA) skip the pacing and the per-host + // allowance: dispatch their queued requests back-to-back, bounded by their 429 backoff and the + // per-host in-flight cap so a large burst can't queue replies that time out before Qt opens a + // connection for them. + for (int i = 0; i < requestLoadQueue.size();) { + const auto &request = requestLoadQueue.at(i); + // Dispatch decisions must key on the host the request will actually go to, not the URL that + // merely redirects to it: a redirect learned after this URL was queued would otherwise + // bypass the in-flight cap and drain the whole queue onto the target host unchecked. + const QUrl resolvedUrl = resolveCachedRedirect(request.first); + const QString host = resolvedUrl.host(); + if (isUnlockedHost(host)) { + if (CardPictureLoaderWorkerWork::rateLimiter().isRateLimited(host, now)) { + ++i; + continue; + } + if (hostInFlight.value(host) < MAX_IN_FLIGHT_PER_HOST) { + makeRequest(resolvedUrl, request.second); + requestLoadQueue.removeAt(i); + dispatched = true; + continue; + } + unlockedCapped = true; + } + ++i; + } + + if (requestLoadQueue.isEmpty()) { + dispatchTimer.stop(); + requestTimer.stop(); + return; + } + + if (processSingleRequest()) { + dispatched = true; + } + + // Keep the timer running while there is progress to make or unlocked work waiting on a free + // in-flight slot; otherwise no host has allowance left this second, so wait for the quota reset. + if (!dispatched && !unlockedCapped) { + 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); + // Copy the entry: takeAt(i) below erases within the list this reference points into. + const auto request = requestLoadQueue.at(i); + // Resolve cached redirects so the rate-limit and allowance arithmetic keys on the host the + // request will actually hit (see resolveCachedRedirect). + const QUrl resolvedUrl = resolveCachedRedirect(request.first); + const QString host = resolvedUrl.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. Only applies to + // requests that will actually touch the network: one that will be served from the disk + // cache costs nothing and shouldn't wait out the 429. + if (requestTouchesNetwork(request.first) && + CardPictureLoaderWorkerWork::rateLimiter().isRateLimited(host, now)) { + // The queued URL is usually a cached-redirect target whose host differs from + // cardToDownload.getCurrentUrl(), so scheduleDeferredRetry() (which waits out the + // blocked host's deadline) is used instead of startNextPicDownload() looping on the + // original host. + auto entry = requestLoadQueue.takeAt(i); + if (host != entry.first.host()) { + // A cached redirect target is what is blocked, which the work object would not + // discover from its own URL; wait out that specific host (with jitter) instead. + entry.second->scheduleDeferredRetry(host); + } else { + entry.second->startNextPicDownload(); + } + return true; + } + // Unlocked hosts are handled by dispatchQueuedRequest's fast path, bounded by the in-flight + // cap; they must not fall through to the per-host allowance arithmetic below. + if (isUnlockedHost(host)) { + continue; + } + int ceiling = hostAllowanceCeiling(host); + if (ceiling == DownloadSettings::UNLIMITED_HOST_QUOTA) { + // A 429 dropped this unlocked host out of the fast path and installed a concrete + // allowance; pace it against that allowance until the recovery loop unlocks it again. + ceiling = hostRequestQuota.value(host, DownloadSettings::DEFAULT_HOST_REQUEST_LIMIT); + } + // Seed the allowance lazily so a host that enters the queue mid-second gets its reduced + // per-host allowance, clamped against the ceiling so a lowered user cap applies from this + // second onward. + if (!hostQuotaRemaining.contains(host)) { + hostQuotaRemaining.insert(host, qMin(ceiling, hostRequestQuota.value(host, ceiling))); + } + 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(resolvedUrl, entry.second); return true; } } return false; } +bool CardPictureLoaderWorker::requestTouchesNetwork(const QUrl &url) const +{ + bool useNetworkCache = static_cast( + SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) == + CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE && + (cache->metaData(url).isValid() || !picDownload); + return !useNetworkCache; +} + +int CardPictureLoaderWorker::hostAllowanceCeiling(const QString &host) const +{ + const int devCap = DownloadSettings::getDeveloperHostCaps().value(host, MAX_REQUESTS_PER_SEC); + if (devCap == DownloadSettings::UNLIMITED_HOST_QUOTA && !hostRequestLimits.contains(host)) { + return DownloadSettings::UNLIMITED_HOST_QUOTA; + } + const int requested = hostRequestLimits.value(host, devCap); + return SettingsCache::instance().downloads().clampHostRequestLimit(host, requested); +} + +bool CardPictureLoaderWorker::isUnlockedHost(const QString &host) const +{ + return hostAllowanceCeiling(host) == DownloadSettings::UNLIMITED_HOST_QUOTA && !hostRequestQuota.contains(host); +} + void CardPictureLoaderWorker::onHostRateLimited(const QString &host) { - hostRequestQuota.insert(host, qMax(MIN_HOST_QUOTA, hostRequestQuota.value(host, MAX_REQUESTS_PER_SEC) / 2)); + const int ceiling = hostAllowanceCeiling(host); + // An unlocked host has no per-host allowance to halve. Install one instead so it drops out of + // the unlocked fast path and is paced like a throttled host; the recovery loop in + // resetRequestQuota() then walks it back up and unlocks it again. + const int base = + ceiling == DownloadSettings::UNLIMITED_HOST_QUOTA ? DownloadSettings::DEFAULT_HOST_REQUEST_LIMIT : ceiling; + hostRequestQuota.insert(host, qMax(MIN_HOST_QUOTA, hostRequestQuota.value(host, base) / 2)); hostLast429.insert(host, QDateTime::currentDateTime()); } @@ -228,6 +471,22 @@ QUrl CardPictureLoaderWorker::getCachedRedirect(const QUrl &originalUrl) const return {}; } +QUrl CardPictureLoaderWorker::resolveCachedRedirect(const QUrl &url) const +{ + // Follow the whole cached-redirect chain so dispatch keys on the host that is really hit. The + // depth bound keeps a corrupt or self-referencing cache entry from spinning us forever. + QUrl resolved = url; + int depth = 0; + while (depth++ < MAX_REDIRECT_CHAIN_DEPTH) { + QUrl target = getCachedRedirect(resolved); + if (target.isEmpty() || target == resolved) { + break; + } + resolved = target; + } + return resolved; +} + void CardPictureLoaderWorker::loadRedirectCache() { QSettings settings(cacheFilePath, QSettings::IniFormat); @@ -280,4 +539,5 @@ void CardPictureLoaderWorker::clearNetworkCache() { networkManager->cache()->clear(); redirectCache.clear(); + emit networkCacheCleared(); } 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..8b136f309 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 @@ -74,10 +74,37 @@ public: */ void onHostRateLimited(const QString &host); - /** @brief Clears the network cache and redirect cache. */ - void clearNetworkCache(); + /** + * @brief Stops the worker thread and reports whether it stopped. + * + * Called from the owning thread (CardPictureLoader) on its way out. QThread::quit() posts an + * exit request to the worker's event loop and QThread::wait() blocks (bounded) until the loop + * has returned and the thread finished. Only QThread members are touched here, so this method + * is safe to call from the owning thread. The worker object itself is freed by the finished() + * -> deleteLater chain (see the constructor); the QThread object is deleted afterwards by the + * owner (CardPictureLoader::~CardPictureLoader), not by this method. + * + * @return true if the thread stopped within the timeout, false if it is still running (in + * which case the owner must not delete the QThread). + */ + [[nodiscard]] bool shutdownThread(); + + /** + * @brief Returns the worker's QThread. + * @return The worker thread + * + * Only meaningful while the worker object is alive; capture it before calling shutdownThread(). + */ + QThread *workerThread() const; public slots: + /** + * @brief Clears the network cache and redirect cache. + * + * Runs on the worker thread; invoke it via a queued call when coming from another thread, + * since both caches are owned by the worker thread. + */ + void clearNetworkCache(); /** * @brief Makes a network request for the given URL using the specified worker. * @param url URL to load @@ -86,9 +113,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,18 +148,56 @@ 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 hostRequestLimits; ///< User-set per-host request allowances QHash hostQuotaRemaining; ///< Per-host allowance left in the current second QHash hostLast429; ///< When each host was last rate limited + QHash hostInFlight; ///< Network replies currently in flight, per host + + /** @brief Maximum concurrent in-flight network replies per host. */ + static constexpr int MAX_IN_FLIGHT_PER_HOST = 6; + + /** @brief Bound on how many cached-redirect hops dispatch resolution will follow. */ + static constexpr int MAX_REDIRECT_CHAIN_DEPTH = 10; CardPictureLoaderLocal *localLoader; ///< Loader for local images QSet currentlyLoading; ///< Deduplication: contains pixmapCacheKey currently being loaded + /** + * @brief Effective per-host allowance ceiling for a host. + * @param host The host to look up + * @return The allowance ceiling in requests/second, or DownloadSettings::UNLIMITED_HOST_QUOTA + * when the developer unlocked the host and no user limit is set for it. + */ + [[nodiscard]] int hostAllowanceCeiling(const QString &host) const; + + /** + * @brief Whether a host may skip dispatch pacing and per-host allowance entirely. + * + * A host is unlocked while it has no user limit and no reduced allowance installed by a 429. + * A 429 drops it out of the fast path until resetRequestQuota() walks the allowance back up. + */ + [[nodiscard]] bool isUnlockedHost(const QString &host) const; + /** @brief Returns cached redirect URL for the given original URL, if available. */ [[nodiscard]] QUrl getCachedRedirect(const QUrl &originalUrl) const; + /** @brief Whether a request for this URL would actually touch the network, rather than being served from the disk + * cache. */ + [[nodiscard]] bool requestTouchesNetwork(const QUrl &url) const; + + /** + * @brief Follows the cached-redirect chain to the URL that will actually be requested. + * @param url The URL to resolve + * @return The final URL after chasing cached redirects, or @p url itself if none lead elsewhere + * + * Dispatch decisions (unlocked-host fast path, 429 backoff, in-flight cap) must key on the host + * a request really goes to, not the URL that merely redirects to it. + */ + [[nodiscard]] QUrl resolveCachedRedirect(const QUrl &url) const; + /** @brief Loads redirect cache from disk. */ void loadRedirectCache(); @@ -139,6 +207,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(); @@ -158,6 +229,9 @@ signals: /** @brief Emitted when a network request successfully completes. */ void imageRequestSucceeded(const QUrl &url); + + /** @brief Emitted after clearNetworkCache() has finished clearing both caches. */ + void networkCacheCleared(); }; #endif // PICTURE_LOADER_WORKER_H 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..19c6be469 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,8 +22,13 @@ static const QStringList MD5_BLACKLIST = { "fbc7d763c08771c260b39e2115414eeb" // Current card back hash }; -CardPictureLoaderWorkerWork::CardPictureLoaderWorkerWork(const CardPictureLoaderWorker *worker, const ExactCard &toLoad) - : QObject(nullptr), cardToDownload(CardPictureToLoad(toLoad)), +const ServerRateLimiter &CardPictureLoaderWorkerWork::rateLimiter() +{ + return s_rateLimiter; +} + +CardPictureLoaderWorkerWork::CardPictureLoaderWorkerWork(CardPictureLoaderWorker *worker, const ExactCard &toLoad) + : QObject(worker), cardToDownload(CardPictureToLoad(toLoad)), picDownload(SettingsCache::instance().downloads().getPicDownload()) { // Hook up signals to the orchestrator @@ -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..84a7dafdb 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 @@ -36,13 +37,36 @@ class CardPictureLoaderWorkerWork : public QObject public: /** * @brief Constructs a worker for downloading a specific card image. - * @param worker The orchestrating CardPictureLoaderWorker + * @param worker The orchestrating CardPictureLoaderWorker; the work object becomes its child so + * it is destroyed with the worker even if it never reaches concludeImageLoad(). * @param toLoad The ExactCard to download */ - explicit CardPictureLoaderWorkerWork(const CardPictureLoaderWorker *worker, const ExactCard &toLoad); + explicit CardPictureLoaderWorkerWork(CardPictureLoaderWorker *worker, const ExactCard &toLoad); 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. + */ + 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 +79,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 +103,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 7cb502e92..4f933207e 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,8 +94,7 @@ void CardPictureToLoad::populateSetUrls() } } - const QStringList orderedTemplates = urlTemplates; - for (const QString &urlTemplate : orderedTemplates) { + for (const QString &urlTemplate : urlTemplates) { QString transformedUrl = transformUrl(urlTemplate); if (!transformedUrl.isEmpty()) { 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/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index d86ed77f9..d93dfcf62 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -95,9 +95,11 @@ struct PaletteColorInfo static QString usableDefaultStyle(const QString &style) { - // The Windows 11 native style is broken: when the OS default - // ("System" theme selection) would use it, fall back to the Vista style. - // Explicitly choosing "windows11" in a theme is still honored. + // The Windows 11 native style is broken: dragging cards across zones can + // shrink the board to a tiny grey window that is unfixable without + // rejoining. It is never usable, so guard against it no matter how it was + // requested (OS default or an explicit "windows11" theme choice) and fall + // back to the Vista style. return style.compare("windows11", Qt::CaseInsensitive) == 0 ? QStringLiteral("windowsvista") : style; } @@ -109,7 +111,10 @@ ThemeManager::ThemeManager(QObject *parent) : QObject(parent) ensureThemeDirectoryExists(); #if (QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)) connect(QGuiApplication::styleHints(), &QStyleHints::colorSchemeChanged, this, [this] { - defaultPalette = qApp->palette(); + // Reload so scheme-qualified assets and palettes follow the OS, but do + // NOT recapture defaultPalette: qApp->palette() already carries the + // currently-applied theme palette at this point, so recapturing it + // would contaminate the base for every later theme switch. themeChangedSlot(); }); #endif @@ -407,6 +412,10 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName, } } + // The Windows 11 style is broken even when selected explicitly in a theme, + // so sanitize the resolved name here rather than trusting the theme config. + styleName = usableDefaultStyle(styleName); + QStyle *style = QStyleFactory::create(styleName); if (!style) { style = QStyleFactory::create(usableDefaultStyle(defaultStyleName)); 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_group_display_widgets/card_group_display_widget.cpp b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp index bfbdd7e42..8df3700c3 100644 --- a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp @@ -1,11 +1,13 @@ #include "card_group_display_widget.h" +#include "../../../../client/settings/cache_settings.h" #include "../card_info_picture_with_text_overlay_widget.h" #include #include #include #include +#include CardGroupDisplayWidget::CardGroupDisplayWidget(QWidget *parent, DeckListModel *_deckListModel, @@ -30,6 +32,7 @@ CardGroupDisplayWidget::CardGroupDisplayWidget(QWidget *parent, layout->addWidget(banner); CardGroupDisplayWidget::updateCardDisplays(); + updateCardCount(); connect(deckListModel, &QAbstractItemModel::rowsInserted, this, &CardGroupDisplayWidget::onCardAddition); if (selectionModel) { @@ -38,6 +41,13 @@ CardGroupDisplayWidget::CardGroupDisplayWidget(QWidget *parent, } connect(deckListModel, &QAbstractItemModel::rowsRemoved, this, &CardGroupDisplayWidget::onCardRemoval); connect(deckListModel, &QAbstractItemModel::dataChanged, this, &CardGroupDisplayWidget::onDataChanged); + connect(deckListModel, &QAbstractItemModel::rowsInserted, this, &CardGroupDisplayWidget::updateCardCount); + connect(deckListModel, &QAbstractItemModel::rowsRemoved, this, &CardGroupDisplayWidget::updateCardCount); + connect(deckListModel, &QAbstractItemModel::dataChanged, this, &CardGroupDisplayWidget::updateCardCount); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::visualDeckEditorShowCardCountsChanged, + this, &CardGroupDisplayWidget::updateCardCount); + + cardSizeWidget->enableCtrlScrollResize(this); } // Just here so it can get overwritten in subclasses. @@ -348,4 +358,22 @@ void CardGroupDisplayWidget::onActiveSortCriteriaChanged(QStringList _activeSort clearAllDisplayWidgets(); updateCardDisplays(); -} \ No newline at end of file +} + +void CardGroupDisplayWidget::updateCardCount() +{ + if (!banner || !deckListModel || !trackedIndex.isValid()) { + return; + } + + QString text = cardGroupCategory; + if (SettingsCache::instance().cardsDisplay().getVisualDeckEditorShowCardCounts()) { + int total = 0; + for (int i = 0; i < deckListModel->rowCount(trackedIndex); ++i) { + total += + deckListModel->index(i, DeckListModelColumns::CARD_AMOUNT, trackedIndex).data(Qt::EditRole).toInt(); + } + text += QStringLiteral(" (%1)").arg(total); + } + banner->setText(text); +} diff --git a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.h b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.h index a3bf70981..e5f7c7ee2 100644 --- a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.h @@ -55,6 +55,7 @@ public slots: virtual void onCardRemoval(const QModelIndex &parent, int first, int last); void onDataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector &roles); void onActiveSortCriteriaChanged(QStringList activeSortCriteria); + void updateCardCount(); void resizeEvent(QResizeEvent *event) override; signals: 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_size_widget.cpp b/cockatrice/src/interface/widgets/cards/card_size_widget.cpp index 14e45743d..9cdfd4bce 100644 --- a/cockatrice/src/interface/widgets/cards/card_size_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_size_widget.cpp @@ -3,6 +3,9 @@ #include "../printing_selector/printing_selector.h" #include "../visual_deck_storage/visual_deck_storage_widget.h" +#include +#include + /** * @class CardSizeWidget * @brief A widget for adjusting card sizes using a slider. @@ -58,3 +61,39 @@ QSlider *CardSizeWidget::getSlider() const { return cardSizeSlider; } + +void CardSizeWidget::enableCtrlScrollResize(QWidget *host) +{ + host->installEventFilter(this); + + const auto scrollAreas = host->findChildren(); + for (QScrollArea *scrollArea : scrollAreas) { + if (QWidget *content = scrollArea->widget()) { + content->installEventFilter(this); + } + } +} + +bool CardSizeWidget::eventFilter(QObject *watched, QEvent *event) +{ + if (event->type() == QEvent::Wheel && adjustSliderForWheel(static_cast(event))) { + return true; + } + return QWidget::eventFilter(watched, event); +} + +bool CardSizeWidget::adjustSliderForWheel(QWheelEvent *event) +{ + if (!(event->modifiers() & Qt::ControlModifier)) { + return false; + } + + const int angleDelta = event->angleDelta().y(); + if (angleDelta == 0) { + return false; + } + + const int wheelSteps = angleDelta > 0 ? qMax(1, angleDelta / 120) : qMin(-1, angleDelta / 120); + cardSizeSlider->setValue(cardSizeSlider->value() + wheelSteps * CARD_SIZE_WHEEL_STEP); + return true; +} diff --git a/cockatrice/src/interface/widgets/cards/card_size_widget.h b/cockatrice/src/interface/widgets/cards/card_size_widget.h index 9f4c165fd..46883e792 100644 --- a/cockatrice/src/interface/widgets/cards/card_size_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_size_widget.h @@ -17,6 +17,8 @@ #include #include +class QWheelEvent; + class CardSizeWidget : public QWidget { Q_OBJECT @@ -25,6 +27,18 @@ public: explicit CardSizeWidget(QWidget *parent, FlowWidget *flowWidget = nullptr, int defaultValue = 100); [[nodiscard]] QSlider *getSlider() const; + /** + * @brief Resizes the cards when the user Ctrl + scrolls over @p host or any of its descendants. + * + * Installs this widget as an event filter on the given host widget. If the host contains a + * scroll area the filter is also installed on the scroll area's content widget, so the resize + * intercepts the wheel event before the scroll area would scroll the view. + */ + void enableCtrlScrollResize(QWidget *host); + +protected: + bool eventFilter(QObject *watched, QEvent *event) override; + private slots: void updateCardSizeSetting(int newValue); @@ -44,6 +58,10 @@ private: QSlider *cardSizeSlider; QTimer debounceTimer; // Debounce timer int pendingValue; // Stores the latest slider value + + bool adjustSliderForWheel(QWheelEvent *event); + + static constexpr int CARD_SIZE_WHEEL_STEP = 10; ///< Slider step applied per Ctrl + scroll notch. }; #endif // CARD_SIZE_WIDGET_H diff --git a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp index b00d9db1e..a4b4eaf54 100644 --- a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp @@ -1,8 +1,10 @@ #include "deck_card_zone_display_widget.h" +#include "../../../client/settings/cache_settings.h" #include "card_group_display_widgets/flat_card_group_display_widget.h" #include "card_group_display_widgets/overlapped_card_group_display_widget.h" #include "libcockatrice/card/database/card_database_manager.h" +#include "libcockatrice/settings/cards_display_settings.h" #include #include @@ -39,6 +41,7 @@ DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent, banner->setBuddy(cardGroupContainer); displayCards(); + updateZoneCardCount(); connect(deckListModel, &QAbstractItemModel::rowsInserted, this, &DeckCardZoneDisplayWidget::onCategoryAddition); if (selectionModel) { @@ -46,6 +49,11 @@ DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent, &DeckCardZoneDisplayWidget::onSelectionChanged); } connect(deckListModel, &QAbstractItemModel::rowsRemoved, this, &DeckCardZoneDisplayWidget::onCategoryRemoval); + connect(deckListModel, &QAbstractItemModel::rowsInserted, this, &DeckCardZoneDisplayWidget::updateZoneCardCount); + connect(deckListModel, &QAbstractItemModel::rowsRemoved, this, &DeckCardZoneDisplayWidget::updateZoneCardCount); + connect(deckListModel, &QAbstractItemModel::dataChanged, this, &DeckCardZoneDisplayWidget::updateZoneCardCount); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::visualDeckEditorShowCardCountsChanged, + this, &DeckCardZoneDisplayWidget::updateZoneCardCount); } // ===================================================================================================================== @@ -245,3 +253,21 @@ QList DeckCardZoneDisplayWidget::getGroupCriteriaValueList() return groupCriteriaValues; } + +void DeckCardZoneDisplayWidget::updateZoneCardCount() +{ + if (!banner || !deckListModel) { + return; + } + + QString text = zoneName; + if (SettingsCache::instance().cardsDisplay().getVisualDeckEditorShowCardCounts()) { + int total = 0; + const auto cardNodes = deckListModel->getCardNodesForZone(zoneName); + for (const auto *node : cardNodes) { + total += node->getNumber(); + } + text += QStringLiteral(" (%1)").arg(total); + } + banner->setText(text); +} diff --git a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.h b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.h index 53f3fa7cf..f25108834 100644 --- a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.h +++ b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.h @@ -52,6 +52,7 @@ public slots: QList getGroupCriteriaValueList(); void onCategoryAddition(const QModelIndex &parent, int first, int last); void onCategoryRemoval(const QModelIndex &parent, int first, int last); + void updateZoneCardCount(); signals: void cardClicked(QMouseEvent *event, const ExactCard &card, const QString &zoneName); 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_database_display_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp index c8bdacf0d..a83e25f5f 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp @@ -11,8 +11,10 @@ #include #include #include +#include #include #include +#include DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent, CardDatabaseModel *databaseModel) : QWidget(parent) @@ -40,6 +42,17 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent databaseDisplayModel->setSourceModel(databaseModel); databaseDisplayModel->setFilterKeyColumn(0); + const auto applyCardSearchLanguage = [this]() { + const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay(); + databaseDisplayModel->setSearchLanguage(CardSearchLanguage{ + cardsDisplay.getCardLang(), static_cast(cardsDisplay.getCardSearchLanguage())}); + }; + applyCardSearchLanguage(); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, + applyCardSearchLanguage); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardSearchLanguageChanged, this, + applyCardSearchLanguage); + databaseView = new CardDatabaseView(this, databaseDisplayModel); databaseView->setObjectName("databaseView"); databaseView->setFocusProxy(searchEdit); 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_report_user.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp index 9519846e2..7b855af96 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp @@ -15,6 +15,7 @@ #include #include #include +#include DlgReportUser::DlgReportUser(AbstractClient *_client, const QString &_reportedUser, @@ -56,22 +57,14 @@ DlgReportUser::DlgReportUser(AbstractClient *_client, auto *categoryGrid = new QGridLayout(categoryGroup); categoryBox = new QComboBox; - categoryBox->addItem(tr("Cheating / Unsporting behavior"), "cheating"); - categoryBox->setItemData(categoryBox->count() - 1, - tr("Using external tools, card marked manipulation, or exploiting game bugs"), - Qt::ToolTipRole); - categoryBox->addItem(tr("Harassment / Abuse"), "harassment"); - categoryBox->setItemData(categoryBox->count() - 1, tr("Threatening, bullying, or persistent unwanted contact"), - Qt::ToolTipRole); - categoryBox->addItem(tr("Hate speech"), "hate_speech"); - categoryBox->setItemData(categoryBox->count() - 1, - tr("Discriminatory language targeting race, gender, religion, etc."), Qt::ToolTipRole); - categoryBox->addItem(tr("Spam"), "spam"); - categoryBox->setItemData(categoryBox->count() - 1, tr("Repeated unwanted messages or advertisements"), - Qt::ToolTipRole); - categoryBox->addItem(tr("Other"), "other"); - categoryBox->setItemData(categoryBox->count() - 1, tr("Any behavior not covered by the above categories"), - Qt::ToolTipRole); + for (const QString &key : ReportCategories::keys()) { + const QString label = categoryLabel(key); + if (label.isEmpty()) { + continue; // skip keys without a dialog label + } + categoryBox->addItem(label, key); + categoryBox->setItemData(categoryBox->count() - 1, categoryToolTip(key), Qt::ToolTipRole); + } categoryGrid->addWidget(new QLabel(tr("Category:")), 0, 0); categoryGrid->addWidget(categoryBox, 0, 1); @@ -189,3 +182,55 @@ void DlgReportUser::reportResponse(const Response &response) QMessageBox::warning(this, tr("Submission Failed"), tr("Failed to submit report. Please try again.")); } } + +QString DlgReportUser::categoryLabel(const QString &key) +{ + if (key == QLatin1String("cheating")) { + return tr("Cheating / Unsporting behavior"); + } + if (key == QLatin1String("bug_abuse")) { + return tr("Bug Abuse"); + } + if (key == QLatin1String("harassment")) { + return tr("Harassment / Abuse"); + } + if (key == QLatin1String("verbal_abuse")) { + return tr("Verbal Abuse"); + } + if (key == QLatin1String("hate_speech")) { + return tr("Hate speech"); + } + if (key == QLatin1String("spam")) { + return tr("Spam"); + } + if (key == QLatin1String("other")) { + return tr("Other"); + } + return QString(); +} + +QString DlgReportUser::categoryToolTip(const QString &key) +{ + if (key == QLatin1String("cheating")) { + return tr("Using external tools, cheat programs, or exploiting game bugs"); + } + if (key == QLatin1String("bug_abuse")) { + return tr("Exploiting a bug or glitch to gain an unfair advantage"); + } + if (key == QLatin1String("harassment")) { + return tr("Threatening, bullying, or persistent unwanted contact"); + } + if (key == QLatin1String("verbal_abuse")) { + return tr("Abusive or offensive language directed at another player"); + } + if (key == QLatin1String("hate_speech")) { + return tr("Discriminatory language targeting race, gender, religion, etc."); + } + if (key == QLatin1String("spam")) { + return tr("Repeated unwanted messages or advertisements"); + } + if (key == QLatin1String("other")) { + return tr("Any behavior not covered by the above categories"); + } + return QString(); +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_report_user.h b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.h index c59fc5084..f8d5938ce 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_report_user.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.h @@ -26,6 +26,9 @@ private slots: void reportResponse(const Response &response); private: + static QString categoryLabel(const QString &key); + static QString categoryToolTip(const QString &key); + AbstractClient *client; QString reportedUser; int gameId; 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/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/printing_selector/printing_selector.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp index 0c3bbbe00..f16b4885c 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp @@ -58,6 +58,7 @@ PrintingSelector::PrintingSelector(QWidget *parent, AbstractTabDeckEditor *_deck SettingsCache::instance().cardsDisplay().getPrintingSelectorCardSize()); connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::setPrintingSelectorCardSize); + cardSizeWidget->enableCtrlScrollResize(flowWidget); displayOptionsWidget->addSettingsWidget(sortToolBar); displayOptionsWidget->addSettingsWidget(navigationCheckBox); diff --git a/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp b/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp index bebc2e3c4..0a287c9ce 100644 --- a/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp +++ b/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp @@ -340,16 +340,27 @@ void ChatView::appendMessage(QString message, pos.relativePosition = match.captured(0).length(); // set message start auto before = match.captured(1); auto sentBy = match.captured(2); + + // The user level is not carried in the room chat history, so history + // entries used to render as fixed-level user tags. Resolve online users + // against the user list to turn their history entries into full user + // tags (correct level, name casing and moderation context menu). + QString displayName = sentBy; + // Offline users have no known level; render them as zero-level tags. + QString levelMarker = "0"; + if (const ServerInfo_User *onlineUser = userListProxy->getOnlineUser(sentBy)) { + displayName = QString::fromStdString(onlineUser->name()); + levelMarker = QString::number(onlineUser->user_level()); + } + cursor.insertText(before); // add message timestamp QTextCharFormat senderFormat(defaultFormat); senderFormat.setAnchor(true); - // this underscore is important, it is used to add the user level, but in this case the level is - // unknown, if the name contains an underscore it would split up the name - senderFormat.setAnchorHref("user://_" + sentBy); + senderFormat.setAnchorHref("user://" + levelMarker + "_" + displayName); cursor.setCharFormat(senderFormat); - cursor.insertText(sentBy); // add username with href so it shows the menu - userMessagePositions[sentBy].append(pos); // save message position - message.remove(0, pos.relativePosition - 2); // do not remove semicolon + cursor.insertText(displayName); // add username with href so it shows the menu + userMessagePositions[displayName].append(pos); // save message position + message.remove(0, pos.relativePosition - 2); // do not remove semicolon } } else { //! \todo Remove hardcoded color. 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/server/user/user_list_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp index 1bb7c5288..d9e1e1e7e 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -218,9 +218,10 @@ void UserListTWI::setOnline(bool online) * 2) Admins, judge/vip/donator status ignored * 3) Moderators, judge/vip/donator status ignored * 4) Judges - * 5) VIPs - * 6) Donators - * 7) Everyone else + * 5) Developers + * 6) VIPs + * 7) Donators + * 8) Everyone else * @param other RHS to compare to * @return Left is less than the Right */ @@ -234,11 +235,10 @@ bool UserListTWI::operator<(const QTreeWidgetItem &other) const const auto &lhsUserLevelFlags = UserLevelFlags(data(0, Qt::UserRole).toInt()); const auto &rhsUserLevelFlags = UserLevelFlags(other.data(0, Qt::UserRole).toInt()); - // Admins, Developers & Mods need no additional comparison checks, just to see if they're an admin, a developer + // Admins & Moderators need no additional comparison checks, just to see if they're an admin // or a moderator static const QList userLevelWithNoOtherPrefOrder = { - ServerInfo_User_UserLevelFlag_IsAdmin, ServerInfo_User_UserLevelFlag_IsDeveloper, - ServerInfo_User_UserLevelFlag_IsModerator}; + ServerInfo_User_UserLevelFlag_IsAdmin, ServerInfo_User_UserLevelFlag_IsModerator}; for (const auto &userLevelEntry : userLevelWithNoOtherPrefOrder) { if (lhsUserLevelFlags.testFlag(userLevelEntry) && lhsUserLevelFlags.testFlag(userLevelEntry) == rhsUserLevelFlags.testFlag(userLevelEntry)) { @@ -249,10 +249,10 @@ bool UserListTWI::operator<(const QTreeWidgetItem &other) const } } - // Judges can be sorted by their additional ranks - static const QList userLevelOrder = {ServerInfo_User_UserLevelFlag_IsJudge, - ServerInfo_User_UserLevelFlag_IsRegistered, - ServerInfo_User_UserLevelFlag_IsUser}; + // Judges and developers can be sorted by their additional ranks + static const QList userLevelOrder = { + ServerInfo_User_UserLevelFlag_IsJudge, ServerInfo_User_UserLevelFlag_IsDeveloper, + ServerInfo_User_UserLevelFlag_IsRegistered, ServerInfo_User_UserLevelFlag_IsUser}; for (const auto &userLevelEntry : userLevelOrder) { if (lhsUserLevelFlags.testFlag(userLevelEntry) != rhsUserLevelFlags.testFlag(userLevelEntry)) { return lhsUserLevelFlags.testFlag(userLevelEntry) > rhsUserLevelFlags.testFlag(userLevelEntry); 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 f161f0f19..415eef6b9 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp @@ -62,6 +62,12 @@ AppearanceSettingsPage::AppearanceSettingsPage() // Qt widget style; "System" lets the application decide styleCombo.addItem(tr("System"), QStringLiteral("System")); for (const QString &key : QStyleFactory::keys()) { + // The Windows 11 native style is broken (board rendering glitches when + // moving cards), so never offer it; it is already sanitized at apply + // time in ThemeManager. + if (key.compare("windows11", Qt::CaseInsensitive) == 0) { + continue; + } styleCombo.addItem(key, key); } @@ -505,7 +511,7 @@ void AppearanceSettingsPage::retranslateUi() openThemeButton.setText(tr("Open themes folder")); schemeComboLabel.setText(tr("Active theme palette:")); styleComboLabel.setText(tr("Active theme style:")); - styleCombo.setToolTip(tr("Qt widget style saved to this theme (\"Default\" lets the application decide)")); + styleCombo.setToolTip(tr("Qt widget style saved to this theme (\"System\" lets the application decide)")); editPaletteButton.setText(tr("Edit theme palette")); homeTabGroupBox->setTitle(tr("Home tab settings")); diff --git a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp index f3eac05b8..bdbb38c95 100644 --- a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp @@ -10,7 +10,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -51,7 +53,9 @@ DeckEditorSettingsPage::DeckEditorSettingsPage() urlList->setDragDropMode(QAbstractItemView::InternalMove); connect(urlList->model(), &QAbstractItemModel::rowsMoved, this, &DeckEditorSettingsPage::urlListChanged); - urlList->addItems(SettingsCache::instance().downloads().getAllURLs()); + for (const QString &url : SettingsCache::instance().downloads().getAllURLs()) { + addUrlItem(url); + } aAdd = new QAction(this); aAdd->setIcon(themePixmap(QStringLiteral("icons/increment"))); @@ -65,11 +69,16 @@ DeckEditorSettingsPage::DeckEditorSettingsPage() aRemove->setIcon(themePixmap(QStringLiteral("icons/decrement"))); connect(aRemove, &QAction::triggered, this, &DeckEditorSettingsPage::actRemoveURL); + aRateLimit = new QAction(this); + aRateLimit->setIcon(themePixmap(QStringLiteral("icons/cogwheel"))); + connect(aRateLimit, &QAction::triggered, this, &DeckEditorSettingsPage::actAdjustRateLimit); + auto *urlToolBar = new QToolBar; urlToolBar->setOrientation(Qt::Vertical); urlToolBar->addAction(aAdd); urlToolBar->addAction(aRemove); urlToolBar->addAction(aEdit); + urlToolBar->addAction(aRateLimit); urlToolBar->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::MinimumExpanding); auto *urlListLayout = new QHBoxLayout; @@ -117,7 +126,9 @@ void DeckEditorSettingsPage::resetDownloadedURLsButtonClicked() { SettingsCache::instance().downloads().resetToDefaultURLs(); urlList->clear(); - urlList->addItems(SettingsCache::instance().downloads().getAllURLs()); + for (const QString &url : SettingsCache::instance().downloads().getAllURLs()) { + addUrlItem(url); + } QMessageBox::information(this, tr("Success"), tr("Download URLs have been reset.")); } @@ -126,7 +137,7 @@ void DeckEditorSettingsPage::actAddURL() bool ok; QString msg = QInputDialog::getText(this, tr("Add URL"), tr("URL:"), QLineEdit::Normal, QString(), &ok); if (ok) { - urlList->addItem(msg); + addUrlItem(msg); storeSettings(); } } @@ -141,12 +152,14 @@ void DeckEditorSettingsPage::actRemoveURL() void DeckEditorSettingsPage::actEditURL() { - if (urlList->currentItem()) { - QString oldText = urlList->currentItem()->text(); + QListWidgetItem *item = urlList->currentItem(); + if (item) { + const QString oldText = urlForItem(item); bool ok; QString msg = QInputDialog::getText(this, tr("Edit URL"), tr("URL:"), QLineEdit::Normal, oldText, &ok); if (ok) { - urlList->currentItem()->setText(msg); + item->setData(Qt::UserRole, msg); + item->setText(urlLabel(msg)); storeSettings(); } } @@ -158,10 +171,133 @@ void DeckEditorSettingsPage::storeSettings() QStringList downloadUrls; for (int i = 0; i < urlList->count(); i++) { - qInfo() << "Priority" << i << ":" << urlList->item(i)->text(); - downloadUrls << urlList->item(i)->text(); + const QString url = urlForItem(urlList->item(i)); + qInfo() << "Priority" << i << ":" << url; + downloadUrls << url; } SettingsCache::instance().downloads().setDownloadUrls(downloadUrls); + + // Drop per-host limits whose host is no longer referenced by any configured URL, so removing + // a URL doesn't leave a stale throttle behind that reactivates if the host is re-added. + QSet usedHosts; + for (const QString &url : downloadUrls) { + const QString host = QUrl(url).host(); + if (!host.isEmpty()) { + usedHosts.insert(host); + } + } + QHash limits = SettingsCache::instance().downloads().getHostRequestLimits(); + bool limitsChanged = false; + for (auto it = limits.begin(); it != limits.end();) { + // Prune only limits for hosts that are neither referenced by a configured URL nor carry a + // developer cap. Capped hosts are often redirect targets (e.g. api.scryfall.com redirects + // to cards.scryfall.io) that never appear in the URL list, yet they are exactly the hosts + // the throttle applies to, so dropping them when a URL is removed would silently re-enable + // free-running traffic to a rate-sensitive server. + if (!usedHosts.contains(it.key()) && !DownloadSettings::getDeveloperHostCaps().contains(it.key())) { + it = limits.erase(it); + limitsChanged = true; + } else { + ++it; + } + } + if (limitsChanged) { + SettingsCache::instance().downloads().setHostRequestLimits(limits); + } + + refreshUrlItems(); +} + +QListWidgetItem *DeckEditorSettingsPage::addUrlItem(const QString &url) +{ + auto *item = new QListWidgetItem(urlLabel(url)); + item->setData(Qt::UserRole, url); + urlList->addItem(item); + return item; +} + +QString DeckEditorSettingsPage::urlForItem(const QListWidgetItem *item) const +{ + return item->data(Qt::UserRole).toString(); +} + +QString DeckEditorSettingsPage::urlLabel(const QString &url) const +{ + const QString host = QUrl(url).host(); + if (host.isEmpty()) { + return url; + } + + const QHash limits = SettingsCache::instance().downloads().getHostRequestLimits(); + const int devCap = + DownloadSettings::getDeveloperHostCaps().value(host, DownloadSettings::DEFAULT_HOST_REQUEST_LIMIT); + if (devCap == DownloadSettings::UNLIMITED_HOST_QUOTA && !limits.contains(host)) { + return tr("%1 (unlimited)").arg(url); + } + + const int requested = limits.value( + host, devCap == DownloadSettings::UNLIMITED_HOST_QUOTA ? DownloadSettings::DEFAULT_HOST_REQUEST_LIMIT : devCap); + const int effective = SettingsCache::instance().downloads().clampHostRequestLimit(host, requested); + return tr("%1 (%2/s)").arg(url).arg(effective); +} + +void DeckEditorSettingsPage::refreshUrlItems() +{ + for (int i = 0; i < urlList->count(); ++i) { + QListWidgetItem *item = urlList->item(i); + item->setText(urlLabel(urlForItem(item))); + } +} + +void DeckEditorSettingsPage::actAdjustRateLimit() +{ + if (urlList->currentItem() == nullptr) { + QMessageBox::information(this, tr("Adjust Rate Limit"), tr("Select a URL in the list first.")); + return; + } + + const QString host = QUrl(urlForItem(urlList->currentItem())).host(); + if (host.isEmpty()) { + QMessageBox::information(this, tr("Adjust Rate Limit"), tr("The selected URL does not have a valid host.")); + return; + } + + const QHash &devCaps = DownloadSettings::getDeveloperHostCaps(); + const QHash currentLimits = SettingsCache::instance().downloads().getHostRequestLimits(); + const int devCap = devCaps.value(host, DownloadSettings::DEFAULT_HOST_REQUEST_LIMIT); + const bool unlocked = devCap == DownloadSettings::UNLIMITED_HOST_QUOTA; + + bool ok = false; + int minimum; + int maximum; + int defaultValue; + QString prompt; + if (unlocked) { + minimum = 0; // 0 means "unlimited" + maximum = DownloadSettings::UNLOCKED_HOST_LIMIT_MAX; + defaultValue = currentLimits.value(host, 0); + prompt = tr("Requests per second (0 = unlimited, fastest; up to %1):").arg(maximum); + } else { + minimum = DownloadSettings::MIN_HOST_REQUEST_LIMIT; + maximum = devCap; + defaultValue = currentLimits.value(host, devCap); + prompt = tr("Requests per second (developer maximum is %1):").arg(maximum); + } + + const int value = QInputDialog::getInt(this, tr("Adjust Rate Limit for %1").arg(host), prompt, defaultValue, + minimum, maximum, 1, &ok); + if (!ok) { + return; + } + + QHash limits = currentLimits; + if (unlocked ? value == 0 : value == devCap) { + limits.remove(host); + } else { + limits.insert(host, value); + } + SettingsCache::instance().downloads().setHostRequestLimits(limits); + refreshUrlItems(); } void DeckEditorSettingsPage::urlListChanged(const QModelIndex &, int, int, const QModelIndex &, int) @@ -244,4 +380,8 @@ void DeckEditorSettingsPage::retranslateUi() aAdd->setText(tr("Add New URL")); aEdit->setText(tr("Edit URL")); aRemove->setText(tr("Remove URL")); -} \ No newline at end of file + aRateLimit->setText(tr("Adjust Rate Limit")); + + // The per-URL rate limit suffixes are translated, so refresh them when the language changes. + refreshUrlItems(); +} diff --git a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.h b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.h index 5db009c8a..23e33fa20 100644 --- a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.h @@ -27,6 +27,7 @@ private slots: void actAddURL(); void actRemoveURL(); void actEditURL(); + void actAdjustRateLimit(); void resetDownloadedURLsButtonClicked(); private: @@ -34,7 +35,7 @@ private: QLabel urlLinkLabel; QCheckBox picDownloadCheckBox; QListWidget *urlList; - QAction *aAdd, *aEdit, *aRemove; + QAction *aAdd, *aEdit, *aRemove, *aRateLimit; QCheckBox mcDownloadSpoilersCheckBox; QLabel msDownloadSpoilersLabel; QGroupBox *mpGeneralGroupBox; @@ -46,6 +47,18 @@ private: QLabel infoOnSpoilersLabel; QPushButton *mpSpoilerPathButton; QPushButton *updateNowButton; + + /** @brief Adds a list item for the given URL, storing the raw URL alongside its displayed label. */ + QListWidgetItem *addUrlItem(const QString &url); + + /** @brief Returns the raw URL stored on a list item. */ + [[nodiscard]] QString urlForItem(const QListWidgetItem *item) const; + + /** @brief Returns the display label for a URL, including its current effective rate limit. */ + [[nodiscard]] QString urlLabel(const QString &url) const; + + /** @brief Refreshes the displayed label of every URL item after limits or settings change. */ + void refreshUrlItems(); }; #endif // COCKATRICE_DECK_EDITOR_SETTINGS_PAGE_H 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 36436f8a3..b0fd0e018 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp @@ -63,13 +63,26 @@ GeneralSettingsPage::GeneralSettingsPage() connect(&cardLanguageBox, qOverload(&QComboBox::currentIndexChanged), this, &GeneralSettingsPage::cardLanguageBoxChanged); + // card search language, independent of the card display language + cardSearchLanguageBox.addItem(""); // texts set in retranslateUi + cardSearchLanguageBox.addItem(""); + cardSearchLanguageBox.addItem(""); + const int cardSearchLanguageIndex = SettingsCache::instance().cardsDisplay().getCardSearchLanguage(); + cardSearchLanguageBox.setCurrentIndex(cardSearchLanguageIndex < 0 ? static_cast(SearchLanguageMode::English) + : cardSearchLanguageIndex); + + connect(&cardSearchLanguageBox, qOverload(&QComboBox::currentIndexChanged), this, + &GeneralSettingsPage::cardSearchLanguageBoxChanged); + auto *languageGrid = new QGridLayout; languageGrid->addWidget(&languageLabel, 0, 0); languageGrid->addWidget(&languageBox, 0, 1); languageGrid->addWidget(&cardLanguageLabel, 1, 0); languageGrid->addWidget(&cardLanguageBox, 1, 1); languageGrid->addWidget(&cardLanguageNoteLabel, 2, 1); - languageGrid->addWidget(&advertiseTranslationPageLabel, 3, 1, Qt::AlignRight); + languageGrid->addWidget(&cardSearchLanguageLabel, 3, 0); + languageGrid->addWidget(&cardSearchLanguageBox, 3, 1); + languageGrid->addWidget(&advertiseTranslationPageLabel, 4, 1, Qt::AlignRight); cardLanguageNoteLabel.setWordWrap(true); cardLanguageNoteLabel.setAlignment(Qt::AlignLeft | Qt::AlignVCenter); @@ -481,6 +494,11 @@ void GeneralSettingsPage::cardLanguageBoxChanged(int index) } } +void GeneralSettingsPage::cardSearchLanguageBoxChanged(int index) +{ + SettingsCache::instance().cardsDisplay().setCardSearchLanguage(index); +} + void GeneralSettingsPage::updateStartupServerControlsVisibility() { const int index = startupTabSelector.currentIndex(); @@ -502,6 +520,12 @@ void GeneralSettingsPage::retranslateUi() cardLanguageLabel.setText(tr("Card text & images language:")); cardLanguageNoteLabel.setText( tr("Foreign card names, text and art apply after you update the card database (Oracle).")); + cardSearchLanguageLabel.setText(tr("Language used in card search:")); + cardSearchLanguageBox.setItemText(static_cast(SearchLanguageMode::English), tr("English")); + cardSearchLanguageBox.setItemText(static_cast(SearchLanguageMode::Selected), + tr("Selected card language (untranslated cards still match in English)")); + cardSearchLanguageBox.setItemText(static_cast(SearchLanguageMode::Both), + tr("English and selected card language")); 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 7afba158f..07e0ba3a9 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.h @@ -38,6 +38,7 @@ private slots: void resetAllPathsClicked(); void languageBoxChanged(int index); void cardLanguageBoxChanged(int index); + void cardSearchLanguageBoxChanged(int index); void updateStartupServerControlsVisibility(); private: @@ -55,6 +56,9 @@ private: QComboBox cardLanguageBox; QLabel cardLanguageNoteLabel; + QLabel cardSearchLanguageLabel; + QComboBox cardSearchLanguageBox; + QLabel updateReleaseChannelLabel; QComboBox updateReleaseChannelBox; QCheckBox startupUpdateCheckCheckBox; diff --git a/cockatrice/src/interface/widgets/settings_page/storage_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/storage_settings_page.cpp index 17838e501..4aad91115 100644 --- a/cockatrice/src/interface/widgets/settings_page/storage_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/storage_settings_page.cpp @@ -181,9 +181,14 @@ StorageSettingsPage::StorageSettingsPage() void StorageSettingsPage::clearDownloadedPicsButtonClicked() { - CardPictureLoader::clearNetworkCache(); + // The network cache is cleared asynchronously on the worker thread, so wait for the completion + // signal before confirming; the in-memory pixmap cache is cleared synchronously right away. + connect( + &CardPictureLoader::getInstance(), &CardPictureLoader::networkCacheCleared, this, + [this] { QMessageBox::information(this, tr("Success"), tr("Cached card pictures have been reset.")); }, + Qt::SingleShotConnection); CardPictureLoader::clearPixmapCache(); - QMessageBox::information(this, tr("Success"), tr("Cached card pictures have been reset.")); + CardPictureLoader::clearNetworkCache(); } void StorageSettingsPage::clearImageBackupsButtonClicked() diff --git a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp index 2c6e062da..03b9455fa 100644 --- a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp @@ -149,6 +149,11 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() connect(&openDeckInNewTabCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().deckEditor(), &DeckEditorSettings::setOpenDeckInNewTab); + visualDeckEditorShowCardCountsCheckBox.setChecked( + SettingsCache::instance().cardsDisplay().getVisualDeckEditorShowCardCounts()); + connect(&visualDeckEditorShowCardCountsCheckBox, &QCheckBox::QT_STATE_CHANGED, + &SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::setVisualDeckEditorShowCardCounts); + visualDeckStorageInGameCheckBox.setChecked( SettingsCache::instance().visualDeckStorage().getVisualDeckStorageInGame()); connect(&visualDeckStorageInGameCheckBox, &QCheckBox::QT_STATE_CHANGED, @@ -244,18 +249,19 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() auto *deckEditorGrid = new QGridLayout; deckEditorGrid->addWidget(&openDeckInNewTabCheckBox, 0, 0); - deckEditorGrid->addWidget(&visualDeckStorageInGameCheckBox, 1, 0); - deckEditorGrid->addWidget(&visualDeckStorageSelectionAnimationCheckBox, 2, 0); - deckEditorGrid->addWidget(&visualDeckStoragePromptForConversionLabel, 3, 0); - deckEditorGrid->addWidget(&visualDeckStoragePromptForConversionSelector, 3, 1); - deckEditorGrid->addWidget(&defaultDeckEditorTypeLabel, 4, 0); - deckEditorGrid->addWidget(&defaultDeckEditorTypeSelector, 4, 1); - deckEditorGrid->addWidget(&vdeStartupTabLabel, 5, 0); - deckEditorGrid->addWidget(&vdeStartupTabSelector, 5, 1); - deckEditorGrid->addWidget(&commanderSpellbookIntegrationEnabledLabel, 6, 0); - deckEditorGrid->addWidget(&commanderSpellbookIntegrationEnabledSelector, 6, 1); - deckEditorGrid->addWidget(labelWidget, 7, 0); - deckEditorGrid->addWidget(&commanderSpellbookIntegrationBracketNamingSelector, 7, 1); + deckEditorGrid->addWidget(&visualDeckEditorShowCardCountsCheckBox, 1, 0); + deckEditorGrid->addWidget(&visualDeckStorageInGameCheckBox, 2, 0); + deckEditorGrid->addWidget(&visualDeckStorageSelectionAnimationCheckBox, 3, 0); + deckEditorGrid->addWidget(&visualDeckStoragePromptForConversionLabel, 4, 0); + deckEditorGrid->addWidget(&visualDeckStoragePromptForConversionSelector, 4, 1); + deckEditorGrid->addWidget(&defaultDeckEditorTypeLabel, 5, 0); + deckEditorGrid->addWidget(&defaultDeckEditorTypeSelector, 5, 1); + deckEditorGrid->addWidget(&vdeStartupTabLabel, 6, 0); + deckEditorGrid->addWidget(&vdeStartupTabSelector, 6, 1); + deckEditorGrid->addWidget(&commanderSpellbookIntegrationEnabledLabel, 7, 0); + deckEditorGrid->addWidget(&commanderSpellbookIntegrationEnabledSelector, 7, 1); + deckEditorGrid->addWidget(labelWidget, 8, 0); + deckEditorGrid->addWidget(&commanderSpellbookIntegrationBracketNamingSelector, 8, 1); deckEditorGroupBox = new QGroupBox; deckEditorGroupBox->setLayout(deckEditorGrid); @@ -367,6 +373,7 @@ void UserInterfaceSettingsPage::retranslateUi() deckEditorGroupBox->setTitle(tr("Deck editor/storage settings")); openDeckInNewTabCheckBox.setText(tr("Open deck in new tab by default")); + visualDeckEditorShowCardCountsCheckBox.setText(tr("Show card counts in Visual Deck Editor")); visualDeckStorageInGameCheckBox.setText(tr("Use visual deck storage in game lobby")); visualDeckStorageSelectionAnimationCheckBox.setText(tr("Use selection animation for Visual Deck Storage")); visualDeckStoragePromptForConversionLabel.setText( diff --git a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h index e8a30fb1f..277c5461e 100644 --- a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h @@ -51,6 +51,7 @@ private: QComboBox visualDeckStoragePromptForConversionSelector; QCheckBox visualDeckStorageInGameCheckBox; QCheckBox visualDeckStorageSelectionAnimationCheckBox; + QCheckBox visualDeckEditorShowCardCountsCheckBox; QLabel defaultDeckEditorTypeLabel; QComboBox defaultDeckEditorTypeSelector; QLabel vdeStartupTabLabel; 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_listings_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.cpp index 4d3dfd660..21e8e3f9a 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.cpp @@ -15,6 +15,8 @@ ArchidektApiResponseDeckListingsDisplayWidget::ArchidektApiResponseDeckListingsD flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); + cardSizeSlider->enableCtrlScrollResize(flowWidget); + imageNetworkManager = new QNetworkAccessManager(this); imageNetworkManager->setTransferTimeout(); // Use Qt's default timeout imageNetworkManager->setRedirectPolicy(QNetworkRequest::ManualRedirectPolicy); diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp index 9d294f10c..00c1741df 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp @@ -54,6 +54,7 @@ EdhrecApiResponseCardDetailsDisplayWidget::EdhrecApiResponseCardDetailsDisplayWi &CardInfoPictureWidget::setScaleFactor); connect(this, &EdhrecApiResponseCardDetailsDisplayWidget::requestUrl, parentTab, &TabEdhRecMain::actNavigatePage); + parentTab->getCardSizeSlider()->enableCtrlScrollResize(this); } } diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.cpp index 515475f3e..40a803e77 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.cpp @@ -38,6 +38,7 @@ EdhrecCommanderResponseCommanderDetailsDisplayWidget::EdhrecCommanderResponseCom connect(parentTab->getCardSizeSlider()->getSlider(), &QSlider::valueChanged, commanderPicture, &CardInfoPictureWidget::setScaleFactor); commanderPicture->setScaleFactor(parentTab->getCardSizeSlider()->getSlider()->value()); + parentTab->getCardSizeSlider()->enableCtrlScrollResize(this); } commanderDetails.debugPrint(); 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..d13f9d1e3 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp @@ -0,0 +1,253 @@ +#include "tab_public_decks.h" + +#include "../../../client/settings/cache_settings.h" +#include "../../deck_loader/deck_loader.h" +#include "../cards/card_size_widget.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); + quickSettingsWidget->getCardSizeWidget()->enableCtrlScrollResize(flowWidget); + + 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_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index ccb687ff3..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()) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h index adde7f971..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; @@ -199,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(); @@ -231,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/utility/completer_utils.cpp b/cockatrice/src/interface/widgets/utility/completer_utils.cpp index 16d5cfd13..23ddb30b0 100644 --- a/cockatrice/src/interface/widgets/utility/completer_utils.cpp +++ b/cockatrice/src/interface/widgets/utility/completer_utils.cpp @@ -1,5 +1,6 @@ #include "completer_utils.h" +#include "../../../client/settings/cache_settings.h" #include "card_completer_styler.h" #include @@ -7,13 +8,26 @@ #include #include #include +#include #include #include #include +#include + +namespace +{ +void applyCardSearchLanguage(CardSearchModel *searchModel) +{ + const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay(); + searchModel->setSearchLanguage(CardSearchLanguage{ + cardsDisplay.getCardLang(), static_cast(cardsDisplay.getCardSearchLanguage())}); +} +} // namespace CardCompleterSetup createCardCompleter(CardDatabaseDisplayModel *displayModel, QObject *parent, int maxVisibleItems) { auto *searchModel = new CardSearchModel(displayModel, parent); + applyCardSearchLanguage(searchModel); auto *proxyModel = new CardCompleterProxyModel(parent); proxyModel->setSourceModel(searchModel); @@ -27,6 +41,12 @@ CardCompleterSetup createCardCompleter(CardDatabaseDisplayModel *displayModel, Q completer->setMaxVisibleItems(maxVisibleItems); CardCompleterStyler::apply(completer); + auto *cardsDisplay = &SettingsCache::instance().cardsDisplay(); + QObject::connect(cardsDisplay, &CardsDisplaySettings::cardLangChanged, searchModel, + [searchModel] { applyCardSearchLanguage(searchModel); }); + QObject::connect(cardsDisplay, &CardsDisplaySettings::cardSearchLanguageChanged, searchModel, + [searchModel] { applyCardSearchLanguage(searchModel); }); + return {searchModel, proxyModel, completer}; } diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp index f4530a12b..6e0fee53f 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp @@ -41,6 +41,17 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, databaseDisplayModel->setSourceModel(database_model); databaseDisplayModel->setFilterKeyColumn(0); + const auto applyCardSearchLanguage = [this]() { + const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay(); + databaseDisplayModel->setSearchLanguage(CardSearchLanguage{ + cardsDisplay.getCardLang(), static_cast(cardsDisplay.getCardSearchLanguage())}); + }; + applyCardSearchLanguage(); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, + applyCardSearchLanguage); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardSearchLanguageChanged, this, + applyCardSearchLanguage); + cards = new QList; connect(databaseDisplayModel, &CardDatabaseDisplayModel::modelDirty, this, &VisualDatabaseDisplayWidget::modelDirty); @@ -58,6 +69,7 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, SettingsCache::instance().cardsDisplay().getVisualDatabaseDisplayCardSize()); connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::setVisualDatabaseDisplayCardSize); + cardSizeWidget->enableCtrlScrollResize(flowWidget); searchContainer = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAlwaysOff); diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.cpp index a4268563a..cee268228 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.cpp @@ -48,6 +48,7 @@ VisualDeckEditorSampleHandWidget::VisualDeckEditorSampleHandWidget(QWidget *pare upperLayout->addWidget(flowWidget); cardSizeWidget = new CardSizeWidget(this, flowWidget); + cardSizeWidget->enableCtrlScrollResize(flowWidget); upperLayout->addWidget(cardSizeWidget); auto upperLayoutWidget = new QWidget(this); diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp index 064fbed5d..60e354d6e 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -82,13 +83,8 @@ void VisualDeckEditorWidget::initializeSearchBarAndCompleter() searchBar = new QLineEdit(searchContainer); searchContainer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); connect(searchBar, &QLineEdit::returnPressed, this, [=, this]() { - if (!searchBar->hasFocus()) { - return; - } - - ExactCard card = CardDatabaseManager::query()->getCard({searchBar->text()}); - if (card) { - emit cardAdditionRequested(card); + if (searchBar->hasFocus()) { + addCardFromSearch(); } }); @@ -133,16 +129,24 @@ void VisualDeckEditorWidget::initializeSearchBarAndCompleter() // Search button functionality searchPushButton = new CompactPushButton(searchContainer); searchPushButton->setButtonIcon(themePixmap(QStringLiteral("icons/search"))); - connect(searchPushButton, &QPushButton::clicked, this, [=, this]() { - ExactCard card = CardDatabaseManager::query()->getCard({searchBar->text()}); - if (card) { - emit cardAdditionRequested(card); - } - }); + connect(searchPushButton, &QPushButton::clicked, this, [this] { addCardFromSearch(); }); searchLayout->addWidget(searchPushButton); } +void VisualDeckEditorWidget::addCardFromSearch() +{ + ExactCard card = CardDatabaseManager::query()->getCard({searchBar->text()}); + if (!card) { + return; + } + + emit cardAdditionRequested(card); + + searchBar->setFocus(); + QTimer::singleShot(0, searchBar, [this] { searchBar->setSelection(0, searchBar->text().length()); }); +} + void VisualDeckEditorWidget::initializeDisplayOptionsWidget() { displayOptionsWidget = new VisualDeckDisplayOptionsWidget(this); diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.h b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.h index ac0d07efd..cc045fca2 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.h @@ -78,6 +78,7 @@ signals: protected: void initializeSearchBarAndCompleter(); + void addCardFromSearch(); void initializeDisplayOptionsWidget(); void initializeDisplayOptionsAndSearchWidget(); void initializeScrollAreaAndZoneContainer(); 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..53f21d1a1 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 @@ -1,6 +1,7 @@ #include "visual_deck_storage_folder_display_widget.h" #include "../cards/card_info_picture_widget.h" +#include "../cards/card_size_widget.h" #include "../general/display/banner_widget.h" #include "../general/layout_containers/flow_widget.h" #include "deck_preview/deck_preview_widget.h" @@ -45,6 +46,8 @@ VisualDeckStorageFolderDisplayWidget::VisualDeckStorageFolderDisplayWidget( flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAlwaysOff); containerLayout->addWidget(flowWidget); + visualDeckStorageWidget->settings()->getCardSizeWidget()->enableCtrlScrollResize(this); + auto *proxy = visualDeckStorageWidget->proxyModel(); // A burst of proxy changes (one dataChanged per finished deck load, plus the filter // invalidations) coalesces into a single reconcile, so a scan of many decks doesn't @@ -126,6 +129,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 +217,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 +229,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..a85e9e654 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,10 +65,20 @@ 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; + /** + * @return The card size widget, so card display hosts can resize the cards on + * Ctrl + scroll even though the slider itself lives inside this menu. + */ + [[nodiscard]] CardSizeWidget *getCardSizeWidget() const + { + return cardSizeWidget; + } + signals: void showFoldersChanged(bool enabled); void drawUnusedColorIdentitiesChanged(bool enabled); @@ -65,6 +86,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..0ad6ebcb2 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 @@ -1,9 +1,12 @@ #include "visual_deck_storage_sort_filter_proxy_model.h" +#include "../../../client/settings/cache_settings.h" #include "../../filters/deck_filter_string.h" #include #include +#include +#include VisualDeckStorageSortFilterProxyModel::VisualDeckStorageSortFilterProxyModel(QObject *parent) : QSortFilterProxyModel(parent) @@ -11,6 +14,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()) { @@ -187,7 +218,10 @@ void VisualDeckStorageSortFilterProxyModel::updateSearchMatches() return; } - DeckFilterString filterString(searchText); + const auto &cardsDisplay = SettingsCache::instance().cardsDisplay(); + DeckFilterString filterString( + searchText, CardSearchLanguage{cardsDisplay.getCardLang(), + static_cast(cardsDisplay.getCardSearchLanguage())}); for (int row = 0; row < count; ++row) { const DeckPreviewData &data = source->dataForRow(row); @@ -255,34 +289,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..c7218f195 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 @@ -15,6 +15,7 @@ #include #include #include +#include #include #include @@ -48,6 +49,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 +65,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 = @@ -108,6 +119,13 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare connect(searchWidget, &VisualDeckStorageSearchWidget::searchTextChanged, this, &VisualDeckStorageWidget::updateSearchFilter); + // The deck content search matches card names in the configured card language; + // re-run it whenever that setting changes so active searches follow immediately. + CardsDisplaySettings *cardsDisplay = &SettingsCache::instance().cardsDisplay(); + const auto reapplySearchForLanguage = [this] { storageProxyModel->reapplyFilters(); }; + connect(cardsDisplay, &CardsDisplaySettings::cardLangChanged, this, reapplySearchForLanguage); + connect(cardsDisplay, &CardsDisplaySettings::cardSearchLanguageChanged, this, reapplySearchForLanguage); + connect(CardDatabaseManager::getInstance(), &CardDatabase::cardDatabaseLoadingFinished, this, &VisualDeckStorageWidget::createRootFolderWidget); @@ -155,11 +173,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 +295,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 722df90ef..ae967312d 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -512,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(); @@ -574,11 +575,18 @@ MainWindow::MainWindow(QWidget *parent) void MainWindow::startupConfigCheck() { + const bool isCleanInstall = SettingsCache::instance().network().getClientVersion() == CLIENT_INFO_NOT_SET; + // checkUnknownSets() is intentionally deferred from the card database load // (which runs in main() before MainWindow exists) so that // cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled have live // receivers when emitted. - CardDatabaseManager::getInstance()->checkUnknownSets(); + // On a clean install the onboarding wizard owns the first-run experience; + // wait until it closes so the legacy "all sets enabled" welcome and the + // Manage Sets dialog don't appear first. + if (!isCleanInstall) { + CardDatabaseManager::getInstance()->checkUnknownSets(); + } if (SettingsCache::instance().debug().getLocalGameOnStartup()) { LocalGameOptions options; @@ -592,14 +600,14 @@ void MainWindow::startupConfigCheck() actCheckCommanderBracketDefinitionUpdates(); - if (SettingsCache::instance().network().getClientVersion() == CLIENT_INFO_NOT_SET) { + if (isCleanInstall) { // no config found, 99% new clean install qCInfo(WindowMainStartupVersionLog) << "Startup: old client version empty, assuming first start after clean install"; SettingsCache::instance().downloads().resetToDefaultURLs(); // populate the download urls SettingsCache::instance().network().setClientVersion(VERSION_STRING); actCheckServerUpdates(); - runFirstRunWizard(); + runFirstRunWizard(true); if (QString(VERSION_STRING).contains("custom", Qt::CaseInsensitive)) { SettingsCache::instance().updates().setCheckUpdatesOnStartup(false); @@ -679,7 +687,7 @@ void MainWindow::startupConfigCheck() } } -void MainWindow::runFirstRunWizard() +void MainWindow::runFirstRunWizard(bool firstRun) { auto *wizard = new FirstRunWizard(this); wizard->setAttribute(Qt::WA_DeleteOnClose); @@ -691,6 +699,19 @@ void MainWindow::runFirstRunWizard() connect(wizard, &FirstRunWizard::registerRequested, connectionController, &ConnectionController::registerToServer); connect(wizard, &FirstRunWizard::connectRequested, connectionController, &ConnectionController::connectToServer); + if (firstRun) { + // The onboarding wizard owns set handling for a clean install. Suppress + // the legacy set dialogs while it's open and run checkUnknownSets() once + // it closes so the sets end up enabled in the right order. + firstRunWizardActive = true; + connect(wizard, &QDialog::finished, this, [this] { + QTimer::singleShot(0, this, [this] { + CardDatabaseManager::getInstance()->checkUnknownSets(); + firstRunWizardActive = false; + }); + }); + } + wizard->setModal(true); wizard->show(); } @@ -707,6 +728,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; @@ -728,6 +755,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); @@ -879,18 +907,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(); } } @@ -916,6 +933,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) { @@ -945,7 +1015,7 @@ void MainWindow::cardDatabaseLoadingFailed() void MainWindow::cardDatabaseNewSetsFound(int numUnknownSets, QStringList unknownSetsNames) { - if (SettingsCache::instance().updates().getAlwaysEnableNewSets()) { + if (firstRunWizardActive || SettingsCache::instance().updates().getAlwaysEnableNewSets()) { CardDatabaseManager::getInstance()->enableAllUnknownSets(); const auto reloadOk1 = QtConcurrent::run([] { CardDatabaseManager::getInstance()->reloadCardDatabasesAndNotify(); }); @@ -988,6 +1058,12 @@ void MainWindow::cardDatabaseNewSetsFound(int numUnknownSets, QStringList unknow void MainWindow::cardDatabaseAllNewSetsEnabled() { + if (firstRunWizardActive || CardDatabaseManager::getInstance()->getCardList().isEmpty()) { + // The onboarding wizard owns the first-run messaging on a clean install, + // and with no card data there are no sets to have enabled. + return; + } + QMessageBox::information( this, tr("Welcome"), tr("Hi! It seems like you're running this version of Cockatrice for the first time.\nAll the sets in the card " diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index 08481fd36..fc7980d16 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; @@ -136,7 +139,7 @@ private: void createTrayIcon(); int getNextCustomSetPrefix(QDir dataDir); - void runFirstRunWizard(); + void runFirstRunWizard(bool firstRun = false); inline QString getCardUpdaterBinaryName() { @@ -164,6 +167,9 @@ 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; + bool firstRunWizardActive = false; QProcess *cardUpdateProcess; QByteArray cardUpdateOutputBuffer; DlgViewLog *logviewDialog; @@ -177,6 +183,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/Fabric/backgrounds/home-dark.jpg b/cockatrice/themes/Fabric/backgrounds/home-dark.jpg new file mode 100644 index 000000000..f69e5923e Binary files /dev/null and b/cockatrice/themes/Fabric/backgrounds/home-dark.jpg differ diff --git a/cockatrice/themes/Fabric/backgrounds/home-dark.png b/cockatrice/themes/Fabric/backgrounds/home-dark.png deleted file mode 100644 index 8d9514626..000000000 Binary files a/cockatrice/themes/Fabric/backgrounds/home-dark.png and /dev/null differ diff --git a/cockatrice/themes/Fabric/backgrounds/home-light.jpg b/cockatrice/themes/Fabric/backgrounds/home-light.jpg new file mode 100644 index 000000000..800e369e7 Binary files /dev/null and b/cockatrice/themes/Fabric/backgrounds/home-light.jpg differ diff --git a/cockatrice/themes/Fabric/backgrounds/home-light.png b/cockatrice/themes/Fabric/backgrounds/home-light.png deleted file mode 100644 index 78f51a686..000000000 Binary files a/cockatrice/themes/Fabric/backgrounds/home-light.png and /dev/null differ diff --git a/cockatrice/themes/Leather/backgrounds/home-dark.jpg b/cockatrice/themes/Leather/backgrounds/home-dark.jpg new file mode 100644 index 000000000..8c2c3d1df Binary files /dev/null and b/cockatrice/themes/Leather/backgrounds/home-dark.jpg differ diff --git a/cockatrice/themes/Leather/backgrounds/home-dark.png b/cockatrice/themes/Leather/backgrounds/home-dark.png deleted file mode 100644 index 20d16a27f..000000000 Binary files a/cockatrice/themes/Leather/backgrounds/home-dark.png and /dev/null differ diff --git a/cockatrice/themes/Leather/backgrounds/home-light.jpg b/cockatrice/themes/Leather/backgrounds/home-light.jpg new file mode 100644 index 000000000..3fb49538a Binary files /dev/null and b/cockatrice/themes/Leather/backgrounds/home-light.jpg differ diff --git a/cockatrice/themes/Leather/backgrounds/home-light.png b/cockatrice/themes/Leather/backgrounds/home-light.png deleted file mode 100644 index ee1da361d..000000000 Binary files a/cockatrice/themes/Leather/backgrounds/home-light.png and /dev/null differ diff --git a/cockatrice/themes/Plasma/backgrounds/home-dark.jpg b/cockatrice/themes/Plasma/backgrounds/home-dark.jpg new file mode 100644 index 000000000..230d78e8e Binary files /dev/null and b/cockatrice/themes/Plasma/backgrounds/home-dark.jpg differ diff --git a/cockatrice/themes/Plasma/backgrounds/home-dark.png b/cockatrice/themes/Plasma/backgrounds/home-dark.png deleted file mode 100644 index ce1469e2b..000000000 Binary files a/cockatrice/themes/Plasma/backgrounds/home-dark.png and /dev/null differ diff --git a/cockatrice/themes/Plasma/backgrounds/home-light.jpg b/cockatrice/themes/Plasma/backgrounds/home-light.jpg new file mode 100644 index 000000000..2552325eb Binary files /dev/null and b/cockatrice/themes/Plasma/backgrounds/home-light.jpg differ diff --git a/cockatrice/themes/Plasma/backgrounds/home-light.png b/cockatrice/themes/Plasma/backgrounds/home-light.png deleted file mode 100644 index 2e9000bd6..000000000 Binary files a/cockatrice/themes/Plasma/backgrounds/home-light.png and /dev/null differ diff --git a/cockatrice/themes/System/palette-default-dark.toml b/cockatrice/themes/System/palette-default-dark.toml index e101935b4..9dfe6e19f 100644 --- a/cockatrice/themes/System/palette-default-dark.toml +++ b/cockatrice/themes/System/palette-default-dark.toml @@ -1,67 +1,7 @@ -[Palette] -WindowText = #ffffffff -Button = #ff383838 -Light = #ff737373 -Midlight = #ff525252 -Dark = #ff161616 -Mid = #ff252525 -Text = #ffffffff -BrightText = #ffb4dd8b -ButtonText = #ffffffff -Base = #ff2b2b2b -Window = #ff1c1c1c -Shadow = #ff000000 -HighlightedText = #ff000000 -Link = #ffcde4b6 -LinkVisited = #ff99d999 -AlternateBase = #ff242424 -ToolTipBase = #ffffffdc -ToolTipText = #ff000000 -PlaceholderText = #6effffff - -[Palette.Disabled] -WindowText = #ff9d9d9d -Button = #ff1c1c1c -Light = #ff737373 -Midlight = #ff525252 -Dark = #ff161616 -Mid = #ff252525 -Text = #ff9d9d9d -BrightText = #ffb4dd8b -ButtonText = #ff787878 -Base = #ff1c1c1c -Window = #ff1c1c1c -Shadow = #ff000000 -HighlightedText = #ff9d9d9d -Link = #ff308cc6 -LinkVisited = #ffb450ff -AlternateBase = #ff242424 -ToolTipBase = #ffffffdc -ToolTipText = #ff000000 -PlaceholderText = #46ffffff - -[Palette.Inactive] -WindowText = #ffffffff -Button = #ff383838 -Light = #ff737373 -Midlight = #ff525252 -Dark = #ff161616 -Mid = #ff252525 -Text = #ffffffff -BrightText = #ffb4dd8b -ButtonText = #ffffffff -Base = #ff2b2b2b -Window = #ff1c1c1c -Shadow = #ff000000 -HighlightedText = #ffffffff -Link = #ffcde4b6 -LinkVisited = #ff99d999 -AlternateBase = #ff242424 -ToolTipBase = #ffffffdc -ToolTipText = #ff000000 -PlaceholderText = #6effffff - - +# The System theme derives all of its colors from the platform palette +# (e.g. qt6ct's custom color scheme on Linux), so it must NOT ship palette +# overrides. Only the identity app colors are provided here, so buttons keep +# their classic greens while the actual palette stays native to the OS. [AppColors] AccentStrong = #ff148c3c AccentSoft = #ff78c850 diff --git a/cockatrice/themes/System/palette-default-light.toml b/cockatrice/themes/System/palette-default-light.toml index 14c215cdf..9dfe6e19f 100644 --- a/cockatrice/themes/System/palette-default-light.toml +++ b/cockatrice/themes/System/palette-default-light.toml @@ -1,67 +1,7 @@ -[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 - - +# The System theme derives all of its colors from the platform palette +# (e.g. qt6ct's custom color scheme on Linux), so it must NOT ship palette +# overrides. Only the identity app colors are provided here, so buttons keep +# their classic greens while the actual palette stays native to the OS. [AppColors] AccentStrong = #ff148c3c -AccentSoft = #ff78c850 \ No newline at end of file +AccentSoft = #ff78c850 diff --git a/cockatrice/themes/System/theme.cfg b/cockatrice/themes/System/theme.cfg index 4b756a5e8..23fffccbb 100644 --- a/cockatrice/themes/System/theme.cfg +++ b/cockatrice/themes/System/theme.cfg @@ -1,5 +1,5 @@ [Appearance] -ColorScheme = Light +ColorScheme = System [Style] Name = System diff --git a/cockatrice/themes/VelvetMarble/backgrounds/home-dark.jpg b/cockatrice/themes/VelvetMarble/backgrounds/home-dark.jpg new file mode 100644 index 000000000..b55f2524e Binary files /dev/null and b/cockatrice/themes/VelvetMarble/backgrounds/home-dark.jpg differ diff --git a/cockatrice/themes/VelvetMarble/backgrounds/home-dark.png b/cockatrice/themes/VelvetMarble/backgrounds/home-dark.png deleted file mode 100644 index 34738090f..000000000 Binary files a/cockatrice/themes/VelvetMarble/backgrounds/home-dark.png and /dev/null differ diff --git a/cockatrice/themes/VelvetMarble/backgrounds/home-light.jpg b/cockatrice/themes/VelvetMarble/backgrounds/home-light.jpg new file mode 100644 index 000000000..12a2fd2cc Binary files /dev/null and b/cockatrice/themes/VelvetMarble/backgrounds/home-light.jpg differ diff --git a/cockatrice/themes/VelvetMarble/backgrounds/home-light.png b/cockatrice/themes/VelvetMarble/backgrounds/home-light.png deleted file mode 100644 index de783e086..000000000 Binary files a/cockatrice/themes/VelvetMarble/backgrounds/home-light.png and /dev/null differ diff --git a/doc/doxygen/extra-pages/developer_documentation/loading_card_pictures.md b/doc/doxygen/extra-pages/developer_documentation/loading_card_pictures.md index b606f9e4b..c4c335b00 100644 --- a/doc/doxygen/extra-pages/developer_documentation/loading_card_pictures.md +++ b/doc/doxygen/extra-pages/developer_documentation/loading_card_pictures.md @@ -33,20 +33,178 @@ issue a load request, which will first look for local images on-disk and then co found, use the stored binary data from the network cache to populate the in-memory pixmap cache under the card's cache key. If it is not found, it will then proceed with issuing a network request. -The size of both of these caches can be configured by the user in the "Card Sources" settings page. +The size of both of these caches can be configured by the user on the "Storage" settings page. # PixmapCacheKeys and ProviderIDs -TODO +Every card picture that is loaded ends up in the QPixmapCache under a key that identifies the exact printing it belongs +to. The key is produced by ExactCard::getPixmapCacheKey() and has the following shape: + +```text +card__ +``` + +For example, the _Example Card_ printing with provider ID `0b23cdc8-d413-4fb1-8470-474221b10fe2` is stored +under `card_Example Card_0b23cdc8-d413-4fb1-8470-474221b10fe2`. If the printing has no provider ID, the key +drops the suffix and falls back to `card_`. + +The **provider ID** is the Scryfall UUID of the printing. Oracle maps the `scryfallId` of every printing to the `uuid` +property when building the card database, and deck files persist it as the `uuid` attribute of each card entry. Because +the provider ID is part of the pixmap cache key, two different printings of the same card never share a cache entry. +This is exactly what allows the printing selector and exact-card lookups to display the picture of the precise printing +a card was added as. + +The base key holds the full-size image. When a widget asks for a scaled version, the scaled pixmap is stored under an +additional key of the form `_x`, with the size adjusted for the device pixel ratio of the screen, +so each widget size is only ever scaled once. + +The cache key is also used for bookkeeping outside of the pixmap cache itself: + +- CardPictureLoaderWorker keeps a set of keys that are currently being loaded so the same card is never queued twice. +- CardPictureLoader tracks, per key, the last time loading failed. A failed load stores a NULL pixmap under the key; as + long as that marker is present, subsequent requests for the card show the "failed" card back and are only re-queued + after the retry interval of 300 seconds has passed. +- When the CardInfo of a loaded card is destroyed, its cache entries and failure markers are removed. # The Redirect Cache -TODO +Many picture URLs - in particular the Gatherer and Scryfall URLs from the default set of templates - redirect to a CDN +or to a different host. To avoid following the same redirect for every single card, CardPictureLoaderWorker remembers +redirects and applies them without an extra network round trip. + +The redirect cache is a hash map from original URL to redirect URL plus timestamp. It is persisted to a `cache.ini` +file (Qt's INI format, under the `redirects` array) inside the redirect cache directory +(`SettingsCache::instance().paths().getRedirectCachePath()`, i.e. `/redirects/`). The cache is loaded when the worker +starts, pruned of entries older than the configured TTL, and written back to disk when the application quits. + +Entries are added whenever a network reply reports a redirection (see below) and are consulted before any request is +made: both CardPictureLoaderWorker::queueRequest() and CardPictureLoaderWorker::makeRequest() check for a cached +redirect first and jump straight to the final URL. + +The TTL is the "Redirect Cache TTL" setting on the "Storage" settings page and defaults to 30 days. Lowering it makes +Cockatrice re-resolve redirects sooner, which can help when a download URL changed its redirect target. + +Because Cockatrice tracks redirects itself, the QNetworkAccessManager is configured with Qt's `ManualRedirectPolicy`. +Redirects found in a reply are handled manually: + +- A recursive redirect (a URL redirecting to itself) is treated as a failed load. +- Otherwise the redirect is recorded in the redirect cache and the request is re-issued against the target URL. +- A successful reply with one of the redirect status codes 301, 302, 303, 305, 307 or 308 is handled the same way. + +Clearing the network cache (CardPictureLoader::clearNetworkCache()) also clears the redirect cache. # Local Image Loading -TODO +Before any network request is issued, CardPictureLoaderWorker hands the ExactCard to CardPictureLoaderLocal, which +tries to find a matching picture on disk. If a local picture is found, it is used and no network request is made. + +CardPictureLoaderLocal searches three locations: + +- The **CUSTOM folder** (`/CUSTOM/`). Every file in it is indexed recursively by its base name + (both `baseName` and `completeBaseName`, so a file named `ExampleCard.jpg` is indexed as `ExampleCard`). The index is rebuilt + every 10 seconds, so new files are picked up without restarting the + client (changing the configured pictures directory only reassigns the search paths; the next timer tick rebuilds the index). +- The **set-named subfolders** of the pictures directory: `//` and + `/downloadedPics//`. +- The **root of the `downloadedPics` folder** (`/downloadedPics/`). The export naming schemes without a + set-folder part write their files straight into `downloadedPics/`, so this is where flat-scheme downloads and the + local overrides described below are matched. + +For each candidate folder, the loader generates file-name variants from the card's corrected name, set code, collector +number and provider ID using the import naming schemes (Card Name + Provider ID, Card Name + Set + Collector, +Set + Collector + Card Name, Card Name + Set, Card Name), each tried with both `_` and `-` as separator. A file is +accepted when its name without the extension *equals* the variant exactly - the extension itself is free - and the +first variant that yields a readable image wins. For example, the file `Example Card_EXM_43.png` in the `EXM` set +folder matches the card with corrected name `Example Card`, set code `EXM` and collector number `43`. + +\attention The file-name variants use the *corrected* card name, so split cards are stored under their joined name: the +"Example // Card" card is matched by a file named `ExampleCard.*`. + +The naming schemes are also documented in the user-facing page @ref custom_card_pictures, which additionally covers +the CUSTOM folder workflow, `picurl` and download URL templates. + +When the filesystem cache method is selected on the "Storage" settings page, downloaded images are additionally written +into `/downloadedPics/` using the configured export naming scheme (as `.png` files). The two export +schemes with a set-folder part (`Set Folder / Name + Provider ID` and `Set Folder / Name + Set Name + Collector`) write +into `downloadedPics//`; the three flat schemes write directly into `downloadedPics/`. Automatic cache writes +never overwrite an existing file, so a provider outage can permanently leave an outdated image in that folder until it +is deleted manually - the user-facing troubleshooting guide @ref fixing_card_pictures covers how to do this. Explicit +image overrides (see below) are the exception and always overwrite. + +# Local Image Overrides + +Beyond the generic on-disk lookup above, individual printings can be given explicit artwork that wins over every other +source without touching the CUSTOM folder or any download URL. This is the "Image Overrides" submenu of the context menu +that opens when you right-click a card in the deck editor's printing selector. + +- **Load Custom Image...** asks for a picture file and installs it for the card through + CardPictureLoader::saveCardImageToLocalStorage() with `allowOverwrite == true`. +- **One entry per alternate printing** (labeled ` `): selecting one hands the card to + CardPictureLoader::installPrintingOverride(), which resolves that printing's artwork - enqueueing a load and waiting + for the `CardInfo::pixmapUpdated` signal if it is not cached yet - and persists it for the card. +- **Clear Custom Image** calls CardPictureLoader::deleteAllLocalOverrides() to remove every stored override image of the + card, after which normal resolution resumes. The entry is only enabled while CardPictureLoader::hasLocalOverrides() + reports at least one stored file. + +Overrides are stored as `.png` files in `downloadedPics/` under the export naming scheme configured on the "Storage" +settings page - which is exactly why the local matcher also looks into the `downloadedPics/` root (see above). They are +written with `allowOverwrite == true`, so an override always replaces whatever the filesystem cache previously saved for +that spelling; only *automatic* cache writes are prevented from clobbering it. Overriding a card with its own current +printing is a no-op (the UI omits it from the menu), and an override whose artwork fails to resolve surfaces the +"failed" card back instead of a silent no-op while any override already on disk is left in place and re-displayed. # URL Generation and Resolution -TODO \ No newline at end of file +When no local image is available and downloading is enabled, the network loader starts working through a list of +candidate URLs. This list is managed by CardPictureToLoad and is built in two steps. + +First, CardPictureToLoad::extractSetsSorted() collects all sets the card has printings in and sorts them by set +priority. Unless the user disabled per-printing art ("Override all card art with personal set preference (Pre-ProviderID +change behavior)"), the set that +matches the requested printing's provider ID is moved to the front, so the exact printing is always attempted first. + +For each set, CardPictureToLoad::populateSetUrls() builds an ordered URL list: + +1. A custom URL defined for that printing via the `picurl` property in the card database, if present. +2. The configured download URL templates, in priority order (Deck Editor → "URL Download Priority"). + +URL templates are transformed into concrete URLs by CardPictureToLoad::transformUrl(), which substitutes reference +points. `!name!`, `!setcode!` and friends substitute card and printing data, while the `!set:!` and +`!prop:!` reference points resolve a property of the printing or of the card respectively. The canonical list +of all reference points with examples, including the `_fill_with_` and `_substr_` modifiers, lives in +@ref custom_card_pictures. + +The `!set:...!` and `!prop:...!` reference points also support two modifiers: + +- `_fill_with_` pads the value with the given text, right-aligned, e.g. `!set:num_fill_with_000!` turns collector + number `1` into `001`. If the value is longer than the fill text, the template is invalidated. +- `_substr__` extracts a substring, e.g. `!set:num_substr_2_2!` takes two characters starting at the + third. If the substring would extend past the end of the value, the template is invalidated. + +Substituted values are percent-encoded. If a template asks for a property the card or printing does not have (or one of +the modifiers invalidates it), the template yields no URL and is skipped; the next template is tried instead. + +\attention Custom URLs should start with `http://` or `https://`. The scheme is not validated before the URL is handed +to QNetworkAccessManager, so a template without an absolute scheme may silently fail to download; prefer HTTPS where the +provider allows it. + +The resolution order is: for the current set, try each URL in the list; when all URLs for a set are exhausted, move to +the next set; when every set is exhausted, the load fails. A failed load is reported through the NULL-pixmap mechanism +described in the PixmapCacheKeys and ProviderIDs section above. + +Several mechanisms influence the resolution process: + +- **Rate limiting.** The worker allows roughly 10 requests per second globally. A server that answers with HTTP 429 + gets its per-host allowance halved; the first 429 for a host is waited out (honoring the `Retry-After` header if + present) and the same URL retried, while a second 429 makes the loader fall through to the other configured sources. + When all sources are exhausted the request is deferred with some random jitter and retried once the back-off expires. +- **Redirects.** Replies with a redirect status (301, 302, 303, 305, 307, 308) are followed and recorded in the + redirect cache as described in the Redirect Cache section above. +- **Blacklisted images.** Gatherer returns the card back image for cards it does not know. A few known MD5 hashes of + that image are blacklisted, so such a "successful" download is treated as not found instead of being shown. +- **WebP.** Images detected as WebP (RIFF/WEBP header) are decoded through QMovie instead of QImageReader. +- **Downloads disabled.** When "Download card pictures on the fly" is disabled and the network cache method is active, + requests use Qt's `AlwaysCache` policy so that only previously cached images are served. + +A user-facing reference for writing download URL templates, including more worked examples, is available at +@ref custom_card_pictures. diff --git a/doc/doxygen/extra-pages/user_documentation/card_pictures/custom_card_pictures.md b/doc/doxygen/extra-pages/user_documentation/card_pictures/custom_card_pictures.md new file mode 100644 index 000000000..3f1079661 --- /dev/null +++ b/doc/doxygen/extra-pages/user_documentation/card_pictures/custom_card_pictures.md @@ -0,0 +1,135 @@ +@page custom_card_pictures Custom Card Pictures + +There are four ways to make Cockatrice use custom artwork for your cards: + +- Placing image files in the **CUSTOM pictures folder**. +- Providing a **custom card database** that points each printing at a picture URL via the `picurl` property. +- Writing your **own download URL templates**. +- Setting an **image override** for a single card from inside the deck editor. + +Each of these is described below. If pictures are missing or wrong, see @ref fixing_card_pictures instead. + +# Custom Pictures Folder (CUSTOM) + +Any image file placed in the CUSTOM folder is used as the card picture, and no download is attempted for cards that +match a file there. + +- The folder is `/CUSTOM/`. The pictures directory is configured on the 'General' settings tab, + under 'Directories' → 'Pictures directory'. +- Any image format Qt can decode is accepted (PNG, JPG/JPEG, WebP, GIF, BMP, ...); the file extension is not filtered, + so even an extension-less file is picked up if the decoder recognizes its content. +- Files are indexed by their name, so you can organize them into subfolders freely. +- New or changed files are picked up automatically within a few seconds — no client restart is required. + +The file name must match the card using one of the naming schemes below. Both `_` and `-` are accepted as separators, +and the file extension is ignored when matching: + +| Scheme | Example file name | +| --------------------------- | ------------------------------------------------------- | +| Card Name | `Example Card.png` | +| Card Name + Set | `Example Card_DDL.png` | +| Card Name + Set + Collector | `Example Card_DDL_43.png` | +| Set + Collector + Card Name | `DDL_43_Example Card.png` | +| Card Name + Provider ID | `Example Card_0b23cdc8-d413-4fb1-8470-474221b10fe2.png` | + +The name used for matching is the *corrected* card name. Correction removes the split-card separator ` // ` and the +characters reserved in Windows file names (`* < > : " \ ?` and control characters), and turns `/` into a space, so the +"Example // Card" card is matched by a file named `ExampleCard.png`, not `Example // Card.png`. Most other punctuation +(commas, apostrophes, `!`, ...) is left untouched. + +\attention A file in the CUSTOM folder always wins over downloaded pictures, even if it is the wrong image. Delete the +file if you want to see the downloaded artwork again. + +The naming conventions are the same as those recognized in the set-named subfolders and in `downloadedPics`, and are +documented for developers in @ref loading_card_pictures. + +# Custom Card Database (picurl) + +If you maintain your own card database (see the +[Custom Cards & Sets](https://github.com/Cockatrice/Cockatrice/wiki/Custom-Cards-&-Sets) wiki), each printing's `` +tag can carry a `picurl` attribute containing a full URL for that printing's picture: + +```xml + +``` + +Cockatrice tries this URL **before** the configured download URL templates, so it is the most direct way to provide +custom artwork for a specific printing. + +- The URL should start with `http://` or `https://`; the scheme is not validated, so make sure it is absolute or the + download may silently fail. +- When you change a `picurl` for a card whose picture was already downloaded and cached, delete the stored images + (Storage tab → 'Delete Saved Images' / 'Delete Cached Images') so Cockatrice fetches the new URL. + +# Custom Download URL Templates + +The built-in download URLs are templates: Cockatrice replaces reference points in the URL with information about the +card and its printing. You can write your own templates in 'Cockatrice → Settings' (Ctrl + Shift + P by default), on +the 'Deck Editor' tab, in the 'URL Download Priority' section. + +The following reference points are available: + +| Reference point | Description | Example | +| ------------------------------- | ------------------------------- | -------------- | +| `!name!` | Card name | `Example Card` | +| `!name_lower!` | Card name, lower case | `example card` | +| `!corrected_name!` | Corrected card name | `ExampleCard` (instead of "Example // Card") | +| `!corrected_name_lower!` | Corrected card name, lower case | `examplecard` | +| `!sflang!` | Scryfall language code for the current client language; defaults to English when the language has no localized images | `en`, `zhs` | +| `!setcode!` / `!setcode_lower!` | Set code | `EXM` / `exm` | +| `!setname!` / `!setname_lower!` | Full set name | `Exemplary Set` / `exemplary set` | +| `!set:!` | A property of this printing, e.g. `muid` (Gatherer multiverse ID), `uuid` (Scryfall UUID), `num` (collector number), `rarity` | `373549` | +| `!prop:!` | A property of the card, e.g. `side` (front/back), `colors`, `cmc`, `coloridentity`, `type`, `pt`, and the format legality statuses | `front` | + +The `!set:...!` and `!prop:...!` reference points support two modifiers: + +- `_fill_with_` pads the value with the given text, right-aligned, e.g. `!set:num_fill_with_000!` turns collector + number `1` into `001`. If the value is longer than the fill text, the template is skipped. +- `_substr__` extracts a substring, e.g. `!set:num_substr_2_2!` takes two characters starting at the + third. If the substring would extend past the end of the value, the template is skipped. + +Substituted values are URL-encoded. A template that asks for a property the card or printing does not have is skipped, +and the next template in the list is tried instead. + +\attention Custom URLs should start with `http://` or `https://`. As with `picurl`, the scheme is not validated before +the URL is handed to QNetworkAccessManager, so use an absolute URL or the download may silently fail. + +Some working examples: + +```text +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://gatherer.wizards.com/Handlers/Image.ashx?multiverseid=!set:muid!&type=card +https://gatherer.wizards.com/Handlers/Image.ashx?name=!name!&type=card +``` + +See the [Custom Picture Download URLs](https://github.com/Cockatrice/Cockatrice/wiki/Custom-Picture-Download-URLs) +wiki for more examples and ideas. + +\attention Keep in mind that templates using `!name!` or `!set:muid!` resolve by name or multiverse ID, not by the +exact printing. Only the Scryfall `!set:uuid!` templates always return the exact printing requested. See +@ref fixing_card_pictures for more on this. + +# Image Overrides + +The quickest way to give one card custom art is an image override: right-click the card in the deck editor's printing +selector and open the **Image Overrides** submenu of the context menu. + +- **Load Custom Image...** — choose a picture file (the dialog suggests PNG, JPG/JPEG and WebP); it becomes that card's + artwork immediately. +- **One entry per alternate printing** of the card, labeled ` ` (hovering an entry previews that + printing's artwork). Selecting one makes the card use that exact printing's picture, so e.g. a basic land can be shown + with any of its artworks. +- **Clear Custom Image** — removes the stored override and returns the card to normal resolution. It is only available + while the card has a stored override. + +Overrides are stored as `.png` files in `/downloadedPics/`, under the "Naming scheme" configured on +the Storage settings page, and are matched the same way as downloaded images. Because local files are checked before any +URL is requested, an override always wins over downloaded artwork and `picurl` for that card. The override exists only on +the machine it was created on - it is not part of the deck file - so a card with a stored override shows normally on +another computer. + +\attention If you also keep a matching file in the CUSTOM folder, that file is matched before the override. When you +change an override, use **Clear Custom Image** so the stored `.png` is replaced; manually deleting the file in +`downloadedPics/` has the same effect. diff --git a/doc/doxygen/extra-pages/user_documentation/index.md b/doc/doxygen/extra-pages/user_documentation/index.md index 468a28f8d..b55d00fcd 100644 --- a/doc/doxygen/extra-pages/user_documentation/index.md +++ b/doc/doxygen/extra-pages/user_documentation/index.md @@ -11,6 +11,10 @@ - @subpage beta_release +## Card Pictures + +- @subpage custom_card_pictures + ## Troubleshooting - @subpage fixing_card_pictures diff --git a/doc/doxygen/extra-pages/user_documentation/troubleshooting/fixing_card_pictures.md b/doc/doxygen/extra-pages/user_documentation/troubleshooting/fixing_card_pictures.md index 78ba5586b..7f761c020 100644 --- a/doc/doxygen/extra-pages/user_documentation/troubleshooting/fixing_card_pictures.md +++ b/doc/doxygen/extra-pages/user_documentation/troubleshooting/fixing_card_pictures.md @@ -28,7 +28,8 @@ valid URLs. If you suspect the list has been modified or corrupted, press 'Reset defaults. For information on how to add your own custom URL templates, see the 'How to add a custom URL' link in the same -settings section. +settings section, or @ref custom_card_pictures for a full reference of the URL reference points, the CUSTOM +pictures folder, and custom card databases. # Check Your Local Picture Folder @@ -41,8 +42,12 @@ Cockatrice checks the following locations, in order: - The custom pictures folder (recursively indexed by file name). - `//` - `/downloadedPics//` +- `/downloadedPics/` (for export naming schemes without a set folder) -The following import naming schemes are recognized (using both `_` and `-` as separators): +A file only matches when its name without the extension equals one of the recognized scheme patterns exactly. + +The following import naming schemes are recognized (using both `_` and `-` as separators). The canonical table with +concrete example file names is on @ref custom_card_pictures: | Scheme | Pattern | | --------------------------- | -------------------------- | @@ -56,6 +61,10 @@ If a picture you downloaded or placed manually is wrong, stale, or corrupted, de attention to the `downloadedPics` subfolder: this is where the filesystem caching method writes downloaded images, and after a provider outage it can permanently contain the wrong printing until you delete it manually. +If a card persistently shows artwork you assigned yourself, you may have an **image override** set for it. Right-click +the card in the deck editor's printing selector and use 'Image Overrides' → 'Clear Custom Image' to remove it (or +delete the stored `.png` in `downloadedPics/`). See @ref custom_card_pictures for details. + See @ref loading_card_pictures for details on how local images are loaded. # Clear Caches diff --git a/libcockatrice_card/libcockatrice/card/card_localization.h b/libcockatrice_card/libcockatrice/card/card_localization.h index a9c8d28ea..1bde506cb 100644 --- a/libcockatrice_card/libcockatrice/card/card_localization.h +++ b/libcockatrice_card/libcockatrice/card/card_localization.h @@ -4,6 +4,42 @@ #include #include +/** + * @brief The card languages card search should run against. + */ +enum class SearchLanguageMode +{ + English, ///< Only search the English card names and texts. + Selected, ///< Search the selected card language (untranslated cards still match in English). + Both ///< Search both the English and the selected card language names and texts. +}; + +/** + * @brief The card language and matching mode searches run against. + * + * Bundles the card language code configured in the settings with the + * SearchLanguageMode, so entry points take one value instead of two related + * parameters. + */ +struct CardSearchLanguage +{ + QString language; ///< Card language code (e.g. "de"); empty means the English fallback. + SearchLanguageMode mode = SearchLanguageMode::English; ///< How the language participates in the search. + + /** + * @brief Whether only the English card data is searched. + * + * @return True when no card language is selected or English itself is selected. + */ + [[nodiscard]] bool isEnglishOnly() const + { + return language.isEmpty() || language == QLatin1String("en"); + } + + bool operator==(const CardSearchLanguage &) const = default; + bool operator!=(const CardSearchLanguage &) const = default; +}; + /** * @namespace CardLocalization * @ingroup Cards diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp index aaf391c03..df0c70ae7 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp @@ -20,7 +20,8 @@ SomewhatComplexQueryPart <- [(] QueryPartList [)] / QueryPart QueryPart <- NotQuery / SetQuery / RarityQuery / CMCQuery / FormatQuery / PowerQuery / ToughnessQuery / ColorQuery / TypeQuery / OracleQuery / FieldQuery / GenericQuery NotQuery <- ('NOT' ws/'-') SomewhatComplexQueryPart -SetQuery <- ('e'/'set') SetExpression / ([:] FlexStringValue) +SetQuery <- ('e'/'set') SetQueryValue +SetQueryValue <- ([=:] FlexStringValue) / (<[!][=]?> FlexStringValue) / SetExpression OracleQuery <- 'o' [:] MatcherString @@ -74,6 +75,36 @@ NumericValue <- [0-9]+ static std::once_flag init; +// The peglib parser rules (and therefore their rule actions) are set up once per +// process, so a rule action cannot capture per-instance state. The card language +// plain-text name and text queries search in is therefore handed to the GenericQuery +// and OracleQuery rule actions through this thread-local context, which is live only +// while a FilterString is being parsed. The rule actions copy it into the filter +// closures they produce, so card evaluation never reads process-global state. +thread_local CardSearchLanguage searchLanguageContext; + +namespace +{ +bool matchesInSearchLanguage(const QString &english, + const QString &localized, + const CardSearchLanguage &searchLanguage, + const StringMatcher &matcher) +{ + if (searchLanguage.mode == SearchLanguageMode::English) { + return matcher(english); + } + + if (searchLanguage.mode == SearchLanguageMode::Both) { + if (!searchLanguage.isEnglishOnly() && matcher(localized)) { + return true; + } + return matcher(english); + } + + return searchLanguage.isEnglishOnly() ? matcher(english) : matcher(localized); +} +} // namespace + static void setupParserRules() { auto passthru = [](const peg::SemanticValues &sv) -> Filter { @@ -103,8 +134,8 @@ static void setupParserRules() const auto matcher = std::any_cast(sv[0]); return [=](const CardData &x) -> bool { return matcher(x->getCardType()); }; }; - search["SetQuery"] = [](const peg::SemanticValues &sv) -> Filter { - if (sv.choice() == 1) { + search["SetQueryValue"] = [](const peg::SemanticValues &sv) -> Filter { + if (sv.choice() == 0) { auto matcher = std::any_cast(sv[0]); return [=](const CardData &x) -> bool { QList sets = x->getSets().keys(); @@ -113,6 +144,15 @@ static void setupParserRules() return std::any_of(sets.begin(), sets.end(), matchesSet); }; } + if (sv.choice() == 1) { + auto matcher = std::any_cast(sv[0]); + return [=](const CardData &x) -> bool { + QList sets = x->getSets().keys(); + + auto matchesSet = [&matcher](const QString &set) { return matcher(set); }; + return std::none_of(sets.begin(), sets.end(), matchesSet); + }; + } auto matcher = std::any_cast(sv[0]); return [=](const CardData &x) -> bool { @@ -333,7 +373,11 @@ static void setupParserRules() search["OracleQuery"] = [](const peg::SemanticValues &sv) -> Filter { const auto matcher = std::any_cast(sv[0]); - return [=](const CardData &x) { return matcher(x->getText()); }; + const CardSearchLanguage searchLanguage = searchLanguageContext; + return [=](const CardData &x) { + return matchesInSearchLanguage(x->getText(), x->getLocalizedText(searchLanguage.language), searchLanguage, + matcher); + }; }; search["ColorQuery"] = [](const peg::SemanticValues &sv) -> Filter { @@ -410,7 +454,11 @@ static void setupParserRules() }; search["GenericQuery"] = [](const peg::SemanticValues &sv) -> Filter { const auto matcher = std::any_cast(sv[0]); - return [=](const CardData &x) { return matcher(x->getName()); }; + const CardSearchLanguage searchLanguage = searchLanguageContext; + return [=](const CardData &x) { + return matchesInSearchLanguage(x->getName(), x->getLocalizedName(searchLanguage.language), searchLanguage, + matcher); + }; }; search["Color"] = [](const peg::SemanticValues &sv) -> char { return "WUBRGU"[sv.choice()]; }; @@ -425,7 +473,7 @@ FilterString::FilterString() _error = "Not initialized"; } -FilterString::FilterString(const QString &expr) +FilterString::FilterString(const QString &expr, const CardSearchLanguage &searchLanguage) { QByteArray ba = expr.simplified().toUtf8(); @@ -438,6 +486,8 @@ FilterString::FilterString(const QString &expr) return; } + searchLanguageContext = searchLanguage; + search.set_logger([&](size_t /*ln*/, size_t col, const std::string &msg) { _error = QString("Error at position %1: %2").arg(col).arg(QString::fromStdString(msg)); }); diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.h b/libcockatrice_filters/libcockatrice/filters/filter_string.h index a058f7d07..015df0cf4 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.h @@ -14,6 +14,7 @@ #include #include #include +#include #include inline Q_LOGGING_CATEGORY(FilterStringLog, "filter_string"); @@ -35,7 +36,7 @@ class FilterString { public: FilterString(); - explicit FilterString(const QString &exp); + explicit FilterString(const QString &exp, const CardSearchLanguage &searchLanguage = {}); [[nodiscard]] bool check(const CardData &card) const { if (card.isNull()) { diff --git a/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp b/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp index 8502db50b..a5d91d9d3 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp @@ -100,14 +100,16 @@ FilterTreeNode *FilterItemList::termNode(const QString &term) return childNodes.at(i); } -bool FilterItemList::testTypeAnd(const CardInfoPtr info, CardFilter::Attr attr) const +bool FilterItemList::testTypeAnd(const CardInfoPtr info, + CardFilter::Attr attr, + const CardSearchLanguage &searchLanguage) const { for (auto i = childNodes.constBegin(); i != childNodes.constEnd(); i++) { if (!(*i)->isEnabled()) { continue; } - if (!(*i)->acceptCardAttr(info, attr)) { + if (!(*i)->acceptCardAttr(info, attr, searchLanguage)) { return false; } } @@ -115,13 +117,17 @@ bool FilterItemList::testTypeAnd(const CardInfoPtr info, CardFilter::Attr attr) return true; } -bool FilterItemList::testTypeAndNot(const CardInfoPtr info, CardFilter::Attr attr) const +bool FilterItemList::testTypeAndNot(const CardInfoPtr info, + CardFilter::Attr attr, + const CardSearchLanguage &searchLanguage) const { // if any one in the list is true, return false - return !testTypeOr(info, attr); + return !testTypeOr(info, attr, searchLanguage); } -bool FilterItemList::testTypeOr(const CardInfoPtr info, CardFilter::Attr attr) const +bool FilterItemList::testTypeOr(const CardInfoPtr info, + CardFilter::Attr attr, + const CardSearchLanguage &searchLanguage) const { bool noChildEnabledChild = true; @@ -134,7 +140,7 @@ bool FilterItemList::testTypeOr(const CardInfoPtr info, CardFilter::Attr attr) c noChildEnabledChild = false; } - if ((*i)->acceptCardAttr(info, attr)) { + if ((*i)->acceptCardAttr(info, attr, searchLanguage)) { return true; } } @@ -142,20 +148,58 @@ bool FilterItemList::testTypeOr(const CardInfoPtr info, CardFilter::Attr attr) c return noChildEnabledChild; } -bool FilterItemList::testTypeOrNot(const CardInfoPtr info, CardFilter::Attr attr) const +bool FilterItemList::testTypeOrNot(const CardInfoPtr info, + CardFilter::Attr attr, + const CardSearchLanguage &searchLanguage) const { // if any one in the list is false, return true - return !testTypeAnd(info, attr); + return !testTypeAnd(info, attr, searchLanguage); } -bool FilterItem::acceptName(const CardInfoPtr info) const +bool FilterItem::acceptName(const CardInfoPtr info, const CardSearchLanguage &searchLanguage) const { - return info->getName().contains(term, Qt::CaseInsensitive); + const QString &englishName = info->getName(); + const QString &localizedName = info->getLocalizedName(searchLanguage.language); + + switch (searchLanguage.mode) { + case SearchLanguageMode::English: + return englishName.contains(term, Qt::CaseInsensitive); + case SearchLanguageMode::Both: + if (englishName.contains(term, Qt::CaseInsensitive)) { + return true; + } + return !searchLanguage.isEnglishOnly() && localizedName.contains(term, Qt::CaseInsensitive); + case SearchLanguageMode::Selected: + if (searchLanguage.isEnglishOnly()) { + return englishName.contains(term, Qt::CaseInsensitive); + } + return localizedName.contains(term, Qt::CaseInsensitive); + } + + return false; } -bool FilterItem::acceptNameExact(const CardInfoPtr info) const +bool FilterItem::acceptNameExact(const CardInfoPtr info, const CardSearchLanguage &searchLanguage) const { - return info->getName() == term; + const QString &englishName = info->getName(); + const QString &localizedName = info->getLocalizedName(searchLanguage.language); + + switch (searchLanguage.mode) { + case SearchLanguageMode::English: + return englishName == term; + case SearchLanguageMode::Both: + if (englishName == term) { + return true; + } + return !searchLanguage.isEnglishOnly() && localizedName == term; + case SearchLanguageMode::Selected: + if (searchLanguage.isEnglishOnly()) { + return englishName == term; + } + return localizedName == term; + } + + return false; } bool FilterItem::acceptType(const CardInfoPtr info) const @@ -213,9 +257,27 @@ bool FilterItem::acceptColor(const CardInfoPtr info) const return match_count == converted_term.length(); } -bool FilterItem::acceptText(const CardInfoPtr info) const +bool FilterItem::acceptText(const CardInfoPtr info, const CardSearchLanguage &searchLanguage) const { - return info->getText().contains(term, Qt::CaseInsensitive); + const QString &englishText = info->getText(); + const QString &localizedText = info->getLocalizedText(searchLanguage.language); + + switch (searchLanguage.mode) { + case SearchLanguageMode::English: + return englishText.contains(term, Qt::CaseInsensitive); + case SearchLanguageMode::Both: + if (englishText.contains(term, Qt::CaseInsensitive)) { + return true; + } + return !searchLanguage.isEnglishOnly() && localizedText.contains(term, Qt::CaseInsensitive); + case SearchLanguageMode::Selected: + if (searchLanguage.isEnglishOnly()) { + return englishText.contains(term, Qt::CaseInsensitive); + } + return localizedText.contains(term, Qt::CaseInsensitive); + } + + return false; } bool FilterItem::acceptSet(const CardInfoPtr info) const @@ -402,19 +464,21 @@ bool FilterItem::relationCheck(int cardInfo) const return result; } -bool FilterItem::acceptCardAttr(const CardInfoPtr info, CardFilter::Attr attr) const +bool FilterItem::acceptCardAttr(const CardInfoPtr info, + CardFilter::Attr attr, + const CardSearchLanguage &searchLanguage) const { switch (attr) { case CardFilter::AttrName: - return acceptName(info); + return acceptName(info, searchLanguage); case CardFilter::AttrNameExact: - return acceptNameExact(info); + return acceptNameExact(info, searchLanguage); case CardFilter::AttrType: return acceptType(info); case CardFilter::AttrColor: return acceptColor(info); case CardFilter::AttrText: - return acceptText(info); + return acceptText(info, searchLanguage); case CardFilter::AttrSet: return acceptSet(info); case CardFilter::AttrManaCost: @@ -484,18 +548,18 @@ FilterTreeNode *FilterTree::termNode(const CardFilter *f) return termNode(f->attr(), f->type(), f->term()); } -bool FilterTree::testAttr(const CardInfoPtr info, const LogicMap *lm) const +bool FilterTree::testAttr(const CardInfoPtr info, const LogicMap *lm, const CardSearchLanguage &searchLanguage) const { const FilterItemList *fil; bool status = true; fil = lm->findTypeList(CardFilter::TypeAnd); - if (fil && fil->isEnabled() && !fil->testTypeAnd(info, lm->attr)) { + if (fil && fil->isEnabled() && !fil->testTypeAnd(info, lm->attr, searchLanguage)) { return false; } fil = lm->findTypeList(CardFilter::TypeAndNot); - if (fil && fil->isEnabled() && !fil->testTypeAndNot(info, lm->attr)) { + if (fil && fil->isEnabled() && !fil->testTypeAndNot(info, lm->attr, searchLanguage)) { return false; } @@ -504,23 +568,23 @@ bool FilterTree::testAttr(const CardInfoPtr info, const LogicMap *lm) const status = false; // if this is true we can return because it is OR'd with the OrNot list - if (fil->testTypeOr(info, lm->attr)) { + if (fil->testTypeOr(info, lm->attr, searchLanguage)) { return true; } } fil = lm->findTypeList(CardFilter::TypeOrNot); - if (fil && fil->isEnabled() && fil->testTypeOrNot(info, lm->attr)) { + if (fil && fil->isEnabled() && fil->testTypeOrNot(info, lm->attr, searchLanguage)) { return true; } return status; } -bool FilterTree::acceptsCard(const CardInfoPtr info) const +bool FilterTree::acceptsCard(const CardInfoPtr info, const CardSearchLanguage &searchLanguage) const { for (auto i = childNodes.constBegin(); i != childNodes.constEnd(); i++) { - if ((*i)->isEnabled() && !testAttr(info, *i)) { + if ((*i)->isEnabled() && !testAttr(info, *i, searchLanguage)) { return false; } } diff --git a/libcockatrice_filters/libcockatrice/filters/filter_tree.h b/libcockatrice_filters/libcockatrice/filters/filter_tree.h index aac1777e0..dd47a1ebc 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_tree.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_tree.h @@ -11,6 +11,7 @@ #include #include +#include #include #include @@ -167,10 +168,14 @@ public: return CardFilter::typeName(type); } - [[nodiscard]] bool testTypeAnd(CardInfoPtr info, CardFilter::Attr attr) const; - [[nodiscard]] bool testTypeAndNot(CardInfoPtr info, CardFilter::Attr attr) const; - [[nodiscard]] bool testTypeOr(CardInfoPtr info, CardFilter::Attr attr) const; - [[nodiscard]] bool testTypeOrNot(CardInfoPtr info, CardFilter::Attr attr) const; + [[nodiscard]] bool + testTypeAnd(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; + [[nodiscard]] bool + testTypeAndNot(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; + [[nodiscard]] bool + testTypeOr(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; + [[nodiscard]] bool + testTypeOrNot(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; }; class FilterItem : public FilterTreeNode @@ -207,20 +212,21 @@ public: return true; } - [[nodiscard]] bool acceptName(CardInfoPtr info) const; - [[nodiscard]] bool acceptNameExact(CardInfoPtr info) const; + [[nodiscard]] bool acceptName(CardInfoPtr info, const CardSearchLanguage &searchLanguage) const; + [[nodiscard]] bool acceptNameExact(CardInfoPtr info, const CardSearchLanguage &searchLanguage) const; [[nodiscard]] bool acceptType(CardInfoPtr info) const; [[nodiscard]] bool acceptMainType(CardInfoPtr info) const; [[nodiscard]] bool acceptSubType(CardInfoPtr info) const; [[nodiscard]] bool acceptColor(CardInfoPtr info) const; - [[nodiscard]] bool acceptText(CardInfoPtr info) const; + [[nodiscard]] bool acceptText(CardInfoPtr info, const CardSearchLanguage &searchLanguage) const; [[nodiscard]] bool acceptSet(CardInfoPtr info) const; [[nodiscard]] bool acceptManaCost(CardInfoPtr info) const; [[nodiscard]] bool acceptCmc(CardInfoPtr info) const; [[nodiscard]] bool acceptPowerToughness(CardInfoPtr info, CardFilter::Attr attr) const; [[nodiscard]] bool acceptLoyalty(CardInfoPtr info) const; [[nodiscard]] bool acceptRarity(CardInfoPtr info) const; - [[nodiscard]] bool acceptCardAttr(CardInfoPtr info, CardFilter::Attr attr) const; + [[nodiscard]] bool + acceptCardAttr(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; [[nodiscard]] bool acceptFormat(CardInfoPtr info) const; [[nodiscard]] bool relationCheck(int cardInfo) const; }; @@ -240,7 +246,7 @@ private: LogicMap *attrLogicMap(CardFilter::Attr attr); FilterItemList *attrTypeList(CardFilter::Attr attr, CardFilter::Type type); - bool testAttr(CardInfoPtr info, const LogicMap *lm) const; + bool testAttr(CardInfoPtr info, const LogicMap *lm, const CardSearchLanguage &searchLanguage) const; void nodeChanged() const override { @@ -279,7 +285,7 @@ public: return 0; } - [[nodiscard]] bool acceptsCard(CardInfoPtr info) const; + [[nodiscard]] bool acceptsCard(CardInfoPtr info, const CardSearchLanguage &searchLanguage) const; void removeFiltersByAttr(CardFilter::Attr filterType); void removeFilter(const CardFilter *toRemove); void clear(); 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 304ab3cb7..2821fb96e 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h @@ -25,10 +25,12 @@ public: [[nodiscard]] virtual int getVisualDeckStorageCardSize() const = 0; [[nodiscard]] virtual int getVisualDatabaseDisplayCardSize() const = 0; [[nodiscard]] virtual int getVisualDeckEditorCardSize() const = 0; + [[nodiscard]] virtual bool getVisualDeckEditorShowCardCounts() const = 0; [[nodiscard]] virtual int getEDHRecCardSize() const = 0; [[nodiscard]] virtual int getArchidektPreviewSize() const = 0; [[nodiscard]] virtual int getSampleHandSize() const = 0; [[nodiscard]] virtual QString getCardLang() const = 0; + [[nodiscard]] virtual int getCardSearchLanguage() const = 0; }; #endif // COCKATRICE_INTERFACE_CARDS_DISPLAY_SETTINGS_PROVIDER_H diff --git a/libcockatrice_models/libcockatrice/models/database/card/card_search_model.cpp b/libcockatrice_models/libcockatrice/models/database/card/card_search_model.cpp index 621f28983..3a734ed37 100644 --- a/libcockatrice_models/libcockatrice/models/database/card/card_search_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card/card_search_model.cpp @@ -65,25 +65,31 @@ void CardSearchModel::updateSearchResults(const QString &query) continue; } - const QString lowerName = card->getName().toLower(); - if (!lowerName.contains(lowerQuery)) { - continue; - } + // The completer suggestions match against the same languages the card + // search uses, so typing a localized name finds the card. In Both mode + // either language can match. + for (const QString &matchName : searchableNames(card)) { + const QString lowerName = matchName.toLower(); + if (!lowerName.contains(lowerQuery)) { + continue; + } - const int distance = levenshteinDistance(lowerQuery, lowerName); + const int distance = levenshteinDistance(lowerQuery, lowerName); - if (lowerName.startsWith(lowerQuery)) { - prefixMatches.append({card, distance}); - } else { - containsMatches.append({card, distance}); + if (lowerName.startsWith(lowerQuery)) { + prefixMatches.append({card, distance}); + } else { + containsMatches.append({card, distance}); + } + break; } } - auto sortByDistanceThenLength = [](const SearchResult &a, const SearchResult &b) { + auto sortByDistanceThenLength = [this](const SearchResult &a, const SearchResult &b) { if (a.distance != b.distance) { return a.distance < b.distance; } - return a.card->getName().size() < b.card->getName().size(); + return sortableName(a.card).size() < sortableName(b.card).size(); }; std::sort(prefixMatches.begin(), prefixMatches.end(), sortByDistanceThenLength); @@ -101,3 +107,25 @@ void CardSearchModel::updateSearchResults(const QString &query) endResetModel(); } + +QStringList CardSearchModel::searchableNames(const CardInfoPtr &card) const +{ + if (searchLanguage.isEnglishOnly()) { + return {card->getName()}; + } + + const QString localizedName = card->getLocalizedName(searchLanguage.language); + if (searchLanguage.mode == SearchLanguageMode::Selected) { + return {localizedName}; + } + + return {card->getName(), localizedName}; +} + +QString CardSearchModel::sortableName(const CardInfoPtr &card) const +{ + if (searchLanguage.isEnglishOnly()) { + return card->getName(); + } + return card->getLocalizedName(searchLanguage.language); +} diff --git a/libcockatrice_models/libcockatrice/models/database/card/card_search_model.h b/libcockatrice_models/libcockatrice/models/database/card/card_search_model.h index 646bf7e61..b877a9385 100644 --- a/libcockatrice_models/libcockatrice/models/database/card/card_search_model.h +++ b/libcockatrice_models/libcockatrice/models/database/card/card_search_model.h @@ -27,6 +27,14 @@ public: void updateSearchResults(const QString &query); // Update results based on input + void setSearchLanguage(const CardSearchLanguage &searchLang) + { + if (searchLanguage == searchLang) { + return; + } + searchLanguage = searchLang; + } + private: struct SearchResult { @@ -34,8 +42,15 @@ private: int distance; }; + /** @brief The names a card is searched by with the current search language. */ + [[nodiscard]] QStringList searchableNames(const CardInfoPtr &card) const; + + /** @brief The name used to break distance ties when sorting suggestions. */ + [[nodiscard]] QString sortableName(const CardInfoPtr &card) const; + CardDatabaseDisplayModel *sourceModel; QList searchResults; + CardSearchLanguage searchLanguage; }; #endif // CARD_SEARCH_MODEL_H diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp index 724ee61f2..7f89677c8 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp @@ -179,7 +179,7 @@ bool CardDatabaseDisplayModel::filterAcceptsRow(int sourceRow, const QModelIndex } if (filterString != nullptr) { - if (filterTree != nullptr && !filterTree->acceptsCard(info)) { + if (filterTree != nullptr && !filterTree->acceptsCard(info, searchLanguage)) { return false; } return filterString->check(info); @@ -190,8 +190,14 @@ bool CardDatabaseDisplayModel::filterAcceptsRow(int sourceRow, const QModelIndex bool CardDatabaseDisplayModel::rowMatchesCardName(CardInfoPtr info) const { - if (!cardName.isEmpty() && !info->getName().contains(cardName, Qt::CaseInsensitive)) { - return false; + if (!cardName.isEmpty()) { + const bool matchesEnglish = info->getName().contains(cardName, Qt::CaseInsensitive); + const bool matchesLocalized = + !searchLanguage.isEnglishOnly() && + info->getLocalizedName(searchLanguage.language).contains(cardName, Qt::CaseInsensitive); + if (!matchesEnglish && !matchesLocalized) { + return false; + } } if (!cardNameSet.isEmpty() && !cardNameSet.contains(info->getName())) { @@ -199,7 +205,7 @@ bool CardDatabaseDisplayModel::rowMatchesCardName(CardInfoPtr info) const } if (filterTree != nullptr) { - return filterTree->acceptsCard(info); + return filterTree->acceptsCard(info, searchLanguage); } return true; @@ -235,6 +241,28 @@ void CardDatabaseDisplayModel::setFilterTree(FilterTree *_filterTree) invalidate(); } +void CardDatabaseDisplayModel::setStringFilter(const QString &_src) +{ + searchText = _src; + delete filterString; + filterString = new FilterString(_src, searchLanguage); + dirty(); +} + +void CardDatabaseDisplayModel::setSearchLanguage(const CardSearchLanguage &searchLang) +{ + if (searchLanguage == searchLang) { + return; + } + + searchLanguage = searchLang; + + if (filterString != nullptr) { + setStringFilter(searchText); + } + dirty(); +} + void CardDatabaseDisplayModel::filterTreeChanged() { invalidate(); diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h index c3145c356..e6aadd8c6 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h +++ b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h @@ -10,6 +10,7 @@ #include #include +#include #include class FilterTree; @@ -32,6 +33,8 @@ private: FilterString *filterString; int loadedRowCount; QTimer dirtyTimer; + CardSearchLanguage searchLanguage; + QString searchText; /** The translation table that will be used for sanitizeCardName. */ static QMap characterTranslation; @@ -55,17 +58,13 @@ public: cardName = sanitizeCardName(_cardName, characterTranslation); dirty(); } - void setStringFilter(const QString &_src) - { - delete filterString; - filterString = new FilterString(_src); - dirty(); - } + void setStringFilter(const QString &_src); void setCardNameSet(const QSet &_cardNameSet) { cardNameSet = _cardNameSet; dirty(); } + void setSearchLanguage(const CardSearchLanguage &searchLang); void dirty() { diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp index 53608db65..de2cc720f 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp @@ -559,6 +559,15 @@ void RemoteClient::ping() PendingCommand *pend = i.next().value(); if (pend->tick() > maxTimeout) { i.remove(); + // Answer the command instead of dropping it silently: the connection may still + // be alive (other traffic keeps lastDataReceived fresh) while this single + // command stalled, and awaiters such as TabServer's room-join dedup can only + // release their in-flight markers when `finished` fires. A silent drop wedges + // them permanently - e.g. a room whose join timing never surfaces again. + Response response; + response.set_response_code(Response::RespNotConnected); + response.set_cmd_id(pend->getCommandContainer().cmd_id()); + pend->processResponse(response); pend->deleteLater(); } } 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/cards_display_settings.cpp b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp index b8eafca6c..3af607198 100644 --- a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp @@ -90,6 +90,11 @@ int CardsDisplaySettings::getVisualDeckEditorCardSize() const return getValue("visualDeckEditor", "cards", "cardSize", 100).toInt(); } +bool CardsDisplaySettings::getVisualDeckEditorShowCardCounts() const +{ + return getValue("visualDeckEditorShowCardCounts", QString(), QString(), true).toBool(); +} + int CardsDisplaySettings::getEDHRecCardSize() const { return getValue("edhrec", "cards", "cardSize", 100).toInt(); @@ -110,6 +115,11 @@ QString CardsDisplaySettings::getCardLang() const return getValue("cardLang", QString(), QString(), "en").toString(); } +int CardsDisplaySettings::getCardSearchLanguage() const +{ + return getValue("cardSearchLanguage", QString(), QString(), static_cast(SearchLanguageMode::English)).toInt(); +} + void CardsDisplaySettings::setDisplayCardNames(bool _displayCardNames) { setValue(_displayCardNames, "displayCardNames"); @@ -212,6 +222,15 @@ void CardsDisplaySettings::setVisualDeckEditorCardSize(int _cardSize) emit visualDeckEditorCardSizeChanged(); } +void CardsDisplaySettings::setVisualDeckEditorShowCardCounts(bool _showCardCounts) +{ + if (_showCardCounts == getVisualDeckEditorShowCardCounts()) { + return; + } + setValue(_showCardCounts, "visualDeckEditorShowCardCounts"); + emit visualDeckEditorShowCardCountsChanged(_showCardCounts); +} + void CardsDisplaySettings::setEDHRecCardSize(int _edhrecCardSize) { setValue(_edhrecCardSize, "edhrec", "cards", "cardSize"); @@ -242,3 +261,12 @@ void CardsDisplaySettings::setCardLang(const QString &_cardLang) sync(); emit cardLangChanged(_cardLang); } + +void CardsDisplaySettings::setCardSearchLanguage(int _cardSearchLanguage) +{ + if (_cardSearchLanguage == getCardSearchLanguage()) { + return; + } + setValue(_cardSearchLanguage, "cardSearchLanguage"); + emit cardSearchLanguageChanged(_cardSearchLanguage); +} diff --git a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h index 0b47ce490..77924d14c 100644 --- a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h @@ -3,6 +3,7 @@ #include "settings_manager.h" +#include #include class CardsDisplaySettings : public SettingsManager, public ICardsDisplaySettingsProvider @@ -28,10 +29,12 @@ public: [[nodiscard]] int getVisualDeckStorageCardSize() const override; [[nodiscard]] int getVisualDatabaseDisplayCardSize() const override; [[nodiscard]] int getVisualDeckEditorCardSize() const override; + [[nodiscard]] bool getVisualDeckEditorShowCardCounts() const override; [[nodiscard]] int getEDHRecCardSize() const override; [[nodiscard]] int getArchidektPreviewSize() const override; [[nodiscard]] int getSampleHandSize() const override; [[nodiscard]] QString getCardLang() const override; + [[nodiscard]] int getCardSearchLanguage() const override; void setDisplayCardNames(bool _displayCardNames); void setRoundCardCorners(bool _roundCardCorners); @@ -50,10 +53,12 @@ public: void setVisualDeckStorageCardSize(int _cardSize); void setVisualDatabaseDisplayCardSize(int _cardSize); void setVisualDeckEditorCardSize(int _cardSize); + void setVisualDeckEditorShowCardCounts(bool _showCardCounts); void setEDHRecCardSize(int _edhrecCardSize); void setArchidektPreviewCardSize(int _archidektPreviewCardSize); void setSampleHandSize(int _sampleHandSize); void setCardLang(const QString &_cardLang); + void setCardSearchLanguage(int _cardSearchLanguage); signals: void displayCardNamesChanged(); @@ -67,10 +72,12 @@ signals: void visualDeckStorageCardSizeChanged(); void visualDatabaseDisplayCardSizeChanged(); void visualDeckEditorCardSizeChanged(); + void visualDeckEditorShowCardCountsChanged(bool showCardCounts); void edhRecCardSizeChanged(); void archidektPreviewSizeChanged(); void sampleHandSizeChanged(int amount); void cardLangChanged(const QString &lang); + void cardSearchLanguageChanged(int cardSearchLanguage); 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 eb73e58ee..6e22a84f4 100644 --- a/libcockatrice_settings/libcockatrice/settings/download_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/download_settings.cpp @@ -12,6 +12,22 @@ const QStringList DownloadSettings::DEFAULT_DOWNLOAD_URLS = { const QString DownloadSettings::SCRYFALL_NAMED_LOCALIZED_URL = "https://api.scryfall.com/cards/named?fuzzy=!localizedName!&lang=!sflang!&format=image&face=!prop:side!"; +// Developer-set ceilings for the per-host request allowance. Users may lower a host's +// allowance via the download settings, but can never raise it above these values. Hosts +// not listed default to DEFAULT_HOST_REQUEST_LIMIT. A cap of UNLIMITED_HOST_QUOTA marks a +// host that is never throttled per host and skips the dispatch pacing (429 backoff still applies). +const QHash DownloadSettings::DEVELOPER_HOST_CAPS = { + // The Scryfall API enforces 10 requests/second; stay one under so a burst can't trip 429s. + {"api.scryfall.com", 9}, + // The Scryfall image CDN has no documented per-client rate limit. + {"cards.scryfall.io", UNLIMITED_HOST_QUOTA}, +}; + +const QHash &DownloadSettings::getDeveloperHostCaps() +{ + return DEVELOPER_HOST_CAPS; +} + DownloadSettings::DownloadSettings(const QString &settingPath, QObject *parent = nullptr) : SettingsManager(settingPath + "downloads.ini", "downloads", QString(), parent) { @@ -65,3 +81,57 @@ void DownloadSettings::setDownloadSpoilerStatus(bool _spoilerStatus) setValue(_spoilerStatus, "downloadSpoilers"); emit downloadSpoilerStatusChanged(); } + +QHash DownloadSettings::getHostRequestLimits() const +{ + auto settings = getSettings(); + if (!defaultGroup.isEmpty()) { + settings.beginGroup(defaultGroup); + } + settings.beginGroup("hostRequestLimits"); + + QHash hostRequestLimits; + const QStringList hosts = settings.childKeys(); + for (const QString &host : hosts) { + hostRequestLimits.insert(host, settings.value(host).toInt()); + } + + settings.endGroup(); + if (!defaultGroup.isEmpty()) { + settings.endGroup(); + } + return hostRequestLimits; +} + +void DownloadSettings::setHostRequestLimits(const QHash &hostRequestLimits) +{ + auto settings = getSettings(); + if (!defaultGroup.isEmpty()) { + settings.beginGroup(defaultGroup); + } + + // Drop the legacy single-key form (an opaque @Variant blob) written by earlier builds so each + // host is stored as a plain, hand-editable key in its own subgroup. + settings.remove("hostRequestLimits"); + settings.beginGroup("hostRequestLimits"); + settings.remove(QString()); + for (auto it = hostRequestLimits.cbegin(); it != hostRequestLimits.cend(); ++it) { + settings.setValue(it.key(), it.value()); + } + settings.endGroup(); + + if (!defaultGroup.isEmpty()) { + settings.endGroup(); + } + settings.sync(); + emit hostRequestLimitsChanged(); +} + +int DownloadSettings::clampHostRequestLimit(const QString &host, int requested) const +{ + const int devCap = DEVELOPER_HOST_CAPS.value(host, DEFAULT_HOST_REQUEST_LIMIT); + if (devCap == UNLIMITED_HOST_QUOTA) { + return qMax(MIN_HOST_REQUEST_LIMIT, requested); + } + return qBound(MIN_HOST_REQUEST_LIMIT, requested, devCap); +} diff --git a/libcockatrice_settings/libcockatrice/settings/download_settings.h b/libcockatrice_settings/libcockatrice/settings/download_settings.h index ae49884f0..7eca77df6 100644 --- a/libcockatrice_settings/libcockatrice/settings/download_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/download_settings.h @@ -9,6 +9,8 @@ #include "settings_manager.h" +#include + class DownloadSettings : public SettingsManager { Q_OBJECT @@ -16,8 +18,35 @@ class DownloadSettings : public SettingsManager static const QStringList DEFAULT_DOWNLOAD_URLS; static const QString SCRYFALL_NAMED_LOCALIZED_URL; + static const QHash DEVELOPER_HOST_CAPS; public: + /** @brief Per-host request allowance (requests/second) when no developer cap applies. */ + static constexpr int DEFAULT_HOST_REQUEST_LIMIT = 10; + /** @brief Floor for any per-host request allowance. */ + static constexpr int MIN_HOST_REQUEST_LIMIT = 1; + /** + * @brief Upper bound offered to the user when lowering an unlocked host's allowance. + * + * Unlocked hosts have no developer cap, so `clampHostRequestLimit` puts no upper bound on + * them; this only bounds what the settings dialog offers, and matches the widest paced + * allowance a user is documented to be able to hand-edit in `downloads.ini`. Choosing 0 + * below this restores the "unlimited" fast path. + */ + static constexpr int UNLOCKED_HOST_LIMIT_MAX = 50; + /** @brief Developer cap marking a host as never throttled per host or by the dispatch pacing. */ + static constexpr int UNLIMITED_HOST_QUOTA = -1; + + /** + * @brief Developer-set per-host allowance ceilings (requests/second), keyed by host. + * + * Hosts not present default to DEFAULT_HOST_REQUEST_LIMIT. An entry of + * UNLIMITED_HOST_QUOTA marks a host that users may still lower, but that is never + * throttled per host by default. Users can never raise a host's allowance above its + * developer cap. + */ + static const QHash &getDeveloperHostCaps(); + explicit DownloadSettings(const QString &, QObject *); QStringList getAllURLs() const; @@ -29,9 +58,23 @@ public: [[nodiscard]] bool getDownloadSpoilersStatus() const; void setDownloadSpoilerStatus(bool _spoilerStatus); + /** @brief User-set per-host request allowances (requests/second). Missing hosts use the developer default. */ + QHash getHostRequestLimits() const; + void setHostRequestLimits(const QHash &hostRequestLimits); + + /** + * @brief Clamps the user's requested allowance for a host against its developer cap. + * @param host The host to clamp for + * @param requested The user-requested allowance in requests/second + * @return The effective allowance. Users may lower a host's allowance but never raise it + * above the developer cap; hosts with UNLIMITED_HOST_QUOTA have no upper bound. + */ + [[nodiscard]] int clampHostRequestLimit(const QString &host, int requested) const; + signals: void picDownloadChanged(); void downloadSpoilerStatusChanged(); + void hostRequestLimitsChanged(); }; #endif // COCKATRICE_DOWNLOADSETTINGS_H 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/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/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index db23f7951..ab647258c 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -6,8 +6,12 @@ set(CMAKE_AUTOUIC ON) set(CMAKE_AUTORCC ON) set(UTILITY_SOURCES - libcockatrice/utility/cryptoutil.cpp libcockatrice/utility/expression.cpp libcockatrice/utility/levenshtein.cpp - libcockatrice/utility/passwordhasher.cpp libcockatrice/utility/server_rate_limiter.cpp + libcockatrice/utility/cryptoutil.cpp + libcockatrice/utility/expression.cpp + libcockatrice/utility/levenshtein.cpp + libcockatrice/utility/passwordhasher.cpp + libcockatrice/utility/report_categories.cpp + libcockatrice/utility/server_rate_limiter.cpp libcockatrice/utility/warning_categories.cpp ) @@ -20,6 +24,7 @@ set(UTILITY_HEADERS libcockatrice/utility/macros.h libcockatrice/utility/passwordhasher.h libcockatrice/utility/playmat_params.h + libcockatrice/utility/report_categories.h libcockatrice/utility/string_limits.h libcockatrice/utility/dice_limits.h libcockatrice/utility/counter_limits.h diff --git a/libcockatrice_utility/libcockatrice/utility/report_categories.cpp b/libcockatrice_utility/libcockatrice/utility/report_categories.cpp new file mode 100644 index 000000000..8551c1ca1 --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/report_categories.cpp @@ -0,0 +1,16 @@ +#include "report_categories.h" + +namespace ReportCategories +{ +const QStringList &keys() +{ + static const QStringList categories = {"cheating", "bug_abuse", "harassment", "verbal_abuse", + "hate_speech", "spam", "other"}; + return categories; +} + +bool isValid(const QString &key) +{ + return keys().contains(key, Qt::CaseInsensitive); +} +} // namespace ReportCategories \ No newline at end of file diff --git a/libcockatrice_utility/libcockatrice/utility/report_categories.h b/libcockatrice_utility/libcockatrice/utility/report_categories.h new file mode 100644 index 000000000..5758c3dcf --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/report_categories.h @@ -0,0 +1,24 @@ +#ifndef REPORT_CATEGORIES_H +#define REPORT_CATEGORIES_H + +#include +#include + +/** + * Canonical report categories, shared by the client and the server. + * + * This is the single source of truth for the category keys exchanged over the + * wire (Command_Report.category) and stored in the reports table. The server + * only accepts keys from this list and the client report dialog only offers + * keys from this list, so the two sides cannot drift apart again. + */ +namespace ReportCategories +{ +/** @brief The complete list of valid category keys, in canonical order. */ +const QStringList &keys(); + +/** @brief Whether @a key is a valid report category (case-insensitive). */ +bool isValid(const QString &key); +} // namespace ReportCategories + +#endif // REPORT_CATEGORIES_H \ No newline at end of file diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index 392184b6e..d8f30dcad 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -31,8 +31,6 @@ set(oracle_SOURCES ../cockatrice/src/interface/pixel_map_generator.cpp ../cockatrice/src/interface/theme_config.cpp ../cockatrice/src/interface/theme_manager.cpp - ../cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp - ../cockatrice/src/interface/widgets/quick_settings/settings_popup_widget.cpp ${VERSION_STRING_CPP} ) @@ -123,10 +121,14 @@ target_link_libraries( oracle PUBLIC libcockatrice_card PUBLIC libcockatrice_settings - PUBLIC libcockatrice_network + PUBLIC libcockatrice_protocol PUBLIC ${ORACLE_QT_MODULES} ) +# Only user_level.h is needed from the network headers (pulled in transitively +# by pixel_map_generator.h); nothing from the network libraries is linked. +target_include_directories(oracle PRIVATE ${CMAKE_SOURCE_DIR}/libcockatrice_network) + if(ZLIB_FOUND) target_link_libraries(oracle PUBLIC ${ZLIB_LIBRARIES}) endif() @@ -213,10 +215,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/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 af6118646..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,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -1079,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 f0e369449..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 @@ -80,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..9c78c48f3 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 @@ -113,6 +128,7 @@ #include #include #include +#include #include #include #include @@ -277,6 +293,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 +342,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 +598,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 +685,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 +693,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 +955,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 +995,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 +1011,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 +1109,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) { @@ -3261,8 +3776,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReport(const Command_Re return Response::RespInvalidData; } - static const QStringList validCategories = {"cheating", "bug_abuse", "verbal_abuse", "other"}; - if (!validCategories.contains(category.toLower())) { + if (!ReportCategories::isValid(category)) { return Response::RespInvalidData; } 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 7bb834d7e..d80995d26 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -14,6 +14,7 @@ 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 report_categories_test COMMAND report_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) @@ -37,6 +38,7 @@ 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(report_categories_test report_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) @@ -91,6 +93,7 @@ if(NOT GTEST_FOUND) add_dependencies(server_developer_role_test gtest) add_dependencies(server_game_join_test gtest) add_dependencies(warning_categories_test gtest) + add_dependencies(report_categories_test gtest) add_dependencies(lag_monitor_test gtest) add_dependencies(latency_tracker_test gtest) add_dependencies(metrics_registry_test gtest) @@ -137,6 +140,9 @@ target_link_libraries( target_link_libraries( warning_categories_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} ) +target_link_libraries( + report_categories_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} +) target_link_libraries(lag_monitor_test Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}) target_link_libraries( latency_tracker_test libcockatrice_network Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} @@ -155,3 +161,9 @@ add_subdirectory(loading_from_clipboard) add_subdirectory(movecard_tests) add_subdirectory(oracle) add_subdirectory(settings) + +# picture_loader_benchmark links libcockatrice_settings, which only exists when +# a client/UI-capable target is being built. +if(WITH_ORACLE OR WITH_CLIENT) + add_subdirectory(picture_loader_benchmark) +endif() diff --git a/tests/carddatabase/filter_string_test.cpp b/tests/carddatabase/filter_string_test.cpp index c6d68be1f..dcf1cf0a2 100644 --- a/tests/carddatabase/filter_string_test.cpp +++ b/tests/carddatabase/filter_string_test.cpp @@ -2,6 +2,9 @@ #include "test_card_database_path_provider.h" #include "gtest/gtest.h" +#include +#include +#include #include #include #include @@ -71,10 +74,142 @@ QUERY(Color2, cat, "c:gw", true) QUERY(Color3, cat, "c!g", true) QUERY(Color4, cat, "c!gw", false) +QUERY(SetCodeCaseInsensitive1, cat, "set:cat", true) +QUERY(SetCodeCaseInsensitive2, cat, "set:CAT", true) +QUERY(SetCodeCaseInsensitive3, cat, "set:CAt", true) +QUERY(SetCodeShortForm, cat, "e:cat", true) +QUERY(SetCodeWrongSet, cat, "set:who", false) +QUERY(SetCodeWrongSet2, doctor, "set:cat", false) + QUERY(BracketNextToUnquotedString, cat, "(o:woof OR o:meow)", true) +CardInfoPtr localizedCat() +{ + CardInfoPtr localized = CardInfo::newInstance("Cat", "Meow!", false, {}, {}, {}, {}, {}); + localized->setLocalizedName("de", "Kater"); + localized->setLocalizedText("de", "miaut"); + return localized; +} + +TEST_F(CardQuery, SearchLanguageEnglishMatchesOnlyEnglish) +{ + const CardData localized = localizedCat(); + ASSERT_TRUE(FilterString("Cat", CardSearchLanguage{"de", SearchLanguageMode::English}).check(localized)); + ASSERT_FALSE(FilterString("Kater", CardSearchLanguage{"de", SearchLanguageMode::English}).check(localized)); +} + +TEST_F(CardQuery, SearchLanguageSelectedMatchesLocalizedNameAndText) +{ + const CardData localized = localizedCat(); + ASSERT_TRUE(FilterString("Kater", CardSearchLanguage{"de", SearchLanguageMode::Selected}).check(localized)); + ASSERT_TRUE(FilterString("o:miaut", CardSearchLanguage{"de", SearchLanguageMode::Selected}).check(localized)); + ASSERT_FALSE(FilterString("Cat", CardSearchLanguage{"de", SearchLanguageMode::Selected}).check(localized)); +} + +TEST_F(CardQuery, SearchLanguageSelectedFallsBackToEnglishForUntranslatedCards) +{ + const CardData localized = localizedCat(); + ASSERT_TRUE(FilterString("Cat", CardSearchLanguage{"fr", SearchLanguageMode::Selected}).check(localized)); + ASSERT_FALSE(FilterString("Kater", CardSearchLanguage{"fr", SearchLanguageMode::Selected}).check(localized)); +} + +TEST_F(CardQuery, SearchLanguageBothMatchesEitherLanguage) +{ + const CardData localized = localizedCat(); + ASSERT_TRUE(FilterString("Cat", CardSearchLanguage{"de", SearchLanguageMode::Both}).check(localized)); + ASSERT_TRUE(FilterString("Kater", CardSearchLanguage{"de", SearchLanguageMode::Both}).check(localized)); +} + +TEST_F(CardQuery, SearchLanguageIsBoundPerInstance) +{ + const CardData localized = localizedCat(); + + FilterString germanQuery("Kater", CardSearchLanguage{"de", SearchLanguageMode::Selected}); + ASSERT_TRUE(germanQuery.check(localized)); + + // Constructing an English-bound instance afterwards must not change the + // language the earlier instance searches in. + FilterString englishQuery("Kater", CardSearchLanguage{"", SearchLanguageMode::English}); + ASSERT_FALSE(englishQuery.check(localized)); + ASSERT_TRUE(germanQuery.check(localized)); +} + } // namespace +class SetQuery : public ::testing::Test +{ +protected: + void SetUp() override + { + // EOE and EOC share a release date, like a set and its Commander counterpart. + const QDate sharedReleaseDate(2026, 3, 13); + mainSet = CardSet::newInstance(&controller, "EOE", "Edge of Eternities", "expansion", sharedReleaseDate, + CardSet::PriorityPrimary); + commanderSet = CardSet::newInstance(&controller, "EOC", "Edge of Eternities Commander", "commander", + sharedReleaseDate, CardSet::PrioritySecondary); + oldSet = CardSet::newInstance(&controller, "OLD", "Older Set", "expansion", QDate(2020, 1, 1), + CardSet::PriorityPrimary); + + inBothSets = newCardWithPrintings({{"EOE", mainSet}, {"EOC", commanderSet}}); + onlyInCommanderSet = newCardWithPrintings({{"EOC", commanderSet}}); + onlyInOldSet = newCardWithPrintings({{"OLD", oldSet}}); + + // SetExpression resolves set codes to release dates through the global set list. + auto *database = CardDatabaseManager::getInstance(); + database->addSet(mainSet); + database->addSet(commanderSet); + database->addSet(oldSet); + } + + CardInfoPtr newCardWithPrintings(const QList> &printings) + { + SetToPrintingsMap setsInfo; + for (const auto &printing : printings) { + setsInfo[printing.first].append(PrintingInfo(printing.second)); + } + return CardInfo::newInstance("Test Card", "", false, {}, {}, {}, setsInfo, CardInfo::UiAttributes()); + } + + NoopCardSetPriorityController controller; + CardSetPtr mainSet; + CardSetPtr commanderSet; + CardSetPtr oldSet; + CardInfoPtr inBothSets; + CardInfoPtr onlyInCommanderSet; + CardInfoPtr onlyInOldSet; +}; + +TEST_F(SetQuery, ExactMatchSeparatesSameDaySets) +{ + EXPECT_TRUE(FilterString("set:EOE").check(inBothSets)); + EXPECT_TRUE(FilterString("e:EOE").check(inBothSets)); + EXPECT_TRUE(FilterString("set:EOC").check(inBothSets)); + EXPECT_FALSE(FilterString("set:EOE").check(onlyInCommanderSet)); + EXPECT_TRUE(FilterString("set:EOC").check(onlyInCommanderSet)); +} + +TEST_F(SetQuery, ExactMatchIsCaseInsensitive) +{ + EXPECT_TRUE(FilterString("set:eoe").check(inBothSets)); + EXPECT_TRUE(FilterString("set:EoE").check(inBothSets)); + EXPECT_FALSE(FilterString("set:eoe").check(onlyInCommanderSet)); +} + +TEST_F(SetQuery, NotEqualsMatchesPrintingsOutsideTheSet) +{ + EXPECT_FALSE(FilterString("set!EOE").check(inBothSets)); + EXPECT_TRUE(FilterString("set!EOE").check(onlyInCommanderSet)); +} + +TEST_F(SetQuery, ComparisonMatchesByReleaseDate) +{ + // OLD (2020) predates EOE/EOC (2026-03-13), so comparison operators still use release dates. + EXPECT_TRUE(FilterString("setEOE").check(onlyInOldSet)); + EXPECT_TRUE(FilterString("set>=EOE").check(inBothSets)); + EXPECT_FALSE(FilterString("settryLoad(cardFor("TestCard", "", "")); + + EXPECT_FALSE(image.isNull()) << "A CUSTOM-folder image in a subdirectory must resolve"; +} + +TEST_F(LocalMatcherTest, CustomSubfolderIgnoresSuffixedFiles) +{ + writePngUnderPics("CUSTOM/poker/TestCard (1).png"); + rebuildLoader(); + + const QImage image = loader->tryLoad(cardFor("TestCard", "", "")); + + EXPECT_TRUE(image.isNull()) << "A suffixed CUSTOM-folder file must not satisfy an exact name lookup"; +} + } // namespace int main(int argc, char **argv) diff --git a/tests/picture_loader_benchmark/CMakeLists.txt b/tests/picture_loader_benchmark/CMakeLists.txt new file mode 100644 index 000000000..a3b75fe88 --- /dev/null +++ b/tests/picture_loader_benchmark/CMakeLists.txt @@ -0,0 +1,26 @@ +# Manual benchmark hitting the real card image hosts. Not registered with +# add_test(): it requires a cards.xml, touches the network for minutes at a +# time, and needs network access. Build it explicitly and run by hand. +add_executable( + picture_loader_benchmark_test + ${VERSION_STRING_CPP} + ../../cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp + ../../cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp + ../../cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp + ../../cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp + ../../cockatrice/src/client/settings/cache_settings.h + picture_loader_benchmark.cpp + settings_cache_mock.cpp +) + +target_include_directories(picture_loader_benchmark_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src) + +target_link_libraries( + picture_loader_benchmark_test + libcockatrice_card + libcockatrice_settings + libcockatrice_interfaces + libcockatrice_utility + Threads::Threads + ${TEST_QT_MODULES} +) diff --git a/tests/picture_loader_benchmark/picture_loader_benchmark.cpp b/tests/picture_loader_benchmark/picture_loader_benchmark.cpp new file mode 100644 index 000000000..f991f2ec0 --- /dev/null +++ b/tests/picture_loader_benchmark/picture_loader_benchmark.cpp @@ -0,0 +1,547 @@ +/* + * Picture loader benchmark / regression suite against the real card image hosts. + * + * Deliberately not registered with ctest: it hits live Scryfall / Gatherer + * endpoints at ~10 requests per second and takes minutes. Run it by hand. + * + * picture_loader_benchmark_test --carddb /path/to/cards.xml [options] + * + * Modes + * ----- + * default : for every URL template in the configured download list (or for each + * --url given), load #count pictures twice: once cold (network) and + * once cached (served from the QNetworkDiskCache). Both passes must + * load every card with zero failures. The cached pass must complete + * well under the cold time, which is the regression gate for serving + * cached pictures instead of re-fetching them. The cold pass must stay + * above a pacing lower bound, the regression gate for burst-free + * request throttling. + * --stress: two CardPictureLoaderWorker instances loading the same cards + * concurrently against one host (~20 req/s aggregate), which forces + * real 429 responses. Both workers must still complete 100% of their + * cards via the shared backoff logic. + */ + +#include "client/settings/cache_settings.h" +#include "interface/card_picture_loader/card_picture_loader_worker.h" +#include "interface/card_picture_loader/card_picture_to_load.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 + +class BenchmarkCardDatabasePathProvider : public ICardDatabasePathProvider +{ +public: + BenchmarkCardDatabasePathProvider(QString _cardsXml, QString _customSetsDir) + : cardsXml(std::move(_cardsXml)), customSetsDir(std::move(_customSetsDir)) + { + } + + QString getCardDatabasePath() const override + { + return cardsXml; + } + + QString getCustomCardDatabasePath() const override + { + return customSetsDir; + } + + QString getTokenDatabasePath() const override + { + return QString(); + } + + QString getSpoilerCardDatabasePath() const override + { + return QString(); + } + +private: + QString cardsXml; + QString customSetsDir; +}; + +struct PassResult +{ + int enqueued = 0; + int finished = 0; + int failed = 0; + qint64 elapsedMs = 0; + QStringList failedCards; +}; + +static QList selectCardsForTemplate(CardDatabase &db, const QString &urlTemplate, int maxCards) +{ + QList selected; + const QList cards = db.getCardList().values(); + for (const CardInfoPtr &card : cards) { + if (selected.size() >= maxCards) { + break; + } + const SetToPrintingsMap &sets = card->getSets(); + if (sets.isEmpty()) { + continue; + } + const QList printings = sets.first(); + if (printings.isEmpty()) { + continue; + } + const ExactCard cardToLoad(card, printings.first()); + if (CardPictureToLoad(cardToLoad).transformUrl(urlTemplate).isEmpty()) { + continue; + } + selected.append(cardToLoad); + } + return selected; +} + +static PassResult runPass(CardPictureLoaderWorker *worker, const QList &cards, int timeoutMs) +{ + PassResult result; + result.enqueued = cards.size(); + + QEventLoop loop; + QTimer watchdog; + watchdog.setSingleShot(true); + watchdog.setInterval(timeoutMs); + QObject::connect(&watchdog, &QTimer::timeout, &loop, &QEventLoop::quit); + + QElapsedTimer clock; + QObject::connect(worker, &CardPictureLoaderWorker::imageLoaded, &loop, + [&](const ExactCard &card, const QImage &image) { + ++result.finished; + if (image.isNull()) { + ++result.failed; + if (result.failedCards.size() < 10) { + result.failedCards.append(card.getName()); + } + } + if (result.finished >= result.enqueued) { + loop.quit(); + } + }); + + clock.start(); + for (const ExactCard &card : cards) { + worker->enqueueImageLoad(card); + } + watchdog.start(); + loop.exec(); + result.elapsedMs = clock.elapsed(); + return result; +} + +struct StressResult +{ + PassResult a; + PassResult b; + int http429Count = 0; +}; + +struct LogCounters +{ + int http429 = 0; + QMutex mutex; +}; + +static std::atomic s_activeCounters{nullptr}; +static QtMessageHandler s_previousMessageHandler = nullptr; + +static void stressLogHandler(QtMsgType type, const QMessageLogContext &context, const QString &msg) +{ + if (LogCounters *counters = s_activeCounters.load(std::memory_order_acquire); + counters && msg.contains(QStringLiteral("Too many requests from"))) { + QMutexLocker locker(&counters->mutex); + ++counters->http429; + } + if (s_previousMessageHandler) { + s_previousMessageHandler(type, context, msg); + } else { + // qInstallMessageHandler() reports the built-in handler as nullptr, so a plain `if` would + // swallow every message - including the 429 warnings this run is meant to surface. Fall + // back to Qt's message pattern written to stderr instead. + std::fprintf(stderr, "%s\n", qPrintable(qFormatLogMessage(type, context, msg))); + } +} + +// Stops a worker's thread and frees it. shutdownThread()'s bounded wait guarantees that the worker +// object was freed by its finished() -> deleteLater chain, so the thread itself can then be deleted +// safely. A worker whose thread refused to stop is left alone (and leaked) rather than freed while +// still running. +static void destroyWorker(CardPictureLoaderWorker *worker) +{ + if (!worker) { + return; + } + QThread *thread = worker->workerThread(); + if (worker->shutdownThread()) { + delete thread; + } +} + +static StressResult runStress(CardPictureLoaderWorker *workerA, + CardPictureLoaderWorker *workerB, + const QList &cards, + int timeoutMs) +{ + StressResult result; + result.a.enqueued = cards.size(); + result.b.enqueued = cards.size(); + + int completed = 0; + QMutex completedMutex; + + QEventLoop loop; + QTimer watchdog; + watchdog.setSingleShot(true); + watchdog.setInterval(timeoutMs); + QObject::connect(&watchdog, &QTimer::timeout, &loop, &QEventLoop::quit); + + const auto finishOne = [&](PassResult &pass, const ExactCard &card, const QImage &image) { + ++pass.finished; + if (image.isNull()) { + ++pass.failed; + if (pass.failedCards.size() < 10) { + pass.failedCards.append(card.getName()); + } + } + QMutexLocker locker(&completedMutex); + ++completed; + if (completed >= result.a.enqueued + result.b.enqueued) { + loop.quit(); + } + }; + + QElapsedTimer clock; + QObject::connect(workerA, &CardPictureLoaderWorker::imageLoaded, &loop, + [&](const ExactCard &card, const QImage &image) { finishOne(result.a, card, image); }); + QObject::connect(workerB, &CardPictureLoaderWorker::imageLoaded, &loop, + [&](const ExactCard &card, const QImage &image) { finishOne(result.b, card, image); }); + + // Count 429 responses as seen by the shared rate limiter. + LogCounters counters; + s_activeCounters = &counters; + s_previousMessageHandler = qInstallMessageHandler(stressLogHandler); + + clock.start(); + for (const ExactCard &card : cards) { + workerA->enqueueImageLoad(card); + workerB->enqueueImageLoad(card); + } + watchdog.start(); + loop.exec(); + const qint64 elapsedMs = clock.elapsed(); + result.a.elapsedMs = elapsedMs; + result.b.elapsedMs = elapsedMs; + + // Stop both workers before touching the counters or restoring the message handler: their + // threads log from stressLogHandler, and must not outlive the stack-local counters (which is + // guaranteed on the watchdog path, where requests and deferred retries are still pending). + destroyWorker(workerA); + destroyWorker(workerB); + + result.http429Count = counters.http429; + s_activeCounters.store(nullptr, std::memory_order_release); + qInstallMessageHandler(s_previousMessageHandler); + return result; +} + +static QString formatDuration(qint64 ms) +{ + return QStringLiteral("%1.%2 s").arg(ms / 1000).arg((ms % 1000) / 100); +} + +static bool likelyRedirects(const QString &urlTemplate) +{ + return urlTemplate.contains(QStringLiteral("api.scryfall.com")); +} + +static QString hostOf(const QString &urlTemplate) +{ + return QUrl(urlTemplate).host(); +} + +static void printUsage() +{ + std::printf("usage: picture_loader_benchmark_test --carddb [options]\n" + "\n" + "Loads card pictures from the real configured hosts (not a mock server) and\n" + "verifies the picture loader's pacing / cache 429 behavior.\n" + "\n" + "options:\n" + " --carddb cards.xml to load card data from (required)\n" + " --count cards to load per template (default 300)\n" + " --url