Merge branch 'master' into tooomm-patch-33

This commit is contained in:
tooomm 2026-09-20 20:36:11 +02:00 committed by GitHub
commit 7c20778c27
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
121 changed files with 5524 additions and 340 deletions

View file

@ -18,12 +18,12 @@ RUN apt-get update && \
libssl-dev \ libssl-dev \
ninja-build \ ninja-build \
protobuf-compiler \ protobuf-compiler \
qt6-declarative-dev \
qt6-image-formats-plugins \ qt6-image-formats-plugins \
qt6-l10n-tools \ qt6-l10n-tools \
qt6-multimedia-dev \ qt6-multimedia-dev \
qt6-declarative-dev \
qt6-svg-dev \
qt6-shadertools-dev \ qt6-shadertools-dev \
qt6-svg-dev \
qt6-tools-dev \ qt6-tools-dev \
qt6-tools-dev-tools \ qt6-tools-dev-tools \
qt6-websockets-dev \ qt6-websockets-dev \

View file

@ -19,12 +19,12 @@ RUN apt-get update && \
libssl-dev \ libssl-dev \
ninja-build \ ninja-build \
protobuf-compiler \ protobuf-compiler \
qt6-declarative-dev \
qt6-image-formats-plugins \ qt6-image-formats-plugins \
qt6-l10n-tools \ qt6-l10n-tools \
qt6-multimedia-dev \ qt6-multimedia-dev \
qt6-declarative-dev \
qt6-svg-dev \
qt6-shadertools-dev \ qt6-shadertools-dev \
qt6-svg-dev \
qt6-tools-dev \ qt6-tools-dev \
qt6-tools-dev-tools \ qt6-tools-dev-tools \
qt6-websockets-dev \ qt6-websockets-dev \

View file

@ -9,7 +9,7 @@ RUN dnf install -y \
ninja-build \ ninja-build \
openssl-devel \ openssl-devel \
protobuf-devel \ protobuf-devel \
qt6-{qtdeclarative,qtshadertools,qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \ qt6-{qtdeclarative,qtmultimedia,qtshadertools,qtsvg,qttools,qtwebsockets}-devel \
qt6-qtimageformats \ qt6-qtimageformats \
rpm-build \ rpm-build \
xz-devel \ xz-devel \

View file

@ -9,7 +9,7 @@ RUN dnf install -y \
ninja-build \ ninja-build \
openssl-devel \ openssl-devel \
protobuf-devel \ protobuf-devel \
qt6-{qtdeclarative,qtshadertools,qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \ qt6-{qtdeclarative,qtmultimedia,qtshadertools,qtsvg,qttools,qtwebsockets}-devel \
qt6-qtimageformats \ qt6-qtimageformats \
rpm-build \ rpm-build \
xz-devel \ xz-devel \

View file

@ -18,12 +18,12 @@ RUN apt-get update && \
libssl-dev \ libssl-dev \
ninja-build \ ninja-build \
protobuf-compiler \ protobuf-compiler \
qt6-declarative-dev \
qt6-image-formats-plugins \ qt6-image-formats-plugins \
qt6-l10n-tools \ qt6-l10n-tools \
qt6-multimedia-dev \ qt6-multimedia-dev \
qt6-declarative-dev \
qt6-svg-dev \
qt6-shadertools-dev \ qt6-shadertools-dev \
qt6-svg-dev \
qt6-tools-dev \ qt6-tools-dev \
qt6-tools-dev-tools \ qt6-tools-dev-tools \
qt6-websockets-dev \ qt6-websockets-dev \

View file

@ -19,12 +19,12 @@ RUN apt-get update && \
libssl-dev \ libssl-dev \
ninja-build \ ninja-build \
protobuf-compiler \ protobuf-compiler \
qt6-declarative-dev \
qt6-image-formats-plugins \ qt6-image-formats-plugins \
qt6-l10n-tools \ qt6-l10n-tools \
qt6-multimedia-dev \ qt6-multimedia-dev \
qt6-declarative-dev \
qt6-svg-dev \
qt6-shadertools-dev \ qt6-shadertools-dev \
qt6-svg-dev \
qt6-tools-dev \ qt6-tools-dev \
qt6-tools-dev-tools \ qt6-tools-dev-tools \
qt6-websockets-dev \ qt6-websockets-dev \

View file

@ -348,4 +348,32 @@ if [[ $MAKE_PACKAGE == "1" ]]; then
BUILD_DIR="$BUILD_DIR" .ci/name_build.sh "$PACKAGE_SUFFIX" BUILD_DIR="$BUILD_DIR" .ci/name_build.sh "$PACKAGE_SUFFIX"
echo "::endgroup::" echo "::endgroup::"
fi 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 fi

View file

@ -272,8 +272,8 @@ jobs:
make_package: 1 make_package: 1
override_target: 13 override_target: 13
package_suffix: "-macOS13_Intel" package_suffix: "-macOS13_Intel"
qt_version: 6.11.1 qt_version: 6.11.*
qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
soc: Intel soc: Intel
type: Release type: Release
use_ccache: 1 use_ccache: 1
@ -288,8 +288,8 @@ jobs:
make_package: 1 make_package: 1
override_target: 14 override_target: 14
package_suffix: "-macOS14" package_suffix: "-macOS14"
qt_version: 6.11.1 qt_version: 6.11.*
qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
soc: Apple soc: Apple
type: Release type: Release
use_ccache: 1 use_ccache: 1
@ -304,8 +304,8 @@ jobs:
make_package: 1 make_package: 1
override_target: 15 override_target: 15
package_suffix: "-macOS15" package_suffix: "-macOS15"
qt_version: 6.11.1 qt_version: 6.11.*
qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
soc: Apple soc: Apple
type: Release type: Release
use_ccache: 1 use_ccache: 1
@ -317,8 +317,8 @@ jobs:
ccache_eviction_age: 7d ccache_eviction_age: 7d
cmake_generator: Ninja cmake_generator: Ninja
qt_version: 6.11.1 qt_version: 6.11.*
qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
soc: Apple soc: Apple
type: Debug type: Debug
use_ccache: 1 use_ccache: 1
@ -332,8 +332,8 @@ jobs:
cmake_generator_platform: x64 cmake_generator_platform: x64
make_package: 1 make_package: 1
package_suffix: "-Win10" package_suffix: "-Win10"
qt_version: 6.11.1 qt_version: 6.11.*
qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
type: Release type: Release
name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }} 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 }} key: ccache-${{ matrix.runner }}_${{ matrix.override_target }}-Xcode${{ matrix.xcode }}
path: ${{ env.CCACHE_DIR }} path: ${{ env.CCACHE_DIR }}
- name: "Install aqtinstall" - name: "[macOS] Install aqtinstall"
if: matrix.os == 'macOS'
run: pipx install aqtinstall 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 # 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: env:
QT_VERSION: ${{ matrix.qt_version }} QT_VERSION: ${{ matrix.qt_version }}
id: resolve_qt_version id: resolve_qt_version
shell: bash shell: bash
run: .ci/resolve_latest_aqt_qt_version.sh "$QT_VERSION" 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' if: matrix.os == 'macOS'
id: restore_qt id: restore_qt
uses: actions/cache/restore@v6 uses: actions/cache/restore@v6
@ -389,21 +391,22 @@ jobs:
# Using jurplel/install-qt-action to install Qt without using brew # Using jurplel/install-qt-action to install Qt without using brew
# Qt build using vcpkg either just fails or takes too long to build # 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' if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true'
uses: jurplel/install-qt-action@v4 uses: jurplel/install-qt-action@v4
with: with:
cache: false cache: false
dir: ${{ github.workspace }} # cache-key-prefix: Qt
dir: ${{ github.workspace }} # thinning script depends on this location
modules: ${{ matrix.qt_modules }} modules: ${{ matrix.qt_modules }}
version: ${{ steps.resolve_qt_version.outputs.version }} version: ${{ matrix.qt_version }}
- name: "[macOS] Create thin Qt libraries" - name: "[macOS] Create 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'
run: .ci/thin_macos_qtlib.sh run: .ci/thin_macos_qtlib.sh
- name: "[macOS] Cache thin Qt libraries" - 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 uses: actions/cache/save@v6
with: with:
key: ${{ steps.restore_qt.outputs.cache-primary-key }} key: ${{ steps.restore_qt.outputs.cache-primary-key }}
@ -415,10 +418,10 @@ jobs:
with: with:
# Qt 6.11.0 only works with aqtinstall directly from git until aqtinstall 3.4 is released # Qt 6.11.0 only works with aqtinstall directly from git until aqtinstall 3.4 is released
aqtsource: git+https://github.com/miurahr/aqtinstall.git aqtsource: git+https://github.com/miurahr/aqtinstall.git
cache: true cache: ${{ github.ref == 'refs/heads/master' }}
cache-key-prefix: Qt cache-key-prefix: Qt
modules: ${{ matrix.qt_modules }} modules: ${{ matrix.qt_modules }}
version: ${{ steps.resolve_qt_version.outputs.version }} version: ${{ matrix.qt_version }}
- name: "[Windows] Install NSIS" - name: "[Windows] Install NSIS"
if: matrix.os == 'Windows' if: matrix.os == 'Windows'
@ -450,7 +453,7 @@ jobs:
PACKAGE_SUFFIX: '${{ matrix.package_suffix }}' PACKAGE_SUFFIX: '${{ matrix.package_suffix }}'
TARGET_MACOS_VERSION: ${{ matrix.override_target }} TARGET_MACOS_VERSION: ${{ matrix.override_target }}
USE_CCACHE: ${{ matrix.use_ccache }} 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_DISABLE_METRICS: 1
VCPKG_FEATURE_FLAGS: dependencygraph VCPKG_FEATURE_FLAGS: dependencygraph
run: .ci/compile.sh --server --test --vcpkg run: .ci/compile.sh --server --test --vcpkg

View file

@ -76,7 +76,7 @@ jobs:
uses: docker/build-push-action@v7 uses: docker/build-push-action@v7
with: with:
cache-from: type=gha,scope=${{ env.CACHE_SCOPE }} 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: . context: .
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
push: false push: false

View file

@ -327,7 +327,7 @@ if(UNIX)
if(CPACK_GENERATOR STREQUAL "RPM") if(CPACK_GENERATOR STREQUAL "RPM")
set(CPACK_RPM_PACKAGE_LICENSE "GPLv2") set(CPACK_RPM_PACKAGE_LICENSE "GPLv2")
set(CPACK_RPM_MAIN_COMPONENT "cockatrice") 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_GROUP "Amusements/Games")
set(CPACK_RPM_PACKAGE_URL "http://github.com/Cockatrice/Cockatrice") set(CPACK_RPM_PACKAGE_URL "http://github.com/Cockatrice/Cockatrice")
# Stop directories from creating package conflicts # Stop directories from creating package conflicts
@ -345,7 +345,7 @@ if(UNIX)
set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON) set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON)
set(CPACK_DEBIAN_PACKAGE_SECTION "games") set(CPACK_DEBIAN_PACKAGE_SECTION "games")
set(CPACK_DEBIAN_PACKAGE_HOMEPAGE "http://github.com/Cockatrice/Cockatrice") 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 set(CPACK_DEBIAN_PACKAGE_RECOMMENDS "libqt6sql6-mysql") # for connecting Servatrice to a MySQL DB
endif() endif()
endif() endif()

View file

@ -387,19 +387,14 @@ SectionEnd
Section "un.Application" UnSecApplication Section "un.Application" UnSecApplication
SetShellVarContext all SetShellVarContext all
RMDir /r "$INSTDIR\plugins"
RMDir /r "$INSTDIR\sounds"
RMDir /r "$INSTDIR\themes"
RMDir /r "$INSTDIR\translations"
Delete "$INSTDIR\*.exe"
Delete "$INSTDIR\*.dll"
Delete "$INSTDIR\qt.conf"
Delete "$INSTDIR\qdebug.txt"
Delete "$INSTDIR\servatrice.sql"
Delete "$INSTDIR\servatrice.ini.example"
RMDir "$INSTDIR"
RMDir "$SMPROGRAMS\Cockatrice" ; Remove the entire application directory so any file that is not part of
; the installed payload (e.g. build-tree artifacts such as *.dir folders,
; *_autogen and *.tlog files from a build) cannot survive between an
; uninstall and a fresh reinstall.
RMDir /r "$INSTDIR"
RMDir /r "$SMPROGRAMS\Cockatrice"
DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice"
SectionEnd SectionEnd

View file

@ -45,11 +45,14 @@ set(cockatrice_SOURCES
src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp
src/interface/widgets/dialogs/dlg_load_remote_deck.cpp src/interface/widgets/dialogs/dlg_load_remote_deck.cpp
src/interface/widgets/dialogs/dlg_local_game_options.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_manage_sets.cpp
src/interface/widgets/dialogs/dlg_my_reports.cpp src/interface/widgets/dialogs/dlg_my_reports.cpp
src/interface/widgets/dialogs/dlg_register.cpp src/interface/widgets/dialogs/dlg_register.cpp
src/interface/widgets/dialogs/dlg_report_user.cpp src/interface/widgets/dialogs/dlg_report_user.cpp
src/interface/widgets/dialogs/dlg_select_set_for_cards.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_settings.cpp
src/interface/widgets/dialogs/dlg_startup_card_check.cpp src/interface/widgets/dialogs/dlg_startup_card_check.cpp
src/interface/widgets/dialogs/dlg_tip_of_the_day.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/dlg_view_log.cpp
src/interface/widgets/dialogs/override_printing_warning.cpp src/interface/widgets/dialogs/override_printing_warning.cpp
src/interface/widgets/dialogs/tip_of_the_day.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/deck_filter_string.cpp
src/filters/filter_builder.cpp src/filters/filter_builder.cpp
src/filters/filter_tree_model.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_grid_widget.cpp
src/interface/palette_editor/palette_editor_dialog.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/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_cost_widget.cpp
src/interface/widgets/cards/additional_info/mana_symbol_widget.cpp src/interface/widgets/cards/additional_info/mana_symbol_widget.cpp
src/interface/widgets/cards/art_crop_attribution.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_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_tag_item_widget.cpp
src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_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_folder_display_widget.cpp
src/interface/widgets/visual_deck_storage/visual_deck_storage_model.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 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_logs.cpp
src/interface/widgets/tabs/tab_message.cpp src/interface/widgets/tabs/tab_message.cpp
src/interface/widgets/tabs/tab_moderation.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_report.cpp
src/interface/widgets/tabs/tab_replays.cpp src/interface/widgets/tabs/tab_replays.cpp
src/interface/widgets/tabs/tab_room.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_login.h
src/interface/intents/intent_open_server_room_by_name.cpp 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_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.cpp
src/interface/intents/url_parser.h src/interface/intents/url_parser.h
src/interface/widgets/server/user/user_info_popup.cpp src/interface/widgets/server/user/user_info_popup.cpp
@ -656,18 +668,35 @@ if(WIN32)
set(qtconf_dest_dir .) set(qtconf_dest_dir .)
install( install(
DIRECTORY "${CMAKE_BINARY_DIR}/${PROJECT_NAME}/${CMAKE_BUILD_TYPE}/" DIRECTORY "$<TARGET_FILE_DIR:cockatrice>/"
DESTINATION ./ DESTINATION ./
FILES_MATCHING FILES_MATCHING
PATTERN "*.dll" 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( install(
DIRECTORY "${CMAKE_BINARY_DIR}/cockatrice/" DIRECTORY "${CMAKE_BINARY_DIR}/cockatrice/"
DESTINATION ./ DESTINATION ./
FILES_MATCHING FILES_MATCHING
PATTERN "CMakeFiles" EXCLUDE
PATTERN "*.ini" 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 # Qt plugins: audio, iconengines, imageformats, multimedia, platforms, printsupport, styles, tls
@ -720,10 +749,6 @@ Data = Resources\")
" "
COMPONENT Runtime COMPONENT Runtime
) )
if(OPENSSL_FOUND)
install(FILES ${OPENSSL_INCLUDE_DIRS} DESTINATION ./)
endif()
endif() endif()
if(Qt6LinguistTools_FOUND) if(Qt6LinguistTools_FOUND)

View file

@ -0,0 +1,14 @@
#ifndef COCKATRICE_CONTEXT_OPEN_DECK_H
#define COCKATRICE_CONTEXT_OPEN_DECK_H
#include "context_connect_to_server.h"
#include <QString>
struct ContextOpenDeck
{
ContextConnectToServer serverContext;
QString shareToken;
};
#endif // COCKATRICE_CONTEXT_OPEN_DECK_H

View file

@ -2,10 +2,11 @@
Intent::Intent(QObject *parent) : QObject(parent) Intent::Intent(QObject *parent) : QObject(parent)
{ {
// An intent is done as soon as it reports success or failure. Deleting it // An intent is done as soon as it reports success, failure, or cancellation.
// also tears down its dependency chain and disconnects any signal wiring. // Deleting it also tears down its dependency chain and disconnects any signal wiring.
connect(this, &Intent::finished, this, &QObject::deleteLater); connect(this, &Intent::finished, this, &QObject::deleteLater);
connect(this, &Intent::failed, this, &QObject::deleteLater); connect(this, &Intent::failed, this, &QObject::deleteLater);
connect(this, &Intent::cancelled, this, &QObject::deleteLater);
} }
Intent::~Intent() = default; Intent::~Intent() = default;
@ -27,6 +28,7 @@ void Intent::runDependency(Intent *dependency)
this->execute(); this->execute();
}); });
connect(dependency, &Intent::failed, this, &Intent::failed); connect(dependency, &Intent::failed, this, &Intent::failed);
connect(dependency, &Intent::cancelled, this, &Intent::cancelled);
dependency->execute(); dependency->execute();
} }
@ -46,3 +48,11 @@ void Intent::emitFailed(const QString &reason)
emit failed(reason); emit failed(reason);
} }
} }
void Intent::emitCancelled()
{
if (!completed) {
completed = true;
emit cancelled();
}
}

View file

@ -16,6 +16,7 @@ public:
signals: signals:
void finished(); void finished();
void failed(QString reason); void failed(QString reason);
void cancelled();
protected: protected:
// --- Subclasses must implement these --- // --- Subclasses must implement these ---
@ -29,6 +30,7 @@ protected:
// Emit the outcome exactly once; ignore late signals after the intent is done. // Emit the outcome exactly once; ignore late signals after the intent is done.
void emitFinished(); void emitFinished();
void emitFailed(const QString &reason); void emitFailed(const QString &reason);
void emitCancelled();
private: private:
bool completed = false; bool completed = false;

View file

@ -19,13 +19,15 @@ bool IntentJoinServerGame::checkPrecondition() const
if (remoteClient->getStatus() != ClientStatus::StatusLoggedIn) { if (remoteClient->getStatus() != ClientStatus::StatusLoggedIn) {
return false; return false;
} }
// peerPort() reflects the actual TCP peer, which may differ from the // serverName()/serverPort() reflect the server the client was configured
// configured server port (e.g. when connecting through a proxy), so only // to connect to, which may differ from the actual TCP peer (e.g. when
// the hostname is compared here. // connecting through a proxy), so compare those configured values. A link
if (remoteClient->peerName() != context->roomContext.serverContext.hostname) { // 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; return false;
} }
if (QString::number(remoteClient->peerPort()) != context->roomContext.serverContext.port) { if (QString::number(remoteClient->serverPort()) != context->roomContext.serverContext.port) {
return false; return false;
} }

View file

@ -1,9 +1,14 @@
#include "intent_login.h" #include "intent_login.h"
#include "../../client/settings/cache_settings.h" #include "../../client/settings/cache_settings.h"
#include "../widgets/dialogs/dlg_login_prompt.h"
#include "libcockatrice/settings/servers_settings.h" #include "libcockatrice/settings/servers_settings.h"
IntentGetLoginCredentials::IntentGetLoginCredentials(ContextConnectToServer *_context) : Intent(), context(_context) #include <QDialog>
IntentGetLoginCredentials::IntentGetLoginCredentials(ContextConnectToServer *_context,
bool _promptForMissingCredentials)
: Intent(), context(_context), promptForMissingCredentials(_promptForMissingCredentials)
{ {
} }
@ -29,5 +34,46 @@ void IntentGetLoginCredentials::onPreconditionSatisfied()
void IntentGetLoginCredentials::onPreconditionNotSatisfied() 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();
} }

View file

@ -9,7 +9,10 @@ class IntentGetLoginCredentials : public Intent
Q_OBJECT Q_OBJECT
public: 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: protected:
bool checkPrecondition() const override; bool checkPrecondition() const override;
@ -18,6 +21,7 @@ protected:
private: private:
ContextConnectToServer *context; ContextConnectToServer *context;
bool promptForMissingCredentials;
}; };
#endif // COCKATRICE_INTENT_LOGIN_H #endif // COCKATRICE_INTENT_LOGIN_H

View file

@ -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 <QMessageBox>
#include <QTimer>
#include <libcockatrice/card/database/card_database_querier.h>
#include <libcockatrice/protocol/pb/command_deck_share_download.pb.h>
#include <libcockatrice/protocol/pb/command_deck_share_list.pb.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/response_deck_share_download.pb.h>
#include <libcockatrice/protocol/pb/response_deck_share_list.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_deck_share_item.pb.h>
#include <libcockatrice/protocol/pending_command.h>
IntentOpenSharedDeck::IntentOpenSharedDeck(TabSupervisor *_tabSupervisor,
RemoteClient *_remoteClient,
const CardDatabaseQuerier *_querier,
std::unique_ptr<ContextOpenDeck> _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<ServerInfo_DeckShareItem> 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<int> &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<LoadedDeck> 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();
}

View file

@ -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 <QList>
#include <QMap>
#include <QScopedPointer>
#include <memory>
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<ContextOpenDeck> _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<int> &itemIds);
void downloadNextItem();
void onItemFailure(const QString &reason);
void finishAll();
TabSupervisor *tabSupervisor;
RemoteClient *remoteClient;
const CardDatabaseQuerier *querier;
QScopedPointer<ContextOpenDeck> context;
DlgSharedDecksPreview *previewDialog = nullptr;
QTimer *downloadTimer;
QMap<int, QString> itemNames;
QList<int> pendingItemIds;
QList<LoadedDeck> loadedDecks;
bool listPhase = true;
int currentItemId = 0;
int totalItems = 0;
int completedItems = 0;
};
#endif // COCKATRICE_INTENT_OPEN_SHARED_DECK_H

View file

@ -1,19 +1,28 @@
#include "url_parser.h" #include "url_parser.h"
#include "../../client/settings/cache_settings.h"
#include "../widgets/tabs/tab_room.h" #include "../widgets/tabs/tab_room.h"
#include "../widgets/tabs/tab_supervisor.h" #include "../widgets/tabs/tab_supervisor.h"
#include "../window_main.h" #include "../window_main.h"
#include "contexts/context_join_game.h" #include "contexts/context_join_game.h"
#include "contexts/context_open_deck.h"
#include "intent.h"
#include "intent_join_server_game.h" #include "intent_join_server_game.h"
#include "intent_login.h" #include "intent_login.h"
#include "intent_open_shared_deck.h"
#include <QDebug> #include <QDebug>
#include <QLoggingCategory>
#include <QMessageBox> #include <QMessageBox>
#include <QUrl> #include <QUrl>
#include <QUrlQuery> #include <QUrlQuery>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/network/client/abstract/abstract_client.h> #include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/settings/servers_settings.h>
#include <memory> #include <memory>
inline Q_LOGGING_CATEGORY(UrlParserLog, "url_parser");
IntentUrlParser::IntentUrlParser(QObject *parent, MainWindow *_mainWindow) : QObject(parent), mainWindow(_mainWindow) 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(); const QString action = url.host();
QUrlQuery query(url); QUrlQuery query(url);
qCDebug(UrlParserLog) << "Parsing intent URL, action:" << action;
PendingIntentChain chain;
Intent *firstIntent = nullptr;
if (action == "joingame") { if (action == "joingame") {
handleJoinGame(query); firstIntent = createJoinGameIntent(query, chain);
} else if (action == "opendeck") { } else if (action == "opendeck") {
// handleOpenDeck(query); firstIntent = createOpenDeckIntent(query, chain);
} else { } else {
qWarning() << "Unknown intent:" << action; 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); }; 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()) { if (ctx->roomContext.serverContext.hostname.isEmpty()) {
showError(tr("Missing or empty hostname in the game link")); showError(tr("Missing or empty hostname in the game link"));
return; return nullptr;
} }
bool ok = false; bool ok = false;
ctx->roomContext.serverContext.port.toUShort(&ok); ctx->roomContext.serverContext.port.toUShort(&ok);
if (!ok) { if (!ok) {
showError(tr("Invalid or missing port in the game link")); showError(tr("Invalid or missing port in the game link"));
return; return nullptr;
} }
ctx->roomContext.roomId = query.queryItemValue("roomid").toInt(&ok); ctx->roomContext.roomId = query.queryItemValue("roomid").toInt(&ok);
if (!ok) { if (!ok) {
showError(tr("Invalid or missing room id in the game link")); showError(tr("Invalid or missing room id in the game link"));
return; return nullptr;
} }
ok = false; ok = false;
@ -71,7 +97,7 @@ void IntentUrlParser::handleJoinGame(const QUrlQuery &query)
if (!ok) { if (!ok) {
showError(tr("Invalid or missing game id in the game link")); showError(tr("Invalid or missing game id in the game link"));
return; return nullptr;
} }
const QString gameDescription = query.queryItemValue("game", QUrl::FullyDecoded); const QString gameDescription = query.queryItemValue("game", QUrl::FullyDecoded);
@ -80,24 +106,33 @@ void IntentUrlParser::handleJoinGame(const QUrlQuery &query)
const QMessageBox::StandardButton answer = QMessageBox::question( const QMessageBox::StandardButton answer = QMessageBox::question(
mainWindow, tr("Join game"), message, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); mainWindow, tr("Join game"), message, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes);
if (answer != 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 // The join game intent owns the context and the credential lookup; once the
// chain finishes (or fails) it deletes the whole tree. // chain finishes (or fails) it deletes the whole tree.
ContextConnectToServer *serverContext = &ctx->roomContext.serverContext; auto joinGameIntent = new IntentJoinServerGame(mainWindow->getTabSupervisor(), client, std::move(ctx));
auto joinGameIntent =
new IntentJoinServerGame(mainWindow->getTabSupervisor(), mainWindow->getRemoteClient(), std::move(ctx));
joinGameIntent->setParent(this); joinGameIntent->setParent(this);
chain.intents.append(joinGameIntent);
auto getLoginCredentialsIntent = new IntentGetLoginCredentials(serverContext);
getLoginCredentialsIntent->setParent(joinGameIntent);
connect(getLoginCredentialsIntent, &Intent::finished, joinGameIntent, &Intent::execute);
connect(getLoginCredentialsIntent, &Intent::failed, joinGameIntent, &Intent::failed);
connect(joinGameIntent, &Intent::failed, this, [showError](const QString &reason) { showError(reason); }); 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) 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) .arg(gameDescription, gameIdStr, roomTab->getRoomName(), server)
: tr("Join game \"%1\" (#%2) on %3?").arg(gameDescription, gameIdStr, 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<ContextOpenDeck>();
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<QMetaObject::Connection>();
*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);
}
}

View file

@ -1,10 +1,46 @@
#ifndef COCKATRICE_URL_PARSER_H #ifndef COCKATRICE_URL_PARSER_H
#define COCKATRICE_URL_PARSER_H #define COCKATRICE_URL_PARSER_H
#include <QList>
#include <QObject> #include <QObject>
#include <QUrlQuery> #include <QUrlQuery>
class Intent;
class MainWindow; class MainWindow;
struct ContextJoinGame; 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<Intent *> 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 class IntentUrlParser : public QObject
{ {
Q_OBJECT Q_OBJECT
@ -12,12 +48,28 @@ class IntentUrlParser : public QObject
public: public:
IntentUrlParser(QObject *parent, MainWindow *mainWindow); IntentUrlParser(QObject *parent, MainWindow *mainWindow);
void handle(const QString &urlStr); 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: private:
Intent *createJoinGameIntent(const QUrlQuery &query, PendingIntentChain &chain);
Intent *createOpenDeckIntent(const QUrlQuery &query, PendingIntentChain &chain);
QString generateJoinGameMessage(const ContextJoinGame &context, const QString &gameDescription); 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; MainWindow *mainWindow;
QList<PendingIntentChain> 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 #endif // COCKATRICE_URL_PARSER_H

View file

@ -85,7 +85,7 @@ void ColorIdentityWidget::resizeEvent(QResizeEvent *event)
} }
lastWidth = totalWidth; 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); setFixedHeight(totalHeight);
const int count = layout->count(); const int count = layout->count();
@ -97,6 +97,10 @@ void ColorIdentityWidget::resizeEvent(QResizeEvent *event)
const int availableWidth = totalWidth - (spacing * (count - 1)); const int availableWidth = totalWidth - (spacing * (count - 1));
const int iconSize = qMin(availableWidth / count, totalHeight); // Ensure icons fit within the new height const int iconSize = qMin(availableWidth / count, totalHeight); // Ensure icons fit within the new height
if (iconSize <= 0) {
lastIconSize = iconSize;
return;
}
if (iconSize == lastIconSize) { if (iconSize == lastIconSize) {
return; return;
} }

View file

@ -0,0 +1,37 @@
#include "deck_color_identity.h"
#include <QSet>
#include <libcockatrice/card/database/card_database_querier.h>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/deck_list/tree/inner_deck_list_node.h>
QString getDeckColorIdentity(const DeckList &deck, const CardDatabaseQuerier *db)
{
const QStringList cardList = deck.getCardList({DECK_ZONE_MAIN, DECK_ZONE_SIDE});
if (cardList.isEmpty()) {
return {};
}
QSet<QChar> 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;
}

View file

@ -0,0 +1,20 @@
#ifndef COCKATRICE_DECK_COLOR_IDENTITY_H
#define COCKATRICE_DECK_COLOR_IDENTITY_H
#include <QString>
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

View file

@ -133,12 +133,14 @@ void CardInfoPictureWithTextOverlayWidget::paintEvent(QPaintEvent *event)
path.addRoundedRect(glowRect, radius, radius); path.addRoundedRect(glowRect, radius, radius);
// Soft outer glow // 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.setPen(QPen(glowColor, 6));
painter.drawPath(path); painter.drawPath(path);
// Thin inner border for crispness // 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.setPen(QPen(borderColor, 2));
painter.drawRoundedRect(pixmapRect, radius, radius); painter.drawRoundedRect(pixmapRect, radius, radius);

View file

@ -27,18 +27,23 @@ DeckPreviewCardPictureWidget::DeckPreviewCardPictureWidget(QWidget *parent,
const QColor &textColor, const QColor &textColor,
const QColor &outlineColor, const QColor &outlineColor,
const int fontSize, const int fontSize,
const Qt::Alignment alignment) const Qt::Alignment alignment,
const bool _emitClickImmediately)
: CardInfoPictureWithTextOverlayWidget(parent, : CardInfoPictureWithTextOverlayWidget(parent,
hoverToZoomEnabled, hoverToZoomEnabled,
raiseOnEnter, raiseOnEnter,
textColor, textColor,
outlineColor, outlineColor,
fontSize, fontSize,
alignment) alignment),
emitClickImmediately(_emitClickImmediately)
{ {
singleClickTimer = new QTimer(this); singleClickTimer = new QTimer(this);
singleClickTimer->setSingleShot(true); 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(), connect(&SettingsCache::instance().visualDeckStorage(),
&VisualDeckStorageSettings::visualDeckStorageSelectionAnimationChanged, this, &VisualDeckStorageSettings::visualDeckStorageSelectionAnimationChanged, this,
&CardInfoPictureWidget::setRaiseOnEnterEnabled); &CardInfoPictureWidget::setRaiseOnEnterEnabled);
@ -47,8 +52,13 @@ DeckPreviewCardPictureWidget::DeckPreviewCardPictureWidget(QWidget *parent,
void DeckPreviewCardPictureWidget::mousePressEvent(QMouseEvent *event) void DeckPreviewCardPictureWidget::mousePressEvent(QMouseEvent *event)
{ {
if (event->button() == Qt::LeftButton) { if (event->button() == Qt::LeftButton) {
lastMouseEvent = event; if (emitClickImmediately) {
singleClickTimer->start(QApplication::doubleClickInterval()); emit imageClicked(event, this);
emit imageSingleClicked();
} else {
lastMouseEvent = event;
singleClickTimer->start(QApplication::doubleClickInterval());
}
} else { } else {
emit imageClicked(event, this); emit imageClicked(event, this);
event->accept(); event->accept();
@ -58,7 +68,14 @@ void DeckPreviewCardPictureWidget::mousePressEvent(QMouseEvent *event)
void DeckPreviewCardPictureWidget::mouseDoubleClickEvent(QMouseEvent *event) void DeckPreviewCardPictureWidget::mouseDoubleClickEvent(QMouseEvent *event)
{ {
if (event->button() == Qt::LeftButton) { if (event->button() == Qt::LeftButton) {
singleClickTimer->stop(); // Prevent single-click logic if (emitClickImmediately) {
emit imageDoubleClicked(lastMouseEvent, this); // 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);
}
} }
} }

View file

@ -20,21 +20,38 @@ class DeckPreviewCardPictureWidget final : public CardInfoPictureWithTextOverlay
Q_OBJECT Q_OBJECT
public: 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, explicit DeckPreviewCardPictureWidget(QWidget *parent,
bool hoverToZoomEnabled = false, bool hoverToZoomEnabled = false,
bool raiseOnEnter = false, bool raiseOnEnter = false,
const QColor &textColor = Qt::white, const QColor &textColor = Qt::white,
const QColor &outlineColor = Qt::black, const QColor &outlineColor = Qt::black,
int fontSize = 12, int fontSize = 12,
Qt::Alignment alignment = Qt::AlignCenter); Qt::Alignment alignment = Qt::AlignCenter,
bool _emitClickImmediately = false);
signals: signals:
void imageClicked(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); void imageClicked(QMouseEvent *event, DeckPreviewCardPictureWidget *instance);
void imageSingleClicked();
void imageDoubleClicked(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); void imageDoubleClicked(QMouseEvent *event, DeckPreviewCardPictureWidget *instance);
private: private:
QTimer *singleClickTimer; QTimer *singleClickTimer;
QMouseEvent *lastMouseEvent = nullptr; // Store the last mouse event QMouseEvent *lastMouseEvent = nullptr; // Store the last mouse event
bool emitClickImmediately;
protected: protected:
void mousePressEvent(QMouseEvent *event) override; void mousePressEvent(QMouseEvent *event) override;

View file

@ -0,0 +1,59 @@
#include "deck_share_utils.h"
#include <QClipboard>
#include <QGuiApplication>
#include <QLocale>
#include <QTimeZone>
#include <QUrl>
#include <QUrlQuery>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/response_deck_share_create.pb.h>
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

View file

@ -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 <QDateTime>
#include <QString>
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

View file

@ -0,0 +1,77 @@
#include "share_bar_widget.h"
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
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();
}

View file

@ -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 <QWidget>
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

View file

@ -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 <QCheckBox>
#include <QFrame>
#include <QHBoxLayout>
#include <QKeyEvent>
#include <QLabel>
#include <QVBoxLayout>
#include <libcockatrice/card/database/card_database_querier.h>
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);
}

View file

@ -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 <QWidget>
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

View file

@ -0,0 +1,50 @@
#include "dlg_login_prompt.h"
#include <QCheckBox>
#include <QDialogButtonBox>
#include <QFormLayout>
#include <QLabel>
#include <QLineEdit>
#include <QVBoxLayout>
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();
}

View file

@ -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 <QDialog>
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

View file

@ -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 <QDialogButtonBox>
#include <QFormLayout>
#include <QLineEdit>
#include <QMessageBox>
#include <QPushButton>
#include <QTimer>
#include <QVBoxLayout>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/command_deck_share_create.pb.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/response_deck_share_create.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/settings/network_settings.h>
DlgShareDeck::DlgShareDeck(AbstractClient *_client, const QSharedPointer<DeckList> &_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<int>((static_cast<qint64>(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<int>(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."));
}

View file

@ -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 <QDialog>
#include <QSharedPointer>
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<DeckList> &_deck, QWidget *parent = nullptr);
private slots:
void actShare();
void shareFinished(const Response &response, const CommandContainer &commandContainer);
void onShareTimeout();
private:
AbstractClient *client;
QSharedPointer<DeckList> deck;
QLineEdit *nameEdit;
QDialogButtonBox *buttonBox;
QTimer *shareTimeoutTimer;
};
#endif // DLG_SHARE_DECK_H

View file

@ -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 <QCloseEvent>
#include <QDateTime>
#include <QDialogButtonBox>
#include <QLabel>
#include <QPushButton>
#include <QVBoxLayout>
#include <libcockatrice/card/database/card_database_querier.h>
#include <libcockatrice/protocol/pb/serverinfo_deck_share_item.pb.h>
DlgSharedDecksPreview::DlgSharedDecksPreview(QWidget *parent,
const CardDatabaseQuerier *querier,
const QString &shareName,
qint64 expiresAt,
const QString &serverText,
const QList<ServerInfo_DeckShareItem> &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<int>{itemId});
});
}
updateOpenSelectedEnabled();
}
QList<int> DlgSharedDecksPreview::selectedItemIds() const
{
QList<int> 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<int> 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 &currentDeckName)
{
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);
}

View file

@ -0,0 +1,68 @@
#ifndef COCKATRICE_DLG_SHARED_DECKS_PREVIEW_H
#define COCKATRICE_DLG_SHARED_DECKS_PREVIEW_H
#include <QDialog>
#include <QList>
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<ServerInfo_DeckShareItem> &items);
void setDownloadProgress(int done, int total, const QString &currentDeckName);
public slots:
void setDownloading(bool downloading);
signals:
void openRequested(const QList<int> &itemIds);
void cancelled();
protected:
void closeEvent(QCloseEvent *event) override;
private slots:
void openSelected();
void openAll();
void updateOpenSelectedEnabled();
void onCancel();
private:
QList<int> selectedItemIds() const;
FlowWidget *flowWidget;
QList<SharedDeckPreviewWidget *> tiles;
QList<int> itemIds;
QPushButton *openSelectedButton;
QPushButton *openAllButton;
QLabel *downloadStatusLabel;
bool resultEmitted = false;
bool downloadInProgress = false;
};
#endif // COCKATRICE_DLG_SHARED_DECKS_PREVIEW_H

View file

@ -7,6 +7,7 @@
#include "flow_widget.h" #include "flow_widget.h"
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QKeyEvent>
#include <QResizeEvent> #include <QResizeEvent>
#include <QScrollArea> #include <QScrollArea>
#include <QSizePolicy> #include <QSizePolicy>
@ -80,13 +81,35 @@ FlowWidget::FlowWidget(QWidget *parent,
/** /**
* @brief Adds a widget to the flow layout within the FlowWidget. * @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. * @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); 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) void FlowWidget::insertWidgetAtIndex(QWidget *toInsert, int index)
{ {
flowLayout->insertWidgetAtIndex(toInsert, index); flowLayout->insertWidgetAtIndex(toInsert, index);
@ -177,6 +200,66 @@ QLayoutItem *FlowWidget::itemAt(int index) const
return flowLayout->itemAt(index); 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<QKeyEvent *>(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<QWidget *> 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 int FlowWidget::count() const
{ {
return flowLayout->count(); return flowLayout->count();

View file

@ -11,6 +11,7 @@
#include "../../../layouts/flow_layout.h" #include "../../../layouts/flow_layout.h"
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QKeyEvent>
#include <QLoggingCategory> #include <QLoggingCategory>
#include <QScrollArea> #include <QScrollArea>
#include <QWidget> #include <QWidget>
@ -28,7 +29,8 @@ public:
Qt::ScrollBarPolicy horizontalPolicy, Qt::ScrollBarPolicy horizontalPolicy,
Qt::ScrollBarPolicy verticalPolicy); 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 insertWidgetAtIndex(QWidget *toInsert, int index);
void removeWidget(QWidget *widgetToRemove) const; void removeWidget(QWidget *widgetToRemove) const;
void clearLayout(); void clearLayout();
@ -43,9 +45,15 @@ public slots:
void setSpacing(int hSpacing, int vSpacing); void setSpacing(int hSpacing, int vSpacing);
protected: protected:
bool eventFilter(QObject *watched, QEvent *event) override;
void resizeEvent(QResizeEvent *event) override; void resizeEvent(QResizeEvent *event) override;
void keyPressEvent(QKeyEvent *event) override;
private: 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; Qt::Orientation flowDirection;
QHBoxLayout *mainLayout; QHBoxLayout *mainLayout;
FlowLayout *flowLayout; FlowLayout *flowLayout;

View file

@ -28,6 +28,9 @@ DeckEditorMenu::DeckEditorMenu(AbstractTabDeckEditor *parent) : QMenu(parent), d
aSaveDeckAs = new QAction(QString(), this); aSaveDeckAs = new QAction(QString(), this);
connect(aSaveDeckAs, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::actSaveDeckAs); connect(aSaveDeckAs, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::actSaveDeckAs);
aShareDeck = new QAction(QString(), this);
connect(aShareDeck, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::actShareDeck);
aLoadDeckFromClipboard = new QAction(QString(), this); aLoadDeckFromClipboard = new QAction(QString(), this);
connect(aLoadDeckFromClipboard, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::actLoadDeckFromClipboard); connect(aLoadDeckFromClipboard, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::actLoadDeckFromClipboard);
@ -96,6 +99,7 @@ DeckEditorMenu::DeckEditorMenu(AbstractTabDeckEditor *parent) : QMenu(parent), d
addMenu(loadRecentDeckMenu); addMenu(loadRecentDeckMenu);
addAction(aSaveDeck); addAction(aSaveDeck);
addAction(aSaveDeckAs); addAction(aSaveDeckAs);
addAction(aShareDeck);
addSeparator(); addSeparator();
addAction(aLoadDeckFromClipboard); addAction(aLoadDeckFromClipboard);
addMenu(editDeckInClipboardMenu); addMenu(editDeckInClipboardMenu);
@ -120,6 +124,7 @@ void DeckEditorMenu::setSaveStatus(bool newStatus)
{ {
aSaveDeck->setEnabled(newStatus); aSaveDeck->setEnabled(newStatus);
aSaveDeckAs->setEnabled(newStatus); aSaveDeckAs->setEnabled(newStatus);
aShareDeck->setEnabled(newStatus);
aSaveDeckToClipboard->setEnabled(newStatus); aSaveDeckToClipboard->setEnabled(newStatus);
aSaveDeckToClipboardNoSetInfo->setEnabled(newStatus); aSaveDeckToClipboardNoSetInfo->setEnabled(newStatus);
aSaveDeckToClipboardRaw->setEnabled(newStatus); aSaveDeckToClipboardRaw->setEnabled(newStatus);
@ -157,6 +162,7 @@ void DeckEditorMenu::retranslateUi()
aClearRecents->setText(tr("Clear")); aClearRecents->setText(tr("Clear"));
aSaveDeck->setText(tr("&Save deck")); aSaveDeck->setText(tr("&Save deck"));
aSaveDeckAs->setText(tr("Save deck &as...")); aSaveDeckAs->setText(tr("Save deck &as..."));
aShareDeck->setText(tr("Share deck..."));
aLoadDeckFromClipboard->setText(tr("Load deck from cl&ipboard...")); aLoadDeckFromClipboard->setText(tr("Load deck from cl&ipboard..."));

View file

@ -21,7 +21,8 @@ public:
QAction *aNewDeck, *aLoadDeck, *aClearRecents, *aSaveDeck, *aSaveDeckAs, *aLoadDeckFromClipboard, QAction *aNewDeck, *aLoadDeck, *aClearRecents, *aSaveDeck, *aSaveDeckAs, *aLoadDeckFromClipboard,
*aEditDeckInClipboard, *aEditDeckInClipboardRaw, *aSaveDeckToClipboard, *aSaveDeckToClipboardNoSetInfo, *aEditDeckInClipboard, *aEditDeckInClipboardRaw, *aSaveDeckToClipboard, *aSaveDeckToClipboardNoSetInfo,
*aSaveDeckToClipboardRaw, *aSaveDeckToClipboardRawNoSetInfo, *aPrintDeck, *aLoadDeckFromWebsite, *aSaveDeckToClipboardRaw, *aSaveDeckToClipboardRawNoSetInfo, *aPrintDeck, *aLoadDeckFromWebsite,
*aExportDeckDecklist, *aExportDeckDecklistXyz, *aAnalyzeDeckDeckstats, *aAnalyzeDeckTappedout, *aClose; *aExportDeckDecklist, *aExportDeckDecklistXyz, *aAnalyzeDeckDeckstats, *aAnalyzeDeckTappedout, *aShareDeck,
*aClose;
QMenu *loadRecentDeckMenu, *analyzeDeckMenu, *editDeckInClipboardMenu, *saveDeckToClipboardMenu; QMenu *loadRecentDeckMenu, *analyzeDeckMenu, *editDeckInClipboardMenu, *saveDeckToClipboardMenu;
void setSaveStatus(bool newStatus); void setSaveStatus(bool newStatus);

View file

@ -113,7 +113,7 @@ int RemoteDeckList_TreeModel::rowCount(const QModelIndex &parent) const
int RemoteDeckList_TreeModel::columnCount(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 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()) { if (!index.isValid()) {
return QVariant(); return QVariant();
} }
if (index.column() >= 3) { if (index.column() >= 4) {
return QVariant(); return QVariant();
} }
@ -134,12 +134,29 @@ QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) cons
switch (index.column()) { switch (index.column()) {
case 0: case 0:
return node->getName(); 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: default:
return QVariant(); return QVariant();
} }
} }
case Qt::DecorationRole: case Qt::DecorationRole:
return index.column() == 0 ? dirIcon : QVariant(); 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: default:
return QVariant(); return QVariant();
} }
@ -153,6 +170,13 @@ QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) cons
return file->getId(); return file->getId();
case 2: case 2:
return file->getUploadTime(); 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: default:
return QVariant(); return QVariant();
} }
@ -161,6 +185,16 @@ QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) cons
return index.column() == 0 ? fileIcon : QVariant(); return index.column() == 0 ? fileIcon : QVariant();
case Qt::TextAlignmentRole: case Qt::TextAlignmentRole:
return index.column() == 1 ? Qt::AlignRight : Qt::AlignLeft; 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: default:
return QVariant(); return QVariant();
} }
@ -183,6 +217,8 @@ QVariant RemoteDeckList_TreeModel::headerData(int section, Qt::Orientation orien
return tr("ID"); return tr("ID");
case 2: case 2:
return tr("Upload time"); return tr("Upload time");
case 3:
return tr("Visibility");
default: default:
return QVariant(); return QVariant();
} }
@ -239,13 +275,14 @@ void RemoteDeckList_TreeModel::addFileToTree(const ServerInfo_DeckStorage_TreeIt
time.setSecsSinceEpoch(fileInfo.creation_time()); time.setSecsSinceEpoch(fileInfo.creation_time());
beginInsertRows(nodeToIndex(parent), parent->size(), parent->size()); 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(); endInsertRows();
} }
void RemoteDeckList_TreeModel::addFolderToTree(const ServerInfo_DeckStorage_TreeItem &folder, DirectoryNode *parent) void RemoteDeckList_TreeModel::addFolderToTree(const ServerInfo_DeckStorage_TreeItem &folder, DirectoryNode *parent)
{ {
DirectoryNode *newItem = addNamedFolderToTree(QString::fromStdString(folder.name()), parent); DirectoryNode *newItem = addNamedFolderToTree(QString::fromStdString(folder.name()), parent);
newItem->setIsPublic(folder.folder().is_public());
const ServerInfo_DeckStorage_Folder &folderInfo = folder.folder(); const ServerInfo_DeckStorage_Folder &folderInfo = folder.folder();
const int folderItemsSize = folderInfo.items_size(); const int folderItemsSize = folderInfo.items_size();
for (int i = 0; i < folderItemsSize; ++i) { for (int i = 0; i < folderItemsSize; ++i) {
@ -285,6 +322,21 @@ void RemoteDeckList_TreeModel::refreshTree()
client->sendCommand(pend); 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() void RemoteDeckList_TreeModel::clearTree()
{ {
beginResetModel(); beginResetModel();

View file

@ -27,9 +27,11 @@ public:
protected: protected:
DirectoryNode *parent; DirectoryNode *parent;
QString name; QString name;
bool publicFlag;
public: 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; virtual ~Node() = default;
@ -41,6 +43,14 @@ public:
{ {
return name; return name;
} }
[[nodiscard]] bool isPublic() const
{
return publicFlag;
}
void setIsPublic(bool _public)
{
publicFlag = _public;
}
}; };
class DirectoryNode : public Node, public QList<Node *> class DirectoryNode : public Node, public QList<Node *>
{ {
@ -59,9 +69,14 @@ public:
QDateTime uploadTime; QDateTime uploadTime;
public: 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) : Node(_name, _parent), id(_id), uploadTime(_uploadTime)
{ {
setIsPublic(_isPublic);
} }
[[nodiscard]] int getId() const [[nodiscard]] int getId() const
{ {
@ -109,6 +124,11 @@ public:
{ {
return root; 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 addFileToTree(const ServerInfo_DeckStorage_TreeItem &file, DirectoryNode *parent);
void addFolderToTree(const ServerInfo_DeckStorage_TreeItem &folder, DirectoryNode *parent); void addFolderToTree(const ServerInfo_DeckStorage_TreeItem &folder, DirectoryNode *parent);
DirectoryNode *addNamedFolderToTree(const QString &name, DirectoryNode *parent); DirectoryNode *addNamedFolderToTree(const QString &name, DirectoryNode *parent);

View file

@ -37,6 +37,7 @@ UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent,
aDetails = new QAction(QString(), this); aDetails = new QAction(QString(), this);
aChat = new QAction(QString(), this); aChat = new QAction(QString(), this);
aShowGames = new QAction(QString(), this); aShowGames = new QAction(QString(), this);
aViewPublicDecks = new QAction(QString(), this);
aAddToBuddyList = new QAction(QString(), this); aAddToBuddyList = new QAction(QString(), this);
aRemoveFromBuddyList = new QAction(QString(), this); aRemoveFromBuddyList = new QAction(QString(), this);
aAddToIgnoreList = new QAction(QString(), this); aAddToIgnoreList = new QAction(QString(), this);
@ -64,6 +65,7 @@ void UserContextMenu::retranslateUi()
aDetails->setText(tr("User &details")); aDetails->setText(tr("User &details"));
aChat->setText(tr("Private &chat")); aChat->setText(tr("Private &chat"));
aShowGames->setText(tr("Show this user's &games")); aShowGames->setText(tr("Show this user's &games"));
aViewPublicDecks->setText(tr("View this user's &public decks"));
aAddToBuddyList->setText(tr("Add to &buddy list")); aAddToBuddyList->setText(tr("Add to &buddy list"));
aRemoveFromBuddyList->setText(tr("Remove from &buddy list")); aRemoveFromBuddyList->setText(tr("Remove from &buddy list"));
aAddToIgnoreList->setText(tr("Add to &ignore list")); aAddToIgnoreList->setText(tr("Add to &ignore list"));
@ -376,6 +378,9 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
} }
menu->addAction(aDetails); menu->addAction(aDetails);
menu->addAction(aShowGames); menu->addAction(aShowGames);
if (userLevel.testFlag(ServerInfo_User::IsRegistered)) {
menu->addAction(aViewPublicDecks);
}
menu->addAction(aChat); menu->addAction(aChat);
const QList<GameInviteOption> inviteOptions = inviteOptionsForUser(userName); const QList<GameInviteOption> inviteOptions = inviteOptionsForUser(userName);
if (!inviteOptions.isEmpty()) { if (!inviteOptions.isEmpty()) {
@ -455,6 +460,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
aChat->setEnabled(anotherUser && online && !userListProxy->isUserIgnored(userName)); aChat->setEnabled(anotherUser && online && !userListProxy->isUserIgnored(userName));
aShowGames->setEnabled(online); aShowGames->setEnabled(online);
aReport->setEnabled(anotherUser); aReport->setEnabled(anotherUser);
aViewPublicDecks->setEnabled(anotherUser);
aAddToBuddyList->setEnabled(anotherUser); aAddToBuddyList->setEnabled(anotherUser);
aRemoveFromBuddyList->setEnabled(anotherUser); aRemoveFromBuddyList->setEnabled(anotherUser);
aAddToIgnoreList->setEnabled(anotherUser); aAddToIgnoreList->setEnabled(anotherUser);
@ -481,6 +487,8 @@ void UserContextMenu::showContextMenu(const QPoint &pos,
execChat(userName); execChat(userName);
} else if (actionClicked == aShowGames) { } else if (actionClicked == aShowGames) {
execShowGames(userName); execShowGames(userName);
} else if (actionClicked == aViewPublicDecks) {
execViewPublicDecks(userName);
} else if (actionClicked == aAddToBuddyList) { } else if (actionClicked == aAddToBuddyList) {
execAddToBuddy(userName); execAddToBuddy(userName);
} else if (actionClicked == aRemoveFromBuddyList) { } else if (actionClicked == aRemoveFromBuddyList) {
@ -604,6 +612,11 @@ void UserContextMenu::execShowGames(const QString &userName)
client->sendCommand(pend); client->sendCommand(pend);
} }
void UserContextMenu::execViewPublicDecks(const QString &userName)
{
tabSupervisor->openTabPublicDecks(userName);
}
void UserContextMenu::execAddToBuddy(const QString &userName) void UserContextMenu::execAddToBuddy(const QString &userName)
{ {
Command_AddToList cmd; Command_AddToList cmd;

View file

@ -37,6 +37,7 @@ private:
QAction *aUserName; QAction *aUserName;
QAction *aDetails; QAction *aDetails;
QAction *aShowGames; QAction *aShowGames;
QAction *aViewPublicDecks;
QAction *aChat; QAction *aChat;
QAction *aAddToBuddyList, *aRemoveFromBuddyList; QAction *aAddToBuddyList, *aRemoveFromBuddyList;
QAction *aAddToIgnoreList, *aRemoveFromIgnoreList; QAction *aAddToIgnoreList, *aRemoveFromIgnoreList;
@ -111,6 +112,7 @@ public:
void execInvite(const QString &userName); void execInvite(const QString &userName);
void execDetails(const QString &userName); void execDetails(const QString &userName);
void execShowGames(const QString &userName); void execShowGames(const QString &userName);
void execViewPublicDecks(const QString &userName);
void execAddToBuddy(const QString &userName); void execAddToBuddy(const QString &userName);
void execRemoveFromBuddy(const QString &userName); void execRemoveFromBuddy(const QString &userName);
void execAddToIgnore(const QString &userName); void execAddToIgnore(const QString &userName);

View file

@ -11,6 +11,7 @@
#include "../../../client/settings/cache_settings.h" #include "../../../client/settings/cache_settings.h"
#include "../../../client/settings/shortcuts_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/deck_stats_interface.h"
#include "../client/network/interfaces/tapped_out_interface.h" #include "../client/network/interfaces/tapped_out_interface.h"
#include "../deck_editor/deck_state_manager.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.h"
#include "../interface/widgets/dialogs/dlg_load_deck_from_clipboard.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_load_deck_from_website.h"
#include "../interface/widgets/dialogs/dlg_share_deck.h"
#include "../utility/visibility_change_listener.h" #include "../utility/visibility_change_listener.h"
#include "tab_supervisor.h" #include "tab_supervisor.h"
@ -323,6 +325,7 @@ bool AbstractTabDeckEditor::actSaveDeck()
Command_DeckUpload cmd; Command_DeckUpload cmd;
cmd.set_deck_id(static_cast<google::protobuf::uint32>(loadedDeck.lastLoadInfo.remoteDeckId)); cmd.set_deck_id(static_cast<google::protobuf::uint32>(loadedDeck.lastLoadInfo.remoteDeckId));
cmd.set_deck_list(deckString.toStdString()); cmd.set_deck_list(deckString.toStdString());
cmd.set_color_identity(getDeckColorIdentity(loadedDeck.deckList, CardDatabaseManager::query()).toStdString());
PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd); PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &AbstractTabDeckEditor::saveDeckRemoteFinished); connect(pend, &PendingCommand::finished, this, &AbstractTabDeckEditor::saveDeckRemoteFinished);
@ -382,6 +385,27 @@ bool AbstractTabDeckEditor::actSaveDeckAs()
return true; 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<DeckList> 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. * @brief Callback for remote deck save completion.
* @param response Server response. * @param response Server response.

View file

@ -214,6 +214,9 @@ protected slots:
/** @brief Saves the current deck under a new name. */ /** @brief Saves the current deck under a new name. */
virtual bool actSaveDeckAs(); virtual bool actSaveDeckAs();
/** @brief Opens the deck share dialog for the current deck. */
void actShareDeck();
/** @brief Loads a deck from the clipboard. */ /** @brief Loads a deck from the clipboard. */
virtual void actLoadDeckFromClipboard(); virtual void actLoadDeckFromClipboard();

View file

@ -3,35 +3,54 @@
#include "../../../client/settings/cache_settings.h" #include "../../../client/settings/cache_settings.h"
#include "../../deck_loader/deck_loader.h" #include "../../deck_loader/deck_loader.h"
#include "../../pixel_map_generator.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/server/remote/remote_decklist_tree_widget.h"
#include "../interface/widgets/utility/get_text_with_max.h" #include "../interface/widgets/utility/get_text_with_max.h"
#include <QAction> #include <QAction>
#include <QApplication> #include <QApplication>
#include <QDateTime>
#include <QDebug> #include <QDebug>
#include <QDesktopServices> #include <QDesktopServices>
#include <QFileSystemModel> #include <QFileSystemModel>
#include <QGroupBox> #include <QGroupBox>
#include <QHBoxLayout>
#include <QHeaderView> #include <QHeaderView>
#include <QInputDialog> #include <QInputDialog>
#include <QLineEdit>
#include <QMessageBox> #include <QMessageBox>
#include <QTimer>
#include <QToolBar> #include <QToolBar>
#include <QTreeView> #include <QTreeView>
#include <QUrl> #include <QUrl>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/deck_list.h> #include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/protocol/pb/command_deck_del.pb.h> #include <libcockatrice/protocol/pb/command_deck_del.pb.h>
#include <libcockatrice/protocol/pb/command_deck_del_dir.pb.h> #include <libcockatrice/protocol/pb/command_deck_del_dir.pb.h>
#include <libcockatrice/protocol/pb/command_deck_download.pb.h> #include <libcockatrice/protocol/pb/command_deck_download.pb.h>
#include <libcockatrice/protocol/pb/command_deck_new_dir.pb.h> #include <libcockatrice/protocol/pb/command_deck_new_dir.pb.h>
#include <libcockatrice/protocol/pb/command_deck_set_visibility.pb.h>
#include <libcockatrice/protocol/pb/command_deck_share_create.pb.h>
#include <libcockatrice/protocol/pb/command_deck_upload.pb.h> #include <libcockatrice/protocol/pb/command_deck_upload.pb.h>
#include <libcockatrice/protocol/pb/response.pb.h> #include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/response_deck_download.pb.h> #include <libcockatrice/protocol/pb/response_deck_download.pb.h>
#include <libcockatrice/protocol/pb/response_deck_share_create.pb.h>
#include <libcockatrice/protocol/pb/response_deck_upload.pb.h> #include <libcockatrice/protocol/pb/response_deck_upload.pb.h>
#include <libcockatrice/protocol/pending_command.h> #include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/settings/network_settings.h>
#include <libcockatrice/settings/paths_settings.h> #include <libcockatrice/settings/paths_settings.h>
#include <libcockatrice/utility/string_limits.h> #include <libcockatrice/utility/string_limits.h>
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, TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
AbstractClient *_client, AbstractClient *_client,
const ServerInfo_User *currentUserInfo) const ServerInfo_User *currentUserInfo)
@ -92,8 +111,34 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
serverDirView = new RemoteDeckList_TreeWidget(client); serverDirView = new RemoteDeckList_TreeWidget(client);
connect(serverDirView, &QTreeView::doubleClicked, this, &TabDeckStorage::actRemoteDoubleClick); 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<int>((static_cast<qint64>(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; QVBoxLayout *rightVbox = new QVBoxLayout;
rightVbox->addWidget(shareBar);
rightVbox->addWidget(serverDirView); rightVbox->addWidget(serverDirView);
rightVbox->addLayout(rightToolBarLayout); rightVbox->addLayout(rightToolBarLayout);
rightGroupBox = new QGroupBox; rightGroupBox = new QGroupBox;
@ -139,6 +184,14 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
aDeleteRemoteDeck->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); aDeleteRemoteDeck->setIcon(themePixmap(QStringLiteral("icons/remove_row")));
connect(aDeleteRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteRemoteDeck); 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 // Add actions to toolbars
leftToolBar->addAction(aOpenLocalDeck); leftToolBar->addAction(aOpenLocalDeck);
leftToolBar->addAction(aRenameLocal); leftToolBar->addAction(aRenameLocal);
@ -150,6 +203,8 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor,
rightToolBar->addAction(aOpenRemoteDeck); rightToolBar->addAction(aOpenRemoteDeck);
rightToolBar->addAction(aDownload); rightToolBar->addAction(aDownload);
rightToolBar->addAction(aShareDecks);
rightToolBar->addAction(aPublishDeck);
rightToolBar->addAction(aNewFolder); rightToolBar->addAction(aNewFolder);
rightToolBar->addAction(aDeleteRemoteDeck); rightToolBar->addAction(aDeleteRemoteDeck);
@ -178,7 +233,13 @@ void TabDeckStorage::retranslateUi()
aNewFolder->setText(tr("New folder")); aNewFolder->setText(tr("New folder"));
aDeleteLocalDeck->setText(tr("Delete")); aDeleteLocalDeck->setText(tr("Delete"));
aDeleteRemoteDeck->setText(tr("Delete")); aDeleteRemoteDeck->setText(tr("Delete"));
aShareDecks->setText(tr("Share decks"));
aPublishDeck->setText(tr("Publish/unpublish deck"));
aOpenDecksFolder->setText(tr("Open decks folder")); aOpenDecksFolder->setText(tr("Open decks folder"));
shareBar->retranslateUi();
if (shareBar->isVisible()) {
onServerSelectionChanged();
}
} }
QString TabDeckStorage::getTargetPath() const QString TabDeckStorage::getTargetPath() const
@ -211,6 +272,8 @@ void TabDeckStorage::handleConnected(const ServerInfo_User &userInfo)
void TabDeckStorage::handleConnectionChanged(ClientStatus status) void TabDeckStorage::handleConnectionChanged(ClientStatus status)
{ {
if (status == StatusDisconnected) { if (status == StatusDisconnected) {
visibilityRefreshTimer->stop();
visibilityRefreshStarted = false;
setRemoteEnabled(false); setRemoteEnabled(false);
} }
} }
@ -220,12 +283,15 @@ void TabDeckStorage::setRemoteEnabled(bool enabled)
aUpload->setEnabled(enabled); aUpload->setEnabled(enabled);
aOpenRemoteDeck->setEnabled(enabled); aOpenRemoteDeck->setEnabled(enabled);
aDownload->setEnabled(enabled); aDownload->setEnabled(enabled);
aShareDecks->setEnabled(enabled);
aPublishDeck->setEnabled(enabled);
aNewFolder->setEnabled(enabled); aNewFolder->setEnabled(enabled);
aDeleteRemoteDeck->setEnabled(enabled); aDeleteRemoteDeck->setEnabled(enabled);
if (enabled) { if (enabled) {
serverDirView->refreshTree(); serverDirView->refreshTree();
} else { } else {
setShareModeEnabled(false);
serverDirView->clearTree(); serverDirView->clearTree();
} }
} }
@ -347,6 +413,8 @@ void TabDeckStorage::uploadDeck(const QString &filePath, const QString &targetPa
cmd.set_path(targetPath.toStdString()); cmd.set_path(targetPath.toStdString());
cmd.set_deck_list(deckString.toStdString()); cmd.set_deck_list(deckString.toStdString());
cmd.set_color_identity(getDeckColorIdentity(deck, CardDatabaseManager::query()).toStdString());
PendingCommand *pend = client->prepareSessionCommand(cmd); PendingCommand *pend = client->prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &TabDeckStorage::uploadFinished); connect(pend, &PendingCommand::finished, this, &TabDeckStorage::uploadFinished);
client->sendCommand(pend); client->sendCommand(pend);
@ -626,3 +694,241 @@ void TabDeckStorage::deleteFolderFinished(const Response &response, const Comman
serverDirView->removeNode(toDelete); 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<const RemoteDeckList_TreeModel::DirectoryNode *>(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<const RemoteDeckList_TreeModel::DirectoryNode *>(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<const RemoteDeckList_TreeModel::FileNode *>(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<int>(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<int>((static_cast<qint64>(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<const RemoteDeckList_TreeModel::FileNode *>(node)) {
cmd.set_deck_id(fileNode->getId());
} else if (const auto *dirNode = dynamic_cast<const RemoteDeckList_TreeModel::DirectoryNode *>(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<int>(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();
}

View file

@ -11,6 +11,7 @@
#include "../interface/widgets/server/remote/remote_decklist_tree_widget.h" #include "../interface/widgets/server/remote/remote_decklist_tree_widget.h"
#include "tab.h" #include "tab.h"
#include <QStringList>
#include <libcockatrice/network/client/abstract/abstract_client.h> #include <libcockatrice/network/client/abstract/abstract_client.h>
struct LoadedDeck; struct LoadedDeck;
@ -22,8 +23,10 @@ class QToolBar;
class QTreeWidget; class QTreeWidget;
class QTreeWidgetItem; class QTreeWidgetItem;
class QGroupBox; class QGroupBox;
class QTimer;
class CommandContainer; class CommandContainer;
class Response; class Response;
class ShareBarWidget;
class TabDeckStorage : public Tab class TabDeckStorage : public Tab
{ {
@ -35,14 +38,25 @@ private:
QToolBar *leftToolBar, *rightToolBar; QToolBar *leftToolBar, *rightToolBar;
RemoteDeckList_TreeWidget *serverDirView; RemoteDeckList_TreeWidget *serverDirView;
QGroupBox *leftGroupBox, *rightGroupBox; QGroupBox *leftGroupBox, *rightGroupBox;
ShareBarWidget *shareBar;
QTimer *shareTimeoutTimer;
int shareRequestSeq = 0;
int shareInFlightSeq = 0;
QAction *aOpenLocalDeck, *aRenameLocal, *aUpload, *aNewLocalFolder, *aDeleteLocalDeck; QAction *aOpenLocalDeck, *aRenameLocal, *aUpload, *aNewLocalFolder, *aDeleteLocalDeck;
QAction *aOpenDecksFolder; QAction *aOpenDecksFolder;
QAction *aOpenRemoteDeck, *aDownload, *aNewFolder, *aDeleteRemoteDeck; QAction *aOpenRemoteDeck, *aDownload, *aShareDecks, *aPublishDeck, *aNewFolder, *aDeleteRemoteDeck;
bool visibilityRefreshStarted = false;
QTimer *visibilityRefreshTimer;
QStringList visibilityFailures;
QString getTargetPath() const; QString getTargetPath() const;
void setRemoteEnabled(bool enabled); 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 uploadDeck(const QString &filePath, const QString &targetPath);
void deleteRemoteDeck(const RemoteDeckList_TreeModel::Node *node); void deleteRemoteDeck(const RemoteDeckList_TreeModel::Node *node);
@ -75,6 +89,17 @@ private slots:
void actNewFolder(); void actNewFolder();
void newFolderFinished(const Response &response, const CommandContainer &commandContainer); 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 actDeleteRemoteDeck();
void deleteFolderFinished(const Response &response, const CommandContainer &commandContainer); void deleteFolderFinished(const Response &response, const CommandContainer &commandContainer);
void deleteDeckFinished(const Response &response, const CommandContainer &commandContainer); void deleteDeckFinished(const Response &response, const CommandContainer &commandContainer);

View file

@ -0,0 +1,251 @@
#include "tab_public_decks.h"
#include "../../../client/settings/cache_settings.h"
#include "../../deck_loader/deck_loader.h"
#include "../general/layout_containers/flow_widget.h"
#include "../visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h"
#include "../visual_deck_storage/deck_preview/public_deck_preview_widget.h"
#include "../visual_deck_storage/remote_public_decks_model.h"
#include "../visual_deck_storage/visual_deck_storage_quick_settings_widget.h"
#include "../visual_deck_storage/visual_deck_storage_search_widget.h"
#include "../visual_deck_storage/visual_deck_storage_tag_filter_widget.h"
#include "tab_supervisor.h"
#include <QDateTime>
#include <QHBoxLayout>
#include <QLabel>
#include <QMessageBox>
#include <QPixmap>
#include <QStringList>
#include <QToolButton>
#include <QVBoxLayout>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/command_deck_download_public.pb.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/response_deck_download.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <optional>
TabPublicDecks::TabPublicDecks(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &_userName)
: Tab(_tabSupervisor), client(_client), userName(_userName)
{
model = new RemotePublicDecksModel(client, this);
cardSize = SettingsCache::instance().cardsDisplay().getVisualDeckStorageCardSize();
titleLabel = new QLabel(tr("Public decks of %1").arg(userName.toHtmlEscaped()), this);
QFont titleFont = titleLabel->font();
titleFont.setBold(true);
titleLabel->setFont(titleFont);
auto *headerLayout = new QHBoxLayout;
headerLayout->addWidget(titleLabel);
headerLayout->addStretch(1);
// Filter/toolbar row, matching the Visual Deck Storage: color identity filter
// first, the search bar stretching in the middle, and the quick settings
// cogwheel at the end. The card size slider lives inside the cogwheel popup.
emptyLabel = new QLabel(tr("This user has not published any decks."), this);
emptyLabel->setAlignment(Qt::AlignCenter);
emptyLabel->setVisible(false);
statusLabel = new QLabel(this);
statusLabel->setAlignment(Qt::AlignCenter);
statusLabel->setVisible(false);
flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded);
flowWidget->setSpacing(8, 8);
colorIdentityFilter = new DeckPreviewColorIdentityFilterWidget(this);
searchWidget = new VisualDeckStorageSearchWidget(this);
refreshButton = new QToolButton(this);
refreshButton->setIcon(QPixmap("theme:icons/reload"));
refreshButton->setFixedSize(32, 32);
quickSettingsWidget = new VisualDeckStorageQuickSettingsWidget(this);
quickSettingsWidget->setPublicDecksMode(true);
auto *filterLayout = new QHBoxLayout;
filterLayout->addWidget(colorIdentityFilter);
filterLayout->addWidget(searchWidget, 1);
filterLayout->addWidget(refreshButton);
filterLayout->addWidget(quickSettingsWidget);
tagFilterWidget = new VisualDeckStorageTagFilterWidget(this);
tagFilterWidget->setAllTagsProvider([this] { return model->allTags(); });
updateTagsVisibility(quickSettingsWidget->getShowTagFilter());
auto *layout = new QVBoxLayout;
layout->addLayout(headerLayout);
layout->addLayout(filterLayout);
layout->addWidget(tagFilterWidget);
layout->addWidget(statusLabel);
layout->addWidget(emptyLabel);
layout->addWidget(flowWidget, 1);
auto *mainWidget = new QWidget(this);
mainWidget->setLayout(layout);
setCentralWidget(mainWidget);
connect(refreshButton, &QToolButton::clicked, this, [this] { model->refresh(userName); });
connect(model, &QAbstractItemModel::modelReset, this, &TabPublicDecks::rebuildGrid);
connect(model, &RemotePublicDecksModel::loadingChanged, this, &TabPublicDecks::updateLoadingState);
connect(model, &RemotePublicDecksModel::loadFailed, this, [this](const QString &message) {
lastFailureMessage = message;
statusLabel->setText(message);
statusLabel->setVisible(true);
flowWidget->setVisible(false);
emptyLabel->setVisible(false);
});
connect(searchWidget, &VisualDeckStorageSearchWidget::searchTextChanged, this,
[this](const QString &text) { model->setSearchText(text); });
connect(colorIdentityFilter, &DeckPreviewColorIdentityFilterWidget::activeColorsChanged, this,
&TabPublicDecks::updateColorFilter);
connect(colorIdentityFilter, &DeckPreviewColorIdentityFilterWidget::filterModeChanged, this,
&TabPublicDecks::updateColorFilter);
connect(tagFilterWidget, &VisualDeckStorageTagFilterWidget::filterChanged, this, &TabPublicDecks::updateTagFilter);
connect(quickSettingsWidget, &VisualDeckStorageQuickSettingsWidget::cardSizeChanged, this,
&TabPublicDecks::updateCardSize);
connect(quickSettingsWidget, &VisualDeckStorageQuickSettingsWidget::showTagFilterChanged, this,
&TabPublicDecks::updateTagsVisibility);
retranslateUi();
model->refresh(userName);
}
QString TabPublicDecks::getTabText() const
{
return tr("Public decks of %1").arg(userName);
}
void TabPublicDecks::retranslateUi()
{
// The username is another user's data, so escape it for the AutoText QLabel.
titleLabel->setText(tr("Public decks of %1").arg(userName.toHtmlEscaped()));
// The same choice rebuildGrid makes, so a language change does not swap
// the "no match" variant for the "nothing published" one.
emptyLabel->setText(model->totalCount() > 0 ? tr("No decks match your filters.")
: tr("This user has not published any decks."));
refreshButton->setToolTip(tr("Refresh"));
refreshButton->setAccessibleName(tr("Refresh"));
quickSettingsWidget->setToolTip(tr("Public Decks Settings"));
// Re-show whatever the status label is showing so a language change picks up
// the new language or, for a failure message, at least does not hide it.
if (model->isLoading()) {
updateLoadingState(true);
} else if (!lastFailureMessage.isEmpty()) {
statusLabel->setText(lastFailureMessage);
statusLabel->setVisible(true);
flowWidget->setVisible(false);
emptyLabel->setVisible(false);
} else {
updateLoadingState(false);
}
emit tabTextChanged(this, getTabText());
}
bool TabPublicDecks::closeRequest()
{
emit closing(this);
return Tab::closeRequest();
}
void TabPublicDecks::rebuildGrid()
{
flowWidget->clearLayout();
const int count = model->rowCount();
if (count == 0) {
emptyLabel->setText(model->totalCount() > 0 ? tr("No decks match your filters.")
: tr("This user has not published any decks."));
}
emptyLabel->setVisible(count == 0);
for (int i = 0; i < count; ++i) {
auto *tile = new PublicDeckPreviewWidget(flowWidget, model->entryAt(i));
tile->setScaleFactor(cardSize);
connect(tile, &PublicDeckPreviewWidget::openDeckRequested, this, &TabPublicDecks::openDeck);
flowWidget->addWidget(tile);
}
// The deck set changed, so the tag filter chips are re-gathered from it.
tagFilterWidget->refreshTags();
}
void TabPublicDecks::updateColorFilter()
{
model->setColorFilter(colorIdentityFilter->getFilterMode(), colorIdentityFilter->getActiveColors());
}
void TabPublicDecks::updateTagFilter()
{
const QStringList selectedTags = tagFilterWidget->selectedTags();
const QStringList excludedTags = tagFilterWidget->excludedTags();
model->setTagFilter(QSet<QString>(selectedTags.cbegin(), selectedTags.cend()),
QSet<QString>(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<PublicDeckPreviewWidget *>();
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<int>(response.response_code()))));
return;
}
const Response_DeckDownload &resp = response.GetExtension(Response_DeckDownload::ext);
std::optional<LoadedDeck> 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());
}

View file

@ -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

View file

@ -21,6 +21,7 @@
#include "tab_logs.h" #include "tab_logs.h"
#include "tab_message.h" #include "tab_message.h"
#include "tab_moderation.h" #include "tab_moderation.h"
#include "tab_public_decks.h"
#include "tab_replays.h" #include "tab_replays.h"
#include "tab_report.h" #include "tab_report.h"
#include "tab_room.h" #include "tab_room.h"
@ -274,6 +275,10 @@ void TabSupervisor::retranslateUi()
while (gameIterator.hasNext()) { while (gameIterator.hasNext()) {
tabs.append(gameIterator.next().value()); tabs.append(gameIterator.next().value());
} }
QMapIterator<QString, TabPublicDecks *> publicDecksIterator(publicDecksTabs);
while (publicDecksIterator.hasNext()) {
tabs.append(publicDecksIterator.next().value());
}
QListIterator<TabGame *> replayIterator(replayTabs); QListIterator<TabGame *> replayIterator(replayTabs);
while (replayIterator.hasNext()) { while (replayIterator.hasNext()) {
tabs.append(replayIterator.next()); tabs.append(replayIterator.next());
@ -626,6 +631,10 @@ void TabSupervisor::stop()
tabsToDelete << i.value(); tabsToDelete << i.value();
} }
for (auto i = publicDecksTabs.cbegin(), end = publicDecksTabs.cend(); i != end; ++i) {
tabsToDelete << i.value();
}
for (const auto tab : tabsToDelete) { for (const auto tab : tabsToDelete) {
tab->close(); tab->close();
} }
@ -672,7 +681,7 @@ void TabSupervisor::actTabVisualDeckStorage(bool checked)
void TabSupervisor::openTabVisualDeckStorage() void TabSupervisor::openTabVisualDeckStorage()
{ {
tabVisualDeckStorage = new TabDeckStorageVisual(this); tabVisualDeckStorage = new TabDeckStorageVisual(this, client);
myAddTab(tabVisualDeckStorage, aTabVisualDeckStorage); myAddTab(tabVisualDeckStorage, aTabVisualDeckStorage);
connect(tabVisualDeckStorage, &QObject::destroyed, this, [this] { connect(tabVisualDeckStorage, &QObject::destroyed, this, [this] {
tabVisualDeckStorage = nullptr; tabVisualDeckStorage = nullptr;
@ -1037,6 +1046,30 @@ void TabSupervisor::roomLeft(TabRoom *tab)
removeTab(indexOf(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() void TabSupervisor::switchToFirstAvailableNetworkTab()
{ {
if (!roomTabs.isEmpty()) { if (!roomTabs.isEmpty()) {

View file

@ -47,6 +47,7 @@ class TabAccount;
class TabDeckEditor; class TabDeckEditor;
class TabDeveloper; class TabDeveloper;
class TabLog; class TabLog;
class TabPublicDecks;
class RoomEvent; class RoomEvent;
class GameEventContainer; class GameEventContainer;
class Event_GameJoined; class Event_GameJoined;
@ -114,6 +115,7 @@ private:
QMap<int, TabGame *> gameTabs; QMap<int, TabGame *> gameTabs;
QList<TabGame *> replayTabs; QList<TabGame *> replayTabs;
QMap<QString, TabMessage *> messageTabs; QMap<QString, TabMessage *> messageTabs;
QMap<QString, TabPublicDecks *> publicDecksTabs;
QList<AbstractTabDeckEditor *> deckEditorTabs; QList<AbstractTabDeckEditor *> deckEditorTabs;
bool isLocalGame; bool isLocalGame;
@ -152,6 +154,10 @@ public:
return userInfo; return userInfo;
} }
[[nodiscard]] AbstractClient *getClient() const; [[nodiscard]] AbstractClient *getClient() const;
[[nodiscard]] AbstractClient *getServerClient() const
{
return client;
}
[[nodiscard]] UserListManager *getUserListManager() const [[nodiscard]] UserListManager *getUserListManager() const
{ {
return userListManager; return userListManager;
@ -199,6 +205,7 @@ public slots:
void actTabReplays(bool checked); void actTabReplays(bool checked);
void openTabServer(); void openTabServer();
void addRoomTab(const ServerInfo_Room &info, bool setCurrent); void addRoomTab(const ServerInfo_Room &info, bool setCurrent);
void openTabPublicDecks(const QString &userName);
private slots: private slots:
void refreshShortcuts(); void refreshShortcuts();
@ -231,6 +238,7 @@ private slots:
void localGameJoined(const Event_GameJoined &event); void localGameJoined(const Event_GameJoined &event);
void gameLeft(TabGame *tab); void gameLeft(TabGame *tab);
void roomLeft(TabRoom *tab); void roomLeft(TabRoom *tab);
void publicDecksClosed(TabPublicDecks *tab);
TabMessage *addMessageTab(const QString &userName, bool focus); TabMessage *addMessageTab(const QString &userName, bool focus);
void replayLeft(TabGame *tab); void replayLeft(TabGame *tab);
void processUserLeft(const QString &userName); void processUserLeft(const QString &userName);

View file

@ -1,25 +1,78 @@
#include "tab_deck_storage_visual.h" #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 "../../interface/widgets/visual_deck_storage/visual_deck_storage_widget.h"
#include "../tab_supervisor.h" #include "../tab_supervisor.h"
#include <QMessageBox> #include <QMessageBox>
#include <libcockatrice/protocol/pb/command_deck_del.pb.h> #include <QTimer>
#include <QVBoxLayout>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/command_deck_share_create.pb.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/response_deck_share_create.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/settings/network_settings.h>
TabDeckStorageVisual::TabDeckStorageVisual(TabSupervisor *_tabSupervisor) TabDeckStorageVisual::TabDeckStorageVisual(TabSupervisor *_tabSupervisor, AbstractClient *_client)
: Tab(_tabSupervisor), visualDeckStorageWidget(new VisualDeckStorageWidget(this)) : Tab(_tabSupervisor), visualDeckStorageWidget(new VisualDeckStorageWidget(this)), client(_client),
shareTimeoutTimer(new QTimer(this))
{ {
connect(this, &TabDeckStorageVisual::openDeckEditor, tabSupervisor, &TabSupervisor::openDeckInNewTab); connect(this, &TabDeckStorageVisual::openDeckEditor, tabSupervisor, &TabSupervisor::openDeckInNewTab);
connect(visualDeckStorageWidget, &VisualDeckStorageWidget::deckLoadRequested, this, connect(visualDeckStorageWidget, &VisualDeckStorageWidget::deckLoadRequested, this,
&TabDeckStorageVisual::actOpenLocalDeck); &TabDeckStorageVisual::actOpenLocalDeck);
connect(visualDeckStorageWidget, &VisualDeckStorageWidget::openDeckEditor, this, connect(visualDeckStorageWidget, &VisualDeckStorageWidget::openDeckEditor, this,
&TabDeckStorageVisual::openDeckEditor); &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 *widget = new QWidget(this);
auto *layout = new QVBoxLayout(widget); auto *layout = new QVBoxLayout(widget);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(0);
widget->setLayout(layout); widget->setLayout(layout);
this->setCentralWidget(widget); this->setCentralWidget(widget);
layout->addWidget(visualDeckStorageWidget); 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<int>((static_cast<qint64>(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) void TabDeckStorageVisual::actOpenLocalDeck(const QString &filePath)
@ -33,3 +86,144 @@ void TabDeckStorageVisual::actOpenLocalDeck(const QString &filePath)
emit openDeckEditor(deckOpt.value()); 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<LoadedDeck> 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<int>(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);
}

View file

@ -7,14 +7,19 @@
#ifndef TAB_DECK_STORAGE_VISUAL_H #ifndef TAB_DECK_STORAGE_VISUAL_H
#define TAB_DECK_STORAGE_VISUAL_H #define TAB_DECK_STORAGE_VISUAL_H
#include "../../deck_share/share_bar_widget.h"
#include "../tab.h" #include "../tab.h"
#include <QStringList>
#include <libcockatrice/network/client/abstract/abstract_client.h>
struct LoadedDeck; struct LoadedDeck;
class AbstractClient; class AbstractClient;
class CommandContainer; class CommandContainer;
class DeckPreviewWidget; class DeckPreviewWidget;
class QFileSystemModel; class QFileSystemModel;
class QGroupBox; class QGroupBox;
class QTimer;
class QToolBar; class QToolBar;
class QTreeView; class QTreeView;
class QTreeWidget; class QTreeWidget;
@ -26,23 +31,55 @@ class TabDeckStorageVisual final : public Tab
{ {
Q_OBJECT Q_OBJECT
public: public:
explicit TabDeckStorageVisual(TabSupervisor *_tabSupervisor); explicit TabDeckStorageVisual(TabSupervisor *_tabSupervisor, AbstractClient *_client);
void retranslateUi() override void retranslateUi() override;
{
}
[[nodiscard]] QString getTabText() const override [[nodiscard]] QString getTabText() const override
{ {
return tr("Visual Deck Storage"); 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: public slots:
void actOpenLocalDeck(const QString &filePath); void actOpenLocalDeck(const QString &filePath);
void actShareDeck(const QString &filePath);
signals: signals:
void openDeckEditor(const LoadedDeck &deck); 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: private:
void showShareNotice(const QString &message, bool warning = false);
void updateShareHint();
VisualDeckStorageWidget *visualDeckStorageWidget; VisualDeckStorageWidget *visualDeckStorageWidget;
ShareBarWidget *shareBar = nullptr;
AbstractClient *client;
QTimer *shareTimeoutTimer;
int shareRequestSeq = 0;
int shareInFlightSeq = 0;
bool shareDeckAvailable = false;
}; };
#endif #endif

View file

@ -1,11 +1,10 @@
#include "deck_preview_color_identity_filter_widget.h" #include "deck_preview_color_identity_filter_widget.h"
#include "../../cards/additional_info/mana_symbol_widget.h" #include "../../cards/additional_info/mana_symbol_widget.h"
#include "../visual_deck_storage_widget.h"
#include <QSet> #include <QSet>
DeckPreviewColorIdentityFilterWidget::DeckPreviewColorIdentityFilterWidget(VisualDeckStorageWidget *parent) DeckPreviewColorIdentityFilterWidget::DeckPreviewColorIdentityFilterWidget(QWidget *parent)
: QWidget(parent), layout(new QHBoxLayout(this)) : QWidget(parent), layout(new QHBoxLayout(this))
{ {
setLayout(layout); setLayout(layout);

View file

@ -14,14 +14,12 @@
#include <QSet> #include <QSet>
#include <QWidget> #include <QWidget>
class VisualDeckStorageWidget;
class DeckPreviewColorIdentityFilterWidget : public QWidget class DeckPreviewColorIdentityFilterWidget : public QWidget
{ {
Q_OBJECT Q_OBJECT
public: public:
explicit DeckPreviewColorIdentityFilterWidget(VisualDeckStorageWidget *parent); explicit DeckPreviewColorIdentityFilterWidget(QWidget *parent = nullptr);
void retranslateUi(); void retranslateUi();
/** /**

View file

@ -48,6 +48,7 @@ QSize DeckPreviewTagDisplayWidget::sizeHint() const
void DeckPreviewTagDisplayWidget::mousePressEvent(QMouseEvent *event) void DeckPreviewTagDisplayWidget::mousePressEvent(QMouseEvent *event)
{ {
const TagState previousState = state;
switch (event->button()) { switch (event->button()) {
case Qt::LeftButton: case Qt::LeftButton:
setState(state != TagState::Selected ? TagState::Selected : TagState::NotSelected); setState(state != TagState::Selected ? TagState::Selected : TagState::NotSelected);
@ -62,7 +63,12 @@ void DeckPreviewTagDisplayWidget::mousePressEvent(QMouseEvent *event)
break; 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); QWidget::mousePressEvent(event);
} }

View file

@ -48,7 +48,10 @@ public:
signals: 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(); void tagClicked();

View file

@ -11,6 +11,7 @@
#include "deck_preview_deck_tags_display_widget.h" #include "deck_preview_deck_tags_display_widget.h"
#include <QFileInfo> #include <QFileInfo>
#include <QFrame>
#include <QInputDialog> #include <QInputDialog>
#include <QLabel> #include <QLabel>
#include <QMenu> #include <QMenu>
@ -27,7 +28,7 @@ DeckPreviewWidget::DeckPreviewWidget(QWidget *_parent,
VisualDeckStorageWidget *_visualDeckStorageWidget, VisualDeckStorageWidget *_visualDeckStorageWidget,
VisualDeckStorageModel *_model, VisualDeckStorageModel *_model,
const QString &_filePath) const QString &_filePath)
: QWidget(_parent), visualDeckStorageWidget(_visualDeckStorageWidget), model(_model), filePath(_filePath) : QWidget(_parent), filePath(_filePath), visualDeckStorageWidget(_visualDeckStorageWidget), model(_model)
{ {
layout = new QVBoxLayout(this); layout = new QVBoxLayout(this);
setLayout(layout); setLayout(layout);
@ -36,6 +37,8 @@ DeckPreviewWidget::DeckPreviewWidget(QWidget *_parent,
new DeckPreviewCardPictureWidget(this, false, visualDeckStorageWidget->deckPreviewSelectionAnimationEnabled); new DeckPreviewCardPictureWidget(this, false, visualDeckStorageWidget->deckPreviewSelectionAnimationEnabled);
pictureWidget->setFontSize(24); pictureWidget->setFontSize(24);
connect(pictureWidget, &DeckPreviewCardPictureWidget::imageClicked, this, &DeckPreviewWidget::imageClickedEvent); connect(pictureWidget, &DeckPreviewCardPictureWidget::imageClicked, this, &DeckPreviewWidget::imageClickedEvent);
connect(pictureWidget, &DeckPreviewCardPictureWidget::imageSingleClicked, this,
&DeckPreviewWidget::imageSingleClicked);
connect(pictureWidget, &DeckPreviewCardPictureWidget::imageDoubleClicked, this, connect(pictureWidget, &DeckPreviewCardPictureWidget::imageDoubleClicked, this,
&DeckPreviewWidget::imageDoubleClickedEvent); &DeckPreviewWidget::imageDoubleClickedEvent);
bannerCardDisplayWidget = pictureWidget; bannerCardDisplayWidget = pictureWidget;
@ -99,6 +102,15 @@ DeckPreviewWidget::DeckPreviewWidget(QWidget *_parent,
// to keep the resize handler from searching the widget tree on every layout pass. // to keep the resize handler from searching the widget tree on every layout pass.
fixedWidthChildren = {bannerCardDisplayWidget, colorIdentityWidget, deckTagsDisplayWidget, bannerCardLabel, fixedWidthChildren = {bannerCardDisplayWidget, colorIdentityWidget, deckTagsDisplayWidget, bannerCardLabel,
bannerCardComboBox}; 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() void DeckPreviewWidget::retranslateUi()
@ -122,6 +134,63 @@ void DeckPreviewWidget::resizeEvent(QResizeEvent *event)
for (QWidget *widget : fixedWidthChildren) { for (QWidget *widget : fixedWidthChildren) {
widget->setMaximumWidth(width); 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) 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) void DeckPreviewWidget::imageDoubleClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance)
{ {
Q_UNUSED(event); Q_UNUSED(event);
Q_UNUSED(instance); Q_UNUSED(instance);
if (isShareSelectable()) {
return; // in share mode a double click would just toggle a single selection
}
emit deckLoadRequested(filePath); 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, connect(menu->addAction(tr("Edit Tags")), &QAction::triggered, deckTagsDisplayWidget,
&DeckPreviewDeckTagsDisplayWidget::openTagEditDlg); &DeckPreviewDeckTagsDisplayWidget::openTagEditDlg);

View file

@ -17,6 +17,7 @@
#include <QWidget> #include <QWidget>
class QEnterEvent; class QEnterEvent;
class QFrame;
class QLabel; class QLabel;
class QMenu; class QMenu;
class QMouseEvent; class QMouseEvent;
@ -41,9 +42,19 @@ public:
*/ */
DeckPreviewCardPictureWidget *bannerCardDisplayWidget; 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: signals:
void deckLoadRequested(const QString &filePath); void deckLoadRequested(const QString &filePath);
void openDeckEditor(const LoadedDeck &deck); void openDeckEditor(const LoadedDeck &deck);
void shareDeckRequested(const QString &filePath);
void shareSelectionToggled(bool selected);
public slots: public slots:
/** /**
@ -66,6 +77,7 @@ public slots:
protected: protected:
void enterEvent(QEnterEvent *event) override; void enterEvent(QEnterEvent *event) override;
void resizeEvent(QResizeEvent *event) override; void resizeEvent(QResizeEvent *event) override;
bool eventFilter(QObject *watched, QEvent *event) override;
private: private:
[[nodiscard]] int row() const; [[nodiscard]] int row() const;
@ -76,6 +88,7 @@ private:
QMenu *createRightClickMenu(); QMenu *createRightClickMenu();
void addSetBannerCardMenu(QMenu *menu); void addSetBannerCardMenu(QMenu *menu);
void imageClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); void imageClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance);
void imageSingleClicked();
void imageDoubleClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); void imageDoubleClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance);
void actRenameDeck(); void actRenameDeck();
@ -84,7 +97,6 @@ private:
VisualDeckStorageWidget *visualDeckStorageWidget; VisualDeckStorageWidget *visualDeckStorageWidget;
VisualDeckStorageModel *model; VisualDeckStorageModel *model;
QString filePath;
QVBoxLayout *layout; QVBoxLayout *layout;
ColorIdentityWidget *colorIdentityWidget; ColorIdentityWidget *colorIdentityWidget;
DeckPreviewDeckTagsDisplayWidget *deckTagsDisplayWidget; DeckPreviewDeckTagsDisplayWidget *deckTagsDisplayWidget;
@ -92,6 +104,12 @@ private:
QComboBox *bannerCardComboBox; QComboBox *bannerCardComboBox;
QList<QWidget *> fixedWidthChildren; ///< Children clamped to the picture width on resize. QList<QWidget *> fixedWidthChildren; ///< Children clamped to the picture width on resize.
int lastKnownBannerWidth = -1; ///< The picture width last applied to the children. 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 class NoScrollFilter : public QObject

View file

@ -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 <QKeyEvent>
#include <QLabel>
#include <QMouseEvent>
#include <QResizeEvent>
#include <QVBoxLayout>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/settings/visual_deck_storage_settings.h>
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);
}

View file

@ -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 <QList>
#include <QString>
#include <QWidget>
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<QWidget *> fixedWidthChildren;
DeckPreviewCardPictureWidget *bannerCardDisplayWidget;
ColorIdentityWidget *colorIdentityWidget;
FlowWidget *tagsFlowWidget;
QLabel *uploadTimeLabel;
};
#endif // PUBLIC_DECK_PREVIEW_WIDGET_H

View file

@ -0,0 +1,220 @@
#include "remote_public_decks_model.h"
#include "../../../client/settings/cache_settings.h"
#include <QTimer>
#include <algorithm>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <libcockatrice/protocol/pb/command_deck_list_other_user.pb.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/response_deck_list.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_deckstorage.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/settings/network_settings.h>
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<int>((static_cast<qint64>(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<QChar> &colors)
{
colorFilterMode = mode;
activeColors = colors;
rebuildVisibleIndices();
}
void RemotePublicDecksModel::setTagFilter(const QSet<QString> &selected, const QSet<QString> &excluded)
{
includedTags = selected;
excludedTags = excluded;
rebuildVisibleIndices();
}
QSet<QString> RemotePublicDecksModel::allTags() const
{
QSet<QString> all;
for (const DeckEntry &entry : decks) {
all.unite(QSet<QString>(entry.tags.cbegin(), entry.tags.cend()));
}
return all;
}
void RemotePublicDecksModel::rebuildVisibleIndices()
{
QList<int> 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<QString> 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<int>(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);
}

View file

@ -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 <QAbstractListModel>
#include <QDateTime>
#include <QList>
#include <QSet>
#include <QStringList>
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<QChar> &colors);
/** @brief Filters decks by required (`selected`) and forbidden (`excluded`) tags. */
void setTagFilter(const QSet<QString> &selected, const QSet<QString> &excluded);
/** @brief All tags present across all loaded decks, for building filter chips. */
[[nodiscard]] QSet<QString> 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<DeckEntry> decks;
QList<int> 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<QChar> activeColors;
QSet<QString> includedTags;
QSet<QString> excludedTags;
};
#endif // REMOTE_PUBLIC_DECKS_MODEL_H

View file

@ -126,6 +126,11 @@ void VisualDeckStorageFolderDisplayWidget::continueDeckPass()
const bool matches = index.data(VisualDeckStorageRoles::FilterMatchRole).toBool(); const bool matches = index.data(VisualDeckStorageRoles::FilterMatchRole).toBool();
deckPreviewWidget->setVisible(matches); 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) { if (matches) {
++visibleDeckCount; ++visibleDeckCount;
} }
@ -209,6 +214,11 @@ DeckPreviewWidget *VisualDeckStorageFolderDisplayWidget::createDeckPreviewWidget
&VisualDeckStorageWidget::deckLoadRequested); &VisualDeckStorageWidget::deckLoadRequested);
connect(deckPreviewWidget, &DeckPreviewWidget::openDeckEditor, visualDeckStorageWidget, connect(deckPreviewWidget, &DeckPreviewWidget::openDeckEditor, visualDeckStorageWidget,
&VisualDeckStorageWidget::openDeckEditor); &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, connect(visualDeckStorageWidget->settings(), &VisualDeckStorageQuickSettingsWidget::cardSizeChanged,
deckPreviewWidget->bannerCardDisplayWidget, &CardInfoPictureWidget::setScaleFactor); deckPreviewWidget->bannerCardDisplayWidget, &CardInfoPictureWidget::setScaleFactor);
deckPreviewWidget->bannerCardDisplayWidget->setScaleFactor(visualDeckStorageWidget->settings()->getCardSize()); deckPreviewWidget->bannerCardDisplayWidget->setScaleFactor(visualDeckStorageWidget->settings()->getCardSize());
@ -216,6 +226,18 @@ DeckPreviewWidget *VisualDeckStorageFolderDisplayWidget::createDeckPreviewWidget
return deckPreviewWidget; return deckPreviewWidget;
} }
void VisualDeckStorageFolderDisplayWidget::setShareSelectable(bool selectable)
{
const auto previews = flowWidget->findChildren<DeckPreviewWidget *>();
for (DeckPreviewWidget *preview : previews) {
preview->setShareSelectable(selectable);
}
const auto subFolders = findChildren<VisualDeckStorageFolderDisplayWidget *>();
for (VisualDeckStorageFolderDisplayWidget *subFolder : subFolders) {
subFolder->setShareSelectable(selectable);
}
}
/** /**
* @brief Creates, removes and keeps in sync the subfolder widgets of this folder. * @brief Creates, removes and keeps in sync the subfolder widgets of this folder.
* *

View file

@ -51,6 +51,7 @@ public slots:
*/ */
void scheduleReconcile(); void scheduleReconcile();
void updateShowFolders(bool enabled); void updateShowFolders(bool enabled);
void setShareSelectable(bool selectable);
signals: signals:
/** /**

View file

@ -1,6 +1,7 @@
#include "visual_deck_storage_model.h" #include "visual_deck_storage_model.h"
#include "../../deck_loader/deck_loader.h" #include "../../deck_loader/deck_loader.h"
#include "../cards/additional_info/deck_color_identity.h"
#include <QDir> #include <QDir>
#include <QDirIterator> #include <QDirIterator>
@ -119,8 +120,6 @@ DeckScanResult scanDeckDirectory(const QString &deckPath)
} }
} // namespace } // namespace
static QString computeColorIdentity(const LoadedDeck &deck);
VisualDeckStorageModel::VisualDeckStorageModel(QObject *parent) : QAbstractListModel(parent) 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 // Color identity walks every card through the database, so compute it here to
// keep the completion handler on the UI thread cheap. // 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}; 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<QChar> 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. * @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.lastLoaded = QDateTime::fromString(deckList.getLastLoadedTimestamp());
data.bannerCard = deckList.getBannerCard(); data.bannerCard = deckList.getBannerCard();
if (recomputeColorIdentity) { if (recomputeColorIdentity) {
data.colorIdentity = computeColorIdentity(data.deck); data.colorIdentity = getDeckColorIdentity(data.deck.deckList, CardDatabaseManager::query());
} }
} }

View file

@ -50,6 +50,15 @@ VisualDeckStorageQuickSettingsWidget::VisualDeckStorageQuickSettingsWidget(QWidg
&SettingsCache::instance().visualDeckStorage(), &SettingsCache::instance().visualDeckStorage(),
&VisualDeckStorageSettings::setVisualDeckStorageShowTagsOnDeckPreviews); &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 // show banner card selector checkbox
showBannerCardComboBoxCheckBox = new QCheckBox(this); showBannerCardComboBoxCheckBox = new QCheckBox(this);
showBannerCardComboBoxCheckBox->setChecked( showBannerCardComboBoxCheckBox->setChecked(
@ -94,7 +103,7 @@ VisualDeckStorageQuickSettingsWidget::VisualDeckStorageQuickSettingsWidget(QWidg
unusedColorIdentityOpacityLayout->addWidget(unusedColorIdentitiesOpacitySpinBox); unusedColorIdentityOpacityLayout->addWidget(unusedColorIdentitiesOpacitySpinBox);
// tooltip selector // tooltip selector
auto deckPreviewTooltipWidget = new QWidget(this); deckPreviewTooltipWidget = new QWidget(this);
deckPreviewTooltipLabel = new QLabel(deckPreviewTooltipWidget); deckPreviewTooltipLabel = new QLabel(deckPreviewTooltipWidget);
deckPreviewTooltipComboBox = new QComboBox(deckPreviewTooltipWidget); deckPreviewTooltipComboBox = new QComboBox(deckPreviewTooltipWidget);
@ -128,6 +137,7 @@ VisualDeckStorageQuickSettingsWidget::VisualDeckStorageQuickSettingsWidget(QWidg
this->addSettingsWidget(showTagFilterCheckBox); this->addSettingsWidget(showTagFilterCheckBox);
this->addSettingsWidget(showColorIdentityCheckBox); this->addSettingsWidget(showColorIdentityCheckBox);
this->addSettingsWidget(showTagsOnDeckPreviewsCheckBox); this->addSettingsWidget(showTagsOnDeckPreviewsCheckBox);
this->addSettingsWidget(showUploadTimeCheckBox);
this->addSettingsWidget(showBannerCardComboBoxCheckBox); this->addSettingsWidget(showBannerCardComboBoxCheckBox);
this->addSettingsWidget(drawUnusedColorIdentitiesCheckBox); this->addSettingsWidget(drawUnusedColorIdentitiesCheckBox);
this->addSettingsWidget(unusedColorIdentityOpacityWidget); this->addSettingsWidget(unusedColorIdentityOpacityWidget);
@ -145,6 +155,7 @@ void VisualDeckStorageQuickSettingsWidget::retranslateUi()
showTagFilterCheckBox->setText(tr("Show Tag Filter")); showTagFilterCheckBox->setText(tr("Show Tag Filter"));
showColorIdentityCheckBox->setText(tr("Show Color Identity")); showColorIdentityCheckBox->setText(tr("Show Color Identity"));
showTagsOnDeckPreviewsCheckBox->setText(tr("Show Tags On Deck Previews")); showTagsOnDeckPreviewsCheckBox->setText(tr("Show Tags On Deck Previews"));
showUploadTimeCheckBox->setText(tr("Show Upload Time"));
showBannerCardComboBoxCheckBox->setText(tr("Show Banner Card Selection Option")); showBannerCardComboBoxCheckBox->setText(tr("Show Banner Card Selection Option"));
drawUnusedColorIdentitiesCheckBox->setText(tr("Draw unused Color Identities")); drawUnusedColorIdentitiesCheckBox->setText(tr("Draw unused Color Identities"));
unusedColorIdentitiesOpacityLabel->setText(tr("Unused Color Identities Opacity")); unusedColorIdentitiesOpacityLabel->setText(tr("Unused Color Identities Opacity"));
@ -155,6 +166,14 @@ void VisualDeckStorageQuickSettingsWidget::retranslateUi()
deckPreviewTooltipComboBox->setItemText(1, tr("Filepath")); 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 bool VisualDeckStorageQuickSettingsWidget::getShowFolders() const
{ {
return showFoldersCheckBox->isChecked(); return showFoldersCheckBox->isChecked();
@ -185,6 +204,11 @@ bool VisualDeckStorageQuickSettingsWidget::getShowTagsOnDeckPreviews() const
return showTagsOnDeckPreviewsCheckBox->isChecked(); return showTagsOnDeckPreviewsCheckBox->isChecked();
} }
bool VisualDeckStorageQuickSettingsWidget::getShowUploadTime() const
{
return showUploadTimeCheckBox->isChecked();
}
int VisualDeckStorageQuickSettingsWidget::getUnusedColorIdentitiesOpacity() const int VisualDeckStorageQuickSettingsWidget::getUnusedColorIdentitiesOpacity() const
{ {
return unusedColorIdentitiesOpacitySpinBox->value(); return unusedColorIdentitiesOpacitySpinBox->value();

View file

@ -27,10 +27,12 @@ class VisualDeckStorageQuickSettingsWidget : public SettingsButtonWidget
QCheckBox *showBannerCardComboBoxCheckBox; QCheckBox *showBannerCardComboBoxCheckBox;
QCheckBox *showTagFilterCheckBox; QCheckBox *showTagFilterCheckBox;
QCheckBox *showTagsOnDeckPreviewsCheckBox; QCheckBox *showTagsOnDeckPreviewsCheckBox;
QCheckBox *showUploadTimeCheckBox;
QLabel *unusedColorIdentitiesOpacityLabel; QLabel *unusedColorIdentitiesOpacityLabel;
QSpinBox *unusedColorIdentitiesOpacitySpinBox; QSpinBox *unusedColorIdentitiesOpacitySpinBox;
QLabel *deckPreviewTooltipLabel; QLabel *deckPreviewTooltipLabel;
QComboBox *deckPreviewTooltipComboBox; QComboBox *deckPreviewTooltipComboBox;
QWidget *deckPreviewTooltipWidget;
CardSizeWidget *cardSizeWidget; CardSizeWidget *cardSizeWidget;
public: public:
@ -46,6 +48,15 @@ public:
explicit VisualDeckStorageQuickSettingsWidget(QWidget *parent = nullptr); 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(); void retranslateUi();
[[nodiscard]] bool getShowFolders() const; [[nodiscard]] bool getShowFolders() const;
@ -54,6 +65,7 @@ public:
[[nodiscard]] bool getShowBannerCardComboBox() const; [[nodiscard]] bool getShowBannerCardComboBox() const;
[[nodiscard]] bool getShowTagFilter() const; [[nodiscard]] bool getShowTagFilter() const;
[[nodiscard]] bool getShowTagsOnDeckPreviews() const; [[nodiscard]] bool getShowTagsOnDeckPreviews() const;
[[nodiscard]] bool getShowUploadTime() const;
[[nodiscard]] int getUnusedColorIdentitiesOpacity() const; [[nodiscard]] int getUnusedColorIdentitiesOpacity() const;
[[nodiscard]] TooltipType getDeckPreviewTooltip() const; [[nodiscard]] TooltipType getDeckPreviewTooltip() const;
[[nodiscard]] int getCardSize() const; [[nodiscard]] int getCardSize() const;
@ -65,6 +77,7 @@ signals:
void showBannerCardComboBoxChanged(bool enabled); void showBannerCardComboBoxChanged(bool enabled);
void showTagFilterChanged(bool enabled); void showTagFilterChanged(bool enabled);
void showTagsOnDeckPreviewsChanged(bool enabled); void showTagsOnDeckPreviewsChanged(bool enabled);
void showUploadTimeChanged(bool enabled);
void unusedColorIdentitiesOpacityChanged(int opacity); void unusedColorIdentitiesOpacityChanged(int opacity);
void deckPreviewTooltipChanged(TooltipType tooltip); void deckPreviewTooltipChanged(TooltipType tooltip);
void cardSizeChanged(int scale); void cardSizeChanged(int scale);

View file

@ -11,6 +11,34 @@ VisualDeckStorageSortFilterProxyModel::VisualDeckStorageSortFilterProxyModel(QOb
setDynamicSortFilter(false); setDynamicSortFilter(false);
} }
bool colorIdentityMatches(VisualDeckStorageSortFilterProxyModel::FilterMode mode,
const QSet<QChar> &colors,
const QString &identity)
{
switch (mode) {
case VisualDeckStorageSortFilterProxyModel::ExactMatch: {
QSet<QChar> activeColorSet;
for (const QChar &color : colors) {
activeColorSet.insert(color.toUpper());
}
QSet<QChar> 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) void VisualDeckStorageSortFilterProxyModel::setSourceModel(QAbstractItemModel *model)
{ {
if (QAbstractItemModel *oldModel = sourceModel()) { if (QAbstractItemModel *oldModel = sourceModel()) {
@ -255,34 +283,7 @@ void VisualDeckStorageSortFilterProxyModel::updateColorMatches()
for (int row = 0; row < count; ++row) { for (int row = 0; row < count; ++row) {
const QString colorIdentity = source->dataForRow(row).colorIdentity; const QString colorIdentity = source->dataForRow(row).colorIdentity;
colorMatches[row] = colorIdentityMatches(colorFilterMode, activeColors, colorIdentity);
bool matches = true;
switch (colorFilterMode) {
case ExactMatch: {
QSet<QChar> activeColorSet;
for (const QChar &color : activeColors) {
activeColorSet.insert(color.toUpper());
}
QSet<QChar> 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;
} }
} }

View file

@ -102,4 +102,14 @@ private:
QList<bool> colorMatches; ///< Per-row color identity match. QList<bool> 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<QChar> &colors,
const QString &identity);
#endif // VISUAL_DECK_STORAGE_SORT_FILTER_PROXY_MODEL_H #endif // VISUAL_DECK_STORAGE_SORT_FILTER_PROXY_MODEL_H

View file

@ -2,14 +2,10 @@
#include "../general/layout_containers/flow_widget.h" #include "../general/layout_containers/flow_widget.h"
#include "deck_preview/deck_preview_tag_display_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 <QHBoxLayout> #include <QHBoxLayout>
VisualDeckStorageTagFilterWidget::VisualDeckStorageTagFilterWidget(VisualDeckStorageWidget *_parent) VisualDeckStorageTagFilterWidget::VisualDeckStorageTagFilterWidget(QWidget *parent) : QWidget(parent)
: QWidget(_parent), parent(_parent)
{ {
setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum);
@ -25,99 +21,72 @@ VisualDeckStorageTagFilterWidget::VisualDeckStorageTagFilterWidget(VisualDeckSto
layout->addWidget(flowWidget); layout->addWidget(flowWidget);
} }
void VisualDeckStorageTagFilterWidget::setAllTagsProvider(const std::function<QSet<QString>()> &provider)
{
allTagsProvider = provider;
}
void VisualDeckStorageTagFilterWidget::showEvent(QShowEvent *event) void VisualDeckStorageTagFilterWidget::showEvent(QShowEvent *event)
{ {
QWidget::showEvent(event); QWidget::showEvent(event);
refreshTags(); refreshTags();
} }
/**
* @brief The tags of all decks currently accepted by the proxy model.
*/
QSet<QString> VisualDeckStorageTagFilterWidget::gatherAllTags() const
{
QSet<QString> 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() void VisualDeckStorageTagFilterWidget::refreshTags()
{ {
QSet<QString> allTags = gatherAllTags(); const QSet<QString> allTags = allTagsProvider ? allTagsProvider() : QSet<QString>();
removeTagsNotInList(allTags);
addTagsIfNotPresent(allTags);
sortTags();
}
void VisualDeckStorageTagFilterWidget::removeTagsNotInList(const QSet<QString> &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<DeckPreviewTagDisplayWidget *> chips;
for (DeckPreviewTagDisplayWidget *tagWidget : findChildren<DeckPreviewTagDisplayWidget *>()) { for (DeckPreviewTagDisplayWidget *tagWidget : findChildren<DeckPreviewTagDisplayWidget *>()) {
const QString &tagName = tagWidget->getTagName(); if (tagWidget->getState() != TagState::NotSelected || allTags.contains(tagWidget->getTagName())) {
chips.append(tagWidget);
// Keep the tag widget if it is either selected or excluded } else {
if (!tags.contains(tagName) && tagWidget->getState() == TagState::NotSelected) {
flowWidget->removeWidget(tagWidget); flowWidget->removeWidget(tagWidget);
tagWidget->setParent(nullptr);
tagWidget->deleteLater(); tagWidget->deleteLater();
} }
} }
}
void VisualDeckStorageTagFilterWidget::addTagsIfNotPresent(const QSet<QString> &tags) // Add chips for tags that are not shown yet.
{ QSet<QString> existingTags;
for (const QString &tag : tags) { for (DeckPreviewTagDisplayWidget *tagWidget : chips) {
addTagIfNotPresent(tag); existingTags.insert(tagWidget->getTagName());
} }
} for (const QString &tag : allTags) {
if (!existingTags.contains(tag)) {
void VisualDeckStorageTagFilterWidget::addTagIfNotPresent(const QString &tag) auto *newTagWidget = new DeckPreviewTagDisplayWidget(this, tag);
{ connect(newTagWidget, &DeckPreviewTagDisplayWidget::tagClicked, this,
// Check if the tag already exists in the flow widget &VisualDeckStorageTagFilterWidget::filterChanged);
bool tagExists = false; flowWidget->addWidget(newTagWidget);
for (DeckPreviewTagDisplayWidget *tagWidget : findChildren<DeckPreviewTagDisplayWidget *>()) { chips.append(newTagWidget);
if (tagWidget->getTagName() == tag) {
tagExists = true;
break;
} }
} }
// If the tag doesn't exist, add a new DeckPreviewTagDisplayWidget // Sort, but skip the full remove/re-add when the order already matches the layout.
if (!tagExists) { // FlowWidget inherits QLayout::removeWidget's linear scan, so rebuilding an unchanged
auto *newTagWidget = new DeckPreviewTagDisplayWidget(this, tag); // order would be quadratic plus a full relayout on every chip click and load batch.
connect(newTagWidget, &DeckPreviewTagDisplayWidget::tagClicked, parent, std::sort(chips.begin(), chips.end(), [](DeckPreviewTagDisplayWidget *a, DeckPreviewTagDisplayWidget *b) {
&VisualDeckStorageWidget::updateTagFilter); const QString aName = a->getTagName();
flowWidget->addWidget(newTagWidget); const QString bName = b->getTagName();
} const int compared = aName.compare(bName, Qt::CaseInsensitive);
} return compared != 0 ? compared < 0 : aName < bName;
void VisualDeckStorageTagFilterWidget::sortTags()
{
// Get all tag widgets
QList<DeckPreviewTagDisplayWidget *> tagWidgets = findChildren<DeckPreviewTagDisplayWidget *>();
// Sort widgets by tag name
std::sort(tagWidgets.begin(), tagWidgets.end(), [](DeckPreviewTagDisplayWidget *a, DeckPreviewTagDisplayWidget *b) {
return a->getTagName().toLower() < b->getTagName().toLower();
}); });
if (chips == currentChipOrder) {
// Clear and re-add widgets in sorted order return;
for (DeckPreviewTagDisplayWidget *tagWidget : tagWidgets) { }
for (DeckPreviewTagDisplayWidget *tagWidget : chips) {
flowWidget->removeWidget(tagWidget); flowWidget->removeWidget(tagWidget);
} }
for (DeckPreviewTagDisplayWidget *tagWidget : tagWidgets) { for (DeckPreviewTagDisplayWidget *tagWidget : chips) {
flowWidget->addWidget(tagWidget); flowWidget->addWidget(tagWidget);
} }
currentChipOrder = chips;
} }
QStringList VisualDeckStorageTagFilterWidget::selectedTags() const QStringList VisualDeckStorageTagFilterWidget::selectedTags() const

View file

@ -9,26 +9,28 @@
#include <QSet> #include <QSet>
#include <QStringList> #include <QStringList>
#include <QWidget> #include <QWidget>
#include <functional>
class DeckPreviewTagDisplayWidget;
class FlowWidget; class FlowWidget;
class VisualDeckStorageWidget;
class VisualDeckStorageTagFilterWidget : public QWidget class VisualDeckStorageTagFilterWidget : public QWidget
{ {
Q_OBJECT Q_OBJECT
VisualDeckStorageWidget *parent;
FlowWidget *flowWidget; FlowWidget *flowWidget;
std::function<QSet<QString>()> allTagsProvider;
[[nodiscard]] QSet<QString> gatherAllTags() const; QList<DeckPreviewTagDisplayWidget *> currentChipOrder;
void removeTagsNotInList(const QSet<QString> &tags);
void addTagsIfNotPresent(const QSet<QString> &tags);
void addTagIfNotPresent(const QString &tag);
void sortTags();
public: public:
explicit VisualDeckStorageTagFilterWidget(VisualDeckStorageWidget *_parent); explicit VisualDeckStorageTagFilterWidget(QWidget *parent = nullptr);
[[nodiscard]] QStringList getAllKnownTags() const; [[nodiscard]] QStringList getAllKnownTags() const;
/**
* @brief Sets a provider for the full set of tags to draw chips from.
*/
void setAllTagsProvider(const std::function<QSet<QString>()> &provider);
/** /**
* @brief The tags currently in "selected" state. * @brief The tags currently in "selected" state.
*/ */
@ -39,9 +41,18 @@ public:
*/ */
[[nodiscard]] QStringList excludedTags() const; [[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: 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 refreshTags();
void showEvent(QShowEvent *event) override; void showEvent(QShowEvent *event) override;

View file

@ -48,6 +48,12 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare
refreshButton->setFixedSize(32, 32); refreshButton->setFixedSize(32, 32);
connect(refreshButton, &QPushButton::clicked, this, &VisualDeckStorageWidget::refreshIfPossible); 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); quickSettingsWidget = new VisualDeckStorageQuickSettingsWidget(this);
connect(quickSettingsWidget, &VisualDeckStorageQuickSettingsWidget::showFoldersChanged, this, connect(quickSettingsWidget, &VisualDeckStorageQuickSettingsWidget::showFoldersChanged, this,
&VisualDeckStorageWidget::updateShowFolders); &VisualDeckStorageWidget::updateShowFolders);
@ -58,10 +64,14 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare
searchAndSortLayout->addWidget(sortWidget); searchAndSortLayout->addWidget(sortWidget);
searchAndSortLayout->addWidget(searchWidget); searchAndSortLayout->addWidget(searchWidget);
searchAndSortLayout->addWidget(refreshButton); searchAndSortLayout->addWidget(refreshButton);
searchAndSortLayout->addWidget(shareButton);
searchAndSortLayout->addWidget(quickSettingsWidget); searchAndSortLayout->addWidget(quickSettingsWidget);
// tag filter box // tag filter box
tagFilterWidget = new VisualDeckStorageTagFilterWidget(this); tagFilterWidget = new VisualDeckStorageTagFilterWidget(this);
tagFilterWidget->setAllTagsProvider([this] { return gatherVisibleTags(); });
connect(tagFilterWidget, &VisualDeckStorageTagFilterWidget::filterChanged, this,
&VisualDeckStorageWidget::updateTagFilter);
updateTagsVisibility(SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowTagFilter()); updateTagsVisibility(SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowTagFilter());
deckPreviewSelectionAnimationEnabled = deckPreviewSelectionAnimationEnabled =
@ -155,11 +165,71 @@ void VisualDeckStorageWidget::retranslateUi()
databaseLoadIndicator->setText(tr("Loading database ...")); databaseLoadIndicator->setText(tr("Loading database ..."));
refreshButton->setToolTip(tr("Refresh loaded files")); refreshButton->setToolTip(tr("Refresh loaded files"));
shareButton->setToolTip(tr("Select decks to share"));
quickSettingsWidget->setToolTip(tr("Visual Deck Storage Settings")); quickSettingsWidget->setToolTip(tr("Visual Deck Storage Settings"));
sortWidget->retranslateUi(); 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<DeckPreviewWidget *>();
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<DeckPreviewWidget *>();
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<DeckPreviewWidget *>();
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. * Gets a const pointer to the quick settings so that the values can be accessed.
*/ */
@ -217,6 +287,25 @@ void VisualDeckStorageWidget::updateTagFilter()
tagFilterWidget->refreshTags(); tagFilterWidget->refreshTags();
} }
/**
* @brief The tags of all decks currently accepted by the proxy model.
*/
QSet<QString> VisualDeckStorageWidget::gatherVisibleTags() const
{
QSet<QString> 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. * Pushes the color identity filter widget's state into the proxy model.
*/ */

View file

@ -33,6 +33,12 @@ public:
explicit VisualDeckStorageWidget(QWidget *parent); explicit VisualDeckStorageWidget(QWidget *parent);
void refreshIfPossible(); void refreshIfPossible();
void retranslateUi(); 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; VisualDeckStorageTagFilterWidget *tagFilterWidget;
bool deckPreviewSelectionAnimationEnabled; bool deckPreviewSelectionAnimationEnabled;
@ -63,6 +69,9 @@ public slots:
signals: signals:
void deckLoadRequested(const QString &filePath); void deckLoadRequested(const QString &filePath);
void openDeckEditor(const LoadedDeck &deck); void openDeckEditor(const LoadedDeck &deck);
void shareDeckRequested(const QString &filePath);
void shareSelectionChanged();
void shareRequested();
protected: protected:
void resizeEvent(QResizeEvent *event) override; void resizeEvent(QResizeEvent *event) override;
@ -70,6 +79,7 @@ protected:
private: private:
void reapplySortAndFilters(); void reapplySortAndFilters();
[[nodiscard]] QSet<QString> gatherVisibleTags() const;
private: private:
QVBoxLayout *layout; QVBoxLayout *layout;
@ -80,12 +90,14 @@ private:
VisualDeckStorageSearchWidget *searchWidget; VisualDeckStorageSearchWidget *searchWidget;
DeckPreviewColorIdentityFilterWidget *deckPreviewColorIdentityFilterWidget; DeckPreviewColorIdentityFilterWidget *deckPreviewColorIdentityFilterWidget;
QToolButton *refreshButton; QToolButton *refreshButton;
QToolButton *shareButton;
VisualDeckStorageQuickSettingsWidget *quickSettingsWidget; VisualDeckStorageQuickSettingsWidget *quickSettingsWidget;
QScrollArea *scrollArea; QScrollArea *scrollArea;
VisualDeckStorageFolderDisplayWidget *folderWidget = nullptr; VisualDeckStorageFolderDisplayWidget *folderWidget = nullptr;
VisualDeckStorageModel *storageModel = nullptr; VisualDeckStorageModel *storageModel = nullptr;
VisualDeckStorageSortFilterProxyModel *storageProxyModel = nullptr; VisualDeckStorageSortFilterProxyModel *storageProxyModel = nullptr;
QTimer *refreshTimer = nullptr; ///< Coalesces the re-apply/refresh burst following a batch of deck loads. QTimer *refreshTimer = nullptr; ///< Coalesces the re-apply/refresh burst following a batch of deck loads.
bool shareSelectable = false;
}; };
#endif // VISUAL_DECK_STORAGE_WIDGET_H #endif // VISUAL_DECK_STORAGE_WIDGET_H

View file

@ -512,6 +512,7 @@ MainWindow::MainWindow(QWidget *parent)
connectionController = new ConnectionController(this, this); connectionController = new ConnectionController(this, this);
urlParser = new IntentUrlParser(this, this); urlParser = new IntentUrlParser(this, this);
connect(urlParser, &IntentUrlParser::urlChainFinished, this, &MainWindow::onUrlChainFinished);
createActions(); createActions();
createMenus(); createMenus();
@ -707,6 +708,12 @@ void MainWindow::applyStartupDestination()
return; 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(); const int destination = SettingsCache::instance().tabs().getStartupTabIndex();
if (destination != StartupTab::StartupTabServer && destination != StartupTab::StartupTabServerRoom) { if (destination != StartupTab::StartupTabServer && destination != StartupTab::StartupTabServerRoom) {
return; return;
@ -728,6 +735,7 @@ void MainWindow::applyStartupDestination()
connect(credentials, &Intent::finished, connector, &Intent::execute); connect(credentials, &Intent::finished, connector, &Intent::execute);
connect(credentials, &Intent::failed, this, &MainWindow::startupDestinationFailed); connect(credentials, &Intent::failed, this, &MainWindow::startupDestinationFailed);
connect(credentials, &Intent::cancelled, this, [this]() { startupDestinationFailed(tr("Sign-in cancelled")); });
connect(connector, &Intent::finished, this, connect(connector, &Intent::finished, this,
[this, destination, serverContext]() { onStartupDestinationConnected(destination, *serverContext); }); [this, destination, serverContext]() { onStartupDestinationConnected(destination, *serverContext); });
connect(connector, &Intent::failed, this, &MainWindow::startupDestinationFailed); connect(connector, &Intent::failed, this, &MainWindow::startupDestinationFailed);
@ -879,18 +887,7 @@ void MainWindow::changeEvent(QEvent *event)
} else if (event->type() == QEvent::ActivationChange) { } else if (event->type() == QEvent::ActivationChange) {
if (isActiveWindow() && !bHasActivated) { if (isActiveWindow() && !bHasActivated) {
bHasActivated = true; bHasActivated = true;
if (!connectTo.isEmpty()) { attemptStartupAutoConnect();
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<unsigned int>(dlg.getPort()),
dlg.getPlayerName(), dlg.getPassword());
}
} }
} }
@ -916,6 +913,59 @@ void MainWindow::handleCockatriceLink(const QString &url)
urlParser->handle(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<unsigned int>(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() void MainWindow::cardDatabaseLoadingFailed()
{ {
if (askedForDbUpdater) { if (askedForDbUpdater) {

View file

@ -80,6 +80,7 @@ public slots:
void actCheckClientUpdates(); void actCheckClientUpdates();
void actConnect(); void actConnect();
void actExit(); void actExit();
void handleCockatriceLink(const QString &url);
private slots: private slots:
void updateTabMenu(const QList<QMenu *> &newMenuList); void updateTabMenu(const QList<QMenu *> &newMenuList);
void statusChanged(ClientStatus _status); void statusChanged(ClientStatus _status);
@ -97,7 +98,7 @@ private slots:
void actOpenSettingsFolder(); void actOpenSettingsFolder();
void actShow(); void actShow();
void showWindowIfHidden(); void showWindowIfHidden();
void handleCockatriceLink(const QString &url); void onUrlChainFinished(bool connected);
void cardUpdateError(QProcess::ProcessError err); void cardUpdateError(QProcess::ProcessError err);
void cardUpdateFinished(int exitCode, QProcess::ExitStatus exitStatus); void cardUpdateFinished(int exitCode, QProcess::ExitStatus exitStatus);
@ -126,6 +127,8 @@ private slots:
void startupDestinationFailed(const QString &reason); void startupDestinationFailed(const QString &reason);
[[nodiscard]] bool startupDestinationConnectsToServer() const; [[nodiscard]] bool startupDestinationConnectsToServer() const;
void attemptStartupAutoConnect();
private: private:
static const QString appName; static const QString appName;
static const QStringList fileNameFilters; static const QStringList fileNameFilters;
@ -164,6 +167,8 @@ private:
LagMonitor lagMonitor; ///< watches the main thread for event loop stalls LagMonitor lagMonitor; ///< watches the main thread for event loop stalls
LatencyStatusWidget *latencyStatus = nullptr; ///< status bar widget with live round-trip stats and history graph LatencyStatusWidget *latencyStatus = nullptr; ///< status bar widget with live round-trip stats and history graph
bool bHasActivated, askedForDbUpdater; bool bHasActivated, askedForDbUpdater;
bool skipStartupAutoConnect = false;
bool startupAutoConnectAttempted = false;
QProcess *cardUpdateProcess; QProcess *cardUpdateProcess;
QByteArray cardUpdateOutputBuffer; QByteArray cardUpdateOutputBuffer;
DlgViewLog *logviewDialog; DlgViewLog *logviewDialog;
@ -177,6 +182,16 @@ public:
{ {
connectTo = QUrl(QString("cockatrice://%1").arg(url)); 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; ~MainWindow() override;
RemoteClient *getRemoteClient() const RemoteClient *getRemoteClient() const

View file

@ -26,7 +26,6 @@
#include "client/url_scheme_event_filter.h" #include "client/url_scheme_event_filter.h"
#include "database/interface/settings_card_preference_provider.h" #include "database/interface/settings_card_preference_provider.h"
#include "interface/intents/intent_open_local_deck.h" #include "interface/intents/intent_open_local_deck.h"
#include "interface/intents/url_parser.h"
#include "interface/logger.h" #include "interface/logger.h"
#include "interface/pixel_map_generator.h" #include "interface/pixel_map_generator.h"
#include "interface/theme_manager.h" #include "interface/theme_manager.h"
@ -45,6 +44,8 @@
#include <QMessageBox> #include <QMessageBox>
#include <QSystemTrayIcon> #include <QSystemTrayIcon>
#include <QTranslator> #include <QTranslator>
#include <QUrl>
#include <algorithm>
#include <libcockatrice/card/database/card_database_manager.h> #include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/rng/rng_sfmt.h> #include <libcockatrice/rng/rng_sfmt.h>
#include <libcockatrice/settings/appearance_settings.h> #include <libcockatrice/settings/appearance_settings.h>
@ -177,6 +178,18 @@ QString const generateClientID()
return strClientID; 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[]) int main(int argc, char *argv[])
{ {
#ifdef Q_OS_WIN #ifdef Q_OS_WIN
@ -273,10 +286,17 @@ int main(int argc, char *argv[])
SingleInstanceManager instance; SingleInstanceManager instance;
if (hasActivationFiles) { 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 // Activation launch: hand off to the primary instance if one is
// running, otherwise become the primary ourselves. Do this before // running, otherwise become the primary ourselves. Do this before
// constructing the main window so a hand-off exits cheaply. // constructing the main window so a hand-off exits cheaply.
if (!instance.tryRun(startupFiles)) { if (!instance.tryRun(startupFiles)) {
qInfo() << "Handed off to a running instance, exiting";
// Sent successfully → exit // Sent successfully → exit
return 0; return 0;
} }
@ -325,10 +345,30 @@ int main(int argc, char *argv[])
MainWindow ui; 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) { auto handleActivation = [&ui](const QString &file) {
if (file.startsWith("cockatrice://")) { if (file.startsWith("cockatrice://")) {
auto urlParser = new IntentUrlParser(&ui, &ui); qCInfo(MainLog) << "Handling URL activation:" << redactActivationUrl(file);
urlParser->handle(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()) { } else if (QFileInfo(file).exists()) {
auto openDeckIntent = new IntentOpenLocalDeck(ui.getTabSupervisor(), file); auto openDeckIntent = new IntentOpenLocalDeck(ui.getTabSupervisor(), file);
QObject::connect(openDeckIntent, &Intent::failed, &ui, [&ui](const QString &reason) { QObject::connect(openDeckIntent, &Intent::failed, &ui, [&ui](const QString &reason) {

View file

@ -2,6 +2,14 @@
#include <QDir> #include <QDir>
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) SingleInstanceManager::SingleInstanceManager(QObject *parent) : QObject(parent)
{ {
} }
@ -20,9 +28,15 @@ bool SingleInstanceManager::tryRun(const QStringList &filesToSend)
} }
serverName = QStringLiteral("CockatriceSingleInstance-%1").arg(userName); serverName = QStringLiteral("CockatriceSingleInstance-%1").arg(userName);
// Hand off to an already-running primary instance if one exists. // Hand off to an already-running primary instance if one exists. Never steal
if (forwardToPrimary(filesToSend)) { // the socket of a busy primary: it is alive and will act on the payload.
return false; 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. // 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 // Another instance may have started while we were probing; hand off to it
// instead of stealing its socket. // instead of stealing its socket.
if (forwardToPrimary(filesToSend)) { switch (forwardToPrimary(filesToSend)) {
return false; 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 // The socket is stale (left over by a crashed instance), so no primary is
// retry. If that still fails, another instance just took the name. // holding it: remove it and retry. If that still fails, another instance
// just took the name.
QLocalServer::removeServer(serverName); QLocalServer::removeServer(serverName);
if (server->listen(serverName)) { if (server->listen(serverName)) {
return true; return true;
@ -50,12 +70,12 @@ bool SingleInstanceManager::tryRun(const QStringList &filesToSend)
return false; return false;
} }
bool SingleInstanceManager::forwardToPrimary(const QStringList &filesToSend) SingleInstanceManager::ForwardResult SingleInstanceManager::forwardToPrimary(const QStringList &filesToSend)
{ {
QLocalSocket socket; QLocalSocket socket;
socket.connectToServer(serverName); socket.connectToServer(serverName);
if (!socket.waitForConnected(200)) { if (!socket.waitForConnected(200)) {
return false; return ForwardResult::NoPrimary;
} }
// Serialize payload with length prefix // Serialize payload with length prefix
@ -72,7 +92,23 @@ bool SingleInstanceManager::forwardToPrimary(const QStringList &filesToSend)
socket.flush(); socket.flush();
socket.waitForBytesWritten(1000); 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() void SingleInstanceManager::handleNewConnection()
@ -111,12 +147,23 @@ void SingleInstanceManager::handleNewConnection()
QStringList files; QStringList files;
payloadStream >> 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(); buffer->clear();
*expectedSize = 0; *expectedSize = 0;
emit filesReceived(files);
socket->disconnectFromServer(); socket->disconnectFromServer();
return; return;
} }

View file

@ -23,7 +23,14 @@ private slots:
void handleNewConnection(); void handleNewConnection();
private: 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; QString serverName;
QLocalServer *server = nullptr; QLocalServer *server = nullptr;

View file

@ -15,9 +15,17 @@ set(PROTO_FILES
command_deck_del.proto command_deck_del.proto
command_deck_del_dir.proto command_deck_del_dir.proto
command_deck_download.proto command_deck_download.proto
command_deck_download_public.proto
command_deck_list.proto command_deck_list.proto
command_deck_list_other_user.proto
command_deck_new_dir.proto command_deck_new_dir.proto
command_deck_select.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_deck_upload.proto
command_del_counter.proto command_del_counter.proto
command_delete_arrow.proto command_delete_arrow.proto
@ -137,6 +145,10 @@ set(PROTO_FILES
response_card_art_rule_entry.proto response_card_art_rule_entry.proto
response_deck_download.proto response_deck_download.proto
response_deck_list.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_deck_upload.proto
response_dump_zone.proto response_dump_zone.proto
response_forgotpasswordrequest.proto response_forgotpasswordrequest.proto
@ -175,6 +187,8 @@ set(PROTO_FILES
serverinfo_cardcounter.proto serverinfo_cardcounter.proto
serverinfo_chat_message.proto serverinfo_chat_message.proto
serverinfo_counter.proto serverinfo_counter.proto
serverinfo_deck_share_item.proto
serverinfo_deck_share_summary.proto
serverinfo_deckstorage.proto serverinfo_deckstorage.proto
serverinfo_game.proto serverinfo_game.proto
serverinfo_gametype.proto serverinfo_gametype.proto

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -0,0 +1,9 @@
syntax = "proto2";
import "session_commands.proto";
message Command_DeckShareList {
extend SessionCommand {
optional Command_DeckShareList ext = 1027;
}
optional string token = 1;
}

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -8,4 +8,10 @@ message Command_DeckUpload {
optional string path = 1; // to upload a new deck optional string path = 1; // to upload a new deck
optional uint32 deck_id = 2; // to replace an existing deck optional uint32 deck_id = 2; // to replace an existing deck
optional string deck_list = 3; 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;
} }

View file

@ -81,6 +81,10 @@ message Response {
REPLAY_LIST = 1100; // Response listing replays REPLAY_LIST = 1100; // Response listing replays
REPLAY_DOWNLOAD = 1101; // Response for replay download REPLAY_DOWNLOAD = 1101; // Response for replay download
REPLAY_GET_CODE = 1102; // Response containing replay code 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 CARD_ART_RULE_LIST = 1200; // Response containing a list of card art rules
} }

View file

@ -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;
}

View file

@ -0,0 +1,9 @@
syntax = "proto2";
import "response.proto";
message Response_DeckShareDownload {
extend Response {
optional Response_DeckShareDownload ext = 1105;
}
optional string deck = 1;
}

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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;
}

Some files were not shown because too many files have changed in this diff Show more