mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-09-21 09:05:10 -07:00
Compare commits
26 commits
47fea21f88
...
97c0de876d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97c0de876d | ||
|
|
de47ddfcae | ||
|
|
1b7f9c0de4 | ||
|
|
6d06ae2bcf | ||
|
|
6823d54c1e | ||
|
|
8ca749c07d | ||
|
|
ba2900dcb9 | ||
|
|
a289d61765 | ||
|
|
073ec29c4d | ||
|
|
a5e94d8a4f | ||
|
|
b3c426cd43 | ||
|
|
c97e1c4149 | ||
|
|
ec41c103d1 | ||
|
|
d9cd2d1750 | ||
|
|
1405952f1b | ||
|
|
db2e159dca | ||
|
|
cb19922e55 | ||
|
|
ec39ec611b | ||
|
|
662f1b79cc | ||
|
|
eb1e34c5a6 | ||
|
|
dce9efcaa3 | ||
|
|
c45cb8ac32 | ||
|
|
1a6d9d7749 | ||
|
|
faffb5a837 | ||
|
|
9acb9739b2 | ||
|
|
59dd052143 |
136 changed files with 6289 additions and 494 deletions
|
|
@ -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 \
|
||||||
|
|
|
||||||
|
|
@ -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 \
|
||||||
|
|
|
||||||
|
|
@ -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 \
|
||||||
|
|
|
||||||
|
|
@ -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 \
|
||||||
|
|
|
||||||
|
|
@ -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 \
|
||||||
|
|
|
||||||
|
|
@ -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 \
|
||||||
|
|
|
||||||
|
|
@ -327,4 +327,32 @@ if [[ $MAKE_PACKAGE ]]; 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
|
||||||
|
|
|
||||||
43
.github/workflows/desktop-build.yml
vendored
43
.github/workflows/desktop-build.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
2
.github/workflows/docker-release.yml
vendored
2
.github/workflows/docker-release.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -293,7 +293,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 making package conflicts
|
# stop directories from making package conflicts
|
||||||
|
|
@ -311,7 +311,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()
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,13 @@ ${EndIf}
|
||||||
; Now that $PortableMode reflects reality, commit InstDir into the correct slot
|
; Now that $PortableMode reflects reality, commit InstDir into the correct slot
|
||||||
Call SetModeDestinationFromInstdir
|
Call SetModeDestinationFromInstdir
|
||||||
|
|
||||||
|
; Make sure no application instance is still running (and holding file locks)
|
||||||
|
; before the previous version is uninstalled or new files are installed.
|
||||||
|
; On a silent update (/R /S) running processes are asked to close gracefully
|
||||||
|
; and waited for, then force-closed only on timeout. On interactive installs
|
||||||
|
; the user is prompted to close them instead.
|
||||||
|
Call EnsureAppsNotRunning
|
||||||
|
|
||||||
${If} $ReinstallMode = 1
|
${If} $ReinstallMode = 1
|
||||||
${AndIf} $PortableMode = 0
|
${AndIf} $PortableMode = 0
|
||||||
Call AutoUninstallIfNeeded
|
Call AutoUninstallIfNeeded
|
||||||
|
|
@ -144,6 +151,9 @@ ${If} ${NSIS_IS_64_BIT} == 1
|
||||||
SetRegView 64
|
SetRegView 64
|
||||||
${EndIf}
|
${EndIf}
|
||||||
|
|
||||||
|
; Ensure no application instance is still running before removing files.
|
||||||
|
Call un.EnsureAppsNotRunning
|
||||||
|
|
||||||
FunctionEnd
|
FunctionEnd
|
||||||
|
|
||||||
Function RequireAdmin
|
Function RequireAdmin
|
||||||
|
|
@ -199,6 +209,170 @@ ${EndIf}
|
||||||
|
|
||||||
FunctionEnd
|
FunctionEnd
|
||||||
|
|
||||||
|
; --- Running instance handling ---
|
||||||
|
; Cockatrice, Oracle and Servatrice must not be running while files are
|
||||||
|
; replaced or deleted. A still-running process holds locks on its .exe and
|
||||||
|
; Qt runtime DLLs, so a silent upgrade could otherwise end up with a mix of
|
||||||
|
; old and new Qt DLLs next to the new executable, failing with
|
||||||
|
; "The procedure entry point X could not be located in the dynamic link
|
||||||
|
; library ...Qt6Network.dll" on the next start.
|
||||||
|
;
|
||||||
|
; Processes are matched by image name AND by their executable path living
|
||||||
|
; under $INSTDIR, so unrelated processes that merely share an image name
|
||||||
|
; (e.g. the Oracle DB instance "oracle.exe") are never touched.
|
||||||
|
|
||||||
|
; usage: set $R2 to the base image name (without extension, e.g. "cockatrice",
|
||||||
|
; as accepted by Get-Process -Name), call this, result in $R0
|
||||||
|
; (1 = running from $INSTDIR, 0 = not running)
|
||||||
|
Function IsAppRunning
|
||||||
|
nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { $$_.Path -like $\'$INSTDIR\*$\' } | Select-Object -First 1"'
|
||||||
|
Pop $R0
|
||||||
|
${If} $R0 = 0
|
||||||
|
StrCpy $R0 1
|
||||||
|
${Else}
|
||||||
|
StrCpy $R0 0
|
||||||
|
${EndIf}
|
||||||
|
FunctionEnd
|
||||||
|
|
||||||
|
Function CloseMatchingApps
|
||||||
|
; gracefully ask every matching instance to close (sends WM_CLOSE)
|
||||||
|
nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { $$_.Path -like $\'$INSTDIR\*$\' } | ForEach-Object { $$null = $$_.CloseMainWindow() }"'
|
||||||
|
Pop $R3
|
||||||
|
FunctionEnd
|
||||||
|
|
||||||
|
Function ForceCloseMatchingApps
|
||||||
|
nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { $$_.Path -like $\'$INSTDIR\*$\' } | Stop-Process -Force -ErrorAction SilentlyContinue"'
|
||||||
|
Pop $R3
|
||||||
|
FunctionEnd
|
||||||
|
|
||||||
|
Function WaitForAppToClose
|
||||||
|
; usage: set $R1 to the display name (e.g. "cockatrice.exe") and $R2 to the
|
||||||
|
; base image name (e.g. "cockatrice")
|
||||||
|
Call IsAppRunning
|
||||||
|
${If} $R0 = 0
|
||||||
|
Return
|
||||||
|
${EndIf}
|
||||||
|
|
||||||
|
${If} ${Silent}
|
||||||
|
; ask the application to close gracefully (WM_CLOSE), then wait for it to exit
|
||||||
|
DetailPrint "Closing $R1 ..."
|
||||||
|
Call CloseMatchingApps
|
||||||
|
StrCpy $R8 0
|
||||||
|
ck_wait_loop:
|
||||||
|
Sleep 500
|
||||||
|
IntOp $R8 $R8 + 1
|
||||||
|
Call IsAppRunning
|
||||||
|
${If} $R0 = 0
|
||||||
|
DetailPrint "$R1 closed."
|
||||||
|
Return
|
||||||
|
${EndIf}
|
||||||
|
${If} $R8 < 60
|
||||||
|
Goto ck_wait_loop
|
||||||
|
${EndIf}
|
||||||
|
; give up waiting, force close
|
||||||
|
DetailPrint "Force closing $R1 ..."
|
||||||
|
Call ForceCloseMatchingApps
|
||||||
|
Sleep 500
|
||||||
|
${Else}
|
||||||
|
ck_wait_prompt:
|
||||||
|
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \
|
||||||
|
"$R1 is still running.$\r$\n$\r$\nPlease close it, then click Retry.$\r$\nClick Cancel to abort." \
|
||||||
|
IDCANCEL ck_abort_install
|
||||||
|
Call IsAppRunning
|
||||||
|
${If} $R0 = 0
|
||||||
|
Return
|
||||||
|
${EndIf}
|
||||||
|
Goto ck_wait_prompt
|
||||||
|
ck_abort_install:
|
||||||
|
Abort
|
||||||
|
${EndIf}
|
||||||
|
FunctionEnd
|
||||||
|
|
||||||
|
Function EnsureAppsNotRunning
|
||||||
|
StrCpy $R1 "cockatrice.exe"
|
||||||
|
StrCpy $R2 "cockatrice"
|
||||||
|
Call WaitForAppToClose
|
||||||
|
StrCpy $R1 "oracle.exe"
|
||||||
|
StrCpy $R2 "oracle"
|
||||||
|
Call WaitForAppToClose
|
||||||
|
StrCpy $R1 "servatrice.exe"
|
||||||
|
StrCpy $R2 "servatrice"
|
||||||
|
Call WaitForAppToClose
|
||||||
|
FunctionEnd
|
||||||
|
|
||||||
|
; Uninstaller copies of the same routines (the uninstaller gets its own
|
||||||
|
; function set compiled in, it cannot call the installer functions).
|
||||||
|
Function un.IsAppRunning
|
||||||
|
nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { $$_.Path -like $\'$INSTDIR\*$\' } | Select-Object -First 1"'
|
||||||
|
Pop $R0
|
||||||
|
${If} $R0 = 0
|
||||||
|
StrCpy $R0 1
|
||||||
|
${Else}
|
||||||
|
StrCpy $R0 0
|
||||||
|
${EndIf}
|
||||||
|
FunctionEnd
|
||||||
|
|
||||||
|
Function un.CloseMatchingApps
|
||||||
|
nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { $$_.Path -like $\'$INSTDIR\*$\' } | ForEach-Object { $$null = $$_.CloseMainWindow() }"'
|
||||||
|
Pop $R3
|
||||||
|
FunctionEnd
|
||||||
|
|
||||||
|
Function un.ForceCloseMatchingApps
|
||||||
|
nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { $$_.Path -like $\'$INSTDIR\*$\' } | Stop-Process -Force -ErrorAction SilentlyContinue"'
|
||||||
|
Pop $R3
|
||||||
|
FunctionEnd
|
||||||
|
|
||||||
|
Function un.WaitForAppToClose
|
||||||
|
Call un.IsAppRunning
|
||||||
|
${If} $R0 = 0
|
||||||
|
Return
|
||||||
|
${EndIf}
|
||||||
|
|
||||||
|
${If} ${Silent}
|
||||||
|
DetailPrint "Closing $R1 ..."
|
||||||
|
Call un.CloseMatchingApps
|
||||||
|
StrCpy $R8 0
|
||||||
|
un_ck_wait_loop:
|
||||||
|
Sleep 500
|
||||||
|
IntOp $R8 $R8 + 1
|
||||||
|
Call un.IsAppRunning
|
||||||
|
${If} $R0 = 0
|
||||||
|
DetailPrint "$R1 closed."
|
||||||
|
Return
|
||||||
|
${EndIf}
|
||||||
|
${If} $R8 < 60
|
||||||
|
Goto un_ck_wait_loop
|
||||||
|
${EndIf}
|
||||||
|
DetailPrint "Force closing $R1 ..."
|
||||||
|
Call un.ForceCloseMatchingApps
|
||||||
|
Sleep 500
|
||||||
|
${Else}
|
||||||
|
un_ck_wait_prompt:
|
||||||
|
MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \
|
||||||
|
"$R1 is still running.$\r$\n$\r$\nPlease close it, then click Retry.$\r$\nClick Cancel to abort." \
|
||||||
|
IDCANCEL un_ck_abort_install
|
||||||
|
Call un.IsAppRunning
|
||||||
|
${If} $R0 = 0
|
||||||
|
Return
|
||||||
|
${EndIf}
|
||||||
|
Goto un_ck_wait_prompt
|
||||||
|
un_ck_abort_install:
|
||||||
|
Abort
|
||||||
|
${EndIf}
|
||||||
|
FunctionEnd
|
||||||
|
|
||||||
|
Function un.EnsureAppsNotRunning
|
||||||
|
StrCpy $R1 "cockatrice.exe"
|
||||||
|
StrCpy $R2 "cockatrice"
|
||||||
|
Call un.WaitForAppToClose
|
||||||
|
StrCpy $R1 "oracle.exe"
|
||||||
|
StrCpy $R2 "oracle"
|
||||||
|
Call un.WaitForAppToClose
|
||||||
|
StrCpy $R1 "servatrice.exe"
|
||||||
|
StrCpy $R2 "servatrice"
|
||||||
|
Call un.WaitForAppToClose
|
||||||
|
FunctionEnd
|
||||||
|
|
||||||
Function PortableModePageCreate
|
Function PortableModePageCreate
|
||||||
|
|
||||||
${If} $ReinstallMode = 1
|
${If} $ReinstallMode = 1
|
||||||
|
|
@ -318,6 +492,26 @@ ${AndIf} ${FileExists} "$INSTDIR\portable.dat"
|
||||||
RMDir "$INSTDIR"
|
RMDir "$INSTDIR"
|
||||||
${EndIf}
|
${EndIf}
|
||||||
|
|
||||||
|
; Belt and braces: the old uninstaller may have already run in the /R path, so
|
||||||
|
; ensure no application instance is still holding file locks, then remove any
|
||||||
|
; runtime DLLs left over from older versions. A mismatched Qt/OpenSSL set next
|
||||||
|
; to the new executable is what causes "The procedure entry point X could not be
|
||||||
|
; located in the dynamic link library ...Qt6Network.dll" after an update.
|
||||||
|
Call EnsureAppsNotRunning
|
||||||
|
|
||||||
|
${If} $PortableMode = 0
|
||||||
|
${AndIf} ${FileExists} "$INSTDIR\cockatrice.exe"
|
||||||
|
RMDir /r "$INSTDIR\Plugins"
|
||||||
|
Delete "$INSTDIR\Qt*.dll"
|
||||||
|
Delete "$INSTDIR\libcrypto*.dll"
|
||||||
|
Delete "$INSTDIR\libssl*.dll"
|
||||||
|
Delete "$INSTDIR\zlib*.dll"
|
||||||
|
Delete "$INSTDIR\libmysql.dll"
|
||||||
|
Delete "$INSTDIR\icu*.dll"
|
||||||
|
Delete "$INSTDIR\libeay32.dll"
|
||||||
|
Delete "$INSTDIR\ssleay32.dll"
|
||||||
|
${EndIf}
|
||||||
|
|
||||||
@CPACK_NSIS_EXTRA_PREINSTALL_COMMANDS@
|
@CPACK_NSIS_EXTRA_PREINSTALL_COMMANDS@
|
||||||
@CPACK_NSIS_FULL_INSTALL@
|
@CPACK_NSIS_FULL_INSTALL@
|
||||||
|
|
||||||
|
|
@ -387,19 +581,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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
208
cockatrice/src/interface/intents/intent_open_shared_deck.cpp
Normal file
208
cockatrice/src/interface/intents/intent_open_shared_deck.cpp
Normal 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();
|
||||||
|
}
|
||||||
60
cockatrice/src/interface/intents/intent_open_shared_deck.h
Normal file
60
cockatrice/src/interface/intents/intent_open_shared_deck.h
Normal 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
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -520,8 +520,11 @@ void DeckEditorDeckDockWidget::syncBannerCardComboBoxSelectionWithDeck()
|
||||||
|
|
||||||
void DeckEditorDeckDockWidget::setSelectedIndex(const QModelIndex &newCardIndex, bool preserveWidgetFocus)
|
void DeckEditorDeckDockWidget::setSelectedIndex(const QModelIndex &newCardIndex, bool preserveWidgetFocus)
|
||||||
{
|
{
|
||||||
|
const QModelIndex proxyIndex = proxy->mapFromSource(newCardIndex);
|
||||||
|
|
||||||
deckView->clearSelection();
|
deckView->clearSelection();
|
||||||
deckView->setCurrentIndex(newCardIndex);
|
deckView->setCurrentIndex(proxyIndex);
|
||||||
|
deckView->scrollTo(proxyIndex);
|
||||||
recursiveExpand(newCardIndex);
|
recursiveExpand(newCardIndex);
|
||||||
|
|
||||||
if (!preserveWidgetFocus) {
|
if (!preserveWidgetFocus) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
40
cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.h
Normal file
40
cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.h
Normal 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
|
||||||
96
cockatrice/src/interface/widgets/dialogs/dlg_share_deck.cpp
Normal file
96
cockatrice/src/interface/widgets/dialogs/dlg_share_deck.cpp
Normal 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."));
|
||||||
|
}
|
||||||
46
cockatrice/src/interface/widgets/dialogs/dlg_share_deck.h
Normal file
46
cockatrice/src/interface/widgets/dialogs/dlg_share_deck.h
Normal 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
|
||||||
|
|
@ -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 ¤tDeckName)
|
||||||
|
{
|
||||||
|
if (!downloadInProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
downloadStatusLabel->setText(tr("Downloading deck %1 of %2: %3").arg(done).arg(total).arg(currentDeckName));
|
||||||
|
}
|
||||||
|
|
||||||
|
void DlgSharedDecksPreview::updateOpenSelectedEnabled()
|
||||||
|
{
|
||||||
|
openSelectedButton->setEnabled(!selectedItemIds().isEmpty());
|
||||||
|
}
|
||||||
|
|
||||||
|
void DlgSharedDecksPreview::onCancel()
|
||||||
|
{
|
||||||
|
if (!resultEmitted || downloadInProgress) {
|
||||||
|
resultEmitted = true;
|
||||||
|
emit cancelled();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void DlgSharedDecksPreview::closeEvent(QCloseEvent *event)
|
||||||
|
{
|
||||||
|
onCancel();
|
||||||
|
QDialog::closeEvent(event);
|
||||||
|
}
|
||||||
|
|
@ -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 ¤tDeckName);
|
||||||
|
|
||||||
|
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
|
||||||
|
|
@ -5,11 +5,13 @@
|
||||||
#include "../client/network/update/client/release_channel.h"
|
#include "../client/network/update/client/release_channel.h"
|
||||||
#include "../interface/window_main.h"
|
#include "../interface/window_main.h"
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
#include <QDesktopServices>
|
#include <QDesktopServices>
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
#include <QProgressBar>
|
#include <QProgressBar>
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
|
#include <QTimer>
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
#include <QtNetwork>
|
#include <QtNetwork>
|
||||||
#include <version_string.h>
|
#include <version_string.h>
|
||||||
|
|
@ -240,8 +242,23 @@ void DlgUpdate::downloadSuccessful(const QUrl &filepath)
|
||||||
|
|
||||||
// Try to open the installer. If it opens, quit Cockatrice
|
// Try to open the installer. If it opens, quit Cockatrice
|
||||||
if (process.startDetached()) {
|
if (process.startDetached()) {
|
||||||
QMetaObject::invokeMethod(static_cast<MainWindow *>(parent()), "close", Qt::QueuedConnection);
|
|
||||||
qCInfo(DlgUpdateLog) << "Opened downloaded update file successfully - closing Cockatrice";
|
qCInfo(DlgUpdateLog) << "Opened downloaded update file successfully - closing Cockatrice";
|
||||||
|
// Close the main window synchronously so file locks are released before the NSIS installer
|
||||||
|
// (already launched) starts replacing files. This also flushes settings and shuts down the
|
||||||
|
// tabs, but only when the close is actually accepted: MainWindow may veto it for a running
|
||||||
|
// card DB update, an open game, or an unsaved deck. In that case keep running so the user
|
||||||
|
// can resolve the blocker, and tell them the installer is already waiting.
|
||||||
|
if (auto *window = qobject_cast<MainWindow *>(parent())) {
|
||||||
|
if (window->close()) {
|
||||||
|
QTimer::singleShot(0, qApp, &QCoreApplication::quit);
|
||||||
|
} else {
|
||||||
|
QMessageBox::warning(this, tr("Update"),
|
||||||
|
tr("The update installer is already running and will finish the update once "
|
||||||
|
"Cockatrice closes. Cockatrice is still busy, so it stays open for now."));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
QTimer::singleShot(0, qApp, &QCoreApplication::quit);
|
||||||
|
}
|
||||||
close();
|
close();
|
||||||
} else {
|
} else {
|
||||||
setLabel(tr("Error"));
|
setLabel(tr("Error"));
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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..."));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -369,7 +369,7 @@ void GameSelector::joinGame(const ServerInfo_Game &game, const bool asSpectator,
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool overrideRestrictions = !tabSupervisor->getAdminLocked();
|
bool overrideRestrictions = tabSupervisor->canOverrideGameRestrictions();
|
||||||
|
|
||||||
// Joining a full game without override privileges silently becomes a
|
// Joining a full game without override privileges silently becomes a
|
||||||
// spectator join, so ask first instead of surprising the player.
|
// spectator join, so ask first instead of surprising the player.
|
||||||
|
|
@ -462,7 +462,7 @@ void GameSelector::enableButtonsForIndex(const QModelIndex ¤t)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ServerInfo_Game &game = gameListModel->getGame(current.data(Qt::UserRole).toInt());
|
const ServerInfo_Game &game = gameListModel->getGame(current.data(Qt::UserRole).toInt());
|
||||||
bool overrideRestrictions = !tabSupervisor->getAdminLocked();
|
bool overrideRestrictions = tabSupervisor->canOverrideGameRestrictions();
|
||||||
|
|
||||||
spectateButton->setEnabled(game.spectators_allowed() || overrideRestrictions);
|
spectateButton->setEnabled(game.spectators_allowed() || overrideRestrictions);
|
||||||
joinButton->setEnabled(game.player_count() < game.max_players() || overrideRestrictions);
|
joinButton->setEnabled(game.player_count() < game.max_players() || overrideRestrictions);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
251
cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp
Normal file
251
cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp
Normal 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());
|
||||||
|
}
|
||||||
80
cockatrice/src/interface/widgets/tabs/tab_public_decks.h
Normal file
80
cockatrice/src/interface/widgets/tabs/tab_public_decks.h
Normal 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
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
#include <libcockatrice/protocol/pb/event_list_rooms.pb.h>
|
#include <libcockatrice/protocol/pb/event_list_rooms.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/event_server_message.pb.h>
|
#include <libcockatrice/protocol/pb/event_server_message.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/response_join_room.pb.h>
|
#include <libcockatrice/protocol/pb/response_join_room.pb.h>
|
||||||
|
#include <libcockatrice/protocol/pb/room_commands.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/session_commands.pb.h>
|
#include <libcockatrice/protocol/pb/session_commands.pb.h>
|
||||||
#include <libcockatrice/protocol/pending_command.h>
|
#include <libcockatrice/protocol/pending_command.h>
|
||||||
|
|
||||||
|
|
@ -185,25 +186,37 @@ void TabServer::processServerMessageEvent(const Event_ServerMessage &event)
|
||||||
void TabServer::joinRoom(int id, bool setCurrent)
|
void TabServer::joinRoom(int id, bool setCurrent)
|
||||||
{
|
{
|
||||||
TabRoom *room = tabSupervisor->getRoomTabs().value(id);
|
TabRoom *room = tabSupervisor->getRoomTabs().value(id);
|
||||||
if (!room) {
|
if (room) {
|
||||||
Command_JoinRoom cmd;
|
if (setCurrent) {
|
||||||
cmd.set_room_id(id);
|
tabSupervisor->setCurrentWidget((QWidget *)room);
|
||||||
|
}
|
||||||
PendingCommand *pend = client->prepareSessionCommand(cmd);
|
|
||||||
pend->setExtraData(setCurrent);
|
|
||||||
connect(pend, &PendingCommand::finished, this,
|
|
||||||
[this, id](const Response &r, const CommandContainer &c, const QVariant &v) {
|
|
||||||
joinRoomFinished(r, c, v, id);
|
|
||||||
});
|
|
||||||
|
|
||||||
client->sendCommand(pend);
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (setCurrent) {
|
auto pendingIt = pendingRoomJoins.find(id);
|
||||||
tabSupervisor->setCurrentWidget((QWidget *)room);
|
if (pendingIt != pendingRoomJoins.end()) {
|
||||||
|
// A join for this room is already in flight: the room tab opens when its response
|
||||||
|
// arrives. Fold the new request into the pending one so that, for example, clicking
|
||||||
|
// a room the selector is auto-joining does not send a second Command_JoinRoom - the
|
||||||
|
// server would reject that duplicate with RespContextError.
|
||||||
|
if (setCurrent) {
|
||||||
|
pendingIt.value() = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pendingRoomJoins.insert(id, setCurrent);
|
||||||
|
|
||||||
|
Command_JoinRoom cmd;
|
||||||
|
cmd.set_room_id(id);
|
||||||
|
|
||||||
|
PendingCommand *pend = client->prepareSessionCommand(cmd);
|
||||||
|
pend->setExtraData(setCurrent);
|
||||||
|
connect(
|
||||||
|
pend, &PendingCommand::finished, this,
|
||||||
|
[this, id](const Response &r, const CommandContainer &c, const QVariant &v) { joinRoomFinished(r, c, v, id); });
|
||||||
|
|
||||||
|
client->sendCommand(pend);
|
||||||
}
|
}
|
||||||
|
|
||||||
void TabServer::joinRoomFinished(const Response &r,
|
void TabServer::joinRoomFinished(const Response &r,
|
||||||
|
|
@ -211,34 +224,72 @@ void TabServer::joinRoomFinished(const Response &r,
|
||||||
const QVariant &extraData,
|
const QVariant &extraData,
|
||||||
int roomId)
|
int roomId)
|
||||||
{
|
{
|
||||||
|
const bool setCurrent = pendingRoomJoins.value(roomId, extraData.toBool());
|
||||||
|
pendingRoomJoins.remove(roomId);
|
||||||
|
const bool healedJoin = healedRoomJoins.contains(roomId);
|
||||||
|
healedRoomJoins.remove(roomId);
|
||||||
|
|
||||||
switch (r.response_code()) {
|
switch (r.response_code()) {
|
||||||
case Response::RespOk:
|
case Response::RespOk:
|
||||||
break;
|
break;
|
||||||
case Response::RespNameNotFound:
|
case Response::RespNameNotFound:
|
||||||
QMessageBox::critical(this, tr("Error"),
|
if (setCurrent) {
|
||||||
tr("Failed to join the server room: it doesn't exist on the server."));
|
QMessageBox::critical(this, tr("Error"),
|
||||||
|
tr("Failed to join the server room: it doesn't exist on the server."));
|
||||||
|
}
|
||||||
emit roomJoinFailed(roomId);
|
emit roomJoinFailed(roomId);
|
||||||
return;
|
return;
|
||||||
case Response::RespContextError:
|
case Response::RespContextError:
|
||||||
QMessageBox::critical(
|
if (healedJoin) {
|
||||||
this, tr("Error"),
|
// The rejoin below was already answered and the server still rejects the join, so
|
||||||
tr("The server thinks you are in the server room but your client is unable to display it. "
|
// the stale-membership heal cannot help: surface the error. The guard was already
|
||||||
"Try restarting your client."));
|
// released above so a later user-initiated join may try a fresh heal.
|
||||||
emit roomJoinFailed(roomId);
|
if (setCurrent) {
|
||||||
|
QMessageBox::critical(
|
||||||
|
this, tr("Error"),
|
||||||
|
tr("The server thinks you are in the server room but your client is unable to display it. "
|
||||||
|
"Try restarting your client."));
|
||||||
|
}
|
||||||
|
emit roomJoinFailed(roomId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The server already had us registered in the room even though no tab was open,
|
||||||
|
// usually because two join attempts for the same room overlapped. Leaving and
|
||||||
|
// rejoining makes the server reply with a fresh RespOk so the tab is displayed
|
||||||
|
// without requiring a client restart. The guard above covers exactly the rejoin that
|
||||||
|
// leaveAndRejoinRoom triggers, so a server that keeps replying with RespContextError
|
||||||
|
// gets one heal attempt per join instead of an endless recursion.
|
||||||
|
healedRoomJoins.insert(roomId);
|
||||||
|
leaveAndRejoinRoom(roomId, setCurrent);
|
||||||
return;
|
return;
|
||||||
case Response::RespUserLevelTooLow:
|
case Response::RespUserLevelTooLow:
|
||||||
QMessageBox::critical(this, tr("Error"),
|
if (setCurrent) {
|
||||||
tr("You do not have the required permission to join this server room."));
|
QMessageBox::critical(this, tr("Error"),
|
||||||
|
tr("You do not have the required permission to join this server room."));
|
||||||
|
}
|
||||||
emit roomJoinFailed(roomId);
|
emit roomJoinFailed(roomId);
|
||||||
return;
|
return;
|
||||||
default:
|
default:
|
||||||
QMessageBox::critical(
|
if (setCurrent) {
|
||||||
this, tr("Error"),
|
QMessageBox::critical(
|
||||||
tr("Failed to join the server room due to an unknown error: %1.").arg(r.response_code()));
|
this, tr("Error"),
|
||||||
|
tr("Failed to join the server room due to an unknown error: %1.").arg(r.response_code()));
|
||||||
|
}
|
||||||
emit roomJoinFailed(roomId);
|
emit roomJoinFailed(roomId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Response_JoinRoom &resp = r.GetExtension(Response_JoinRoom::ext);
|
const Response_JoinRoom &resp = r.GetExtension(Response_JoinRoom::ext);
|
||||||
emit roomJoined(resp.room_info(), extraData.toBool());
|
emit roomJoined(resp.room_info(), setCurrent);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TabServer::leaveAndRejoinRoom(int roomId, bool setCurrent)
|
||||||
|
{
|
||||||
|
// Clear the stale room membership server-side. The leave is sent before the rejoin below,
|
||||||
|
// so the server no longer considers us a member by the time the join arrives. The leave
|
||||||
|
// response is intentionally not awaited: commands are processed in send order on the
|
||||||
|
// connection, and a failed leave (RespNotInRoom) only means the membership was already gone.
|
||||||
|
client->sendCommand(client->prepareRoomCommand(Command_LeaveRoom(), roomId));
|
||||||
|
|
||||||
|
joinRoom(roomId, setCurrent);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@
|
||||||
#include "tab.h"
|
#include "tab.h"
|
||||||
|
|
||||||
#include <QGroupBox>
|
#include <QGroupBox>
|
||||||
|
#include <QHash>
|
||||||
|
#include <QSet>
|
||||||
#include <QTextBrowser>
|
#include <QTextBrowser>
|
||||||
#include <QTreeWidget>
|
#include <QTreeWidget>
|
||||||
|
|
||||||
|
|
@ -58,10 +60,17 @@ private slots:
|
||||||
int roomId);
|
int roomId);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
void leaveAndRejoinRoom(int roomId, bool setCurrent);
|
||||||
|
|
||||||
AbstractClient *client;
|
AbstractClient *client;
|
||||||
RoomSelector *roomSelector;
|
RoomSelector *roomSelector;
|
||||||
QTextBrowser *serverInfoBox;
|
QTextBrowser *serverInfoBox;
|
||||||
bool shouldEmitUpdate = false;
|
bool shouldEmitUpdate = false;
|
||||||
|
/** Room ids with a join command in flight, mapped to whether the tab should be focused once it opens. */
|
||||||
|
QHash<int, bool> pendingRoomJoins;
|
||||||
|
/** Room ids for which a stale-membership heal (leave + rejoin) is currently in flight. Released as soon as the
|
||||||
|
* rejoin has been answered, so a heal is attempted at most once per join. */
|
||||||
|
QSet<int> healedRoomJoins;
|
||||||
|
|
||||||
public:
|
public:
|
||||||
TabServer(TabSupervisor *_tabSupervisor, AbstractClient *_client);
|
TabServer(TabSupervisor *_tabSupervisor, AbstractClient *_client);
|
||||||
|
|
|
||||||
|
|
@ -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()) {
|
||||||
|
|
@ -1451,6 +1484,11 @@ bool TabSupervisor::getAdminLocked() const
|
||||||
return tabAdmin->getLocked();
|
return tabAdmin->getLocked();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool TabSupervisor::canOverrideGameRestrictions() const
|
||||||
|
{
|
||||||
|
return !getAdminLocked() || (userInfo->user_level() & ServerInfo_User::IsJudge);
|
||||||
|
}
|
||||||
|
|
||||||
void TabSupervisor::processNotifyUserEvent(const Event_NotifyUser &event)
|
void TabSupervisor::processNotifyUserEvent(const Event_NotifyUser &event)
|
||||||
{
|
{
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -171,6 +177,7 @@ public:
|
||||||
[[nodiscard]] QList<GameInviteOption> getGameInviteLinksForRoom(int roomId) const;
|
[[nodiscard]] QList<GameInviteOption> getGameInviteLinksForRoom(int roomId) const;
|
||||||
void sendInviteToUser(const QString &userName, const QString &inviteText);
|
void sendInviteToUser(const QString &userName, const QString &inviteText);
|
||||||
[[nodiscard]] bool getAdminLocked() const;
|
[[nodiscard]] bool getAdminLocked() const;
|
||||||
|
[[nodiscard]] bool canOverrideGameRestrictions() const;
|
||||||
void closeEvent(QCloseEvent *event) override;
|
void closeEvent(QCloseEvent *event) override;
|
||||||
bool switchToGameTabIfAlreadyExists(const int gameId);
|
bool switchToGameTabIfAlreadyExists(const int gameId);
|
||||||
static void actShowPopup(const QString &message);
|
static void actShowPopup(const QString &message);
|
||||||
|
|
@ -198,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();
|
||||||
|
|
||||||
|
|
@ -230,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);
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ public slots:
|
||||||
*/
|
*/
|
||||||
void scheduleReconcile();
|
void scheduleReconcile();
|
||||||
void updateShowFolders(bool enabled);
|
void updateShowFolders(bool enabled);
|
||||||
|
void setShareSelectable(bool selectable);
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,48 @@ static double parseClampedParam(const QString &valueString, double fallback, dou
|
||||||
return qBound(min, value, max);
|
return qBound(min, value, max);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Reads a `bannerCard` element from the XML stream.
|
||||||
|
*
|
||||||
|
* @param xml Reader positioned at the element.
|
||||||
|
* @return The referenced card.
|
||||||
|
*/
|
||||||
|
static CardRef readBannerCard(QXmlStreamReader *xml)
|
||||||
|
{
|
||||||
|
QString providerId = xml->attributes().value("providerId").toString();
|
||||||
|
QString cardName = xml->readElementText();
|
||||||
|
return {cardName, providerId};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Reads a `playmatCard` element from the XML stream.
|
||||||
|
*
|
||||||
|
* Attribute values are read before readElementText consumes the element, and
|
||||||
|
* the params are clamped to the same ranges as the settings dialog and the
|
||||||
|
* remote player-properties path so malformed deck files cannot produce
|
||||||
|
* degenerate art rectangles (e.g. a zoom of 0 dividing by zero).
|
||||||
|
*
|
||||||
|
* @param xml Reader positioned at the element.
|
||||||
|
* @return The referenced card plus its clamped positioning parameters.
|
||||||
|
*/
|
||||||
|
static PlaymatInfo readPlaymatCard(QXmlStreamReader *xml)
|
||||||
|
{
|
||||||
|
QString providerId = xml->attributes().value("providerId").toString();
|
||||||
|
QString marginLStr = xml->attributes().value("marginPctL").toString();
|
||||||
|
QString marginRStr = xml->attributes().value("marginPctR").toString();
|
||||||
|
QString vOffStr = xml->attributes().value("verticalOffset").toString();
|
||||||
|
QString zoomStr = xml->attributes().value("zoom").toString();
|
||||||
|
QString cardName = xml->readElementText();
|
||||||
|
|
||||||
|
return {
|
||||||
|
.card = {cardName, providerId},
|
||||||
|
.params = {.marginPctL = parseClampedParam(marginLStr, 0.07, 0.0, 0.95),
|
||||||
|
.marginPctR = parseClampedParam(marginRStr, 0.07, 0.0, 0.95),
|
||||||
|
.verticalOffset = parseClampedParam(vOffStr, 0.33, 0.0, 1.0),
|
||||||
|
.zoom = parseClampedParam(zoomStr, 1.0, 0.1, 4.0)},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
bool DeckList::Metadata::isEmpty() const
|
bool DeckList::Metadata::isEmpty() const
|
||||||
{
|
{
|
||||||
return name.isEmpty() && comments.isEmpty() && bannerCard.isEmpty() && tags.isEmpty() && playmat.card.isEmpty();
|
return name.isEmpty() && comments.isEmpty() && bannerCard.isEmpty() && tags.isEmpty() && playmat.card.isEmpty();
|
||||||
|
|
@ -54,25 +96,9 @@ bool DeckList::Metadata::readElement(QXmlStreamReader *xml, const QString &child
|
||||||
} else if (childName == "comments") {
|
} else if (childName == "comments") {
|
||||||
comments = xml->readElementText();
|
comments = xml->readElementText();
|
||||||
} else if (childName == "bannerCard") {
|
} else if (childName == "bannerCard") {
|
||||||
QString providerId = xml->attributes().value("providerId").toString();
|
bannerCard = readBannerCard(xml);
|
||||||
QString cardName = xml->readElementText();
|
|
||||||
bannerCard = {cardName, providerId};
|
|
||||||
} else if (childName == "playmatCard") {
|
} else if (childName == "playmatCard") {
|
||||||
QString providerId = xml->attributes().value("providerId").toString();
|
playmat = readPlaymatCard(xml);
|
||||||
// Attributes are read before readElementText consumes the element.
|
|
||||||
QString marginLStr = xml->attributes().value("marginPctL").toString();
|
|
||||||
QString marginRStr = xml->attributes().value("marginPctR").toString();
|
|
||||||
QString vOffStr = xml->attributes().value("verticalOffset").toString();
|
|
||||||
QString zoomStr = xml->attributes().value("zoom").toString();
|
|
||||||
QString cardName = xml->readElementText();
|
|
||||||
playmat.card = {cardName, providerId};
|
|
||||||
// Clamp to the same ranges as the settings dialog and the remote
|
|
||||||
// player-properties path so malformed deck files cannot produce
|
|
||||||
// degenerate art rectangles (e.g. a zoom of 0 dividing by zero).
|
|
||||||
playmat.params.marginPctL = parseClampedParam(marginLStr, 0.07, 0.0, 0.95);
|
|
||||||
playmat.params.marginPctR = parseClampedParam(marginRStr, 0.07, 0.0, 0.95);
|
|
||||||
playmat.params.verticalOffset = parseClampedParam(vOffStr, 0.33, 0.0, 1.0);
|
|
||||||
playmat.params.zoom = parseClampedParam(zoomStr, 1.0, 0.1, 4.0);
|
|
||||||
} else if (childName == "tags") {
|
} else if (childName == "tags") {
|
||||||
tags.clear(); // Clear existing tags
|
tags.clear(); // Clear existing tags
|
||||||
while (xml->readNextStartElement()) {
|
while (xml->readNextStartElement()) {
|
||||||
|
|
@ -184,6 +210,27 @@ void DeckList::write(QXmlStreamWriter *xml) const
|
||||||
xml->writeEndElement(); // Close "cockatrice_deck"
|
xml->writeEndElement(); // Close "cockatrice_deck"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool DeckList::seekToNextElement(QXmlStreamReader *xml)
|
||||||
|
{
|
||||||
|
while (!xml->atEnd()) {
|
||||||
|
xml->readNext();
|
||||||
|
if (xml->isStartElement()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void DeckList::readDeckBody(QXmlStreamReader *xml)
|
||||||
|
{
|
||||||
|
while (!xml->atEnd()) {
|
||||||
|
xml->readNext();
|
||||||
|
if (!readElement(xml)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
bool DeckList::loadFromXml(QXmlStreamReader *xml)
|
bool DeckList::loadFromXml(QXmlStreamReader *xml)
|
||||||
{
|
{
|
||||||
if (xml->error()) {
|
if (xml->error()) {
|
||||||
|
|
@ -192,19 +239,11 @@ bool DeckList::loadFromXml(QXmlStreamReader *xml)
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanList();
|
cleanList();
|
||||||
while (!xml->atEnd()) {
|
while (seekToNextElement(xml)) {
|
||||||
xml->readNext();
|
if (xml->name().toString() != "cockatrice_deck") {
|
||||||
if (xml->isStartElement()) {
|
return false;
|
||||||
if (xml->name().toString() != "cockatrice_deck") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
while (!xml->atEnd()) {
|
|
||||||
xml->readNext();
|
|
||||||
if (!readElement(xml)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
readDeckBody(xml);
|
||||||
}
|
}
|
||||||
refreshDeckHash();
|
refreshDeckHash();
|
||||||
if (xml->error()) {
|
if (xml->error()) {
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,22 @@ private:
|
||||||
*/
|
*/
|
||||||
mutable QString cachedDeckHash;
|
mutable QString cachedDeckHash;
|
||||||
|
|
||||||
|
/** @name XML load helpers */
|
||||||
|
///@{
|
||||||
|
/**
|
||||||
|
* @brief Advances to the next element in the XML stream.
|
||||||
|
* @param xml Reader to advance past non-element tokens.
|
||||||
|
* @return true when a start element was reached, false at end of stream.
|
||||||
|
*/
|
||||||
|
bool seekToNextElement(QXmlStreamReader *xml);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Reads the contents of a `cockatrice_deck` element into this deck.
|
||||||
|
* @param xml Reader positioned at the deck element, stopped at its end.
|
||||||
|
*/
|
||||||
|
void readDeckBody(QXmlStreamReader *xml);
|
||||||
|
///@}
|
||||||
|
|
||||||
public:
|
public:
|
||||||
/** @name Metadata setters */
|
/** @name Metadata setters */
|
||||||
///@{
|
///@{
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,84 @@
|
||||||
|
|
||||||
static constexpr int MAX_DECK_SIZE = 1e5;
|
static constexpr int MAX_DECK_SIZE = 1e5;
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Expands card nodes into one lowercase name entry per copy.
|
||||||
|
*
|
||||||
|
* @param nodes The card nodes to expand.
|
||||||
|
* @param prefix Optional prefix prepended to every entry (e.g. "SB:" for the sideboard).
|
||||||
|
* @return One entry per copy, in node order.
|
||||||
|
*/
|
||||||
|
QStringList cardNodesToCopies(const QList<const DecklistCardNode *> &nodes, const QString &prefix = {})
|
||||||
|
{
|
||||||
|
QStringList result;
|
||||||
|
for (auto node : nodes) {
|
||||||
|
for (int i = 0; i < node->getNumber(); ++i) {
|
||||||
|
result.append(prefix + node->getName().toLower());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Packs the first five bytes of a SHA-1 digest into a compact base-32 number.
|
||||||
|
*
|
||||||
|
* The bytes are placed in most-significant-byte-first order (byte 0 shifted by 32,
|
||||||
|
* byte 4 unshifted) so decks that only differ in their low-order hash bytes still
|
||||||
|
* produce distinct identifiers.
|
||||||
|
*
|
||||||
|
* @return The 8-character base-32 representation of the packed number.
|
||||||
|
*/
|
||||||
|
QString encodeDeckHash(const QByteArray &digest)
|
||||||
|
{
|
||||||
|
quint64 number = 0;
|
||||||
|
for (int i = 0; i < 5; ++i) {
|
||||||
|
number |= static_cast<quint64>(static_cast<unsigned char>(digest[i])) << (32 - 8 * i);
|
||||||
|
}
|
||||||
|
return QString::number(number, 32).rightJustified(8, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Collects every card node in @p node's subtree, in tree order.
|
||||||
|
*
|
||||||
|
* @return The collected card nodes.
|
||||||
|
*/
|
||||||
|
QList<const DecklistCardNode *> collectCardsRecursive(const InnerDecklistNode *node)
|
||||||
|
{
|
||||||
|
QList<const DecklistCardNode *> result;
|
||||||
|
for (int i = 0; i < node->size(); i++) {
|
||||||
|
if (auto *card = dynamic_cast<const DecklistCardNode *>(node->at(i))) {
|
||||||
|
result.append(card);
|
||||||
|
} else if (auto *inner = dynamic_cast<const InnerDecklistNode *>(node->at(i))) {
|
||||||
|
result.append(collectCardsRecursive(inner));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Invokes @p func on every card in @p node's subtree.
|
||||||
|
*
|
||||||
|
* Cards nested in custom zones are reported with their top-level @p boardZone
|
||||||
|
* so that callers can classify cards by board (main/side/maybeboard/tokens).
|
||||||
|
*/
|
||||||
|
void forEachCardInNode(InnerDecklistNode *boardZone,
|
||||||
|
InnerDecklistNode *node,
|
||||||
|
const std::function<void(InnerDecklistNode *, DecklistCardNode *)> &func)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < node->size(); i++) {
|
||||||
|
if (auto *card = dynamic_cast<DecklistCardNode *>(node->at(i))) {
|
||||||
|
func(boardZone, card);
|
||||||
|
} else if (auto *inner = dynamic_cast<InnerDecklistNode *>(node->at(i))) {
|
||||||
|
forEachCardInNode(boardZone, inner, func);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
DecklistNodeTree::DecklistNodeTree() : root(new InnerDecklistNode())
|
DecklistNodeTree::DecklistNodeTree() : root(new InnerDecklistNode())
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
@ -43,19 +121,8 @@ QList<const DecklistCardNode *> DecklistNodeTree::getCardNodes(const QSet<QStrin
|
||||||
{
|
{
|
||||||
QList<const DecklistCardNode *> result;
|
QList<const DecklistCardNode *> result;
|
||||||
|
|
||||||
std::function<void(const InnerDecklistNode *)> collectCards = [&collectCards,
|
|
||||||
&result](const InnerDecklistNode *node) {
|
|
||||||
for (int i = 0; i < node->size(); i++) {
|
|
||||||
if (auto *card = dynamic_cast<const DecklistCardNode *>(node->at(i))) {
|
|
||||||
result.append(card);
|
|
||||||
} else if (auto *inner = dynamic_cast<const InnerDecklistNode *>(node->at(i))) {
|
|
||||||
collectCards(inner);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for (auto *zoneNode : getZoneNodes(restrictToZones)) {
|
for (auto *zoneNode : getZoneNodes(restrictToZones)) {
|
||||||
collectCards(zoneNode);
|
result.append(collectCardsRecursive(zoneNode));
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|
@ -83,25 +150,11 @@ QString DecklistNodeTree::computeDeckHash() const
|
||||||
auto mainDeckNodes = getCardNodes({DECK_ZONE_MAIN});
|
auto mainDeckNodes = getCardNodes({DECK_ZONE_MAIN});
|
||||||
auto sideDeckNodes = getCardNodes({DECK_ZONE_SIDE});
|
auto sideDeckNodes = getCardNodes({DECK_ZONE_SIDE});
|
||||||
|
|
||||||
static auto nodesToCardList = [](const QList<const DecklistCardNode *> &nodes, const QString &prefix = {}) {
|
QStringList cardList = cardNodesToCopies(mainDeckNodes) + cardNodesToCopies(sideDeckNodes, "SB:");
|
||||||
QStringList result;
|
|
||||||
for (auto node : nodes) {
|
|
||||||
for (int i = 0; i < node->getNumber(); ++i) {
|
|
||||||
result.append(prefix + node->getName().toLower());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
QStringList cardList = nodesToCardList(mainDeckNodes) + nodesToCardList(sideDeckNodes, "SB:");
|
|
||||||
|
|
||||||
cardList.sort();
|
cardList.sort();
|
||||||
QByteArray deckHashArray = QCryptographicHash::hash(cardList.join(";").toUtf8(), QCryptographicHash::Sha1);
|
QByteArray deckHashArray = QCryptographicHash::hash(cardList.join(";").toUtf8(), QCryptographicHash::Sha1);
|
||||||
quint64 number = (((quint64)(unsigned char)deckHashArray[0]) << 32) +
|
return encodeDeckHash(deckHashArray);
|
||||||
(((quint64)(unsigned char)deckHashArray[1]) << 24) +
|
|
||||||
(((quint64)(unsigned char)deckHashArray[2] << 16)) +
|
|
||||||
(((quint64)(unsigned char)deckHashArray[3]) << 8) + (quint64)(unsigned char)deckHashArray[4];
|
|
||||||
return QString::number(number, 32).rightJustified(8, '0');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void DecklistNodeTree::write(QXmlStreamWriter *xml) const
|
void DecklistNodeTree::write(QXmlStreamWriter *xml) const
|
||||||
|
|
@ -148,12 +201,7 @@ bool DecklistNodeTree::deleteNode(AbstractDecklistNode *node, InnerDecklistNode
|
||||||
int index = rootNode->indexOf(node);
|
int index = rootNode->indexOf(node);
|
||||||
if (index != -1) {
|
if (index != -1) {
|
||||||
delete rootNode->takeAt(index);
|
delete rootNode->takeAt(index);
|
||||||
|
pruneEmptyBoardZone(rootNode);
|
||||||
// Empty custom zones are kept while empty board zones get pruned.
|
|
||||||
if (rootNode->empty() && rootNode->getParent() == root) {
|
|
||||||
deleteNode(rootNode, rootNode->getParent());
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -169,24 +217,18 @@ bool DecklistNodeTree::deleteNode(AbstractDecklistNode *node, InnerDecklistNode
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void DecklistNodeTree::pruneEmptyBoardZone(InnerDecklistNode *container)
|
||||||
|
{
|
||||||
|
if (container->isEmpty() && container->getParent() == root) {
|
||||||
|
deleteNode(container, container->getParent());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void DecklistNodeTree::forEachCard(const std::function<void(InnerDecklistNode *, DecklistCardNode *)> &func) const
|
void DecklistNodeTree::forEachCard(const std::function<void(InnerDecklistNode *, DecklistCardNode *)> &func) const
|
||||||
{
|
{
|
||||||
// Cards nested in custom zones are reported with their top-level board zone
|
|
||||||
// so that callers can classify cards by board (main/side/maybeboard/tokens).
|
|
||||||
std::function<void(InnerDecklistNode *, InnerDecklistNode *)> walk = [&func, &walk](InnerDecklistNode *boardZone,
|
|
||||||
InnerDecklistNode *node) {
|
|
||||||
for (int i = 0; i < node->size(); i++) {
|
|
||||||
if (auto *card = dynamic_cast<DecklistCardNode *>(node->at(i))) {
|
|
||||||
func(boardZone, card);
|
|
||||||
} else if (auto *inner = dynamic_cast<InnerDecklistNode *>(node->at(i))) {
|
|
||||||
walk(boardZone, inner);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
for (int i = 0; i < root->size(); i++) {
|
for (int i = 0; i < root->size(); i++) {
|
||||||
if (auto *zone = dynamic_cast<InnerDecklistNode *>(root->at(i))) {
|
if (auto *zone = dynamic_cast<InnerDecklistNode *>(root->at(i))) {
|
||||||
walk(zone, zone);
|
forEachCardInNode(zone, zone, func);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,14 @@ private:
|
||||||
InnerDecklistNode *getZoneObjFromName(const QString &zoneName);
|
InnerDecklistNode *getZoneObjFromName(const QString &zoneName);
|
||||||
InnerDecklistNode *findBoardZone(const QString &boardZoneName) const;
|
InnerDecklistNode *findBoardZone(const QString &boardZoneName) const;
|
||||||
InnerDecklistNode *findOrCreateBoardZone(const QString &boardZoneName);
|
InnerDecklistNode *findOrCreateBoardZone(const QString &boardZoneName);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Recursively removes @p container when it is an empty board zone.
|
||||||
|
*
|
||||||
|
* Empty custom zones are kept while empty board zones get pruned, so a
|
||||||
|
* board zone disappears once its last card or custom zone goes away.
|
||||||
|
*/
|
||||||
|
void pruneEmptyBoardZone(InnerDecklistNode *container);
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // COCKATRICE_DECKLIST_NODE_TREE_H
|
#endif // COCKATRICE_DECKLIST_NODE_TREE_H
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,32 @@
|
||||||
|
|
||||||
#include <QXmlStreamReader>
|
#include <QXmlStreamReader>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
|
||||||
|
void readMoveCardToZone(QXmlStreamReader *xml, QList<MoveCard_ToZone> &moveList)
|
||||||
|
{
|
||||||
|
MoveCard_ToZone move;
|
||||||
|
while (!xml->atEnd()) {
|
||||||
|
xml->readNext();
|
||||||
|
const QString childName = xml->name().toString();
|
||||||
|
if (xml->isStartElement()) {
|
||||||
|
if (childName == "card_name") {
|
||||||
|
move.set_card_name(xml->readElementText().toStdString());
|
||||||
|
} else if (childName == "start_zone") {
|
||||||
|
move.set_start_zone(xml->readElementText().toStdString());
|
||||||
|
} else if (childName == "target_zone") {
|
||||||
|
move.set_target_zone(xml->readElementText().toStdString());
|
||||||
|
}
|
||||||
|
} else if (xml->isEndElement() && (childName == "move_card_to_zone")) {
|
||||||
|
moveList.append(move);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
SideboardPlan::SideboardPlan(const QString &_name, const QList<MoveCard_ToZone> &_moveList)
|
SideboardPlan::SideboardPlan(const QString &_name, const QList<MoveCard_ToZone> &_moveList)
|
||||||
: name(_name), moveList(_moveList)
|
: name(_name), moveList(_moveList)
|
||||||
{
|
{
|
||||||
|
|
@ -21,23 +47,7 @@ bool SideboardPlan::readElement(QXmlStreamReader *xml)
|
||||||
if (childName == "name") {
|
if (childName == "name") {
|
||||||
name = xml->readElementText();
|
name = xml->readElementText();
|
||||||
} else if (childName == "move_card_to_zone") {
|
} else if (childName == "move_card_to_zone") {
|
||||||
MoveCard_ToZone m;
|
readMoveCardToZone(xml, moveList);
|
||||||
while (!xml->atEnd()) {
|
|
||||||
xml->readNext();
|
|
||||||
const QString childName2 = xml->name().toString();
|
|
||||||
if (xml->isStartElement()) {
|
|
||||||
if (childName2 == "card_name") {
|
|
||||||
m.set_card_name(xml->readElementText().toStdString());
|
|
||||||
} else if (childName2 == "start_zone") {
|
|
||||||
m.set_start_zone(xml->readElementText().toStdString());
|
|
||||||
} else if (childName2 == "target_zone") {
|
|
||||||
m.set_target_zone(xml->readElementText().toStdString());
|
|
||||||
}
|
|
||||||
} else if (xml->isEndElement() && (childName2 == "move_card_to_zone")) {
|
|
||||||
moveList.append(m);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else if (xml->isEndElement() && (childName == "sideboard_plan")) {
|
} else if (xml->isEndElement() && (childName == "sideboard_plan")) {
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -151,24 +151,29 @@ bool InnerDecklistNode::compareName(AbstractDecklistNode *other) const
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int InnerDecklistNode::readCardElement(QXmlStreamReader *xml, int remainingBudget)
|
||||||
|
{
|
||||||
|
const int amount = qMin(xml->attributes().value("number").toString().toInt(), remainingBudget);
|
||||||
|
new DecklistCardNode(xml->attributes().value("name").toString(), amount, this, -1,
|
||||||
|
xml->attributes().value("setShortName").toString(),
|
||||||
|
xml->attributes().value("collectorNumber").toString(),
|
||||||
|
xml->attributes().value("uuid").toString());
|
||||||
|
return amount;
|
||||||
|
}
|
||||||
|
|
||||||
int InnerDecklistNode::readElement(QXmlStreamReader *xml, int limit)
|
int InnerDecklistNode::readElement(QXmlStreamReader *xml, int limit)
|
||||||
{
|
{
|
||||||
int totalCards = 0;
|
int totalCards = 0;
|
||||||
while (!xml->atEnd()) {
|
while (!xml->atEnd()) {
|
||||||
xml->readNext();
|
xml->readNext();
|
||||||
const QString childName = xml->name().toString();
|
const QString childName = xml->name().toString();
|
||||||
|
const int remainingBudget = limit - totalCards;
|
||||||
if (xml->isStartElement()) {
|
if (xml->isStartElement()) {
|
||||||
if (childName == "zone") {
|
if (childName == "zone") {
|
||||||
auto *newZone = new InnerDecklistNode(xml->attributes().value("name").toString(), this);
|
auto *newZone = new InnerDecklistNode(xml->attributes().value("name").toString(), this);
|
||||||
totalCards += newZone->readElement(xml, limit - totalCards);
|
totalCards += newZone->readElement(xml, remainingBudget);
|
||||||
} else if (childName == "card") {
|
} else if (childName == "card") {
|
||||||
int amount = xml->attributes().value("number").toString().toInt();
|
totalCards += readCardElement(xml, remainingBudget);
|
||||||
amount = qMin(amount, limit - totalCards);
|
|
||||||
new DecklistCardNode(xml->attributes().value("name").toString(), amount, this, -1,
|
|
||||||
xml->attributes().value("setShortName").toString(),
|
|
||||||
xml->attributes().value("collectorNumber").toString(),
|
|
||||||
xml->attributes().value("uuid").toString());
|
|
||||||
totalCards += amount;
|
|
||||||
}
|
}
|
||||||
} else if (xml->isEndElement() && (childName == "zone")) {
|
} else if (xml->isEndElement() && (childName == "zone")) {
|
||||||
return totalCards;
|
return totalCards;
|
||||||
|
|
@ -187,31 +192,35 @@ void InnerDecklistNode::writeElement(QXmlStreamWriter *xml)
|
||||||
xml->writeEndElement(); // zone
|
xml->writeEndElement(); // zone
|
||||||
}
|
}
|
||||||
|
|
||||||
QVector<QPair<int, int>> InnerDecklistNode::sort(Qt::SortOrder order)
|
QVector<QPair<int, AbstractDecklistNode *>> InnerDecklistNode::indexedSnapshot() const
|
||||||
|
{
|
||||||
|
QVector<QPair<int, AbstractDecklistNode *>> snapshot(size());
|
||||||
|
for (int i = size() - 1; i >= 0; --i) {
|
||||||
|
snapshot[i].first = i;
|
||||||
|
snapshot[i].second = at(i);
|
||||||
|
}
|
||||||
|
return snapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
QVector<QPair<int, int>> InnerDecklistNode::applySortedOrder(const QVector<QPair<int, AbstractDecklistNode *>> &sorted)
|
||||||
{
|
{
|
||||||
QVector<QPair<int, int>> result(size());
|
QVector<QPair<int, int>> result(size());
|
||||||
|
|
||||||
// Initialize temporary list with contents of current list
|
|
||||||
QVector<QPair<int, AbstractDecklistNode *>> tempList(size());
|
|
||||||
for (int i = size() - 1; i >= 0; --i) {
|
for (int i = size() - 1; i >= 0; --i) {
|
||||||
tempList[i].first = i;
|
result[i].first = sorted[i].first;
|
||||||
tempList[i].second = at(i);
|
result[i].second = i;
|
||||||
|
replace(i, sorted[i].second);
|
||||||
}
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
QVector<QPair<int, int>> InnerDecklistNode::sort(Qt::SortOrder order)
|
||||||
|
{
|
||||||
|
auto snapshot = indexedSnapshot();
|
||||||
|
|
||||||
// Sort temporary list
|
|
||||||
auto cmp = [order](const auto &a, const auto &b) {
|
auto cmp = [order](const auto &a, const auto &b) {
|
||||||
return (order == Qt::AscendingOrder) ? (b.second->compare(a.second)) : (a.second->compare(b.second));
|
return (order == Qt::AscendingOrder) ? (b.second->compare(a.second)) : (a.second->compare(b.second));
|
||||||
};
|
};
|
||||||
|
std::sort(snapshot.begin(), snapshot.end(), cmp);
|
||||||
|
|
||||||
std::sort(tempList.begin(), tempList.end(), cmp);
|
return applySortedOrder(snapshot);
|
||||||
|
|
||||||
// Map old indexes to new indexes and
|
|
||||||
// copy temporary list to the current one
|
|
||||||
for (int i = size() - 1; i >= 0; --i) {
|
|
||||||
result[i].first = tempList[i].first;
|
|
||||||
result[i].second = i;
|
|
||||||
replace(i, tempList[i].second);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,20 @@ public:
|
||||||
*/
|
*/
|
||||||
QVector<QPair<int, int>> sort(Qt::SortOrder order = Qt::AscendingOrder);
|
QVector<QPair<int, int>> sort(Qt::SortOrder order = Qt::AscendingOrder);
|
||||||
|
|
||||||
|
private:
|
||||||
|
/**
|
||||||
|
* @brief Snapshots the current children as (old index, node) pairs.
|
||||||
|
*/
|
||||||
|
QVector<QPair<int, AbstractDecklistNode *>> indexedSnapshot() const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Replaces this node's children with @p sorted and maps old indexes to new ones.
|
||||||
|
*
|
||||||
|
* @return A list of (old index, new index) pairs for each reordered child.
|
||||||
|
*/
|
||||||
|
QVector<QPair<int, int>> applySortedOrder(const QVector<QPair<int, AbstractDecklistNode *>> &sorted);
|
||||||
|
|
||||||
|
public:
|
||||||
/**
|
/**
|
||||||
* @brief Deserialize this node and its children from XML.
|
* @brief Deserialize this node and its children from XML.
|
||||||
* @param xml Reader positioned at this element.
|
* @param xml Reader positioned at this element.
|
||||||
|
|
@ -236,6 +250,18 @@ public:
|
||||||
* @param xml Writer to append elements to.
|
* @param xml Writer to append elements to.
|
||||||
*/
|
*/
|
||||||
void writeElement(QXmlStreamWriter *xml) override;
|
void writeElement(QXmlStreamWriter *xml) override;
|
||||||
|
|
||||||
|
private:
|
||||||
|
/**
|
||||||
|
* @brief Reads a single `card` element and appends it to this node.
|
||||||
|
*
|
||||||
|
* The card's quantity is capped at @p remainingBudget so a malicious or
|
||||||
|
* oversized deck file cannot push the total card count past the deck size
|
||||||
|
* limit.
|
||||||
|
*
|
||||||
|
* @return The amount of cards actually added.
|
||||||
|
*/
|
||||||
|
int readCardElement(QXmlStreamReader *xml, int remainingBudget);
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // COCKATRICE_INNER_DECK_LIST_NODE_H
|
#endif // COCKATRICE_INNER_DECK_LIST_NODE_H
|
||||||
|
|
|
||||||
|
|
@ -465,7 +465,7 @@ Response::ResponseCode Server_Game::checkJoin(ServerInfo_User *user,
|
||||||
if (asJudge && !(user->user_level() & ServerInfo_User::IsJudge)) {
|
if (asJudge && !(user->user_level() & ServerInfo_User::IsJudge)) {
|
||||||
return Response::RespUserLevelTooLow;
|
return Response::RespUserLevelTooLow;
|
||||||
}
|
}
|
||||||
if (!(overrideRestrictions && (user->user_level() & ServerInfo_User::IsModerator))) {
|
if (!(overrideRestrictions && (user->user_level() & (ServerInfo_User::IsModerator | ServerInfo_User::IsJudge)))) {
|
||||||
if ((_password != password) && !(spectator && !spectatorsNeedPassword)) {
|
if ((_password != password) && !(spectator && !spectatorsNeedPassword)) {
|
||||||
return Response::RespWrongPassword;
|
return Response::RespWrongPassword;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue