Compare commits

..

No commits in common. "master" and "2026-08-25-Development-3.1.0-beta.9" have entirely different histories.

484 changed files with 3365 additions and 21719 deletions

View file

@ -8,7 +8,6 @@ RUN pacman --sync --refresh --sysupgrade --needed --noconfirm \
gtest \ gtest \
mariadb-libs \ mariadb-libs \
ninja \ ninja \
openssl \
protobuf \ protobuf \
qt6-base \ qt6-base \
qt6-declarative \ qt6-declarative \

View file

@ -15,15 +15,14 @@ RUN apt-get update && \
libprotobuf-dev \ libprotobuf-dev \
libqt6multimedia6 \ libqt6multimedia6 \
libqt6sql6-mysql \ libqt6sql6-mysql \
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-shadertools-dev \ qt6-declarative-dev \
qt6-svg-dev \ qt6-svg-dev \
qt6-shadertools-dev \
qt6-tools-dev \ qt6-tools-dev \
qt6-tools-dev-tools \ qt6-tools-dev-tools \
qt6-websockets-dev \ qt6-websockets-dev \

View file

@ -16,15 +16,14 @@ RUN apt-get update && \
libprotobuf-dev \ libprotobuf-dev \
libqt6multimedia6 \ libqt6multimedia6 \
libqt6sql6-mysql \ libqt6sql6-mysql \
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-shadertools-dev \ qt6-declarative-dev \
qt6-svg-dev \ qt6-svg-dev \
qt6-shadertools-dev \
qt6-tools-dev \ qt6-tools-dev \
qt6-tools-dev-tools \ qt6-tools-dev-tools \
qt6-websockets-dev \ qt6-websockets-dev \

View file

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

View file

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

View file

@ -12,7 +12,6 @@ RUN apt-get update && \
libmariadb-dev-compat \ libmariadb-dev-compat \
libprotobuf-dev \ libprotobuf-dev \
libqt6sql6-mysql \ libqt6sql6-mysql \
libssl-dev \
ninja-build \ ninja-build \
protobuf-compiler \ protobuf-compiler \
qt6-tools-dev \ qt6-tools-dev \

View file

@ -15,15 +15,14 @@ RUN apt-get update && \
libprotobuf-dev \ libprotobuf-dev \
libqt6multimedia6 \ libqt6multimedia6 \
libqt6sql6-mysql \ libqt6sql6-mysql \
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-shadertools-dev \ qt6-declarative-dev \
qt6-svg-dev \ qt6-svg-dev \
qt6-shadertools-dev \
qt6-tools-dev \ qt6-tools-dev \
qt6-tools-dev-tools \ qt6-tools-dev-tools \
qt6-websockets-dev \ qt6-websockets-dev \

View file

@ -16,15 +16,14 @@ RUN apt-get update && \
libprotobuf-dev \ libprotobuf-dev \
libqt6multimedia6 \ libqt6multimedia6 \
libqt6sql6-mysql \ libqt6sql6-mysql \
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-shadertools-dev \ qt6-declarative-dev \
qt6-svg-dev \ qt6-svg-dev \
qt6-shadertools-dev \
qt6-tools-dev \ qt6-tools-dev \
qt6-tools-dev-tools \ qt6-tools-dev-tools \
qt6-websockets-dev \ qt6-websockets-dev \

View file

@ -149,9 +149,6 @@ if [[ $MAKE_TEST ]]; then
fi fi
if [[ $USE_CCACHE ]]; then if [[ $USE_CCACHE ]]; then
flags+=("-DUSE_CCACHE=1") flags+=("-DUSE_CCACHE=1")
# PCH-aware caching is required or ccache refuses to cache any TU that
# consumes a precompiled header, silently recompiling everything on every run.
ccache --set-config sloppiness=pch_defines,time_macros
if [[ $CCACHE_SIZE ]]; then if [[ $CCACHE_SIZE ]]; then
# note, this setting persists after running the script # note, this setting persists after running the script
ccache --max-size "$CCACHE_SIZE" ccache --max-size "$CCACHE_SIZE"
@ -327,32 +324,4 @@ 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

View file

@ -40,7 +40,7 @@ jobs:
steps: steps:
- name: "Checkout repository" - name: "Checkout repository"
uses: actions/checkout@v7 uses: actions/checkout@v6
- name: "Initialize CodeQL" - name: "Initialize CodeQL"
uses: github/codeql-action/init@v4 uses: github/codeql-action/init@v4

View file

@ -152,7 +152,7 @@ jobs:
env: env:
CACHE: ${{ github.workspace }}/.cache/${{ matrix.distro }}${{ matrix.version }} # directory for caching docker image and ccache CACHE: ${{ github.workspace }}/.cache/${{ matrix.distro }}${{ matrix.version }} # directory for caching docker image and ccache
CCACHE_EVICTION_AGE: 7d CCACHE_EVICTION_AGE: 7d
CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy CCACHE_SIZE: 550M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy
CMAKE_GENERATOR: 'Ninja' CMAKE_GENERATOR: 'Ninja'
NAME: ${{ matrix.distro }}${{ matrix.version }} NAME: ${{ matrix.distro }}${{ matrix.version }}
@ -176,12 +176,8 @@ jobs:
shell: bash shell: bash
run: | run: |
source .ci/docker.sh source .ci/docker.sh
args=() RUN --server --debug --test --ccache "$CCACHE_SIZE" \
[[ $GITHUB_REF == "refs/heads/master" ]] && args+=(--evict-ccache "$CCACHE_EVICTION_AGE") --cmake-generator "$CMAKE_GENERATOR"
args+=(--ccache "$CCACHE_SIZE")
args+=(--cmake-generator "$CMAKE_GENERATOR")
RUN --server --debug --test "${args[@]}"
- name: "Build release package" - name: "Build release package"
id: build id: build
@ -272,8 +268,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.* qt_version: 6.11.1
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools
soc: Intel soc: Intel
type: Release type: Release
use_ccache: 1 use_ccache: 1
@ -288,8 +284,8 @@ jobs:
make_package: 1 make_package: 1
override_target: 14 override_target: 14
package_suffix: "-macOS14" package_suffix: "-macOS14"
qt_version: 6.11.* qt_version: 6.11.1
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools
soc: Apple soc: Apple
type: Release type: Release
use_ccache: 1 use_ccache: 1
@ -304,8 +300,8 @@ jobs:
make_package: 1 make_package: 1
override_target: 15 override_target: 15
package_suffix: "-macOS15" package_suffix: "-macOS15"
qt_version: 6.11.* qt_version: 6.11.1
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools
soc: Apple soc: Apple
type: Release type: Release
use_ccache: 1 use_ccache: 1
@ -317,8 +313,8 @@ jobs:
ccache_eviction_age: 7d ccache_eviction_age: 7d
cmake_generator: Ninja cmake_generator: Ninja
qt_version: 6.11.* qt_version: 6.11.1
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools
soc: Apple soc: Apple
type: Debug type: Debug
use_ccache: 1 use_ccache: 1
@ -332,8 +328,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.* qt_version: 6.11.1
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools
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' || '' }}
@ -342,7 +338,7 @@ jobs:
timeout-minutes: 100 timeout-minutes: 100
env: env:
CCACHE_DIR: ${{ github.workspace }}/.cache/ CCACHE_DIR: ${{ github.workspace }}/.cache/
CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy CCACHE_SIZE: 550M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy
steps: steps:
- name: "Checkout" - name: "Checkout"
@ -368,20 +364,18 @@ 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: "[macOS] Install aqtinstall" - name: "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: "[macOS] Resolve latest Qt from ${{ matrix.qt_version }} input" - name: "Resolve latest Qt patch version"
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 }}" - name: "[macOS] Restore thin Qt ${{ steps.resolve_qt_version.outputs.version }} libraries"
if: matrix.os == 'macOS' if: matrix.os == 'macOS'
id: restore_qt id: restore_qt
uses: actions/cache/restore@v6 uses: actions/cache/restore@v6
@ -391,22 +385,21 @@ 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 ${{ matrix.qt_version }}" - name: "[macOS] Install fat Qt ${{ steps.resolve_qt_version.outputs.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
# cache-key-prefix: Qt dir: ${{ github.workspace }}
dir: ${{ github.workspace }} # thinning script depends on this location
modules: ${{ matrix.qt_modules }} modules: ${{ matrix.qt_modules }}
version: ${{ matrix.qt_version }} version: ${{ steps.resolve_qt_version.outputs.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' && github.ref == 'refs/heads/master' if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true'
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 }}
@ -418,10 +411,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: ${{ github.ref == 'refs/heads/master' }} cache: true
cache-key-prefix: Qt cache-key-prefix: Qt
modules: ${{ matrix.qt_modules }} modules: ${{ matrix.qt_modules }}
version: ${{ matrix.qt_version }} version: ${{ steps.resolve_qt_version.outputs.version }}
- name: "[Windows] Install NSIS" - name: "[Windows] Install NSIS"
if: matrix.os == 'Windows' if: matrix.os == 'Windows'
@ -453,7 +446,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 }},${{ case(github.ref == 'refs/heads/master', 'readwrite', 'read') }}" VCPKG_BINARY_SOURCES: 'clear;files,${{ steps.vcpkg-cache.outputs.path }},readwrite'
VCPKG_DISABLE_METRICS: 1 VCPKG_DISABLE_METRICS: 1
VCPKG_FEATURE_FLAGS: dependencygraph VCPKG_FEATURE_FLAGS: dependencygraph
run: .ci/compile.sh --server --test --vcpkg run: .ci/compile.sh --server --test --vcpkg

View file

@ -76,7 +76,7 @@ jobs:
uses: docker/build-push-action@v7 uses: docker/build-push-action@v7
with: with:
cache-from: type=gha,scope=${{ env.CACHE_SCOPE }} cache-from: type=gha,scope=${{ env.CACHE_SCOPE }}
cache-to: ${{ case(github.ref == 'refs/heads/master', format('type=gha,mode=max,scope={0}', env.CACHE_SCOPE), '') }} cache-to: type=gha,mode=max,scope=${{ env.CACHE_SCOPE }}
context: . context: .
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
push: false push: false
@ -127,7 +127,7 @@ jobs:
steps: steps:
- name: "Download digests" - name: "Download digests"
uses: actions/download-artifact@v8 uses: actions/download-artifact@v7
with: with:
path: ${{ runner.temp }}/digests path: ${{ runner.temp }}/digests
pattern: digest-* pattern: digest-*

View file

@ -5,23 +5,23 @@
# This file sets all the variables shared between the projects # This file sets all the variables shared between the projects
# like the installation path, compilation flags etc.. # like the installation path, compilation flags etc..
# 3.16 required for Qt6 and target_precompile_headers() # cmake 3.16 is required if using qt6
cmake_minimum_required(VERSION 3.16) cmake_minimum_required(VERSION 3.10)
# Use compiler cache (ccache) # Early detect ccache
option(USE_CCACHE "Cache the build results with ccache" ON) option(USE_CCACHE "Cache the build results with ccache" ON)
# Treat warnings as errors (Debug builds only) # Treat warnings as errors (Debug builds only)
option(WARNING_AS_ERROR "Treat warnings as errors in debug builds" ON) option(WARNING_AS_ERROR "Treat warnings as errors in debug builds" ON)
# Check for translation updates # Check for translation updates
option(UPDATE_TRANSLATIONS "Update translations on compile" OFF) option(UPDATE_TRANSLATIONS "Update translations on compile" OFF)
# Compile Cockatrice # Compile servatrice
option(WITH_CLIENT "Build Cockatrice client" ON) option(WITH_SERVER "build servatrice" OFF)
# Compile Oracle # Compile cockatrice
option(WITH_ORACLE "Build Cockatrice card database tool (Oracle)" ON) option(WITH_CLIENT "build cockatrice" ON)
# Compile Servatrice # Compile oracle
option(WITH_SERVER "Build Cockatrice server (Servatrice)" OFF) option(WITH_ORACLE "build oracle" ON)
# Compile tests # Compile tests
option(TEST "Build tests" OFF) option(TEST "build tests" OFF)
# Use vcpkg regardless of OS # Use vcpkg regardless of OS
option(USE_VCPKG "Use vcpkg regardless of OS" OFF) option(USE_VCPKG "Use vcpkg regardless of OS" OFF)
@ -39,24 +39,13 @@ else()
) )
endif() endif()
# ccache does not support MSVC and must not auto-engage on Windows if(USE_CCACHE)
# (it is installed unintentionally on the Windows CI runner).
# NOTE: this keys off the target OS, so a mingw/Ninja configuration on Windows
# also opts out of ccache even though the GNUCXX branch below supports it.
if(USE_CCACHE AND NOT WIN32)
find_program(CCACHE_PROGRAM ccache) find_program(CCACHE_PROGRAM ccache)
if(CCACHE_PROGRAM) if(CCACHE_PROGRAM)
# Support Unix Makefiles and Ninja # Support Unix Makefiles and Ninja
set_property(GLOBAL PROPERTY RULE_LAUNCH_COMPILE "${CCACHE_PROGRAM}") set_property(GLOBAL PROPERTY RULE_LAUNCH_COMPILE "${CCACHE_PROGRAM}")
# PCH-aware caching, matching .ci/compile.sh: without this ccache refuses
# to cache any TU that consumes a precompiled header, so every PCH-backed
# target recompiles from scratch on each build.
execute_process(COMMAND ${CCACHE_PROGRAM} --set-config sloppiness=pch_defines,time_macros)
message(STATUS "Found CCache ${CCACHE_PROGRAM}") message(STATUS "Found CCache ${CCACHE_PROGRAM}")
endif() endif()
elseif(USE_CCACHE AND WIN32)
# An explicit opt-in must not disappear silently on Windows.
message(STATUS "ccache disabled: not supported for the MSVC toolchain on Windows")
endif() endif()
if(WIN32 OR USE_VCPKG) if(WIN32 OR USE_VCPKG)
@ -195,9 +184,6 @@ elseif(CMAKE_COMPILER_IS_GNUCXX)
set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} ${FLAG}") set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} ${FLAG}")
endif() endif()
endforeach() endforeach()
# Reduce compiler I/O by using pipes between stages instead of temp files
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe")
else() else()
# other: osx/llvm, bsd/llvm # other: osx/llvm, bsd/llvm
set(CMAKE_CXX_FLAGS_RELEASE "-O2") set(CMAKE_CXX_FLAGS_RELEASE "-O2")
@ -206,9 +192,6 @@ else()
else() else()
set(CMAKE_CXX_FLAGS_DEBUG "-g -O0 -Wall -Wextra") set(CMAKE_CXX_FLAGS_DEBUG "-g -O0 -Wall -Wextra")
endif() endif()
# Reduce compiler I/O by using pipes between stages instead of temp files
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe")
endif() endif()
# GNU systems need to define the Mersenne exponent for the RNG to compile w/o warning # GNU systems need to define the Mersenne exponent for the RNG to compile w/o warning
@ -256,6 +239,11 @@ if(WIN32)
find_package(OpenSSL REQUIRED) find_package(OpenSSL REQUIRED)
if(OPENSSL_FOUND) if(OPENSSL_FOUND)
include_directories(${OPENSSL_INCLUDE_DIRS}) include_directories(${OPENSSL_INCLUDE_DIRS})
else()
message(
WARNING
"Could not find OpenSSL runtime libraries. They are not required for compiling, but needs to be available at runtime."
)
endif() endif()
endif() endif()
@ -293,7 +281,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-qtimageformats, qt6-qtmultimedia, qt6-qtsvg, qt6-qttools") set(CPACK_RPM_PACKAGE_REQUIRES "protobuf, qt6-qttools, qt6-qtsvg, qt6-qtmultimedia, qt6-qtimageformats")
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 +299,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-image-formats-plugins, qt6-qpa-plugins") set(CPACK_DEBIAN_PACKAGE_DEPENDS "libqt6multimedia6, libqt6svg6, qt6-qpa-plugins, qt6-image-formats-plugins")
set(CPACK_DEBIAN_PACKAGE_RECOMMENDS "libqt6sql6-mysql") # for connecting servatrice to a mysql db set(CPACK_DEBIAN_PACKAGE_RECOMMENDS "libqt6sql6-mysql") # for connecting servatrice to a mysql db
endif() endif()
endif() endif()

View file

@ -14,7 +14,6 @@ RUN apt-get update \
libmariadb-dev-compat \ libmariadb-dev-compat \
libprotobuf-dev \ libprotobuf-dev \
libqt6sql6-mysql \ libqt6sql6-mysql \
libssl-dev \
qt6-websockets-dev \ qt6-websockets-dev \
protobuf-compiler \ protobuf-compiler \
qt6-tools-dev \ qt6-tools-dev \
@ -43,7 +42,6 @@ RUN apt-get update \
libprotobuf32t64 \ libprotobuf32t64 \
libqt6sql6-mysql \ libqt6sql6-mysql \
libqt6websockets6 \ libqt6websockets6 \
libssl3 \
&& apt-get clean \ && apt-get clean \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*

View file

@ -150,7 +150,7 @@ You can then
The following flags (with their non-default values) can be passed to `cmake`: The following flags (with their non-default values) can be passed to `cmake`:
| Flag | Description | | Flag | Description |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | --- | --- |
| `-DWITH_SERVER=1` | Build <kbd>Servatrice</kbd> server | | `-DWITH_SERVER=1` | Build <kbd>Servatrice</kbd> server |
| `-DWITH_CLIENT=0` | Don't build <kbd>Cockatrice</kbd> client | | `-DWITH_CLIENT=0` | Don't build <kbd>Cockatrice</kbd> client |
| `-DWITH_ORACLE=0` | Don't build <kbd>Oracle</kbd> card database tool | | `-DWITH_ORACLE=0` | Don't build <kbd>Oracle</kbd> card database tool |

View file

@ -28,7 +28,7 @@ if(WITH_CLIENT)
) )
endif() endif()
if(WITH_ORACLE) if(WITH_ORACLE)
set(_ORACLE_NEEDED Concurrent Network Svg Widgets Xml) set(_ORACLE_NEEDED Concurrent Network Svg Widgets)
endif() endif()
if(TEST) if(TEST)
# Union of Qt modules required across all test targets (independent of application targets). # Union of Qt modules required across all test targets (independent of application targets).

View file

@ -387,14 +387,19 @@ 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"
; Remove the entire application directory so any file that is not part of RMDir "$SMPROGRAMS\Cockatrice"
; the installed payload (e.g. build-tree artifacts such as *.dir folders,
; *_autogen and *.tlog files from a build) cannot survive between an
; uninstall and a fresh reinstall.
RMDir /r "$INSTDIR"
RMDir /r "$SMPROGRAMS\Cockatrice"
DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice"
SectionEnd SectionEnd

View file

@ -1,24 +0,0 @@
/** @file qtcore_pch.h
* @brief Precompiled header for all Qt targets (Qt Core only).
*
* Safe for every target that links Qt Core, including the headless
* Servatrice binary. Keep this header free of any widget/gui types.
*/
#include <QBasicTimer>
#include <QByteArray>
#include <QDateTime>
#include <QDebug>
#include <QFile>
#include <QHash>
#include <QList>
#include <QLoggingCategory>
#include <QMap>
#include <QMetaObject>
#include <QObject>
#include <QRandomGenerator>
#include <QSharedPointer>
#include <QString>
#include <QStringList>
#include <QTimer>
#include <QVariant>

View file

@ -1,30 +0,0 @@
/** @file qtwidgets_pch.h
* @brief Precompiled header for GUI targets (Cockatrice client, Oracle).
*
* Includes the Qt Core precompiled header plus the heavy Gui, Widgets and
* Network layers that virtually every client translation unit re-parses.
* Do not use on Servatrice (headless, QT_DONT_USE_QTGUI).
*/
#include "qtcore_pch.h"
#include <QAction>
#include <QApplication>
#include <QFrame>
#include <QGraphicsItem>
#include <QGraphicsScene>
#include <QGraphicsView>
#include <QImage>
#include <QLabel>
#include <QLayout>
#include <QMainWindow>
#include <QMenu>
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QPainter>
#include <QPushButton>
#include <QScrollArea>
#include <QTabWidget>
#include <QToolBar>
#include <QTreeWidget>
#include <QWidget>

View file

@ -45,14 +45,11 @@ 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
@ -60,9 +57,6 @@ 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
@ -169,11 +163,9 @@ 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
src/interface/widgets/cards/card_art_utils.cpp
src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp
src/interface/widgets/cards/card_group_display_widgets/flat_card_group_display_widget.cpp src/interface/widgets/cards/card_group_display_widgets/flat_card_group_display_widget.cpp
src/interface/widgets/cards/card_group_display_widgets/overlapped_card_group_display_widget.cpp src/interface/widgets/cards/card_group_display_widgets/overlapped_card_group_display_widget.cpp
@ -222,7 +214,6 @@ set(cockatrice_SOURCES
src/interface/widgets/deck_editor/deck_editor_printing_selector_dock_widget.cpp src/interface/widgets/deck_editor/deck_editor_printing_selector_dock_widget.cpp
src/interface/widgets/deck_editor/deck_list_style_proxy.cpp src/interface/widgets/deck_editor/deck_list_style_proxy.cpp
src/interface/widgets/deck_editor/deck_state_manager.cpp src/interface/widgets/deck_editor/deck_state_manager.cpp
src/interface/widgets/deck_editor/deck_zone_dialog.cpp
src/interface/widgets/deck_editor/printing_disabled_info_widget.cpp src/interface/widgets/deck_editor/printing_disabled_info_widget.cpp
src/interface/widgets/general/background_sources.cpp src/interface/widgets/general/background_sources.cpp
src/interface/widgets/general/display/background_plate_widget.cpp src/interface/widgets/general/display/background_plate_widget.cpp
@ -280,7 +271,6 @@ set(cockatrice_SOURCES
src/interface/widgets/server/user/user_context_menu.cpp src/interface/widgets/server/user/user_context_menu.cpp
src/interface/widgets/server/user/user_info_box.cpp src/interface/widgets/server/user/user_info_box.cpp
src/interface/widgets/server/user/user_info_connection.cpp src/interface/widgets/server/user/user_info_connection.cpp
src/interface/widgets/server/user/user_list_dialog.cpp
src/interface/widgets/server/user/user_list_manager.cpp src/interface/widgets/server/user/user_list_manager.cpp
src/interface/widgets/server/user/user_list_painter.cpp src/interface/widgets/server/user/user_list_painter.cpp
src/interface/widgets/server/user/user_list_panel_widget.cpp src/interface/widgets/server/user/user_list_panel_widget.cpp
@ -324,8 +314,6 @@ 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
@ -391,13 +379,11 @@ set(cockatrice_SOURCES
src/interface/widgets/tabs/tab_card_art_rules.cpp src/interface/widgets/tabs/tab_card_art_rules.cpp
src/interface/widgets/tabs/tab_deck_editor.cpp src/interface/widgets/tabs/tab_deck_editor.cpp
src/interface/widgets/tabs/tab_deck_storage.cpp src/interface/widgets/tabs/tab_deck_storage.cpp
src/interface/widgets/tabs/tab_developer.cpp
src/interface/widgets/tabs/tab_game.cpp src/interface/widgets/tabs/tab_game.cpp
src/interface/widgets/tabs/tab_home.cpp src/interface/widgets/tabs/tab_home.cpp
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
@ -413,7 +399,6 @@ set(cockatrice_SOURCES
src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp
src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp
src/interface/widgets/onboarding/banner_shader_config.h src/interface/widgets/onboarding/banner_shader_config.h
src/interface/widgets/onboarding/brand_colors.h
src/interface/widgets/onboarding/first_run_wizard.cpp src/interface/widgets/onboarding/first_run_wizard.cpp
src/interface/widgets/onboarding/first_run_wizard.h src/interface/widgets/onboarding/first_run_wizard.h
src/interface/widgets/onboarding/first_run_wizard_page.cpp src/interface/widgets/onboarding/first_run_wizard_page.cpp
@ -454,8 +439,6 @@ 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
@ -532,8 +515,6 @@ qt6_add_executable(
MANUAL_FINALIZATION MANUAL_FINALIZATION
) )
target_precompile_headers(cockatrice PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtwidgets_pch.h")
qt6_add_shaders( qt6_add_shaders(
cockatrice cockatrice
"onboarding_shaders" "onboarding_shaders"
@ -543,7 +524,6 @@ qt6_add_shaders(
"src/interface/widgets/onboarding/shaders" "src/interface/widgets/onboarding/shaders"
FILES FILES
src/interface/widgets/onboarding/shaders/brand_banner.frag src/interface/widgets/onboarding/shaders/brand_banner.frag
src/interface/widgets/onboarding/shaders/brand_plate.frag
) )
qt6_add_resources( qt6_add_resources(
@ -668,35 +648,18 @@ if(WIN32)
set(qtconf_dest_dir .) set(qtconf_dest_dir .)
install( install(
DIRECTORY "$<TARGET_FILE_DIR:cockatrice>/" DIRECTORY "${CMAKE_BINARY_DIR}/${PROJECT_NAME}/${CMAKE_BUILD_TYPE}/"
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
@ -749,6 +712,10 @@ Data = Resources\")
" "
COMPONENT Runtime COMPONENT Runtime
) )
if(OPENSSL_FOUND)
install(FILES ${OPENSSL_INCLUDE_DIRS} DESTINATION ./)
endif()
endif() endif()
if(Qt6LinguistTools_FOUND) if(Qt6LinguistTools_FOUND)

View file

@ -63,8 +63,6 @@
<file>resources/icons/mana/W.svg</file> <file>resources/icons/mana/W.svg</file>
<file>resources/backgrounds/home.png</file> <file>resources/backgrounds/home.png</file>
<file>resources/backgrounds/home-dark.png</file>
<file>resources/backgrounds/home-light.png</file>
<file>resources/backgrounds/card_triplet.svg</file> <file>resources/backgrounds/card_triplet.svg</file>
<file>resources/backgrounds/placeholder_printing_selector.svg</file> <file>resources/backgrounds/placeholder_printing_selector.svg</file>
@ -365,8 +363,6 @@
<file>resources/usericons/pawn_single.svg</file> <file>resources/usericons/pawn_single.svg</file>
<file>resources/usericons/pawn_double.svg</file> <file>resources/usericons/pawn_double.svg</file>
<file>resources/usericons/pawn_dev_single.svg</file>
<file>resources/usericons/pawn_dev_double.svg</file>
<file>resources/usericons/pawn_donator_single.svg</file> <file>resources/usericons/pawn_donator_single.svg</file>
<file>resources/usericons/pawn_donator_double.svg</file> <file>resources/usericons/pawn_donator_double.svg</file>
<file>resources/usericons/pawn_judge_single.svg</file> <file>resources/usericons/pawn_judge_single.svg</file>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 MiB

After

Width:  |  Height:  |  Size: 12 MiB

Before After
Before After

View file

@ -52,7 +52,6 @@ In this list of examples below, each entry has an explanation and can be clicked
<dt><u>E</u>dition:</dt> <dt><u>E</u>dition:</dt>
<dd>[set:lea](#set:lea) <small>(Cards that appear in Alpha, which has the set code LEA)</small></dd> <dd>[set:lea](#set:lea) <small>(Cards that appear in Alpha, which has the set code LEA)</small></dd>
<dd>[e:lea OR e:leb](#e:lea OR e:leb) <small>(Cards that appear in Alpha or Beta)</small></dd> <dd>[e:lea OR e:leb](#e:lea OR e:leb) <small>(Cards that appear in Alpha or Beta)</small></dd>
<dd>[e&lt;8ED](#e<8ED) <small>(Cards that appear before 8th edition)</small></dd>
<dt>Negate:</dt> <dt>Negate:</dt>
<dd>[c:wu -c:m](#c:wu -c:m) <small>(Any card that is white or blue, but not multicolored)</small></dd> <dd>[c:wu -c:m](#c:wu -c:m) <small>(Any card that is white or blue, but not multicolored)</small></dd>

View file

@ -1,343 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="100"
height="100"
id="svg5322"
version="1.1"
inkscape:version="1.4.2 (ebf0e940, 2025-05-08)"
sodipodi:docname="pawn_dev_double.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs3">
<inkscape:perspective
sodipodi:type="inkscape:persp3d"
inkscape:vp_x="0 : 526.18109 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_z="744.09448 : 526.18109 : 1"
inkscape:persp3d-origin="372.04724 : 350.78739 : 1"
id="perspective5328"/>
<inkscape:perspective
id="perspective5305"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d"/>
<linearGradient
id="linearGradient5181">
<stop
style="stop-color:#0fbb00;stop-opacity:1;"
offset="0"
id="stop5183"/>
<stop
style="stop-color:#064400;stop-opacity:1;"
offset="1"
id="stop5185"/>
</linearGradient>
<radialGradient
inkscape:collect="always"
xlink:href="#linearGradient3600-2"
id="radialGradient3606-7"
cx="324.32715"
cy="131.40274"
fx="324.32715"
fy="131.40274"
r="25.501276"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)"/>
<linearGradient
id="linearGradient3600-2">
<stop
style="stop-color:#ffc33d;stop-opacity:1;"
offset="0"
id="stop3602-4"/>
<stop
style="stop-color:#e09900;stop-opacity:1;"
offset="1"
id="stop3604-9"/>
</linearGradient>
<inkscape:perspective
id="perspective5478"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d"/>
<linearGradient
id="linearGradient5189">
<stop
style="stop-color:#000ec9;stop-opacity:1;"
offset="0"
id="stop5191"/>
<stop
style="stop-color:#000657;stop-opacity:1;"
offset="1"
id="stop5193"/>
</linearGradient>
<radialGradient
inkscape:collect="always"
xlink:href="#linearGradient3600-4"
id="radialGradient3606-1"
cx="324.32715"
cy="131.40274"
fx="324.32715"
fy="131.40274"
r="25.501276"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)"/>
<linearGradient
id="linearGradient3600-4">
<stop
style="stop-color:#ffc33d;stop-opacity:1;"
offset="0"
id="stop3602-3"/>
<stop
style="stop-color:#e09900;stop-opacity:1;"
offset="1"
id="stop3604-5"/>
</linearGradient>
<inkscape:perspective
id="perspective5559"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d"/>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient5173"
id="linearGradient5179"
x1="167.33386"
y1="178.83276"
x2="244.78181"
y2="178.83276"
gradientUnits="userSpaceOnUse"/>
<linearGradient
id="linearGradient5173">
<stop
style="stop-color:#f50000;stop-opacity:1;"
offset="0"
id="stop5175"/>
<stop
style="stop-color:#950000;stop-opacity:1;"
offset="1"
id="stop5177"/>
</linearGradient>
<radialGradient
inkscape:collect="always"
xlink:href="#linearGradient3600"
id="radialGradient5169"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)"
cx="324.32715"
cy="131.40274"
fx="324.32715"
fy="131.40274"
r="25.501276"/>
<linearGradient
id="linearGradient3600">
<stop
style="stop-color:#ffc13d;stop-opacity:1;"
offset="0"
id="stop3602"/>
<stop
style="stop-color:#e09900;stop-opacity:1;"
offset="1"
id="stop3604"/>
</linearGradient>
<radialGradient
r="25.501276"
fy="131.40274"
fx="324.32715"
cy="131.40274"
cx="324.32715"
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)"
gradientUnits="userSpaceOnUse"
id="radialGradient5574"
xlink:href="#linearGradient3600"
inkscape:collect="always"/>
<inkscape:perspective
id="perspective5663"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d"/>
<radialGradient
inkscape:collect="always"
xlink:href="#linearGradient3600-7"
id="radialGradient3606-8"
cx="324.32715"
cy="131.40274"
fx="324.32715"
fy="131.40274"
r="25.501276"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)"/>
<linearGradient
id="linearGradient3600-7">
<stop
style="stop-color:#ffc13d;stop-opacity:1;"
offset="0"
id="stop3602-7"/>
<stop
style="stop-color:#e09900;stop-opacity:1;"
offset="1"
id="stop3604-6"/>
</linearGradient>
<radialGradient
inkscape:collect="always"
xlink:href="#linearGradient3600-7"
id="radialGradient5254"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)"
cx="324.32715"
cy="131.40274"
fx="324.32715"
fy="131.40274"
r="25.501276"/>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient5189-1"
id="linearGradient5394"
gradientUnits="userSpaceOnUse"
x1="385.03503"
y1="180.09546"
x2="462.48297"
y2="180.09546"
gradientTransform="matrix(0.96839241,0,0,0.96839241,-360.365,847.52359)"/>
<linearGradient
id="linearGradient5189-1">
<stop
style="stop-color:#000ec9;stop-opacity:1;"
offset="0"
id="stop5191-0"/>
<stop
style="stop-color:#000657;stop-opacity:1;"
offset="1"
id="stop5193-4"/>
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient5173-1"
id="linearGradient5581"
gradientUnits="userSpaceOnUse"
x1="167.33386"
y1="178.83276"
x2="244.78181"
y2="178.83276"
gradientTransform="matrix(0.96839241,0,0,0.96839241,-149.54484,848.74636)"/>
<linearGradient
id="linearGradient5173-1">
<stop
style="stop-color:#f50000;stop-opacity:1;"
offset="0"
id="stop5175-5"/>
<stop
style="stop-color:#950000;stop-opacity:1;"
offset="1"
id="stop5177-3"/>
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient5181-9"
id="linearGradient5782"
gradientUnits="userSpaceOnUse"
x1="282.50455"
y1="181.61069"
x2="359.95248"
y2="181.61069"
gradientTransform="matrix(0.96839241,0,0,0.96839241,-261.07526,846.05625)"/>
<linearGradient
id="linearGradient5181-9">
<stop
style="stop-color:#80d600;stop-opacity:1;"
offset="0"
id="stop5183-3"/>
<stop
style="stop-color:#80d600;stop-opacity:1;"
offset="1"
id="stop5185-0"/>
</linearGradient>
<linearGradient
y2="181.61069"
x2="359.95248"
y1="181.61069"
x1="282.50455"
gradientTransform="matrix(0.96839241,0,0,0.96839241,-175.71812,893.2775)"
gradientUnits="userSpaceOnUse"
id="linearGradient5799"
xlink:href="#linearGradient5181-9"
inkscape:collect="always"/>
</defs>
<sodipodi:namedview
inkscape:document-units="mm"
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="6.0735294"
inkscape:cx="53.757869"
inkscape:cy="53.840194"
inkscape:current-layer="layer1"
showgrid="false"
inkscape:window-width="1696"
inkscape:window-height="1051"
inkscape:window-x="98"
inkscape:window-y="1118"
inkscape:window-maximized="1"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showguides="true">
<sodipodi:guide
position="49.829627,61.114263"
orientation="1,0"
id="guide1"
inkscape:locked="false"/>
</sodipodi:namedview>
<metadata
id="metadata4">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<dc:title/>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-952.36218)">
<path
style="fill-opacity:1;stroke:black;stroke-width:2.78220296;stroke-miterlimit:4;stroke-opacity:1;stroke-dasharray:none"
d="M 49.84375 1.71875 C 36.719738 1.71875 26.0625 12.375988 26.0625 25.5 C 26.0625 32.977454 29.538325 39.612734 34.9375 43.96875 C 24.439951 49.943698 17.919149 62.196126 14.3125 75.65625 C 9.0380874 95.34065 30.224013 98.21875 49.84375 98.21875 C 69.463486 98.21875 90.549327 94.96715 85.375 75.65625 C 81.693381 61.916246 75.224585 49.827177 64.8125 43.9375 C 70.181573 39.580662 73.59375 32.953205 73.59375 25.5 C 73.59375 12.375988 62.967762 1.71875 49.84375 1.71875 z "
transform="translate(0,952.36218)"
id="left"/>
<path
style="opacity:1;fill-opacity:1;fill-rule:nonzero;stroke:#000000;stroke-width:1.73577702;stroke-linecap:butt;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 51.28696,1001.834 0,-46.98372 1.434151,0.16768 c 5.155008,0.60274 9.462857,2.72154 12.938257,6.36366 4.74393,4.9715 6.87913,11.35611 6.16464,18.43328 -0.53702,5.31935 -3.09008,10.59498 -6.83833,14.13074 l -1.94072,1.83069 3.04083,2.20427 c 3.58084,2.5957 7.18975,6.4912 9.55296,10.3116 4.89572,7.9144 9.23593,21.4918 8.50487,26.6055 -0.81312,5.6877 -5.43872,9.6977 -13.62216,11.8093 -3.80822,0.9826 -7.68056,1.4713 -14.763321,1.8633 l -4.471177,0.2474 0,-46.9837 z"
id="right"
inkscape:connector-curvature="0"/>
<path
d="m 478.409,116.617 c -0.368,-4.271 -3.181,-7.94 -7.2,-9.403 -4.029,-1.472 -8.539,-0.47 -11.57,2.556 l -62.015,62.011 -68.749,-21.768 -21.768,-68.748 62.016,-62.016 c 3.035,-3.032 4.025,-7.543 2.563,-11.565 -1.477,-4.03 -5.137,-6.837 -9.417,-7.207 -37.663,-3.245 -74.566,10.202 -101.247,36.887 -36.542,36.545 -46.219,89.911 -29.083,135.399 -1.873,1.578 -3.721,3.25 -5.544,5.053 L 19.386,373.152 c -0.073,0.071 -0.145,0.149 -0.224,0.219 -24.345,24.346 -24.345,63.959 0,88.309 24.349,24.344 63.672,24.048 88.013,-0.298 0.105,-0.098 0.201,-0.196 0.297,-0.305 L 301.104,252.456 c 1.765,-1.773 3.404,-3.628 4.949,-5.532 45.5,17.167 98.9,7.513 135.474,-29.056 26.675,-26.687 40.131,-63.593 36.882,-101.251 z M 75.98,435.38 c -8.971,8.969 -23.5,8.963 -32.47,0 -8.967,-8.961 -8.967,-23.502 0,-32.466 8.97,-8.963 23.499,-8.963 32.47,0 8.967,8.964 8.967,23.505 0,32.466 z"
id="path1"
style="display:inline;fill:#A1A1A1;stroke-width:9.87059588;stroke-dasharray:none;fill-opacity:1;stroke:#000000;stroke-opacity:1"
transform="matrix(0.19145387,0,0,0.19145387,4.0816072,956.99677)"
inkscape:label="wrench" />
</g>
</svg>

Before

Width:  |  Height:  |  Size: 12 KiB

View file

@ -1,211 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="100"
height="100"
id="svg5322"
version="1.1"
inkscape:version="1.4.2 (ebf0e940, 2025-05-08)"
sodipodi:docname="pawn_dev_single.svg"
xml:space="preserve"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/"><defs
id="defs3"><inkscape:perspective
sodipodi:type="inkscape:persp3d"
inkscape:vp_x="0 : 526.18109 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_z="744.09448 : 526.18109 : 1"
inkscape:persp3d-origin="372.04724 : 350.78739 : 1"
id="perspective5328" /><inkscape:perspective
id="perspective5305"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" /><linearGradient
id="linearGradient5181"><stop
style="stop-color:#0fbb00;stop-opacity:1;"
offset="0"
id="stop5183" /><stop
style="stop-color:#064400;stop-opacity:1;"
offset="1"
id="stop5185" /></linearGradient><radialGradient
inkscape:collect="always"
xlink:href="#linearGradient3600-2"
id="radialGradient3606-7"
cx="324.32715"
cy="131.40274"
fx="324.32715"
fy="131.40274"
r="25.501276"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)" /><linearGradient
id="linearGradient3600-2"><stop
style="stop-color:#ffc33d;stop-opacity:1;"
offset="0"
id="stop3602-4" /><stop
style="stop-color:#e09900;stop-opacity:1;"
offset="1"
id="stop3604-9" /></linearGradient><inkscape:perspective
id="perspective5478"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" /><linearGradient
id="linearGradient5189"><stop
style="stop-color:#000ec9;stop-opacity:1;"
offset="0"
id="stop5191" /><stop
style="stop-color:#000657;stop-opacity:1;"
offset="1"
id="stop5193" /></linearGradient><radialGradient
inkscape:collect="always"
xlink:href="#linearGradient3600-4"
id="radialGradient3606-1"
cx="324.32715"
cy="131.40274"
fx="324.32715"
fy="131.40274"
r="25.501276"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)" /><linearGradient
id="linearGradient3600-4"><stop
style="stop-color:#ffc33d;stop-opacity:1;"
offset="0"
id="stop3602-3" /><stop
style="stop-color:#e09900;stop-opacity:1;"
offset="1"
id="stop3604-5" /></linearGradient><inkscape:perspective
id="perspective5559"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" /><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient5173"
id="linearGradient5179"
x1="167.33386"
y1="178.83276"
x2="244.78181"
y2="178.83276"
gradientUnits="userSpaceOnUse" /><linearGradient
id="linearGradient5173"><stop
style="stop-color:#f50000;stop-opacity:1;"
offset="0"
id="stop5175" /><stop
style="stop-color:#950000;stop-opacity:1;"
offset="1"
id="stop5177" /></linearGradient><radialGradient
inkscape:collect="always"
xlink:href="#linearGradient3600"
id="radialGradient5169"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)"
cx="324.32715"
cy="131.40274"
fx="324.32715"
fy="131.40274"
r="25.501276" /><linearGradient
id="linearGradient3600"><stop
style="stop-color:#ffc13d;stop-opacity:1;"
offset="0"
id="stop3602" /><stop
style="stop-color:#e09900;stop-opacity:1;"
offset="1"
id="stop3604" /></linearGradient><radialGradient
r="25.501276"
fy="131.40274"
fx="324.32715"
cy="131.40274"
cx="324.32715"
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)"
gradientUnits="userSpaceOnUse"
id="radialGradient5574"
xlink:href="#linearGradient3600"
inkscape:collect="always" /><inkscape:perspective
id="perspective5663"
inkscape:persp3d-origin="0.5 : 0.33333333 : 1"
inkscape:vp_z="1 : 0.5 : 1"
inkscape:vp_y="0 : 1000 : 0"
inkscape:vp_x="0 : 0.5 : 1"
sodipodi:type="inkscape:persp3d" /><radialGradient
inkscape:collect="always"
xlink:href="#linearGradient3600-7"
id="radialGradient3606-8"
cx="324.32715"
cy="131.40274"
fx="324.32715"
fy="131.40274"
r="25.501276"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)" /><linearGradient
id="linearGradient3600-7"><stop
style="stop-color:#ffc13d;stop-opacity:1;"
offset="0"
id="stop3602-7" /><stop
style="stop-color:#e09900;stop-opacity:1;"
offset="1"
id="stop3604-6" /></linearGradient><radialGradient
r="25.501276"
fy="131.40274"
fx="324.32715"
cy="131.40274"
cx="324.32715"
gradientTransform="matrix(0.92332021,0.38403097,-0.41592401,1.0000002,78.192026,-120.05314)"
gradientUnits="userSpaceOnUse"
id="radialGradient5676"
xlink:href="#linearGradient3600-7"
inkscape:collect="always" /></defs><sodipodi:namedview
inkscape:document-units="mm"
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="3.1582091"
inkscape:cx="104.64791"
inkscape:cy="56.04442"
inkscape:current-layer="layer1"
showgrid="false"
inkscape:window-width="1920"
inkscape:window-height="1027"
inkscape:window-x="0"
inkscape:window-y="25"
inkscape:window-maximized="1"
inkscape:showpageshadow="2"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
showguides="true"><sodipodi:guide
position="50.002551,111.99556"
orientation="1,0"
id="guide3"
inkscape:locked="false" /></sodipodi:namedview><metadata
id="metadata4"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /></cc:Work></rdf:RDF></metadata><g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-952.36218)"
style="display:inline">
<path
style="display:inline;opacity:1;fill-opacity:1;stroke:#000000;stroke-width:2.7822;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 49.84375,1.71875 C 36.719738,1.71875 26.0625,12.375988 26.0625,25.5 c 0,7.477454 3.475825,14.112734 8.875,18.46875 -10.497549,5.974948 -17.018351,18.227376 -20.625,31.6875 -5.2744126,19.6844 15.911513,22.5625 35.53125,22.5625 19.619736,0 40.705577,-3.2516 35.53125,-22.5625 C 81.693381,61.916246 75.224585,49.827177 64.8125,43.9375 70.181573,39.580662 73.59375,32.953205 73.59375,25.5 c 0,-13.124012 -10.625988,-23.78125 -23.75,-23.78125 z"
id="left"
sodipodi:insensitive="true"
transform="translate(0,952.36218)" />
<path
d="m 478.409,116.617 c -0.368,-4.271 -3.181,-7.94 -7.2,-9.403 -4.029,-1.472 -8.539,-0.47 -11.57,2.556 l -62.015,62.011 -68.749,-21.768 -21.768,-68.748 62.016,-62.016 c 3.035,-3.032 4.025,-7.543 2.563,-11.565 -1.477,-4.03 -5.137,-6.837 -9.417,-7.207 -37.663,-3.245 -74.566,10.202 -101.247,36.887 -36.542,36.545 -46.219,89.911 -29.083,135.399 -1.873,1.578 -3.721,3.25 -5.544,5.053 L 19.386,373.152 c -0.073,0.071 -0.145,0.149 -0.224,0.219 -24.345,24.346 -24.345,63.959 0,88.309 24.349,24.344 63.672,24.048 88.013,-0.298 0.105,-0.098 0.201,-0.196 0.297,-0.305 L 301.104,252.456 c 1.765,-1.773 3.404,-3.628 4.949,-5.532 45.5,17.167 98.9,7.513 135.474,-29.056 26.675,-26.687 40.131,-63.593 36.882,-101.251 z M 75.98,435.38 c -8.971,8.969 -23.5,8.963 -32.47,0 -8.967,-8.961 -8.967,-23.502 0,-32.466 8.97,-8.963 23.499,-8.963 32.47,0 8.967,8.964 8.967,23.505 0,32.466 z"
id="path1"
style="display:inline;fill:#A1A1A1;stroke-width:9.87059588;stroke-dasharray:none;fill-opacity:1;stroke:#000000;stroke-opacity:1"
transform="matrix(0.19145387,0,0,0.19145387,4.0816072,956.99677)"
inkscape:label="wrench" /></g></svg>

Before

Width:  |  Height:  |  Size: 9.1 KiB

View file

@ -1,6 +1,5 @@
#include "remote_connection_controller.h" #include "remote_connection_controller.h"
#include "../../../interface/pixel_map_generator.h"
#include "../../settings/cache_settings.h" #include "../../settings/cache_settings.h"
#include "../interface/widgets/dialogs/dlg_connect.h" #include "../interface/widgets/dialogs/dlg_connect.h"
#include "../interface/widgets/dialogs/dlg_forgot_password_challenge.h" #include "../interface/widgets/dialogs/dlg_forgot_password_challenge.h"
@ -181,7 +180,7 @@ void ConnectionController::onServerShutdownEvent(const Event_ServerShutdown &eve
"games will be lost.\nReason for shutdown: %1", "games will be lost.\nReason for shutdown: %1",
"", event.minutes()) "", event.minutes())
.arg(QString::fromStdString(event.reason()))); .arg(QString::fromStdString(event.reason())));
serverShutdownMessageBox.setIconPixmap(themePixmap(QStringLiteral("cockatrice")).scaled(64, 64)); serverShutdownMessageBox.setIconPixmap(QPixmap("theme:cockatrice").scaled(64, 64));
serverShutdownMessageBox.setText(tr("Scheduled server shutdown")); serverShutdownMessageBox.setText(tr("Scheduled server shutdown"));
serverShutdownMessageBox.setWindowModality(Qt::ApplicationModal); serverShutdownMessageBox.setWindowModality(Qt::ApplicationModal);
serverShutdownMessageBox.setVisible(true); serverShutdownMessageBox.setVisible(true);

View file

@ -58,33 +58,33 @@ signals:
void themeChanged(); void themeChanged();
private: private:
QSettings *settings = nullptr; QSettings *settings;
ShortcutsSettings *shortcutsSettings = nullptr; ShortcutsSettings *shortcutsSettings;
CardDatabaseSettings *cardDatabaseSettings = nullptr; CardDatabaseSettings *cardDatabaseSettings;
ServersSettings *serversSettings = nullptr; ServersSettings *serversSettings;
MessageSettings *messageSettings = nullptr; MessageSettings *messageSettings;
GameFiltersSettings *gameFiltersSettings = nullptr; GameFiltersSettings *gameFiltersSettings;
LayoutsSettings *layoutsSettings = nullptr; LayoutsSettings *layoutsSettings;
DownloadSettings *downloadSettings = nullptr; DownloadSettings *downloadSettings;
RecentsSettings *recentsSettings = nullptr; RecentsSettings *recentsSettings;
CardOverrideSettings *cardOverrideSettings = nullptr; CardOverrideSettings *cardOverrideSettings;
DebugSettings *debugSettings = nullptr; DebugSettings *debugSettings;
CardCounterSettings *cardCounterSettings = nullptr; CardCounterSettings *cardCounterSettings;
TabsSettings *tabsSettings = nullptr; TabsSettings *tabsSettings;
SoundSettings *soundSettings = nullptr; SoundSettings *soundSettings;
GameSettings *gameSettings = nullptr; GameSettings *gameSettings;
ChatSettings *chatSettings = nullptr; ChatSettings *chatSettings;
CacheStorageSettings *cacheStorageSettings = nullptr; CacheStorageSettings *cacheStorageSettings;
UpdatesSettings *updatesSettings = nullptr; UpdatesSettings *updatesSettings;
PersonalSettings *personalSettings = nullptr; PersonalSettings *personalSettings;
CardsDisplaySettings *cardsDisplaySettings = nullptr; CardsDisplaySettings *cardsDisplaySettings;
InterfaceSettings *interfaceSettings = nullptr; InterfaceSettings *interfaceSettings;
DeckEditorSettings *deckEditorSettings = nullptr; DeckEditorSettings *deckEditorSettings;
PathsSettings *pathsSettings = nullptr; PathsSettings *pathsSettings;
VisualDeckStorageSettings *visualDeckStorageSettings = nullptr; VisualDeckStorageSettings *visualDeckStorageSettings;
AppearanceSettings *appearanceSettings = nullptr; AppearanceSettings *appearanceSettings;
NetworkSettings *networkSettings = nullptr; NetworkSettings *networkSettings;
CommanderBracketSettings *commanderBracketSettings = nullptr; CommanderBracketSettings *commanderBracketSettings;
QString themeName; QString themeName;

View file

@ -94,7 +94,7 @@ QStringMap &SoundEngine::getAvailableThemes()
QDir dir; QDir dir;
availableThemes.clear(); availableThemes.clear();
// Load themes from user profile dir // load themes from user profile dir
dir.setPath(SettingsCache::instance().getDataPath() + "/sounds"); dir.setPath(SettingsCache::instance().getDataPath() + "/sounds");
@ -104,7 +104,7 @@ QStringMap &SoundEngine::getAvailableThemes()
} }
} }
// Load themes from Cockatrice system dir // load themes from cockatrice system dir
dir.setPath(qApp->applicationDirPath() + dir.setPath(qApp->applicationDirPath() +
#ifdef Q_OS_MAC #ifdef Q_OS_MAC
"/../Resources/sounds" "/../Resources/sounds"

View file

@ -43,12 +43,6 @@ NumericValue <- [0-9]+
static std::once_flag init; static std::once_flag init;
// The peglib parser is a single permanent object, so the rule actions below cannot see
// per-instance state. The card language that the nested [[card name]] search matches
// against is passed through this thread-local context, which is live only while a
// DeckFilterString is being parsed, and copied into the nested FilterString closures.
thread_local CardSearchLanguage deckSearchLanguageContext;
static void setupParserRules() static void setupParserRules()
{ {
// plumbing // plumbing
@ -122,7 +116,7 @@ static void setupParserRules()
// actual functionality // actual functionality
search["DeckContentQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter { search["DeckContentQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter {
auto cardFilter = FilterString(std::any_cast<QString>(sv[0]), deckSearchLanguageContext); auto cardFilter = FilterString(std::any_cast<QString>(sv[0]));
auto numberMatcher = sv.size() > 1 ? std::any_cast<NumberMatcher>(sv[1]) : [](int count) { return count > 0; }; auto numberMatcher = sv.size() > 1 ? std::any_cast<NumberMatcher>(sv[1]) : [](int count) { return count > 0; };
return [=](const DeckSearchData &data) -> bool { return [=](const DeckSearchData &data) -> bool {
@ -192,7 +186,7 @@ DeckFilterString::DeckFilterString()
_error = "Not initialized"; _error = "Not initialized";
} }
DeckFilterString::DeckFilterString(const QString &expr, const CardSearchLanguage &searchLanguage) DeckFilterString::DeckFilterString(const QString &expr)
{ {
QByteArray ba = expr.simplified().toUtf8(); QByteArray ba = expr.simplified().toUtf8();
@ -205,8 +199,6 @@ DeckFilterString::DeckFilterString(const QString &expr, const CardSearchLanguage
return; return;
} }
deckSearchLanguageContext = searchLanguage;
search.set_logger([&](size_t /*ln*/, size_t col, const std::string &msg) { search.set_logger([&](size_t /*ln*/, size_t col, const std::string &msg) {
_error = QString("Error at position %1: %2").arg(col).arg(QString::fromStdString(msg)); _error = QString("Error at position %1: %2").arg(col).arg(QString::fromStdString(msg));
}); });

View file

@ -12,7 +12,6 @@
#include <QLoggingCategory> #include <QLoggingCategory>
#include <QString> #include <QString>
#include <functional> #include <functional>
#include <libcockatrice/card/card_localization.h>
inline Q_LOGGING_CATEGORY(DeckFilterStringLog, "deck_filter_string"); inline Q_LOGGING_CATEGORY(DeckFilterStringLog, "deck_filter_string");
@ -36,7 +35,7 @@ class DeckFilterString
{ {
public: public:
DeckFilterString(); DeckFilterString();
explicit DeckFilterString(const QString &expr, const CardSearchLanguage &searchLanguage = {}); explicit DeckFilterString(const QString &expr);
bool check(const DeckSearchData &data) const bool check(const DeckSearchData &data) const
{ {
return filter(data); return filter(data);

View file

@ -1,6 +1,5 @@
#include "filter_builder.h" #include "filter_builder.h"
#include "../interface/pixel_map_generator.h"
#include "../interface/widgets/utility/custom_line_edit.h" #include "../interface/widgets/utility/custom_line_edit.h"
#include <QComboBox> #include <QComboBox>
@ -22,7 +21,7 @@ FilterBuilder::FilterBuilder(QWidget *parent) : QWidget(parent)
typeCombo->addItem(CardFilter::typeName(static_cast<CardFilter::Type>(i)), QVariant(i)); typeCombo->addItem(CardFilter::typeName(static_cast<CardFilter::Type>(i)), QVariant(i));
} }
QPushButton *ok = new QPushButton(themePixmap(QStringLiteral("icons/increment")), QString()); QPushButton *ok = new QPushButton(QPixmap("theme:icons/increment"), QString());
ok->setObjectName("ok"); ok->setObjectName("ok");
ok->setMaximumSize(20, 20); ok->setMaximumSize(20, 20);

View file

@ -13,12 +13,12 @@ CounterState *CounterState::fromProto(const ServerInfo_Counter &counter, QObject
convertColorToQColor(counter.counter_color()), counter.radius(), counter.count(), parent); convertColorToQColor(counter.counter_color()), counter.radius(), counter.count(), parent);
} }
void CounterState::setValue(int newValue, bool skipDamageAnimation) void CounterState::setValue(int newValue)
{ {
if (newValue == value) { if (newValue == value) {
return; return;
} }
int old = value; int old = value;
value = newValue; value = newValue;
emit valueChanged(old, newValue, skipDamageAnimation); emit valueChanged(old, newValue);
} }

View file

@ -35,23 +35,10 @@ public:
return value; return value;
} }
/** void setValue(int newValue);
* @brief Set the counter value.
* @param newValue The new value.
* @param skipDamageAnimation When true, valueChanged is emitted with skipDamageAnimation=true, letting views
* suppress damage-related feedback (e.g. battlefield shimmer, life counter flash) for values set during replay
* rewinds.
*/
void setValue(int newValue, bool skipDamageAnimation = false);
signals: signals:
/** void valueChanged(int oldValue, int newValue);
* @brief Emitted whenever the value changes.
* @param oldValue The previous value.
* @param newValue The new value.
* @param skipDamageAnimation True when the change should not trigger damage/life-change feedback in views.
*/
void valueChanged(int oldValue, int newValue, bool skipDamageAnimation);
private: private:
int id; int id;

View file

@ -430,13 +430,12 @@ void GameEventHandler::eventJoin(const Event_Join &event, int /*eventPlayerId*/,
QString playerName = QString::fromStdString(playerInfo.user_info().name()); QString playerName = QString::fromStdString(playerInfo.user_info().name());
emit addPlayerToAutoCompleteList(playerName); emit addPlayerToAutoCompleteList(playerName);
PlayerManager *playerManager = game->getPlayerManager(); if (game->getPlayerManager()->getPlayers().contains(playerId)) {
if (playerManager->getPlayers().contains(playerId) || playerManager->getSpectators().contains(playerId)) {
return; return;
} }
if (playerInfo.spectator()) { if (playerInfo.spectator()) {
playerManager->addSpectator(playerId, playerInfo); game->getPlayerManager()->addSpectator(playerId, playerInfo);
emit logJoinSpectator(playerName); emit logJoinSpectator(playerName);
emit spectatorJoined(playerInfo); emit spectatorJoined(playerInfo);
} else { } else {

View file

@ -13,8 +13,7 @@
enum EventProcessingOption enum EventProcessingOption
{ {
SKIP_REVEAL_WINDOW = 0x0001, SKIP_REVEAL_WINDOW = 0x0001,
SKIP_TAP_ANIMATION = 0x0002, SKIP_TAP_ANIMATION = 0x0002
SKIP_DAMAGE_ANIMATION = 0x0004
}; };
// Wrap it in a QFlags typedef // Wrap it in a QFlags typedef

View file

@ -262,15 +262,14 @@ void PlayerEventHandler::eventCreateCounter(const Event_CreateCounter &event)
player->addCounter(event.counter_info()); player->addCounter(event.counter_info());
} }
void PlayerEventHandler::eventSetCounter(const Event_SetCounter &event, EventProcessingOptions options) void PlayerEventHandler::eventSetCounter(const Event_SetCounter &event)
{ {
CounterState *ctr = player->getCounters().value(event.counter_id(), nullptr); CounterState *ctr = player->getCounters().value(event.counter_id(), nullptr);
if (!ctr) { if (!ctr) {
return; return;
} }
int oldValue = ctr->getValue(); int oldValue = ctr->getValue();
const bool skipDamageAnimation = options.testFlag(SKIP_DAMAGE_ANIMATION); ctr->setValue(event.value());
ctr->setValue(event.value(), skipDamageAnimation);
emit logSetCounter(player, ctr->getName(), event.value(), oldValue); emit logSetCounter(player, ctr->getName(), event.value(), oldValue);
} }
@ -626,7 +625,7 @@ void PlayerEventHandler::processGameEvent(GameEvent::GameEventType type,
eventCreateCounter(event.GetExtension(Event_CreateCounter::ext)); eventCreateCounter(event.GetExtension(Event_CreateCounter::ext));
break; break;
case GameEvent::SET_COUNTER: case GameEvent::SET_COUNTER:
eventSetCounter(event.GetExtension(Event_SetCounter::ext), options); eventSetCounter(event.GetExtension(Event_SetCounter::ext));
break; break;
case GameEvent::DEL_COUNTER: case GameEvent::DEL_COUNTER:
eventDelCounter(event.GetExtension(Event_DelCounter::ext)); eventDelCounter(event.GetExtension(Event_DelCounter::ext));

View file

@ -153,7 +153,7 @@ public:
void eventCreateCounter(const Event_CreateCounter &event); void eventCreateCounter(const Event_CreateCounter &event);
/// Set a player-level counter value. /// Set a player-level counter value.
void eventSetCounter(const Event_SetCounter &event, EventProcessingOptions options); void eventSetCounter(const Event_SetCounter &event);
/// Delete a player-level counter. /// Delete a player-level counter.
void eventDelCounter(const Event_DelCounter &event); void eventDelCounter(const Event_DelCounter &event);

View file

@ -175,15 +175,7 @@ void PlayerLogic::processPlayerInfo(const ServerInfo_Player &info)
const ServerInfo_Card &cardInfo = zoneInfo.card_list(j); const ServerInfo_Card &cardInfo = zoneInfo.card_list(j);
auto *card = new CardItem(this); auto *card = new CardItem(this);
card->processCardInfo(cardInfo); card->processCardInfo(cardInfo);
// Zones without coordinates (hand, piles, stack) preserve the order
// they arrive in on the server in the positions of their cards list.
// The x coordinate of such cards is always 0, so inserting at it
// would reverse the list on reconnect. Append instead.
if (zoneInfo.with_coords()) {
zone->addCard(card, false, cardInfo.x(), cardInfo.y()); zone->addCard(card, false, cardInfo.x(), cardInfo.y());
} else {
zone->addCard(card, false, -1);
}
} }
} }
if (zoneInfo.has_always_reveal_top_card()) { if (zoneInfo.has_always_reveal_top_card()) {

View file

@ -75,14 +75,6 @@ PlayerLogic *PlayerManager::getPlayer(int playerId) const
return player; return player;
} }
void PlayerManager::clearSpectators()
{
const QList<int> spectatorIds = spectators.keys();
for (int spectatorId : spectatorIds) {
removeSpectator(spectatorId);
}
}
void PlayerManager::onPlayerConceded(int playerId, bool conceded) void PlayerManager::onPlayerConceded(int playerId, bool conceded)
{ {
// Everything else cares about this // Everything else cares about this

View file

@ -100,9 +100,6 @@ public:
emit spectatorRemoved(spectatorId, spectatorInfo); emit spectatorRemoved(spectatorId, spectatorInfo);
} }
/** @brief Remove all spectators, emitting the removal signal for each. */
void clearSpectators();
[[nodiscard]] AbstractGame *getGame() const [[nodiscard]] AbstractGame *getGame() const
{ {
return game; return game;

View file

@ -1,7 +1,6 @@
#include "abstract_card_item.h" #include "abstract_card_item.h"
#include "../../client/settings/cache_settings.h" #include "../../client/settings/cache_settings.h"
#include "../../interface/card_localization.h"
#include "../../interface/card_picture_loader/card_picture_loader.h" #include "../../interface/card_picture_loader/card_picture_loader.h"
#include "../game_scene.h" #include "../game_scene.h"
#include "../z_values.h" #include "../z_values.h"
@ -27,8 +26,6 @@ AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::displayCardNamesChanged, this, connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::displayCardNamesChanged, this,
[this] { update(); }); [this] { update(); });
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this,
[this] { update(); });
refreshCardInfo(); refreshCardInfo();
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::roundCardCornersChanged, this, connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::roundCardCornersChanged, this,
@ -174,7 +171,7 @@ void AbstractCardItem::paintPicture(QPainter *painter, const QSizeF &translatedS
if (SettingsCache::instance().debug().getShowCardId()) { if (SettingsCache::instance().debug().getShowCardId()) {
prefix = "#" + QString::number(id) + " "; prefix = "#" + QString::number(id) + " ";
} }
nameStr = prefix + CardLocalization::displayName(getCardInfo()); nameStr = prefix + cardRef.name;
} }
painter->drawText(QRectF(3 * scaleFactor, 3 * scaleFactor, translatedSize.width() - 6 * scaleFactor, painter->drawText(QRectF(3 * scaleFactor, 3 * scaleFactor, translatedSize.width() - 6 * scaleFactor,
translatedSize.height() - 6 * scaleFactor), translatedSize.height() - 6 * scaleFactor),

View file

@ -29,9 +29,9 @@ AbstractCounter::AbstractCounter(CounterState *state,
{ {
setAcceptHoverEvents(true); setAcceptHoverEvents(true);
connect(state, &CounterState::valueChanged, this, [this](int oldValue, int newValue, bool skipDamageAnimation) { connect(state, &CounterState::valueChanged, this, [this](int oldValue, int newValue) {
value = newValue; value = newValue;
onValueChanged(oldValue, newValue, skipDamageAnimation); onValueChanged(oldValue, newValue);
update(); update();
}); });
@ -230,7 +230,7 @@ void AbstractCounterDialog::changeValue(int diff)
setTextValue(QString::number(curValue)); setTextValue(QString::number(curValue));
} }
void AbstractCounter::onValueChanged(int /*oldValue*/, int /*newValue*/, bool /*skipDamageAnimation*/) void AbstractCounter::onValueChanged(int /*oldValue*/, int /*newValue*/)
{ {
// Default: no feedback. Subclasses such as PlayerCounter override this to // Default: no feedback. Subclasses such as PlayerCounter override this to
// flash the counter on meaningful changes (life gain/loss). // flash the counter on meaningful changes (life gain/loss).

View file

@ -39,9 +39,8 @@ protected:
* @brief Hook for subclasses that need per-value-change feedback (e.g. life-total flash). * @brief Hook for subclasses that need per-value-change feedback (e.g. life-total flash).
* *
* Called whenever the counter's value changes, before the item repaints. * Called whenever the counter's value changes, before the item repaints.
* @param skipDamageAnimation True when damage-related feedback should be suppressed (replay rewinds).
*/ */
virtual void onValueChanged(int oldValue, int newValue, bool skipDamageAnimation); virtual void onValueChanged(int oldValue, int newValue);
void mousePressEvent(QGraphicsSceneMouseEvent *event) override; void mousePressEvent(QGraphicsSceneMouseEvent *event) override;
void hoverEnterEvent(QGraphicsSceneHoverEvent *event) override; void hoverEnterEvent(QGraphicsSceneHoverEvent *event) override;

View file

@ -316,7 +316,7 @@ void CardItem::drawAttachArrow()
for (const auto &item : scene()->selectedItems()) { for (const auto &item : scene()->selectedItems()) {
CardItem *card = qgraphicsitem_cast<CardItem *>(item); CardItem *card = qgraphicsitem_cast<CardItem *>(item);
if (card == nullptr || card == this) { if (card == nullptr) {
continue; continue;
} }
if (card->getZone() != state->getZone()) { if (card->getZone() != state->getZone()) {

View file

@ -10,6 +10,7 @@
#include <algorithm> #include <algorithm>
#include <libcockatrice/card/card_info.h> #include <libcockatrice/card/card_info.h>
#include <libcockatrice/deck_list/deck_list.h> #include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/deck_list/tree/deck_list_card_node.h>
#include <libcockatrice/settings/cards_display_settings.h> #include <libcockatrice/settings/cards_display_settings.h>
DeckViewCardDragItem::DeckViewCardDragItem(DeckViewCard *_item, DeckViewCardDragItem::DeckViewCardDragItem(DeckViewCard *_item,
@ -380,10 +381,12 @@ void DeckViewScene::rebuildTree()
addItem(container); addItem(container);
} }
// Cards in custom zones nested under a board are regular board cards in-game. for (int j = 0; j < currentZone->size(); j++) {
// They are collected recursively (like every other consumer) and reported with auto *currentCard = dynamic_cast<DecklistCardNode *>(currentZone->at(j));
// the top-level board zone as their origin, so that sideboard plans keep working. if (!currentCard) {
for (auto *currentCard : deck->getCardNodes({currentZone->getName()})) { continue;
}
for (int k = 0; k < currentCard->getNumber(); ++k) { for (int k = 0; k < currentCard->getNumber(); ++k) {
auto *newCard = new DeckViewCard(container, currentCard->toCardRef(), currentZone->getName()); auto *newCard = new DeckViewCard(container, currentCard->toCardRef(), currentZone->getName());
container->addCard(newCard); container->addCard(newCard);

View file

@ -16,13 +16,11 @@
#include <QLineEdit> #include <QLineEdit>
#include <QRadioButton> #include <QRadioButton>
#include <QTreeView> #include <QTreeView>
#include <libcockatrice/card/card_localization.h>
#include <libcockatrice/card/database/card_database_manager.h> #include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/deck_list.h> #include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/models/database/card_database_model.h> #include <libcockatrice/models/database/card_database_model.h>
#include <libcockatrice/models/database/token/token_display_model.h> #include <libcockatrice/models/database/token/token_display_model.h>
#include <libcockatrice/settings/card_override_settings.h> #include <libcockatrice/settings/card_override_settings.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/interface_settings.h> #include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/settings/layouts_settings.h> #include <libcockatrice/settings/layouts_settings.h>
#include <libcockatrice/utility/string_limits.h> #include <libcockatrice/utility/string_limits.h>
@ -90,17 +88,6 @@ DlgCreateToken::DlgCreateToken(const QStringList &_predefinedTokens, QWidget *pa
cardDatabaseDisplayModel = new TokenDisplayModel(this); cardDatabaseDisplayModel = new TokenDisplayModel(this);
cardDatabaseDisplayModel->setSourceModel(cardDatabaseModel); cardDatabaseDisplayModel->setSourceModel(cardDatabaseModel);
const auto applyCardSearchLanguage = [this]() {
const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay();
cardDatabaseDisplayModel->setSearchLanguage(CardSearchLanguage{
cardsDisplay.getCardLang(), static_cast<SearchLanguageMode>(cardsDisplay.getCardSearchLanguage())});
};
applyCardSearchLanguage();
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this,
applyCardSearchLanguage);
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardSearchLanguageChanged, this,
applyCardSearchLanguage);
chooseTokenFromAllRadioButton = new QRadioButton(tr("Show &all tokens")); chooseTokenFromAllRadioButton = new QRadioButton(tr("Show &all tokens"));
connect(chooseTokenFromAllRadioButton, &QRadioButton::toggled, this, &DlgCreateToken::actChooseTokenFromAll); connect(chooseTokenFromAllRadioButton, &QRadioButton::toggled, this, &DlgCreateToken::actChooseTokenFromAll);
chooseTokenFromDeckRadioButton = new QRadioButton(tr("Show tokens from this &deck")); chooseTokenFromDeckRadioButton = new QRadioButton(tr("Show tokens from this &deck"));

View file

@ -44,16 +44,11 @@ GameScene::GameScene(PhasesToolbar *_phasesToolbar, QObject *parent)
GameScene::~GameScene() GameScene::~GameScene()
{ {
// Sever all destroyed->removeAnimatedItem connections before the members below // Sever all incoming connections (animated item destroy-tracking) before the
// are destroyed: the base QGraphicsScene destructor destroys the remaining items, // members below are destroyed: the base QGraphicsScene destructor destroys the
// and their destroyed() signals must not reach slots that reference members that // remaining items, and their destroyed() signals must not reach slots that
// no longer exist. The connection handle overload is used because the string-based // reference members that no longer exist.
// disconnect(nullptr, nullptr, this, nullptr) is invalid (the sender must never be disconnect(this);
// nullptr) and would otherwise fail to sever these pointer-to-member connections.
for (auto it = animationItemConnections.constBegin(); it != animationItemConnections.constEnd(); ++it) {
QObject::disconnect(*it);
}
animationItemConnections.clear();
delete animationTimer; delete animationTimer;
animationTimer = nullptr; animationTimer = nullptr;
@ -221,12 +216,7 @@ void GameScene::removePlayer(PlayerLogic *player)
clearArrowsForPlayer(player->getPlayerInfo()->getId()); clearArrowsForPlayer(player->getPlayerInfo()->getId());
// Closing a view removes it from zoneViews synchronously, so iterate over a for (ZoneViewWidget *zone : zoneViews) {
// copy: otherwise a player with several open views (e.g. library and hand)
// only has the first one closed here and the remaining views are left
// pointing at a player that is about to be deleted.
const QList<ZoneViewWidget *> zoneViewCopy = zoneViews;
for (ZoneViewWidget *zone : zoneViewCopy) {
if (zone->getPlayer() == player) { if (zone->getPlayer() == player) {
zone->close(); zone->close();
} }
@ -669,10 +659,7 @@ CardItem *GameScene::findTopmostCardInZone(const QList<QGraphicsItem *> &items,
*/ */
void GameScene::toggleZoneView(PlayerLogic *player, const QString &zoneName, int numberCards, bool isReversed) void GameScene::toggleZoneView(PlayerLogic *player, const QString &zoneName, int numberCards, bool isReversed)
{ {
// Closing a view removes it from zoneViews synchronously, so iterate over a for (auto &view : zoneViews) {
// copy to make sure every already-open matching view is closed.
const QList<ZoneViewWidget *> zoneViewCopy = zoneViews;
for (auto *view : zoneViewCopy) {
ZoneViewZone *temp = view->getZone(); ZoneViewZone *temp = view->getZone();
if (temp->getLogic()->getName() == zoneName && temp->getLogic()->getPlayer() == player && if (temp->getLogic()->getName() == zoneName && temp->getLogic()->getPlayer() == player &&
qobject_cast<ZoneViewZoneLogic *>(temp->getLogic())->getNumberCards() == numberCards) { qobject_cast<ZoneViewZoneLogic *>(temp->getLogic())->getNumberCards() == numberCards) {
@ -790,15 +777,8 @@ void GameScene::registerAnimationItem(IAnimatedItem *item)
if (!object) { if (!object) {
return; return;
} }
// Guard against duplicate connections using the connection map, not if (!animatedItems.contains(object)) {
// animatedItems: the animation timer removes entries from animatedItems when an connect(object, &QObject::destroyed, this, &GameScene::removeAnimatedItem);
// animation completes, but the destroyed->removeAnimatedItem connection must
// persist until the object is destroyed. Relying on animatedItems here would let
// a re-registered item (e.g. a life counter that flashes repeatedly) accumulate
// duplicate destroyed connections, the older ones of which would survive teardown.
if (!animationItemConnections.contains(object)) {
animationItemConnections.insert(object,
connect(object, &QObject::destroyed, this, &GameScene::removeAnimatedItem));
} }
animatedItems.insert(object, item); animatedItems.insert(object, item);
if (animationTimer && !animationTimer->isActive()) { if (animationTimer && !animationTimer->isActive()) {
@ -817,7 +797,6 @@ void GameScene::unregisterAnimationItem(IAnimatedItem *item)
void GameScene::removeAnimatedItem(QObject *item) void GameScene::removeAnimatedItem(QObject *item)
{ {
animatedItems.remove(item); animatedItems.remove(item);
animationItemConnections.remove(item);
if (animationTimer && animatedItems.isEmpty()) { if (animationTimer && animatedItems.isEmpty()) {
animationTimer->stop(); animationTimer->stop();
} }

View file

@ -54,8 +54,6 @@ private:
QPointer<CardItem> hoveredCard; ///< Currently hovered card QPointer<CardItem> hoveredCard; ///< Currently hovered card
QBasicTimer *animationTimer; ///< Timer for scene animations QBasicTimer *animationTimer; ///< Timer for scene animations
QHash<QObject *, IAnimatedItem *> animatedItems; ///< Items currently animating QHash<QObject *, IAnimatedItem *> animatedItems; ///< Items currently animating
QHash<QObject *, QMetaObject::Connection>
animationItemConnections; ///< destroyed->removeAnimatedItem handles per animated item
int playerRotation; ///< Rotation offset for player layout int playerRotation; ///< Rotation offset for player layout
bool rearranging = false; ///< Guard against re-entrant rearrange bool rearranging = false; ///< Guard against re-entrant rearrange
bool needsReArrange = false; ///< Pending rearrange requested during a pass bool needsReArrange = false; ///< Pending rearrange requested during a pass

View file

@ -1,6 +1,5 @@
#include "hand_counter.h" #include "hand_counter.h"
#include "../interface/pixel_map_generator.h"
#include "zones/card_zone.h" #include "zones/card_zone.h"
#include <QGraphicsSceneMouseEvent> #include <QGraphicsSceneMouseEvent>
@ -33,8 +32,7 @@ void HandCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*op
QSize translatedSize = painter->combinedTransform().mapRect(boundingRect()).size().toSize(); QSize translatedSize = painter->combinedTransform().mapRect(boundingRect()).size().toSize();
QPixmap cachedPixmap; QPixmap cachedPixmap;
if (!QPixmapCache::find("handCounter" + QString::number(translatedSize.width()), &cachedPixmap)) { if (!QPixmapCache::find("handCounter" + QString::number(translatedSize.width()), &cachedPixmap)) {
cachedPixmap = cachedPixmap = QPixmap("theme:hand").scaled(translatedSize, Qt::KeepAspectRatio, Qt::SmoothTransformation);
themePixmap(QStringLiteral("hand")).scaled(translatedSize, Qt::KeepAspectRatio, Qt::SmoothTransformation);
QPixmapCache::insert("handCounter" + QString::number(translatedSize.width()), cachedPixmap); QPixmapCache::insert("handCounter" + QString::number(translatedSize.width()), cachedPixmap);
} }
resetPainterTransform(painter); resetPainterTransform(painter);

View file

@ -12,13 +12,11 @@ TallyMenu::TallyMenu()
aTallyNone = createTallyAction(TallyType::None); aTallyNone = createTallyAction(TallyType::None);
aTallySubtypes = createTallyAction(TallyType::Subtypes); aTallySubtypes = createTallyAction(TallyType::Subtypes);
aTallyTotalPower = createTallyAction(TallyType::TotalPower); aTallyTotalPower = createTallyAction(TallyType::TotalPower);
aTallyTotalToughness = createTallyAction(TallyType::TotalToughness);
addAction(aTallyNone); addAction(aTallyNone);
addSeparator(); addSeparator();
addAction(aTallySubtypes); addAction(aTallySubtypes);
addAction(aTallyTotalPower); addAction(aTallyTotalPower);
addAction(aTallyTotalToughness);
retranslateUi(); retranslateUi();
} }
@ -56,5 +54,4 @@ void TallyMenu::retranslateUi()
aTallyNone->setText(tr("None")); aTallyNone->setText(tr("None"));
aTallySubtypes->setText(tr("Subtypes")); aTallySubtypes->setText(tr("Subtypes"));
aTallyTotalPower->setText(tr("Total Power")); aTallyTotalPower->setText(tr("Total Power"));
aTallyTotalToughness->setText(tr("Total Toughness"));
} }

View file

@ -24,7 +24,6 @@ private:
QAction *aTallyNone = nullptr; QAction *aTallyNone = nullptr;
QAction *aTallySubtypes = nullptr; QAction *aTallySubtypes = nullptr;
QAction *aTallyTotalPower = nullptr; QAction *aTallyTotalPower = nullptr;
QAction *aTallyTotalToughness = nullptr;
QAction *createTallyAction(TallyType tallyType); QAction *createTallyAction(TallyType tallyType);
}; };

View file

@ -3,7 +3,6 @@
#include "../../game/player/player_actions.h" #include "../../game/player/player_actions.h"
#include "../../interface/card_picture_loader/card_picture_loader.h" #include "../../interface/card_picture_loader/card_picture_loader.h"
#include "../../interface/widgets/cards/art_crop_attribution.h" #include "../../interface/widgets/cards/art_crop_attribution.h"
#include "../../interface/widgets/cards/card_art_utils.h"
#include "../../interface/widgets/playmat/playmat_utils.h" #include "../../interface/widgets/playmat/playmat_utils.h"
#include "../../interface/widgets/tabs/tab_game.h" #include "../../interface/widgets/tabs/tab_game.h"
#include "../board/abstract_card_item.h" #include "../board/abstract_card_item.h"
@ -252,8 +251,8 @@ void PlayerGraphicsItem::onCounterAdded(CounterState *state)
AbstractCounter *widget; AbstractCounter *widget;
if (state->getName() == "life") { if (state->getName() == "life") {
widget = playerTarget->addCounter(state); widget = playerTarget->addCounter(state);
connect(state, &CounterState::valueChanged, this, [this](int oldValue, int newValue, bool skipDamageAnimation) { connect(state, &CounterState::valueChanged, this, [this](int oldValue, int newValue) {
if (newValue < oldValue && !skipDamageAnimation) { if (newValue < oldValue) {
tableZoneGraphicsItem->triggerDamageShimmer(); tableZoneGraphicsItem->triggerDamageShimmer();
} }
}); });
@ -443,7 +442,7 @@ void PlayerGraphicsItem::updatePlaymat()
hasPlaymat = true; hasPlaymat = true;
emit playmatChanged(true); emit playmatChanged(true);
} }
playmatPixmap = CardArtUtils::rotateSidewaysLayoutArt(fullRes, card); playmatPixmap = fullRes;
update(); update();
} }

View file

@ -53,13 +53,13 @@ PlayerListWidget::PlayerListWidget(TabSupervisor *_tabSupervisor,
QWidget *parent) QWidget *parent)
: QTreeWidget(parent), tabSupervisor(_tabSupervisor), client(_client), game(_game), gameStarted(false) : QTreeWidget(parent), tabSupervisor(_tabSupervisor), client(_client), game(_game), gameStarted(false)
{ {
readyIcon = themePixmap(QStringLiteral("icons/ready_start")); readyIcon = QPixmap("theme:icons/ready_start");
notReadyIcon = themePixmap(QStringLiteral("icons/not_ready_start")); notReadyIcon = QPixmap("theme:icons/not_ready_start");
concededIcon = themePixmap(QStringLiteral("icons/conceded")); concededIcon = QPixmap("theme:icons/conceded");
playerIcon = loadColorAdjustedPixmap("theme:icons/player"); playerIcon = loadColorAdjustedPixmap("theme:icons/player");
judgeIcon = loadColorAdjustedPixmap("theme:icons/scales"); judgeIcon = loadColorAdjustedPixmap("theme:icons/scales");
spectatorIcon = loadColorAdjustedPixmap("theme:icons/spectator"); spectatorIcon = loadColorAdjustedPixmap("theme:icons/spectator");
lockIcon = themePixmap(QStringLiteral("icons/lock")); lockIcon = QPixmap("theme:icons/lock");
if (tabSupervisor) { if (tabSupervisor) {
itemDelegate = new PlayerListItemDelegate(this); itemDelegate = new PlayerListItemDelegate(this);
@ -92,11 +92,6 @@ void PlayerListWidget::retranslateUi()
void PlayerListWidget::addPlayer(const ServerInfo_PlayerProperties &player) void PlayerListWidget::addPlayer(const ServerInfo_PlayerProperties &player)
{ {
if (players.contains(player.player_id())) {
updatePlayerProperties(player);
return;
}
QTreeWidgetItem *newPlayer = new PlayerListTWI; QTreeWidgetItem *newPlayer = new PlayerListTWI;
players.insert(player.player_id(), newPlayer); players.insert(player.player_id(), newPlayer);
updatePlayerProperties(player); updatePlayerProperties(player);
@ -181,17 +176,6 @@ void PlayerListWidget::removePlayer(int playerId)
delete takeTopLevelItem(indexOfTopLevelItem(player)); delete takeTopLevelItem(indexOfTopLevelItem(player));
} }
void PlayerListWidget::clearSpectators()
{
const QList<int> playerIds = players.keys();
for (int playerId : playerIds) {
QTreeWidgetItem *player = players.value(playerId, 0);
if (player && !player->data(1, Qt::UserRole).toBool()) {
removePlayer(playerId);
}
}
}
void PlayerListWidget::setActivePlayer(int playerId) void PlayerListWidget::setActivePlayer(int playerId)
{ {
QMapIterator<int, QTreeWidgetItem *> i(players); QMapIterator<int, QTreeWidgetItem *> i(players);

View file

@ -66,7 +66,6 @@ public slots:
void addPlayer(const ServerInfo_PlayerProperties &player); void addPlayer(const ServerInfo_PlayerProperties &player);
void removePlayer(int playerId); void removePlayer(int playerId);
void updatePlayerProperties(const ServerInfo_PlayerProperties &prop, int playerId = -1); void updatePlayerProperties(const ServerInfo_PlayerProperties &prop, int playerId = -1);
void clearSpectators();
}; };
#endif #endif

View file

@ -69,7 +69,7 @@ void PlayerCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*
} }
} }
void PlayerCounter::onValueChanged(int oldValue, int newValue, bool skipDamageAnimation) void PlayerCounter::onValueChanged(int oldValue, int newValue)
{ {
flashDelta = newValue - oldValue; flashDelta = newValue - oldValue;
if (flashDelta == 0) { if (flashDelta == 0) {
@ -81,11 +81,6 @@ void PlayerCounter::onValueChanged(int oldValue, int newValue, bool skipDamageAn
return; return;
} }
if (skipDamageAnimation) {
flashAlpha = 0.0;
return;
}
flashAlpha = 1.0; flashAlpha = 1.0;
flashClock.start(); flashClock.start();
if (scene()) { if (scene()) {
@ -137,18 +132,8 @@ void PlayerTarget::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*o
QRectF translatedRect = painter->combinedTransform().mapRect(avatarBoundingRect); QRectF translatedRect = painter->combinedTransform().mapRect(avatarBoundingRect);
QSize translatedSize = translatedRect.size().toSize(); QSize translatedSize = translatedRect.size().toSize();
QPixmap cachedPixmap; QPixmap cachedPixmap;
// The key must cover everything the generated pawn depends on: the rendered
// size, the user level, and the pixmap being drawn. fullPixmap.cacheKey() is
// 0 for every null pixmap, so the default-pawn branch additionally needs the
// pawn's privlevel (lowercased, matching UserLevelPixmapGenerator) and colors
// in the key — otherwise two players without a custom avatar (and the same
// user level) would share one cached pawn.
const QString cacheKey = "avatar" + QString::number(translatedSize.width()) + "_" + const QString cacheKey = "avatar" + QString::number(translatedSize.width()) + "_" +
QString::number(translatedSize.height()) + "_" + QString::number(info->user_level()) + QString::number(info->user_level()) + "_" + QString::number(fullPixmap.cacheKey());
"_" + QString::number(fullPixmap.cacheKey()) + "_" +
QString::fromStdString(info->privlevel()).toLower() + "_" +
QString::fromStdString(info->pawn_colors().left_side()) + "_" +
QString::fromStdString(info->pawn_colors().right_side());
if (!QPixmapCache::find(cacheKey, &cachedPixmap)) { if (!QPixmapCache::find(cacheKey, &cachedPixmap)) {
cachedPixmap = QPixmap(translatedSize.width(), translatedSize.height()); cachedPixmap = QPixmap(translatedSize.width(), translatedSize.height());

View file

@ -21,7 +21,7 @@ class PlayerCounter : public AbstractCounter, public IAnimatedItem
{ {
Q_OBJECT Q_OBJECT
protected: protected:
void onValueChanged(int oldValue, int newValue, bool skipDamageAnimation) override; void onValueChanged(int oldValue, int newValue) override;
private: private:
static constexpr qreal flashDurationMs = 450.0; static constexpr qreal flashDurationMs = 450.0;

View file

@ -34,31 +34,3 @@ QList<TallyRow> StatsTally::computeTotalPower(const QList<CardItem *> &cards)
QString name = QCoreApplication::translate("StatsTally", "Total Power"); QString name = QCoreApplication::translate("StatsTally", "Total Power");
return {TallyRow{name, QString::number(total)}}; return {TallyRow{name, QString::number(total)}};
} }
static int sumToughness(const QList<CardItem *> &cards)
{
int total = 0;
for (auto card : cards) {
QVariantList parsed = CardItem::parsePT(card->getPT());
if (parsed.size() == 2) {
int toughness = parsed.at(1).toInt(); // toInt will default to 0 if it's not an int
total += qMax(toughness, 0);
}
}
return total;
}
QList<TallyRow> StatsTally::computeTotalToughness(const QList<CardItem *> &cards)
{
// don't bother if none of the cards have pt
bool hasPT =
std::any_of(cards.cbegin(), cards.cend(), [](const CardItem *card) { return !card->getPT().isEmpty(); });
if (!hasPT) {
return {};
}
int total = sumToughness(cards);
QString name = QCoreApplication::translate("StatsTally", "Total Toughness");
return {TallyRow{name, QString::number(total)}};
}

View file

@ -16,14 +16,6 @@ namespace StatsTally
*/ */
QList<TallyRow> computeTotalPower(const QList<CardItem *> &cards); QList<TallyRow> computeTotalPower(const QList<CardItem *> &cards);
/**
* @brief Sums the toughness of all selected cards
*
* @param cards The list of selected card items to analyze.
* @return A single row containing the total, or an empty list if none of the cards have pt
*/
QList<TallyRow> computeTotalToughness(const QList<CardItem *> &cards);
} // namespace StatsTally } // namespace StatsTally
#endif // COCKATRICE_STATS_TALLY_H #endif // COCKATRICE_STATS_TALLY_H

View file

@ -21,8 +21,6 @@ QList<TallyRow> Tally::compute(const QList<CardItem *> &cards, const TallyType t
return SubtypeTally::countSubtypes(cards); return SubtypeTally::countSubtypes(cards);
case TallyType::TotalPower: case TallyType::TotalPower:
return StatsTally::computeTotalPower(cards); return StatsTally::computeTotalPower(cards);
case TallyType::TotalToughness:
return StatsTally::computeTotalToughness(cards);
} }
return {}; return {};
} }

View file

@ -21,8 +21,7 @@ enum class TallyType
None, None,
Subtypes, Subtypes,
TotalPower, TotalPower,
TotalToughness, MaxValue = TotalPower // sentinel value
MaxValue = TotalToughness // sentinel value
}; };
namespace Tally namespace Tally

View file

@ -41,8 +41,7 @@ void HandZone::handleDropEvent(const QList<CardDragItem *> &dragItems,
} }
} }
} else { } else {
bool sameZone = startZone == getLogic(); x = calcDropIndexFromY(dropPoint.y());
x = calcDropIndexFromY(dropPoint.y(), !sameZone);
} }
Command_MoveCard cmd; Command_MoveCard cmd;

View file

@ -83,7 +83,7 @@ SelectZone::StackLayoutParams SelectZone::buildStackParams(qreal minOffset) cons
return {cardCount, boundingRect().height(), cardHeight, offset, minOffset}; return {cardCount, boundingRect().height(), cardHeight, offset, minOffset};
} }
int SelectZone::calcDropIndexFromY(qreal dropY, bool allowCountExpand, qreal minOffset) const int SelectZone::calcDropIndexFromY(qreal dropY, qreal minOffset) const
{ {
const auto &cards = getLogic()->getCards(); const auto &cards = getLogic()->getCards();
if (cards.isEmpty()) { if (cards.isEmpty()) {
@ -94,8 +94,7 @@ int SelectZone::calcDropIndexFromY(qreal dropY, bool allowCountExpand, qreal min
if (effectiveOffset <= 0.0) { if (effectiveOffset <= 0.0) {
return 0; return 0;
} }
int max = allowCountExpand ? params.cardCount : params.cardCount - 1; return qBound(0, qRound((dropY - start) / effectiveOffset), params.cardCount - 1);
return qBound(0, qRound((dropY - start) / effectiveOffset), max);
} }
void SelectZone::restoreStaleEscapedCards() void SelectZone::restoreStaleEscapedCards()

View file

@ -104,12 +104,8 @@ protected:
/** /**
* @brief Computes the card index at a given y-coordinate within the zone's vertical layout. * @brief Computes the card index at a given y-coordinate within the zone's vertical layout.
* Returns 0 if the zone has no cards or the offset is zero. * Returns 0 if the zone has no cards or the offset is zero.
*
* @param dropY The y-coordinate that the card was dropped at
* @param allowCountExpand If false, clamps the index at the number of cards minus 1
* @param minOffset Minimum offset to preserve
*/ */
int calcDropIndexFromY(qreal dropY, bool allowCountExpand, qreal minOffset = 0.0) const; int calcDropIndexFromY(qreal dropY, qreal minOffset = 0.0) const;
/** /**
* @brief Positions cards vertically with alternating left/right x-offsets. * @brief Positions cards vertically with alternating left/right x-offsets.

View file

@ -57,14 +57,18 @@ void StackZone::handleDropEvent(const QList<CardDragItem *> &dragItems,
return; return;
} }
bool sameZone = startZone == getLogic();
int index = calcDropIndexFromY(dropPoint.y(), !sameZone, MIN_CARD_VISIBLE);
if (sameZone) {
// Same-zone no-op: don't move a card onto itself
const auto &cards = getLogic()->getCards(); const auto &cards = getLogic()->getCards();
int index;
if (startZone == getLogic()) {
// Reordering within the zone: use drop position
index = calcDropIndexFromY(dropPoint.y(), MIN_CARD_VISIBLE);
// Same-zone no-op: don't move a card onto itself
if (!cards.isEmpty() && cards.at(index)->getId() == dragItems.at(0)->getId()) { if (!cards.isEmpty() && cards.at(index)->getId() == dragItems.at(0)->getId()) {
return; return;
} }
} else {
// Coming from another zone: append at end (top of stack, rendered on top)
index = static_cast<int>(cards.size());
} }
Command_MoveCard cmd; Command_MoveCard cmd;

View file

@ -1,6 +1,5 @@
#include "view_zone.h" #include "view_zone.h"
#include "../../client/settings/cache_settings.h"
#include "../../game/player/player_actions.h" #include "../../game/player/player_actions.h"
#include "../../game/player/player_logic.h" #include "../../game/player/player_logic.h"
#include "../../game/zones/view_zone_logic.h" #include "../../game/zones/view_zone_logic.h"
@ -12,13 +11,11 @@
#include <QGraphicsSceneWheelEvent> #include <QGraphicsSceneWheelEvent>
#include <QPainter> #include <QPainter>
#include <QtMath> #include <QtMath>
#include <libcockatrice/card/card_localization.h>
#include <libcockatrice/protocol/pb/command_dump_zone.pb.h> #include <libcockatrice/protocol/pb/command_dump_zone.pb.h>
#include <libcockatrice/protocol/pb/command_move_card.pb.h> #include <libcockatrice/protocol/pb/command_move_card.pb.h>
#include <libcockatrice/protocol/pb/response_dump_zone.pb.h> #include <libcockatrice/protocol/pb/response_dump_zone.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_card.pb.h> #include <libcockatrice/protocol/pb/serverinfo_card.pb.h>
#include <libcockatrice/protocol/pending_command.h> #include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/settings/cards_display_settings.h>
/** /**
* @param parent the parent QGraphicsWidget containing the reveal zone * @param parent the parent QGraphicsWidget containing the reveal zone
@ -256,10 +253,7 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca
void ZoneViewZone::setFilterString(const QString &_filterString) void ZoneViewZone::setFilterString(const QString &_filterString)
{ {
const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay(); filterString = FilterString(_filterString);
filterString = FilterString(
_filterString, CardSearchLanguage{cardsDisplay.getCardLang(),
static_cast<SearchLanguageMode>(cardsDisplay.getCardSearchLanguage())});
reorganizeCards(); reorganizeCards();
} }

View file

@ -21,7 +21,6 @@
#include <QStyle> #include <QStyle>
#include <QStyleOption> #include <QStyleOption>
#include <libcockatrice/protocol/pb/command_shuffle.pb.h> #include <libcockatrice/protocol/pb/command_shuffle.pb.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/interface_settings.h> #include <libcockatrice/settings/interface_settings.h>
namespace namespace
@ -63,7 +62,7 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player,
searchEdit.setPlaceholderText(tr("Search by card name (or search expressions)")); searchEdit.setPlaceholderText(tr("Search by card name (or search expressions)"));
searchEdit.setClearButtonEnabled(true); searchEdit.setClearButtonEnabled(true);
searchEdit.addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition); searchEdit.addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition);
auto help = searchEdit.addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); auto help = searchEdit.addAction(QPixmap("theme:icons/info"), QLineEdit::TrailingPosition);
connect(help, &QAction::triggered, this, [this] { createSearchSyntaxHelpWindow(&searchEdit); }); connect(help, &QAction::triggered, this, [this] { createSearchSyntaxHelpWindow(&searchEdit); });
@ -169,12 +168,6 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player,
} }
connect(&searchEdit, &QLineEdit::textChanged, zone, &ZoneViewZone::setFilterString); connect(&searchEdit, &QLineEdit::textChanged, zone, &ZoneViewZone::setFilterString);
const auto applyCardSearchLanguage = [this] { zone->setFilterString(searchEdit.text()); };
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this,
applyCardSearchLanguage);
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardSearchLanguageChanged, this,
applyCardSearchLanguage);
} }
setLayout(vbox); setLayout(vbox);
@ -556,7 +549,7 @@ void ZoneViewWidget::initStyleOption(QStyleOption *option) const
{ {
QStyleOptionTitleBar *titleBar = qstyleoption_cast<QStyleOptionTitleBar *>(option); QStyleOptionTitleBar *titleBar = qstyleoption_cast<QStyleOptionTitleBar *>(option);
if (titleBar) { if (titleBar) {
titleBar->icon = themePixmap(QStringLiteral("cockatrice")); titleBar->icon = QPixmap("theme:cockatrice");
} }
} }

View file

@ -1,59 +0,0 @@
#ifndef COCKATRICE_CARD_LOCALIZATION_H
#define COCKATRICE_CARD_LOCALIZATION_H
#include "../client/settings/cache_settings.h"
#include <QString>
#include <libcockatrice/card/card_info.h>
#include <libcockatrice/settings/cards_display_settings.h>
namespace CardLocalization
{
/**
* @brief The language code selected for localized card text and images.
*/
inline QString displayLang()
{
return SettingsCache::instance().cardsDisplay().getCardLang();
}
/**
* @brief Card name in the configured display language, falling back to English.
* @param card The card to display.
* @return The localized name, or an empty string for a null card.
*/
inline QString displayName(const CardInfoPtr &card)
{
return card.isNull() ? QString() : card->getLocalizedName(displayLang());
}
/**
* @brief Card rules text in the configured display language, falling back to English.
* @param card The card to display.
* @return The localized text, or an empty string for a null card.
*/
inline QString displayText(const CardInfoPtr &card)
{
return card.isNull() ? QString() : card->getLocalizedText(displayLang());
}
/**
* @brief Card name in the configured display language, falling back to English.
* @param card The card to display.
*/
inline QString displayName(const CardInfo &card)
{
return card.getLocalizedName(displayLang());
}
/**
* @brief Card rules text in the configured display language, falling back to English.
* @param card The card to display.
*/
inline QString displayText(const CardInfo &card)
{
return card.getLocalizedText(displayLang());
}
} // namespace CardLocalization
#endif // COCKATRICE_CARD_LOCALIZATION_H

View file

@ -1,17 +1,15 @@
#include "card_picture_loader.h" #include "card_picture_loader.h"
#include "../../client/settings/cache_settings.h" #include "../../client/settings/cache_settings.h"
#include "../pixel_map_generator.h"
#include "card_picture_loader_cache_method.h" #include "card_picture_loader_cache_method.h"
#include "card_picture_loader_local_schemes.h" #include "card_picture_loader_local_schemes.h"
#include <QApplication> #include <QApplication>
#include <QBuffer> #include <QBuffer>
#include <QDebug> #include <QDebug>
#include <QDir> #include <QDirIterator>
#include <QFileInfo> #include <QFileInfo>
#include <QMainWindow> #include <QMainWindow>
#include <QMetaObject>
#include <QMovie> #include <QMovie>
#include <QNetworkRequest> #include <QNetworkRequest>
#include <QPainter> #include <QPainter>
@ -21,7 +19,6 @@
#include <QThread> #include <QThread>
#include <algorithm> #include <algorithm>
#include <libcockatrice/settings/cache_storage_settings.h> #include <libcockatrice/settings/cache_storage_settings.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/download_settings.h> #include <libcockatrice/settings/download_settings.h>
#include <libcockatrice/settings/paths_settings.h> #include <libcockatrice/settings/paths_settings.h>
#include <utility> #include <utility>
@ -39,12 +36,9 @@ CardPictureLoader::CardPictureLoader() : QObject(nullptr)
&CardPictureLoader::picsPathChanged); &CardPictureLoader::picsPathChanged);
connect(&SettingsCache::instance().downloads(), &DownloadSettings::picDownloadChanged, this, connect(&SettingsCache::instance().downloads(), &DownloadSettings::picDownloadChanged, this,
&CardPictureLoader::picDownloadChanged); &CardPictureLoader::picDownloadChanged);
connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this,
&CardPictureLoader::cardLangChanged);
qRegisterMetaType<ExactCard>("ExactCard"); qRegisterMetaType<ExactCard>();
connect(worker, &CardPictureLoaderWorker::imageLoaded, this, &CardPictureLoader::imageLoaded); connect(worker, &CardPictureLoaderWorker::imageLoaded, this, &CardPictureLoader::imageLoaded);
connect(worker, &CardPictureLoaderWorker::networkCacheCleared, this, &CardPictureLoader::networkCacheCleared);
statusBar = new CardPictureLoaderStatusBar(nullptr); statusBar = new CardPictureLoaderStatusBar(nullptr);
QMainWindow *mainWindow = qobject_cast<QMainWindow *>(QApplication::activeWindow()); QMainWindow *mainWindow = qobject_cast<QMainWindow *>(QApplication::activeWindow());
@ -60,18 +54,7 @@ CardPictureLoader::CardPictureLoader() : QObject(nullptr)
CardPictureLoader::~CardPictureLoader() CardPictureLoader::~CardPictureLoader()
{ {
if (worker) { worker->deleteLater();
// Capture the thread first: shutdownThread() blocks until the worker has been freed by the
// finished() -> deleteLater chain, after which the worker pointer must not be dereferenced.
QThread *pictureLoaderThread = worker->workerThread();
const bool stopped = worker->shutdownThread();
worker = nullptr;
// Deleting a QThread that is still running is undefined behaviour, so only free it once the
// bounded wait in shutdownThread() confirmed that it stopped.
if (stopped) {
delete pictureLoaderThread;
}
}
} }
void CardPictureLoader::getCardBackPixmap(QPixmap &pixmap, QSize size) void CardPictureLoader::getCardBackPixmap(QPixmap &pixmap, QSize size)
@ -79,7 +62,7 @@ void CardPictureLoader::getCardBackPixmap(QPixmap &pixmap, QSize size)
QString backCacheKey = "_trice_card_back_" + QString::number(size.width()) + "x" + QString::number(size.height()); QString backCacheKey = "_trice_card_back_" + QString::number(size.width()) + "x" + QString::number(size.height());
if (!QPixmapCache::find(backCacheKey, &pixmap)) { if (!QPixmapCache::find(backCacheKey, &pixmap)) {
qCDebug(CardPictureLoaderLog) << "PictureLoader: cache miss for" << backCacheKey; qCDebug(CardPictureLoaderLog) << "PictureLoader: cache miss for" << backCacheKey;
QPixmap tmpPixmap = themePixmap(QStringLiteral("cardback")); QPixmap tmpPixmap("theme:cardback");
if (tmpPixmap.isNull()) { if (tmpPixmap.isNull()) {
qCWarning(CardPictureLoaderLog) << "Failed to load 'theme:cardback'! Using fallback pixmap."; qCWarning(CardPictureLoaderLog) << "Failed to load 'theme:cardback'! Using fallback pixmap.";
@ -100,7 +83,7 @@ void CardPictureLoader::getCardBackLoadingInProgressPixmap(QPixmap &pixmap, QSiz
"_trice_card_back_inprogress_" + QString::number(size.width()) + "x" + QString::number(size.height()); "_trice_card_back_inprogress_" + QString::number(size.width()) + "x" + QString::number(size.height());
if (!QPixmapCache::find(backCacheKey, &pixmap)) { if (!QPixmapCache::find(backCacheKey, &pixmap)) {
qCDebug(CardPictureLoaderCardBackCacheFailLog) << "PictureLoader: cache miss for" << backCacheKey; qCDebug(CardPictureLoaderCardBackCacheFailLog) << "PictureLoader: cache miss for" << backCacheKey;
QPixmap tmpPixmap = themePixmap(QStringLiteral("cardback")); QPixmap tmpPixmap("theme:cardback");
if (tmpPixmap.isNull()) { if (tmpPixmap.isNull()) {
qCWarning(CardPictureLoaderLog) << "Failed to load 'theme:cardback' for in-progress state! Using fallback."; qCWarning(CardPictureLoaderLog) << "Failed to load 'theme:cardback' for in-progress state! Using fallback.";
@ -122,7 +105,7 @@ void CardPictureLoader::getCardBackLoadingFailedPixmap(QPixmap &pixmap, QSize si
"_trice_card_back_failed_" + QString::number(size.width()) + "x" + QString::number(size.height()); "_trice_card_back_failed_" + QString::number(size.width()) + "x" + QString::number(size.height());
if (!QPixmapCache::find(backCacheKey, &pixmap)) { if (!QPixmapCache::find(backCacheKey, &pixmap)) {
qCDebug(CardPictureLoaderCardBackCacheFailLog) << "PictureLoader: cache miss for" << backCacheKey; qCDebug(CardPictureLoaderCardBackCacheFailLog) << "PictureLoader: cache miss for" << backCacheKey;
QPixmap tmpPixmap = themePixmap(QStringLiteral("cardback")); QPixmap tmpPixmap("theme:cardback");
if (tmpPixmap.isNull()) { if (tmpPixmap.isNull()) {
qCWarning(CardPictureLoaderLog) << "Failed to load 'theme:cardback' for failed state! Using fallback."; qCWarning(CardPictureLoaderLog) << "Failed to load 'theme:cardback' for failed state! Using fallback.";
@ -155,8 +138,7 @@ void CardPictureLoader::getPixmap(QPixmap &pixmap, const ExactCard &card, QSize
QPixmap bigPixmap; QPixmap bigPixmap;
if (QPixmapCache::find(key, &bigPixmap)) { if (QPixmapCache::find(key, &bigPixmap)) {
if (bigPixmap.isNull()) { if (bigPixmap.isNull()) {
// Leave the pixmap null so callers fall back to a solid color getCardBackLoadingFailedPixmap(pixmap, size);
// instead of showing the card back.
QDateTime failedAtTime = getInstance().failedAt.value(key); QDateTime failedAtTime = getInstance().failedAt.value(key);
if (!failedAtTime.isValid() || if (!failedAtTime.isValid() ||
failedAtTime.addSecs(RETRY_FAILED_CARDS_SECS) < QDateTime::currentDateTime()) { failedAtTime.addSecs(RETRY_FAILED_CARDS_SECS) < QDateTime::currentDateTime()) {
@ -222,49 +204,7 @@ void CardPictureLoader::imageLoaded(const ExactCard &card, const QImage &image)
card.emitPixmapUpdated(); card.emitPixmapUpdated();
} }
void CardPictureLoader::deleteAllLocalOverrides(const ExactCard &card) void CardPictureLoader::saveCardImageToLocalStorage(const ExactCard &card, const QPixmap &pixmap)
{
const QString picsRoot = SettingsCache::instance().paths().getPicsPath();
if (picsRoot.isEmpty() || !card) {
return;
}
QDir baseDir(picsRoot);
if (!baseDir.cd("downloadedPics")) {
return;
}
const QString name = card.getInfo().getCorrectedName();
QString set, collector, uuid;
auto printing = card.getPrinting();
if (printing.getSet()) {
set = printing.getSet()->getCorrectedShortName();
collector = printing.getProperty("num");
uuid = printing.getUuid();
}
for (const auto &scheme : CardPictureLoaderLocalSchemes::exportSchemes()) {
QString rel = CardPictureLoaderLocalSchemes::expandPattern(scheme.pattern, name, set, collector, uuid);
if (rel.isEmpty()) {
continue;
}
rel += ".png";
rel = QDir::cleanPath(rel);
QString fullPath = baseDir.filePath(rel);
if (QFile::exists(fullPath)) {
QFile::remove(fullPath);
}
}
}
void CardPictureLoader::saveCardImageToLocalStorage(const ExactCard &card,
const QPixmap &pixmap,
const bool allowOverwrite)
{ {
if (pixmap.isNull() || !card) { if (pixmap.isNull() || !card) {
return; return;
@ -324,9 +264,8 @@ void CardPictureLoader::saveCardImageToLocalStorage(const ExactCard &card,
QFileInfo outInfo(baseDir.filePath(relativePath)); QFileInfo outInfo(baseDir.filePath(relativePath));
// Automatic cache writes (FILESYSTEM_CACHE) must never clobber an explicit user override. // Do not overwrite existing files
// Only the explicit override paths pass allowOverwrite == true. if (outInfo.exists()) {
if (!allowOverwrite && outInfo.exists()) {
return; return;
} }
@ -347,122 +286,6 @@ void CardPictureLoader::saveCardImageToLocalStorage(const ExactCard &card,
} }
} }
void CardPictureLoader::installPrintingOverrideOnLoad(const ExactCard &originalCard, const ExactCard &overrideCard)
{
// Overriding a card with itself is the reset case, not a real override: every code path below
// would re-enter itself through emitPixmapUpdated(). Reject it outright.
if (originalCard == overrideCard) {
return;
}
CardInfoPtr cardPtr = overrideCard.getCardPtr();
if (!cardPtr) {
return;
}
// Heap-allocate so the lambda can capture it before the connection is made
auto *connectionHandle = new QMetaObject::Connection;
*connectionHandle =
connect(cardPtr.data(), &CardInfo::pixmapUpdated, cardPtr.data(),
[originalCard, overrideCard, connectionHandle, this](const PrintingInfo &printing) {
// All printings share the same CardInfo, so ignore updates triggered by any
// other printing (e.g., the original card re-loading from disk).
if (printing != overrideCard.getPrinting()) {
return;
}
QPixmap pixmap;
if (QPixmapCache::find(overrideCard.getPixmapCacheKey(), &pixmap) && !pixmap.isNull()) {
// The override art has resolved — persist it and reflect it immediately.
// Retire the connection before emitting so the refresh can't re-enter.
saveCardImageToLocalStorage(originalCard, pixmap, /*allowOverwrite=*/true);
QObject::disconnect(*connectionHandle);
delete connectionHandle;
QPixmapCache::clear();
originalCard.emitPixmapUpdated();
return;
}
// The art could not be resolved. Keep the connection armed so a late resolution
// still lands, and surface a visible refusal instead of a silent no-op. An
// override already on disk is left untouched and simply re-displayed.
QPixmapCache::clear();
if (!hasLocalOverrides(originalCard)) {
QPixmap refusedPixmap;
getCardBackLoadingFailedPixmap(refusedPixmap, QSize(480, 672));
QPixmapCache::insert(originalCard.getPixmapCacheKey(), refusedPixmap);
}
originalCard.emitPixmapUpdated();
});
// Now enqueue; if the image is already loading (deduplicated in the worker),
// the signal will still fire when it completes
CardPictureLoader::getInstance().worker->enqueueImageLoad(overrideCard);
}
void CardPictureLoader::installPrintingOverride(const ExactCard &originalCard, const ExactCard &overrideCard)
{
// Same guard as installPrintingOverrideOnLoad: self-override is the reset case.
if (originalCard == overrideCard) {
return;
}
QPixmap pixmap;
const QString key = overrideCard.getPixmapCacheKey();
if (QPixmapCache::find(key, &pixmap) && !pixmap.isNull()) {
// Already cached — save immediately; the caller refreshes the card.
saveCardImageToLocalStorage(originalCard, pixmap, /*allowOverwrite=*/true);
return;
}
// Cache miss or previously failed load — enqueue load and wait for the signal.
installPrintingOverrideOnLoad(originalCard, overrideCard);
}
bool CardPictureLoader::hasLocalOverrides(const ExactCard &card)
{
const QString picsRoot = SettingsCache::instance().paths().getPicsPath();
if (picsRoot.isEmpty() || !card) {
return false;
}
QDir baseDir(picsRoot);
if (!baseDir.cd("downloadedPics")) {
return false;
}
const QString name = card.getInfo().getCorrectedName();
QString set, collector, uuid;
const PrintingInfo printing = card.getPrinting();
if (printing.getSet()) {
set = printing.getSet()->getCorrectedShortName();
collector = printing.getProperty("num");
uuid = printing.getUuid();
}
for (const auto &scheme : CardPictureLoaderLocalSchemes::exportSchemes()) {
QString rel = CardPictureLoaderLocalSchemes::expandPattern(scheme.pattern, name, set, collector, uuid);
if (rel.isEmpty()) {
continue;
}
rel += ".png";
rel = QDir::cleanPath(rel);
if (QFile::exists(baseDir.filePath(rel))) {
return true;
}
}
return false;
}
void CardPictureLoader::clearPixmapCache() void CardPictureLoader::clearPixmapCache()
{ {
QPixmapCache::clear(); QPixmapCache::clear();
@ -470,17 +293,7 @@ void CardPictureLoader::clearPixmapCache()
void CardPictureLoader::clearNetworkCache() void CardPictureLoader::clearNetworkCache()
{ {
// During teardown the worker is released before this singleton, so a queued clear may still getInstance().worker->clearNetworkCache();
// arrive with no worker left to run it.
CardPictureLoaderWorker *worker = getInstance().worker;
if (!worker) {
return;
}
// The disk cache and redirect cache are owned by the worker thread, so the clear has to run
// there. Invoke it asynchronously to keep the GUI responsive while the worker may be walking
// the user's picture directories or recursively deleting the cache directory; callers that
// need to know when it is done can listen for networkCacheCleared().
QMetaObject::invokeMethod(worker, &CardPictureLoaderWorker::clearNetworkCache, Qt::QueuedConnection);
} }
void CardPictureLoader::cacheCardPixmaps(const QList<ExactCard> &cards) void CardPictureLoader::cacheCardPixmaps(const QList<ExactCard> &cards)
@ -512,11 +325,31 @@ void CardPictureLoader::picsPathChanged()
QPixmapCache::clear(); QPixmapCache::clear();
} }
void CardPictureLoader::cardLangChanged() bool CardPictureLoader::hasCustomArt()
{ {
// Localized images are fetched via a different URL, but the in-memory auto picsPath = SettingsCache::instance().paths().getPicsPath();
// pixmap cache is keyed by card name/uuid, so drop everything cached QDirIterator it(picsPath, QDir::Dirs | QDir::NoDotAndDotDot);
// (including failure timestamps) to force a reload in the new language.
QPixmapCache::clear(); // Check if there is at least one non-directory file in the pics path, other
failedAt.clear(); // than in the "downloadedPics" subdirectory.
while (it.hasNext()) {
#if (QT_VERSION >= QT_VERSION_CHECK(6, 3, 0))
QFileInfo dir(it.nextFileInfo());
#else
// nextFileInfo() is only available in Qt 6.3+, for previous versions, we build
// the QFileInfo from a QString which requires more system calls.
QFileInfo dir(it.next());
#endif
if (it.fileName() == "downloadedPics") {
continue;
}
QDirIterator subIt(it.filePath(), QDir::Files, QDirIterator::Subdirectories | QDirIterator::FollowSymlinks);
if (subIt.hasNext()) {
return true;
}
}
return false;
} }

View file

@ -97,17 +97,10 @@ public:
static void cacheCardPixmaps(const QList<ExactCard> &cards); static void cacheCardPixmaps(const QList<ExactCard> &cards);
/** /**
* @brief Check if a local override image already exists for the card. * @brief Check if the user has custom card art in the picsPath directory.
* @param card The card to check. * @return True if any custom art exists.
* @return True if the card has at least one locally stored override image.
*/ */
static bool hasLocalOverrides(const ExactCard &card); static bool hasCustomArt();
/**
* @brief Removes all locally stored override images for the card.
* @param card The card to remove the override images of.
*/
static void deleteAllLocalOverrides(const ExactCard &card);
/** /**
* @brief Clears the in-memory QPixmap cache for all cards. * @brief Clears the in-memory QPixmap cache for all cards.
@ -117,9 +110,6 @@ public:
public slots: public slots:
/** /**
* @brief Clears the network disk cache of the worker. * @brief Clears the network disk cache of the worker.
*
* The clear runs on the worker thread, so this returns before it has completed; connect to
* networkCacheCleared() to act once it is done.
*/ */
static void clearNetworkCache(); static void clearNetworkCache();
@ -130,13 +120,7 @@ public slots:
* @param image Loaded QImage. * @param image Loaded QImage.
*/ */
void imageLoaded(const ExactCard &card, const QImage &image); void imageLoaded(const ExactCard &card, const QImage &image);
void saveCardImageToLocalStorage(const ExactCard &card, const QPixmap &pixmap, bool allowOverwrite = false); void saveCardImageToLocalStorage(const ExactCard &card, const QPixmap &pixmap);
void installPrintingOverride(const ExactCard &originalCard, const ExactCard &overrideCard);
void installPrintingOverrideOnLoad(const ExactCard &originalCard, const ExactCard &overrideCard);
signals:
/** @brief Emitted after the worker has finished clearing the network and redirect caches. */
void networkCacheCleared();
private slots: private slots:
/** /**
@ -150,12 +134,6 @@ private slots:
* Clears the QPixmap cache to reload images. * Clears the QPixmap cache to reload images.
*/ */
void picsPathChanged(); void picsPathChanged();
/**
* @brief Triggered when the card language setting changes.
* Clears the in-memory picture caches so images reload in the new language.
*/
void cardLangChanged();
}; };
#endif #endif

View file

@ -94,14 +94,10 @@ QImage CardPictureLoaderLocal::tryLoadCardImageFromDisk(const QString &setName,
candidatePaths << picsPath + "/downloadedPics/" + setName + "/" + nameVariant; candidatePaths << picsPath + "/downloadedPics/" + setName + "/" + nameVariant;
} }
// Non-set-folder export schemes (e.g., Name_Set_Collector) write straight into
// downloadedPics/; check there as a fallback so local overrides round-trip.
candidatePaths << picsPath + "/downloadedPics/" + nameVariant;
for (const QString &path : candidatePaths) { for (const QString &path : candidatePaths) {
QFileInfo fileInfo(path); QFileInfo fileInfo(path);
QDir dir = fileInfo.dir(); QDir dir = fileInfo.dir();
QString baseName = fileInfo.completeBaseName(); QString baseName = fileInfo.fileName();
if (!dir.exists()) { if (!dir.exists()) {
continue; continue;
@ -109,8 +105,7 @@ QImage CardPictureLoaderLocal::tryLoadCardImageFromDisk(const QString &setName,
QStringList files = dir.entryList(QDir::Files); QStringList files = dir.entryList(QDir::Files);
for (const QString &file : files) { for (const QString &file : files) {
QFileInfo fi(file); if (!file.startsWith(baseName)) {
if (fi.completeBaseName() != baseName) {
continue; continue;
} }

View file

@ -16,16 +16,13 @@
#include <utility> #include <utility>
#include <version_string.h> #include <version_string.h>
static constexpr int MAX_REQUESTS_PER_SEC = DownloadSettings::DEFAULT_HOST_REQUEST_LIMIT; static constexpr int MAX_REQUESTS_PER_SEC = 10;
static constexpr int MIN_HOST_QUOTA = DownloadSettings::MIN_HOST_REQUEST_LIMIT; static constexpr int MIN_HOST_QUOTA = 1; ///< Floor for the per-host request allowance
static constexpr qint64 QUOTA_RECOVER_MS = 60000; ///< Idle time before a reduced quota starts recovering static constexpr qint64 QUOTA_RECOVER_MS = 60000; ///< Idle time before a reduced quota starts recovering
static constexpr int DISPATCH_INTERVAL_MS = 100; ///< Pacing between individual network requests
static constexpr qint64 QUOTA_RESET_INTERVAL_MS = 1000; ///< Interval at which the request quota resets
static constexpr int THREAD_SHUTDOWN_WAIT_MS = 5000; ///< Bounded wait for the worker thread to stop at exit
CardPictureLoaderWorker::CardPictureLoaderWorker() CardPictureLoaderWorker::CardPictureLoaderWorker()
: QObject(nullptr), picDownload(SettingsCache::instance().downloads().getPicDownload()), : QObject(nullptr), picDownload(SettingsCache::instance().downloads().getPicDownload()),
hostRequestLimits(SettingsCache::instance().downloads().getHostRequestLimits()) requestQuota(MAX_REQUESTS_PER_SEC)
{ {
networkManager = new QNetworkAccessManager(this); networkManager = new QNetworkAccessManager(this);
// We need a timeout to ensure requests don't hang indefinitely in case of // We need a timeout to ensure requests don't hang indefinitely in case of
@ -60,59 +57,20 @@ CardPictureLoaderWorker::CardPictureLoaderWorker()
localLoader = new CardPictureLoaderLocal(this); localLoader = new CardPictureLoaderLocal(this);
pictureLoaderThread = new QThread; pictureLoaderThread = new QThread;
// The worker object frees itself once its thread finishes, so no event loop is left
// running and the QThread is never destroyed while still executing.
connect(pictureLoaderThread, &QThread::finished, this, &QObject::deleteLater);
pictureLoaderThread->start(QThread::LowPriority); pictureLoaderThread->start(QThread::LowPriority);
moveToThread(pictureLoaderThread); moveToThread(pictureLoaderThread);
// QTimer value members are not QObject children, so moveToThread on the worker doesn't move
// them. They must live in the worker's thread to be started from the slot code that runs there.
requestTimer.moveToThread(pictureLoaderThread);
dispatchTimer.moveToThread(pictureLoaderThread);
connect(this, &CardPictureLoaderWorker::imageLoadEnqueued, this, &CardPictureLoaderWorker::handleImageLoadEnqueued); connect(this, &CardPictureLoaderWorker::imageLoadEnqueued, this, &CardPictureLoaderWorker::handleImageLoadEnqueued);
connect(&requestTimer, &QTimer::timeout, this, &CardPictureLoaderWorker::resetRequestQuota); connect(&requestTimer, &QTimer::timeout, this, &CardPictureLoaderWorker::resetRequestQuota);
requestTimer.setInterval(static_cast<int>(QUOTA_RESET_INTERVAL_MS)); requestTimer.setInterval(1000);
requestTimer.start();
connect(&dispatchTimer, &QTimer::timeout, this, &CardPictureLoaderWorker::dispatchQueuedRequest);
dispatchTimer.setInterval(DISPATCH_INTERVAL_MS);
connect(&SettingsCache::instance().downloads(), &DownloadSettings::hostRequestLimitsChanged, this,
[this] { hostRequestLimits = SettingsCache::instance().downloads().getHostRequestLimits(); });
} }
CardPictureLoaderWorker::~CardPictureLoaderWorker() CardPictureLoaderWorker::~CardPictureLoaderWorker()
{ {
saveRedirectCache(); saveRedirectCache();
} pictureLoaderThread->deleteLater();
bool CardPictureLoaderWorker::shutdownThread()
{
// The finished() -> deleteLater chain (wired in the constructor) frees this worker as soon as
// its event loop exits, so nothing - not even a member read - may run once wait() returns.
// QThread::quit() and QThread::wait() are thread-safe and may be called from the owning thread.
QThread *thread = pictureLoaderThread;
if (!thread) {
return true;
}
thread->quit();
// Only an unbounded wait() would guarantee the thread stops, but this runs from a function-local
// static destructor after main() has returned, with no UI left to interrupt a worker stuck in a
// slow slot or on a stalled filesystem. Bound the wait and leave such a thread to the OS rather
// than hanging the process forever.
if (!thread->wait(THREAD_SHUTDOWN_WAIT_MS)) {
qCWarning(CardPictureLoaderWorkerLog) << "Picture loader worker thread did not stop within"
<< THREAD_SHUTDOWN_WAIT_MS << "ms; leaving it to be torn down by the OS";
return false;
}
return true;
}
QThread *CardPictureLoaderWorker::workerThread() const
{
return pictureLoaderThread;
} }
void CardPictureLoaderWorker::queueRequest(const QUrl &url, CardPictureLoaderWorkerWork *worker) void CardPictureLoaderWorker::queueRequest(const QUrl &url, CardPictureLoaderWorkerWork *worker)
@ -126,8 +84,8 @@ void CardPictureLoaderWorker::queueRequest(const QUrl &url, CardPictureLoaderWor
SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) == SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) ==
CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE && CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE &&
cache->metaData(url).isValid()) { cache->metaData(url).isValid()) {
// A request that will be served from the disk cache never touches the network and therefore // If we hit a cached url, we get to make the request for free, since it won't contribute towards the
// doesn't use up any of the rate limit, so it gets to skip the queue. // rate-limit
makeRequest(url, worker); makeRequest(url, worker);
return; return;
} }
@ -141,17 +99,7 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture
// Check for cached redirects // Check for cached redirects
QUrl cachedRedirect = getCachedRedirect(url); QUrl cachedRedirect = getCachedRedirect(url);
if (!cachedRedirect.isEmpty()) { if (!cachedRedirect.isEmpty()) {
// The status bar still needs to reclaim this URL's widget even when we hand the request back
// for a deferred retry instead of dispatching it onto the network.
emit imageRequestSucceeded(url); emit imageRequestSucceeded(url);
// The redirect target is a different host, which may itself be in 429 backoff; hand the
// entry back to its worker so it waits the backoff out instead of dispatching straight
// onto the backed-off host.
if (CardPictureLoaderWorkerWork::rateLimiter().isRateLimited(cachedRedirect.host(),
QDateTime::currentDateTime())) {
worker->scheduleDeferredRetry(cachedRedirect.host());
return nullptr;
}
return makeRequest(cachedRedirect, worker); return makeRequest(cachedRedirect, worker);
} }
@ -159,261 +107,67 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture
req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
req.setRawHeader("Accept", "image/avif,image/webp,image/apng,image/,/*;q=0.8"); req.setRawHeader("Accept", "image/avif,image/webp,image/apng,image/,/*;q=0.8");
// Cached entries are served straight from the disk cache even when picture downloads are bool useNetworkCache =
// enabled: re-fetching an already-cached image would burn the rate limit for nothing. Only a !picDownload && static_cast<CardPictureLoaderCacheMethod::CacheMethod>(
// genuine cache miss goes to the network, and only when downloads are enabled. SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) ==
bool useNetworkCache = !requestTouchesNetwork(url); CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE;
req.setAttribute(QNetworkRequest::CacheLoadControlAttribute, req.setAttribute(QNetworkRequest::CacheLoadControlAttribute,
useNetworkCache ? QNetworkRequest::AlwaysCache : QNetworkRequest::AlwaysNetwork); useNetworkCache ? QNetworkRequest::AlwaysCache : QNetworkRequest::AlwaysNetwork);
QNetworkReply *reply = networkManager->get(req); QNetworkReply *reply = networkManager->get(req);
// Track in-flight replies per host so the unlocked fast path can bound how many requests it // Connect reply handling
// issues at once, instead of creating replies that time out before Qt opens a connection. connect(reply, &QNetworkReply::finished, worker, [reply, worker] { worker->handleNetworkReply(reply); });
const QString host = url.host();
hostInFlight.insert(host, hostInFlight.value(host) + 1);
// Release the in-flight slot when the reply is destroyed, not when it emits `finished`, and use
// the worker (not the work object) as the context object: a reply can go away without ever
// finishing (aborted, or a work object deleted while a reply is still pending), and a connection
// bound to that work object's lifetime would then never run, permanently shrinking the fast
// path's concurrency until it wedges. This way the slot is released exactly once.
connect(reply, &QObject::destroyed, this,
[this, host] { hostInFlight.insert(host, qMax(0, hostInFlight.value(host) - 1)); });
// Connect reply handling; the work object is the context so its handler dies with it.
connect(reply, &QNetworkReply::finished, worker, [worker, reply] { worker->handleNetworkReply(reply); });
return reply; return reply;
} }
void CardPictureLoaderWorker::resetRequestQuota() void CardPictureLoaderWorker::resetRequestQuota()
{ {
requestQuota = MAX_REQUESTS_PER_SEC;
QDateTime now = QDateTime::currentDateTime(); QDateTime now = QDateTime::currentDateTime();
for (auto it = hostRequestQuota.begin(); it != hostRequestQuota.end();) { for (auto it = hostRequestQuota.begin(); it != hostRequestQuota.end(); ++it) {
if (!hostLast429.contains(it.key()) || now.msecsTo(hostLast429.value(it.key())) < -QUOTA_RECOVER_MS) { if (!hostLast429.contains(it.key()) || now.msecsTo(hostLast429.value(it.key())) < -QUOTA_RECOVER_MS) {
if (hostAllowanceCeiling(it.key()) == DownloadSettings::UNLIMITED_HOST_QUOTA) { it.value() = qMin(MAX_REQUESTS_PER_SEC, it.value() + 1);
// A developer-unlocked host that fell back after a 429 recovers towards the default
// allowance; once it gets there it becomes unlocked (fast-path) again.
if (it.value() + 1 >= DownloadSettings::DEFAULT_HOST_REQUEST_LIMIT) {
it = hostRequestQuota.erase(it);
continue;
} }
it.value() += 1;
} else {
// Recover towards the host's effective allowance ceiling, which may be
// lowered by the user's per-host request limits.
it.value() = qMin(hostAllowanceCeiling(it.key()), it.value() + 1);
}
}
++it;
} }
// Forget the per-second allowances; each host's allowance is re-seeded lazily from its for (const auto &request : requestLoadQueue) {
// reduced sustained quota the first time it is dispatched in the new second, so a host that const QString host = request.first.host();
// enters the queue mid-second no longer falls through to a fresh full quota. hostQuotaRemaining.insert(host, hostRequestQuota.value(host, MAX_REQUESTS_PER_SEC));
hostQuotaRemaining.clear(); }
updateTimerState(); processQueuedRequests();
} }
void CardPictureLoaderWorker::processQueuedRequests() void CardPictureLoaderWorker::processQueuedRequests()
{ {
// QTimer must be started from the thread it lives in; if this public slot is ever reached from while (requestQuota > 0 && processSingleRequest()) {
// another thread, replay it on the worker's event loop instead of letting start() fail silently. --requestQuota;
if (thread() != QThread::currentThread()) {
QMetaObject::invokeMethod(this, &CardPictureLoaderWorker::processQueuedRequests, Qt::QueuedConnection);
return;
}
updateTimerState();
}
void CardPictureLoaderWorker::dispatchQueuedRequest()
{
if (requestLoadQueue.isEmpty()) {
// All queued requests have been dispatched; stop the pacing timers.
updateTimerState();
return;
}
QDateTime now = QDateTime::currentDateTime();
bool dispatched = false;
// Set while an unlocked host still has queued work blocked only by the in-flight cap; the
// timer must keep running so it gets another try as soon as a slot frees. A host blocked by
// its 429 backoff instead waits for the next quota-reset tick to restart the dispatcher.
bool unlockedCapped = false;
// Unlocked hosts (developer cap UNLIMITED_HOST_QUOTA) skip the pacing and the per-host
// allowance: dispatch their queued requests back-to-back, bounded by their 429 backoff and the
// per-host in-flight cap so a large burst can't queue replies that time out before Qt opens a
// connection for them.
for (int i = 0; i < requestLoadQueue.size();) {
const auto &request = requestLoadQueue.at(i);
// Dispatch decisions must key on the host the request will actually go to, not the URL that
// merely redirects to it: a redirect learned after this URL was queued would otherwise
// bypass the in-flight cap and drain the whole queue onto the target host unchecked.
const QUrl resolvedUrl = resolveCachedRedirect(request.first);
const QString host = resolvedUrl.host();
if (isUnlockedHost(host)) {
if (CardPictureLoaderWorkerWork::rateLimiter().isRateLimited(host, now)) {
++i;
continue;
}
if (hostInFlight.value(host) < MAX_IN_FLIGHT_PER_HOST) {
makeRequest(resolvedUrl, request.second);
requestLoadQueue.removeAt(i);
dispatched = true;
continue;
}
unlockedCapped = true;
}
++i;
}
if (requestLoadQueue.isEmpty()) {
dispatchTimer.stop();
requestTimer.stop();
return;
}
if (processSingleRequest()) {
dispatched = true;
}
// Keep the timer running while there is progress to make or unlocked work waiting on a free
// in-flight slot; otherwise no host has allowance left this second, so wait for the quota reset.
if (!dispatched && !unlockedCapped) {
dispatchTimer.stop();
}
}
void CardPictureLoaderWorker::updateTimerState()
{
// Never restart an active timer: that would reset the pacing countdown and a burst of enqueues
// could keep starving the dispatcher, so only (re)start a timer that has actually stopped.
if (requestLoadQueue.isEmpty()) {
dispatchTimer.stop();
// Forget per-second allowances once nothing is pending: a stale zero would otherwise delay
// the next single request by a full quota-reset interval.
hostQuotaRemaining.clear();
} else if (!dispatchTimer.isActive()) {
dispatchTimer.start();
}
// The quota timer resets allowances every second and is also the only thing that heals a host
// after a 429 (see resetRequestQuota). It must keep ticking while work is queued or a host is
// still recovering below the ceiling, and only winds down once no host needs recovery anymore.
// Keeping it alive during such idle periods lets reduced quotas recover as intended.
bool hostRecovering = false;
for (auto it = hostRequestQuota.cbegin(); it != hostRequestQuota.cend(); ++it) {
if (it.value() < MAX_REQUESTS_PER_SEC) {
hostRecovering = true;
break;
}
}
if (!requestLoadQueue.isEmpty() || hostRecovering) {
if (!requestTimer.isActive()) {
requestTimer.start();
}
} else if (requestTimer.isActive()) {
requestTimer.stop();
} }
} }
bool CardPictureLoaderWorker::processSingleRequest() bool CardPictureLoaderWorker::processSingleRequest()
{ {
QDateTime now = QDateTime::currentDateTime();
for (int i = 0; i < requestLoadQueue.size(); ++i) { for (int i = 0; i < requestLoadQueue.size(); ++i) {
// Copy the entry: takeAt(i) below erases within the list this reference points into. const auto &request = requestLoadQueue.at(i);
const auto request = requestLoadQueue.at(i); QString host = request.first.host();
// Resolve cached redirects so the rate-limit and allowance arithmetic keys on the host the int allowance = hostQuotaRemaining.value(host, MAX_REQUESTS_PER_SEC);
// request will actually hit (see resolveCachedRedirect).
const QUrl resolvedUrl = resolveCachedRedirect(request.first);
const QString host = resolvedUrl.host();
// Don't dispatch requests to a host that is currently in its 429 backoff; hand the entry
// back to its worker so it can wait the backoff out or fall through to another source,
// instead of leaving it parked in the queue with no reply pending. Only applies to
// requests that will actually touch the network: one that will be served from the disk
// cache costs nothing and shouldn't wait out the 429.
if (requestTouchesNetwork(request.first) &&
CardPictureLoaderWorkerWork::rateLimiter().isRateLimited(host, now)) {
// The queued URL is usually a cached-redirect target whose host differs from
// cardToDownload.getCurrentUrl(), so scheduleDeferredRetry() (which waits out the
// blocked host's deadline) is used instead of startNextPicDownload() looping on the
// original host.
auto entry = requestLoadQueue.takeAt(i);
if (host != entry.first.host()) {
// A cached redirect target is what is blocked, which the work object would not
// discover from its own URL; wait out that specific host (with jitter) instead.
entry.second->scheduleDeferredRetry(host);
} else {
entry.second->startNextPicDownload();
}
return true;
}
// Unlocked hosts are handled by dispatchQueuedRequest's fast path, bounded by the in-flight
// cap; they must not fall through to the per-host allowance arithmetic below.
if (isUnlockedHost(host)) {
continue;
}
int ceiling = hostAllowanceCeiling(host);
if (ceiling == DownloadSettings::UNLIMITED_HOST_QUOTA) {
// A 429 dropped this unlocked host out of the fast path and installed a concrete
// allowance; pace it against that allowance until the recovery loop unlocks it again.
ceiling = hostRequestQuota.value(host, DownloadSettings::DEFAULT_HOST_REQUEST_LIMIT);
}
// Seed the allowance lazily so a host that enters the queue mid-second gets its reduced
// per-host allowance, clamped against the ceiling so a lowered user cap applies from this
// second onward.
if (!hostQuotaRemaining.contains(host)) {
hostQuotaRemaining.insert(host, qMin(ceiling, hostRequestQuota.value(host, ceiling)));
}
int allowance = hostQuotaRemaining.value(host);
if (allowance > 0) { if (allowance > 0) {
hostQuotaRemaining.insert(host, allowance - 1); hostQuotaRemaining.insert(host, allowance - 1);
auto entry = requestLoadQueue.takeAt(i); makeRequest(request.first, request.second);
makeRequest(resolvedUrl, entry.second); requestLoadQueue.removeAt(i);
return true; return true;
} }
} }
return false; return false;
} }
bool CardPictureLoaderWorker::requestTouchesNetwork(const QUrl &url) const
{
bool useNetworkCache = static_cast<CardPictureLoaderCacheMethod::CacheMethod>(
SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) ==
CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE &&
(cache->metaData(url).isValid() || !picDownload);
return !useNetworkCache;
}
int CardPictureLoaderWorker::hostAllowanceCeiling(const QString &host) const
{
const int devCap = DownloadSettings::getDeveloperHostCaps().value(host, MAX_REQUESTS_PER_SEC);
if (devCap == DownloadSettings::UNLIMITED_HOST_QUOTA && !hostRequestLimits.contains(host)) {
return DownloadSettings::UNLIMITED_HOST_QUOTA;
}
const int requested = hostRequestLimits.value(host, devCap);
return SettingsCache::instance().downloads().clampHostRequestLimit(host, requested);
}
bool CardPictureLoaderWorker::isUnlockedHost(const QString &host) const
{
return hostAllowanceCeiling(host) == DownloadSettings::UNLIMITED_HOST_QUOTA && !hostRequestQuota.contains(host);
}
void CardPictureLoaderWorker::onHostRateLimited(const QString &host) void CardPictureLoaderWorker::onHostRateLimited(const QString &host)
{ {
const int ceiling = hostAllowanceCeiling(host); hostRequestQuota.insert(host, qMax(MIN_HOST_QUOTA, hostRequestQuota.value(host, MAX_REQUESTS_PER_SEC) / 2));
// An unlocked host has no per-host allowance to halve. Install one instead so it drops out of
// the unlocked fast path and is paced like a throttled host; the recovery loop in
// resetRequestQuota() then walks it back up and unlocks it again.
const int base =
ceiling == DownloadSettings::UNLIMITED_HOST_QUOTA ? DownloadSettings::DEFAULT_HOST_REQUEST_LIMIT : ceiling;
hostRequestQuota.insert(host, qMax(MIN_HOST_QUOTA, hostRequestQuota.value(host, base) / 2));
hostLast429.insert(host, QDateTime::currentDateTime()); hostLast429.insert(host, QDateTime::currentDateTime());
} }
@ -471,22 +225,6 @@ QUrl CardPictureLoaderWorker::getCachedRedirect(const QUrl &originalUrl) const
return {}; return {};
} }
QUrl CardPictureLoaderWorker::resolveCachedRedirect(const QUrl &url) const
{
// Follow the whole cached-redirect chain so dispatch keys on the host that is really hit. The
// depth bound keeps a corrupt or self-referencing cache entry from spinning us forever.
QUrl resolved = url;
int depth = 0;
while (depth++ < MAX_REDIRECT_CHAIN_DEPTH) {
QUrl target = getCachedRedirect(resolved);
if (target.isEmpty() || target == resolved) {
break;
}
resolved = target;
}
return resolved;
}
void CardPictureLoaderWorker::loadRedirectCache() void CardPictureLoaderWorker::loadRedirectCache()
{ {
QSettings settings(cacheFilePath, QSettings::IniFormat); QSettings settings(cacheFilePath, QSettings::IniFormat);
@ -539,5 +277,4 @@ void CardPictureLoaderWorker::clearNetworkCache()
{ {
networkManager->cache()->clear(); networkManager->cache()->clear();
redirectCache.clear(); redirectCache.clear();
emit networkCacheCleared();
} }

View file

@ -74,37 +74,10 @@ public:
*/ */
void onHostRateLimited(const QString &host); void onHostRateLimited(const QString &host);
/** /** @brief Clears the network cache and redirect cache. */
* @brief Stops the worker thread and reports whether it stopped. void clearNetworkCache();
*
* Called from the owning thread (CardPictureLoader) on its way out. QThread::quit() posts an
* exit request to the worker's event loop and QThread::wait() blocks (bounded) until the loop
* has returned and the thread finished. Only QThread members are touched here, so this method
* is safe to call from the owning thread. The worker object itself is freed by the finished()
* -> deleteLater chain (see the constructor); the QThread object is deleted afterwards by the
* owner (CardPictureLoader::~CardPictureLoader), not by this method.
*
* @return true if the thread stopped within the timeout, false if it is still running (in
* which case the owner must not delete the QThread).
*/
[[nodiscard]] bool shutdownThread();
/**
* @brief Returns the worker's QThread.
* @return The worker thread
*
* Only meaningful while the worker object is alive; capture it before calling shutdownThread().
*/
QThread *workerThread() const;
public slots: public slots:
/**
* @brief Clears the network cache and redirect cache.
*
* Runs on the worker thread; invoke it via a queued call when coming from another thread,
* since both caches are owned by the worker thread.
*/
void clearNetworkCache();
/** /**
* @brief Makes a network request for the given URL using the specified worker. * @brief Makes a network request for the given URL using the specified worker.
* @param url URL to load * @param url URL to load
@ -113,12 +86,9 @@ public slots:
*/ */
QNetworkReply *makeRequest(const QUrl &url, CardPictureLoaderWorkerWork *workThread); QNetworkReply *makeRequest(const QUrl &url, CardPictureLoaderWorkerWork *workThread);
/** @brief Ensures the pacing and quota-reset timers reflect the current queue and recovery state. */ /** @brief Processes all queued requests respecting the request quota. */
void processQueuedRequests(); void processQueuedRequests();
/** @brief Chooses a request from the queue and starts it, respecting the quota and pacing. */
void dispatchQueuedRequest();
/** /**
* @brief Processes a single queued request. * @brief Processes a single queued request.
* @return true if a request was processed, false if queue is empty. * @return true if a request was processed, false if queue is empty.
@ -148,56 +118,18 @@ private:
bool picDownload; ///< Whether downloading images from network is enabled bool picDownload; ///< Whether downloading images from network is enabled
QQueue<QPair<QUrl, CardPictureLoaderWorkerWork *>> requestLoadQueue; ///< Queue of pending network requests QQueue<QPair<QUrl, CardPictureLoaderWorkerWork *>> requestLoadQueue; ///< Queue of pending network requests
int requestQuota; ///< Remaining requests allowed per second
QTimer requestTimer; ///< Timer to reset the request quota QTimer requestTimer; ///< Timer to reset the request quota
QTimer dispatchTimer; ///< Timer pacing individual network requests
QHash<QString, int> hostRequestQuota; ///< Sustained per-host request allowance QHash<QString, int> hostRequestQuota; ///< Sustained per-host request allowance
QHash<QString, int> hostRequestLimits; ///< User-set per-host request allowances
QHash<QString, int> hostQuotaRemaining; ///< Per-host allowance left in the current second QHash<QString, int> hostQuotaRemaining; ///< Per-host allowance left in the current second
QHash<QString, QDateTime> hostLast429; ///< When each host was last rate limited QHash<QString, QDateTime> hostLast429; ///< When each host was last rate limited
QHash<QString, int> hostInFlight; ///< Network replies currently in flight, per host
/** @brief Maximum concurrent in-flight network replies per host. */
static constexpr int MAX_IN_FLIGHT_PER_HOST = 6;
/** @brief Bound on how many cached-redirect hops dispatch resolution will follow. */
static constexpr int MAX_REDIRECT_CHAIN_DEPTH = 10;
CardPictureLoaderLocal *localLoader; ///< Loader for local images CardPictureLoaderLocal *localLoader; ///< Loader for local images
QSet<QString> currentlyLoading; ///< Deduplication: contains pixmapCacheKey currently being loaded QSet<QString> currentlyLoading; ///< Deduplication: contains pixmapCacheKey currently being loaded
/**
* @brief Effective per-host allowance ceiling for a host.
* @param host The host to look up
* @return The allowance ceiling in requests/second, or DownloadSettings::UNLIMITED_HOST_QUOTA
* when the developer unlocked the host and no user limit is set for it.
*/
[[nodiscard]] int hostAllowanceCeiling(const QString &host) const;
/**
* @brief Whether a host may skip dispatch pacing and per-host allowance entirely.
*
* A host is unlocked while it has no user limit and no reduced allowance installed by a 429.
* A 429 drops it out of the fast path until resetRequestQuota() walks the allowance back up.
*/
[[nodiscard]] bool isUnlockedHost(const QString &host) const;
/** @brief Returns cached redirect URL for the given original URL, if available. */ /** @brief Returns cached redirect URL for the given original URL, if available. */
[[nodiscard]] QUrl getCachedRedirect(const QUrl &originalUrl) const; [[nodiscard]] QUrl getCachedRedirect(const QUrl &originalUrl) const;
/** @brief Whether a request for this URL would actually touch the network, rather than being served from the disk
* cache. */
[[nodiscard]] bool requestTouchesNetwork(const QUrl &url) const;
/**
* @brief Follows the cached-redirect chain to the URL that will actually be requested.
* @param url The URL to resolve
* @return The final URL after chasing cached redirects, or @p url itself if none lead elsewhere
*
* Dispatch decisions (unlocked-host fast path, 429 backoff, in-flight cap) must key on the host
* a request really goes to, not the URL that merely redirects to it.
*/
[[nodiscard]] QUrl resolveCachedRedirect(const QUrl &url) const;
/** @brief Loads redirect cache from disk. */ /** @brief Loads redirect cache from disk. */
void loadRedirectCache(); void loadRedirectCache();
@ -207,9 +139,6 @@ private:
/** @brief Removes stale redirect entries older than TTL. */ /** @brief Removes stale redirect entries older than TTL. */
void cleanStaleEntries(); void cleanStaleEntries();
/** @brief Starts or stops the pacing and quota-reset timers to match the queue and recovery state. */
void updateTimerState();
private slots: private slots:
/** @brief Resets the request quota for rate-limiting. */ /** @brief Resets the request quota for rate-limiting. */
void resetRequestQuota(); void resetRequestQuota();
@ -229,9 +158,6 @@ signals:
/** @brief Emitted when a network request successfully completes. */ /** @brief Emitted when a network request successfully completes. */
void imageRequestSucceeded(const QUrl &url); void imageRequestSucceeded(const QUrl &url);
/** @brief Emitted after clearNetworkCache() has finished clearing both caches. */
void networkCacheCleared();
}; };
#endif // PICTURE_LOADER_WORKER_H #endif // PICTURE_LOADER_WORKER_H

View file

@ -22,13 +22,8 @@ static const QStringList MD5_BLACKLIST = {
"fbc7d763c08771c260b39e2115414eeb" // Current card back hash "fbc7d763c08771c260b39e2115414eeb" // Current card back hash
}; };
const ServerRateLimiter &CardPictureLoaderWorkerWork::rateLimiter() CardPictureLoaderWorkerWork::CardPictureLoaderWorkerWork(const CardPictureLoaderWorker *worker, const ExactCard &toLoad)
{ : QObject(nullptr), cardToDownload(CardPictureToLoad(toLoad)),
return s_rateLimiter;
}
CardPictureLoaderWorkerWork::CardPictureLoaderWorkerWork(CardPictureLoaderWorker *worker, const ExactCard &toLoad)
: QObject(worker), cardToDownload(CardPictureToLoad(toLoad)),
picDownload(SettingsCache::instance().downloads().getPicDownload()) picDownload(SettingsCache::instance().downloads().getPicDownload())
{ {
// Hook up signals to the orchestrator // Hook up signals to the orchestrator
@ -173,7 +168,7 @@ void CardPictureLoaderWorkerWork::handleFailedReply(const QNetworkReply *reply)
<< "PictureLoader: [card: " << cardToDownload.getCard().getName() << "PictureLoader: [card: " << cardToDownload.getCard().getName()
<< " set: " << cardToDownload.getSetName() << "]: Too many requests from " << host << " set: " << cardToDownload.getSetName() << "]: Too many requests from " << host
<< ", backing off until " << backoffUntil.toString(Qt::ISODate) << ", retrying the same url"; << ", backing off until " << backoffUntil.toString(Qt::ISODate) << ", retrying the same url";
scheduleDeferredRetry(host); scheduleDeferredRetry();
} else { } else {
qCWarning(CardPictureLoaderWorkerWorkLog).nospace() qCWarning(CardPictureLoaderWorkerWorkLog).nospace()
<< "PictureLoader: [card: " << cardToDownload.getCard().getName() << "PictureLoader: [card: " << cardToDownload.getCard().getName()
@ -278,16 +273,14 @@ QImage CardPictureLoaderWorkerWork::tryLoadImageFromReply(QNetworkReply *reply)
return imgReader.read(); return imgReader.read();
} }
void CardPictureLoaderWorkerWork::scheduleDeferredRetry(const QString &preferredHost) void CardPictureLoaderWorkerWork::scheduleDeferredRetry()
{ {
QDateTime now = QDateTime::currentDateTime(); QDateTime now = QDateTime::currentDateTime();
// Prefer waiting on the server that is actually blocking the request: callers hand in the // Prefer waiting on the current URL's server so we retry the same source.
// rate-limited host when it differs from the current URL (e.g. a cached redirect target still QString currentHost = QUrl(cardToDownload.getCurrentUrl()).host();
// in backoff), otherwise fall back to the current URL's server so we retry the same source. QDateTime backoffUntil = s_rateLimiter.deadline(currentHost);
QString waitHost = preferredHost.isEmpty() ? QUrl(cardToDownload.getCurrentUrl()).host() : preferredHost; if (!s_rateLimiter.isRateLimited(currentHost, now)) {
QDateTime backoffUntil = s_rateLimiter.deadline(waitHost);
if (!s_rateLimiter.isRateLimited(waitHost, now)) {
backoffUntil = s_rateLimiter.earliestDeadline(now); backoffUntil = s_rateLimiter.earliestDeadline(now);
} }

View file

@ -10,7 +10,6 @@
#include <QNetworkAccessManager> #include <QNetworkAccessManager>
#include <QObject> #include <QObject>
#include <QRandomGenerator> #include <QRandomGenerator>
#include <QString>
#include <QThread> #include <QThread>
#include <libcockatrice/card/database/card_database.h> #include <libcockatrice/card/database/card_database.h>
#include <libcockatrice/utility/server_rate_limiter.h> #include <libcockatrice/utility/server_rate_limiter.h>
@ -37,36 +36,13 @@ class CardPictureLoaderWorkerWork : public QObject
public: public:
/** /**
* @brief Constructs a worker for downloading a specific card image. * @brief Constructs a worker for downloading a specific card image.
* @param worker The orchestrating CardPictureLoaderWorker; the work object becomes its child so * @param worker The orchestrating CardPictureLoaderWorker
* it is destroyed with the worker even if it never reaches concludeImageLoad().
* @param toLoad The ExactCard to download * @param toLoad The ExactCard to download
*/ */
explicit CardPictureLoaderWorkerWork(CardPictureLoaderWorker *worker, const ExactCard &toLoad); explicit CardPictureLoaderWorkerWork(const CardPictureLoaderWorker *worker, const ExactCard &toLoad);
CardPictureToLoad cardToDownload; ///< The card and associated URLs to try downloading CardPictureToLoad cardToDownload; ///< The card and associated URLs to try downloading
/** @brief Shared per-server 429 backoff state. */
static const ServerRateLimiter &rateLimiter();
/**
* @brief Starts downloading the next URL for this card.
*
* Skips URLs whose server is currently in 429 backoff, either waiting the
* backoff out or falling through to the other configured sources.
*/
void startNextPicDownload();
/**
* @brief Schedules a deferred retry after the relevant server backoff expires.
* @param preferredHost The server that is actually blocking the request, or an empty
* string to use the current URL's server
*
* Waits on the blocking server's backoff deadline, otherwise on the earliest active
* backoff. If no servers are in backoff, concludes with failure. Otherwise resets the
* CardPictureToLoad indices and retries after the backoff period.
*/
void scheduleDeferredRetry(const QString &preferredHost = {});
public slots: public slots:
/** /**
* @brief Handles a finished network reply for the card image. * @brief Handles a finished network reply for the card image.
@ -79,6 +55,9 @@ private:
static ServerRateLimiter s_rateLimiter; ///< Shared per-server 429 backoff state static ServerRateLimiter s_rateLimiter; ///< Shared per-server 429 backoff state
/** @brief Starts downloading the next URL for this card. */
void startNextPicDownload();
/** @brief Called when all URLs have been exhausted or download failed. */ /** @brief Called when all URLs have been exhausted or download failed. */
void picDownloadFailed(); void picDownloadFailed();
@ -103,6 +82,16 @@ private:
*/ */
void concludeImageLoad(const QImage &image); void concludeImageLoad(const QImage &image);
/**
* @brief Schedules a deferred retry after the relevant server backoff expires.
*
* Waits on the current URL's server when it is the reason we are blocked,
* otherwise on the earliest active backoff. If no servers are in backoff,
* concludes with failure. Otherwise resets the CardPictureToLoad indices and
* retries after the backoff period.
*/
void scheduleDeferredRetry();
private slots: private slots:
/** @brief Updates the picDownload setting when it changes. */ /** @brief Updates the picDownload setting when it changes. */
void picDownloadChanged(); void picDownloadChanged();

View file

@ -282,15 +282,8 @@ QString CardPictureToLoad::transformUrl(const QString &urlTemplate) const
} }
// language setting // language setting
const QString cardLang = SettingsCache::instance().cardsDisplay().getCardLang(); transformMap["!sflang!"] = QString(QCoreApplication::translate(
transformMap["!sflang!"] = cardLang; "PictureLoader", "en", "code for scryfall's language property, not available for all languages"));
// The localized printing's own id is unknown, so Scryfall must resolve it by
// its translated name (see populateSetUrls); expose that name for the
// `/cards/named` template.
if (cardLang != "en") {
transformMap["!localizedName!"] = card.getInfo().getLocalizedName(cardLang);
}
QString transformedUrl = urlTemplate; QString transformedUrl = urlTemplate;
for (const QString &prop : transformMap.keys()) { for (const QString &prop : transformMap.keys()) {

View file

@ -17,7 +17,7 @@ enum Format
PlainText, PlainText,
/** /**
* This is Cockatrice's native deck file format, and supports deck metadata such as banner cards and tags. * This is cockatrice's native deck file format, and supports deck metadata such as banner cards and tags.
* Stored as .cod files. * Stored as .cod files.
*/ */
Cockatrice Cockatrice

View file

@ -50,7 +50,7 @@ DeckLoader::loadFromFile(const QString &fileName, DeckFileFormat::Format fmt, bo
result = deckList.loadFromFile_Native(&file); result = deckList.loadFromFile_Native(&file);
if (!result) { if (!result) {
qCInfo(DeckLoaderLog) << "Failed to load " << fileName qCInfo(DeckLoaderLog) << "Failed to load " << fileName
<< "as Cockatrice format; retrying as plain format"; << "as cockatrice format; retrying as plain format";
file.seek(0); file.seek(0);
result = deckList.loadFromFile_Plain(&file, CardNameNormalizer()); result = deckList.loadFromFile_Plain(&file, CardNameNormalizer());
fmt = DeckFileFormat::PlainText; fmt = DeckFileFormat::PlainText;
@ -375,32 +375,15 @@ void DeckLoader::saveToStream_DeckHeader(QTextStream &out, const DeckList &deckL
void DeckLoader::saveToStream_DeckZone(QTextStream &out, void DeckLoader::saveToStream_DeckZone(QTextStream &out,
const InnerDecklistNode *zoneNode, const InnerDecklistNode *zoneNode,
bool addComments, bool addComments,
bool addSetNameAndNumber, bool addSetNameAndNumber)
const QString &boardZoneName)
{ {
// Nested sub-zones keep their owning board's identity: the top-level call
// passes no board, so the zone's own name is used; recursive calls carry the
// owning board down so the sideboard marker survives sub-zone nesting.
const QString owningBoardZoneName = boardZoneName.isEmpty() ? zoneNode->getName() : boardZoneName;
// group cards by card type and count the subtotals // group cards by card type and count the subtotals
QMultiMap<QString, DecklistCardNode *> cardsByType; QMultiMap<QString, DecklistCardNode *> cardsByType;
QMap<QString, int> cardTotalByType; QMap<QString, int> cardTotalByType;
int cardTotal = 0; int cardTotal = 0;
QList<const InnerDecklistNode *> subZones;
for (int j = 0; j < zoneNode->size(); j++) { for (int j = 0; j < zoneNode->size(); j++) {
auto *card = dynamic_cast<DecklistCardNode *>(zoneNode->at(j)); auto *card = dynamic_cast<DecklistCardNode *>(zoneNode->at(j));
if (!card) {
// Cards collected in nested sub-zones are exported by recursion so
// they don't end up invisible in the plain text output. They are
// deferred until after this zone's own header and cards so they read
// as part of this zone's block.
if (auto *subZone = dynamic_cast<const InnerDecklistNode *>(zoneNode->at(j))) {
subZones.append(subZone);
}
continue;
}
CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(card->getName()); CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(card->getName());
QString cardType = info ? info->getMainCardType() : "unknown"; QString cardType = info ? info->getMainCardType() : "unknown";
@ -428,30 +411,25 @@ void DeckLoader::saveToStream_DeckZone(QTextStream &out,
QList<DecklistCardNode *> cards = cardsByType.values(cardType); QList<DecklistCardNode *> cards = cardsByType.values(cardType);
saveToStream_DeckZoneCards(out, cards, addComments, addSetNameAndNumber, owningBoardZoneName); saveToStream_DeckZoneCards(out, zoneNode, cards, addComments, addSetNameAndNumber);
if (addComments) { if (addComments) {
out << "\n"; out << "\n";
} }
} }
// Nested sub-zones come last, after the parent's own header and cards.
for (const auto *subZone : subZones) {
saveToStream_DeckZone(out, subZone, addComments, addSetNameAndNumber, owningBoardZoneName);
}
} }
void DeckLoader::saveToStream_DeckZoneCards(QTextStream &out, void DeckLoader::saveToStream_DeckZoneCards(QTextStream &out,
const InnerDecklistNode *zoneNode,
QList<DecklistCardNode *> cards, QList<DecklistCardNode *> cards,
bool addComments, bool addComments,
bool addSetNameAndNumber, bool addSetNameAndNumber)
const QString &boardZoneName)
{ {
// QMultiMap sorts values in reverse order // QMultiMap sorts values in reverse order
for (int i = cards.size() - 1; i >= 0; --i) { for (int i = cards.size() - 1; i >= 0; --i) {
DecklistCardNode *card = cards[i]; DecklistCardNode *card = cards[i];
if (boardZoneName == DECK_ZONE_SIDE && addComments) { if (zoneNode->getName() == DECK_ZONE_SIDE && addComments) {
out << "SB: "; out << "SB: ";
} }
@ -532,26 +510,9 @@ bool DeckLoader::convertToCockatriceFormat(LoadedDeck &deck)
void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode *node) void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode *node)
{ {
if (!node || node->isEmpty()) {
return;
}
const int totalColumns = 2; const int totalColumns = 2;
// Dispatch children by type instead of trusting a whole-node height: a deck if (node->height() == 1) {
// node may hold direct cards and nested zones side by side (custom zones),
// and an empty node would previously crash on at(0).
QVector<const AbstractDecklistCardNode *> cards;
QVector<const InnerDecklistNode *> subZones;
for (int i = 0; i < node->size(); i++) {
if (auto *card = dynamic_cast<const AbstractDecklistCardNode *>(node->at(i))) {
cards.append(card);
} else if (auto *zone = dynamic_cast<const InnerDecklistNode *>(node->at(i))) {
subZones.append(zone);
}
}
if (!cards.isEmpty()) {
QTextBlockFormat blockFormat; QTextBlockFormat blockFormat;
QTextCharFormat charFormat; QTextCharFormat charFormat;
charFormat.setFontPointSize(11); charFormat.setFontPointSize(11);
@ -562,9 +523,9 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode
tableFormat.setCellPadding(0); tableFormat.setCellPadding(0);
tableFormat.setCellSpacing(0); tableFormat.setCellSpacing(0);
tableFormat.setBorder(0); tableFormat.setBorder(0);
QTextTable *table = cursor->insertTable(cards.size() + 1, totalColumns, tableFormat); QTextTable *table = cursor->insertTable(node->size() + 1, totalColumns, tableFormat);
for (int i = 0; i < cards.size(); i++) { for (int i = 0; i < node->size(); i++) {
const AbstractDecklistCardNode *card = cards[i]; auto *card = dynamic_cast<AbstractDecklistCardNode *>(node->at(i));
QTextCharFormat cellCharFormat; QTextCharFormat cellCharFormat;
cellCharFormat.setFontPointSize(9); cellCharFormat.setFontPointSize(9);
@ -579,13 +540,7 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode
cellCursor = cell.firstCursorPosition(); cellCursor = cell.firstCursorPosition();
cellCursor.insertText(card->getName()); cellCursor.insertText(card->getName());
} }
} } else if (node->height() == 2) {
for (const InnerDecklistNode *subZone : subZones) {
if (subZone->isEmpty()) {
continue;
}
QTextBlockFormat blockFormat; QTextBlockFormat blockFormat;
QTextCharFormat charFormat; QTextCharFormat charFormat;
charFormat.setFontPointSize(14); charFormat.setFontPointSize(14);
@ -604,8 +559,10 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode
tableFormat.setColumnWidthConstraints(constraints); tableFormat.setColumnWidthConstraints(constraints);
QTextTable *table = cursor->insertTable(1, totalColumns, tableFormat); QTextTable *table = cursor->insertTable(1, totalColumns, tableFormat);
QTextCursor cellCursor = table->cellAt(0, 0).firstCursorPosition(); for (int i = 0; i < node->size(); i++) {
printDeckListNode(&cellCursor, subZone); QTextCursor cellCursor = table->cellAt(0, (i * totalColumns) / node->size()).lastCursorPosition();
printDeckListNode(&cellCursor, dynamic_cast<InnerDecklistNode *>(node->at(i)));
}
} }
cursor->movePosition(QTextCursor::End); cursor->movePosition(QTextCursor::End);

View file

@ -131,7 +131,7 @@ public:
static void printDeckList(QPrinter *printer, const DeckList &deckList); static void printDeckList(QPrinter *printer, const DeckList &deckList);
/** /**
* Converts the given deck's file to the Cockatrice file format. * Converts the given deck's file to the cockatrice file format.
* Uses the lastLoadInfo in the LoadedDeck to determine the current name of the file and where to save to. * Uses the lastLoadInfo in the LoadedDeck to determine the current name of the file and where to save to.
* @param deck The deck to convert. Should have valid lastLoadInfo. Will update the lastLoadInfo. * @param deck The deck to convert. Should have valid lastLoadInfo. Will update the lastLoadInfo.
* @return Whether the conversion succeeded. * @return Whether the conversion succeeded.
@ -159,13 +159,12 @@ private:
static void saveToStream_DeckZone(QTextStream &out, static void saveToStream_DeckZone(QTextStream &out,
const InnerDecklistNode *zoneNode, const InnerDecklistNode *zoneNode,
bool addComments = true, bool addComments = true,
bool addSetNameAndNumber = true, bool addSetNameAndNumber = true);
const QString &boardZoneName = QString());
static void saveToStream_DeckZoneCards(QTextStream &out, static void saveToStream_DeckZoneCards(QTextStream &out,
const InnerDecklistNode *zoneNode,
QList<DecklistCardNode *> cards, QList<DecklistCardNode *> cards,
bool addComments = true, bool addComments = true,
bool addSetNameAndNumber = true, bool addSetNameAndNumber = true);
const QString &boardZoneName = QString());
}; };
#endif #endif

View file

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

View file

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

View file

@ -16,7 +16,6 @@ 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 ---
@ -30,7 +29,6 @@ protected:
// Emit the outcome exactly once; ignore late signals after the intent is done. // Emit the outcome exactly once; ignore late signals after the intent is done.
void emitFinished(); void emitFinished();
void emitFailed(const QString &reason); void emitFailed(const QString &reason);
void emitCancelled();
private: private:
bool completed = false; bool completed = false;

View file

@ -19,15 +19,13 @@ bool IntentJoinServerGame::checkPrecondition() const
if (remoteClient->getStatus() != ClientStatus::StatusLoggedIn) { if (remoteClient->getStatus() != ClientStatus::StatusLoggedIn) {
return false; return false;
} }
// serverName()/serverPort() reflect the server the client was configured // peerPort() reflects the actual TCP peer, which may differ from the
// to connect to, which may differ from the actual TCP peer (e.g. when // configured server port (e.g. when connecting through a proxy), so only
// connecting through a proxy), so compare those configured values. A link // the hostname is compared here.
// naming the same host on another port is a different server and must not if (remoteClient->peerName() != context->roomContext.serverContext.hostname) {
// reuse the session there.
if (remoteClient->serverName().compare(context->roomContext.serverContext.hostname, Qt::CaseInsensitive) != 0) {
return false; return false;
} }
if (QString::number(remoteClient->serverPort()) != context->roomContext.serverContext.port) { if (QString::number(remoteClient->peerPort()) != context->roomContext.serverContext.port) {
return false; return false;
} }

View file

@ -1,14 +1,9 @@
#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"
#include <QDialog> IntentGetLoginCredentials::IntentGetLoginCredentials(ContextConnectToServer *_context) : Intent(), context(_context)
IntentGetLoginCredentials::IntentGetLoginCredentials(ContextConnectToServer *_context,
bool _promptForMissingCredentials)
: Intent(), context(_context), promptForMissingCredentials(_promptForMissingCredentials)
{ {
} }
@ -34,46 +29,5 @@ void IntentGetLoginCredentials::onPreconditionSatisfied()
void IntentGetLoginCredentials::onPreconditionNotSatisfied() void IntentGetLoginCredentials::onPreconditionNotSatisfied()
{ {
// 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")); emitFailed(tr("No saved credentials for this server"));
return;
}
// No credentials saved for the target server: ask the user for them. They
// opt into saving them so later links to the same server connect directly.
const QString serverText = context->hostname + ":" + context->port;
DlgLoginPrompt dialog(serverText);
// ApplicationModal: the dialog has no parent (the intent is not a widget),
// so WindowModal would not actually block any other window.
dialog.setWindowModality(Qt::ApplicationModal);
if (dialog.exec() != QDialog::Accepted) {
emitCancelled();
return;
}
context->username = dialog.username();
context->password = dialog.password();
if (dialog.savePassword() && !context->username.isEmpty()) {
ServersSettings &servers = SettingsCache::instance().servers();
// The host may already be saved under a friendly name (e.g. a public-server
// list entry) with no credentials; reuse that name instead of overwriting
// it with the raw hostname when addNewServer updates the entry in place.
QString saveName = context->hostname;
const int existingIndex = servers.findServerIndex(context->hostname, context->port);
if (existingIndex >= 0) {
saveName =
servers.getValue(QString("saveName%1").arg(existingIndex), "server", "server_details").toString();
if (saveName.isEmpty()) {
saveName = context->hostname;
}
}
servers.addNewServer(saveName, context->hostname, context->port, context->username, context->password, true);
}
emitFinished();
} }

View file

@ -9,10 +9,7 @@ class IntentGetLoginCredentials : public Intent
Q_OBJECT Q_OBJECT
public: public:
// When promptForMissingCredentials is false (the default) a server without IntentGetLoginCredentials(ContextConnectToServer *_context);
// 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;
@ -21,7 +18,6 @@ protected:
private: private:
ContextConnectToServer *context; ContextConnectToServer *context;
bool promptForMissingCredentials;
}; };
#endif // COCKATRICE_INTENT_LOGIN_H #endif // COCKATRICE_INTENT_LOGIN_H

View file

@ -1,208 +0,0 @@
#include "intent_open_shared_deck.h"
#include "../deck_loader/deck_loader.h"
#include "../widgets/dialogs/dlg_shared_decks_preview.h"
#include "../widgets/tabs/tab_supervisor.h"
#include "intent_connect_to_server.h"
#include <QMessageBox>
#include <QTimer>
#include <libcockatrice/card/database/card_database_querier.h>
#include <libcockatrice/protocol/pb/command_deck_share_download.pb.h>
#include <libcockatrice/protocol/pb/command_deck_share_list.pb.h>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/response_deck_share_download.pb.h>
#include <libcockatrice/protocol/pb/response_deck_share_list.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_deck_share_item.pb.h>
#include <libcockatrice/protocol/pending_command.h>
IntentOpenSharedDeck::IntentOpenSharedDeck(TabSupervisor *_tabSupervisor,
RemoteClient *_remoteClient,
const CardDatabaseQuerier *_querier,
std::unique_ptr<ContextOpenDeck> _context)
: Intent(), tabSupervisor(_tabSupervisor), remoteClient(_remoteClient), querier(_querier),
context(_context.release())
{
downloadTimer = new QTimer(this);
downloadTimer->setSingleShot(true);
downloadTimer->setInterval(15000);
connect(downloadTimer, &QTimer::timeout, this, &IntentOpenSharedDeck::onDownloadTimeout);
}
bool IntentOpenSharedDeck::checkPrecondition() const
{
if (remoteClient->getStatus() != ClientStatus::StatusLoggedIn) {
return false;
}
// serverName()/serverPort() reflect the server the client was configured
// to connect to, which may differ from the actual TCP peer (e.g. when
// connecting through a proxy), so compare those configured values. The
// share token must be resolved against the host the link named — a link to
// the same host on another port is a different server.
if (remoteClient->serverName().compare(context->serverContext.hostname, Qt::CaseInsensitive) != 0) {
return false;
}
return QString::number(remoteClient->serverPort()) == context->serverContext.port;
}
void IntentOpenSharedDeck::onPreconditionSatisfied()
{
// Resolve the share token to its items first; a share can contain more than
// one deck, and each item is downloaded by id. Time the round trip like the
// downloads, so a silent server cannot hang the chain forever.
listPhase = true;
downloadTimer->start();
Command_DeckShareList cmd;
cmd.set_token(context->shareToken.toStdString());
PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &IntentOpenSharedDeck::listShareFinished);
remoteClient->sendCommand(pend);
}
void IntentOpenSharedDeck::onPreconditionNotSatisfied()
{
runDependency(new IntentConnectToServer(remoteClient, &context->serverContext));
}
void IntentOpenSharedDeck::listShareFinished(const Response &response, const CommandContainer & /* commandContainer */)
{
downloadTimer->stop();
listPhase = false;
if (response.response_code() != Response::RespOk) {
emitFailed(tr("The shared deck could not be found or has expired"));
return;
}
const Response_DeckShareList &resp = response.GetExtension(Response_DeckShareList::ext);
if (resp.items_size() == 0) {
emitFailed(tr("The shared deck is empty"));
return;
}
QList<ServerInfo_DeckShareItem> items;
items.reserve(resp.items_size());
for (const ServerInfo_DeckShareItem &item : resp.items()) {
items.append(item);
itemNames.insert(item.id(), QString::fromStdString(item.name()));
}
const QString serverText = context->serverContext.hostname + ":" + context->serverContext.port;
// Ask the user which decks to open before downloading anything.
previewDialog = new DlgSharedDecksPreview(tabSupervisor, querier, QString::fromStdString(resp.name()),
resp.expires_at(), serverText, items);
connect(previewDialog, &DlgSharedDecksPreview::openRequested, this, &IntentOpenSharedDeck::startDownloads);
connect(previewDialog, &DlgSharedDecksPreview::cancelled, this, &IntentOpenSharedDeck::emitCancelled);
connect(previewDialog, &DlgSharedDecksPreview::cancelled, previewDialog, &QWidget::deleteLater);
previewDialog->show();
previewDialog->raise();
previewDialog->activateWindow();
}
void IntentOpenSharedDeck::startDownloads(const QList<int> &itemIds)
{
pendingItemIds = itemIds;
totalItems = itemIds.size();
completedItems = 0;
loadedDecks.clear();
downloadNextItem();
}
void IntentOpenSharedDeck::downloadNextItem()
{
if (pendingItemIds.isEmpty()) {
finishAll();
return;
}
currentItemId = pendingItemIds.takeFirst();
downloadTimer->start();
Command_DeckShareDownload cmd;
cmd.set_token(context->shareToken.toStdString());
cmd.set_item_id(currentItemId);
PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd);
connect(pend, &PendingCommand::finished, this, &IntentOpenSharedDeck::downloadShareFinished);
remoteClient->sendCommand(pend);
}
void IntentOpenSharedDeck::downloadShareFinished(const Response &response,
const CommandContainer & /* commandContainer */)
{
downloadTimer->stop();
QString failureReason;
if (response.response_code() != Response::RespOk) {
failureReason = tr("Failed to download the shared deck");
} else {
const Response_DeckShareDownload &resp = response.GetExtension(Response_DeckShareDownload::ext);
const QString deckString = QString::fromStdString(resp.deck());
if (deckString.isEmpty()) {
failureReason = tr("The shared deck is empty");
} else {
std::optional<LoadedDeck> deckOpt =
DeckLoader::loadFromRemote(deckString, LoadedDeck::LoadInfo::NON_REMOTE_ID);
if (!deckOpt) {
failureReason = tr("The shared deck could not be loaded");
} else {
loadedDecks.append(deckOpt.value());
++completedItems;
previewDialog->setDownloadProgress(completedItems, totalItems,
itemNames.value(currentItemId, tr("Unknown deck")));
downloadNextItem();
return;
}
}
}
onItemFailure(failureReason);
}
void IntentOpenSharedDeck::onItemFailure(const QString &reason)
{
downloadTimer->stop();
if (loadedDecks.isEmpty()) {
previewDialog->deleteLater();
emitFailed(reason);
return;
}
const int downloadedCount = loadedDecks.size();
const QMessageBox::StandardButton answer = QMessageBox::question(
previewDialog, tr("Open shared decks"),
tr("Could not download the deck \"%1\".\n\n%n deck(s) were already downloaded. Open them?", "", downloadedCount)
.arg(itemNames.value(currentItemId, tr("Unknown deck"))),
QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes);
if (answer == QMessageBox::Yes) {
finishAll();
} else {
previewDialog->deleteLater();
emitCancelled();
}
}
void IntentOpenSharedDeck::onDownloadTimeout()
{
// The list phase has no preview dialog yet to report progress into; fail the
// whole intent instead of letting the shared deck hang in limbo.
if (listPhase) {
emitFailed(tr("Timed out while loading the shared deck"));
return;
}
onItemFailure(tr("Timed out while downloading the shared deck"));
}
void IntentOpenSharedDeck::finishAll()
{
previewDialog->deleteLater();
for (const LoadedDeck &deck : loadedDecks) {
tabSupervisor->openDeckInNewTab(deck);
}
emitFinished();
}

View file

@ -1,60 +0,0 @@
#ifndef COCKATRICE_INTENT_OPEN_SHARED_DECK_H
#define COCKATRICE_INTENT_OPEN_SHARED_DECK_H
#include "contexts/context_open_deck.h"
#include "intent.h"
#include "remote_client.h"
#include <QList>
#include <QMap>
#include <QScopedPointer>
#include <memory>
class TabSupervisor;
struct LoadedDeck;
class CardDatabaseQuerier;
class DlgSharedDecksPreview;
class QTimer;
class IntentOpenSharedDeck : public Intent
{
Q_OBJECT
public:
IntentOpenSharedDeck(TabSupervisor *_tabSupervisor,
RemoteClient *_remoteClient,
const CardDatabaseQuerier *_querier,
std::unique_ptr<ContextOpenDeck> _context);
protected:
bool checkPrecondition() const override;
void onPreconditionSatisfied() override;
void onPreconditionNotSatisfied() override;
private slots:
void listShareFinished(const Response &response, const CommandContainer &commandContainer);
void downloadShareFinished(const Response &response, const CommandContainer &commandContainer);
void onDownloadTimeout();
private:
void startDownloads(const QList<int> &itemIds);
void downloadNextItem();
void onItemFailure(const QString &reason);
void finishAll();
TabSupervisor *tabSupervisor;
RemoteClient *remoteClient;
const CardDatabaseQuerier *querier;
QScopedPointer<ContextOpenDeck> context;
DlgSharedDecksPreview *previewDialog = nullptr;
QTimer *downloadTimer;
QMap<int, QString> itemNames;
QList<int> pendingItemIds;
QList<LoadedDeck> loadedDecks;
bool listPhase = true;
int currentItemId = 0;
int totalItems = 0;
int completedItems = 0;
};
#endif // COCKATRICE_INTENT_OPEN_SHARED_DECK_H

View file

@ -1,28 +1,19 @@
#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)
{ {
} }
@ -38,33 +29,16 @@ 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") {
firstIntent = createJoinGameIntent(query, chain); handleJoinGame(query);
} else if (action == "opendeck") { } else if (action == "opendeck") {
firstIntent = createOpenDeckIntent(query, chain); // handleOpenDeck(query);
} 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();
} }
Intent *IntentUrlParser::createJoinGameIntent(const QUrlQuery &query, PendingIntentChain &chain) void IntentUrlParser::handleJoinGame(const QUrlQuery &query)
{ {
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); };
@ -75,21 +49,21 @@ Intent *IntentUrlParser::createJoinGameIntent(const QUrlQuery &query, PendingInt
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 nullptr; return;
} }
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 nullptr; return;
} }
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 nullptr; return;
} }
ok = false; ok = false;
@ -97,7 +71,7 @@ Intent *IntentUrlParser::createJoinGameIntent(const QUrlQuery &query, PendingInt
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 nullptr; return;
} }
const QString gameDescription = query.queryItemValue("game", QUrl::FullyDecoded); const QString gameDescription = query.queryItemValue("game", QUrl::FullyDecoded);
@ -106,33 +80,24 @@ Intent *IntentUrlParser::createJoinGameIntent(const QUrlQuery &query, PendingInt
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 nullptr; return;
} }
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.
auto joinGameIntent = new IntentJoinServerGame(mainWindow->getTabSupervisor(), client, std::move(ctx)); ContextConnectToServer *serverContext = &ctx->roomContext.serverContext;
auto joinGameIntent =
new IntentJoinServerGame(mainWindow->getTabSupervisor(), mainWindow->getRemoteClient(), std::move(ctx));
joinGameIntent->setParent(this); joinGameIntent->setParent(this);
chain.intents.append(joinGameIntent);
connect(joinGameIntent, &Intent::failed, this, [showError](const QString &reason) { showError(reason); });
Intent *firstIntent = joinGameIntent; auto getLoginCredentialsIntent = new IntentGetLoginCredentials(serverContext);
if (!isConnectedTo(serverContext->hostname, serverContext->port)) {
auto getLoginCredentialsIntent =
new IntentGetLoginCredentials(serverContext, /*promptForMissingCredentials=*/true);
getLoginCredentialsIntent->setParent(joinGameIntent); getLoginCredentialsIntent->setParent(joinGameIntent);
chain.intents.insert(0, getLoginCredentialsIntent);
connect(getLoginCredentialsIntent, &Intent::finished, joinGameIntent, &Intent::execute); connect(getLoginCredentialsIntent, &Intent::finished, joinGameIntent, &Intent::execute);
connect(getLoginCredentialsIntent, &Intent::failed, joinGameIntent, &Intent::failed); connect(getLoginCredentialsIntent, &Intent::failed, joinGameIntent, &Intent::failed);
connect(getLoginCredentialsIntent, &Intent::cancelled, joinGameIntent, &Intent::cancelled); connect(joinGameIntent, &Intent::failed, this, [showError](const QString &reason) { showError(reason); });
firstIntent = getLoginCredentialsIntent;
}
return firstIntent; getLoginCredentialsIntent->execute();
} }
QString IntentUrlParser::generateJoinGameMessage(const ContextJoinGame &context, const QString &gameDescription) QString IntentUrlParser::generateJoinGameMessage(const ContextJoinGame &context, const QString &gameDescription)
@ -169,270 +134,3 @@ QString IntentUrlParser::generateJoinGameMessage(const ContextJoinGame &context,
.arg(gameDescription, gameIdStr, roomTab->getRoomName(), server) .arg(gameDescription, gameIdStr, roomTab->getRoomName(), server)
: tr("Join game \"%1\" (#%2) on %3?").arg(gameDescription, gameIdStr, server); : tr("Join game \"%1\" (#%2) on %3?").arg(gameDescription, gameIdStr, server);
} }
Intent *IntentUrlParser::createOpenDeckIntent(const QUrlQuery &query, PendingIntentChain &chain)
{
auto showError = [this](const QString &message) {
QMessageBox::warning(mainWindow, tr("Open shared deck"), message);
};
auto ctx = std::make_unique<ContextOpenDeck>();
ctx->serverContext.hostname = query.queryItemValue("hostname");
ctx->serverContext.port = query.queryItemValue("port");
ctx->shareToken = query.queryItemValue("share");
qCDebug(UrlParserLog) << "Open-deck intent: host" << ctx->serverContext.hostname << "port"
<< ctx->serverContext.port << "token length" << ctx->shareToken.length();
if (ctx->serverContext.hostname.isEmpty()) {
showError(tr("Missing or empty hostname in the share link"));
return nullptr;
}
bool ok = false;
const quint16 port = ctx->serverContext.port.toUShort(&ok);
if (!ok || port == 0) {
showError(tr("Invalid or missing port in the share link"));
return nullptr;
}
if (ctx->shareToken.isEmpty()) {
showError(tr("Missing or empty share value in the share link"));
return nullptr;
}
RemoteClient *client = mainWindow->getRemoteClient();
// The open deck download needs a connection to the link's server. Ask before
// taking the session anywhere it isn't already, naming the host we would
// connect to. Remember the link's target when it moves us away from a live
// session so a failed or cancelled chain can restore the session it left.
// The hostname is link-supplied and percent-decoded, so escape it: QMessageBox
// renders AutoText, and markup in a hostname would otherwise flip the whole
// prompt to rich text and let a link pad the message the user is shown.
const bool alreadyConnected = isConnectedTo(ctx->serverContext.hostname, ctx->serverContext.port);
if (!alreadyConnected) {
const QString target =
QStringLiteral("%1:%2").arg(ctx->serverContext.hostname.toHtmlEscaped(), ctx->serverContext.port);
if (client->getStatus() == StatusLoggedIn) {
const QString current =
QStringLiteral("%1:%2").arg(client->serverName(), QString::number(client->serverPort()));
const QMessageBox::StandardButton answer = QMessageBox::question(
mainWindow, tr("Open shared deck"),
tr("Opening this share link connects you to %1 instead of %2.\n\nContinue?").arg(target, current),
QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes);
if (answer != QMessageBox::Yes) {
return nullptr;
}
chain.migrationTargetHost = ctx->serverContext.hostname;
chain.migrationTargetPort = ctx->serverContext.port;
chain.pendingRestore = true;
} else {
// Fresh connection is harmless to wander away from, but a server the
// client has never been configured for deserves a harder warning (no
// by default) so a stray link cannot silently steer the client there.
const bool knownHost = SettingsCache::instance().servers().findHostIndex(ctx->serverContext.hostname) >= 0;
const QMessageBox::StandardButton answer =
knownHost
? QMessageBox::question(mainWindow, tr("Open shared deck"),
tr("Opening this share link connects you to %1.\n\nContinue?").arg(target))
: QMessageBox::warning(mainWindow, tr("Open shared deck"),
tr("Opening this share link connects you to %1, a server you have "
"never connected to before.\n\nContinue?")
.arg(target),
QMessageBox::Yes | QMessageBox::No, QMessageBox::No);
if (answer != QMessageBox::Yes) {
return nullptr;
}
}
}
ContextConnectToServer *serverContext = &ctx->serverContext;
// The open deck intent owns the context and the credential lookup; once
// the chain finishes (or fails) it deletes the whole tree.
auto openDeckIntent =
new IntentOpenSharedDeck(mainWindow->getTabSupervisor(), client, CardDatabaseManager::query(), std::move(ctx));
openDeckIntent->setParent(this);
chain.intents.append(openDeckIntent);
connect(openDeckIntent, &Intent::failed, this, [showError](const QString &reason) { showError(reason); });
Intent *firstIntent = openDeckIntent;
if (!isConnectedTo(serverContext->hostname, serverContext->port)) {
auto getLoginCredentialsIntent =
new IntentGetLoginCredentials(serverContext, /*promptForMissingCredentials=*/true);
getLoginCredentialsIntent->setParent(openDeckIntent);
chain.intents.insert(0, getLoginCredentialsIntent);
connect(getLoginCredentialsIntent, &Intent::finished, openDeckIntent, &Intent::execute);
connect(getLoginCredentialsIntent, &Intent::failed, openDeckIntent, &Intent::failed);
connect(getLoginCredentialsIntent, &Intent::cancelled, openDeckIntent, &Intent::cancelled);
firstIntent = getLoginCredentialsIntent;
}
return firstIntent;
}
bool IntentUrlParser::isConnectedTo(const QString &hostname, const QString &port) const
{
// serverName() reflects the server the client was configured to connect to,
// which may differ from the actual TCP peer (e.g. when connecting through a
// proxy), so compare the configured host and port — exactly what a link
// names. A link to the same host on another port is a different server and
// must not silently reuse an existing session there.
RemoteClient *client = mainWindow->getRemoteClient();
return client->getStatus() == StatusLoggedIn && client->serverName().compare(hostname, Qt::CaseInsensitive) == 0 &&
QString::number(client->serverPort()) == port;
}
void IntentUrlParser::startNextChain()
{
if (chainRunning || pendingChains.isEmpty()) {
return;
}
chainRunning = true;
PendingIntentChain &chain = pendingChains.first();
if (chain.intents.isEmpty()) {
pendingChains.removeFirst();
chainRunning = false;
startNextChain();
return;
}
// Snapshot the session this chain moves away from now that it actually
// runs. Chains are parsed while earlier ones are still queued, so a capture
// at parse time would follow whichever server the chain before it settled
// on, not the one the user is really on when this link is handled.
if (chain.pendingRestore) {
RemoteClient *client = mainWindow->getRemoteClient();
chain.previousServerHost = client->serverName();
chain.previousServerPort = QString::number(client->serverPort());
}
// Only the last intent completes the chain; its terminal signal ends the
// whole run. Cancellation of an intermediate intent (e.g. declined login
// prompt) is forwarded onto the last intent in the chain builders above.
Intent *finalIntent = chain.intents.last();
connect(finalIntent, &Intent::finished, this, [this]() { chainEnded(true); });
connect(finalIntent, &Intent::failed, this, [this]() { chainEnded(false); });
connect(finalIntent, &Intent::cancelled, this, [this]() { chainEnded(false); });
// Backstop: if the final intent is destroyed without emitting a terminal
// signal (e.g. a network error dropped it while running), end the chain so
// later links are not queued and dropped for the rest of the session.
chainBackstopConnection = connect(finalIntent, &QObject::destroyed, this, &IntentUrlParser::onChainIntentDestroyed);
chain.intents.first()->execute();
}
void IntentUrlParser::chainEnded(bool chainSucceeded)
{
chainRunning = false;
QObject::disconnect(chainBackstopConnection);
const PendingIntentChain chain = pendingChains.takeFirst();
// Only a failed or cancelled chain restores the session the link migrated
// away from; a successful one leaves the user where they are.
if (chain.pendingRestore && !chainSucceeded) {
restorePreviousServer(chain);
}
startNextChain();
// Only report the terminal state once the queue has fully drained, so a
// queued follow-up link keeps the startup fallback out of the picture.
if (!chainRunning && pendingChains.isEmpty()) {
emit urlChainFinished(mainWindow->getRemoteClient()->getStatus() == StatusLoggedIn);
}
}
void IntentUrlParser::onChainIntentDestroyed()
{
if (!chainRunning) {
return;
}
qCWarning(UrlParserLog) << "Share-link intent destroyed without a terminal signal; ending its chain";
chainEnded(false);
}
void IntentUrlParser::restorePreviousServer(const PendingIntentChain &chain)
{
if (chain.previousServerHost.isEmpty()) {
return;
}
RemoteClient *client = mainWindow->getRemoteClient();
const ClientStatus status = client->getStatus();
// A failed/cancelled chain can fire while the client is still settling the
// in-flight connection attempt (wrong password, connect timeout). Only
// decide once the client has settled into logged-in or disconnected;
// deciding mid-connect would strand the user offline from their previous
// server.
if (status == StatusDisconnected || status == StatusLoggedIn) {
restoreToPreviousServer(chain);
return;
}
auto waitConnection = std::make_shared<QMetaObject::Connection>();
*waitConnection = connect(client, &RemoteClient::statusChanged, this, [this, chain, client, waitConnection]() {
const ClientStatus settled = client->getStatus();
if (settled == StatusDisconnected || settled == StatusLoggedIn) {
QObject::disconnect(*waitConnection);
restoreToPreviousServer(chain);
}
});
}
void IntentUrlParser::restoreToPreviousServer(const PendingIntentChain &chain)
{
RemoteClient *client = mainWindow->getRemoteClient();
// Back on the previous server already → nothing to undo.
if (client->serverName().compare(chain.previousServerHost, Qt::CaseInsensitive) == 0 &&
QString::number(client->serverPort()) == chain.previousServerPort) {
return;
}
// When logged in somewhere, only intervene if that somewhere is the server
// the link moved us to; if the user went elsewhere on their own, leave them.
if (client->getStatus() == StatusLoggedIn) {
const bool onMigrationTarget =
client->serverName().compare(chain.migrationTargetHost, Qt::CaseInsensitive) == 0 &&
QString::number(client->serverPort()) == chain.migrationTargetPort;
if (!onMigrationTarget) {
return;
}
ServersSettings &servers = SettingsCache::instance().servers();
const int index = servers.findServerIndex(chain.previousServerHost, chain.previousServerPort);
if (index >= 0 && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) {
const QString username =
servers.getValue(QString("username%1").arg(index), "server", "server_details").toString();
const QString password =
servers.getValue(QString("password%1").arg(index), "server", "server_details").toString();
client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), username, password);
return;
}
client->disconnectFromServer();
return;
}
if (client->getStatus() != StatusDisconnected) {
return;
}
// The link's connection attempt failed: reconnect to the previous server
// when credentials are saved, otherwise stay offline.
ServersSettings &servers = SettingsCache::instance().servers();
const int index = servers.findServerIndex(chain.previousServerHost, chain.previousServerPort);
if (index >= 0 && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) {
const QString username =
servers.getValue(QString("username%1").arg(index), "server", "server_details").toString();
const QString password =
servers.getValue(QString("password%1").arg(index), "server", "server_details").toString();
client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), username, password);
}
}

View file

@ -1,46 +1,10 @@
#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
@ -48,28 +12,12 @@ class IntentUrlParser : public QObject
public: public:
IntentUrlParser(QObject *parent, MainWindow *mainWindow); IntentUrlParser(QObject *parent, MainWindow *mainWindow);
void handle(const QString &urlStr); void handle(const QString &urlStr);
void handleJoinGame(const QUrlQuery &query);
signals:
/** @brief Emitted when the last queued chain ended; carries whether the client is logged in. */
void urlChainFinished(bool connected);
private: private:
Intent *createJoinGameIntent(const QUrlQuery &query, PendingIntentChain &chain);
Intent *createOpenDeckIntent(const QUrlQuery &query, PendingIntentChain &chain);
QString generateJoinGameMessage(const ContextJoinGame &context, const QString &gameDescription); QString generateJoinGameMessage(const ContextJoinGame &context, const QString &gameDescription);
[[nodiscard]] bool isConnectedTo(const QString &hostname, const QString &port) const;
void startNextChain();
void chainEnded(bool chainSucceeded);
void onChainIntentDestroyed();
void restorePreviousServer(const PendingIntentChain &chain);
void restoreToPreviousServer(const PendingIntentChain &chain);
MainWindow *mainWindow; MainWindow *mainWindow;
QList<PendingIntentChain> pendingChains;
bool chainRunning = false;
// Disconnects the destroyed-signal backstop once a chain ends, so an old
// intent's deferred deletion cannot end the chain that runs after it.
QMetaObject::Connection chainBackstopConnection;
}; };
#endif // COCKATRICE_URL_PARSER_H #endif // COCKATRICE_URL_PARSER_H

View file

@ -1,5 +1,6 @@
#include "palette_editor_dialog.h" #include "palette_editor_dialog.h"
#include "../../client/settings/cache_settings.h"
#include "../theme_manager.h" #include "../theme_manager.h"
#include "palette_generator.h" #include "palette_generator.h"
#include "palette_grid_widget.h" #include "palette_grid_widget.h"
@ -10,11 +11,31 @@
#include <QDialogButtonBox> #include <QDialogButtonBox>
#include <QDir> #include <QDir>
#include <QFile> #include <QFile>
#include <QFileInfo>
#include <QFrame> #include <QFrame>
#include <QGuiApplication>
#include <QLabel> #include <QLabel>
#include <QLoggingCategory>
#include <QMessageBox> #include <QMessageBox>
#include <QPushButton> #include <QPushButton>
#include <QStyleHints>
#include <QTimer> #include <QTimer>
#include <libcockatrice/settings/paths_settings.h>
// Probe whether a directory is truly writable by trying to create and remove a
// temporary file. QFileInfo::isWritable() on a directory is unreliable (notably
// on Windows where UAC VirtualStore can make a system dir appear writable).
static bool isDirReallyWritable(const QString &dirPath)
{
const QString probe = QDir(dirPath).absoluteFilePath(".cockatrice_write_test");
QFile f(probe);
if (!f.open(QIODevice::WriteOnly)) {
return false;
}
f.close();
f.remove();
return true;
}
PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QString &_themeName, QWidget *parent) PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QString &_themeName, QWidget *parent)
: QDialog(parent), themeDirPath(_themeDirPath), themeName(_themeName) : QDialog(parent), themeDirPath(_themeDirPath), themeName(_themeName)
@ -25,7 +46,14 @@ PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QSt
// Resolve a writable directory for saving. Built-in (Default / Fusion) and // Resolve a writable directory for saving. Built-in (Default / Fusion) and
// other read-only theme directories must be customised in the user-writable // other read-only theme directories must be customised in the user-writable
// themes directory; otherwise the write would fail or be lost on upgrade. // themes directory; otherwise the write would fail or be lost on upgrade.
saveDir = ThemeManager::writableThemeDir(themeName); if (!themeDirPath.isEmpty() && isDirReallyWritable(themeDirPath)) {
saveDir = themeDirPath;
} else {
saveDir = QDir(SettingsCache::instance().paths().getThemesPath()).absoluteFilePath(themeName);
if (!QDir().mkpath(saveDir)) {
qWarning() << "Failed to create palette save directory:" << saveDir;
}
}
// Load both scheme configs upfront so switching is instant // Load both scheme configs upfront so switching is instant
loadSchemes(); loadSchemes();
@ -186,7 +214,7 @@ void PaletteEditorDialog::retranslateUi()
resetBtn->setToolTip(tr("Discard unsaved edits and restore the last saved palette")); resetBtn->setToolTip(tr("Discard unsaved edits and restore the last saved palette"));
saveBtn->setToolTip(tr("Write palette-%1.toml and reload the theme").arg(loadedScheme.toLower())); saveBtn->setToolTip(tr("Write palette-%1.toml and reload the theme").arg(loadedScheme.toLower()));
if (saveDir.isEmpty() || !ThemeManager::isDirReallyWritable(saveDir)) { if (saveDir.isEmpty() || !isDirReallyWritable(saveDir)) {
saveBtn->setEnabled(false); saveBtn->setEnabled(false);
saveBtn->setToolTip(tr("Cannot save: this theme has no writable directory")); saveBtn->setToolTip(tr("Cannot save: this theme has no writable directory"));
} }
@ -269,7 +297,7 @@ void PaletteEditorDialog::onSave()
if (it.key() == loadedScheme) { if (it.key() == loadedScheme) {
continue; continue;
} }
if (it.value() == savedConfig.value(it.key())) { if (it.value().colors == savedConfig.value(it.key()).colors) {
continue; continue;
} }
if (!ThemeManager::commitPalette(saveDir, it.key(), it.value())) { if (!ThemeManager::commitPalette(saveDir, it.key(), it.value())) {
@ -280,7 +308,7 @@ void PaletteEditorDialog::onSave()
} }
// Commit the active scheme last so the global colour scheme matches. // Commit the active scheme last so the global colour scheme matches.
if (workingConfig[loadedScheme] != savedConfig.value(loadedScheme)) { if (workingConfig[loadedScheme].colors != savedConfig.value(loadedScheme).colors) {
if (!ThemeManager::commitPalette(saveDir, loadedScheme, workingConfig[loadedScheme])) { if (!ThemeManager::commitPalette(saveDir, loadedScheme, workingConfig[loadedScheme])) {
QMessageBox::warning(this, tr("Save failed"), QMessageBox::warning(this, tr("Save failed"),
tr("Could not write %1 to:\n%2").arg(PaletteConfig::fileName(loadedScheme), saveDir)); tr("Could not write %1 to:\n%2").arg(PaletteConfig::fileName(loadedScheme), saveDir));

View file

@ -150,17 +150,6 @@ PaletteConfig fromAccent(const QColor &accent, int intensity, const QString &sch
cfg.colors[CG::Disabled][CR::HighlightedText] = disText; cfg.colors[CG::Disabled][CR::HighlightedText] = disText;
cfg.colors[CG::Inactive][CR::HighlightedText] = dark ? Qt::white : Qt::black; cfg.colors[CG::Inactive][CR::HighlightedText] = dark ? Qt::white : Qt::black;
// Accent: same primary hue as Highlight, so palettes derived from a
// QuickSetup accent always carry a matching Accent role.
#if QT_VERSION >= QT_VERSION_CHECK(6, 6, 0)
set3(CR::Accent, hl, disText, hl);
#endif
// Application role colors: Strong tracks the primary accent, while Soft is
// the lightened, desaturated companion used for button-gradient highlights.
cfg.appColors[AppColor::AccentStrong] = hl;
cfg.appColors[AppColor::AccentSoft] = hsl(accent.lightness() + 60, qRound(accent.hslSaturation() * 70 / 100.0));
// BrightText // BrightText
QColor bright; QColor bright;
if (achromatic) { if (achromatic) {

View file

@ -1,7 +1,5 @@
#include "palette_grid_widget.h" #include "palette_grid_widget.h"
#include "../theme_manager.h"
#include <QApplication> #include <QApplication>
#include <QGridLayout> #include <QGridLayout>
#include <QLabel> #include <QLabel>
@ -47,11 +45,6 @@ static const QMap<QPalette::ColorRole, const char *> ROLE_DESCRIPTIONS = {
{QPalette::Shadow, QT_TR_NOOP("Very dark shadow colour")}, {QPalette::Shadow, QT_TR_NOOP("Very dark shadow colour")},
}; };
static const QMap<AppColor::Role, const char *> APP_ROLE_DESCRIPTIONS = {
{AppColor::AccentStrong, QT_TR_NOOP("Vivid primary accent (e.g. home-tab button gradient start)")},
{AppColor::AccentSoft, QT_TR_NOOP("Lightened, desaturated accent (e.g. home-tab button gradient end)")},
};
PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent) PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent)
{ {
scroll = new QScrollArea(this); scroll = new QScrollArea(this);
@ -129,46 +122,6 @@ void PaletteGridWidget::buildGrid(QWidget *host)
grid->addWidget(btn, row + 1, col + 1, Qt::AlignHCenter | Qt::AlignVCenter); grid->addWidget(btn, row + 1, col + 1, Qt::AlignHCenter | Qt::AlignVCenter);
} }
} }
// Application color section: one ColorButton per role below the role grid.
// These are not tied to a color group, so a single button spans the row.
QMetaEnum appEnum = QMetaEnum::fromType<AppColor::Role>();
const int appHeaderRow = roles.size() + 1;
auto *appHeader = new QLabel(tr("App colors"), host);
appHeader->setToolTip(tr("Application-specific colors layered on top of the Qt palette"));
QFont appHeaderFont = appHeader->font();
appHeaderFont.setBold(true);
appHeader->setFont(appHeaderFont);
appHeader->setAutoFillBackground(true);
appHeader->setContentsMargins(4, 4, 4, 4);
grid->addWidget(appHeader, appHeaderRow, 0, 1, 4);
headerLabels.append(appHeader);
for (int i = 0; i < appEnum.keyCount(); ++i) {
auto role = static_cast<AppColor::Role>(appEnum.value(i));
const int row = appHeaderRow + 1 + i;
if (i % 2 == 0) {
for (int col = 0; col < 4; ++col) {
auto *shade = new QWidget(host);
shade->setAutoFillBackground(true);
grid->addWidget(shade, row, col);
rowShadeWidgets.push_back(shade);
}
}
auto *label = new QLabel(QString(appEnum.valueToKey(role)), host);
label->setToolTip(APP_ROLE_DESCRIPTIONS.value(role, {}));
label->setContentsMargins(4, 2, 8, 2);
grid->addWidget(label, row, 0);
auto *btn = new ColorButton(host);
connect(btn, &ColorButton::colorChanged, this, [this] { emit paletteChanged(); });
appColorButtons[role] = btn;
grid->addWidget(btn, row, 1, Qt::AlignHCenter | Qt::AlignVCenter);
}
} }
void PaletteGridWidget::changeEvent(QEvent *e) void PaletteGridWidget::changeEvent(QEvent *e)
@ -213,16 +166,6 @@ void PaletteGridWidget::loadPalette(const PaletteConfig &cfg)
colorButtons[group][role]->setColor(color); colorButtons[group][role]->setColor(color);
} }
} }
QMetaEnum appEnum = QMetaEnum::fromType<AppColor::Role>();
for (int i = 0; i < appEnum.keyCount(); ++i) {
auto role = static_cast<AppColor::Role>(appEnum.value(i));
QColor color = cfg.appColors.value(role);
if (!color.isValid()) {
color = themeManager->appColor(role);
}
appColorButtons[role]->setColor(color);
}
} }
PaletteConfig PaletteGridWidget::currentPaletteConfig() const PaletteConfig PaletteGridWidget::currentPaletteConfig() const
@ -233,12 +176,5 @@ PaletteConfig PaletteGridWidget::currentPaletteConfig() const
cfg.colors[group][role] = colorButtons[group][role]->getColor(); cfg.colors[group][role] = colorButtons[group][role]->getColor();
} }
} }
QMetaEnum appEnum = QMetaEnum::fromType<AppColor::Role>();
for (int i = 0; i < appEnum.keyCount(); ++i) {
auto role = static_cast<AppColor::Role>(appEnum.value(i));
cfg.appColors[role] = appColorButtons[role]->getColor();
}
return cfg; return cfg;
} }

View file

@ -31,7 +31,6 @@ private:
void refreshChromePalettes(); void refreshChromePalettes();
QMap<QPalette::ColorGroup, QMap<QPalette::ColorRole, ColorButton *>> colorButtons; QMap<QPalette::ColorGroup, QMap<QPalette::ColorRole, ColorButton *>> colorButtons;
QMap<AppColor::Role, ColorButton *> appColorButtons;
QScrollArea *scroll; QScrollArea *scroll;
QWidget *gridHost; QWidget *gridHost;
QVBoxLayout *layout; QVBoxLayout *layout;

View file

@ -1,7 +1,5 @@
#include "pixel_map_generator.h" #include "pixel_map_generator.h"
#include "theme_manager.h"
#include <QApplication> #include <QApplication>
#include <QDomDocument> #include <QDomDocument>
#include <QFile> #include <QFile>
@ -84,13 +82,7 @@ static QPixmap loadSvg(const QString &svgPath, const QSize &size, bool expandOnl
/** /**
* Try to load path image from non-SVG formats, otherwise fall back to SVG. * Try to load path image from non-SVG formats, otherwise fall back to SVG.
* This is to allow custom themes to support non-SVG format type overrides, since SVG requires custom loading. * This is to allow custom themes to support non-SVG format type overrides, since SVG requires custom loading.
* * @param path The path to the file, with no file extension. File formats will be automatically detected.
* The path may already carry the resolved file extension (e.g. via
* ThemeManager::assetPath); such paths are loaded directly. Otherwise a
* format-agnostic lookup probes png, jpg and finally svg.
*
* @param path The path to the file, with no file extension unless the caller
* already resolved it. File formats will be automatically detected.
* @param size The desired size of the pixmap. * @param size The desired size of the pixmap.
* @param expandOnly If true, then keep the size of the initial pixmap to at least the size (Only relevant if SVG). * @param expandOnly If true, then keep the size of the initial pixmap to at least the size (Only relevant if SVG).
* *
@ -98,19 +90,6 @@ static QPixmap loadSvg(const QString &svgPath, const QSize &size, bool expandOnl
*/ */
static QPixmap tryLoadImage(const QString &path, const QSize &size, bool expandOnly = false) static QPixmap tryLoadImage(const QString &path, const QSize &size, bool expandOnly = false)
{ {
if (path.endsWith(QLatin1String(".svg"), Qt::CaseInsensitive)) {
return loadSvg(path, size, expandOnly);
}
if (path.endsWith(QLatin1String(".png"), Qt::CaseInsensitive) ||
path.endsWith(QLatin1String(".jpg"), Qt::CaseInsensitive) ||
path.endsWith(QLatin1String(".jpeg"), Qt::CaseInsensitive)) {
QPixmap pix(path);
if (!pix.isNull()) {
return pix.scaled(size, Qt::KeepAspectRatio, Qt::SmoothTransformation);
}
return {};
}
const auto formats = {"png", "jpg"}; const auto formats = {"png", "jpg"};
QPixmap returnPixmap; QPixmap returnPixmap;
@ -132,8 +111,7 @@ QPixmap PhasePixmapGenerator::generatePixmap(int height, QString name)
return pmCache.value(key); return pmCache.value(key);
} }
QPixmap pixmap = tryLoadImage(QStringLiteral("theme:") + themeManager->assetPath(QStringLiteral("phases/") + name), QPixmap pixmap = tryLoadImage("theme:phases/" + name, QSize(height, height));
QSize(height, height));
pmCache.insert(key, pixmap); pmCache.insert(key, pixmap);
return pixmap; return pixmap;
@ -361,10 +339,6 @@ static QString getIconType(const bool isBuddy, const UserLevelFlags &userLevelFl
return "pawn_judge"; return "pawn_judge";
} }
if (userLevelFlags.testFlag(ServerInfo_User::IsDeveloper)) {
return "pawn_dev";
}
if (!privLevel.isEmpty() && privLevel.toLower() != "none") { if (!privLevel.isEmpty() && privLevel.toLower() != "none") {
return QString("pawn_%1").arg(privLevel.toLower()); return QString("pawn_%1").arg(privLevel.toLower());
} }
@ -422,8 +396,7 @@ QPixmap LockPixmapGenerator::generatePixmap(int height)
return pmCache.value(key); return pmCache.value(key);
} }
QPixmap pixmap = tryLoadImage(QStringLiteral("theme:") + themeManager->assetPath(QStringLiteral("icons/lock")), QPixmap pixmap = tryLoadImage("theme:icons/lock", QSize(height, height), true);
QSize(height, height), true);
pmCache.insert(key, pixmap); pmCache.insert(key, pixmap);
return pixmap; return pixmap;
} }
@ -438,8 +411,7 @@ QPixmap DropdownIconPixmapGenerator::generatePixmap(int height, bool expanded)
} }
QString name = expanded ? "dropdown_expanded" : "dropdown_collapsed"; QString name = expanded ? "dropdown_expanded" : "dropdown_collapsed";
QPixmap pixmap = tryLoadImage(QStringLiteral("theme:") + themeManager->assetPath(QStringLiteral("icons/") + name), QPixmap pixmap = tryLoadImage("theme:icons/" + name, QSize(height, height), true);
QSize(height, height), true);
pmCache.insert(key, pixmap); pmCache.insert(key, pixmap);
return pixmap; return pixmap;
@ -500,13 +472,6 @@ QHash<QString, QPixmap> ManaSymbolPixmapGenerator::scaledCache;
QPixmap loadColorAdjustedPixmap(const QString &name) QPixmap loadColorAdjustedPixmap(const QString &name)
{ {
// Prefer an authored scheme-qualified variant when one exists for this asset.
const QString variant = themeManager->schemeVariantPath(QStringView(name).mid(QStringLiteral("theme:").size()));
if (!variant.isEmpty()) {
return QPixmap(QStringLiteral("theme:") + variant);
}
// Legacy fallback: runtime-invert for dark mode when no authored variant.
if (qApp->palette().windowText().color().lightness() > 200) { if (qApp->palette().windowText().color().lightness() > 200) {
QImage img(name); QImage img(name);
img.invertPixels(); img.invertPixels();
@ -517,21 +482,3 @@ QPixmap loadColorAdjustedPixmap(const QString &name)
return QPixmap(name); return QPixmap(name);
} }
} }
QPixmap themePixmap(QStringView prefix)
{
const QString resolved = themeManager->assetPath(prefix);
return QPixmap(QStringLiteral("theme:") + resolved);
}
void clearPixmapGeneratorCaches()
{
PhasePixmapGenerator::clear();
CounterPixmapGenerator::clear();
PingPixmapGenerator::clear();
CountryPixmapGenerator::clear();
UserLevelPixmapGenerator::clear();
LockPixmapGenerator::clear();
DropdownIconPixmapGenerator::clear();
ManaSymbolPixmapGenerator::clear();
}

View file

@ -156,15 +156,4 @@ public:
QPixmap loadColorAdjustedPixmap(const QString &name); QPixmap loadColorAdjustedPixmap(const QString &name);
// Loads a "theme:" asset (with no file extension in prefix), preferring the
// scheme-qualified variant (prefix-dark / prefix-light, resolved via
// ThemeManager::assetPath) and falling back to the plain asset. Callers load
// the returned path directly. Use for scheme-sensitive pixmaps like
// backgrounds, the card back, and the app logo.
QPixmap themePixmap(QStringView prefix);
// Clears every PixmapGenerator's static cache so scheme variants are
// re-resolved when the active theme or color scheme changes.
void clearPixmapGeneratorCaches();
#endif #endif

View file

@ -16,7 +16,7 @@ QString ThemeConfig::toIni() const
out += "[Appearance]\n"; out += "[Appearance]\n";
out += QString("ColorScheme = %1\n").arg(colorScheme.isEmpty() ? "System" : colorScheme); out += QString("ColorScheme = %1\n").arg(colorScheme.isEmpty() ? "System" : colorScheme);
out += "\n[Style]\n"; out += "\n[Style]\n";
out += QString("Name = %1\n").arg(styleName.isEmpty() ? "System" : styleName); out += QString("Name = %1\n").arg(styleName.isEmpty() ? "Default" : styleName);
return out; return out;
} }
@ -96,7 +96,7 @@ bool ThemeConfig::save(const QString &themeDirPath) const
bool PaletteConfig::hasPalette() const bool PaletteConfig::hasPalette() const
{ {
return !colors.isEmpty() || !appColors.isEmpty(); return !colors.isEmpty();
} }
QString PaletteConfig::toToml() const QString PaletteConfig::toToml() const
@ -133,24 +133,6 @@ QString PaletteConfig::toToml() const
out += "\n"; out += "\n";
} }
if (!appColors.isEmpty()) {
QMetaEnum appEnum = QMetaEnum::fromType<AppColor::Role>();
out += "[AppColors]\n";
for (auto it = appColors.cbegin(); it != appColors.cend(); ++it) {
const char *roleName = appEnum.valueToKey(it.key());
if (!roleName) {
continue;
}
out += QString("%1 = %2\n").arg(QString(roleName), -20).arg(it.value().name(QColor::HexArgb));
}
out += "\n";
}
return out; return out;
} }
@ -170,7 +152,6 @@ PaletteConfig PaletteConfig::fromFile(const QString &filePath)
} }
QMetaEnum roleEnum = QMetaEnum::fromType<QPalette::ColorRole>(); QMetaEnum roleEnum = QMetaEnum::fromType<QPalette::ColorRole>();
QMetaEnum appEnum = QMetaEnum::fromType<AppColor::Role>();
QString currentSection; QString currentSection;
QPalette::ColorGroup currentGroup = QPalette::Active; QPalette::ColorGroup currentGroup = QPalette::Active;
@ -221,26 +202,6 @@ PaletteConfig PaletteConfig::fromFile(const QString &filePath)
} }
} }
QColor color(value);
if (!color.isValid()) {
continue;
}
if (currentSection.compare("AppColors", Qt::CaseInsensitive) == 0) {
if (key.startsWith("AppColor::")) {
key = key.mid(10);
}
int appRoleInt = appEnum.keyToValue(key.toUtf8().constData());
if (appRoleInt >= 0) {
cfg.appColors[static_cast<AppColor::Role>(appRoleInt)] = color;
}
continue;
}
if (!currentSection.startsWith("Palette", Qt::CaseInsensitive)) { if (!currentSection.startsWith("Palette", Qt::CaseInsensitive)) {
continue; continue;
} }
@ -255,8 +216,12 @@ PaletteConfig PaletteConfig::fromFile(const QString &filePath)
continue; continue;
} }
QColor color(value);
if (color.isValid()) {
cfg.colors[currentGroup][static_cast<QPalette::ColorRole>(roleInt)] = color; cfg.colors[currentGroup][static_cast<QPalette::ColorRole>(roleInt)] = color;
} }
}
return cfg; return cfg;
} }

View file

@ -3,25 +3,9 @@
#include <QColor> #include <QColor>
#include <QMap> #include <QMap>
#include <QObject>
#include <QPalette> #include <QPalette>
#include <QString> #include <QString>
// Application-specific color roles, layered on top of the fixed QPalette role
// set. Stored in the same palette-<scheme>.toml under an [AppColors] section
// and editable from the palette editor, so theme authors can control colors
// beyond what Qt's palette can express.
namespace AppColor
{
Q_NAMESPACE
enum Role
{
AccentStrong,
AccentSoft,
};
Q_ENUM_NS(Role)
} // namespace AppColor
struct ThemeConfig struct ThemeConfig
{ {
QString colorScheme; QString colorScheme;
@ -37,16 +21,7 @@ struct ThemeConfig
struct PaletteConfig struct PaletteConfig
{ {
QMap<QPalette::ColorGroup, QMap<QPalette::ColorRole, QColor>> colors; QMap<QPalette::ColorGroup, QMap<QPalette::ColorRole, QColor>> colors;
QMap<AppColor::Role, QColor> appColors;
bool operator==(const PaletteConfig &rhs) const
{
return colors == rhs.colors && appColors == rhs.appColors;
}
bool operator!=(const PaletteConfig &rhs) const
{
return !(*this == rhs);
}
bool hasPalette() const; bool hasPalette() const;
QString toToml() const; QString toToml() const;

View file

@ -1,13 +1,10 @@
#include "theme_manager.h" #include "theme_manager.h"
#include "../../client/settings/cache_settings.h" #include "../../client/settings/cache_settings.h"
#include "pixel_map_generator.h"
#include <QApplication> #include <QApplication>
#include <QColor> #include <QColor>
#include <QDebug> #include <QDebug>
#include <QFile>
#include <QFileInfo>
#include <QLibraryInfo> #include <QLibraryInfo>
#include <QMap> #include <QMap>
#include <QMetaEnum> #include <QMetaEnum>
@ -22,7 +19,7 @@
#include <Qt> #include <Qt>
#include <libcockatrice/settings/paths_settings.h> #include <libcockatrice/settings/paths_settings.h>
#define SYSTEM_THEME_NAME "System" #define NONE_THEME_NAME "Default"
#define FUSION_THEME_NAME "Fusion" #define FUSION_THEME_NAME "Fusion"
#define STYLE_CSS_NAME "style.css" #define STYLE_CSS_NAME "style.css"
#define HANDZONE_BG_NAME "handzone" #define HANDZONE_BG_NAME "handzone"
@ -95,11 +92,9 @@ struct PaletteColorInfo
static QString usableDefaultStyle(const QString &style) static QString usableDefaultStyle(const QString &style)
{ {
// The Windows 11 native style is broken: dragging cards across zones can // The Windows 11 native style is broken: when the OS default
// shrink the board to a tiny grey window that is unfixable without // ("Default" theme selection) would use it, fall back to the Vista style.
// rejoining. It is never usable, so guard against it no matter how it was // Explicitly choosing "windows11" in a theme is still honored.
// requested (OS default or an explicit "windows11" theme choice) and fall
// back to the Vista style.
return style.compare("windows11", Qt::CaseInsensitive) == 0 ? QStringLiteral("windowsvista") : style; return style.compare("windows11", Qt::CaseInsensitive) == 0 ? QStringLiteral("windowsvista") : style;
} }
@ -111,10 +106,7 @@ ThemeManager::ThemeManager(QObject *parent) : QObject(parent)
ensureThemeDirectoryExists(); ensureThemeDirectoryExists();
#if (QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)) #if (QT_VERSION >= QT_VERSION_CHECK(6, 5, 0))
connect(QGuiApplication::styleHints(), &QStyleHints::colorSchemeChanged, this, [this] { connect(QGuiApplication::styleHints(), &QStyleHints::colorSchemeChanged, this, [this] {
// Reload so scheme-qualified assets and palettes follow the OS, but do defaultPalette = qApp->palette();
// NOT recapture defaultPalette: qApp->palette() already carries the
// currently-applied theme palette at this point, so recapturing it
// would contaminate the base for every later theme switch.
themeChangedSlot(); themeChangedSlot();
}); });
#endif #endif
@ -124,16 +116,10 @@ ThemeManager::ThemeManager(QObject *parent) : QObject(parent)
void ThemeManager::ensureThemeDirectoryExists() void ThemeManager::ensureThemeDirectoryExists()
{ {
auto &settings = SettingsCache::instance(); if (SettingsCache::instance().getThemeName().isEmpty() ||
!getAvailableThemes().contains(SettingsCache::instance().getThemeName())) {
// Migrate the old "Default" theme name to "System"
if (settings.getThemeName() == "Default") {
settings.setThemeName(SYSTEM_THEME_NAME);
}
if (settings.getThemeName().isEmpty() || !getAvailableThemes().contains(settings.getThemeName())) {
qCInfo(ThemeManagerLog) << "Theme name not set, setting default value"; qCInfo(ThemeManagerLog) << "Theme name not set, setting default value";
settings.setThemeName(FUSION_THEME_NAME); SettingsCache::instance().setThemeName(NONE_THEME_NAME);
} }
} }
@ -154,74 +140,11 @@ bool ThemeManager::isDarkMode(const QString &themeDirPath) const
} }
} }
QString ThemeManager::schemeVariantPath(QStringView prefix) const bool ThemeManager::isBuiltInTheme()
{ {
static const QStringList formats = {QStringLiteral(".png"), QStringLiteral(".jpg"), QStringLiteral(".jpeg"), const auto themeName = SettingsCache::instance().getThemeName();
QStringLiteral(".svg")};
const QString scheme = isDarkMode(currentThemePath) ? QStringLiteral("dark") : QStringLiteral("light");
const QString variantStem = prefix.toString() + QLatin1Char('-') + scheme;
for (const QString &format : formats) { return themeName == NONE_THEME_NAME || themeName == FUSION_THEME_NAME;
if (QFileInfo::exists(QStringLiteral("theme:") + variantStem + format)) {
return variantStem + format;
}
}
return QString();
}
QString ThemeManager::assetPath(QStringView prefix) const
{
// Probe order mirrors tryLoadImage: a theme may override the default SVG
// with a raster of the same stem, so raster wins over SVG within a stem.
static const QStringList formats = {QStringLiteral(".png"), QStringLiteral(".jpg"), QStringLiteral(".jpeg"),
QStringLiteral(".svg")};
auto findExisting = [](const QString &stem) {
for (const QString &format : formats) {
if (QFileInfo::exists(QStringLiteral("theme:") + stem + format)) {
return stem + format;
}
}
return QString();
};
// Prefer the scheme-qualified variant when it exists, else the plain
// asset as the super fallback. Both return the resolved path including
// its file extension so callers can load it directly.
const QString variant = schemeVariantPath(prefix);
if (!variant.isEmpty()) {
return variant;
}
const QString resolvedPlain = findExisting(prefix.toString());
return resolvedPlain.isEmpty() ? prefix.toString() : resolvedPlain;
}
// Probe whether a directory is truly writable by trying to create and remove a
// temporary file. QFileInfo::isWritable() on a directory is unreliable (notably
// on Windows where UAC VirtualStore can make a system dir appear writable).
bool ThemeManager::isDirReallyWritable(const QString &dirPath)
{
const QString probe = QDir(dirPath).absoluteFilePath(".cockatrice_write_test");
QFile f(probe);
if (!f.open(QIODevice::WriteOnly)) {
return false;
}
f.close();
f.remove();
return true;
}
QString ThemeManager::writableThemeDir(const QString &themeName)
{
// All theme writes go to the user themes directory regardless of whether
// the resolved (system) theme directory happens to be writable. Even when a
// write would succeed in-place, routing it to the user directory keeps the
// install intact and guarantees changes survive upgrades.
const QString dirPath = QDir(SettingsCache::instance().paths().getThemesPath()).absoluteFilePath(themeName);
if (!QDir().mkpath(dirPath)) {
qWarning() << "Failed to create theme save directory:" << dirPath;
}
return dirPath;
} }
// System (read-only) themes location, relative to the application binary. // System (read-only) themes location, relative to the application binary.
@ -246,7 +169,9 @@ QStringMap &ThemeManager::getAvailableThemes()
// load themes from user profile dir // load themes from user profile dir
dir.setPath(SettingsCache::instance().paths().getThemesPath()); dir.setPath(SettingsCache::instance().paths().getThemesPath());
availableThemes.insert(SYSTEM_THEME_NAME, dir.absoluteFilePath("System")); // add default value
availableThemes.insert(NONE_THEME_NAME, dir.absoluteFilePath("Default"));
availableThemes.insert(FUSION_THEME_NAME, dir.absoluteFilePath("Fusion")); availableThemes.insert(FUSION_THEME_NAME, dir.absoluteFilePath("Fusion"));
for (QString themeName : dir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot, QDir::Name)) { for (QString themeName : dir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot, QDir::Name)) {
@ -255,7 +180,7 @@ QStringMap &ThemeManager::getAvailableThemes()
} }
} }
// Load themes from Cockatrice system dir // load themes from cockatrice system dir
dir.setPath(systemThemesBasePath()); dir.setPath(systemThemesBasePath());
for (QString themeName : dir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot, QDir::Name)) { for (QString themeName : dir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot, QDir::Name)) {
@ -270,7 +195,7 @@ QStringMap &ThemeManager::getAvailableThemes()
QBrush ThemeManager::loadBrush(QString fileName, QColor fallbackColor) QBrush ThemeManager::loadBrush(QString fileName, QColor fallbackColor)
{ {
QBrush brush; QBrush brush;
QPixmap tmp = QPixmap("theme:" + assetPath(QStringLiteral("zones/") + fileName)); QPixmap tmp = QPixmap("theme:zones/" + fileName);
if (tmp.isNull()) { if (tmp.isNull()) {
brush.setColor(fallbackColor); brush.setColor(fallbackColor);
brush.setStyle(Qt::SolidPattern); brush.setStyle(Qt::SolidPattern);
@ -284,7 +209,7 @@ QBrush ThemeManager::loadBrush(QString fileName, QColor fallbackColor)
QBrush ThemeManager::loadExtraBrush(QString fileName, QBrush &fallbackBrush) QBrush ThemeManager::loadExtraBrush(QString fileName, QBrush &fallbackBrush)
{ {
QBrush brush; QBrush brush;
QPixmap tmp = QPixmap("theme:" + assetPath(QStringLiteral("zones/") + fileName)); QPixmap tmp = QPixmap("theme:zones/" + fileName);
if (tmp.isNull()) { if (tmp.isNull()) {
brush = fallbackBrush; brush = fallbackBrush;
@ -362,7 +287,7 @@ bool ThemeManager::commitPalette(const QString &themeDirPath, const QString &col
void ThemeManager::setColorScheme(const QString &scheme) void ThemeManager::setColorScheme(const QString &scheme)
{ {
const QString dirPath = writableThemeDir(SettingsCache::instance().getThemeName()); const QString dirPath = getAvailableThemes().value(SettingsCache::instance().getThemeName());
ThemeConfig cfg = ThemeConfig::fromThemeDir(dirPath); ThemeConfig cfg = ThemeConfig::fromThemeDir(dirPath);
cfg.colorScheme = scheme; cfg.colorScheme = scheme;
@ -373,7 +298,7 @@ void ThemeManager::setColorScheme(const QString &scheme)
void ThemeManager::setStyleName(const QString &styleName) void ThemeManager::setStyleName(const QString &styleName)
{ {
const QString dirPath = writableThemeDir(SettingsCache::instance().getThemeName()); const QString dirPath = getAvailableThemes().value(SettingsCache::instance().getThemeName());
ThemeConfig cfg = ThemeConfig::fromThemeDir(dirPath); ThemeConfig cfg = ThemeConfig::fromThemeDir(dirPath);
cfg.styleName = styleName; cfg.styleName = styleName;
@ -404,7 +329,7 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName,
Q_UNUSED(activeScheme) Q_UNUSED(activeScheme)
#endif #endif
QString styleName = themeCfg.styleName; QString styleName = themeCfg.styleName;
if (styleName.isEmpty() || styleName.compare("System", Qt::CaseInsensitive) == 0) { if (styleName.isEmpty() || styleName.compare("Default", Qt::CaseInsensitive) == 0) {
if (themeName == FUSION_THEME_NAME) { if (themeName == FUSION_THEME_NAME) {
styleName = "Fusion"; styleName = "Fusion";
} else { } else {
@ -412,10 +337,6 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName,
} }
} }
// The Windows 11 style is broken even when selected explicitly in a theme,
// so sanitize the resolved name here rather than trusting the theme config.
styleName = usableDefaultStyle(styleName);
QStyle *style = QStyleFactory::create(styleName); QStyle *style = QStyleFactory::create(styleName);
if (!style) { if (!style) {
style = QStyleFactory::create(usableDefaultStyle(defaultStyleName)); style = QStyleFactory::create(usableDefaultStyle(defaultStyleName));
@ -451,8 +372,6 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName,
qApp->setPalette(base); qApp->setPalette(base);
qApp->setStyle(style); qApp->setStyle(style);
currentAppColors = palCfg.appColors;
// Force every widget to re-polish and repaint immediately rather than // Force every widget to re-polish and repaint immediately rather than
// waiting for natural expose events, which produces a patchwork of old // waiting for natural expose events, which produces a patchwork of old
// and new colours during a live preview. // and new colours during a live preview.
@ -465,35 +384,6 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName,
style->polish(widget); style->polish(widget);
widget->update(); widget->update();
} }
emit paletteChanged();
}
QColor ThemeManager::appColor(AppColor::Role role) const
{
const auto it = currentAppColors.constFind(role);
if (it != currentAppColors.constEnd()) {
return it.value();
}
// QPalette::Accent was introduced in Qt 6.6 and several shipped palettes
// set it to a value barely distinguishable from Window, so it is not a
// reliable accent source. The selection highlight is the stable accent
// (Accent defaults to Highlight when unset), and deriving from it
// unconditionally keeps every Qt version rendering identically.
const QColor accent = qApp->palette().color(QPalette::Active, QPalette::Highlight);
if (role == AppColor::AccentSoft) {
constexpr int SOFT_SATURATION_PERCENT = 70;
constexpr int SOFT_LIGHTNESS_OFFSET = 60;
// Light end of the gradient: same hue, softened and lightened
return QColor::fromHsl(qMax(0, accent.hslHue()),
qBound(0, qRound(accent.hslSaturation() * SOFT_SATURATION_PERCENT / 100.0), 255),
qBound(0, accent.lightness() + SOFT_LIGHTNESS_OFFSET, 255));
}
return accent;
} }
void ThemeManager::themeChangedSlot() void ThemeManager::themeChangedSlot()
@ -503,22 +393,12 @@ void ThemeManager::themeChangedSlot()
currentThemePath = dirPath; currentThemePath = dirPath;
QDir dir(dirPath); QDir dir(dirPath);
// CSS — prefer the scheme-qualified stylesheet (style-dark.css / // CSS
// style-light.css) when present, else the plain style.css as fallback. if (!dirPath.isEmpty() && dir.exists(STYLE_CSS_NAME)) {
if (!dirPath.isEmpty()) {
const QString scheme = isDarkMode(dirPath) ? QStringLiteral("dark") : QStringLiteral("light");
const QString schemeCss = QFileInfo(QStringLiteral(STYLE_CSS_NAME)).completeBaseName() + QLatin1Char('-') +
scheme + QStringLiteral(".css");
if (dir.exists(schemeCss)) {
qApp->setStyleSheet("file:///" + dir.absoluteFilePath(schemeCss));
} else if (dir.exists(STYLE_CSS_NAME)) {
qApp->setStyleSheet("file:///" + dir.absoluteFilePath(STYLE_CSS_NAME)); qApp->setStyleSheet("file:///" + dir.absoluteFilePath(STYLE_CSS_NAME));
} else { } else {
qApp->setStyleSheet(""); qApp->setStyleSheet("");
} }
} else {
qApp->setStyleSheet("");
}
// load theme.cfg for style + scheme preference // load theme.cfg for style + scheme preference
ThemeConfig themeCfg = ThemeConfig::fromThemeDir(dirPath); ThemeConfig themeCfg = ThemeConfig::fromThemeDir(dirPath);
@ -530,19 +410,8 @@ void ThemeManager::themeChangedSlot()
// ── Load palette: custom first, then theme default ──────────────────── // ── Load palette: custom first, then theme default ────────────────────
PaletteConfig palette = PaletteConfig::fromScheme(dirPath, activeScheme); PaletteConfig palette = PaletteConfig::fromScheme(dirPath, activeScheme);
const PaletteConfig themeDefault = ThemeManager::loadDefaultPaletteConfig(dirPath, themeName, activeScheme); if (!palette.hasPalette()) {
if (palette.hasPalette()) { palette = ThemeManager::loadDefaultPaletteConfig(dirPath, themeName, activeScheme);
// A custom palette written before [AppColors] existed carries no app
// colors; merge the theme's shipped defaults so the identity colors
// survive (hasPalette() counts an app-colors-only file as a palette,
// so those are kept wholesale and never reach here empty).
for (auto it = themeDefault.appColors.cbegin(); it != themeDefault.appColors.cend(); ++it) {
if (!palette.appColors.contains(it.key())) {
palette.appColors.insert(it.key(), it.value());
}
}
} else {
palette = themeDefault;
} }
applyStyleAndPalette(themeName, themeCfg, palette, activeScheme); applyStyleAndPalette(themeName, themeCfg, palette, activeScheme);
@ -577,7 +446,6 @@ void ThemeManager::themeChangedSlot()
} }
QPixmapCache::clear(); QPixmapCache::clear();
clearPixmapGeneratorCaches();
emit themeChanged(); emit themeChanged();
} }

View file

@ -50,7 +50,6 @@ private:
QString currentThemePath; QString currentThemePath;
std::array<QBrush, Role::MaxRole + 1> brushes; std::array<QBrush, Role::MaxRole + 1> brushes;
QStringMap availableThemes; QStringMap availableThemes;
QMap<AppColor::Role, QColor> currentAppColors;
/* /*
Internal cache for multiple backgrounds Internal cache for multiple backgrounds
*/ */
@ -66,16 +65,7 @@ protected:
const QString &activeScheme); const QString &activeScheme);
public: public:
// Resolves the directory to write theme changes to for the given theme bool isBuiltInTheme();
// name. The resolved theme dir (user or system) is used when writable;
// read-only system themes fall back to the user themes directory, creating
// it if needed, so customisations never get lost on upgrade.
static QString writableThemeDir(const QString &themeName);
// Probe whether a directory is truly writable by trying to create and remove
// a temporary file. QFileInfo::isWritable() on a directory is unreliable
// (notably on Windows where UAC VirtualStore can make a system dir appear
// writable).
static bool isDirReallyWritable(const QString &dirPath);
// Explicit color scheme of the theme: theme.cfg's ColorScheme setting // Explicit color scheme of the theme: theme.cfg's ColorScheme setting
// (Dark/Light), falling back to the OS color scheme when it is "System". // (Dark/Light), falling back to the OS color scheme when it is "System".
bool isDarkMode(const QString &themeDirPath) const; bool isDarkMode(const QString &themeDirPath) const;
@ -97,20 +87,6 @@ public:
// Load/save per-scheme palette colors // Load/save per-scheme palette colors
static PaletteConfig loadPaletteConfig(const QString &themeDirPath, const QString &colorScheme); static PaletteConfig loadPaletteConfig(const QString &themeDirPath, const QString &colorScheme);
static bool savePaletteConfig(const QString &themeDirPath, const QString &colorScheme, const PaletteConfig &cfg); static bool savePaletteConfig(const QString &themeDirPath, const QString &colorScheme, const PaletteConfig &cfg);
// Resolve prefix to a scheme-qualified "theme:" path. Existence is probed
// internally across the formats themes may ship (.png/.jpg/.svg), so
// callers load the returned path directly. Prefers "<prefix>-<dark|light>"
// when a file exists at that stem, otherwise the plain "<prefix>" as the
// super fallback. The resolved scheme covers explicit light/dark as well
// as OS-resolved "system". Returns the path with its file extension when a
// match is found; unqualified assets keep working unchanged.
QString assetPath(QStringView prefix) const;
// Like assetPath, but resolves only the scheme-qualified variant
// ("<prefix>-<dark|light>.<ext>") and returns an empty string when no
// variant exists — it never falls back to the plain "<prefix>" asset.
// Callers that must distinguish "no authored variant" (e.g. to keep a
// legacy runtime fallback alive) should use this instead of assetPath.
QString schemeVariantPath(QStringView prefix) const;
// Load the theme's shipped default palette, falling back to the system // Load the theme's shipped default palette, falling back to the system
// theme directory when it is absent from the resolved (user) directory. // theme directory when it is absent from the resolved (user) directory.
static PaletteConfig static PaletteConfig
@ -125,17 +101,12 @@ public:
void reloadCurrentTheme(); void reloadCurrentTheme();
void previewPalette(const PaletteConfig &cfg, const QString &scheme); void previewPalette(const PaletteConfig &cfg, const QString &scheme);
// Resolves an application color role: the theme's stored [AppColors] value
// when present, otherwise a palette-accent-derived fallback.
QColor appColor(AppColor::Role role) const;
QBrush &getBgBrush(Role zone); QBrush &getBgBrush(Role zone);
QBrush getExtraBgBrush(Role zone, int zoneId = 0); QBrush getExtraBgBrush(Role zone, int zoneId = 0);
protected slots: protected slots:
void themeChangedSlot(); void themeChangedSlot();
signals: signals:
void themeChanged(); void themeChanged();
void paletteChanged();
}; };
extern ThemeManager *themeManager; extern ThemeManager *themeManager;

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