mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-08 02:53:25 -08:00
Compare commits
31 commits
64f58535dd
...
b7253efefb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7253efefb | ||
|
|
daceb04a2b | ||
|
|
456d9d9e70 | ||
|
|
213ce691dd | ||
|
|
96b0888a73 | ||
|
|
bbf0425655 | ||
|
|
fec693ca2a | ||
|
|
677692500b | ||
|
|
fea12a74e2 | ||
|
|
0c10798ef4 | ||
|
|
eefe0bd672 | ||
|
|
25c85cf0bb | ||
|
|
cce606764c | ||
|
|
24874b10cf | ||
|
|
a712553c81 | ||
|
|
bac2d285e3 | ||
|
|
96b819d7c8 | ||
|
|
8968709642 | ||
|
|
1a4d89ff8f | ||
|
|
4feb31bd1a | ||
|
|
4c5a5e3e55 | ||
|
|
5d06bb903d | ||
|
|
57a6a0d9cb | ||
|
|
f83a61979f | ||
|
|
07eb9f0103 | ||
|
|
38acddeede | ||
|
|
74a6acde91 | ||
|
|
4a010bd17f | ||
|
|
315cbae345 | ||
|
|
bdda3dedfa | ||
|
|
8822ade49d |
135 changed files with 5147 additions and 1337 deletions
|
|
@ -4,7 +4,6 @@ RUN apt-get update && \
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
build-essential \
|
||||||
ccache \
|
ccache \
|
||||||
clang-format \
|
|
||||||
cmake \
|
cmake \
|
||||||
file \
|
file \
|
||||||
g++ \
|
g++ \
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ RUN apt-get update && \
|
||||||
build-essential \
|
build-essential \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
ccache \
|
ccache \
|
||||||
clang-format \
|
|
||||||
cmake \
|
cmake \
|
||||||
file \
|
file \
|
||||||
g++ \
|
g++ \
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ RUN apt-get update && \
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
build-essential \
|
||||||
ccache \
|
ccache \
|
||||||
clang-format \
|
|
||||||
cmake \
|
cmake \
|
||||||
file \
|
file \
|
||||||
g++ \
|
g++ \
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ RUN apt-get update && \
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
build-essential \
|
build-essential \
|
||||||
ccache \
|
ccache \
|
||||||
clang-format \
|
|
||||||
cmake \
|
cmake \
|
||||||
file \
|
file \
|
||||||
g++ \
|
g++ \
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,10 @@ RUN apt-get update && \
|
||||||
build-essential \
|
build-essential \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
ccache \
|
ccache \
|
||||||
clang-format \
|
|
||||||
cmake \
|
cmake \
|
||||||
file \
|
file \
|
||||||
g++ \
|
g++ \
|
||||||
|
gcovr \
|
||||||
git \
|
git \
|
||||||
libgl-dev \
|
libgl-dev \
|
||||||
liblzma-dev \
|
liblzma-dev \
|
||||||
|
|
|
||||||
151
.ci/compile.sh
151
.ci/compile.sh
|
|
@ -8,14 +8,18 @@
|
||||||
# --suffix <suffix> renames package with this suffix, requires arg
|
# --suffix <suffix> renames package with this suffix, requires arg
|
||||||
# --server compiles servatrice
|
# --server compiles servatrice
|
||||||
# --test runs tests
|
# --test runs tests
|
||||||
|
# --sanitize builds with AddressSanitizer and UndefinedBehaviorSanitizer, gcc/clang only
|
||||||
|
# --coverage instruments the test run for line coverage and prints a gcovr summary, gcc only
|
||||||
|
|
||||||
# --debug or --release sets the build type ie CMAKE_BUILD_TYPE
|
# --debug or --release sets the build type ie CMAKE_BUILD_TYPE
|
||||||
# --ccache [<size>] uses ccache and shows stats, optionally provide size
|
# --ccache [<size>] uses ccache and shows stats, optionally provide size
|
||||||
# --evict-ccache <age> runs ccache eviction based on given age after build
|
# --evict-ccache <age> runs ccache eviction based on given age after build
|
||||||
# --dir <dir> sets the name of the build dir, default is "build"
|
# --dir <dir> sets the name of the build dir, default is "build"
|
||||||
# --cmake-generator <generator> sets CMAKE_GENERATOR as used by cmake
|
# --cmake-generator <generator> sets CMAKE_GENERATOR as used by cmake
|
||||||
# --target-macos-version <version> sets the min os version - only used for macOS builds
|
# --target-macos-version <version> sets the min os version - only used for macOS builds
|
||||||
# uses env: BUILDTYPE MAKE_INSTALL MAKE_PACKAGE PACKAGE_TYPE PACKAGE_SUFFIX MAKE_SERVER MAKE_NO_CLIENT MAKE_TEST USE_CCACHE CCACHE_SIZE CCACHE_EVICTION_AGE BUILD_DIR CMAKE_GENERATOR TARGET_MACOS_VERSION
|
# --profile writes an MSBuild performance summary and binary log, Windows only
|
||||||
# (correspond to args: --debug/--release --install --package <package type> --suffix <suffix> --server --test --ccache <ccache_size> --dir <dir>)
|
# uses env: BUILDTYPE MAKE_INSTALL MAKE_PACKAGE PACKAGE_TYPE PACKAGE_SUFFIX MAKE_SERVER MAKE_NO_CLIENT MAKE_TEST USE_CCACHE CCACHE_SIZE CCACHE_EVICTION_AGE BUILD_DIR CMAKE_GENERATOR TARGET_MACOS_VERSION MAKE_SANITIZE MAKE_COVERAGE VCPKG_TARGET_MACOS_VERSION BUILD_PARALLEL_LEVEL BUILD_PROFILE
|
||||||
|
# (correspond to args: --debug/--release --install --package <package type> --suffix <suffix> --server --test --sanitize --coverage --ccache <ccache_size> --dir <dir> --profile)
|
||||||
# exitcode: 1 for failure, 3 for invalid arguments
|
# exitcode: 1 for failure, 3 for invalid arguments
|
||||||
|
|
||||||
# Read arguments
|
# Read arguments
|
||||||
|
|
@ -57,6 +61,14 @@ while [[ $# != 0 ]]; do
|
||||||
MAKE_TEST=1
|
MAKE_TEST=1
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
'--sanitize')
|
||||||
|
MAKE_SANITIZE=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
'--coverage')
|
||||||
|
MAKE_COVERAGE=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
'--debug')
|
'--debug')
|
||||||
BUILDTYPE="Debug"
|
BUILDTYPE="Debug"
|
||||||
shift
|
shift
|
||||||
|
|
@ -104,6 +116,10 @@ while [[ $# != 0 ]]; do
|
||||||
export CMAKE_GENERATOR=$1
|
export CMAKE_GENERATOR=$1
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
'--profile')
|
||||||
|
BUILD_PROFILE=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
'--target-macos-version')
|
'--target-macos-version')
|
||||||
shift
|
shift
|
||||||
if [[ $# == 0 ]]; then
|
if [[ $# == 0 ]]; then
|
||||||
|
|
@ -131,6 +147,15 @@ if [[ ! $BUILD_DIR ]]; then
|
||||||
BUILD_DIR="build"
|
BUILD_DIR="build"
|
||||||
fi
|
fi
|
||||||
mkdir -p "$BUILD_DIR"
|
mkdir -p "$BUILD_DIR"
|
||||||
|
|
||||||
|
if [[ $MAKE_COVERAGE ]]; then
|
||||||
|
# Resolved while the working directory is still the source root. $0 is relative to the
|
||||||
|
# directory the script was invoked from, so after the cd below it no longer resolves, and
|
||||||
|
# under `set -e` the failed subshell would abort the build rather than just yielding an empty
|
||||||
|
# variable. Only the coverage report needs this, so only coverage pays for it.
|
||||||
|
SRC_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
fi
|
||||||
|
|
||||||
cd "$BUILD_DIR"
|
cd "$BUILD_DIR"
|
||||||
|
|
||||||
# Set minimum CMake Version
|
# Set minimum CMake Version
|
||||||
|
|
@ -147,6 +172,17 @@ fi
|
||||||
if [[ $MAKE_TEST ]]; then
|
if [[ $MAKE_TEST ]]; then
|
||||||
flags+=("-DTEST=1")
|
flags+=("-DTEST=1")
|
||||||
fi
|
fi
|
||||||
|
if [[ $MAKE_SANITIZE ]]; then
|
||||||
|
flags+=("-DENABLE_SANITIZERS=ON")
|
||||||
|
else
|
||||||
|
# The release-package step re-runs cmake in the same build dir, so an
|
||||||
|
# ENABLE_SANITIZERS=ON left over from the debug step would otherwise leak
|
||||||
|
# into the shipped binaries (libasan/libubsan Depends in the DEB).
|
||||||
|
flags+=("-DENABLE_SANITIZERS=OFF")
|
||||||
|
fi
|
||||||
|
if [[ $MAKE_COVERAGE ]]; then
|
||||||
|
flags+=("-DENABLE_COVERAGE=ON")
|
||||||
|
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
|
# PCH-aware caching is required or ccache refuses to cache any TU that
|
||||||
|
|
@ -178,6 +214,22 @@ function ccachestatsverbose() {
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function cpuinfo() {
|
||||||
|
# The number of cores decides how much build parallelism is useful; the hosted
|
||||||
|
# runners are not all sized alike, so read it off the machine instead of assuming.
|
||||||
|
case "$RUNNER_OS" in
|
||||||
|
Windows)
|
||||||
|
echo "cores: ${NUMBER_OF_PROCESSORS:-unknown}"
|
||||||
|
;;
|
||||||
|
macOS)
|
||||||
|
echo "cores: $(sysctl -n hw.ncpu)"
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "cores: $(nproc 2>/dev/null || echo unknown)"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
# Compile
|
# Compile
|
||||||
if [[ $RUNNER_OS == macOS ]]; then
|
if [[ $RUNNER_OS == macOS ]]; then
|
||||||
# QTDIR is needed for macOS since we actually only use the cached thin Qt binaries instead of the install-qt-action,
|
# QTDIR is needed for macOS since we actually only use the cached thin Qt binaries instead of the install-qt-action,
|
||||||
|
|
@ -212,8 +264,14 @@ if [[ $RUNNER_OS == macOS ]]; then
|
||||||
triplet_source="../vcpkg/triplets/community/$arch-osx.cmake"
|
triplet_source="../vcpkg/triplets/community/$arch-osx.cmake"
|
||||||
fi
|
fi
|
||||||
cp "$triplet_source" "$triplet_file"
|
cp "$triplet_source" "$triplet_file"
|
||||||
echo "set(VCPKG_CMAKE_SYSTEM_VERSION $TARGET_MACOS_VERSION)" >>"$triplet_file"
|
# The triplet is what vcpkg hashes into the ABI hash of every port, so two legs that
|
||||||
echo "set(VCPKG_OSX_DEPLOYMENT_TARGET $TARGET_MACOS_VERSION)" >>"$triplet_file"
|
# agree here reuse each other's binary cache. Targets 14, 15 and 26 on the same Xcode
|
||||||
|
# produce identical binaries when they share the lowest deployment target, so they all
|
||||||
|
# build against 14 to keep one vcpkg cache scope instead of three.
|
||||||
|
# Keep in sync with "vcpkg_scope" and "vcpkg_deployment_target" in desktop-build.yml.
|
||||||
|
vcpkg_target="${VCPKG_TARGET_MACOS_VERSION:-$TARGET_MACOS_VERSION}"
|
||||||
|
echo "set(VCPKG_CMAKE_SYSTEM_VERSION $vcpkg_target)" >>"$triplet_file"
|
||||||
|
echo "set(VCPKG_OSX_DEPLOYMENT_TARGET $vcpkg_target)" >>"$triplet_file"
|
||||||
flags+=("-DVCPKG_OVERLAY_TRIPLETS=$triplets_dir")
|
flags+=("-DVCPKG_OVERLAY_TRIPLETS=$triplets_dir")
|
||||||
flags+=("-DVCPKG_HOST_TRIPLET=$triplet_version")
|
flags+=("-DVCPKG_HOST_TRIPLET=$triplet_version")
|
||||||
flags+=("-DVCPKG_TARGET_TRIPLET=$triplet_version")
|
flags+=("-DVCPKG_TARGET_TRIPLET=$triplet_version")
|
||||||
|
|
@ -256,9 +314,34 @@ if [[ $RUNNER_OS == macOS ]]; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
elif [[ $RUNNER_OS == Windows ]]; then
|
elif [[ $RUNNER_OS == Windows ]]; then
|
||||||
# Enable MTT, see https://devblogs.microsoft.com/cppblog/improved-parallelism-in-msbuild/
|
if [[ $CMAKE_GENERATOR == "Visual Studio"* ]]; then
|
||||||
# and https://devblogs.microsoft.com/cppblog/cpp-build-throughput-investigation-and-tune-up/#multitooltask-mtt
|
# cmake only hands MSBuild a job count when one is asked for, and without it
|
||||||
buildflags+=(-- -p:UseMultiToolTask=true -p:EnableClServerMode=true)
|
# MSBuild builds a single node: one project at a time, whatever MTT does inside
|
||||||
|
# it. Ask for a node per core.
|
||||||
|
if [[ ! $BUILD_PARALLEL_LEVEL ]]; then
|
||||||
|
BUILD_PARALLEL_LEVEL="${NUMBER_OF_PROCESSORS:-1}"
|
||||||
|
fi
|
||||||
|
buildflags+=(--parallel "$BUILD_PARALLEL_LEVEL")
|
||||||
|
|
||||||
|
# Enable MTT, see https://devblogs.microsoft.com/cppblog/improved-parallelism-in-msbuild/
|
||||||
|
# and https://devblogs.microsoft.com/cppblog/cpp-build-throughput-investigation-and-tune-up/#multitooltask-mtt
|
||||||
|
# EnforceProcessCountAcrossBuilds makes the CL_MPCount ceiling apply across all
|
||||||
|
# the projects the nodes are building, instead of each node running its own
|
||||||
|
# cl.exe processes and /m and MTT multiplying into an oversubscribed machine.
|
||||||
|
buildflags+=(-- -p:UseMultiToolTask=true -p:EnableClServerMode=true -p:EnforceProcessCountAcrossBuilds=true -p:CL_MPCount="$BUILD_PARALLEL_LEVEL")
|
||||||
|
if [[ $BUILD_PROFILE ]]; then
|
||||||
|
# A performance summary says which projects and tasks cost what, the binary log
|
||||||
|
# keeps the full timeline; both are far too noisy to always produce. The files
|
||||||
|
# land next to the solution, i.e. in the build dir the workflow uploads. These
|
||||||
|
# join the MTT options above, which already carried the "--" separator.
|
||||||
|
buildflags+=("-flp:PerformanceSummary;v=q;LogFile=msbuild-perf.log" -bl:msbuild.binlog)
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Ninja runs the compiler itself, so it neither takes the MSBuild switches
|
||||||
|
# (it rejects -p: options outright) nor a job count: it sizes its own pool
|
||||||
|
# for the cores it finds.
|
||||||
|
echo "ninja $(ninja --version)"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ $USE_CCACHE ]]; then
|
if [[ $USE_CCACHE ]]; then
|
||||||
|
|
@ -274,10 +357,19 @@ cmake .. "${flags[@]}"
|
||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
|
|
||||||
echo "::group::Build project"
|
echo "::group::Build project"
|
||||||
|
cpuinfo
|
||||||
echo "Running cmake --build with flags: ${buildflags[*]}"
|
echo "Running cmake --build with flags: ${buildflags[*]}"
|
||||||
cmake --build . "${buildflags[@]}"
|
cmake --build . "${buildflags[@]}"
|
||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
|
|
||||||
|
if [[ $BUILD_PROFILE && $RUNNER_OS == Windows && -f msbuild-perf.log ]]; then
|
||||||
|
# Repeat the summary in the job log so the numbers are readable without
|
||||||
|
# downloading the binary log, which is hundreds of megabytes.
|
||||||
|
echo "::group::MSBuild performance summary"
|
||||||
|
cat msbuild-perf.log
|
||||||
|
echo "::endgroup::"
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ $USE_CCACHE ]]; then
|
if [[ $USE_CCACHE ]]; then
|
||||||
if [[ $CCACHE_EVICTION_AGE ]]; then
|
if [[ $CCACHE_EVICTION_AGE ]]; then
|
||||||
echo "::group::evict ccache files older than $CCACHE_EVICTION_AGE"
|
echo "::group::evict ccache files older than $CCACHE_EVICTION_AGE"
|
||||||
|
|
@ -306,7 +398,50 @@ fi
|
||||||
|
|
||||||
if [[ $MAKE_TEST ]]; then
|
if [[ $MAKE_TEST ]]; then
|
||||||
echo "::group::Run tests"
|
echo "::group::Run tests"
|
||||||
ctest -C "$BUILDTYPE" --output-on-failure
|
if [[ $MAKE_SANITIZE ]]; then
|
||||||
|
# detect_leaks=0: Qt and the sanitizers' own runtimes both leak at exit by design, and
|
||||||
|
# LeakSanitizer's default is aggressive enough to bury our real findings under hundreds
|
||||||
|
# of process-teardown reports. Malloc/free misuse and use-after-free are what ASan is for
|
||||||
|
# and they stay fully on.
|
||||||
|
# abort_on_error=1: a bad access leaves the process in an unusable state, so continuing
|
||||||
|
# only produces a second, more confusing report from the wreckage.
|
||||||
|
# UBSAN halt_on_error=1: without it UBSan prints "runtime error: ..." but the
|
||||||
|
# process exits 0 (recover mode), so gtest/ctest report a pass and the job
|
||||||
|
# stays green. Combined with -fno-sanitize-recover=undefined at compile time,
|
||||||
|
# the first finding aborts the test process with a non-zero exit.
|
||||||
|
export ASAN_OPTIONS="detect_leaks=0:abort_on_error=1:print_stacktrace=1:symbolize=1"
|
||||||
|
export UBSAN_OPTIONS="print_stacktrace=1:halt_on_error=1:symbolize=1"
|
||||||
|
fi
|
||||||
|
# Every TEST() is its own CTest entry, so the suite is process-spawn bound rather than CPU
|
||||||
|
# bound and -j recovers nearly all of the per-process overhead. The test binaries are safe to
|
||||||
|
# run concurrently: the only one that claims a fixed resource is single_instance_manager_test,
|
||||||
|
# which already scopes its local socket name to its own pid so parallel copies cannot collide.
|
||||||
|
ctest -C "$BUILDTYPE" --output-on-failure -j "${CTEST_JOBS:-4}"
|
||||||
|
echo "::endgroup::"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $MAKE_COVERAGE ]]; then
|
||||||
|
# gcovr is not a build dependency and is not available everywhere, so a missing one is reported
|
||||||
|
# and skipped rather than failing an otherwise green build.
|
||||||
|
echo "::group::Coverage report"
|
||||||
|
GCOVR="$(command -v gcovr || true)"
|
||||||
|
if [[ -z $GCOVR ]]; then
|
||||||
|
echo "gcovr not found; install it to produce a coverage report"
|
||||||
|
else
|
||||||
|
"$GCOVR" --version
|
||||||
|
# This script configures with `cmake ..`, so the build dir it was started from is the object
|
||||||
|
# directory and the source root is one level up. Both are passed explicitly: gcovr otherwise
|
||||||
|
# walks the current directory and trips over in-tree build directories left by other tooling,
|
||||||
|
# whose stale .gcno files cannot be resolved. --config is named too, so the filters apply no
|
||||||
|
# matter what directory gcovr happens to start from.
|
||||||
|
GCOVR_ARGS=("." --root "$SRC_ROOT" --object-directory "." --config "$SRC_ROOT/gcovr.cfg")
|
||||||
|
"$GCOVR" "${GCOVR_ARGS[@]}" --txt --decisions
|
||||||
|
"$GCOVR" "${GCOVR_ARGS[@]}" --html-details coverage-details.html --html-title "Cockatrice line coverage"
|
||||||
|
"$GCOVR" "${GCOVR_ARGS[@]}" --lcov -o coverage.lcov
|
||||||
|
"$GCOVR" "${GCOVR_ARGS[@]}" --cobertura-pretty -o coverage.xml
|
||||||
|
"$GCOVR" "${GCOVR_ARGS[@]}" --json-summary-pretty -o coverage-summary.json
|
||||||
|
echo "Coverage artifacts written to coverage-details.html, coverage.lcov, coverage.xml and coverage-summary.json"
|
||||||
|
fi
|
||||||
echo "::endgroup::"
|
echo "::endgroup::"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,17 @@
|
||||||
# --set-cache <location> sets the location to cache the image or for ccache
|
# --set-cache <location> sets the location to cache the image or for ccache
|
||||||
#
|
#
|
||||||
# requires: docker
|
# requires: docker
|
||||||
# uses env: NAME CACHE BUILD GET SAVE INTERACTIVE
|
# uses env: NAME IMAGE_SUFFIX CACHE BUILD GET SAVE INTERACTIVE
|
||||||
# (correspond to args: <name> --set-cache <cache> --build --get --save --interactive)
|
# (correspond to args: <name> --set-cache <cache> --build --get --save --interactive)
|
||||||
# sets env: RUN CCACHE_DIR IMAGE_NAME RUN_ARGS RUN_OPTS BUILD_SCRIPT
|
# sets env: RUN CCACHE_DIR IMAGE_NAME RUN_ARGS RUN_OPTS BUILD_SCRIPT
|
||||||
# exitcode: 1 for failure, 2 for missing dockerfile, 3 for invalid arguments
|
# exitcode: 1 for failure, 2 for missing dockerfile, 3 for invalid arguments
|
||||||
#
|
#
|
||||||
|
# IMAGE_SUFFIX is optional and only appended to the image name, never to NAME, so that a matrix
|
||||||
|
# leg can have its own image and image cache without needing its own directory under .ci. Two legs
|
||||||
|
# building and tagging the same image concurrently would race on `docker build --tag`.
|
||||||
|
# It is deliberately not called SUFFIX: compile.sh already uses SUFFIX for output file names, and
|
||||||
|
# a job-level SUFFIX would be silently overridden by those per-step env blocks.
|
||||||
|
#
|
||||||
# exported RUN function will run the BUILD_SCRIPT inside of the docker container.
|
# exported RUN function will run the BUILD_SCRIPT inside of the docker container.
|
||||||
# note that the docker container will not inherit any environment variables!
|
# note that the docker container will not inherit any environment variables!
|
||||||
#
|
#
|
||||||
|
|
@ -81,7 +87,7 @@ if ! [[ $NAME ]]; then
|
||||||
return 3
|
return 3
|
||||||
fi
|
fi
|
||||||
|
|
||||||
export IMAGE_NAME="${project_name,,}_${NAME,,}" # lower case
|
export IMAGE_NAME="${project_name,,}_${NAME,,}${IMAGE_SUFFIX:+_${IMAGE_SUFFIX,,}}" # lower case
|
||||||
|
|
||||||
docker_dir=".ci/$NAME"
|
docker_dir=".ci/$NAME"
|
||||||
if ! [[ -r $docker_dir/Dockerfile ]]; then
|
if ! [[ -r $docker_dir/Dockerfile ]]; then
|
||||||
|
|
|
||||||
249
.github/workflows/desktop-build.yml
vendored
249
.github/workflows/desktop-build.yml
vendored
|
|
@ -144,17 +144,36 @@ jobs:
|
||||||
|
|
||||||
package: DEB
|
package: DEB
|
||||||
|
|
||||||
name: ${{ matrix.distro }} ${{ matrix.version }}
|
# The one leg that runs the suite under AddressSanitizer and
|
||||||
|
# UndefinedBehaviorSanitizer. Debian 13 and Fedora 44 keep covering the
|
||||||
|
# uninstrumented suite, so nothing is lost by instrumenting this one.
|
||||||
|
sanitize: yes
|
||||||
|
|
||||||
|
- distro: Ubuntu
|
||||||
|
version: 26.04
|
||||||
|
variant: cover # separate image and ccache, same distro
|
||||||
|
|
||||||
|
# The one leg that runs instrumented for line coverage and publishes the report as
|
||||||
|
# a workflow artifact. It is a separate leg rather than a second flag on the
|
||||||
|
# sanitizer leg because the two instruments answer different questions: the
|
||||||
|
# sanitizer leg reports what the run did wrong, the coverage leg reports what the
|
||||||
|
# run never touched at all. Combining them would also slow both down. Packaging is
|
||||||
|
# skipped because the leg above already publishes the DEB for this distro.
|
||||||
|
coverage: yes
|
||||||
|
package: skip
|
||||||
|
|
||||||
|
name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }}
|
||||||
needs: configure
|
needs: configure
|
||||||
runs-on: ubuntu-latest # https://github.com/actions/runner-images
|
runs-on: ubuntu-latest # https://github.com/actions/runner-images
|
||||||
continue-on-error: ${{ matrix.allow-failure == 'yes' }}
|
continue-on-error: ${{ matrix.allow-failure == 'yes' }}
|
||||||
timeout-minutes: 70
|
timeout-minutes: 70
|
||||||
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 }}${{ matrix.variant }} # 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: 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
|
||||||
CMAKE_GENERATOR: 'Ninja'
|
CMAKE_GENERATOR: 'Ninja'
|
||||||
NAME: ${{ matrix.distro }}${{ matrix.version }}
|
NAME: ${{ matrix.distro }}${{ matrix.version }}
|
||||||
|
IMAGE_SUFFIX: ${{ matrix.variant }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: "Checkout"
|
- name: "Checkout"
|
||||||
|
|
@ -164,8 +183,8 @@ jobs:
|
||||||
id: restore_ccache
|
id: restore_ccache
|
||||||
uses: actions/cache/restore@v6
|
uses: actions/cache/restore@v6
|
||||||
with:
|
with:
|
||||||
key: ccache-${{ matrix.distro }}${{ matrix.version }}
|
key: ccache-${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }}
|
||||||
path: ${{ env.CACHE }}
|
path: ${{ env.CACHE }}/.ccache
|
||||||
|
|
||||||
- name: "Build ${{ matrix.distro }} ${{ matrix.version }} Docker image"
|
- name: "Build ${{ matrix.distro }} ${{ matrix.version }} Docker image"
|
||||||
shell: bash
|
shell: bash
|
||||||
|
|
@ -174,6 +193,9 @@ jobs:
|
||||||
- name: "Build debug and test"
|
- name: "Build debug and test"
|
||||||
if: matrix.test != 'skip'
|
if: matrix.test != 'skip'
|
||||||
shell: bash
|
shell: bash
|
||||||
|
env:
|
||||||
|
sanitize: '${{ matrix.sanitize }}'
|
||||||
|
coverage: '${{ matrix.coverage }}'
|
||||||
run: |
|
run: |
|
||||||
source .ci/docker.sh
|
source .ci/docker.sh
|
||||||
args=()
|
args=()
|
||||||
|
|
@ -181,7 +203,33 @@ jobs:
|
||||||
args+=(--ccache "$CCACHE_SIZE")
|
args+=(--ccache "$CCACHE_SIZE")
|
||||||
args+=(--cmake-generator "$CMAKE_GENERATOR")
|
args+=(--cmake-generator "$CMAKE_GENERATOR")
|
||||||
|
|
||||||
RUN --server --debug --test "${args[@]}"
|
extra=()
|
||||||
|
[[ $sanitize == yes ]] && extra+=(--sanitize)
|
||||||
|
[[ $coverage == yes ]] && extra+=(--coverage)
|
||||||
|
|
||||||
|
RUN --server --debug --test "${extra[@]}" "${args[@]}"
|
||||||
|
|
||||||
|
# RUN bind-mounts the workspace into the container, so the report the build wrote into
|
||||||
|
# build/ is already on the host and no second container is needed to collect it.
|
||||||
|
- name: "Collect coverage report"
|
||||||
|
if: matrix.coverage == 'yes'
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p coverage
|
||||||
|
# --html-details writes one page per source file plus a stylesheet and script, and the
|
||||||
|
# index links to all of them by relative name. Copying the index alone would publish a
|
||||||
|
# report where every link 404s, so the whole family has to travel together.
|
||||||
|
cp build/coverage-details* coverage/
|
||||||
|
cp build/coverage.lcov build/coverage.xml build/coverage-summary.json coverage/
|
||||||
|
ls -la coverage/
|
||||||
|
|
||||||
|
- name: "Upload coverage artifact"
|
||||||
|
if: matrix.coverage == 'yes'
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: coverage-report
|
||||||
|
path: coverage/
|
||||||
|
if-no-files-found: warn
|
||||||
|
|
||||||
- name: "Build release package"
|
- name: "Build release package"
|
||||||
id: build
|
id: build
|
||||||
|
|
@ -219,7 +267,7 @@ jobs:
|
||||||
uses: actions/cache/save@v6
|
uses: actions/cache/save@v6
|
||||||
with:
|
with:
|
||||||
key: ${{ steps.restore_ccache.outputs.cache-primary-key }}
|
key: ${{ steps.restore_ccache.outputs.cache-primary-key }}
|
||||||
path: ${{ env.CACHE }}
|
path: ${{ env.CACHE }}/.ccache
|
||||||
|
|
||||||
- name: "Upload artifact"
|
- name: "Upload artifact"
|
||||||
id: upload_artifact
|
id: upload_artifact
|
||||||
|
|
@ -277,6 +325,8 @@ jobs:
|
||||||
soc: Intel
|
soc: Intel
|
||||||
type: Release
|
type: Release
|
||||||
use_ccache: 1
|
use_ccache: 1
|
||||||
|
vcpkg_scope: 'vcpkg/macos13-x64-xcode26.3/'
|
||||||
|
vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over
|
||||||
xcode: "26.3"
|
xcode: "26.3"
|
||||||
|
|
||||||
- os: macOS
|
- os: macOS
|
||||||
|
|
@ -293,6 +343,9 @@ jobs:
|
||||||
soc: Apple
|
soc: Apple
|
||||||
type: Release
|
type: Release
|
||||||
use_ccache: 1
|
use_ccache: 1
|
||||||
|
vcpkg_deployment_target: 14
|
||||||
|
vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/'
|
||||||
|
vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over
|
||||||
xcode: "26.6"
|
xcode: "26.6"
|
||||||
|
|
||||||
- os: macOS
|
- os: macOS
|
||||||
|
|
@ -309,6 +362,8 @@ jobs:
|
||||||
soc: Apple
|
soc: Apple
|
||||||
type: Release
|
type: Release
|
||||||
use_ccache: 1
|
use_ccache: 1
|
||||||
|
vcpkg_deployment_target: 14
|
||||||
|
vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/'
|
||||||
xcode: "26.6"
|
xcode: "26.6"
|
||||||
|
|
||||||
- os: macOS
|
- os: macOS
|
||||||
|
|
@ -322,19 +377,30 @@ jobs:
|
||||||
soc: Apple
|
soc: Apple
|
||||||
type: Debug
|
type: Debug
|
||||||
use_ccache: 1
|
use_ccache: 1
|
||||||
|
vcpkg_deployment_target: 14
|
||||||
|
vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/'
|
||||||
xcode: "26.6"
|
xcode: "26.6"
|
||||||
|
|
||||||
- os: Windows
|
- os: Windows
|
||||||
target: 10
|
target: 10
|
||||||
runner: windows-2025 # https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-VS2026-Readme.md
|
runner: windows-2025 # https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-VS2026-Readme.md
|
||||||
|
|
||||||
cmake_generator: "Visual Studio 18 2026"
|
# Ninja builds the whole dependency graph at once instead of one
|
||||||
cmake_generator_platform: x64
|
# MSBuild node at a time, runs the compiler itself, and matches the
|
||||||
|
# Linux and macOS legs. Its median build phase measured ~17 min
|
||||||
|
# against ~21 for the Visual Studio generator (shared 4-core hosts
|
||||||
|
# swing either by minutes, so read medians, not single runs).
|
||||||
|
cmake_generator: Ninja
|
||||||
make_package: 1
|
make_package: 1
|
||||||
package_suffix: "-Win10"
|
package_suffix: "-Win10"
|
||||||
qt_version: 6.11.*
|
qt_version: 6.11.*
|
||||||
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
|
qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets
|
||||||
type: Release
|
type: Release
|
||||||
|
# Scope is named after the vs2026 toolchain, not the generator: the
|
||||||
|
# ABI hash covers cl.exe, which is identical, so this leg keeps
|
||||||
|
# restoring the warm archives. Renaming would orphan them.
|
||||||
|
vcpkg_scope: 'vcpkg/windows10-x64-vs2026/'
|
||||||
|
vcpkg_upload: 1 # only one leg per vcpkg_scope hands its archives over
|
||||||
|
|
||||||
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' || '' }}
|
||||||
needs: configure
|
needs: configure
|
||||||
|
|
@ -428,12 +494,39 @@ jobs:
|
||||||
shell: bash
|
shell: bash
|
||||||
run: choco install nsis
|
run: choco install nsis
|
||||||
|
|
||||||
|
# Ninja invokes cl.exe directly and needs the MSVC environment on PATH.
|
||||||
|
- name: "[Windows] Set up MSVC environment"
|
||||||
|
if: matrix.os == 'Windows'
|
||||||
|
uses: TheMrMilchmann/setup-msvc-dev@368ef7d1ee4d1171b31d4a7f67f4d954f903f5a9 # v4.1.0
|
||||||
|
with:
|
||||||
|
arch: x64
|
||||||
|
|
||||||
- name: "Setup vcpkg cache"
|
- name: "Setup vcpkg cache"
|
||||||
id: vcpkg-cache
|
id: vcpkg-cache
|
||||||
uses: TAServers/vcpkg-cache@v3
|
uses: TAServers/vcpkg-cache@v3
|
||||||
with:
|
with:
|
||||||
|
# Every entry is keyed by vcpkg ABI hash, and one hash set exists per platform,
|
||||||
|
# target and toolchain. The action restores every entry it finds under the
|
||||||
|
# prefix, not just the hashes vcpkg asks for, so with one shared vcpkg/
|
||||||
|
# namespace every leg downloads all 66 archives of all 5 legs on every run.
|
||||||
|
# Each of those restores also refreshes the entry's last access date, which
|
||||||
|
# is why a dead ABI generation is never evicted by age. Naming the scope is
|
||||||
|
# what makes those generations identifiable, and prune-caches below able to
|
||||||
|
# delete them.
|
||||||
|
prefix: ${{ matrix.vcpkg_scope }}
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
# The action only creates its directory when the prefix matched at least one
|
||||||
|
# entry, and it reads that directory unguarded when it saves at the end of the
|
||||||
|
# job. A scope nobody has written to yet therefore leaves the directory missing
|
||||||
|
# and fails the job, which is exactly the state every scope starts in. Make the
|
||||||
|
# directory up front so an empty scope is a cache miss instead of an error.
|
||||||
|
- name: "Create vcpkg cache directory"
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
CACHE_DIR: '${{ steps.vcpkg-cache.outputs.path }}'
|
||||||
|
run: mkdir -p "$CACHE_DIR"
|
||||||
|
|
||||||
# Uses environment variables, see compile.sh for more details
|
# Uses environment variables, see compile.sh for more details
|
||||||
- name: "Build Cockatrice"
|
- name: "Build Cockatrice"
|
||||||
id: build
|
id: build
|
||||||
|
|
@ -442,7 +535,6 @@ jobs:
|
||||||
BUILDTYPE: '${{ matrix.type }}'
|
BUILDTYPE: '${{ matrix.type }}'
|
||||||
CCACHE_EVICTION_AGE: ${{ matrix.ccache_eviction_age }}
|
CCACHE_EVICTION_AGE: ${{ matrix.ccache_eviction_age }}
|
||||||
CMAKE_GENERATOR: ${{ matrix.cmake_generator }}
|
CMAKE_GENERATOR: ${{ matrix.cmake_generator }}
|
||||||
CMAKE_GENERATOR_PLATFORM: ${{ matrix.cmake_generator_platform }}
|
|
||||||
DEVELOPER_DIR: '/Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer'
|
DEVELOPER_DIR: '/Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer'
|
||||||
GITHUB_TOKEN: ${{ github.token }} # needed for vcpkg dependency graph updates, see VCPKG_FEATURE_FLAGS
|
GITHUB_TOKEN: ${{ github.token }} # needed for vcpkg dependency graph updates, see VCPKG_FEATURE_FLAGS
|
||||||
MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }}
|
MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }}
|
||||||
|
|
@ -453,9 +545,19 @@ 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 }}
|
||||||
|
# Only pushes to the default branch may write the shared binary cache: the
|
||||||
|
# archives a run compiles are only as trustworthy as the code they were
|
||||||
|
# built from, and only merged code may populate the scope that master and
|
||||||
|
# release builds restore and link. Pull requests (same-repository ones
|
||||||
|
# included) restore the shared cache but never write it; with the source
|
||||||
|
# left at read the collect step below finds nothing on PRs, so no
|
||||||
|
# pull-request-scoped entry is ever saved that no later run could restore.
|
||||||
|
# Forks pushing to their own default branch resolve github.ref against
|
||||||
|
# their own repository, so they keep a working cache of their own there.
|
||||||
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 }},${{ case(github.ref == 'refs/heads/master', 'readwrite', 'read') }}"
|
||||||
VCPKG_DISABLE_METRICS: 1
|
VCPKG_DISABLE_METRICS: 1
|
||||||
VCPKG_FEATURE_FLAGS: dependencygraph
|
VCPKG_FEATURE_FLAGS: dependencygraph
|
||||||
|
VCPKG_TARGET_MACOS_VERSION: ${{ matrix.vcpkg_deployment_target }}
|
||||||
run: .ci/compile.sh --server --test --vcpkg
|
run: .ci/compile.sh --server --test --vcpkg
|
||||||
|
|
||||||
# CPack reports a failed makensis run as "Problem running NSIS command"
|
# CPack reports a failed makensis run as "Problem running NSIS command"
|
||||||
|
|
@ -469,6 +571,63 @@ jobs:
|
||||||
shell: bash
|
shell: bash
|
||||||
run: find . -name NSISOutput.log -exec cat {} +
|
run: find . -name NSISOutput.log -exec cat {} +
|
||||||
|
|
||||||
|
# A master run hands over the archives it had to build itself so that
|
||||||
|
# vcpkg-cache-import.yml, which runs in the default branch scope, can save them
|
||||||
|
# where later runs can restore them. Pull requests hand over nothing: their cache
|
||||||
|
# entries would be scoped to refs/pull/<n>/merge and stay unreadable for every
|
||||||
|
# other run, so writing them only burns the repository's quota.
|
||||||
|
# vcpkg_upload keeps this to one leg per scope: macOS 14, 15 and 26 share a scope
|
||||||
|
# by design, and all three rebuild the same archives, so uploading from each of them
|
||||||
|
# would publish three identical 269 MB artifacts under one artifact name and make
|
||||||
|
# the importer spawn a save job per copy.
|
||||||
|
- name: "Collect new vcpkg entries"
|
||||||
|
id: vcpkg_new
|
||||||
|
if: github.ref == 'refs/heads/master' && matrix.vcpkg_upload == 1
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
CACHE_DIR: '${{ steps.vcpkg-cache.outputs.path }}'
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
VCPKG_SCOPE: '${{ matrix.vcpkg_scope }}'
|
||||||
|
run: |
|
||||||
|
# The archive name is the ABI hash, so a missing key is exactly what vcpkg had
|
||||||
|
# to compile from source in this job.
|
||||||
|
gh api --paginate \
|
||||||
|
"repos/$GITHUB_REPOSITORY/actions/caches?ref=refs/heads/master&key=$VCPKG_SCOPE&per_page=100" \
|
||||||
|
--jq '.actions_caches[].key' | sort -u >"$RUNNER_TEMP/master-keys.txt"
|
||||||
|
find "$CACHE_DIR" -type f -name '*.zip' -exec basename {} .zip \; |
|
||||||
|
sed "s|^|$VCPKG_SCOPE|" | sort -u >"$RUNNER_TEMP/local-keys.txt"
|
||||||
|
comm -23 "$RUNNER_TEMP/local-keys.txt" "$RUNNER_TEMP/master-keys.txt" >"$RUNNER_TEMP/new-keys.txt"
|
||||||
|
|
||||||
|
mkdir -p "$RUNNER_TEMP/vcpkg-new"
|
||||||
|
: >"$RUNNER_TEMP/vcpkg-new/keys.txt"
|
||||||
|
while read -r key; do
|
||||||
|
hash="${key##*/}"
|
||||||
|
archive="$(find "$CACHE_DIR" -type f -name "$hash.zip" -print -quit)"
|
||||||
|
dir="$RUNNER_TEMP/vcpkg-new/${hash:0:2}" # vcpkg's own two character fanout
|
||||||
|
mkdir -p "$dir"
|
||||||
|
cp "$archive" "$dir/"
|
||||||
|
echo "$key ${hash:0:2}/$hash.zip" >>"$RUNNER_TEMP/vcpkg-new/keys.txt"
|
||||||
|
done <"$RUNNER_TEMP/new-keys.txt"
|
||||||
|
|
||||||
|
count="$(wc -l <"$RUNNER_TEMP/new-keys.txt")"
|
||||||
|
echo "vcpkg scope $VCPKG_SCOPE: $count entries to import"
|
||||||
|
echo "count=$count" >>"$GITHUB_OUTPUT"
|
||||||
|
echo "slug=$(sed -e 's|^vcpkg/||' -e 's|/$||' -e 's|/|-|g' <<<"$VCPKG_SCOPE")" >>"$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
# archive: false accepts a single file and nothing else, and a scope hands over one
|
||||||
|
# archive per ABI hash vcpkg had to rebuild, which is 23 files on a cold runner.
|
||||||
|
# Zipping the directory keeps the fanout at the artifact root, which is where
|
||||||
|
# vcpkg-cache-import.yml looks for keys.txt once download-artifact unpacks it.
|
||||||
|
- name: "Upload new vcpkg entries"
|
||||||
|
# An empty count means the step above did not run at all, on master and on forks
|
||||||
|
if: steps.vcpkg_new.outputs.count != '' && steps.vcpkg_new.outputs.count != '0'
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
if-no-files-found: error # a count above zero with nothing staged is a bug
|
||||||
|
name: vcpkg-new-${{ steps.vcpkg_new.outputs.slug }}
|
||||||
|
path: ${{ runner.temp }}/vcpkg-new
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
# Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342
|
# Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342
|
||||||
- name: "[macOS] Delete remote compiler cache (ccache)"
|
- name: "[macOS] Delete remote compiler cache (ccache)"
|
||||||
if: matrix.os == 'macOS' && matrix.use_ccache == 1 && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit
|
if: matrix.os == 'macOS' && matrix.use_ccache == 1 && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit
|
||||||
|
|
@ -544,6 +703,8 @@ jobs:
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
path: ${{ steps.build.outputs.path }}
|
path: ${{ steps.build.outputs.path }}
|
||||||
|
|
||||||
|
# Ninja is single-config: binaries and their PDBs land directly in the
|
||||||
|
# target directories instead of a Release subdirectory.
|
||||||
- name: "[Windows] Upload PDBs (Program Databases)"
|
- name: "[Windows] Upload PDBs (Program Databases)"
|
||||||
if: matrix.os == 'Windows' && github.ref_type != 'tag'
|
if: matrix.os == 'Windows' && github.ref_type != 'tag'
|
||||||
uses: actions/upload-artifact@v7
|
uses: actions/upload-artifact@v7
|
||||||
|
|
@ -551,9 +712,9 @@ jobs:
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
name: ${{ steps.build.outputs.name }}-PDBs
|
name: ${{ steps.build.outputs.name }}-PDBs
|
||||||
path: |
|
path: |
|
||||||
build/cockatrice/Release/*.pdb
|
build/cockatrice/*.pdb
|
||||||
build/oracle/Release/*.pdb
|
build/oracle/*.pdb
|
||||||
build/servatrice/Release/*.pdb
|
build/servatrice/*.pdb
|
||||||
|
|
||||||
- name: "Upload to release"
|
- name: "Upload to release"
|
||||||
if: needs.configure.outputs.tag != null && matrix.make_package == '1'
|
if: needs.configure.outputs.tag != null && matrix.make_package == '1'
|
||||||
|
|
@ -581,3 +742,67 @@ jobs:
|
||||||
BUILD_PATH: ${{ steps.build.outputs.path }}
|
BUILD_PATH: ${{ steps.build.outputs.path }}
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
run: gh attestation verify "$BUILD_PATH" --repo Cockatrice/Cockatrice
|
run: gh attestation verify "$BUILD_PATH" --repo Cockatrice/Cockatrice
|
||||||
|
|
||||||
|
prune-caches:
|
||||||
|
name: Prune caches
|
||||||
|
# Only master writes caches at all, so only master may clean them up too
|
||||||
|
if: github.ref == 'refs/heads/master'
|
||||||
|
runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md
|
||||||
|
continue-on-error: true # freeing space must never fail a build
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# The repository holds 10Gi of cache against entries that want more, and GitHub
|
||||||
|
# evicts the least recently used entry whenever that is exceeded. The vcpkg
|
||||||
|
# entries are the reason that never happens: the restore above touches every
|
||||||
|
# entry under the prefix on every run, so a dead ABI generation keeps its access
|
||||||
|
# date fresh forever and age based eviction can never reach it. Deleting by age
|
||||||
|
# alone cannot fix that, only knowing which scope an entry belongs to can.
|
||||||
|
- name: "Delete unusable cache entries"
|
||||||
|
shell: bash
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
# Must list every vcpkg_scope of the build-vcpkg matrix above, or the entries of
|
||||||
|
# a scope that is still in use get deleted on the next run.
|
||||||
|
LIVE_VCPKG_SCOPES: 'vcpkg/macos13-x64-xcode26.3/ vcpkg/macos14-arm64-xcode26.6/ vcpkg/windows10-x64-vs2026/'
|
||||||
|
run: |
|
||||||
|
gh api --paginate "repos/$GITHUB_REPOSITORY/actions/caches?per_page=100" \
|
||||||
|
--jq '.actions_caches[] | [.id, .ref, .key, .last_accessed_at] | @tsv' >"$RUNNER_TEMP/caches.tsv"
|
||||||
|
|
||||||
|
cutoff="$(( $(date -u +%s) - 7 * 24 * 3600 ))"
|
||||||
|
live=" ${LIVE_VCPKG_SCOPES} "
|
||||||
|
deleted=0
|
||||||
|
while IFS=$'\t' read -r id ref key accessed; do
|
||||||
|
[[ -n $id ]] || continue
|
||||||
|
# Only ever touch the key shapes this workflow creates, so that an entry of
|
||||||
|
# some other workflow can never be deleted by accident
|
||||||
|
case "$key" in
|
||||||
|
vcpkg/* | ccache-* | Qt*) ;;
|
||||||
|
*) continue ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
reason=""
|
||||||
|
if [[ $ref != refs/heads/master ]]; then
|
||||||
|
reason="scoped to $ref"
|
||||||
|
elif [[ $key == vcpkg/* ]]; then
|
||||||
|
# "<scope>/<abi hash>", cut the hash off the end to get the scope
|
||||||
|
scope="vcpkg/${key#vcpkg/}"
|
||||||
|
scope="${scope%/*}/"
|
||||||
|
if [[ $live != *" $scope "* ]]; then
|
||||||
|
reason="no matrix leg uses $scope"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [[ -z $reason ]]; then
|
||||||
|
stamp="$(date -u -d "$accessed" +%s 2>/dev/null || echo 0)"
|
||||||
|
if [[ $stamp -ne 0 && $stamp -lt $cutoff ]]; then
|
||||||
|
reason="unused since $accessed"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n $reason ]]; then
|
||||||
|
echo "::notice::deleting $key, $reason"
|
||||||
|
gh api -X DELETE "repos/$GITHUB_REPOSITORY/actions/caches/$id"
|
||||||
|
deleted=$((deleted + 1))
|
||||||
|
fi
|
||||||
|
done <"$RUNNER_TEMP/caches.tsv"
|
||||||
|
|
||||||
|
echo "::notice::deleted $deleted of $(wc -l <"$RUNNER_TEMP/caches.tsv") cache entries"
|
||||||
|
|
|
||||||
121
.github/workflows/vcpkg-cache-import.yml
vendored
Normal file
121
.github/workflows/vcpkg-cache-import.yml
vendored
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
name: Import vcpkg cache
|
||||||
|
|
||||||
|
# After a runner image update changes every port's ABI hash, the first master run
|
||||||
|
# rebuilds protobuf, openssl and abseil from source and saves them into the shared
|
||||||
|
# cache, where every later run can restore them.
|
||||||
|
#
|
||||||
|
# Only master pushes are imported here: the collect step, keys.txt and the zip
|
||||||
|
# contents all come from the workflow run being imported, so importing from an
|
||||||
|
# unmerged pull request would let anyone who can push a branch — or a bot actor
|
||||||
|
# such as Dependabot, whose PRs are same-repo — promote arbitrary binaries into
|
||||||
|
# the scope that master and tag builds restore and link into signed releases.
|
||||||
|
# Forks build untrusted code and must never reach that scope either.
|
||||||
|
#
|
||||||
|
# repository_dispatch is not used on purpose: it would also resolve to the default branch,
|
||||||
|
# but it counts as a low-trust trigger and may only write there when it declares a
|
||||||
|
# write-capable cache-mode, which puts it in the same position as workflow_run.
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
actions: write # needed to save entries into the default branch's cache scope
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["Build Desktop"]
|
||||||
|
types: [completed]
|
||||||
|
|
||||||
|
# Every import writes the same scopes, so let them queue instead of racing each other
|
||||||
|
concurrency:
|
||||||
|
group: vcpkg-cache-import
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
list:
|
||||||
|
name: List new vcpkg entries
|
||||||
|
# Only a successful push to the default branch has archives worth importing.
|
||||||
|
# Only code that landed on master may populate the scope master runs restore.
|
||||||
|
# Bot actors are excluded on top: their runs must never promote binaries into
|
||||||
|
# the shared scope even if they somehow target the default branch.
|
||||||
|
if: >-
|
||||||
|
github.event.workflow_run.conclusion == 'success' &&
|
||||||
|
github.event.workflow_run.event == 'push' &&
|
||||||
|
github.event.workflow_run.head_branch == 'master' &&
|
||||||
|
github.event.workflow_run.head_repository.full_name == github.repository &&
|
||||||
|
!endsWith(github.event.workflow_run.actor.login, '[bot]') &&
|
||||||
|
!endsWith(github.event.workflow_run.triggering_actor.login, '[bot]')
|
||||||
|
runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md
|
||||||
|
# workflow_run is a low-trust trigger, so cache access is read-only by default even
|
||||||
|
# though this run resolves to the default branch. Assert below that this took effect,
|
||||||
|
# because a save under read-only access only warns and would import nothing silently.
|
||||||
|
cache-mode: write
|
||||||
|
outputs:
|
||||||
|
entries: ${{ steps.scan.outputs.entries }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: "Download new vcpkg entries"
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
pattern: vcpkg-new-*
|
||||||
|
path: incoming
|
||||||
|
run-id: ${{ github.event.workflow_run.id }}
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: "Assert write access"
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [[ $ACTIONS_CACHE_MODE != *write* ]]; then
|
||||||
|
echo "::error::expected a write-capable cache, got '$ACTIONS_CACHE_MODE'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: "List cache entries"
|
||||||
|
id: scan
|
||||||
|
shell: bash
|
||||||
|
working-directory: incoming
|
||||||
|
run: |
|
||||||
|
entries="$(find . -name keys.txt -type f -exec sh -c '
|
||||||
|
for manifest; do
|
||||||
|
artifact="$(basename "$(dirname "$manifest")")"
|
||||||
|
while read -r key path; do
|
||||||
|
printf "{\"artifact\": \"%s\", \"key\": \"%s\", \"path\": \"%s\"}\n" \
|
||||||
|
"$artifact" "$key" "$path"
|
||||||
|
done <"$manifest"
|
||||||
|
done
|
||||||
|
' _ {} +)"
|
||||||
|
[[ -n $entries ]] || exit 0
|
||||||
|
# Compact, because a multi line value cannot be written to GITHUB_OUTPUT
|
||||||
|
entries="$(jq -cs '.' <<<"$entries")"
|
||||||
|
echo "entries=$entries" >>"$GITHUB_OUTPUT"
|
||||||
|
echo "::notice::importing $(jq length <<<"$entries") vcpkg entries"
|
||||||
|
|
||||||
|
save:
|
||||||
|
name: "Save ${{ matrix.entry.key }}"
|
||||||
|
needs: list
|
||||||
|
if: needs.list.outputs.entries
|
||||||
|
runs-on: ubuntu-slim
|
||||||
|
cache-mode: write
|
||||||
|
strategy:
|
||||||
|
# One job per archive, they run in parallel and each only uploads a single file
|
||||||
|
fail-fast: false
|
||||||
|
max-parallel: 20
|
||||||
|
matrix:
|
||||||
|
entry: ${{ fromJSON(needs.list.outputs.entries) }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: "Download new vcpkg entries"
|
||||||
|
uses: actions/download-artifact@v8
|
||||||
|
with:
|
||||||
|
pattern: ${{ matrix.entry.artifact }}
|
||||||
|
path: incoming
|
||||||
|
run-id: ${{ github.event.workflow_run.id }}
|
||||||
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: "Save vcpkg entry"
|
||||||
|
uses: actions/cache/save@v6
|
||||||
|
with:
|
||||||
|
# The payload is a Windows or macOS archive while the upload runs on Linux, and
|
||||||
|
# without this the entry would be stamped as a Linux cache that neither of them
|
||||||
|
# is allowed to restore.
|
||||||
|
enableCrossOsArchive: true
|
||||||
|
key: ${{ matrix.entry.key }}
|
||||||
|
path: incoming/${{ matrix.entry.path }}
|
||||||
|
|
@ -22,6 +22,10 @@ option(WITH_ORACLE "Build Cockatrice card database tool (Oracle)" ON)
|
||||||
option(WITH_SERVER "Build Cockatrice server (Servatrice)" OFF)
|
option(WITH_SERVER "Build Cockatrice server (Servatrice)" OFF)
|
||||||
# Compile tests
|
# Compile tests
|
||||||
option(TEST "Build tests" OFF)
|
option(TEST "Build tests" OFF)
|
||||||
|
# Build with AddressSanitizer and UndefinedBehaviorSanitizer
|
||||||
|
option(ENABLE_SANITIZERS "Build with AddressSanitizer and UndefinedBehaviorSanitizer" OFF)
|
||||||
|
# Build instrumented for GCC line coverage, reported with gcovr
|
||||||
|
option(ENABLE_COVERAGE "Build instrumented for GCC line coverage" 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)
|
||||||
|
|
||||||
|
|
@ -86,7 +90,7 @@ endif()
|
||||||
|
|
||||||
# A project name is needed for CPack
|
# A project name is needed for CPack
|
||||||
# Version can be overriden by git tags, see cmake/getversion.cmake
|
# Version can be overriden by git tags, see cmake/getversion.cmake
|
||||||
project("Cockatrice" VERSION 3.1.1)
|
project("Cockatrice" VERSION 3.1.2)
|
||||||
|
|
||||||
# Set release name if not provided via env/cmake var
|
# Set release name if not provided via env/cmake var
|
||||||
if(NOT DEFINED GIT_TAG_RELEASENAME)
|
if(NOT DEFINED GIT_TAG_RELEASENAME)
|
||||||
|
|
@ -211,6 +215,12 @@ else()
|
||||||
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe")
|
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe")
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
# Always included, because they define cockatrice_disable_sanitizers_for() and
|
||||||
|
# cockatrice_disable_coverage_for() for the vendored trees. They only touch the global flags
|
||||||
|
# when ENABLE_SANITIZERS or ENABLE_COVERAGE is on.
|
||||||
|
include(Sanitizers)
|
||||||
|
include(Coverage)
|
||||||
|
|
||||||
# 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
|
||||||
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
|
if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang")
|
||||||
add_definitions("-DSFMT_MEXP=19937")
|
add_definitions("-DSFMT_MEXP=19937")
|
||||||
|
|
|
||||||
75
cmake/Coverage.cmake
Normal file
75
cmake/Coverage.cmake
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
# Line-coverage instrumentation opt-in, plus per-source opt-out
|
||||||
|
# Input: ENABLE_COVERAGE
|
||||||
|
# Defines: cockatrice_disable_coverage_for
|
||||||
|
#
|
||||||
|
# Included once from the root CMakeLists.txt, immediately after the per-compiler flag
|
||||||
|
# block, so add_compile_options() reaches every library and executable defined below.
|
||||||
|
#
|
||||||
|
# Note the name deliberately shadows CMake's own built-in Modules/Coverage.cmake. It resolves
|
||||||
|
# to this file only because the root CMakeLists.txt puts cmake/ at the front of
|
||||||
|
# CMAKE_MODULE_PATH; if that ever moves, include(Coverage) would silently pick up the built-in
|
||||||
|
# module, which only defines a `COVERAGE` custom target and touches no global flags, so
|
||||||
|
# instrumentation would quietly stop happening rather than erroring.
|
||||||
|
|
||||||
|
include(CheckCXXSourceCompiles)
|
||||||
|
|
||||||
|
# Opt a set of vendored sources out of instrumentation.
|
||||||
|
#
|
||||||
|
# cockatrice_disable_coverage_for(<source> ...)
|
||||||
|
#
|
||||||
|
# Third-party code drags the total down with lines no test can ever reach, and a total that
|
||||||
|
# cannot move is not a useful signal. This is the same carve-out the sanitizer build uses.
|
||||||
|
#
|
||||||
|
# The sources must be named relative to the directory that compiles them. CMake resolves a
|
||||||
|
# source-file property against the directory scope the target was declared in, so calling
|
||||||
|
# this from the root with absolute paths silently does nothing. That is why the call sites
|
||||||
|
# live next to the add_library/add_executable that pull the vendored code in.
|
||||||
|
#
|
||||||
|
# SKIP_PRECOMPILE_HEADERS mirrors the sanitizer helper. The precompiled header carries the
|
||||||
|
# instrumentation flags, and mixing an instrumented PCH with an uninstrumented translation
|
||||||
|
# unit makes GCC reject it.
|
||||||
|
function(cockatrice_disable_coverage_for)
|
||||||
|
if(NOT ENABLE_COVERAGE)
|
||||||
|
return()
|
||||||
|
endif()
|
||||||
|
# There is no -fno-coverage: --coverage is shorthand for -fprofile-arcs -ftest-coverage, so both
|
||||||
|
# halves have to be negated. CC rejects "-fno-coverage" outright, which is how the vendored C
|
||||||
|
# files below hit it first.
|
||||||
|
set_source_files_properties(
|
||||||
|
${ARGN} PROPERTIES COMPILE_OPTIONS "-fno-profile-arcs;-fno-test-coverage" SKIP_PRECOMPILE_HEADERS ON
|
||||||
|
)
|
||||||
|
endfunction()
|
||||||
|
|
||||||
|
if(ENABLE_COVERAGE)
|
||||||
|
# Line coverage needs the compiler's own toolchain, so only GCC is supported. Clang's
|
||||||
|
# equivalent is llvm-cov, which reads a different intermediate format and would need a
|
||||||
|
# separate report path; rejecting it is better than emitting .gcno files that gcovr cannot
|
||||||
|
# read.
|
||||||
|
if(NOT CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
|
||||||
|
message(FATAL_ERROR "ENABLE_COVERAGE requires GCC, but the compiler is ${CMAKE_CXX_COMPILER_ID}. "
|
||||||
|
"Clang coverage needs llvm-cov and is not wired up."
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Probe the way the flag will actually be used: compile AND link. A .gcno file comes from the
|
||||||
|
# compile step, but the runtime that writes .gcda files comes from the link step, so probing
|
||||||
|
# only the compile step would pass on a toolchain that cannot actually produce coverage.
|
||||||
|
set(_cockatrice_saved_required_flags "${CMAKE_REQUIRED_FLAGS}")
|
||||||
|
set(_cockatrice_saved_required_link_options "${CMAKE_REQUIRED_LINK_OPTIONS}")
|
||||||
|
set(CMAKE_REQUIRED_FLAGS "-O0 -g")
|
||||||
|
set(CMAKE_REQUIRED_LINK_OPTIONS -fprofile-arcs -ftest-coverage)
|
||||||
|
check_cxx_source_compiles("int main() { return 0; }" COCKATRICE_HAVE_COVERAGE_FLAGS)
|
||||||
|
set(CMAKE_REQUIRED_FLAGS "${_cockatrice_saved_required_flags}")
|
||||||
|
set(CMAKE_REQUIRED_LINK_OPTIONS "${_cockatrice_saved_required_link_options}")
|
||||||
|
|
||||||
|
if(NOT COCKATRICE_HAVE_COVERAGE_FLAGS)
|
||||||
|
message(FATAL_ERROR "ENABLE_COVERAGE is on, but this toolchain rejected the coverage flags.")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# -O0 keeps the line mapping honest: at higher optimisation levels the compiler merges and
|
||||||
|
# relocates lines, and the report describes code that no longer exists in that form.
|
||||||
|
add_compile_options(--coverage -O0 -g)
|
||||||
|
add_link_options(--coverage)
|
||||||
|
|
||||||
|
message(STATUS "Coverage instrumentation enabled (--coverage). Report with gcovr over the build dir.")
|
||||||
|
endif()
|
||||||
|
|
@ -40,7 +40,9 @@ 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).
|
||||||
# When adding a new test that needs additional Qt modules, add them here rather than in the test's CMakeLists.txt.
|
# When adding a new test that needs additional Qt modules, add them here rather than in the test's CMakeLists.txt.
|
||||||
set(_TEST_NEEDED Concurrent Network Svg Widgets)
|
# Test is QtTest: it brings QAbstractItemModelTester and QSignalSpy, which are how the Qt model
|
||||||
|
# and signal contracts get verified without standing up a GUI.
|
||||||
|
set(_TEST_NEEDED Concurrent Network Svg Widgets Test)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
set(REQUIRED_QT_COMPONENTS ${REQUIRED_QT_COMPONENTS} ${_SERVATRICE_NEEDED} ${_COCKATRICE_NEEDED} ${_ORACLE_NEEDED}
|
set(REQUIRED_QT_COMPONENTS ${REQUIRED_QT_COMPONENTS} ${_SERVATRICE_NEEDED} ${_COCKATRICE_NEEDED} ${_ORACLE_NEEDED}
|
||||||
|
|
|
||||||
|
|
@ -304,10 +304,11 @@ SetShellVarContext all
|
||||||
; _?=$INSTDIR keeps the old uninstaller running in place. An NSIS uninstaller
|
; _?=$INSTDIR keeps the old uninstaller running in place. An NSIS uninstaller
|
||||||
; normally copies itself to %TEMP% and runs from there so that it can delete its
|
; normally copies itself to %TEMP% and runs from there so that it can delete its
|
||||||
; own directory, and the process we started exits at once. ExecWait then returns
|
; own directory, and the process we started exits at once. ExecWait then returns
|
||||||
; before the uninstall has run, and the temp copy's "RMDir /r $INSTDIR" executes
|
; before the uninstall has run, and the temp copy keeps deleting files - from
|
||||||
; while the section below is still writing the new payload. _?= disables the copy
|
; 3.1.0 and 3.1.1 on disk a blanket "RMDir /r $INSTDIR" - while the section
|
||||||
; and makes ExecWait block until the uninstall finishes. It must be the last
|
; below is still writing the new payload. _?= disables the copy and makes
|
||||||
; argument and must not be quoted, even when the path contains spaces.
|
; ExecWait block until the uninstall finishes. It must be the last argument
|
||||||
|
; and must not be quoted, even when the path contains spaces.
|
||||||
;
|
;
|
||||||
; --- 32-bit uninstall ---
|
; --- 32-bit uninstall ---
|
||||||
SetRegView 32
|
SetRegView 32
|
||||||
|
|
@ -1107,9 +1108,14 @@ ${AndIf} ${FileExists} "$INSTDIR\portable.dat"
|
||||||
; upgrade portable mode
|
; upgrade portable mode
|
||||||
!insertmacro CockLog "section: portable upgrade purge"
|
!insertmacro CockLog "section: portable upgrade purge"
|
||||||
!insertmacro CockLogListing "before-portable-purge" "*.dll"
|
!insertmacro CockLogListing "before-portable-purge" "*.dll"
|
||||||
|
; plugins and translations are payload and are rewritten by the payload
|
||||||
|
; below, so removing them here is safe. sounds and themes are payload
|
||||||
|
; too - the default packs are rewritten below as well - but a portable
|
||||||
|
; user's custom sounds and themes live in exactly those folders, so a
|
||||||
|
; recursive purge here would delete user content along with the defaults.
|
||||||
|
; Stale default files from an older release are overwritten or replaced
|
||||||
|
; by the payload, never by a purge.
|
||||||
RMDir /r "$INSTDIR\plugins"
|
RMDir /r "$INSTDIR\plugins"
|
||||||
RMDir /r "$INSTDIR\sounds"
|
|
||||||
RMDir /r "$INSTDIR\themes"
|
|
||||||
RMDir /r "$INSTDIR\translations"
|
RMDir /r "$INSTDIR\translations"
|
||||||
Delete "$INSTDIR\uninstall.exe"
|
Delete "$INSTDIR\uninstall.exe"
|
||||||
Delete "$INSTDIR\cockatrice.exe"
|
Delete "$INSTDIR\cockatrice.exe"
|
||||||
|
|
@ -1263,16 +1269,32 @@ Section "un.Application" UnSecApplication
|
||||||
SetShellVarContext all
|
SetShellVarContext all
|
||||||
StrCpy $R1 0
|
StrCpy $R1 0
|
||||||
|
|
||||||
; Record whether this was a portable install before the directory (and the
|
; Record whether this was a portable install before portable.dat is
|
||||||
; portable.dat marker inside it) is removed below.
|
; removed below.
|
||||||
IfFileExists "$INSTDIR\portable.dat" 0 +2
|
IfFileExists "$INSTDIR\portable.dat" 0 +2
|
||||||
StrCpy $R1 1
|
StrCpy $R1 1
|
||||||
|
|
||||||
; Remove the entire application directory so any file that is not part of
|
; Remove only what the installer wrote. The two lists below are generated
|
||||||
; the installed payload (e.g. build-tree artifacts such as *.dir folders,
|
; by CPack from the staged install tree: one Delete per installed file and
|
||||||
; *_autogen and *.tlog files from a build) cannot survive between an
|
; one non-recursive RMDir per installed directory, children first.
|
||||||
; uninstall and a fresh reinstall.
|
; Anything the user put in $INSTDIR - decks, pictures, portable data
|
||||||
RMDir /r "$INSTDIR"
|
; folders - is not on those lists and survives, and $INSTDIR itself is
|
||||||
|
; only removed when nothing else is left in it. An earlier version of this
|
||||||
|
; section ran a blanket "RMDir /r $INSTDIR" to keep build-tree artifacts
|
||||||
|
; out of a fresh reinstall; that also deleted user decks whenever a user
|
||||||
|
; had pointed their data folders into the install directory, so payload
|
||||||
|
; purity may never be bought at that price again.
|
||||||
|
@CPACK_NSIS_DELETE_FILES@
|
||||||
|
@CPACK_NSIS_DELETE_DIRECTORIES@
|
||||||
|
|
||||||
|
; Written by the installer itself, so absent from the staged payload
|
||||||
|
; above; qdebug.txt is app-generated log output next to the executables.
|
||||||
|
Delete "$INSTDIR\uninstall.exe"
|
||||||
|
Delete "$INSTDIR\portable.dat"
|
||||||
|
Delete "$INSTDIR\qdebug.txt"
|
||||||
|
|
||||||
|
; Succeeds only when no foreign files remain.
|
||||||
|
RMDir "$INSTDIR"
|
||||||
|
|
||||||
RMDir /r "$SMPROGRAMS\Cockatrice"
|
RMDir /r "$SMPROGRAMS\Cockatrice"
|
||||||
|
|
||||||
|
|
|
||||||
109
cmake/Sanitizers.cmake
Normal file
109
cmake/Sanitizers.cmake
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
# AddressSanitizer + UndefinedBehaviorSanitizer opt-in, plus per-source opt-out
|
||||||
|
# Input: ENABLE_SANITIZERS
|
||||||
|
# Defines: cockatrice_disable_sanitizers_for
|
||||||
|
#
|
||||||
|
# Included once from the root CMakeLists.txt, immediately after the per-compiler flag
|
||||||
|
# block, so add_compile_options() reaches every library and executable defined below.
|
||||||
|
|
||||||
|
include(CheckCXXSourceCompiles)
|
||||||
|
|
||||||
|
# Opt a set of vendored sources out of instrumentation.
|
||||||
|
#
|
||||||
|
# cockatrice_disable_sanitizers_for(<source> ...)
|
||||||
|
#
|
||||||
|
# Sanitizer findings inside vendored code are not actionable for us, and letting them
|
||||||
|
# through buries our own bugs in noise until everyone learns to skip the job.
|
||||||
|
#
|
||||||
|
# The sources must be named relative to the directory that compiles them. CMake resolves a
|
||||||
|
# source-file property against the directory scope the target was declared in, so calling
|
||||||
|
# this from the root with absolute paths silently does nothing. That is why the call sites
|
||||||
|
# live next to the add_library/add_executable that pull the vendored code in.
|
||||||
|
#
|
||||||
|
# SKIP_PRECOMPILE_HEADERS is not optional. The precompiled header is built with the
|
||||||
|
# sanitizer flags, so it defines __SANITIZE_ADDRESS__; a translation unit compiled with
|
||||||
|
# -fno-sanitize=all does not, and GCC then refuses the PCH with
|
||||||
|
# "not used because __SANITIZE_ADDRESS__ not defined", which is a fatal -Werror=invalid-pch
|
||||||
|
# in the servatrice and oracle builds.
|
||||||
|
function(cockatrice_disable_sanitizers_for)
|
||||||
|
if(NOT ENABLE_SANITIZERS)
|
||||||
|
return()
|
||||||
|
endif()
|
||||||
|
set_source_files_properties(${ARGN} PROPERTIES COMPILE_OPTIONS "-fno-sanitize=all" SKIP_PRECOMPILE_HEADERS ON)
|
||||||
|
endfunction()
|
||||||
|
|
||||||
|
if(ENABLE_SANITIZERS)
|
||||||
|
# Probe a flag the way it will actually be used: on the compile step AND the link step.
|
||||||
|
# check_cxx_compiler_flag() only probes the compile step, which makes every sanitizer
|
||||||
|
# flag look unsupported because linking an ASan object without -fsanitize=address leaves
|
||||||
|
# __asan_init undefined. That is a silent misconfiguration rather than an error, so the
|
||||||
|
# probe has to link.
|
||||||
|
function(cockatrice_probe_flag FLAG VARNAME)
|
||||||
|
set(CMAKE_REQUIRED_FLAGS "${FLAG}")
|
||||||
|
set(CMAKE_REQUIRED_LINK_OPTIONS "${FLAG}")
|
||||||
|
check_cxx_source_compiles("int main() { return 0; }" ${VARNAME})
|
||||||
|
unset(CMAKE_REQUIRED_FLAGS)
|
||||||
|
unset(CMAKE_REQUIRED_LINK_OPTIONS)
|
||||||
|
endfunction()
|
||||||
|
|
||||||
|
set(SANITIZER_FLAGS
|
||||||
|
-fsanitize=address,undefined
|
||||||
|
-fno-omit-frame-pointer
|
||||||
|
# UBSan defaults to recover mode: it prints "runtime error: ..." but the
|
||||||
|
# process exits 0, so gtest/ctest report a pass and CI stays green.
|
||||||
|
# -fno-sanitize-recover makes the first finding abort instead. This is
|
||||||
|
# probed like the other cosmetic flags, but CI also sets
|
||||||
|
# UBSAN_OPTIONS=halt_on_error=1 as the runtime-side guarantee.
|
||||||
|
-fno-sanitize-recover=undefined
|
||||||
|
# Qt builds QObject subclasses into raw storage and writes the vptr afterwards, which
|
||||||
|
# the vptr check reports on essentially every QObject instantiation. The check has no
|
||||||
|
# value for us because nothing outside moc-generated code inherits QObject, so it is
|
||||||
|
# off outright rather than suppressed piecemeal.
|
||||||
|
-fno-sanitize=vptr
|
||||||
|
)
|
||||||
|
|
||||||
|
# MSVC has no ASan/UBSan equivalent, and clang-cl would need /fsanitize=address plus a
|
||||||
|
# matching runtime link, which is a separate piece of work.
|
||||||
|
if(MSVC)
|
||||||
|
message(FATAL_ERROR "ENABLE_SANITIZERS is not supported with MSVC")
|
||||||
|
endif()
|
||||||
|
|
||||||
|
# Probe rather than assume, so an older toolchain gets the sanitizers it can support
|
||||||
|
# instead of failing the build on a flag it does not recognize.
|
||||||
|
set(SANITIZER_SUPPORTED_FLAGS)
|
||||||
|
foreach(FLAG ${SANITIZER_FLAGS})
|
||||||
|
string(MAKE_C_IDENTIFIER "COCKATRICE_HAS${FLAG}" _FLAG_VAR)
|
||||||
|
cockatrice_probe_flag("${FLAG}" ${_FLAG_VAR})
|
||||||
|
if(${_FLAG_VAR})
|
||||||
|
list(APPEND SANITIZER_SUPPORTED_FLAGS ${FLAG})
|
||||||
|
else()
|
||||||
|
message(STATUS "Sanitizers: ${FLAG} unsupported by ${CMAKE_CXX_COMPILER_ID}, skipping")
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
|
||||||
|
# The whole point of the option is the sanitizers, so an unsupported pair is fatal even
|
||||||
|
# though the cosmetic flags above are allowed to degrade.
|
||||||
|
if(NOT "-fsanitize=address,undefined" IN_LIST SANITIZER_SUPPORTED_FLAGS)
|
||||||
|
message(FATAL_ERROR "ENABLE_SANITIZERS requires -fsanitize=address,undefined, which "
|
||||||
|
"${CMAKE_CXX_COMPILER_ID} does not accept"
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
add_compile_options(${SANITIZER_SUPPORTED_FLAGS})
|
||||||
|
add_link_options(-fsanitize=address,undefined)
|
||||||
|
|
||||||
|
# Recorded so the test tree can label its entries and a plain `make` at the same time
|
||||||
|
# cannot quietly disagree with what CI ran.
|
||||||
|
set(COCKATRICE_SANITIZERS
|
||||||
|
ON
|
||||||
|
CACHE INTERNAL "AddressSanitizer and UBSan are enabled"
|
||||||
|
)
|
||||||
|
else()
|
||||||
|
# ENABLE_SANITIZERS is OFF, but COCKATRICE_SANITIZERS is CACHE INTERNAL, so a
|
||||||
|
# reused build dir (e.g. CI's release-package step reconfiguring the same dir
|
||||||
|
# after the sanitized debug step) would otherwise keep skipping NO_SANITIZE
|
||||||
|
# tests and keep the 300s discovery timeout. Reset it explicitly.
|
||||||
|
set(COCKATRICE_SANITIZERS
|
||||||
|
OFF
|
||||||
|
CACHE INTERNAL "AddressSanitizer and UBSan are enabled" FORCE
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
|
@ -3,22 +3,53 @@
|
||||||
*
|
*
|
||||||
* Safe for every target that links Qt Core, including the headless
|
* Safe for every target that links Qt Core, including the headless
|
||||||
* Servatrice binary. Keep this header free of any widget/gui types.
|
* Servatrice binary. Keep this header free of any widget/gui types.
|
||||||
|
*
|
||||||
|
* Candidates are chosen from IWYU include statistics: a header earns a
|
||||||
|
* spot when it is not already reachable from the rest of this list and
|
||||||
|
* enough translation units re-parse it. Qt headers are stable across
|
||||||
|
* builds, so the precompiled header rarely invalidates.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include <QBasicTimer>
|
#include <QBasicTimer>
|
||||||
#include <QByteArray>
|
#include <QByteArray>
|
||||||
|
#include <QCommandLineParser>
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QCryptographicHash>
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
|
#include <QDir>
|
||||||
|
#include <QElapsedTimer>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
|
#include <QFileInfo>
|
||||||
#include <QHash>
|
#include <QHash>
|
||||||
|
#include <QItemSelectionModel>
|
||||||
|
#include <QJsonArray>
|
||||||
|
#include <QJsonDocument>
|
||||||
|
#include <QJsonObject>
|
||||||
|
#include <QJsonValue>
|
||||||
#include <QList>
|
#include <QList>
|
||||||
#include <QLoggingCategory>
|
#include <QLoggingCategory>
|
||||||
#include <QMap>
|
#include <QMap>
|
||||||
|
#include <QMetaEnum>
|
||||||
#include <QMetaObject>
|
#include <QMetaObject>
|
||||||
|
#include <QMutex>
|
||||||
|
#include <QMutexLocker>
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
|
#include <QPointer>
|
||||||
|
#include <QPropertyAnimation>
|
||||||
#include <QRandomGenerator>
|
#include <QRandomGenerator>
|
||||||
|
#include <QReadWriteLock>
|
||||||
|
#include <QRegularExpression>
|
||||||
|
#include <QSet>
|
||||||
|
#include <QSettings>
|
||||||
#include <QSharedPointer>
|
#include <QSharedPointer>
|
||||||
|
#include <QSortFilterProxyModel>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QStringList>
|
#include <QStringList>
|
||||||
|
#include <QTemporaryDir>
|
||||||
|
#include <QThread>
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
|
#include <QUuid>
|
||||||
#include <QVariant>
|
#include <QVariant>
|
||||||
|
#include <QXmlStreamReader>
|
||||||
|
#include <QXmlStreamWriter>
|
||||||
|
|
|
||||||
|
|
@ -4,27 +4,56 @@
|
||||||
* Includes the Qt Core precompiled header plus the heavy Gui, Widgets and
|
* Includes the Qt Core precompiled header plus the heavy Gui, Widgets and
|
||||||
* Network layers that virtually every client translation unit re-parses.
|
* Network layers that virtually every client translation unit re-parses.
|
||||||
* Do not use on Servatrice (headless, QT_DONT_USE_QTGUI).
|
* Do not use on Servatrice (headless, QT_DONT_USE_QTGUI).
|
||||||
|
*
|
||||||
|
* Candidates are chosen from IWYU include statistics: a header earns a
|
||||||
|
* spot when it is not already reachable from the rest of this list and
|
||||||
|
* enough translation units re-parse it. Qt headers are stable across
|
||||||
|
* builds, so the precompiled header rarely invalidates.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include "qtcore_pch.h"
|
#include "qtcore_pch.h"
|
||||||
|
|
||||||
#include <QAction>
|
#include <QAction>
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
|
#include <QCheckBox>
|
||||||
|
#include <QComboBox>
|
||||||
|
#include <QCompleter>
|
||||||
|
#include <QDialogButtonBox>
|
||||||
|
#include <QDockWidget>
|
||||||
|
#include <QFileDialog>
|
||||||
|
#include <QFormLayout>
|
||||||
#include <QFrame>
|
#include <QFrame>
|
||||||
#include <QGraphicsItem>
|
#include <QGraphicsItem>
|
||||||
#include <QGraphicsScene>
|
#include <QGraphicsScene>
|
||||||
|
#include <QGraphicsSceneMouseEvent>
|
||||||
#include <QGraphicsView>
|
#include <QGraphicsView>
|
||||||
|
#include <QGridLayout>
|
||||||
|
#include <QGroupBox>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QHeaderView>
|
||||||
#include <QImage>
|
#include <QImage>
|
||||||
|
#include <QImageReader>
|
||||||
|
#include <QInputDialog>
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QLayout>
|
#include <QLayout>
|
||||||
|
#include <QListWidget>
|
||||||
#include <QMainWindow>
|
#include <QMainWindow>
|
||||||
#include <QMenu>
|
#include <QMenu>
|
||||||
|
#include <QMessageBox>
|
||||||
#include <QNetworkAccessManager>
|
#include <QNetworkAccessManager>
|
||||||
#include <QNetworkReply>
|
#include <QNetworkReply>
|
||||||
#include <QPainter>
|
#include <QPainter>
|
||||||
|
#include <QPlainTextEdit>
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
#include <QScrollArea>
|
#include <QScrollArea>
|
||||||
|
#include <QSpinBox>
|
||||||
|
#include <QSplitter>
|
||||||
|
#include <QStandardItemModel>
|
||||||
#include <QTabWidget>
|
#include <QTabWidget>
|
||||||
|
#include <QTableWidget>
|
||||||
|
#include <QTableWidgetItem>
|
||||||
#include <QToolBar>
|
#include <QToolBar>
|
||||||
|
#include <QToolButton>
|
||||||
#include <QTreeWidget>
|
#include <QTreeWidget>
|
||||||
|
#include <QVBoxLayout>
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,8 @@ set(cockatrice_SOURCES
|
||||||
src/interface/widgets/dialogs/dlg_update.cpp
|
src/interface/widgets/dialogs/dlg_update.cpp
|
||||||
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/path_safety_warning.cpp
|
||||||
|
src/interface/widgets/dialogs/settings_view.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/deck_share_utils.cpp
|
||||||
src/interface/widgets/deck_share/shared_deck_preview_widget.cpp
|
src/interface/widgets/deck_share/shared_deck_preview_widget.cpp
|
||||||
|
|
|
||||||
|
|
@ -239,6 +239,17 @@ void ConnectionController::onLoginError(int r,
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case Response::RespTooManyRequests: {
|
||||||
|
QString bannedStr = endTime ? tr("Too many login attempts. Try again after %1.")
|
||||||
|
.arg(QDateTime::fromSecsSinceEpoch(endTime).toString())
|
||||||
|
: tr("Too many login attempts. Please try again later.");
|
||||||
|
if (!reasonStr.isEmpty()) {
|
||||||
|
bannedStr.append("\n\n" + reasonStr);
|
||||||
|
}
|
||||||
|
QMessageBox::critical(dialogParent, tr("Error"), bannedStr);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case Response::RespWrongPassword: {
|
case Response::RespWrongPassword: {
|
||||||
QMessageBox::critical(dialogParent, tr("Error"),
|
QMessageBox::critical(dialogParent, tr("Error"),
|
||||||
tr("Incorrect username or password. "
|
tr("Incorrect username or password. "
|
||||||
|
|
@ -365,12 +376,15 @@ void ConnectionController::onRegisterError(int r, QString reasonStr, quint32 end
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case Response::RespTooManyRequests: {
|
case Response::RespTooManyRequests: {
|
||||||
QMessageBox::critical(dialogParent, tr("Registration denied"),
|
QString deniedStr =
|
||||||
tr("It appears you are attempting to register a new account on this server yet you "
|
endTime ? tr("Too many registration attempts. Try again after %1.")
|
||||||
"already have an account registered with the email provided. This server "
|
.arg(QDateTime::fromSecsSinceEpoch(endTime).toString())
|
||||||
"restricts the number of accounts a user can register per address. Please "
|
: tr("It appears you are attempting to register a new account on this server yet you "
|
||||||
"contact the server operator for further assistance or to obtain your "
|
"already have an account registered with the email provided. This server "
|
||||||
"credential information."));
|
"restricts the number of accounts a user can register per address. Please "
|
||||||
|
"contact the server operator for further assistance or to obtain your "
|
||||||
|
"credential information.");
|
||||||
|
QMessageBox::critical(dialogParent, tr("Registration denied"), deniedStr);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case Response::RespPasswordTooShort: {
|
case Response::RespPasswordTooShort: {
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
|
#include <QFutureWatcher>
|
||||||
#include <QIODevice>
|
#include <QIODevice>
|
||||||
#include <QList>
|
#include <QList>
|
||||||
#include <QLocale>
|
#include <QLocale>
|
||||||
|
|
@ -177,7 +178,15 @@ bool SpoilerBackgroundUpdater::saveDownloadedFile(QByteArray data)
|
||||||
|
|
||||||
// Data written, so reload the card database
|
// Data written, so reload the card database
|
||||||
qCInfo(SpoilerBackgroundUpdaterLog) << "Spoiler Service Data Written";
|
qCInfo(SpoilerBackgroundUpdaterLog) << "Spoiler Service Data Written";
|
||||||
const auto reloadOk = QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); });
|
// Spoilers can introduce sets the config has never seen; the loader never
|
||||||
|
// runs checkUnknownSets() itself, so run it once the finished snapshot has
|
||||||
|
// been swapped in.
|
||||||
|
auto *watcher = new QFutureWatcher<void>(this);
|
||||||
|
connect(watcher, &QFutureWatcher<void>::finished, this, [watcher] {
|
||||||
|
watcher->deleteLater();
|
||||||
|
CardDatabaseManager::getInstance()->checkUnknownSets();
|
||||||
|
});
|
||||||
|
watcher->setFuture(QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); }));
|
||||||
|
|
||||||
// If the user has notifications enabled, let them know
|
// If the user has notifications enabled, let them know
|
||||||
// when the database was last updated
|
// when the database was last updated
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,10 @@ SettingsCache::SettingsCache()
|
||||||
// Migrate settings from global.ini to per-class INI files (runs at most once)
|
// Migrate settings from global.ini to per-class INI files (runs at most once)
|
||||||
SettingsMigration::migrateSettingsFromGlobalIni(settingsPath);
|
SettingsMigration::migrateSettingsFromGlobalIni(settingsPath);
|
||||||
|
|
||||||
|
// Repair gamefilters.ini keys renamed to camelCase by #7084 for profiles that migrated
|
||||||
|
// before that rename (idempotent, safe to run on every startup, portable builds included).
|
||||||
|
SettingsMigration::migrateGameFiltersToCamelCase(settingsPath);
|
||||||
|
|
||||||
shortcutsSettings = new ShortcutsSettings(settingsPath, this);
|
shortcutsSettings = new ShortcutsSettings(settingsPath, this);
|
||||||
cardDatabaseSettings = new CardDatabaseSettings(settingsPath, this);
|
cardDatabaseSettings = new CardDatabaseSettings(settingsPath, this);
|
||||||
serversSettings = new ServersSettings(settingsPath, this);
|
serversSettings = new ServersSettings(settingsPath, this);
|
||||||
|
|
|
||||||
|
|
@ -277,8 +277,6 @@ void GameEventHandler::eventSpectatorLeave(const Event_Leave &event,
|
||||||
emit spectatorLeft(eventPlayerId);
|
emit spectatorLeft(eventPlayerId);
|
||||||
|
|
||||||
game->getPlayerManager()->removeSpectator(eventPlayerId);
|
game->getPlayerManager()->removeSpectator(eventPlayerId);
|
||||||
|
|
||||||
emitUserEvent();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void GameEventHandler::eventGameStateChanged(const Event_GameStateChanged &event,
|
void GameEventHandler::eventGameStateChanged(const Event_GameStateChanged &event,
|
||||||
|
|
@ -465,9 +463,9 @@ void GameEventHandler::eventJoin(const Event_Join &event, int /*eventPlayerId*/,
|
||||||
PlayerLogic *newPlayer = game->getPlayerManager()->addPlayer(playerId, playerInfo.user_info());
|
PlayerLogic *newPlayer = game->getPlayerManager()->addPlayer(playerId, playerInfo.user_info());
|
||||||
emit logJoinPlayer(newPlayer);
|
emit logJoinPlayer(newPlayer);
|
||||||
emit playerJoined(playerInfo);
|
emit playerJoined(playerInfo);
|
||||||
}
|
|
||||||
|
|
||||||
emitUserEvent();
|
emitUserEvent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
QString GameEventHandler::getLeaveReason(Event_Leave::LeaveReason reason)
|
QString GameEventHandler::getLeaveReason(Event_Leave::LeaveReason reason)
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
#include "libcockatrice/card/printing/exact_card.h"
|
#include "libcockatrice/card/printing/exact_card.h"
|
||||||
#include "libcockatrice/utility/card_ref.h"
|
#include "libcockatrice/utility/card_ref.h"
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
#include <QBrush>
|
#include <QBrush>
|
||||||
#include <QChar>
|
#include <QChar>
|
||||||
#include <QCursor>
|
#include <QCursor>
|
||||||
|
|
@ -28,6 +29,7 @@
|
||||||
#include <QPixmap>
|
#include <QPixmap>
|
||||||
#include <QPointF>
|
#include <QPointF>
|
||||||
#include <QRectF>
|
#include <QRectF>
|
||||||
|
#include <QTimer>
|
||||||
#include <QTransform>
|
#include <QTransform>
|
||||||
#include <QVariant>
|
#include <QVariant>
|
||||||
#include <QtGlobal>
|
#include <QtGlobal>
|
||||||
|
|
@ -42,7 +44,7 @@ class QSizeF;
|
||||||
|
|
||||||
AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef, PlayerLogic *_owner, int _id)
|
AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef, PlayerLogic *_owner, int _id)
|
||||||
: ArrowTarget(_owner, parent), id(_id), cardRef(cardRef), tapped(false), facedown(false), tapAngle(0),
|
: ArrowTarget(_owner, parent), id(_id), cardRef(cardRef), tapped(false), facedown(false), tapAngle(0),
|
||||||
bgColor(Qt::transparent), isHovered(false), realZValue(0)
|
bgColor(Qt::transparent), isHovered(false), realZValue(0), pendingSelectionCollapse(false)
|
||||||
{
|
{
|
||||||
setCursor(Qt::OpenHandCursor);
|
setCursor(Qt::OpenHandCursor);
|
||||||
setFlag(ItemIsSelectable);
|
setFlag(ItemIsSelectable);
|
||||||
|
|
@ -344,6 +346,16 @@ void AbstractCardItem::setFaceDown(bool _facedown)
|
||||||
|
|
||||||
void AbstractCardItem::mousePressEvent(QGraphicsSceneMouseEvent *event)
|
void AbstractCardItem::mousePressEvent(QGraphicsSceneMouseEvent *event)
|
||||||
{
|
{
|
||||||
|
cancelPendingSelectionCollapse();
|
||||||
|
if (scene() != nullptr) {
|
||||||
|
// A new press starts a new gesture: no card may collapse the selection out from under it afterwards.
|
||||||
|
for (QGraphicsItem *selectedItem : scene()->selectedItems()) {
|
||||||
|
auto *selectedCard = qgraphicsitem_cast<AbstractCardItem *>(selectedItem);
|
||||||
|
if (selectedCard != nullptr && selectedCard != this) {
|
||||||
|
selectedCard->cancelPendingSelectionCollapse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if ((event->modifiers() & Qt::AltModifier) && event->button() == Qt::LeftButton) {
|
if ((event->modifiers() & Qt::AltModifier) && event->button() == Qt::LeftButton) {
|
||||||
emit cardShiftClicked(cardRef.name);
|
emit cardShiftClicked(cardRef.name);
|
||||||
} else if ((event->modifiers() & Qt::ControlModifier)) {
|
} else if ((event->modifiers() & Qt::ControlModifier)) {
|
||||||
|
|
@ -351,6 +363,10 @@ void AbstractCardItem::mousePressEvent(QGraphicsSceneMouseEvent *event)
|
||||||
} else if (!isSelected()) {
|
} else if (!isSelected()) {
|
||||||
scene()->clearSelection();
|
scene()->clearSelection();
|
||||||
setSelected(true);
|
setSelected(true);
|
||||||
|
} else if (event->button() == Qt::LeftButton && scene()->selectedItems().count() > 1) {
|
||||||
|
// Part of a multi-selection; collapse only if this turns out to be a click rather than a drag,
|
||||||
|
// so that dragging the whole selection keeps it intact.
|
||||||
|
pendingSelectionCollapse = true;
|
||||||
}
|
}
|
||||||
if (event->button() == Qt::LeftButton) {
|
if (event->button() == Qt::LeftButton) {
|
||||||
setCursor(Qt::ClosedHandCursor);
|
setCursor(Qt::ClosedHandCursor);
|
||||||
|
|
@ -362,6 +378,32 @@ void AbstractCardItem::mousePressEvent(QGraphicsSceneMouseEvent *event)
|
||||||
|
|
||||||
void AbstractCardItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
|
void AbstractCardItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
|
||||||
{
|
{
|
||||||
|
if (pendingSelectionCollapse) {
|
||||||
|
pendingSelectionCollapse = false;
|
||||||
|
bool dragged = (event->screenPos() - event->buttonDownScreenPos(event->button())).manhattanLength() >=
|
||||||
|
2 * QApplication::startDragDistance();
|
||||||
|
if (!dragged) {
|
||||||
|
if (doubleClickActsOnSelection()) {
|
||||||
|
// A double-click may still arrive and must act on the whole multi-selection, so collapse only
|
||||||
|
// once the double-click window has passed without one. The generation guard (and the receiver
|
||||||
|
// context) drops the collapse if the item is gone, deselected, or a double-click cancelled it;
|
||||||
|
// the count guard drops it if the selection changed meanwhile (rubber band, ctrl-toggle).
|
||||||
|
const int generation = ++selectionCollapseGeneration;
|
||||||
|
const int armedSelectionCount = scene()->selectedItems().count();
|
||||||
|
QTimer::singleShot(QApplication::doubleClickInterval(), this, [this, generation, armedSelectionCount] {
|
||||||
|
if (generation == selectionCollapseGeneration && scene() != nullptr && isSelected() &&
|
||||||
|
scene()->selectedItems().count() == armedSelectionCount && armedSelectionCount > 1) {
|
||||||
|
scene()->clearSelection();
|
||||||
|
setSelected(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
scene()->clearSelection();
|
||||||
|
setSelected(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (event->button() == Qt::MiddleButton) {
|
if (event->button() == Qt::MiddleButton) {
|
||||||
emit deleteCardInfoPopup(cardRef.name);
|
emit deleteCardInfoPopup(cardRef.name);
|
||||||
}
|
}
|
||||||
|
|
@ -370,6 +412,17 @@ void AbstractCardItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
|
||||||
event->accept();
|
event->accept();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void AbstractCardItem::cancelPendingSelectionCollapse()
|
||||||
|
{
|
||||||
|
pendingSelectionCollapse = false;
|
||||||
|
++selectionCollapseGeneration;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AbstractCardItem::doubleClickActsOnSelection() const
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
void AbstractCardItem::processHoverEvent()
|
void AbstractCardItem::processHoverEvent()
|
||||||
{
|
{
|
||||||
emit hovered(this);
|
emit hovered(this);
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,8 @@ protected:
|
||||||
private:
|
private:
|
||||||
bool isHovered;
|
bool isHovered;
|
||||||
qreal realZValue;
|
qreal realZValue;
|
||||||
|
bool pendingSelectionCollapse;
|
||||||
|
int selectionCollapseGeneration = 0; ///< Guards the deferred collapse timer against stale firings
|
||||||
private slots:
|
private slots:
|
||||||
void pixmapUpdated();
|
void pixmapUpdated();
|
||||||
|
|
||||||
|
|
@ -130,6 +132,8 @@ public:
|
||||||
}
|
}
|
||||||
void setFaceDown(bool _facedown);
|
void setFaceDown(bool _facedown);
|
||||||
void processHoverEvent();
|
void processHoverEvent();
|
||||||
|
/** @brief Drop a pending collapse and disarm any deferred collapse timer. */
|
||||||
|
void cancelPendingSelectionCollapse();
|
||||||
void deleteCardInfoPopup()
|
void deleteCardInfoPopup()
|
||||||
{
|
{
|
||||||
emit deleteCardInfoPopup(cardRef.name);
|
emit deleteCardInfoPopup(cardRef.name);
|
||||||
|
|
@ -144,6 +148,16 @@ protected:
|
||||||
void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override;
|
void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override;
|
||||||
QVariant itemChange(QGraphicsItem::GraphicsItemChange change, const QVariant &value) override;
|
QVariant itemChange(QGraphicsItem::GraphicsItemChange change, const QVariant &value) override;
|
||||||
void cacheBgColor();
|
void cacheBgColor();
|
||||||
|
/**
|
||||||
|
* @brief Whether a double-click on this item acts on the whole selection.
|
||||||
|
*
|
||||||
|
* When true, a release without drag defers the multi-selection collapse
|
||||||
|
* until the double-click window passes, so a following double-click still
|
||||||
|
* sees every selected card. Subclasses that handle double-clicks on the
|
||||||
|
* selection must return true here and call
|
||||||
|
* cancelPendingSelectionCollapse() from their double-click handler.
|
||||||
|
*/
|
||||||
|
virtual bool doubleClickActsOnSelection() const;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -518,6 +518,9 @@ void CardItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event)
|
||||||
|
|
||||||
void CardItem::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event)
|
void CardItem::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event)
|
||||||
{
|
{
|
||||||
|
// The preceding release deferred its multi-selection collapse for exactly this: the double-click plays
|
||||||
|
// every selected card, so the collapse must not run.
|
||||||
|
cancelPendingSelectionCollapse();
|
||||||
if ((event->modifiers() != Qt::AltModifier) && (event->buttons() == Qt::LeftButton) &&
|
if ((event->modifiers() != Qt::AltModifier) && (event->buttons() == Qt::LeftButton) &&
|
||||||
(SettingsCache::instance().userInterface().getDoubleClickToPlay())) {
|
(SettingsCache::instance().userInterface().getDoubleClickToPlay())) {
|
||||||
handleClickedToPlay(event->modifiers().testFlag(Qt::ShiftModifier));
|
handleClickedToPlay(event->modifiers().testFlag(Qt::ShiftModifier));
|
||||||
|
|
@ -525,6 +528,11 @@ void CardItem::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event)
|
||||||
event->accept();
|
event->accept();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool CardItem::doubleClickActsOnSelection() const
|
||||||
|
{
|
||||||
|
return SettingsCache::instance().userInterface().getDoubleClickToPlay();
|
||||||
|
}
|
||||||
|
|
||||||
bool CardItem::animationEvent()
|
bool CardItem::animationEvent()
|
||||||
{
|
{
|
||||||
int rotation = ROTATION_DEGREES_PER_FRAME;
|
int rotation = ROTATION_DEGREES_PER_FRAME;
|
||||||
|
|
|
||||||
|
|
@ -175,6 +175,7 @@ protected:
|
||||||
void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override;
|
void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override;
|
||||||
void mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) override;
|
void mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) override;
|
||||||
QVariant itemChange(GraphicsItemChange change, const QVariant &value) override;
|
QVariant itemChange(GraphicsItemChange change, const QVariant &value) override;
|
||||||
|
bool doubleClickActsOnSelection() const override;
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif
|
#endif
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,15 @@ void DeckView::mouseDoubleClickEvent(QMouseEvent *event)
|
||||||
QList<MoveCard_ToZone> result;
|
QList<MoveCard_ToZone> result;
|
||||||
QList<QGraphicsItem *> sel = scene()->selectedItems();
|
QList<QGraphicsItem *> sel = scene()->selectedItems();
|
||||||
|
|
||||||
|
// The double-click goes to the view, not the card, so cancel any pending
|
||||||
|
// selection collapse on the cards before reading the selection.
|
||||||
|
for (auto *item : sel) {
|
||||||
|
if (auto *c = dynamic_cast<DeckViewCard *>(item)) {
|
||||||
|
c->cancelPendingSelectionCollapse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sel = scene()->selectedItems();
|
||||||
|
|
||||||
for (int i = 0; i < sel.size(); i++) {
|
for (int i = 0; i < sel.size(); i++) {
|
||||||
auto *c = static_cast<DeckViewCard *>(sel.at(i));
|
auto *c = static_cast<DeckViewCard *>(sel.at(i));
|
||||||
auto *zone = static_cast<DeckViewCardContainer *>(c->parentItem());
|
auto *zone = static_cast<DeckViewCardContainer *>(c->parentItem());
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,10 @@ public:
|
||||||
{
|
{
|
||||||
return originZone;
|
return originZone;
|
||||||
}
|
}
|
||||||
|
bool doubleClickActsOnSelection() const override
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override;
|
void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override;
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,7 @@ void GameView::startRubberBand(const QPointF &_selectionOrigin)
|
||||||
selectionOrigin = _selectionOrigin;
|
selectionOrigin = _selectionOrigin;
|
||||||
previousBandRect = QRect();
|
previousBandRect = QRect();
|
||||||
rubberBand->setGeometry(QRect(mapFromScene(selectionOrigin), QSize(0, 0)));
|
rubberBand->setGeometry(QRect(mapFromScene(selectionOrigin), QSize(0, 0)));
|
||||||
|
rubberBand->raise();
|
||||||
rubberBand->show();
|
rubberBand->show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,205 +1,41 @@
|
||||||
/**
|
/**
|
||||||
* @file dlg_settings.cpp
|
* @file dlg_settings.cpp
|
||||||
* @brief Implementation of the main settings dialog
|
* @brief Implementation of the main settings dialog (thin frame around SettingsView)
|
||||||
* @ingroup Dialogs
|
* @ingroup Dialogs
|
||||||
*/
|
*/
|
||||||
#include "dlg_settings.h"
|
#include "dlg_settings.h"
|
||||||
|
|
||||||
#include "../../../client/settings/cache_settings.h"
|
#include "../../../client/settings/cache_settings.h"
|
||||||
#include "../../pixel_map_generator.h"
|
|
||||||
#include "../main.h"
|
#include "../main.h"
|
||||||
#include "../settings_page/abstract_settings_page.h"
|
#include "../settings_page/abstract_settings_page.h"
|
||||||
#include "../settings_page/appearance_settings_page.h"
|
|
||||||
#include "../settings_page/deck_editor_settings_page.h"
|
|
||||||
#include "../settings_page/general_settings_page.h"
|
|
||||||
#include "../settings_page/messages_settings_page.h"
|
|
||||||
#include "../settings_page/settings_search_delegate.h"
|
|
||||||
#include "../settings_page/settings_search_model.h"
|
|
||||||
#include "../settings_page/shortcut_settings_page.h"
|
|
||||||
#include "../settings_page/sound_settings_page.h"
|
|
||||||
#include "../settings_page/storage_settings_page.h"
|
|
||||||
#include "../settings_page/user_interface_settings_page.h"
|
|
||||||
#include "libcockatrice/card/database/card_database.h"
|
#include "libcockatrice/card/database/card_database.h"
|
||||||
#include "libcockatrice/card/database/card_database_loader.h"
|
#include "libcockatrice/card/database/card_database_loader.h"
|
||||||
#include "libcockatrice/card/database/card_database_manager.h"
|
#include "libcockatrice/card/database/card_database_manager.h"
|
||||||
|
|
||||||
#include <QAbstractAnimation>
|
|
||||||
#include <QAbstractItemView>
|
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#include <QBrush>
|
|
||||||
#include <QByteArray>
|
|
||||||
#include <QCloseEvent>
|
#include <QCloseEvent>
|
||||||
#include <QColor>
|
|
||||||
#include <QDebug>
|
|
||||||
#include <QDir>
|
#include <QDir>
|
||||||
#include <QEasingCurve>
|
|
||||||
#include <QEvent>
|
|
||||||
#include <QFlags>
|
|
||||||
#include <QFrame>
|
|
||||||
#include <QGraphicsOpacityEffect>
|
|
||||||
#include <QGuiApplication>
|
#include <QGuiApplication>
|
||||||
#include <QHBoxLayout>
|
#include <QHBoxLayout>
|
||||||
#include <QIcon>
|
|
||||||
#include <QKeyEvent>
|
|
||||||
#include <QKeySequence>
|
|
||||||
#include <QLineEdit>
|
|
||||||
#include <QListView>
|
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
#include <QMessageLogger>
|
#include <QMessageLogger>
|
||||||
#include <QModelIndex>
|
|
||||||
#include <QObject>
|
|
||||||
#include <QPalette>
|
|
||||||
#include <QPixmap>
|
|
||||||
#include <QPropertyAnimation>
|
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
#include <QRect>
|
|
||||||
#include <QScreen>
|
#include <QScreen>
|
||||||
#include <QScrollArea>
|
|
||||||
#include <QScrollBar>
|
|
||||||
#include <QSequentialAnimationGroup>
|
|
||||||
#include <QShortcut>
|
|
||||||
#include <QSize>
|
#include <QSize>
|
||||||
#include <QSizePolicy>
|
|
||||||
#include <QStackedLayout>
|
|
||||||
#include <QStackedWidget>
|
|
||||||
#include <QStringLiteral>
|
|
||||||
#include <QToolButton>
|
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
#include <QtGlobal>
|
#include <QtGlobal>
|
||||||
#include <libcockatrice/settings/paths_settings.h>
|
#include <libcockatrice/settings/paths_settings.h>
|
||||||
#include <libcockatrice/settings/personal_settings.h>
|
#include <libcockatrice/settings/personal_settings.h>
|
||||||
#include <qnamespace.h>
|
|
||||||
|
|
||||||
class QModelIndex;
|
DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent)
|
||||||
|
|
||||||
/**
|
|
||||||
* @brief Wraps a widget in a scroll area for long settings pages
|
|
||||||
* @param widget The widget to wrap
|
|
||||||
* @return The scroll area containing the widget
|
|
||||||
*/
|
|
||||||
static QScrollArea *makeScrollable(QWidget *widget)
|
|
||||||
{
|
|
||||||
widget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Maximum);
|
|
||||||
|
|
||||||
auto *scrollArea = new QScrollArea;
|
|
||||||
scrollArea->setWidgetResizable(true);
|
|
||||||
scrollArea->setContentsMargins(0, 0, 0, 0);
|
|
||||||
scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
|
||||||
scrollArea->horizontalScrollBar()->setEnabled(false);
|
|
||||||
scrollArea->setWidget(widget);
|
|
||||||
return scrollArea;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @brief Returns the theme icon resources for each settings page, indexed by SettingsPage order
|
|
||||||
*/
|
|
||||||
static QStringList pageIconResources()
|
|
||||||
{
|
|
||||||
return {QStringLiteral("theme:config/general"), QStringLiteral("theme:config/appearance"),
|
|
||||||
QStringLiteral("theme:config/interface"), QStringLiteral("theme:config/deckeditor"),
|
|
||||||
QStringLiteral("theme:config/storage"), QStringLiteral("theme:config/messages"),
|
|
||||||
QStringLiteral("theme:config/sound"), QStringLiteral("theme:config/shorcuts")};
|
|
||||||
}
|
|
||||||
|
|
||||||
DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent), currentTabIndex(0), searchActive(false)
|
|
||||||
{
|
{
|
||||||
auto rec = QGuiApplication::primaryScreen()->availableGeometry();
|
auto rec = QGuiApplication::primaryScreen()->availableGeometry();
|
||||||
setMinimumSize(qMin(750, rec.width()), qMin(700, rec.height()));
|
setMinimumSize(qMin(750, rec.width()), qMin(700, rec.height()));
|
||||||
|
|
||||||
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::updateLanguage);
|
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::updateLanguage);
|
||||||
|
|
||||||
setupUi();
|
settingsView = new SettingsView(this);
|
||||||
|
|
||||||
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::retranslateUi);
|
|
||||||
retranslateUi();
|
|
||||||
|
|
||||||
searchEdit->setFocus();
|
|
||||||
|
|
||||||
adjustSize();
|
|
||||||
}
|
|
||||||
|
|
||||||
void DlgSettings::setupUi()
|
|
||||||
{
|
|
||||||
// Search bar
|
|
||||||
searchEdit = new QLineEdit;
|
|
||||||
searchEdit->setClearButtonEnabled(true);
|
|
||||||
searchEdit->addAction(themePixmap(QStringLiteral("icons/search")), QLineEdit::LeadingPosition);
|
|
||||||
searchEdit->installEventFilter(this);
|
|
||||||
connect(searchEdit, &QLineEdit::textChanged, this, &DlgSettings::onSearchTextChanged);
|
|
||||||
|
|
||||||
auto *searchLayout = new QHBoxLayout;
|
|
||||||
searchLayout->addWidget(searchEdit);
|
|
||||||
|
|
||||||
// Tab bar (built in setupTabBar)
|
|
||||||
setupTabBar();
|
|
||||||
|
|
||||||
// Pages stacked widget
|
|
||||||
pagesWidget = new QStackedWidget;
|
|
||||||
|
|
||||||
auto *generalPage = new GeneralSettingsPage;
|
|
||||||
auto *appearancePage = new AppearanceSettingsPage;
|
|
||||||
auto *userInterfacePage = new UserInterfaceSettingsPage;
|
|
||||||
auto *deckEditorPage = new DeckEditorSettingsPage;
|
|
||||||
auto *storagePage = new StorageSettingsPage;
|
|
||||||
auto *messagesPage = new MessagesSettingsPage;
|
|
||||||
auto *soundPage = new SoundSettingsPage;
|
|
||||||
auto *shortcutsPage = new ShortcutSettingsPage;
|
|
||||||
|
|
||||||
pages.append(generalPage);
|
|
||||||
pages.append(appearancePage);
|
|
||||||
pages.append(userInterfacePage);
|
|
||||||
pages.append(deckEditorPage);
|
|
||||||
pages.append(storagePage);
|
|
||||||
pages.append(messagesPage);
|
|
||||||
pages.append(soundPage);
|
|
||||||
pages.append(shortcutsPage);
|
|
||||||
|
|
||||||
pagesWidget->addWidget(makeScrollable(generalPage));
|
|
||||||
pagesWidget->addWidget(makeScrollable(appearancePage));
|
|
||||||
pagesWidget->addWidget(makeScrollable(userInterfacePage));
|
|
||||||
pagesWidget->addWidget(makeScrollable(deckEditorPage));
|
|
||||||
pagesWidget->addWidget(makeScrollable(storagePage));
|
|
||||||
pagesWidget->addWidget(makeScrollable(messagesPage));
|
|
||||||
pagesWidget->addWidget(soundPage);
|
|
||||||
pagesWidget->addWidget(shortcutsPage);
|
|
||||||
|
|
||||||
Q_ASSERT(pages.size() == NumPages);
|
|
||||||
|
|
||||||
// Search results view (hidden by default)
|
|
||||||
searchResultsView = new QListView;
|
|
||||||
searchResultsView->setUniformItemSizes(false);
|
|
||||||
searchResultsView->setSelectionMode(QAbstractItemView::SingleSelection);
|
|
||||||
searchResultsView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
|
||||||
searchResultsView->setVisible(false);
|
|
||||||
searchResultsView->setStyleSheet(
|
|
||||||
"QListView::item:selected { background: palette(highlight); color: palette(highlighted-text); }");
|
|
||||||
|
|
||||||
searchModel = new SettingsSearchModel(this);
|
|
||||||
searchDelegate = new SettingsSearchDelegate(this);
|
|
||||||
searchResultsView->setModel(searchModel);
|
|
||||||
searchResultsView->setItemDelegate(searchDelegate);
|
|
||||||
connect(searchResultsView, &QListView::clicked, this, &DlgSettings::onSearchResultClicked);
|
|
||||||
|
|
||||||
connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, [this] {
|
|
||||||
const QStringList icons = pageIconResources();
|
|
||||||
for (int i = 0; i < tabButtons.size() && i < icons.size(); ++i) {
|
|
||||||
tabButtons[i]->setIcon(QPixmap(icons[i]));
|
|
||||||
}
|
|
||||||
searchDelegate->setPageIcons(icons);
|
|
||||||
searchResultsView->viewport()->update();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Build search index after pages are created
|
|
||||||
buildSearchIndex();
|
|
||||||
|
|
||||||
// Pages container (stacked widget + search results overlay)
|
|
||||||
pagesContainer = new QWidget;
|
|
||||||
auto *containerLayout = new QStackedLayout;
|
|
||||||
containerLayout->setStackingMode(QStackedLayout::StackAll);
|
|
||||||
containerLayout->addWidget(pagesWidget);
|
|
||||||
containerLayout->addWidget(searchResultsView);
|
|
||||||
pagesContainer->setLayout(containerLayout);
|
|
||||||
|
|
||||||
// Bottom buttons
|
// Bottom buttons
|
||||||
auto *buttonBox = new QHBoxLayout;
|
auto *buttonBox = new QHBoxLayout;
|
||||||
|
|
@ -211,241 +47,25 @@ void DlgSettings::setupUi()
|
||||||
|
|
||||||
// Main layout
|
// Main layout
|
||||||
auto *mainLayout = new QVBoxLayout;
|
auto *mainLayout = new QVBoxLayout;
|
||||||
mainLayout->addLayout(searchLayout);
|
mainLayout->addWidget(settingsView);
|
||||||
mainLayout->addWidget(tabBarWidget);
|
|
||||||
auto *separator = new QFrame;
|
|
||||||
separator->setFrameShape(QFrame::HLine);
|
|
||||||
separator->setFrameShadow(QFrame::Sunken);
|
|
||||||
mainLayout->addWidget(separator);
|
|
||||||
mainLayout->addWidget(pagesContainer);
|
|
||||||
mainLayout->addSpacing(4);
|
mainLayout->addSpacing(4);
|
||||||
mainLayout->addLayout(buttonBox);
|
mainLayout->addLayout(buttonBox);
|
||||||
setLayout(mainLayout);
|
setLayout(mainLayout);
|
||||||
|
|
||||||
// Keyboard shortcuts
|
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::retranslateUi);
|
||||||
auto *searchShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_F), this);
|
retranslateUi();
|
||||||
connect(searchShortcut, &QShortcut::activated, searchEdit, qOverload<>(&QLineEdit::setFocus));
|
|
||||||
|
|
||||||
auto *nextTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Tab), this);
|
adjustSize();
|
||||||
connect(nextTabShortcut, &QShortcut::activated, this, [this] {
|
|
||||||
int next = (currentTabIndex + 1) % tabButtons.size();
|
|
||||||
setActiveTab(next);
|
|
||||||
});
|
|
||||||
|
|
||||||
auto *prevTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Tab), this);
|
|
||||||
connect(prevTabShortcut, &QShortcut::activated, this, [this] {
|
|
||||||
int prev = (currentTabIndex - 1 + tabButtons.size()) % tabButtons.size();
|
|
||||||
setActiveTab(prev);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize to first tab
|
|
||||||
setActiveTab(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
void DlgSettings::setupTabBar()
|
|
||||||
{
|
|
||||||
tabBarWidget = new QWidget;
|
|
||||||
auto *tabLayout = new QHBoxLayout;
|
|
||||||
tabLayout->setContentsMargins(0, 0, 0, 0);
|
|
||||||
tabLayout->setSpacing(2);
|
|
||||||
|
|
||||||
const QStringList iconResources = pageIconResources();
|
|
||||||
|
|
||||||
for (int i = 0; i < iconResources.size(); ++i) {
|
|
||||||
auto *tabButton = new QToolButton;
|
|
||||||
tabButton->setCheckable(true);
|
|
||||||
tabButton->setIcon(QPixmap(iconResources[i]));
|
|
||||||
tabButton->setIconSize(QSize(48, 48));
|
|
||||||
tabButton->setToolButtonStyle(Qt::ToolButtonTextUnderIcon);
|
|
||||||
tabButton->setAutoExclusive(true);
|
|
||||||
tabButton->setMinimumHeight(85);
|
|
||||||
tabButton->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
|
|
||||||
|
|
||||||
connect(tabButton, &QToolButton::clicked, this, [this, idx = i] { onTabClicked(idx); });
|
|
||||||
|
|
||||||
tabButtons.append(tabButton);
|
|
||||||
tabLayout->addWidget(tabButton);
|
|
||||||
}
|
|
||||||
|
|
||||||
tabBarWidget->setLayout(tabLayout);
|
|
||||||
}
|
|
||||||
|
|
||||||
void DlgSettings::buildSearchIndex()
|
|
||||||
{
|
|
||||||
QList<SettingsSearchEntry> allEntries;
|
|
||||||
|
|
||||||
const QStringList pageNames = translatedPageNames();
|
|
||||||
searchDelegate->setPageNames(pageNames);
|
|
||||||
searchDelegate->setPageIcons(pageIconResources());
|
|
||||||
|
|
||||||
for (int i = 0; i < pages.size(); ++i) {
|
|
||||||
QList<SettingsSearchEntry> pageEntries = pages[i]->getSearchEntries();
|
|
||||||
for (auto &entry : pageEntries) {
|
|
||||||
if (entry.pageIndex == -1) {
|
|
||||||
entry.pageIndex = i;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
allEntries.append(pageEntries);
|
|
||||||
}
|
|
||||||
|
|
||||||
searchModel->setSourceEntries(allEntries);
|
|
||||||
}
|
|
||||||
|
|
||||||
void DlgSettings::onTabClicked(int index)
|
|
||||||
{
|
|
||||||
if (searchActive) {
|
|
||||||
switchToTabMode();
|
|
||||||
}
|
|
||||||
setActiveTab(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
void DlgSettings::setActiveTab(int index)
|
|
||||||
{
|
|
||||||
if (index < 0 || index >= tabButtons.size()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentTabIndex = index;
|
|
||||||
pagesWidget->setCurrentIndex(index);
|
|
||||||
|
|
||||||
for (int i = 0; i < tabButtons.size(); ++i) {
|
|
||||||
tabButtons[i]->setChecked(i == index);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Style active tab with a thick accent border + subtle background tint
|
|
||||||
for (int i = 0; i < tabButtons.size(); ++i) {
|
|
||||||
if (i == index) {
|
|
||||||
tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 3px solid palette(highlight); "
|
|
||||||
"border-top-left-radius: 4px; border-top-right-radius: 4px; "
|
|
||||||
"background: palette(window); padding-bottom: 1px; }");
|
|
||||||
} else {
|
|
||||||
tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 1px solid transparent; "
|
|
||||||
"border-top-left-radius: 4px; border-top-right-radius: 4px; "
|
|
||||||
"background: transparent; }");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void DlgSettings::flashWidget(QWidget *widget)
|
|
||||||
{
|
|
||||||
auto *overlay = new QWidget(widget);
|
|
||||||
overlay->setGeometry(widget->rect());
|
|
||||||
overlay->setAttribute(Qt::WA_TransparentForMouseEvents, true);
|
|
||||||
|
|
||||||
QPalette pal = overlay->palette();
|
|
||||||
QColor flashColor = pal.color(QPalette::Highlight);
|
|
||||||
flashColor.setAlpha(100);
|
|
||||||
pal.setBrush(QPalette::Window, flashColor);
|
|
||||||
overlay->setPalette(pal);
|
|
||||||
overlay->setAutoFillBackground(true);
|
|
||||||
|
|
||||||
auto *effect = new QGraphicsOpacityEffect(overlay);
|
|
||||||
effect->setOpacity(0.0);
|
|
||||||
overlay->setGraphicsEffect(effect);
|
|
||||||
overlay->show();
|
|
||||||
overlay->raise();
|
|
||||||
|
|
||||||
auto *flashIn = new QPropertyAnimation(effect, "opacity");
|
|
||||||
flashIn->setDuration(120);
|
|
||||||
flashIn->setStartValue(0.0);
|
|
||||||
flashIn->setEndValue(0.6);
|
|
||||||
flashIn->setEasingCurve(QEasingCurve::OutCubic);
|
|
||||||
|
|
||||||
auto *fadeOut = new QPropertyAnimation(effect, "opacity");
|
|
||||||
fadeOut->setDuration(900);
|
|
||||||
fadeOut->setStartValue(0.6);
|
|
||||||
fadeOut->setEndValue(0.0);
|
|
||||||
fadeOut->setEasingCurve(QEasingCurve::InCubic);
|
|
||||||
|
|
||||||
auto *group = new QSequentialAnimationGroup(overlay);
|
|
||||||
group->addAnimation(flashIn);
|
|
||||||
group->addAnimation(fadeOut);
|
|
||||||
|
|
||||||
connect(group, &QSequentialAnimationGroup::finished, overlay, &QWidget::deleteLater);
|
|
||||||
|
|
||||||
group->start(QAbstractAnimation::DeleteWhenStopped);
|
|
||||||
}
|
|
||||||
|
|
||||||
void DlgSettings::onSearchTextChanged(const QString &text)
|
|
||||||
{
|
|
||||||
searchModel->setFilterString(text);
|
|
||||||
|
|
||||||
if (searchModel->isFilterActive() && !text.trimmed().isEmpty()) {
|
|
||||||
if (!searchActive) {
|
|
||||||
switchToSearchMode();
|
|
||||||
}
|
|
||||||
if (searchModel->rowCount(QModelIndex()) > 0) {
|
|
||||||
searchResultsView->setCurrentIndex(searchModel->index(0));
|
|
||||||
}
|
|
||||||
} else if (searchActive) {
|
|
||||||
switchToTabMode();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void DlgSettings::switchToSearchMode()
|
|
||||||
{
|
|
||||||
searchActive = true;
|
|
||||||
tabBarWidget->setVisible(false);
|
|
||||||
pagesWidget->setVisible(false);
|
|
||||||
searchResultsView->setVisible(true);
|
|
||||||
if (searchModel->rowCount(QModelIndex()) > 0) {
|
|
||||||
searchResultsView->setCurrentIndex(searchModel->index(0));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void DlgSettings::switchToTabMode()
|
|
||||||
{
|
|
||||||
searchActive = false;
|
|
||||||
tabBarWidget->setVisible(true);
|
|
||||||
pagesWidget->setVisible(true);
|
|
||||||
searchResultsView->setVisible(false);
|
|
||||||
searchEdit->blockSignals(true);
|
|
||||||
searchEdit->clear();
|
|
||||||
searchEdit->blockSignals(false);
|
|
||||||
setActiveTab(currentTabIndex);
|
|
||||||
}
|
|
||||||
|
|
||||||
void DlgSettings::onSearchResultClicked(const QModelIndex &index)
|
|
||||||
{
|
|
||||||
navigateToSearchResult(index);
|
|
||||||
}
|
|
||||||
|
|
||||||
void DlgSettings::navigateToSearchResult(const QModelIndex &index)
|
|
||||||
{
|
|
||||||
SettingsSearchEntry entry = searchModel->entryForIndex(index);
|
|
||||||
if (entry.pageIndex < 0 || entry.pageIndex >= pages.size()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Switch to the page
|
|
||||||
switchToTabMode();
|
|
||||||
setActiveTab(entry.pageIndex);
|
|
||||||
|
|
||||||
// Scroll to the widget, focus it, and flash to highlight it
|
|
||||||
if (entry.widget) {
|
|
||||||
QWidget *widget = entry.widget;
|
|
||||||
while (widget) {
|
|
||||||
if (auto *scrollArea = qobject_cast<QScrollArea *>(widget)) {
|
|
||||||
scrollArea->ensureWidgetVisible(entry.widget);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
widget = widget->parentWidget();
|
|
||||||
}
|
|
||||||
entry.widget->setFocus();
|
|
||||||
flashWidget(entry.widget);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void DlgSettings::setTab(int index)
|
void DlgSettings::setTab(int index)
|
||||||
{
|
{
|
||||||
if (index >= 0 && index < tabButtons.size()) {
|
settingsView->setTab(index);
|
||||||
setActiveTab(index);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
AbstractSettingsPage *DlgSettings::page(SettingsPage which) const
|
AbstractSettingsPage *DlgSettings::page(SettingsPage which) const
|
||||||
{
|
{
|
||||||
return pages.value(static_cast<int>(which));
|
return settingsView->page(which);
|
||||||
}
|
}
|
||||||
|
|
||||||
void DlgSettings::updateLanguage()
|
void DlgSettings::updateLanguage()
|
||||||
|
|
@ -454,49 +74,6 @@ void DlgSettings::updateLanguage()
|
||||||
installNewTranslator();
|
installNewTranslator();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool DlgSettings::eventFilter(QObject *watched, QEvent *event)
|
|
||||||
{
|
|
||||||
if (watched == searchEdit && event->type() == QEvent::KeyPress) {
|
|
||||||
auto *keyEvent = static_cast<QKeyEvent *>(event);
|
|
||||||
if (keyEvent->key() == Qt::Key_Escape) {
|
|
||||||
if (searchActive) {
|
|
||||||
switchToTabMode();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} else if (keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter) {
|
|
||||||
if (searchActive) {
|
|
||||||
if (searchResultsView->currentIndex().isValid()) {
|
|
||||||
navigateToSearchResult(searchResultsView->currentIndex());
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} else if (keyEvent->key() == Qt::Key_Down) {
|
|
||||||
if (searchActive) {
|
|
||||||
int nextRow = searchResultsView->currentIndex().row() + 1;
|
|
||||||
if (nextRow >= searchModel->rowCount()) {
|
|
||||||
nextRow = 0;
|
|
||||||
}
|
|
||||||
searchResultsView->setCurrentIndex(searchModel->index(nextRow));
|
|
||||||
searchResultsView->scrollTo(searchModel->index(nextRow));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} else if (keyEvent->key() == Qt::Key_Up) {
|
|
||||||
if (searchActive) {
|
|
||||||
int prevRow = searchResultsView->currentIndex().row() - 1;
|
|
||||||
if (prevRow < 0) {
|
|
||||||
prevRow = searchModel->rowCount() - 1;
|
|
||||||
}
|
|
||||||
if (prevRow >= 0) {
|
|
||||||
searchResultsView->setCurrentIndex(searchModel->index(prevRow));
|
|
||||||
searchResultsView->scrollTo(searchModel->index(prevRow));
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return QDialog::eventFilter(watched, event);
|
|
||||||
}
|
|
||||||
|
|
||||||
void DlgSettings::closeEvent(QCloseEvent *event)
|
void DlgSettings::closeEvent(QCloseEvent *event)
|
||||||
{
|
{
|
||||||
bool showLoadError = true;
|
bool showLoadError = true;
|
||||||
|
|
@ -576,26 +153,5 @@ void DlgSettings::closeEvent(QCloseEvent *event)
|
||||||
void DlgSettings::retranslateUi()
|
void DlgSettings::retranslateUi()
|
||||||
{
|
{
|
||||||
setWindowTitle(tr("Settings"));
|
setWindowTitle(tr("Settings"));
|
||||||
retranslateTabNames();
|
|
||||||
|
|
||||||
searchEdit->setPlaceholderText(tr("Search settings..."));
|
|
||||||
okButton->setText(tr("OK"));
|
okButton->setText(tr("OK"));
|
||||||
|
|
||||||
// Rebuild search index for translated text
|
|
||||||
buildSearchIndex();
|
|
||||||
}
|
|
||||||
|
|
||||||
QStringList DlgSettings::translatedPageNames()
|
|
||||||
{
|
|
||||||
return {tr("General"), tr("Appearance"), tr("User Interface"), tr("Card Sources"),
|
|
||||||
tr("Storage"), tr("Chat"), tr("Sound"), tr("Shortcuts")};
|
|
||||||
}
|
|
||||||
|
|
||||||
void DlgSettings::retranslateTabNames()
|
|
||||||
{
|
|
||||||
const QStringList tabLabels = translatedPageNames();
|
|
||||||
|
|
||||||
for (int i = 0; i < tabButtons.size() && i < tabLabels.size(); ++i) {
|
|
||||||
tabButtons[i]->setText(tabLabels[i]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,105 +1,59 @@
|
||||||
/**
|
/**
|
||||||
* @file dlg_settings.h
|
* @file dlg_settings.h
|
||||||
* @brief Main settings dialog for the Cockatrice client
|
* @brief Main settings dialog for the Cockatrice client (thin frame around SettingsView)
|
||||||
* @ingroup Dialogs
|
* @ingroup Dialogs
|
||||||
*/
|
*/
|
||||||
#ifndef DLG_SETTINGS_H
|
#ifndef DLG_SETTINGS_H
|
||||||
#define DLG_SETTINGS_H
|
#define DLG_SETTINGS_H
|
||||||
|
|
||||||
|
#include "settings_view.h"
|
||||||
|
|
||||||
#include <QDialog>
|
#include <QDialog>
|
||||||
#include <QList>
|
|
||||||
#include <QLoggingCategory>
|
#include <QLoggingCategory>
|
||||||
#include <QString>
|
|
||||||
#include <QStringList>
|
|
||||||
#include <qtmetamacros.h>
|
#include <qtmetamacros.h>
|
||||||
|
|
||||||
class QPushButton;
|
class QPushButton;
|
||||||
class QModelIndex;
|
class AbstractSettingsPage;
|
||||||
class QWidget;
|
|
||||||
|
|
||||||
inline Q_LOGGING_CATEGORY(DlgSettingsLog, "dlg_settings");
|
inline Q_LOGGING_CATEGORY(DlgSettingsLog, "dlg_settings");
|
||||||
|
|
||||||
class QStackedWidget;
|
|
||||||
class QToolButton;
|
|
||||||
class QListView;
|
|
||||||
class QLineEdit;
|
|
||||||
class AbstractSettingsPage;
|
|
||||||
class SettingsSearchModel;
|
|
||||||
class SettingsSearchDelegate;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Main application settings dialog with tabbed navigation and search
|
* @brief Main application settings dialog hosting the shared SettingsView
|
||||||
*
|
*
|
||||||
* Provides a modern settings interface organized into tabbed pages. Users can
|
* Thin QDialog frame around SettingsView: only the window title, the OK button
|
||||||
* either navigate by clicking tabs or search for specific settings using the
|
* and the close validation live here. All pages, navigation and search are
|
||||||
* built-in search bar. Search results are filtered and ranked by relevance.
|
* owned by SettingsView so other hosts (e.g. a settings tab) can reuse them.
|
||||||
*/
|
*/
|
||||||
class DlgSettings : public QDialog
|
class DlgSettings : public QDialog
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
public:
|
public:
|
||||||
/**
|
using SettingsPage = SettingsView::SettingsPage;
|
||||||
* @brief Page order in the tab bar, matching the order pages are added in setupUi()
|
static constexpr SettingsPage GeneralPage = SettingsView::GeneralPage;
|
||||||
*
|
static constexpr SettingsPage AppearancePage = SettingsView::AppearancePage;
|
||||||
* Use these values instead of raw indices so reordering pages never silently
|
static constexpr SettingsPage UserInterfacePage = SettingsView::UserInterfacePage;
|
||||||
* breaks external callers like tab_room.cpp.
|
static constexpr SettingsPage DeckEditorPage = SettingsView::DeckEditorPage;
|
||||||
*/
|
static constexpr SettingsPage StoragePage = SettingsView::StoragePage;
|
||||||
enum SettingsPage
|
static constexpr SettingsPage MessagesPage = SettingsView::MessagesPage;
|
||||||
{
|
static constexpr SettingsPage SoundPage = SettingsView::SoundPage;
|
||||||
GeneralPage = 0,
|
static constexpr SettingsPage ShortcutsPage = SettingsView::ShortcutsPage;
|
||||||
AppearancePage,
|
static constexpr SettingsPage NumPages = SettingsView::NumPages;
|
||||||
UserInterfacePage,
|
|
||||||
DeckEditorPage,
|
|
||||||
StoragePage,
|
|
||||||
MessagesPage,
|
|
||||||
SoundPage,
|
|
||||||
ShortcutsPage,
|
|
||||||
NumPages
|
|
||||||
};
|
|
||||||
|
|
||||||
explicit DlgSettings(QWidget *parent = nullptr);
|
explicit DlgSettings(QWidget *parent = nullptr);
|
||||||
void setTab(int index);
|
void setTab(int index);
|
||||||
AbstractSettingsPage *page(SettingsPage which) const;
|
AbstractSettingsPage *page(SettingsPage which) const;
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
void onTabClicked(int index);
|
|
||||||
void onSearchTextChanged(const QString &text);
|
|
||||||
void onSearchResultClicked(const QModelIndex &index);
|
|
||||||
void updateLanguage();
|
void updateLanguage();
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// UI elements
|
SettingsView *settingsView; ///< Shared settings content
|
||||||
QLineEdit *searchEdit; ///< Search bar for filtering settings
|
QPushButton *okButton; ///< Button to close the dialog
|
||||||
QWidget *tabBarWidget; ///< Container widget for the tab buttons
|
|
||||||
QList<QToolButton *> tabButtons; ///< Navigation tab buttons
|
|
||||||
QStackedWidget *pagesWidget; ///< Stacked widget containing settings pages
|
|
||||||
QListView *searchResultsView; ///< Search results list view
|
|
||||||
QWidget *pagesContainer; ///< Container stacking pages and search results
|
|
||||||
QPushButton *okButton; ///< Button to close the dialog
|
|
||||||
|
|
||||||
// Data
|
|
||||||
QList<AbstractSettingsPage *> pages; ///< All settings page instances
|
|
||||||
SettingsSearchModel *searchModel; ///< Model for search results
|
|
||||||
SettingsSearchDelegate *searchDelegate; ///< Delegate for search result rendering
|
|
||||||
int currentTabIndex; ///< Currently active tab index
|
|
||||||
bool searchActive; ///< Whether search mode is active
|
|
||||||
|
|
||||||
void setupUi();
|
|
||||||
void setupTabBar();
|
|
||||||
void buildSearchIndex();
|
|
||||||
void switchToTabMode();
|
|
||||||
void switchToSearchMode();
|
|
||||||
void navigateToSearchResult(const QModelIndex &index);
|
|
||||||
void setActiveTab(int index);
|
|
||||||
static void flashWidget(QWidget *widget);
|
|
||||||
static QStringList translatedPageNames();
|
|
||||||
|
|
||||||
void retranslateUi();
|
void retranslateUi();
|
||||||
void retranslateTabNames();
|
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
void closeEvent(QCloseEvent *event) override;
|
void closeEvent(QCloseEvent *event) override;
|
||||||
bool eventFilter(QObject *watched, QEvent *event) override;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // DLG_SETTINGS_H
|
#endif // DLG_SETTINGS_H
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
#include "path_safety_warning.h"
|
||||||
|
|
||||||
|
#include "../../client/settings/cache_settings.h"
|
||||||
|
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QMessageBox>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QString>
|
||||||
|
#include <libcockatrice/settings/paths_settings.h>
|
||||||
|
|
||||||
|
class QWidget;
|
||||||
|
|
||||||
|
bool PathSafetyWarning::confirmOutsideProgramDir(QWidget *parent, const QString &path)
|
||||||
|
{
|
||||||
|
if (SettingsCache::instance().getIsPortableBuild()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!PathsSettings::isInsideDir(path, QCoreApplication::applicationDirPath())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
QMessageBox box(QMessageBox::Warning, QObject::tr("Path inside the program directory"),
|
||||||
|
QObject::tr("'%1' is inside the Cockatrice program directory.\n\n"
|
||||||
|
"Files stored there can be deleted by an update or by uninstalling "
|
||||||
|
"Cockatrice, so this change was not applied. Choose a location outside "
|
||||||
|
"the program directory.")
|
||||||
|
.arg(path),
|
||||||
|
QMessageBox::Ok, parent);
|
||||||
|
box.setTextFormat(Qt::PlainText);
|
||||||
|
box.exec();
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
#ifndef COCKATRICE_PATH_SAFETY_WARNING_H
|
||||||
|
#define COCKATRICE_PATH_SAFETY_WARNING_H
|
||||||
|
|
||||||
|
class QString;
|
||||||
|
class QWidget;
|
||||||
|
|
||||||
|
namespace PathSafetyWarning
|
||||||
|
{
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Warns when the user tries to configure a data path inside the program directory.
|
||||||
|
*
|
||||||
|
* Shows a message box explaining that files kept there can be deleted by an
|
||||||
|
* update or by uninstalling Cockatrice. Portable builds are always exempt:
|
||||||
|
* their data lives beside the executables by design.
|
||||||
|
*
|
||||||
|
* @param parent The parent widget
|
||||||
|
* @param path The path the user is trying to configure
|
||||||
|
* @return true when the path is outside the program directory and safe to apply
|
||||||
|
*/
|
||||||
|
bool confirmOutsideProgramDir(QWidget *parent, const QString &path);
|
||||||
|
|
||||||
|
} // namespace PathSafetyWarning
|
||||||
|
|
||||||
|
#endif // COCKATRICE_PATH_SAFETY_WARNING_H
|
||||||
497
cockatrice/src/interface/widgets/dialogs/settings_view.cpp
Normal file
497
cockatrice/src/interface/widgets/dialogs/settings_view.cpp
Normal file
|
|
@ -0,0 +1,497 @@
|
||||||
|
/**
|
||||||
|
* @file settings_view.cpp
|
||||||
|
* @brief Implementation of the settings content widget
|
||||||
|
* @ingroup Dialogs
|
||||||
|
*/
|
||||||
|
#include "settings_view.h"
|
||||||
|
|
||||||
|
#include "../../../client/settings/cache_settings.h"
|
||||||
|
#include "../../pixel_map_generator.h"
|
||||||
|
#include "../settings_page/abstract_settings_page.h"
|
||||||
|
#include "../settings_page/appearance_settings_page.h"
|
||||||
|
#include "../settings_page/deck_editor_settings_page.h"
|
||||||
|
#include "../settings_page/general_settings_page.h"
|
||||||
|
#include "../settings_page/messages_settings_page.h"
|
||||||
|
#include "../settings_page/settings_search_delegate.h"
|
||||||
|
#include "../settings_page/settings_search_model.h"
|
||||||
|
#include "../settings_page/shortcut_settings_page.h"
|
||||||
|
#include "../settings_page/sound_settings_page.h"
|
||||||
|
#include "../settings_page/storage_settings_page.h"
|
||||||
|
#include "../settings_page/user_interface_settings_page.h"
|
||||||
|
|
||||||
|
#include <QAbstractAnimation>
|
||||||
|
#include <QAbstractItemView>
|
||||||
|
#include <QBrush>
|
||||||
|
#include <QColor>
|
||||||
|
#include <QCoreApplication>
|
||||||
|
#include <QDebug>
|
||||||
|
#include <QEasingCurve>
|
||||||
|
#include <QEvent>
|
||||||
|
#include <QFlags>
|
||||||
|
#include <QFrame>
|
||||||
|
#include <QGraphicsOpacityEffect>
|
||||||
|
#include <QHBoxLayout>
|
||||||
|
#include <QIcon>
|
||||||
|
#include <QKeyEvent>
|
||||||
|
#include <QKeySequence>
|
||||||
|
#include <QLineEdit>
|
||||||
|
#include <QListView>
|
||||||
|
#include <QMessageLogger>
|
||||||
|
#include <QModelIndex>
|
||||||
|
#include <QObject>
|
||||||
|
#include <QPalette>
|
||||||
|
#include <QPixmap>
|
||||||
|
#include <QPropertyAnimation>
|
||||||
|
#include <QRect>
|
||||||
|
#include <QScreen>
|
||||||
|
#include <QScrollArea>
|
||||||
|
#include <QScrollBar>
|
||||||
|
#include <QSequentialAnimationGroup>
|
||||||
|
#include <QShortcut>
|
||||||
|
#include <QSize>
|
||||||
|
#include <QSizePolicy>
|
||||||
|
#include <QStackedLayout>
|
||||||
|
#include <QStackedWidget>
|
||||||
|
#include <QStringLiteral>
|
||||||
|
#include <QToolButton>
|
||||||
|
#include <QVBoxLayout>
|
||||||
|
#include <QWidget>
|
||||||
|
#include <QtGlobal>
|
||||||
|
#include <libcockatrice/settings/personal_settings.h>
|
||||||
|
#include <qnamespace.h>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Wraps a widget in a scroll area for long settings pages
|
||||||
|
* @param widget The widget to wrap
|
||||||
|
* @return The scroll area containing the widget
|
||||||
|
*/
|
||||||
|
static QScrollArea *makeScrollable(QWidget *widget)
|
||||||
|
{
|
||||||
|
widget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Maximum);
|
||||||
|
|
||||||
|
auto *scrollArea = new QScrollArea;
|
||||||
|
scrollArea->setWidgetResizable(true);
|
||||||
|
scrollArea->setContentsMargins(0, 0, 0, 0);
|
||||||
|
scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||||
|
scrollArea->horizontalScrollBar()->setEnabled(false);
|
||||||
|
scrollArea->setWidget(widget);
|
||||||
|
return scrollArea;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Returns the theme icon resources for each settings page, indexed by SettingsPage order
|
||||||
|
*/
|
||||||
|
static QStringList pageIconResources()
|
||||||
|
{
|
||||||
|
return {QStringLiteral("theme:config/general"), QStringLiteral("theme:config/appearance"),
|
||||||
|
QStringLiteral("theme:config/interface"), QStringLiteral("theme:config/deckeditor"),
|
||||||
|
QStringLiteral("theme:config/storage"), QStringLiteral("theme:config/messages"),
|
||||||
|
QStringLiteral("theme:config/sound"), QStringLiteral("theme:config/shorcuts")};
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsView::SettingsView(QWidget *parent) : QWidget(parent), currentTabIndex(0), searchActive(false)
|
||||||
|
{
|
||||||
|
setupUi();
|
||||||
|
|
||||||
|
connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &SettingsView::retranslateUi);
|
||||||
|
retranslateUi();
|
||||||
|
|
||||||
|
searchEdit->setFocus();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsView::setupUi()
|
||||||
|
{
|
||||||
|
// Search bar
|
||||||
|
searchEdit = new QLineEdit;
|
||||||
|
searchEdit->setClearButtonEnabled(true);
|
||||||
|
searchEdit->addAction(themePixmap(QStringLiteral("icons/search")), QLineEdit::LeadingPosition);
|
||||||
|
searchEdit->installEventFilter(this);
|
||||||
|
connect(searchEdit, &QLineEdit::textChanged, this, &SettingsView::onSearchTextChanged);
|
||||||
|
|
||||||
|
auto *searchLayout = new QHBoxLayout;
|
||||||
|
searchLayout->addWidget(searchEdit);
|
||||||
|
|
||||||
|
// Tab bar (built in setupTabBar)
|
||||||
|
setupTabBar();
|
||||||
|
|
||||||
|
// Pages stacked widget
|
||||||
|
pagesWidget = new QStackedWidget;
|
||||||
|
|
||||||
|
auto *generalPage = new GeneralSettingsPage;
|
||||||
|
auto *appearancePage = new AppearanceSettingsPage;
|
||||||
|
auto *userInterfacePage = new UserInterfaceSettingsPage;
|
||||||
|
auto *deckEditorPage = new DeckEditorSettingsPage;
|
||||||
|
auto *storagePage = new StorageSettingsPage;
|
||||||
|
auto *messagesPage = new MessagesSettingsPage;
|
||||||
|
auto *soundPage = new SoundSettingsPage;
|
||||||
|
auto *shortcutsPage = new ShortcutSettingsPage;
|
||||||
|
|
||||||
|
pages.append(generalPage);
|
||||||
|
pages.append(appearancePage);
|
||||||
|
pages.append(userInterfacePage);
|
||||||
|
pages.append(deckEditorPage);
|
||||||
|
pages.append(storagePage);
|
||||||
|
pages.append(messagesPage);
|
||||||
|
pages.append(soundPage);
|
||||||
|
pages.append(shortcutsPage);
|
||||||
|
|
||||||
|
pagesWidget->addWidget(makeScrollable(generalPage));
|
||||||
|
pagesWidget->addWidget(makeScrollable(appearancePage));
|
||||||
|
pagesWidget->addWidget(makeScrollable(userInterfacePage));
|
||||||
|
pagesWidget->addWidget(makeScrollable(deckEditorPage));
|
||||||
|
pagesWidget->addWidget(makeScrollable(storagePage));
|
||||||
|
pagesWidget->addWidget(makeScrollable(messagesPage));
|
||||||
|
pagesWidget->addWidget(soundPage);
|
||||||
|
pagesWidget->addWidget(shortcutsPage);
|
||||||
|
|
||||||
|
Q_ASSERT(pages.size() == NumPages);
|
||||||
|
|
||||||
|
// Search results view (hidden by default)
|
||||||
|
searchResultsView = new QListView;
|
||||||
|
searchResultsView->setUniformItemSizes(false);
|
||||||
|
searchResultsView->setSelectionMode(QAbstractItemView::SingleSelection);
|
||||||
|
searchResultsView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
|
||||||
|
searchResultsView->setVisible(false);
|
||||||
|
searchResultsView->setStyleSheet(
|
||||||
|
"QListView::item:selected { background: palette(highlight); color: palette(highlighted-text); }");
|
||||||
|
|
||||||
|
searchModel = new SettingsSearchModel(this);
|
||||||
|
searchDelegate = new SettingsSearchDelegate(this);
|
||||||
|
searchResultsView->setModel(searchModel);
|
||||||
|
searchResultsView->setItemDelegate(searchDelegate);
|
||||||
|
connect(searchResultsView, &QListView::clicked, this, &SettingsView::onSearchResultClicked);
|
||||||
|
|
||||||
|
connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, [this] {
|
||||||
|
const QStringList icons = pageIconResources();
|
||||||
|
for (int i = 0; i < tabButtons.size() && i < icons.size(); ++i) {
|
||||||
|
tabButtons[i]->setIcon(QPixmap(icons[i]));
|
||||||
|
}
|
||||||
|
searchDelegate->setPageIcons(icons);
|
||||||
|
searchResultsView->viewport()->update();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build search index after pages are created
|
||||||
|
buildSearchIndex();
|
||||||
|
|
||||||
|
// Pages container (stacked widget + search results overlay)
|
||||||
|
pagesContainer = new QWidget;
|
||||||
|
auto *containerLayout = new QStackedLayout;
|
||||||
|
containerLayout->setStackingMode(QStackedLayout::StackAll);
|
||||||
|
containerLayout->addWidget(pagesWidget);
|
||||||
|
containerLayout->addWidget(searchResultsView);
|
||||||
|
pagesContainer->setLayout(containerLayout);
|
||||||
|
|
||||||
|
// Main layout
|
||||||
|
auto *mainLayout = new QVBoxLayout;
|
||||||
|
mainLayout->addLayout(searchLayout);
|
||||||
|
mainLayout->addWidget(tabBarWidget);
|
||||||
|
auto *separator = new QFrame;
|
||||||
|
separator->setFrameShape(QFrame::HLine);
|
||||||
|
separator->setFrameShadow(QFrame::Sunken);
|
||||||
|
mainLayout->addWidget(separator);
|
||||||
|
mainLayout->addWidget(pagesContainer);
|
||||||
|
mainLayout->addSpacing(4);
|
||||||
|
setLayout(mainLayout);
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
auto *searchShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_F), this);
|
||||||
|
connect(searchShortcut, &QShortcut::activated, searchEdit, qOverload<>(&QLineEdit::setFocus));
|
||||||
|
|
||||||
|
auto *nextTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Tab), this);
|
||||||
|
connect(nextTabShortcut, &QShortcut::activated, this, [this] {
|
||||||
|
int next = (currentTabIndex + 1) % tabButtons.size();
|
||||||
|
setActiveTab(next);
|
||||||
|
});
|
||||||
|
|
||||||
|
auto *prevTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Tab), this);
|
||||||
|
connect(prevTabShortcut, &QShortcut::activated, this, [this] {
|
||||||
|
int prev = (currentTabIndex - 1 + tabButtons.size()) % tabButtons.size();
|
||||||
|
setActiveTab(prev);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize to first tab
|
||||||
|
setActiveTab(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsView::setupTabBar()
|
||||||
|
{
|
||||||
|
tabBarWidget = new QWidget;
|
||||||
|
auto *tabLayout = new QHBoxLayout;
|
||||||
|
tabLayout->setContentsMargins(0, 0, 0, 0);
|
||||||
|
tabLayout->setSpacing(2);
|
||||||
|
|
||||||
|
const QStringList iconResources = pageIconResources();
|
||||||
|
|
||||||
|
for (int i = 0; i < iconResources.size(); ++i) {
|
||||||
|
auto *tabButton = new QToolButton;
|
||||||
|
tabButton->setCheckable(true);
|
||||||
|
tabButton->setIcon(QPixmap(iconResources[i]));
|
||||||
|
tabButton->setIconSize(QSize(48, 48));
|
||||||
|
tabButton->setToolButtonStyle(Qt::ToolButtonTextUnderIcon);
|
||||||
|
tabButton->setAutoExclusive(true);
|
||||||
|
tabButton->setMinimumHeight(85);
|
||||||
|
tabButton->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed);
|
||||||
|
|
||||||
|
connect(tabButton, &QToolButton::clicked, this, [this, idx = i] { onTabClicked(idx); });
|
||||||
|
|
||||||
|
tabButtons.append(tabButton);
|
||||||
|
tabLayout->addWidget(tabButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
tabBarWidget->setLayout(tabLayout);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsView::buildSearchIndex()
|
||||||
|
{
|
||||||
|
QList<SettingsSearchEntry> allEntries;
|
||||||
|
|
||||||
|
const QStringList pageNames = translatedPageNames();
|
||||||
|
searchDelegate->setPageNames(pageNames);
|
||||||
|
searchDelegate->setPageIcons(pageIconResources());
|
||||||
|
|
||||||
|
for (int i = 0; i < pages.size(); ++i) {
|
||||||
|
QList<SettingsSearchEntry> pageEntries = pages[i]->getSearchEntries();
|
||||||
|
for (auto &entry : pageEntries) {
|
||||||
|
if (entry.pageIndex == -1) {
|
||||||
|
entry.pageIndex = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
allEntries.append(pageEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
searchModel->setSourceEntries(allEntries);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsView::onTabClicked(int index)
|
||||||
|
{
|
||||||
|
if (searchActive) {
|
||||||
|
switchToTabMode();
|
||||||
|
}
|
||||||
|
setActiveTab(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsView::setActiveTab(int index)
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= tabButtons.size()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentTabIndex = index;
|
||||||
|
pagesWidget->setCurrentIndex(index);
|
||||||
|
|
||||||
|
for (int i = 0; i < tabButtons.size(); ++i) {
|
||||||
|
tabButtons[i]->setChecked(i == index);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Style active tab with a thick accent border + subtle background tint
|
||||||
|
for (int i = 0; i < tabButtons.size(); ++i) {
|
||||||
|
if (i == index) {
|
||||||
|
tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 3px solid palette(highlight); "
|
||||||
|
"border-top-left-radius: 4px; border-top-right-radius: 4px; "
|
||||||
|
"background: palette(window); padding-bottom: 1px; }");
|
||||||
|
} else {
|
||||||
|
tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 1px solid transparent; "
|
||||||
|
"border-top-left-radius: 4px; border-top-right-radius: 4px; "
|
||||||
|
"background: transparent; }");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsView::flashWidget(QWidget *widget)
|
||||||
|
{
|
||||||
|
auto *overlay = new QWidget(widget);
|
||||||
|
overlay->setGeometry(widget->rect());
|
||||||
|
overlay->setAttribute(Qt::WA_TransparentForMouseEvents, true);
|
||||||
|
|
||||||
|
QPalette pal = overlay->palette();
|
||||||
|
QColor flashColor = pal.color(QPalette::Highlight);
|
||||||
|
flashColor.setAlpha(100);
|
||||||
|
pal.setBrush(QPalette::Window, flashColor);
|
||||||
|
overlay->setPalette(pal);
|
||||||
|
overlay->setAutoFillBackground(true);
|
||||||
|
|
||||||
|
auto *effect = new QGraphicsOpacityEffect(overlay);
|
||||||
|
effect->setOpacity(0.0);
|
||||||
|
overlay->setGraphicsEffect(effect);
|
||||||
|
overlay->show();
|
||||||
|
overlay->raise();
|
||||||
|
|
||||||
|
auto *flashIn = new QPropertyAnimation(effect, "opacity");
|
||||||
|
flashIn->setDuration(120);
|
||||||
|
flashIn->setStartValue(0.0);
|
||||||
|
flashIn->setEndValue(0.6);
|
||||||
|
flashIn->setEasingCurve(QEasingCurve::OutCubic);
|
||||||
|
|
||||||
|
auto *fadeOut = new QPropertyAnimation(effect, "opacity");
|
||||||
|
fadeOut->setDuration(900);
|
||||||
|
fadeOut->setStartValue(0.6);
|
||||||
|
fadeOut->setEndValue(0.0);
|
||||||
|
fadeOut->setEasingCurve(QEasingCurve::InCubic);
|
||||||
|
|
||||||
|
auto *group = new QSequentialAnimationGroup(overlay);
|
||||||
|
group->addAnimation(flashIn);
|
||||||
|
group->addAnimation(fadeOut);
|
||||||
|
|
||||||
|
connect(group, &QSequentialAnimationGroup::finished, overlay, &QWidget::deleteLater);
|
||||||
|
|
||||||
|
group->start(QAbstractAnimation::DeleteWhenStopped);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsView::onSearchTextChanged(const QString &text)
|
||||||
|
{
|
||||||
|
searchModel->setFilterString(text);
|
||||||
|
|
||||||
|
if (searchModel->isFilterActive() && !text.trimmed().isEmpty()) {
|
||||||
|
if (!searchActive) {
|
||||||
|
switchToSearchMode();
|
||||||
|
}
|
||||||
|
if (searchModel->rowCount(QModelIndex()) > 0) {
|
||||||
|
searchResultsView->setCurrentIndex(searchModel->index(0));
|
||||||
|
}
|
||||||
|
} else if (searchActive) {
|
||||||
|
switchToTabMode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsView::switchToSearchMode()
|
||||||
|
{
|
||||||
|
searchActive = true;
|
||||||
|
tabBarWidget->setVisible(false);
|
||||||
|
pagesWidget->setVisible(false);
|
||||||
|
searchResultsView->setVisible(true);
|
||||||
|
if (searchModel->rowCount(QModelIndex()) > 0) {
|
||||||
|
searchResultsView->setCurrentIndex(searchModel->index(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsView::switchToTabMode()
|
||||||
|
{
|
||||||
|
searchActive = false;
|
||||||
|
tabBarWidget->setVisible(true);
|
||||||
|
pagesWidget->setVisible(true);
|
||||||
|
searchResultsView->setVisible(false);
|
||||||
|
searchEdit->blockSignals(true);
|
||||||
|
searchEdit->clear();
|
||||||
|
searchEdit->blockSignals(false);
|
||||||
|
setActiveTab(currentTabIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsView::onSearchResultClicked(const QModelIndex &index)
|
||||||
|
{
|
||||||
|
navigateToSearchResult(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsView::navigateToSearchResult(const QModelIndex &index)
|
||||||
|
{
|
||||||
|
SettingsSearchEntry entry = searchModel->entryForIndex(index);
|
||||||
|
if (entry.pageIndex < 0 || entry.pageIndex >= pages.size()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switch to the page
|
||||||
|
switchToTabMode();
|
||||||
|
setActiveTab(entry.pageIndex);
|
||||||
|
|
||||||
|
// Scroll to the widget, focus it, and flash to highlight it
|
||||||
|
if (entry.widget) {
|
||||||
|
QWidget *widget = entry.widget;
|
||||||
|
while (widget) {
|
||||||
|
if (auto *scrollArea = qobject_cast<QScrollArea *>(widget)) {
|
||||||
|
scrollArea->ensureWidgetVisible(entry.widget);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
widget = widget->parentWidget();
|
||||||
|
}
|
||||||
|
entry.widget->setFocus();
|
||||||
|
flashWidget(entry.widget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsView::setTab(int index)
|
||||||
|
{
|
||||||
|
if (index >= 0 && index < tabButtons.size()) {
|
||||||
|
setActiveTab(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AbstractSettingsPage *SettingsView::page(SettingsPage which) const
|
||||||
|
{
|
||||||
|
return pages.value(static_cast<int>(which));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SettingsView::eventFilter(QObject *watched, QEvent *event)
|
||||||
|
{
|
||||||
|
if (watched == searchEdit && event->type() == QEvent::KeyPress) {
|
||||||
|
auto *keyEvent = static_cast<QKeyEvent *>(event);
|
||||||
|
if (keyEvent->key() == Qt::Key_Escape) {
|
||||||
|
if (searchActive) {
|
||||||
|
switchToTabMode();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if (keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter) {
|
||||||
|
if (searchActive) {
|
||||||
|
if (searchResultsView->currentIndex().isValid()) {
|
||||||
|
navigateToSearchResult(searchResultsView->currentIndex());
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if (keyEvent->key() == Qt::Key_Down) {
|
||||||
|
if (searchActive) {
|
||||||
|
int nextRow = searchResultsView->currentIndex().row() + 1;
|
||||||
|
if (nextRow >= searchModel->rowCount()) {
|
||||||
|
nextRow = 0;
|
||||||
|
}
|
||||||
|
searchResultsView->setCurrentIndex(searchModel->index(nextRow));
|
||||||
|
searchResultsView->scrollTo(searchModel->index(nextRow));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} else if (keyEvent->key() == Qt::Key_Up) {
|
||||||
|
if (searchActive) {
|
||||||
|
int prevRow = searchResultsView->currentIndex().row() - 1;
|
||||||
|
if (prevRow < 0) {
|
||||||
|
prevRow = searchModel->rowCount() - 1;
|
||||||
|
}
|
||||||
|
if (prevRow >= 0) {
|
||||||
|
searchResultsView->setCurrentIndex(searchModel->index(prevRow));
|
||||||
|
searchResultsView->scrollTo(searchModel->index(prevRow));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return QWidget::eventFilter(watched, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsView::retranslateUi()
|
||||||
|
{
|
||||||
|
retranslateTabNames();
|
||||||
|
|
||||||
|
// NOTE: the "DlgSettings" context is deliberate — these strings lived in DlgSettings
|
||||||
|
// before the view was extracted, and keeping the context preserves existing translations.
|
||||||
|
searchEdit->setPlaceholderText(QCoreApplication::translate("DlgSettings", "Search settings..."));
|
||||||
|
|
||||||
|
// Rebuild search index for translated text
|
||||||
|
buildSearchIndex();
|
||||||
|
}
|
||||||
|
|
||||||
|
QStringList SettingsView::translatedPageNames()
|
||||||
|
{
|
||||||
|
// NOTE: same "DlgSettings" context as above (translation continuity, see retranslateUi).
|
||||||
|
return {QCoreApplication::translate("DlgSettings", "General"),
|
||||||
|
QCoreApplication::translate("DlgSettings", "Appearance"),
|
||||||
|
QCoreApplication::translate("DlgSettings", "User Interface"),
|
||||||
|
QCoreApplication::translate("DlgSettings", "Card Sources"),
|
||||||
|
QCoreApplication::translate("DlgSettings", "Storage"),
|
||||||
|
QCoreApplication::translate("DlgSettings", "Chat"),
|
||||||
|
QCoreApplication::translate("DlgSettings", "Sound"),
|
||||||
|
QCoreApplication::translate("DlgSettings", "Shortcuts")};
|
||||||
|
}
|
||||||
|
|
||||||
|
void SettingsView::retranslateTabNames()
|
||||||
|
{
|
||||||
|
const QStringList tabLabels = translatedPageNames();
|
||||||
|
|
||||||
|
for (int i = 0; i < tabButtons.size() && i < tabLabels.size(); ++i) {
|
||||||
|
tabButtons[i]->setText(tabLabels[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
98
cockatrice/src/interface/widgets/dialogs/settings_view.h
Normal file
98
cockatrice/src/interface/widgets/dialogs/settings_view.h
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
/**
|
||||||
|
* @file settings_view.h
|
||||||
|
* @brief Settings content widget hosted by the settings dialog (and, later, the settings tab)
|
||||||
|
* @ingroup Dialogs
|
||||||
|
*/
|
||||||
|
#ifndef SETTINGS_VIEW_H
|
||||||
|
#define SETTINGS_VIEW_H
|
||||||
|
|
||||||
|
#include <QList>
|
||||||
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
|
#include <QWidget>
|
||||||
|
#include <qtmetamacros.h>
|
||||||
|
|
||||||
|
class QModelIndex;
|
||||||
|
class QObject;
|
||||||
|
class QEvent;
|
||||||
|
|
||||||
|
class QStackedWidget;
|
||||||
|
class QToolButton;
|
||||||
|
class QListView;
|
||||||
|
class QLineEdit;
|
||||||
|
class AbstractSettingsPage;
|
||||||
|
class SettingsSearchModel;
|
||||||
|
class SettingsSearchDelegate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Settings pages with tabbed navigation and search, without any dialog chrome
|
||||||
|
*
|
||||||
|
* Owns the search bar, the tab bar, the stacked settings pages and the search
|
||||||
|
* results view. Dialog/tab hosts only add their own frame (buttons, titles).
|
||||||
|
*/
|
||||||
|
class SettingsView : public QWidget
|
||||||
|
{
|
||||||
|
Q_OBJECT
|
||||||
|
public:
|
||||||
|
/**
|
||||||
|
* @brief Page order in the tab bar, matching the order pages are added in setupUi()
|
||||||
|
*
|
||||||
|
* Use these values instead of raw indices so reordering pages never silently
|
||||||
|
* breaks external callers like tab_room.cpp.
|
||||||
|
*/
|
||||||
|
enum SettingsPage
|
||||||
|
{
|
||||||
|
GeneralPage = 0,
|
||||||
|
AppearancePage,
|
||||||
|
UserInterfacePage,
|
||||||
|
DeckEditorPage,
|
||||||
|
StoragePage,
|
||||||
|
MessagesPage,
|
||||||
|
SoundPage,
|
||||||
|
ShortcutsPage,
|
||||||
|
NumPages
|
||||||
|
};
|
||||||
|
|
||||||
|
explicit SettingsView(QWidget *parent = nullptr);
|
||||||
|
void setTab(int index);
|
||||||
|
AbstractSettingsPage *page(SettingsPage which) const;
|
||||||
|
|
||||||
|
private slots:
|
||||||
|
void onTabClicked(int index);
|
||||||
|
void onSearchTextChanged(const QString &text);
|
||||||
|
void onSearchResultClicked(const QModelIndex &index);
|
||||||
|
|
||||||
|
private:
|
||||||
|
// UI elements
|
||||||
|
QLineEdit *searchEdit; ///< Search bar for filtering settings
|
||||||
|
QWidget *tabBarWidget; ///< Container widget for the tab buttons
|
||||||
|
QList<QToolButton *> tabButtons; ///< Navigation tab buttons
|
||||||
|
QStackedWidget *pagesWidget; ///< Stacked widget containing settings pages
|
||||||
|
QListView *searchResultsView; ///< Search results list view
|
||||||
|
QWidget *pagesContainer; ///< Container stacking pages and search results
|
||||||
|
|
||||||
|
// Data
|
||||||
|
QList<AbstractSettingsPage *> pages; ///< All settings page instances
|
||||||
|
SettingsSearchModel *searchModel; ///< Model for search results
|
||||||
|
SettingsSearchDelegate *searchDelegate; ///< Delegate for search result rendering
|
||||||
|
int currentTabIndex; ///< Currently active tab index
|
||||||
|
bool searchActive; ///< Whether search mode is active
|
||||||
|
|
||||||
|
void setupUi();
|
||||||
|
void setupTabBar();
|
||||||
|
void buildSearchIndex();
|
||||||
|
void switchToTabMode();
|
||||||
|
void switchToSearchMode();
|
||||||
|
void navigateToSearchResult(const QModelIndex &index);
|
||||||
|
void setActiveTab(int index);
|
||||||
|
static void flashWidget(QWidget *widget);
|
||||||
|
static QStringList translatedPageNames();
|
||||||
|
|
||||||
|
void retranslateUi();
|
||||||
|
void retranslateTabNames();
|
||||||
|
|
||||||
|
protected:
|
||||||
|
bool eventFilter(QObject *watched, QEvent *event) override;
|
||||||
|
};
|
||||||
|
|
||||||
|
#endif // SETTINGS_VIEW_H
|
||||||
|
|
@ -17,7 +17,9 @@ struct GameFilterConfigs
|
||||||
static constexpr int DEFAULT_MAX_PLAYERS_MAX = 99;
|
static constexpr int DEFAULT_MAX_PLAYERS_MAX = 99;
|
||||||
|
|
||||||
bool hideBuddiesOnlyGames = false;
|
bool hideBuddiesOnlyGames = false;
|
||||||
bool hideIgnoredUserGames = false;
|
// Defaults to true to match GameFiltersSettings::isHideIgnoredUserGames(), so freshly loaded
|
||||||
|
// filters compare equal to a default-constructed config.
|
||||||
|
bool hideIgnoredUserGames = true;
|
||||||
bool hideFullGames = false;
|
bool hideFullGames = false;
|
||||||
bool hideGamesThatStarted = false;
|
bool hideGamesThatStarted = false;
|
||||||
bool hidePasswordProtectedGames = false;
|
bool hidePasswordProtectedGames = false;
|
||||||
|
|
|
||||||
|
|
@ -113,6 +113,17 @@ GameSelector::GameSelector(AbstractClient *_client,
|
||||||
quickFilterToolBar->setVisible(
|
quickFilterToolBar->setVisible(
|
||||||
SettingsCache::instance().userInterface().getShowGameSelectorFilterToolbar());
|
SettingsCache::instance().userInterface().getShowGameSelectorFilterToolbar());
|
||||||
});
|
});
|
||||||
|
// The quick-filter toolbar only updates the in-memory model, so persist every change.
|
||||||
|
// Otherwise each new GameSelector (room switch, reconnect, restart) would revert to stale
|
||||||
|
// saved values and e.g. hide full/started games the user had unchecked. The save is
|
||||||
|
// debounced so typing in the search field does not rewrite the settings file on every
|
||||||
|
// keystroke; a pending save is flushed in the destructor.
|
||||||
|
filterSaveDebounceTimer.setSingleShot(true);
|
||||||
|
filterSaveDebounceTimer.setInterval(500);
|
||||||
|
connect(&filterSaveDebounceTimer, &QTimer::timeout, this,
|
||||||
|
[this] { gameListProxyModel->saveFilterParameters(gameTypeMap); });
|
||||||
|
connect(gameListProxyModel, &GamesProxyModel::filtersChanged, this,
|
||||||
|
[this] { filterSaveDebounceTimer.start(); });
|
||||||
} else {
|
} else {
|
||||||
quickFilterToolBar = nullptr;
|
quickFilterToolBar = nullptr;
|
||||||
}
|
}
|
||||||
|
|
@ -187,6 +198,18 @@ GameSelector::GameSelector(AbstractClient *_client,
|
||||||
connect(client, &AbstractClient::removeFromListEventReceived, this, &GameSelector::processRemoveFromListEvent);
|
connect(client, &AbstractClient::removeFromListEventReceived, this, &GameSelector::processRemoveFromListEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GameSelector::~GameSelector()
|
||||||
|
{
|
||||||
|
// Nothing else persists quick-filter toolbar edits: the toolbar only updates the in-memory model,
|
||||||
|
// and the debounced timer above is the sole writer for that path (actSetFilter/actClearFilter save
|
||||||
|
// synchronously, but the toolbar does not). Flush a pending debounced save so edits made within
|
||||||
|
// the debounce interval before destruction are not lost on room switch, reconnect, or close.
|
||||||
|
if (filterSaveDebounceTimer.isActive()) {
|
||||||
|
filterSaveDebounceTimer.stop();
|
||||||
|
gameListProxyModel->saveFilterParameters(gameTypeMap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void GameSelector::ignoreListReceived(const QList<ServerInfo_User> &)
|
void GameSelector::ignoreListReceived(const QList<ServerInfo_User> &)
|
||||||
{
|
{
|
||||||
gameListProxyModel->refresh();
|
gameListProxyModel->refresh();
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
#include "game_type_map.h"
|
#include "game_type_map.h"
|
||||||
|
|
||||||
#include <QGroupBox>
|
#include <QGroupBox>
|
||||||
|
#include <QTimer>
|
||||||
#include <qtmetamacros.h>
|
#include <qtmetamacros.h>
|
||||||
// IWYU pragma: keep
|
// IWYU pragma: keep
|
||||||
// ServerInfo_User appears by value and inside QList in slot signatures, so the
|
// ServerInfo_User appears by value and inside QList in slot signatures, so the
|
||||||
|
|
@ -148,6 +149,9 @@ private:
|
||||||
const bool showFilters; /**< Determines whether filter buttons are displayed. */
|
const bool showFilters; /**< Determines whether filter buttons are displayed. */
|
||||||
GameTypeMap gameTypeMap; /**< Mapping of game types for the current room. */
|
GameTypeMap gameTypeMap; /**< Mapping of game types for the current room. */
|
||||||
|
|
||||||
|
QTimer filterSaveDebounceTimer; /**< Delays persisting quick-filter edits so typing does not rewrite the settings
|
||||||
|
file on every keystroke. */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Updates the widget title to reflect the current number of displayed games.
|
* @brief Updates the widget title to reflect the current number of displayed games.
|
||||||
*
|
*
|
||||||
|
|
@ -211,6 +215,7 @@ public:
|
||||||
const bool restoresettings,
|
const bool restoresettings,
|
||||||
const bool _showfilters,
|
const bool _showfilters,
|
||||||
QWidget *parent = nullptr);
|
QWidget *parent = nullptr);
|
||||||
|
~GameSelector() override;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Updates UI text for translation/localization.
|
* @brief Updates UI text for translation/localization.
|
||||||
|
|
|
||||||
|
|
@ -523,6 +523,9 @@ bool GamesProxyModel::filterAcceptsRow(int sourceRow) const
|
||||||
|
|
||||||
void GamesProxyModel::refresh()
|
void GamesProxyModel::refresh()
|
||||||
{
|
{
|
||||||
|
#if (QT_VERSION >= QT_VERSION_CHECK(6, 9, 0))
|
||||||
|
beginFilterChange();
|
||||||
|
#endif
|
||||||
#if (QT_VERSION >= QT_VERSION_CHECK(6, 10, 0))
|
#if (QT_VERSION >= QT_VERSION_CHECK(6, 10, 0))
|
||||||
endFilterChange(QSortFilterProxyModel::Direction::Rows);
|
endFilterChange(QSortFilterProxyModel::Direction::Rows);
|
||||||
#else
|
#else
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
#include <QGroupBox>
|
#include <QGroupBox>
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
|
#include <QPushButton>
|
||||||
#include <QSharedPointer>
|
#include <QSharedPointer>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <QStringList>
|
#include <QStringList>
|
||||||
|
|
@ -84,10 +85,9 @@ AppearanceSettingsPage::AppearanceSettingsPage()
|
||||||
const QString currentStyle = cfg.styleName;
|
const QString currentStyle = cfg.styleName;
|
||||||
const int styleSeedIdx = currentStyle.isEmpty() ? 0 : styleCombo.findData(currentStyle);
|
const int styleSeedIdx = currentStyle.isEmpty() ? 0 : styleCombo.findData(currentStyle);
|
||||||
styleCombo.setCurrentIndex(styleSeedIdx >= 0 ? styleSeedIdx : 0);
|
styleCombo.setCurrentIndex(styleSeedIdx >= 0 ? styleSeedIdx : 0);
|
||||||
|
previousStyle = styleCombo.currentData().toString();
|
||||||
|
|
||||||
connect(&styleCombo, &QComboBox::currentIndexChanged, this,
|
connect(&styleCombo, &QComboBox::currentIndexChanged, this, &AppearanceSettingsPage::styleComboChanged);
|
||||||
[this] { themeManager->setStyleName(styleCombo.currentData().toString()); });
|
|
||||||
connect(&styleCombo, &QComboBox::currentIndexChanged, this, &AppearanceSettingsPage::updateStyleWarning);
|
|
||||||
|
|
||||||
connect(themeManager, &ThemeManager::themeChanged, this, [this, dirPath] {
|
connect(themeManager, &ThemeManager::themeChanged, this, [this, dirPath] {
|
||||||
const QString newDir = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName());
|
const QString newDir = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName());
|
||||||
|
|
@ -104,6 +104,7 @@ AppearanceSettingsPage::AppearanceSettingsPage()
|
||||||
const int styleIdx = currentStyle.isEmpty() ? 0 : styleCombo.findData(currentStyle);
|
const int styleIdx = currentStyle.isEmpty() ? 0 : styleCombo.findData(currentStyle);
|
||||||
styleCombo.setCurrentIndex(styleIdx >= 0 ? styleIdx : 0);
|
styleCombo.setCurrentIndex(styleIdx >= 0 ? styleIdx : 0);
|
||||||
styleCombo.blockSignals(false);
|
styleCombo.blockSignals(false);
|
||||||
|
previousStyle = styleCombo.currentData().toString();
|
||||||
|
|
||||||
updateStyleWarning();
|
updateStyleWarning();
|
||||||
});
|
});
|
||||||
|
|
@ -467,12 +468,88 @@ void AppearanceSettingsPage::updateHomeTabSettingsVisibility()
|
||||||
homeTabDisplayCardNameCheckBox.setVisible(visible);
|
homeTabDisplayCardNameCheckBox.setVisible(visible);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool isBrokenStyle(const QString &style)
|
||||||
|
{
|
||||||
|
return style.compare("windows11", Qt::CaseInsensitive) == 0 ||
|
||||||
|
style.compare("windowsvista", Qt::CaseInsensitive) == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int findFusionIndex(const QComboBox &combo)
|
||||||
|
{
|
||||||
|
int fusionIdx = combo.findData(QStringLiteral("Fusion"));
|
||||||
|
if (fusionIdx >= 0) {
|
||||||
|
return fusionIdx;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < combo.count(); ++i) {
|
||||||
|
if (combo.itemData(i).toString().compare(QStringLiteral("Fusion"), Qt::CaseInsensitive) == 0) {
|
||||||
|
return i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
void AppearanceSettingsPage::updateStyleWarning()
|
void AppearanceSettingsPage::updateStyleWarning()
|
||||||
{
|
{
|
||||||
const QString style = styleCombo.currentData().toString();
|
styleWarningLabel.setVisible(isBrokenStyle(styleCombo.currentData().toString()));
|
||||||
const bool broken =
|
}
|
||||||
style.compare("windows11", Qt::CaseInsensitive) == 0 || style.compare("windowsvista", Qt::CaseInsensitive) == 0;
|
|
||||||
styleWarningLabel.setVisible(broken);
|
void AppearanceSettingsPage::styleComboChanged(int index)
|
||||||
|
{
|
||||||
|
if (index < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QString newStyle = styleCombo.itemData(index).toString();
|
||||||
|
if (!isBrokenStyle(newStyle)) {
|
||||||
|
themeManager->setStyleName(newStyle);
|
||||||
|
previousStyle = newStyle;
|
||||||
|
updateStyleWarning();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QMessageBox msgBox(this);
|
||||||
|
msgBox.setWindowTitle(tr("Broken style"));
|
||||||
|
msgBox.setIcon(QMessageBox::Warning);
|
||||||
|
msgBox.setText(tr("The \"%1\" style is broken and can break the game view: dragging cards across zones can "
|
||||||
|
"shrink the board to a tiny grey window that can only be fixed by rejoining the game.")
|
||||||
|
.arg(newStyle));
|
||||||
|
msgBox.setInformativeText(
|
||||||
|
tr("It can also render controls such as combo boxes unreadable, especially in dark mode.\n\n"
|
||||||
|
"Fusion is the recommended style on every platform. Switch to Fusion instead?"));
|
||||||
|
|
||||||
|
int fusionIdx = findFusionIndex(styleCombo);
|
||||||
|
QPushButton *fusionButton = nullptr;
|
||||||
|
if (fusionIdx >= 0) {
|
||||||
|
fusionButton = msgBox.addButton(tr("Use Fusion instead"), QMessageBox::AcceptRole);
|
||||||
|
}
|
||||||
|
QPushButton *keepButton = msgBox.addButton(tr("Use %1 anyway").arg(newStyle), QMessageBox::DestructiveRole);
|
||||||
|
msgBox.addButton(QMessageBox::Cancel);
|
||||||
|
if (fusionButton != nullptr) {
|
||||||
|
msgBox.setDefaultButton(fusionButton);
|
||||||
|
} else {
|
||||||
|
msgBox.setDefaultButton(keepButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
msgBox.exec();
|
||||||
|
|
||||||
|
const QAbstractButton *clicked = msgBox.clickedButton();
|
||||||
|
if (fusionButton != nullptr && clicked == fusionButton) {
|
||||||
|
styleCombo.setCurrentIndex(fusionIdx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (clicked == keepButton) {
|
||||||
|
themeManager->setStyleName(newStyle);
|
||||||
|
previousStyle = newStyle;
|
||||||
|
updateStyleWarning();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel: revert to the previously applied style without re-prompting
|
||||||
|
styleCombo.blockSignals(true);
|
||||||
|
const int previousIdx = styleCombo.findData(previousStyle);
|
||||||
|
styleCombo.setCurrentIndex(previousIdx >= 0 ? previousIdx : 0);
|
||||||
|
styleCombo.blockSignals(false);
|
||||||
|
updateStyleWarning();
|
||||||
}
|
}
|
||||||
|
|
||||||
void AppearanceSettingsPage::showShortcutsChanged(QT_STATE_CHANGED_T value)
|
void AppearanceSettingsPage::showShortcutsChanged(QT_STATE_CHANGED_T value)
|
||||||
|
|
@ -538,9 +615,10 @@ void AppearanceSettingsPage::retranslateUi()
|
||||||
styleComboLabel.setText(tr("Active theme style:"));
|
styleComboLabel.setText(tr("Active theme style:"));
|
||||||
styleCombo.setToolTip(tr("Qt widget style saved to this theme (\"System\" lets the application decide)"));
|
styleCombo.setToolTip(tr("Qt widget style saved to this theme (\"System\" lets the application decide)"));
|
||||||
styleWarningLabel.setText(
|
styleWarningLabel.setText(
|
||||||
tr("Warning: the Windows 11 and Windows Vista styles are buggy and can render controls such as combo boxes "
|
tr("Warning: the Windows 11 and Windows Vista styles are broken and can break the game view (dragging "
|
||||||
"unreadable, especially in dark mode. Fusion is the recommended style on every platform: it is Qt's "
|
"cards can shrink the board to a tiny grey window that requires rejoining to fix), and can render "
|
||||||
"cross-platform style and the one Cockatrice is developed against."));
|
"controls such as combo boxes unreadable, especially in dark mode. Fusion is the recommended style on "
|
||||||
|
"every platform: it is Qt's cross-platform style and the one Cockatrice is developed against."));
|
||||||
updateStyleWarning();
|
updateStyleWarning();
|
||||||
editPaletteButton.setText(tr("Edit theme palette"));
|
editPaletteButton.setText(tr("Edit theme palette"));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ private slots:
|
||||||
void editPalette();
|
void editPalette();
|
||||||
void updateHomeTabSettingsVisibility();
|
void updateHomeTabSettingsVisibility();
|
||||||
void updateStyleWarning();
|
void updateStyleWarning();
|
||||||
|
void styleComboChanged(int index);
|
||||||
void showShortcutsChanged(QT_STATE_CHANGED_T enabled);
|
void showShortcutsChanged(QT_STATE_CHANGED_T enabled);
|
||||||
void overrideAllCardArtWithPersonalPreferenceToggled(QT_STATE_CHANGED_T enabled);
|
void overrideAllCardArtWithPersonalPreferenceToggled(QT_STATE_CHANGED_T enabled);
|
||||||
|
|
||||||
|
|
@ -40,6 +41,7 @@ private:
|
||||||
QComboBox styleCombo;
|
QComboBox styleCombo;
|
||||||
QLabel styleWarningLabel;
|
QLabel styleWarningLabel;
|
||||||
QPushButton editPaletteButton;
|
QPushButton editPaletteButton;
|
||||||
|
QString previousStyle;
|
||||||
|
|
||||||
QLabel homeTabBackgroundSourceLabel;
|
QLabel homeTabBackgroundSourceLabel;
|
||||||
QComboBox homeTabBackgroundSourceBox;
|
QComboBox homeTabBackgroundSourceBox;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
#include "../../../client/settings/cache_settings.h"
|
#include "../../../client/settings/cache_settings.h"
|
||||||
#include "../../pixel_map_generator.h"
|
#include "../../pixel_map_generator.h"
|
||||||
|
#include "../dialogs/path_safety_warning.h"
|
||||||
#include "update/card_spoiler/spoiler_background_updater.h"
|
#include "update/card_spoiler/spoiler_background_updater.h"
|
||||||
|
|
||||||
#include <QAbstractItemModel>
|
#include <QAbstractItemModel>
|
||||||
|
|
@ -363,6 +364,9 @@ void DeckEditorSettingsPage::spoilerPathButtonClicked()
|
||||||
if (lsPath.isEmpty()) {
|
if (lsPath.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!PathSafetyWarning::confirmOutsideProgramDir(this, lsPath + "/spoiler.xml")) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
mpSpoilerSavePathLineEdit->setText(lsPath + "/spoiler.xml");
|
mpSpoilerSavePathLineEdit->setText(lsPath + "/spoiler.xml");
|
||||||
SettingsCache::instance().paths().setSpoilerDatabasePath(lsPath + "/spoiler.xml");
|
SettingsCache::instance().paths().setSpoilerDatabasePath(lsPath + "/spoiler.xml");
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
#include "general_settings_page.h"
|
#include "general_settings_page.h"
|
||||||
|
|
||||||
#include "../../../client/settings/cache_settings.h"
|
#include "../../../client/settings/cache_settings.h"
|
||||||
|
#include "../dialogs/path_safety_warning.h"
|
||||||
#include "../interface/card_picture_loader/card_picture_loader.h"
|
#include "../interface/card_picture_loader/card_picture_loader.h"
|
||||||
#include "../main.h"
|
#include "../main.h"
|
||||||
#include "../server/user/user_info_connection.h"
|
#include "../server/user/user_info_connection.h"
|
||||||
|
|
@ -378,6 +379,9 @@ void GeneralSettingsPage::deckPathButtonClicked()
|
||||||
if (path.isEmpty()) {
|
if (path.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!PathSafetyWarning::confirmOutsideProgramDir(this, path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
deckPathEdit->setText(path);
|
deckPathEdit->setText(path);
|
||||||
SettingsCache::instance().paths().setDeckPath(path);
|
SettingsCache::instance().paths().setDeckPath(path);
|
||||||
|
|
@ -389,6 +393,9 @@ void GeneralSettingsPage::filtersPathButtonClicked()
|
||||||
if (path.isEmpty()) {
|
if (path.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!PathSafetyWarning::confirmOutsideProgramDir(this, path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
filtersPathEdit->setText(path);
|
filtersPathEdit->setText(path);
|
||||||
SettingsCache::instance().paths().setFiltersPath(path);
|
SettingsCache::instance().paths().setFiltersPath(path);
|
||||||
|
|
@ -400,6 +407,9 @@ void GeneralSettingsPage::replaysPathButtonClicked()
|
||||||
if (path.isEmpty()) {
|
if (path.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!PathSafetyWarning::confirmOutsideProgramDir(this, path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
replaysPathEdit->setText(path);
|
replaysPathEdit->setText(path);
|
||||||
SettingsCache::instance().paths().setReplaysPath(path);
|
SettingsCache::instance().paths().setReplaysPath(path);
|
||||||
|
|
@ -411,6 +421,9 @@ void GeneralSettingsPage::picsPathButtonClicked()
|
||||||
if (path.isEmpty()) {
|
if (path.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!PathSafetyWarning::confirmOutsideProgramDir(this, path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
picsPathEdit->setText(path);
|
picsPathEdit->setText(path);
|
||||||
SettingsCache::instance().paths().setPicsPath(path);
|
SettingsCache::instance().paths().setPicsPath(path);
|
||||||
|
|
@ -422,6 +435,9 @@ void GeneralSettingsPage::cardDatabasePathButtonClicked()
|
||||||
if (path.isEmpty()) {
|
if (path.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!PathSafetyWarning::confirmOutsideProgramDir(this, path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
cardDatabasePathEdit->setText(path);
|
cardDatabasePathEdit->setText(path);
|
||||||
SettingsCache::instance().paths().setCardDatabasePath(path);
|
SettingsCache::instance().paths().setCardDatabasePath(path);
|
||||||
|
|
@ -433,6 +449,9 @@ void GeneralSettingsPage::customCardDatabaseButtonClicked()
|
||||||
if (path.isEmpty()) {
|
if (path.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!PathSafetyWarning::confirmOutsideProgramDir(this, path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
customCardDatabasePathEdit->setText(path);
|
customCardDatabasePathEdit->setText(path);
|
||||||
SettingsCache::instance().paths().setCustomCardDatabasePath(path);
|
SettingsCache::instance().paths().setCustomCardDatabasePath(path);
|
||||||
|
|
@ -444,6 +463,9 @@ void GeneralSettingsPage::tokenDatabasePathButtonClicked()
|
||||||
if (path.isEmpty()) {
|
if (path.isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!PathSafetyWarning::confirmOutsideProgramDir(this, path)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
tokenDatabasePathEdit->setText(path);
|
tokenDatabasePathEdit->setText(path);
|
||||||
SettingsCache::instance().paths().setTokenDatabasePath(path);
|
SettingsCache::instance().paths().setTokenDatabasePath(path);
|
||||||
|
|
|
||||||
|
|
@ -1060,6 +1060,12 @@ void TabGame::setCardMenu(CardMenu *menu)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (aCardMenu->menu() && aCardMenu->menu()->isVisible()) {
|
||||||
|
aCardMenu->menu()->hide();
|
||||||
|
if (gameView && gameView->viewport()) {
|
||||||
|
gameView->viewport()->update();
|
||||||
|
}
|
||||||
|
}
|
||||||
if (menu) {
|
if (menu) {
|
||||||
aCardMenu->setMenu(menu);
|
aCardMenu->setMenu(menu);
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
#include "tab_report.h"
|
#include "tab_report.h"
|
||||||
|
|
||||||
|
#include "../server/user/user_context_menu.h"
|
||||||
#include "../utility/report_utils.h"
|
#include "../utility/report_utils.h"
|
||||||
#include "abstract_client.h"
|
#include "abstract_client.h"
|
||||||
#include "libcockatrice/protocol/pb/response.pb.h"
|
#include "libcockatrice/protocol/pb/response.pb.h"
|
||||||
|
|
@ -33,11 +34,13 @@
|
||||||
#include <QVariant>
|
#include <QVariant>
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
#include <QtGlobal>
|
#include <QtGlobal>
|
||||||
|
#include <libcockatrice/network/server/remote/user_level.h>
|
||||||
#include <libcockatrice/protocol/pb/command_replay_download_by_game_id.pb.h>
|
#include <libcockatrice/protocol/pb/command_replay_download_by_game_id.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/command_report_add_comment.pb.h>
|
#include <libcockatrice/protocol/pb/command_report_add_comment.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/command_report_assign.pb.h>
|
#include <libcockatrice/protocol/pb/command_report_assign.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/command_report_details.pb.h>
|
#include <libcockatrice/protocol/pb/command_report_details.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/command_report_list.pb.h>
|
#include <libcockatrice/protocol/pb/command_report_list.pb.h>
|
||||||
|
#include <libcockatrice/protocol/pb/command_report_reopen.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/command_report_resolve.pb.h>
|
#include <libcockatrice/protocol/pb/command_report_resolve.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/command_report_stats.pb.h>
|
#include <libcockatrice/protocol/pb/command_report_stats.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/command_report_user_info.pb.h>
|
#include <libcockatrice/protocol/pb/command_report_user_info.pb.h>
|
||||||
|
|
@ -120,6 +123,10 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
|
||||||
table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch);
|
table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch);
|
||||||
table->horizontalHeader()->setSectionResizeMode(COL_REPORTER, QHeaderView::ResizeToContents);
|
table->horizontalHeader()->setSectionResizeMode(COL_REPORTER, QHeaderView::ResizeToContents);
|
||||||
connect(table, &QTableWidget::itemSelectionChanged, this, &TabReport::onSelectionChanged);
|
connect(table, &QTableWidget::itemSelectionChanged, this, &TabReport::onSelectionChanged);
|
||||||
|
table->setContextMenuPolicy(Qt::CustomContextMenu);
|
||||||
|
connect(table, &QTableWidget::customContextMenuRequested, this, &TabReport::showTableContextMenu);
|
||||||
|
userContextMenu = new UserContextMenu(tabSupervisor, this);
|
||||||
|
connect(userContextMenu, &UserContextMenu::openMessageDialog, this, &TabReport::openMessageDialog);
|
||||||
|
|
||||||
descGroup = new QGroupBox;
|
descGroup = new QGroupBox;
|
||||||
descriptionEdit = new QTextEdit;
|
descriptionEdit = new QTextEdit;
|
||||||
|
|
@ -231,14 +238,14 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
|
||||||
|
|
||||||
assignButton = new QPushButton;
|
assignButton = new QPushButton;
|
||||||
resolveButton = new QPushButton;
|
resolveButton = new QPushButton;
|
||||||
resolveWithNoteButton = new QPushButton;
|
|
||||||
dismissButton = new QPushButton;
|
dismissButton = new QPushButton;
|
||||||
|
reopenButton = new QPushButton;
|
||||||
viewReplayButton = new QPushButton;
|
viewReplayButton = new QPushButton;
|
||||||
joinGameButton = new QPushButton;
|
joinGameButton = new QPushButton;
|
||||||
connect(assignButton, &QPushButton::clicked, this, &TabReport::assignReport);
|
connect(assignButton, &QPushButton::clicked, this, &TabReport::assignReport);
|
||||||
connect(resolveButton, &QPushButton::clicked, this, [this]() { resolveReport(false, false); });
|
connect(resolveButton, &QPushButton::clicked, this, [this]() { resolveReport(false); });
|
||||||
connect(resolveWithNoteButton, &QPushButton::clicked, this, [this]() { resolveReport(false, true); });
|
connect(dismissButton, &QPushButton::clicked, this, [this]() { resolveReport(true); });
|
||||||
connect(dismissButton, &QPushButton::clicked, this, [this]() { resolveReport(true, true); });
|
connect(reopenButton, &QPushButton::clicked, this, &TabReport::reopenReport);
|
||||||
connect(viewReplayButton, &QPushButton::clicked, this, &TabReport::viewReplay);
|
connect(viewReplayButton, &QPushButton::clicked, this, &TabReport::viewReplay);
|
||||||
connect(joinGameButton, &QPushButton::clicked, this, &TabReport::joinGame);
|
connect(joinGameButton, &QPushButton::clicked, this, &TabReport::joinGame);
|
||||||
|
|
||||||
|
|
@ -247,8 +254,8 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
|
||||||
auto *actionBar = new QHBoxLayout;
|
auto *actionBar = new QHBoxLayout;
|
||||||
actionBar->addWidget(assignButton);
|
actionBar->addWidget(assignButton);
|
||||||
actionBar->addWidget(resolveButton);
|
actionBar->addWidget(resolveButton);
|
||||||
actionBar->addWidget(resolveWithNoteButton);
|
|
||||||
actionBar->addWidget(dismissButton);
|
actionBar->addWidget(dismissButton);
|
||||||
|
actionBar->addWidget(reopenButton);
|
||||||
actionBar->addSpacing(20);
|
actionBar->addSpacing(20);
|
||||||
actionBar->addWidget(viewReplayButton);
|
actionBar->addWidget(viewReplayButton);
|
||||||
actionBar->addWidget(joinGameButton);
|
actionBar->addWidget(joinGameButton);
|
||||||
|
|
@ -272,6 +279,7 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T
|
||||||
|
|
||||||
void TabReport::retranslateUi()
|
void TabReport::retranslateUi()
|
||||||
{
|
{
|
||||||
|
userContextMenu->retranslateUi();
|
||||||
searchEdit->setPlaceholderText(tr("Search by username, category..."));
|
searchEdit->setPlaceholderText(tr("Search by username, category..."));
|
||||||
statusFilter->clear();
|
statusFilter->clear();
|
||||||
statusFilter->addItem(tr("All Statuses"), "");
|
statusFilter->addItem(tr("All Statuses"), "");
|
||||||
|
|
@ -312,9 +320,10 @@ void TabReport::retranslateUi()
|
||||||
statsGroup->setTitle(tr("Report Statistics"));
|
statsGroup->setTitle(tr("Report Statistics"));
|
||||||
|
|
||||||
assignButton->setText(tr("Assign to Me"));
|
assignButton->setText(tr("Assign to Me"));
|
||||||
resolveButton->setText(tr("Resolve"));
|
resolveButton->setText(tr("Resolve..."));
|
||||||
resolveWithNoteButton->setText(tr("Resolve with note..."));
|
|
||||||
dismissButton->setText(tr("Dismiss..."));
|
dismissButton->setText(tr("Dismiss..."));
|
||||||
|
reopenButton->setText(tr("Reopen..."));
|
||||||
|
reopenButton->setToolTip(tr("Available for resolved or dismissed reports."));
|
||||||
viewReplayButton->setText(tr("View Replay"));
|
viewReplayButton->setText(tr("View Replay"));
|
||||||
joinGameButton->setText(tr("Join Game"));
|
joinGameButton->setText(tr("Join Game"));
|
||||||
}
|
}
|
||||||
|
|
@ -346,6 +355,7 @@ void TabReport::reportListResponse(const Response &response)
|
||||||
refreshButton->setEnabled(true);
|
refreshButton->setEnabled(true);
|
||||||
|
|
||||||
if (response.response_code() != Response::RespOk) {
|
if (response.response_code() != Response::RespOk) {
|
||||||
|
pendingStatusMessage.clear();
|
||||||
statusLabel->setText(tr("Failed to load reports."));
|
statusLabel->setText(tr("Failed to load reports."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -359,6 +369,11 @@ void TabReport::reportListResponse(const Response &response)
|
||||||
applyFilters();
|
applyFilters();
|
||||||
updateStats();
|
updateStats();
|
||||||
|
|
||||||
|
if (!pendingStatusMessage.isEmpty()) {
|
||||||
|
statusLabel->setText(pendingStatusMessage);
|
||||||
|
pendingStatusMessage.clear();
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedReportIdBeforeRefresh >= 0) {
|
if (selectedReportIdBeforeRefresh >= 0) {
|
||||||
bool found = false;
|
bool found = false;
|
||||||
for (int row = 0; row < table->rowCount(); ++row) {
|
for (int row = 0; row < table->rowCount(); ++row) {
|
||||||
|
|
@ -494,6 +509,36 @@ void TabReport::onSelectionChanged()
|
||||||
updateActionStates();
|
updateActionStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TabReport::showTableContextMenu(const QPoint &pos)
|
||||||
|
{
|
||||||
|
QTableWidgetItem *clickedItem = table->itemAt(pos);
|
||||||
|
if (!clickedItem || clickedItem->row() < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const int row = clickedItem->row();
|
||||||
|
const QTableWidgetItem *reportedItem = table->item(row, COL_REPORTED);
|
||||||
|
const QTableWidgetItem *idItem = table->item(row, COL_ID);
|
||||||
|
if (!reportedItem || !idItem || reportedItem->text().isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int userLevel = ServerInfo_User::IsUser;
|
||||||
|
const int reportId = idItem->data(Qt::UserRole).toInt();
|
||||||
|
for (const ServerInfo_Report &r : filteredReports) {
|
||||||
|
if (r.report_id() == reportId) {
|
||||||
|
if (r.reported_user_level() != 0) {
|
||||||
|
userLevel = r.reported_user_level();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table->selectRow(row);
|
||||||
|
userContextMenu->showContextMenu(table->viewport()->mapToGlobal(pos), reportedItem->text(),
|
||||||
|
UserLevelFlags(userLevel));
|
||||||
|
}
|
||||||
|
|
||||||
void TabReport::loadReportDetails(int reportId)
|
void TabReport::loadReportDetails(int reportId)
|
||||||
{
|
{
|
||||||
detailsRequestedReportId = reportId;
|
detailsRequestedReportId = reportId;
|
||||||
|
|
@ -577,8 +622,8 @@ void TabReport::updateActionStates()
|
||||||
const QString status = QString::fromStdString(report.status());
|
const QString status = QString::fromStdString(report.status());
|
||||||
assignButton->setEnabled(status == "open");
|
assignButton->setEnabled(status == "open");
|
||||||
resolveButton->setEnabled(status == "open" || status == "assigned");
|
resolveButton->setEnabled(status == "open" || status == "assigned");
|
||||||
resolveWithNoteButton->setEnabled(status == "open" || status == "assigned");
|
|
||||||
dismissButton->setEnabled(status == "open" || status == "assigned");
|
dismissButton->setEnabled(status == "open" || status == "assigned");
|
||||||
|
reopenButton->setEnabled(status == "resolved" || status == "dismissed");
|
||||||
|
|
||||||
const bool hasGameId = report.game_id() > 0;
|
const bool hasGameId = report.game_id() > 0;
|
||||||
const bool hasReplay = hasGameId && report.has_replay_id() && report.replay_id() > 0;
|
const bool hasReplay = hasGameId && report.has_replay_id() && report.replay_id() > 0;
|
||||||
|
|
@ -590,8 +635,8 @@ void TabReport::setActionsEnabled(bool enabled)
|
||||||
{
|
{
|
||||||
assignButton->setEnabled(enabled);
|
assignButton->setEnabled(enabled);
|
||||||
resolveButton->setEnabled(enabled);
|
resolveButton->setEnabled(enabled);
|
||||||
resolveWithNoteButton->setEnabled(enabled);
|
|
||||||
dismissButton->setEnabled(enabled);
|
dismissButton->setEnabled(enabled);
|
||||||
|
reopenButton->setEnabled(enabled);
|
||||||
viewReplayButton->setEnabled(false);
|
viewReplayButton->setEnabled(false);
|
||||||
joinGameButton->setEnabled(false);
|
joinGameButton->setEnabled(false);
|
||||||
commentButton->setEnabled(enabled);
|
commentButton->setEnabled(enabled);
|
||||||
|
|
@ -645,7 +690,7 @@ void TabReport::assignReport()
|
||||||
void TabReport::assignResponse(const Response &response)
|
void TabReport::assignResponse(const Response &response)
|
||||||
{
|
{
|
||||||
if (response.response_code() == Response::RespOk) {
|
if (response.response_code() == Response::RespOk) {
|
||||||
statusLabel->setText(tr("Assigned."));
|
pendingStatusMessage = tr("Assigned.");
|
||||||
refreshList();
|
refreshList();
|
||||||
} else {
|
} else {
|
||||||
statusLabel->setText(tr("Assignment failed."));
|
statusLabel->setText(tr("Assignment failed."));
|
||||||
|
|
@ -653,22 +698,19 @@ void TabReport::assignResponse(const Response &response)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void TabReport::resolveReport(bool dismissed, bool promptNote)
|
void TabReport::resolveReport(bool dismissed)
|
||||||
{
|
{
|
||||||
const int reportId = selectedReportId();
|
const int reportId = selectedReportId();
|
||||||
if (reportId < 0) {
|
if (reportId < 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
QString note;
|
bool ok;
|
||||||
if (promptNote) {
|
QString note = QInputDialog::getText(this, dismissed ? tr("Dismiss Report") : tr("Resolve Report"),
|
||||||
bool ok;
|
dismissed ? tr("Optional note:") : tr("Resolution note (optional):"),
|
||||||
note = QInputDialog::getText(this, dismissed ? tr("Dismiss Report") : tr("Resolve Report"),
|
QLineEdit::Normal, QString(), &ok);
|
||||||
dismissed ? tr("Optional note:") : tr("Resolution note (optional):"),
|
if (!ok) {
|
||||||
QLineEdit::Normal, QString(), &ok);
|
return;
|
||||||
if (!ok) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setActionsEnabled(false);
|
setActionsEnabled(false);
|
||||||
|
|
@ -689,7 +731,46 @@ void TabReport::resolveReport(bool dismissed, bool promptNote)
|
||||||
void TabReport::resolveResponse(const Response &response)
|
void TabReport::resolveResponse(const Response &response)
|
||||||
{
|
{
|
||||||
if (response.response_code() == Response::RespOk) {
|
if (response.response_code() == Response::RespOk) {
|
||||||
statusLabel->setText(tr("Done."));
|
pendingStatusMessage = tr("Done.");
|
||||||
|
refreshList();
|
||||||
|
} else {
|
||||||
|
statusLabel->setText(tr("Action failed."));
|
||||||
|
updateActionStates();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void TabReport::reopenReport()
|
||||||
|
{
|
||||||
|
const int reportId = selectedReportId();
|
||||||
|
if (reportId < 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ok;
|
||||||
|
QString note = QInputDialog::getText(this, tr("Reopen Report"), tr("Reopening note (optional):"), QLineEdit::Normal,
|
||||||
|
QString(), &ok);
|
||||||
|
if (!ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActionsEnabled(false);
|
||||||
|
statusLabel->setText(tr("Reopening..."));
|
||||||
|
|
||||||
|
Command_ReportReopen cmd;
|
||||||
|
cmd.set_report_id(reportId);
|
||||||
|
if (!note.isEmpty()) {
|
||||||
|
cmd.set_reopen_note(note.toStdString());
|
||||||
|
}
|
||||||
|
|
||||||
|
PendingCommand *pend = client->prepareModeratorCommand(cmd);
|
||||||
|
connect(pend, &PendingCommand::finished, this, &TabReport::reopenResponse);
|
||||||
|
client->sendCommand(pend);
|
||||||
|
}
|
||||||
|
|
||||||
|
void TabReport::reopenResponse(const Response &response)
|
||||||
|
{
|
||||||
|
if (response.response_code() == Response::RespOk) {
|
||||||
|
pendingStatusMessage = tr("Reopened.");
|
||||||
refreshList();
|
refreshList();
|
||||||
} else {
|
} else {
|
||||||
statusLabel->setText(tr("Action failed."));
|
statusLabel->setText(tr("Action failed."));
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
#include "tab.h"
|
#include "tab.h"
|
||||||
|
|
||||||
#include <QList>
|
#include <QList>
|
||||||
|
#include <QPoint>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
#include <libcockatrice/protocol/pb/serverinfo_report.pb.h>
|
#include <libcockatrice/protocol/pb/serverinfo_report.pb.h>
|
||||||
#include <qtmetamacros.h>
|
#include <qtmetamacros.h>
|
||||||
|
|
@ -22,6 +23,7 @@ class QTimer;
|
||||||
class GameReplay;
|
class GameReplay;
|
||||||
class Response;
|
class Response;
|
||||||
class TabSupervisor;
|
class TabSupervisor;
|
||||||
|
class UserContextMenu;
|
||||||
|
|
||||||
class TabReport : public Tab
|
class TabReport : public Tab
|
||||||
{
|
{
|
||||||
|
|
@ -37,15 +39,17 @@ public:
|
||||||
signals:
|
signals:
|
||||||
void openReplay(GameReplay *replay);
|
void openReplay(GameReplay *replay);
|
||||||
void requestJoinGame(int gameId, int roomId);
|
void requestJoinGame(int gameId, int roomId);
|
||||||
|
void openMessageDialog(const QString &userName, bool focus);
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
void refreshList();
|
void refreshList();
|
||||||
void reportListResponse(const Response &response);
|
void reportListResponse(const Response &response);
|
||||||
void assignReport();
|
void assignReport();
|
||||||
void assignResponse(const Response &response);
|
void assignResponse(const Response &response);
|
||||||
void resolveReport(bool dismissed, bool promptNote);
|
void resolveReport(bool dismissed);
|
||||||
void resolveResponse(const Response &response);
|
void resolveResponse(const Response &response);
|
||||||
void onSelectionChanged();
|
void onSelectionChanged();
|
||||||
|
void showTableContextMenu(const QPoint &pos);
|
||||||
void viewReplay();
|
void viewReplay();
|
||||||
void viewReplayResponse(const Response &response);
|
void viewReplayResponse(const Response &response);
|
||||||
void joinGame();
|
void joinGame();
|
||||||
|
|
@ -56,6 +60,8 @@ private slots:
|
||||||
void reportDetailsResponse(const Response &response);
|
void reportDetailsResponse(const Response &response);
|
||||||
void requestStats();
|
void requestStats();
|
||||||
void statsResponse(const Response &response);
|
void statsResponse(const Response &response);
|
||||||
|
void reopenReport();
|
||||||
|
void reopenResponse(const Response &response);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
int selectedReportId() const;
|
int selectedReportId() const;
|
||||||
|
|
@ -85,12 +91,13 @@ private:
|
||||||
QPushButton *commentButton;
|
QPushButton *commentButton;
|
||||||
QPushButton *assignButton;
|
QPushButton *assignButton;
|
||||||
QPushButton *resolveButton;
|
QPushButton *resolveButton;
|
||||||
QPushButton *resolveWithNoteButton;
|
|
||||||
QPushButton *dismissButton;
|
QPushButton *dismissButton;
|
||||||
|
QPushButton *reopenButton;
|
||||||
QPushButton *viewReplayButton;
|
QPushButton *viewReplayButton;
|
||||||
QPushButton *joinGameButton;
|
QPushButton *joinGameButton;
|
||||||
QLabel *statusLabel;
|
QLabel *statusLabel;
|
||||||
QTimer *refreshTimer;
|
QTimer *refreshTimer;
|
||||||
|
UserContextMenu *userContextMenu;
|
||||||
|
|
||||||
QGroupBox *userContextGroup;
|
QGroupBox *userContextGroup;
|
||||||
QLabel *userContextName;
|
QLabel *userContextName;
|
||||||
|
|
@ -108,6 +115,7 @@ private:
|
||||||
QLabel *userContextNotesLabel;
|
QLabel *userContextNotesLabel;
|
||||||
QLabel *userContextRecentReportsLabel;
|
QLabel *userContextRecentReportsLabel;
|
||||||
QString lastRequestedUser;
|
QString lastRequestedUser;
|
||||||
|
QString pendingStatusMessage;
|
||||||
QGroupBox *statsGroup;
|
QGroupBox *statsGroup;
|
||||||
QLabel *statsTotalLabel;
|
QLabel *statsTotalLabel;
|
||||||
QLabel *statsTrendLabel;
|
QLabel *statsTrendLabel;
|
||||||
|
|
|
||||||
|
|
@ -919,6 +919,7 @@ void TabSupervisor::openTabReport()
|
||||||
myAddTab(tabReport, aTabReport);
|
myAddTab(tabReport, aTabReport);
|
||||||
connect(tabReport, &TabReport::openReplay, this, &TabSupervisor::openReplay);
|
connect(tabReport, &TabReport::openReplay, this, &TabSupervisor::openReplay);
|
||||||
connect(tabReport, &TabReport::requestJoinGame, this, &TabSupervisor::joinReportGame);
|
connect(tabReport, &TabReport::requestJoinGame, this, &TabSupervisor::joinReportGame);
|
||||||
|
connect(tabReport, &TabReport::openMessageDialog, this, &TabSupervisor::addMessageTab);
|
||||||
connect(tabReport, &QObject::destroyed, this, [this] {
|
connect(tabReport, &QObject::destroyed, this, [this] {
|
||||||
tabReport = nullptr;
|
tabReport = nullptr;
|
||||||
aTabReport->setChecked(false);
|
aTabReport->setChecked(false);
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,22 @@ template <class T> class QSharedPointer;
|
||||||
namespace
|
namespace
|
||||||
{
|
{
|
||||||
|
|
||||||
|
// Below this row width the delegate switches to a compact layout so the card
|
||||||
|
// name stays readable when the popup cannot be as wide as PopupRowWidth.
|
||||||
|
constexpr int CompactRowWidth = 380;
|
||||||
|
constexpr int CompactSymbolRadius = 6;
|
||||||
|
constexpr int CompactSymbolSpacing = 1;
|
||||||
|
constexpr int CompactRightPad = 8;
|
||||||
|
constexpr int ManaZoneWidth = 110;
|
||||||
|
constexpr int CompactManaZoneWidth = 70;
|
||||||
|
constexpr int RightPad = 14;
|
||||||
|
constexpr int TextLeftPad = 12;
|
||||||
|
constexpr int TextRightGap = 8;
|
||||||
|
|
||||||
|
// Below this text width the type-line band is dropped so the name can use the
|
||||||
|
// whole row height instead of being elided to a single readable character.
|
||||||
|
constexpr int MinNameWidth = 120;
|
||||||
|
|
||||||
struct ManaColor
|
struct ManaColor
|
||||||
{
|
{
|
||||||
QColor fill;
|
QColor fill;
|
||||||
|
|
@ -125,7 +141,7 @@ QSize CardCompleterDelegate::sizeHint(const QStyleOptionViewItem &option, const
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fixed wide rows so the popup has room for name, type line, set and mana
|
// Fixed wide rows so the popup has room for name, type line, set and mana
|
||||||
return {480, CardRowHeight};
|
return {PopupRowWidth, CardRowHeight};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
@ -176,7 +192,12 @@ void CardCompleterDelegate::drawManaSymbol(QPainter *p, QPoint centre, const QSt
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
int CardCompleterDelegate::drawManaCost(QPainter *p, const QRect &row, const QString &manaCost, int radius) const
|
int CardCompleterDelegate::drawManaCost(QPainter *p,
|
||||||
|
const QRect &row,
|
||||||
|
const QString &manaCost,
|
||||||
|
int radius,
|
||||||
|
int spacing,
|
||||||
|
int rightPad) const
|
||||||
{
|
{
|
||||||
if (manaCost.isEmpty()) {
|
if (manaCost.isEmpty()) {
|
||||||
return row.right();
|
return row.right();
|
||||||
|
|
@ -205,11 +226,9 @@ int CardCompleterDelegate::drawManaCost(QPainter *p, const QRect &row, const QSt
|
||||||
totalW += PartGap;
|
totalW += PartGap;
|
||||||
}
|
}
|
||||||
|
|
||||||
totalW += parts.at(i).size() * diam + qMax(0, parts.at(i).size() - 1) * SymbolSpacing;
|
totalW += parts.at(i).size() * diam + qMax(0, parts.at(i).size() - 1) * spacing;
|
||||||
}
|
}
|
||||||
|
|
||||||
const int rightPad = 14;
|
|
||||||
|
|
||||||
int x = row.right() - rightPad - totalW + radius;
|
int x = row.right() - rightPad - totalW + radius;
|
||||||
|
|
||||||
const int cy = row.center().y();
|
const int cy = row.center().y();
|
||||||
|
|
@ -219,11 +238,11 @@ int CardCompleterDelegate::drawManaCost(QPainter *p, const QRect &row, const QSt
|
||||||
|
|
||||||
for (const QString &sym : symbols) {
|
for (const QString &sym : symbols) {
|
||||||
drawManaSymbol(p, {x, cy}, sym, radius);
|
drawManaSymbol(p, {x, cy}, sym, radius);
|
||||||
x += diam + SymbolSpacing;
|
x += diam + spacing;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (i < parts.size() - 1) {
|
if (i < parts.size() - 1) {
|
||||||
x += PartGap - SymbolSpacing;
|
x += PartGap - spacing;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -290,6 +309,14 @@ void CardCompleterDelegate::paint(QPainter *painter, const QStyleOptionViewItem
|
||||||
const QString typeLine = card->getCardType();
|
const QString typeLine = card->getCardType();
|
||||||
const QString setCode = setCodeForCard(card);
|
const QString setCode = setCodeForCard(card);
|
||||||
|
|
||||||
|
// When the row is too narrow for the full layout, use smaller mana pips and
|
||||||
|
// a slimmer mana zone so the card name keeps as much room as possible.
|
||||||
|
const bool compact = rect.width() < CompactRowWidth;
|
||||||
|
const int symbolRadius = compact ? CompactSymbolRadius : SymbolRadius;
|
||||||
|
const int symbolSpacing = compact ? CompactSymbolSpacing : SymbolSpacing;
|
||||||
|
const int rightPad = compact ? CompactRightPad : RightPad;
|
||||||
|
const int manaZoneWidth = compact ? CompactManaZoneWidth : ManaZoneWidth;
|
||||||
|
|
||||||
const QColor accent = accentForColors(card->getColors());
|
const QColor accent = accentForColors(card->getColors());
|
||||||
|
|
||||||
const QColor base = pal.color(QPalette::Base);
|
const QColor base = pal.color(QPalette::Base);
|
||||||
|
|
@ -338,7 +365,7 @@ void CardCompleterDelegate::paint(QPainter *painter, const QStyleOptionViewItem
|
||||||
// Right mana zone
|
// Right mana zone
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
const QRectF manaZone(cardRect.right() - 110, cardRect.top(), 110, cardRect.height());
|
const QRectF manaZone(cardRect.right() - manaZoneWidth, cardRect.top(), manaZoneWidth, cardRect.height());
|
||||||
|
|
||||||
QLinearGradient manaGrad(manaZone.topLeft(), manaZone.bottomLeft());
|
QLinearGradient manaGrad(manaZone.topLeft(), manaZone.bottomLeft());
|
||||||
|
|
||||||
|
|
@ -380,22 +407,27 @@ void CardCompleterDelegate::paint(QPainter *painter, const QStyleOptionViewItem
|
||||||
// Mana cost
|
// Mana cost
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
const int costLeft = drawManaCost(painter, cardRect.toRect(), manaCost, SymbolRadius);
|
const int costLeft = drawManaCost(painter, cardRect.toRect(), manaCost, symbolRadius, symbolSpacing, rightPad);
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Card name + type line + set code
|
// Card name + type line + set code
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
const int textLeft = cardRect.left() + AccentBarWidth + 12;
|
const int textLeft = cardRect.left() + AccentBarWidth + TextLeftPad;
|
||||||
const int textRight = costLeft - 8;
|
const int textRight = costLeft - TextRightGap;
|
||||||
const int textWidth = qMax(0, textRight - textLeft);
|
const int textWidth = qMax(0, textRight - textLeft);
|
||||||
|
|
||||||
|
// If even the compact layout leaves too little room for a readable name,
|
||||||
|
// drop the type-line band and let the name use the full row height.
|
||||||
|
const bool showInfoBand = textWidth >= MinNameWidth;
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Card name (top band)
|
// Card name (top band)
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
{
|
{
|
||||||
const QRect nameRect(textLeft, rect.top() + 2, textWidth, 20);
|
const QRect nameRect = showInfoBand ? QRect(textLeft, rect.top() + 2, textWidth, 20)
|
||||||
|
: QRect(textLeft, rect.top() + 2, textWidth, rect.height() - 4);
|
||||||
|
|
||||||
QFont f = option.font;
|
QFont f = option.font;
|
||||||
f.setPixelSize(13);
|
f.setPixelSize(13);
|
||||||
|
|
@ -416,7 +448,7 @@ void CardCompleterDelegate::paint(QPainter *painter, const QStyleOptionViewItem
|
||||||
// Type line + set code (bottom band)
|
// Type line + set code (bottom band)
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
{
|
if (showInfoBand) {
|
||||||
const QRect infoRect(textLeft, rect.top() + 22, textWidth, rect.height() - 24);
|
const QRect infoRect(textLeft, rect.top() + 22, textWidth, rect.height() - 24);
|
||||||
|
|
||||||
QFont f = option.font;
|
QFont f = option.font;
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,9 @@ public:
|
||||||
void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
|
void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
|
||||||
QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override;
|
QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override;
|
||||||
|
|
||||||
|
// Content width of one popup row; the completer popup is sized to fit it.
|
||||||
|
static constexpr int PopupRowWidth = 480;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
// Set short codes, resolved once per card name and cached
|
// Set short codes, resolved once per card name and cached
|
||||||
mutable QCache<QString, QString> setCodeCache;
|
mutable QCache<QString, QString> setCodeCache;
|
||||||
|
|
@ -48,7 +51,8 @@ private:
|
||||||
|
|
||||||
// Draw all mana pips for a cost string like "2RG" or "{2}{R}{G}"; split and
|
// Draw all mana pips for a cost string like "2RG" or "{2}{R}{G}"; split and
|
||||||
// adventure costs ("1W // W") are drawn as separate groups. Returns the left-most x used
|
// adventure costs ("1W // W") are drawn as separate groups. Returns the left-most x used
|
||||||
int drawManaCost(QPainter *p, const QRect &row, const QString &manaCost, int radius) const;
|
int
|
||||||
|
drawManaCost(QPainter *p, const QRect &row, const QString &manaCost, int radius, int spacing, int rightPad) const;
|
||||||
|
|
||||||
// Resolve the preferred printing's set short code for a card
|
// Resolve the preferred printing's set short code for a card
|
||||||
QString setCodeForCard(const QSharedPointer<CardInfo> &card) const;
|
QString setCodeForCard(const QSharedPointer<CardInfo> &card) const;
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
#include <QPropertyAnimation>
|
#include <QPropertyAnimation>
|
||||||
#include <QRect>
|
#include <QRect>
|
||||||
#include <QScreen>
|
#include <QScreen>
|
||||||
|
#include <QScrollBar>
|
||||||
#include <QSharedPointer>
|
#include <QSharedPointer>
|
||||||
#include <QSize>
|
#include <QSize>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
|
@ -103,6 +104,7 @@ bool CardCompleterStyler::eventFilter(QObject *obj, QEvent *ev)
|
||||||
case QEvent::Show:
|
case QEvent::Show:
|
||||||
case QEvent::Move:
|
case QEvent::Move:
|
||||||
case QEvent::Resize:
|
case QEvent::Resize:
|
||||||
|
applyPopupWidth();
|
||||||
updateOrientation();
|
updateOrientation();
|
||||||
reposition();
|
reposition();
|
||||||
break;
|
break;
|
||||||
|
|
@ -159,6 +161,42 @@ bool CardCompleterStyler::handlePopupKeyPress(QKeyEvent *event)
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
void CardCompleterStyler::applyPopupWidth()
|
||||||
|
{
|
||||||
|
auto *popup = completer->popup();
|
||||||
|
|
||||||
|
// QCompleter sizes its popup to the line edit, so a narrow search field
|
||||||
|
// would crush the rows. Ask for the width the rows are designed for (plus
|
||||||
|
// the frame and scrollbar) and let QWidget's geometry clamping keep the
|
||||||
|
// popup that wide, clamped to the screen it is shown on.
|
||||||
|
int contentWidth = CardCompleterDelegate::PopupRowWidth + popup->frameWidth() * 2;
|
||||||
|
if (auto *vbar = popup->verticalScrollBar(); vbar != nullptr) {
|
||||||
|
contentWidth += vbar->sizeHint().width();
|
||||||
|
}
|
||||||
|
|
||||||
|
const QScreen *screen = popup->screen();
|
||||||
|
const QRect available = screen ? screen->availableGeometry() : QRect();
|
||||||
|
|
||||||
|
const int minWidth = available.isEmpty() ? contentWidth : qMin(contentWidth, available.width());
|
||||||
|
popup->setMinimumWidth(minWidth);
|
||||||
|
|
||||||
|
// setMinimumWidth() already widened the popup if it was narrower, but
|
||||||
|
// QCompleter positioned it for the line-edit width, so the widened popup
|
||||||
|
// can now extend past the right screen edge. The popup is a top-level
|
||||||
|
// Qt::Popup window, so geometry()/move() are already in screen
|
||||||
|
// coordinates: clamp the x from geometry() and move directly.
|
||||||
|
if (!available.isEmpty()) {
|
||||||
|
const QRect geom = popup->geometry();
|
||||||
|
const int clampedX =
|
||||||
|
qBound(available.left(), geom.x(), qMax(available.left(), available.right() - geom.width() + 1));
|
||||||
|
if (clampedX != geom.x()) {
|
||||||
|
popup->move(QPoint(clampedX, popup->y()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
void CardCompleterStyler::updateOrientation()
|
void CardCompleterStyler::updateOrientation()
|
||||||
{
|
{
|
||||||
above = isPopupAboveWidget();
|
above = isPopupAboveWidget();
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ private:
|
||||||
void stopPreviewFade();
|
void stopPreviewFade();
|
||||||
void reposition();
|
void reposition();
|
||||||
|
|
||||||
|
void applyPopupWidth();
|
||||||
void updateOrientation();
|
void updateOrientation();
|
||||||
void ensureClosestSelected();
|
void ensureClosestSelected();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -130,10 +130,19 @@ void LineEditCompleter::keyPressEvent(QKeyEvent *event)
|
||||||
CompleterInfo *active = nullptr;
|
CompleterInfo *active = nullptr;
|
||||||
QString prefix;
|
QString prefix;
|
||||||
|
|
||||||
|
// An open "[[" card link takes precedence over a mention: while the cursor is
|
||||||
|
// inside it, an earlier "@" must not shadow the card completer.
|
||||||
|
const int openCardPos = textValue.lastIndexOf("[[", cursorPos - 1);
|
||||||
|
const int closeCardPos = openCardPos == -1 ? -1 : textValue.indexOf("]]", openCardPos + 2);
|
||||||
|
const bool insideCardLink = openCardPos != -1 && (closeCardPos == -1 || closeCardPos >= cursorPos);
|
||||||
|
|
||||||
for (auto &info : completers) {
|
for (auto &info : completers) {
|
||||||
bool triggered = false;
|
bool triggered = false;
|
||||||
switch (info.trigger) {
|
switch (info.trigger) {
|
||||||
case CompleterTrigger::Mention: {
|
case CompleterTrigger::Mention: {
|
||||||
|
if (insideCardLink) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
int triggerPos = textValue.lastIndexOf("@", cursorPos - 1);
|
int triggerPos = textValue.lastIndexOf("@", cursorPos - 1);
|
||||||
if (triggerPos != -1 && (triggerPos == 0 || textValue[triggerPos - 1].isSpace())) {
|
if (triggerPos != -1 && (triggerPos == 0 || textValue[triggerPos - 1].isSpace())) {
|
||||||
triggered = true;
|
triggered = true;
|
||||||
|
|
|
||||||
|
|
@ -100,10 +100,27 @@ void renderReportDetails(QTextEdit *chatLogEdit,
|
||||||
const QString &moderatorPrefix,
|
const QString &moderatorPrefix,
|
||||||
const QString &nonModeratorPrefix)
|
const QString &nonModeratorPrefix)
|
||||||
{
|
{
|
||||||
|
QString details;
|
||||||
|
if (report.has_resolution_note() && !report.resolution_note().empty()) {
|
||||||
|
details = QObject::tr("Resolution note: %1").arg(QString::fromStdString(report.resolution_note()));
|
||||||
|
}
|
||||||
|
if (report.has_resolution_time()) {
|
||||||
|
QString resolvedBy = QObject::tr("unknown");
|
||||||
|
if (report.has_resolved_by_name() && !report.resolved_by_name().empty()) {
|
||||||
|
resolvedBy = QString::fromStdString(report.resolved_by_name());
|
||||||
|
}
|
||||||
|
if (!details.isEmpty()) {
|
||||||
|
details += "\n";
|
||||||
|
}
|
||||||
|
details += QObject::tr("Resolved at %1 by %2").arg(formatReportTime(report.resolution_time()), resolvedBy);
|
||||||
|
}
|
||||||
|
|
||||||
if (report.has_chat_log() && !report.chat_log().empty()) {
|
if (report.has_chat_log() && !report.chat_log().empty()) {
|
||||||
chatLogEdit->setPlainText(QString::fromStdString(report.chat_log()));
|
const QString chatLog = QString::fromStdString(report.chat_log());
|
||||||
|
chatLogEdit->setPlainText(
|
||||||
|
details.isEmpty() ? chatLog : details + "\n\n" + QObject::tr("=== Chat Log ===") + "\n" + chatLog);
|
||||||
} else {
|
} else {
|
||||||
chatLogEdit->clear();
|
chatLogEdit->setPlainText(details);
|
||||||
}
|
}
|
||||||
|
|
||||||
commentsEdit->clear();
|
commentsEdit->clear();
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,9 @@
|
||||||
#include <QAction>
|
#include <QAction>
|
||||||
#include <QApplication>
|
#include <QApplication>
|
||||||
#include <QButtonGroup>
|
#include <QButtonGroup>
|
||||||
|
#include <QCheckBox>
|
||||||
#include <QCloseEvent>
|
#include <QCloseEvent>
|
||||||
|
#include <QCoreApplication>
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QDesktopServices>
|
#include <QDesktopServices>
|
||||||
|
|
@ -68,6 +70,7 @@
|
||||||
#include <QFileDialog>
|
#include <QFileDialog>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
#include <QFlags>
|
#include <QFlags>
|
||||||
|
#include <QFutureWatcher>
|
||||||
#include <QIODevice>
|
#include <QIODevice>
|
||||||
#include <QLocalSocket>
|
#include <QLocalSocket>
|
||||||
#include <QMenu>
|
#include <QMenu>
|
||||||
|
|
@ -603,14 +606,45 @@ MainWindow::MainWindow(QWidget *parent)
|
||||||
QTimer::singleShot(0, this, &MainWindow::applyStartupDestination);
|
QTimer::singleShot(0, this, &MainWindow::applyStartupDestination);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void MainWindow::warnAboutDataPathsInProgramDir()
|
||||||
|
{
|
||||||
|
auto &paths = SettingsCache::instance().paths();
|
||||||
|
if (SettingsCache::instance().getIsPortableBuild() || paths.getAppDirWarningAcknowledged()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const QStringList offenders = paths.pathsInsideDir(QCoreApplication::applicationDirPath());
|
||||||
|
if (offenders.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
QMessageBox box(QMessageBox::Warning, tr("Data paths inside the program directory"),
|
||||||
|
tr("These paths are configured inside the Cockatrice program directory:\n\n%1\n\n"
|
||||||
|
"Cockatrice keeps its own data outside the program directory by default. Files "
|
||||||
|
"stored inside it can be deleted by an update or by uninstalling Cockatrice. "
|
||||||
|
"Change them in the Settings dialog - most are on the General page - to keep "
|
||||||
|
"them safe.")
|
||||||
|
.arg(offenders.join(QLatin1Char('\n'))),
|
||||||
|
QMessageBox::Ok, this);
|
||||||
|
box.setTextFormat(Qt::PlainText);
|
||||||
|
auto *dontShowAgain = new QCheckBox(tr("Don't show this again"));
|
||||||
|
box.setCheckBox(dontShowAgain);
|
||||||
|
box.exec();
|
||||||
|
if (dontShowAgain->isChecked()) {
|
||||||
|
paths.setAppDirWarningAcknowledged(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void MainWindow::startupConfigCheck()
|
void MainWindow::startupConfigCheck()
|
||||||
{
|
{
|
||||||
|
warnAboutDataPathsInProgramDir();
|
||||||
|
|
||||||
const bool isCleanInstall = SettingsCache::instance().network().getClientVersion() == CLIENT_INFO_NOT_SET;
|
const bool isCleanInstall = SettingsCache::instance().network().getClientVersion() == CLIENT_INFO_NOT_SET;
|
||||||
|
|
||||||
// checkUnknownSets() is intentionally deferred from the card database load
|
// The loader never runs checkUnknownSets() itself (see main.cpp): the
|
||||||
// (which runs in main() before MainWindow exists) so that
|
// front-loaded parse runs before MainWindow exists, so
|
||||||
// cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled have live
|
// cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled would have no
|
||||||
// receivers when emitted.
|
// receivers. MainWindow owns the check after startup.
|
||||||
// On a clean install the onboarding wizard owns the first-run experience;
|
// On a clean install the onboarding wizard owns the first-run experience;
|
||||||
// wait until it closes so the legacy "all sets enabled" welcome and the
|
// wait until it closes so the legacy "all sets enabled" welcome and the
|
||||||
// Manage Sets dialog don't appear first.
|
// Manage Sets dialog don't appear first.
|
||||||
|
|
@ -1104,18 +1138,35 @@ void MainWindow::cardDatabaseNewSetsFound(int numUnknownSets, QStringList unknow
|
||||||
|
|
||||||
void MainWindow::cardDatabaseAllNewSetsEnabled()
|
void MainWindow::cardDatabaseAllNewSetsEnabled()
|
||||||
{
|
{
|
||||||
if (firstRunWizardActive || CardDatabaseManager::getInstance()->getCardList().isEmpty()) {
|
auto *cardDatabase = CardDatabaseManager::getInstance();
|
||||||
// The onboarding wizard owns the first-run messaging on a clean install,
|
|
||||||
// and with no card data there are no sets to have enabled.
|
// With no card data there is nothing to have enabled and nothing to reparse.
|
||||||
|
if (cardDatabase->getCardList().isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
QMessageBox::information(
|
// checkUnknownSets() enabled every set after the database had already been
|
||||||
this, tr("Welcome"),
|
// parsed under the (empty) first-run enablement, so the parser dropped every
|
||||||
tr("Hi! It seems like you're running this version of Cockatrice for the first time.\nAll the sets in the card "
|
// printing of every set. Reparse now that the sets are enabled, otherwise the
|
||||||
"database have been enabled.\nRead more about changing the set order or disabling specific sets and "
|
// deck editor's card database display stays empty until the next launch.
|
||||||
"consequent effects in the \"Manage Sets\" dialog."));
|
auto *watcher = new QFutureWatcher<void>(this);
|
||||||
actManageSets();
|
connect(watcher, &QFutureWatcher<void>::finished, this, [this, watcher] {
|
||||||
|
watcher->deleteLater();
|
||||||
|
if (firstRunWizardActive) {
|
||||||
|
// The onboarding wizard owns the first-run messaging on a clean install.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
QMessageBox::information(
|
||||||
|
this, tr("Welcome"),
|
||||||
|
tr("Hi! It seems like you're running this version of Cockatrice for the first time.\nAll the sets in the "
|
||||||
|
"card "
|
||||||
|
"database have been enabled.\nRead more about changing the set order or disabling specific sets and "
|
||||||
|
"consequent effects in the \"Manage Sets\" dialog."));
|
||||||
|
// The reload above replaces the set instances, so Manage Sets must open
|
||||||
|
// only after the finished snapshot has been swapped in.
|
||||||
|
actManageSets();
|
||||||
|
});
|
||||||
|
watcher->setFuture(QtConcurrent::run([cardDatabase] { cardDatabase->reloadCardDatabasesAndNotify(); }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/* CARD UPDATER */
|
/* CARD UPDATER */
|
||||||
|
|
@ -1202,7 +1253,7 @@ void MainWindow::createCardUpdateProcess(bool background)
|
||||||
<< dir.absoluteFilePath(binaryName);
|
<< dir.absoluteFilePath(binaryName);
|
||||||
QMessageBox::warning(this, tr("Error"),
|
QMessageBox::warning(this, tr("Error"),
|
||||||
tr("Unable to run the card database updater: ") + dir.absoluteFilePath(binaryName));
|
tr("Unable to run the card database updater: ") + dir.absoluteFilePath(binaryName));
|
||||||
exitCardDatabaseUpdate();
|
exitCardDatabaseUpdate(false);
|
||||||
emit cardDatabaseUpdateFinished(false);
|
emit cardDatabaseUpdateFinished(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1279,7 +1330,7 @@ void MainWindow::cardUpdateStandardError()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::exitCardDatabaseUpdate()
|
void MainWindow::exitCardDatabaseUpdate(bool doReload)
|
||||||
{
|
{
|
||||||
if (!cardUpdateProcess) {
|
if (!cardUpdateProcess) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -1288,7 +1339,21 @@ void MainWindow::exitCardDatabaseUpdate()
|
||||||
cardUpdateProcess = nullptr;
|
cardUpdateProcess = nullptr;
|
||||||
statusBar()->clearMessage();
|
statusBar()->clearMessage();
|
||||||
|
|
||||||
const auto reloadOk1 = QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); });
|
if (doReload) {
|
||||||
|
// This can be the first successful load of the session (e.g. the onboarding
|
||||||
|
// download), so it must own the set check for that load.
|
||||||
|
reloadCardDatabaseAndCheckSets();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MainWindow::reloadCardDatabaseAndCheckSets()
|
||||||
|
{
|
||||||
|
auto *watcher = new QFutureWatcher<void>(this);
|
||||||
|
connect(watcher, &QFutureWatcher<void>::finished, this, [watcher] {
|
||||||
|
watcher->deleteLater();
|
||||||
|
CardDatabaseManager::getInstance()->checkUnknownSets();
|
||||||
|
});
|
||||||
|
watcher->setFuture(QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); }));
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::cardUpdateError(QProcess::ProcessError err)
|
void MainWindow::cardUpdateError(QProcess::ProcessError err)
|
||||||
|
|
@ -1324,7 +1389,7 @@ void MainWindow::cardUpdateError(QProcess::ProcessError err)
|
||||||
}
|
}
|
||||||
|
|
||||||
qCWarning(CardDatabaseUpdateLog) << "The card database updater exited with an error:" << error;
|
qCWarning(CardDatabaseUpdateLog) << "The card database updater exited with an error:" << error;
|
||||||
exitCardDatabaseUpdate();
|
exitCardDatabaseUpdate(false);
|
||||||
QMessageBox::warning(this, tr("Error"), tr("The card database updater exited with an error:\n%1").arg(error));
|
QMessageBox::warning(this, tr("Error"), tr("The card database updater exited with an error:\n%1").arg(error));
|
||||||
emit cardDatabaseUpdateFinished(false);
|
emit cardDatabaseUpdateFinished(false);
|
||||||
}
|
}
|
||||||
|
|
@ -1337,12 +1402,14 @@ void MainWindow::cardUpdateFinished(int exitCode, QProcess::ExitStatus exitStatu
|
||||||
cardUpdateProgressOutput(); // drain any progress lines not yet parsed
|
cardUpdateProgressOutput(); // drain any progress lines not yet parsed
|
||||||
|
|
||||||
const bool success = (exitStatus == QProcess::NormalExit) && (exitCode == 0);
|
const bool success = (exitStatus == QProcess::NormalExit) && (exitCode == 0);
|
||||||
if (exitStatus == QProcess::NormalExit) {
|
if (success) {
|
||||||
SettingsCache::instance().updates().setLastCardUpdateCheck(QDateTime::currentDateTime().date());
|
SettingsCache::instance().updates().setLastCardUpdateCheck(QDateTime::currentDateTime().date());
|
||||||
|
} else if (exitStatus == QProcess::NormalExit) {
|
||||||
|
statusBar()->showMessage(tr("Card database update cancelled."), 10000);
|
||||||
}
|
}
|
||||||
qCInfo(CardDatabaseUpdateLog) << "The card database updater finished with exit code" << exitCode << "exit status"
|
qCInfo(CardDatabaseUpdateLog) << "The card database updater finished with exit code" << exitCode << "exit status"
|
||||||
<< static_cast<int>(exitStatus) << "success:" << success;
|
<< static_cast<int>(exitStatus) << "success:" << success;
|
||||||
exitCardDatabaseUpdate();
|
exitCardDatabaseUpdate(success);
|
||||||
notifyCardUpdateFinishedFromTray(success);
|
notifyCardUpdateFinishedFromTray(success);
|
||||||
emit cardDatabaseUpdateFinished(success);
|
emit cardDatabaseUpdateFinished(success);
|
||||||
}
|
}
|
||||||
|
|
@ -1490,7 +1557,8 @@ void MainWindow::actAddCustomSet()
|
||||||
QMessageBox::information(
|
QMessageBox::information(
|
||||||
this, tr("Load sets/cards"),
|
this, tr("Load sets/cards"),
|
||||||
tr("The new sets/cards have been added successfully.\nCockatrice will now reload the card database."));
|
tr("The new sets/cards have been added successfully.\nCockatrice will now reload the card database."));
|
||||||
const auto reloadOk1 = QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); });
|
// Imported files can introduce sets the config has never seen.
|
||||||
|
reloadCardDatabaseAndCheckSets();
|
||||||
} else {
|
} else {
|
||||||
QMessageBox::warning(this, tr("Load sets/cards"), tr("Sets/cards failed to import."));
|
QMessageBox::warning(this, tr("Load sets/cards"), tr("Sets/cards failed to import."));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -149,12 +149,23 @@ private:
|
||||||
|
|
||||||
void runFirstRunWizard(bool firstRun = false);
|
void runFirstRunWizard(bool firstRun = false);
|
||||||
|
|
||||||
|
/** @brief Warns once when a non-portable install keeps its data paths inside the program directory. */
|
||||||
|
void warnAboutDataPathsInProgramDir();
|
||||||
|
|
||||||
inline QString getCardUpdaterBinaryName()
|
inline QString getCardUpdaterBinaryName()
|
||||||
{
|
{
|
||||||
return "oracle";
|
return "oracle";
|
||||||
}
|
}
|
||||||
void createCardUpdateProcess(bool background = false);
|
void createCardUpdateProcess(bool background = false);
|
||||||
void exitCardDatabaseUpdate();
|
void exitCardDatabaseUpdate(bool doReload);
|
||||||
|
/**
|
||||||
|
* @brief Reloads the card database on a worker thread and runs checkUnknownSets() once the finished
|
||||||
|
* snapshot has been swapped in.
|
||||||
|
*
|
||||||
|
* The loader never runs checkUnknownSets() itself (see main.cpp); post-update loads that can introduce
|
||||||
|
* new sets must own the check, and it has to run after the queued snapshot swap, not on the worker.
|
||||||
|
*/
|
||||||
|
void reloadCardDatabaseAndCheckSets();
|
||||||
/**
|
/**
|
||||||
* @brief Tries to bring the open card database updater window to the front.
|
* @brief Tries to bring the open card database updater window to the front.
|
||||||
* @return true when the updater acknowledged the request
|
* @return true when the updater acknowledged the request
|
||||||
|
|
|
||||||
|
|
@ -343,11 +343,12 @@ int main(int argc, char *argv[])
|
||||||
// contention that happens when the load runs alongside window construction.
|
// contention that happens when the load runs alongside window construction.
|
||||||
// The CardDatabaseModel populates from the already-loaded data in its
|
// The CardDatabaseModel populates from the already-loaded data in its
|
||||||
// constructor, so the window appears fully populated with no startup lag.
|
// constructor, so the window appears fully populated with no startup lag.
|
||||||
// Note: checkUnknownSets() is deferred from the initial load to
|
// Note: checkUnknownSets() is deliberately not run for this front-loaded
|
||||||
// MainWindow::startupConfigCheck() so that
|
// parse. It would run before MainWindow exists, so
|
||||||
// cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled have live
|
// cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled would have no
|
||||||
// receivers when emitted. Subsequent reloads (e.g. path changes) call
|
// receivers, and enabling the sets up-front would suppress the first-run
|
||||||
// checkUnknownSets() directly from the loader after the first load.
|
// experience MainWindow::startupConfigCheck() and the onboarding wizard
|
||||||
|
// provide. Callers that load after startup own the check.
|
||||||
CardDatabaseManager::getInstance()->loadCardDatabases();
|
CardDatabaseManager::getInstance()->loadCardDatabases();
|
||||||
|
|
||||||
MainWindow ui;
|
MainWindow ui;
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,18 @@ Selecting this action will open a new text editor dialog with the contents of yo
|
||||||
|
|
||||||
The import dialog expects each line to be a card with the following format:
|
The import dialog expects each line to be a card with the following format:
|
||||||
|
|
||||||
TODO
|
`[quantity] Card Name [options]`
|
||||||
|
|
||||||
|
- `quantity` is the number of copies and can be written as `2`, `2x` or `2X`. If omitted, a single copy is imported.
|
||||||
|
- `options` are appended after the card name and are understood on a best-effort basis:
|
||||||
|
- a set code and collector number, either as `Card Name (SET) 123` or `Card Name (SET) 123-1`
|
||||||
|
- a foil marker, `Card Name *F*`
|
||||||
|
- Lines can also carry a `SB:` prefix or a set code prefix (`[SET]`) — Cockatrice tries to stay compatible with the most
|
||||||
|
common deck formats.
|
||||||
|
|
||||||
|
Tab-separated exports from third-party apps (such as Delver Lens) are also accepted: any line whose first column is a
|
||||||
|
plain quantity treats the following columns as the card name and printer/set metadata (which is discarded), for example
|
||||||
|
`2\tCard Name\tSet Name`.
|
||||||
|
|
||||||
Each card should be on a separate line and there should be no empty lines between cards. The first empty line between
|
Each card should be on a separate line and there should be no empty lines between cards. The first empty line between
|
||||||
two blocks of cards will be considered as the divider between mainboard and sideboard.
|
two blocks of cards will be considered as the divider between mainboard and sideboard.
|
||||||
|
|
|
||||||
63
gcovr.cfg
Normal file
63
gcovr.cfg
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
# gcovr configuration for Cockatrice line coverage.
|
||||||
|
#
|
||||||
|
# Used by .ci/compile.sh --coverage and by hand:
|
||||||
|
#
|
||||||
|
# cmake -S . -B build/cover -DCMAKE_BUILD_TYPE=Debug -DWITH_SERVER=1 -DTEST=1 -DENABLE_COVERAGE=ON
|
||||||
|
# cmake --build build/cover -j 24
|
||||||
|
# ctest --test-dir build/cover -LE bench -j 24
|
||||||
|
# gcovr build/cover --root . --object-directory build/cover --txt --decisions
|
||||||
|
#
|
||||||
|
# The point of most of this file is subtraction. A raw report is dominated by code no test can
|
||||||
|
# reach: vendored third-party trees, build directories left over from earlier experiments, and
|
||||||
|
# the client's main() and window classes, which have no headless test. Those all dilute the one
|
||||||
|
# number worth watching, so they are filtered out here rather than in every invocation.
|
||||||
|
|
||||||
|
# root is deliberately not set here. This file is normally invoked from the build directory
|
||||||
|
# (compile.sh runs there, because it configures with `cmake ..`), so a `root` of `.` would
|
||||||
|
# resolve to the build tree and every filter below would exclude everything. compile.sh passes
|
||||||
|
# --root explicitly instead.
|
||||||
|
|
||||||
|
# Our own code only. This keeps generated Qt MOC output, protobuf output and anything CMake
|
||||||
|
# built under the source tree out of the report.
|
||||||
|
filter = ^cockatrice/
|
||||||
|
filter = ^libcockatrice_[^/]+/
|
||||||
|
filter = ^servatrice/
|
||||||
|
filter = ^oracle/
|
||||||
|
|
||||||
|
# The tests are the vehicle, not the subject. They are compiled with the same instrumentation,
|
||||||
|
# so leaving them in would add their near-perfect coverage to the numerator and quietly report a
|
||||||
|
# healthier project than we have: test code is only ever executed by the run that is measuring
|
||||||
|
# it. Helpers under tests/ are excluded on the same grounds.
|
||||||
|
exclude = ^tests/
|
||||||
|
|
||||||
|
# Vendored third-party code is not ours to cover and is never going to move the number.
|
||||||
|
exclude = ^vcpkg/
|
||||||
|
exclude = ^doc/doxygen/theme/
|
||||||
|
exclude = ^libcockatrice_rng/libcockatrice/rng/sfmt/
|
||||||
|
exclude = ^libcockatrice_utility/libcockatrice/utility/peglib\.h$
|
||||||
|
exclude = ^oracle/src/lzma/
|
||||||
|
exclude = ^oracle/src/qt-json/
|
||||||
|
exclude = ^oracle/src/zip/
|
||||||
|
exclude = ^servatrice/src/smtp/
|
||||||
|
|
||||||
|
# Qt's own moc/pch/uic output is generated into the build tree, not the source tree, so the
|
||||||
|
# filters above already keep it out. These cover CLion's directories, which sit in-tree and are
|
||||||
|
# picked up by the object-directory walk.
|
||||||
|
exclude = .*cmake-build-.*
|
||||||
|
exclude = .*\.cache/
|
||||||
|
|
||||||
|
# Process entry points and the client's window chrome. Each of these needs a display, an
|
||||||
|
# interactive terminal, or a live database before it does anything a test could observe, so no
|
||||||
|
# headless run will ever move them. Left in, they contribute thousands of lines at 0% and drag
|
||||||
|
# the total down by a fixed amount that hides movement in the code we can actually test. The
|
||||||
|
# window header is excluded with its .cpp because gcovr attributes its inline template code
|
||||||
|
# there, which is where most of its line count lives.
|
||||||
|
exclude = ^cockatrice/src/main\.cpp$
|
||||||
|
exclude = ^cockatrice/src/interface/window_main\.
|
||||||
|
exclude = ^oracle/src/main\.cpp$
|
||||||
|
exclude = ^servatrice/src/main\.cpp$
|
||||||
|
|
||||||
|
exclude-throw-branches = yes
|
||||||
|
exclude-function-lines = yes
|
||||||
|
|
||||||
|
print-summary = yes
|
||||||
|
|
@ -60,3 +60,5 @@ target_link_libraries(
|
||||||
PUBLIC libcockatrice_utility
|
PUBLIC libcockatrice_utility
|
||||||
PUBLIC ${QT_CORE_MODULE}
|
PUBLIC ${QT_CORE_MODULE}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
target_precompile_headers(libcockatrice_card PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QList>
|
#include <QList>
|
||||||
#include <QMessageLogger>
|
#include <QMessageLogger>
|
||||||
|
#include <QMetaObject>
|
||||||
#include <QMetaType>
|
#include <QMetaType>
|
||||||
#include <QMutexLocker>
|
#include <QMutexLocker>
|
||||||
#include <QSharedPointer>
|
#include <QSharedPointer>
|
||||||
|
|
@ -80,15 +81,17 @@ void CardDatabase::reloadCardDatabasesAndNotify()
|
||||||
{
|
{
|
||||||
// An explicit reload bypasses the binary cache so the files on disk are
|
// An explicit reload bypasses the binary cache so the files on disk are
|
||||||
// always re-read, even when their size/mtime hash still matches the cache.
|
// always re-read, even when their size/mtime hash still matches the cache.
|
||||||
|
// Models refresh from the swap's cardDatabaseReset in a single batch, so
|
||||||
|
// no cardDatabaseEnabledSetsChanged is emitted here: that rebuilt the
|
||||||
|
// 36k-card models a second time on the GUI thread, which froze startup
|
||||||
|
// after enabling new/custom sets. The follow-up unknown-sets check is
|
||||||
|
// queued to the GUI thread so it sees the swapped-in data (this may run
|
||||||
|
// on a worker via QtConcurrent::run); it is silent unless new sets
|
||||||
|
// appeared mid-reload.
|
||||||
loadStatus = loader->loadCardDatabases(/* ignoreCache */ true);
|
loadStatus = loader->loadCardDatabases(/* ignoreCache */ true);
|
||||||
|
|
||||||
if (loadStatus == Ok) {
|
if (loadStatus == Ok) {
|
||||||
checkUnknownSets();
|
QMetaObject::invokeMethod(this, &CardDatabase::checkUnknownSets, Qt::AutoConnection);
|
||||||
// A reload reconstructs the exact same enabled-set state, so the cached
|
|
||||||
// set-name / alt-name data computed during construction is still valid.
|
|
||||||
// Skipping the per-card refresh avoids a full 36k-card recompute that
|
|
||||||
// would otherwise duplicate the work already done while building cards.
|
|
||||||
notifyEnabledSetsChanged(false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -207,15 +210,19 @@ void CardDatabase::checkUnknownSets()
|
||||||
if (numUnknownSets > 0) {
|
if (numUnknownSets > 0) {
|
||||||
emit cardDatabaseNewSetsFound(numUnknownSets, unknownSetNames);
|
emit cardDatabaseNewSetsFound(numUnknownSets, unknownSetNames);
|
||||||
} else {
|
} else {
|
||||||
_sets.markAllAsKnown();
|
_sets.markAllAsKnownBatched(setPriorityController);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No set enabled. Probably this is the first time running trice
|
// No set enabled. Probably this is the first time running trice
|
||||||
_sets.guessSortKeys();
|
_sets.enableAllBatched(setPriorityController);
|
||||||
_sets.sortByKey();
|
_sets.sortByKey();
|
||||||
_sets.enableAll();
|
|
||||||
notifyEnabledSetsChanged();
|
|
||||||
|
|
||||||
|
// No cardDatabaseEnabledSetsChanged here: the reload chained off
|
||||||
|
// cardDatabaseAllNewSetsEnabled re-parses with the sets enabled and its
|
||||||
|
// swap's cardDatabaseReset rebuilds the models in one batch. Emitting
|
||||||
|
// both back-to-back rebuilt the 36k-card models twice on the GUI thread,
|
||||||
|
// stalling startup. (When the card list is empty the slot returns early
|
||||||
|
// without reloading, but there is nothing to display either.)
|
||||||
emit cardDatabaseAllNewSetsEnabled();
|
emit cardDatabaseAllNewSetsEnabled();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -223,13 +230,13 @@ void CardDatabase::checkUnknownSets()
|
||||||
void CardDatabase::enableAllUnknownSets()
|
void CardDatabase::enableAllUnknownSets()
|
||||||
{
|
{
|
||||||
auto _sets = getSetList();
|
auto _sets = getSetList();
|
||||||
_sets.enableAllUnknown();
|
_sets.enableAllUnknownBatched(setPriorityController);
|
||||||
}
|
}
|
||||||
|
|
||||||
void CardDatabase::markAllSetsAsKnown()
|
void CardDatabase::markAllSetsAsKnown()
|
||||||
{
|
{
|
||||||
auto _sets = getSetList();
|
auto _sets = getSetList();
|
||||||
_sets.markAllAsKnown();
|
_sets.markAllAsKnownBatched(setPriorityController);
|
||||||
}
|
}
|
||||||
|
|
||||||
void CardDatabase::notifyEnabledSetsChanged(bool recomputeCachedSets)
|
void CardDatabase::notifyEnabledSetsChanged(bool recomputeCachedSets)
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,13 @@ CardDatabaseLoader::CardDatabaseLoader(QObject *parent,
|
||||||
// the finished snapshot is swapped into the live database on the GUI thread.
|
// the finished snapshot is swapped into the live database on the GUI thread.
|
||||||
|
|
||||||
// when SettingsCache's path changes, trigger reloads
|
// when SettingsCache's path changes, trigger reloads
|
||||||
connect(pathProvider, &ICardDatabasePathProvider::cardDatabasePathChanged, this, [this] { loadCardDatabases(); });
|
connect(pathProvider, &ICardDatabasePathProvider::cardDatabasePathChanged, this, [this] {
|
||||||
|
loadCardDatabases();
|
||||||
|
// This reload runs synchronously on the emitting (GUI) thread, so the
|
||||||
|
// finished snapshot is already swapped in when it returns: enable any
|
||||||
|
// sets the newly pointed-at database introduced.
|
||||||
|
database->checkUnknownSets();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
CardDatabaseLoader::~CardDatabaseLoader()
|
CardDatabaseLoader::~CardDatabaseLoader()
|
||||||
|
|
@ -136,7 +142,23 @@ LoadStatus CardDatabaseLoader::doLoadCardDatabases(bool ignoreCache)
|
||||||
loadCardDatabase(p, data);
|
loadCardDatabase(p, data);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!saveToCache(data, sourceHash)) {
|
// A snapshot parsed while no set is enabled has had every printing
|
||||||
|
// stripped by the parsers, and the cache reader re-derives enablement
|
||||||
|
// from the priority controller, so such an entry can never be reused
|
||||||
|
// meaningfully. Skip writing it so a first-run (blank config) load
|
||||||
|
// cannot poison the cache with an empty snapshot. The check mirrors
|
||||||
|
// what the parsers saw: any set still reporting enabled means the
|
||||||
|
// snapshot is complete for the current enablement.
|
||||||
|
bool anySetEnabled = false;
|
||||||
|
for (const CardSetPtr &set : data.sets) {
|
||||||
|
if (set && set->getEnabled()) {
|
||||||
|
anySetEnabled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!data.sets.isEmpty() && !anySetEnabled) {
|
||||||
|
qCInfo(CardDatabaseLoadingLog) << "Skipping binary cache: no set is enabled yet, snapshot was stripped";
|
||||||
|
} else if (!saveToCache(data, sourceHash)) {
|
||||||
qCWarning(CardDatabaseLoadingLog) << "Failed to write binary cache to" << cachePath();
|
qCWarning(CardDatabaseLoadingLog) << "Failed to write binary cache to" << cachePath();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -148,18 +170,15 @@ LoadStatus CardDatabaseLoader::doLoadCardDatabases(bool ignoreCache)
|
||||||
database->refreshCachedReverseRelatedCards(data.cards);
|
database->refreshCachedReverseRelatedCards(data.cards);
|
||||||
|
|
||||||
qCInfo(CardDatabaseLoadingSuccessOrFailureLog) << "Card Database Loading Success";
|
qCInfo(CardDatabaseLoadingSuccessOrFailureLog) << "Card Database Loading Success";
|
||||||
|
// NOTE: checkUnknownSets() is intentionally NOT called here. During the
|
||||||
|
// front-loaded parse in main() MainWindow does not exist yet, so
|
||||||
|
// cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled would be
|
||||||
|
// emitted with no receivers, and enabling the sets up-front would
|
||||||
|
// suppress the first-run experience MainWindow::startupConfigCheck()
|
||||||
|
// and the onboarding wizard are designed around. Callers that load
|
||||||
|
// after startup own the check (and any reload it triggers).
|
||||||
emit databaseDataReady(std::move(data));
|
emit databaseDataReady(std::move(data));
|
||||||
emit loadingFinished();
|
emit loadingFinished();
|
||||||
// During the front-loaded parse in main() this runs before MainWindow
|
|
||||||
// exists, so cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled
|
|
||||||
// would be emitted with no receivers. Skip the check on the first load;
|
|
||||||
// MainWindow::startupConfigCheck() calls checkUnknownSets() once its
|
|
||||||
// signal connections are live. On subsequent reloads (e.g. path change)
|
|
||||||
// MainWindow exists and signals have live receivers.
|
|
||||||
if (initialLoadComplete) {
|
|
||||||
database->checkUnknownSets();
|
|
||||||
}
|
|
||||||
initialLoadComplete = true;
|
|
||||||
} else {
|
} else {
|
||||||
qCInfo(CardDatabaseLoadingSuccessOrFailureLog) << "Card Database Loading Failed";
|
qCInfo(CardDatabaseLoadingSuccessOrFailureLog) << "Card Database Loading Failed";
|
||||||
emit loadingFailed(); // bring up the settings dialog
|
emit loadingFailed(); // bring up the settings dialog
|
||||||
|
|
|
||||||
|
|
@ -179,7 +179,6 @@ private:
|
||||||
|
|
||||||
QBasicMutex *loadFromFileMutex = new QBasicMutex(); /**< Mutex for single-file loading. */
|
QBasicMutex *loadFromFileMutex = new QBasicMutex(); /**< Mutex for single-file loading. */
|
||||||
QBasicMutex *reloadDatabaseMutex = new QBasicMutex(); /**< Mutex for reloading entire database. */
|
QBasicMutex *reloadDatabaseMutex = new QBasicMutex(); /**< Mutex for reloading entire database. */
|
||||||
bool initialLoadComplete = false; /**< Set after the first successful load. */
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#endif // COCKATRICE_CARD_DATABASE_LOADER_H
|
#endif // COCKATRICE_CARD_DATABASE_LOADER_H
|
||||||
|
|
|
||||||
|
|
@ -227,6 +227,10 @@ public:
|
||||||
* @param _isknown True if known, false if unknown.
|
* @param _isknown True if known, false if unknown.
|
||||||
*/
|
*/
|
||||||
void setIsKnown(bool _isknown);
|
void setIsKnown(bool _isknown);
|
||||||
|
void setIsKnownInMemory(bool _isknown)
|
||||||
|
{
|
||||||
|
isknown = _isknown;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Determines whether the set has incomplete metadata and should be ignored.
|
* @brief Determines whether the set has incomplete metadata and should be ignored.
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
#include "card_set_list.h"
|
#include "card_set_list.h"
|
||||||
|
|
||||||
#include "libcockatrice/card/set/card_set.h"
|
#include "libcockatrice/card/set/card_set.h"
|
||||||
|
#include "libcockatrice/interfaces/interface_card_set_priority_controller.h"
|
||||||
|
|
||||||
#include <QDate>
|
#include <QDate>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
#include <QVector>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <compare>
|
#include <compare>
|
||||||
|
|
||||||
|
|
@ -62,60 +64,87 @@ QStringList CardSetList::getUnknownSetsNames()
|
||||||
return sets;
|
return sets;
|
||||||
}
|
}
|
||||||
|
|
||||||
void CardSetList::enableAllUnknown()
|
void CardSetList::enableAllUnknownBatched(ICardSetPriorityController *controller)
|
||||||
{
|
{
|
||||||
for (int i = 0; i < size(); ++i) {
|
QVector<ICardSetPriorityController::SetSaveData> saveData;
|
||||||
CardSetPtr set = at(i);
|
|
||||||
if (set && !set->getIsKnown() && !set->getIsKnownIgnored()) {
|
|
||||||
set->setIsKnown(true);
|
|
||||||
set->setEnabled(true);
|
|
||||||
} else if (set && set->getIsKnownIgnored() && !set->getEnabled()) {
|
|
||||||
set->setEnabled(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void CardSetList::enableAll()
|
|
||||||
{
|
|
||||||
for (int i = 0; i < size(); ++i) {
|
|
||||||
CardSetPtr set = at(i);
|
|
||||||
|
|
||||||
if (set == nullptr) {
|
|
||||||
// qCWarning(CardInfoLog) << "enabledAll has null";
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!set->getIsKnownIgnored()) {
|
|
||||||
set->setIsKnown(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
set->setEnabled(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void CardSetList::markAllAsKnown()
|
|
||||||
{
|
|
||||||
for (int i = 0; i < size(); ++i) {
|
|
||||||
CardSetPtr set = at(i);
|
|
||||||
if (set && !set->getIsKnown() && !set->getIsKnownIgnored()) {
|
|
||||||
set->setIsKnown(true);
|
|
||||||
set->setEnabled(false);
|
|
||||||
} else if (set && set->getIsKnownIgnored() && !set->getEnabled()) {
|
|
||||||
set->setEnabled(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void CardSetList::guessSortKeys()
|
|
||||||
{
|
|
||||||
defaultSort();
|
|
||||||
for (int i = 0; i < size(); ++i) {
|
for (int i = 0; i < size(); ++i) {
|
||||||
CardSetPtr set = at(i);
|
CardSetPtr set = at(i);
|
||||||
if (set.isNull()) {
|
if (set.isNull()) {
|
||||||
// qCWarning(CardInfoLog) << "guessSortKeys set is null";
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
set->setSortKey(i);
|
|
||||||
|
bool changed = false;
|
||||||
|
if (!set->getIsKnown() && !set->getIsKnownIgnored()) {
|
||||||
|
set->setIsKnownInMemory(true);
|
||||||
|
set->setEnabledInMemory(true);
|
||||||
|
changed = true;
|
||||||
|
} else if (set->getIsKnownIgnored() && !set->getEnabled()) {
|
||||||
|
set->setEnabledInMemory(true);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
saveData.append(
|
||||||
|
{set->getShortName(), static_cast<unsigned int>(set->getSortKey()), true, set->getIsKnown()});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!saveData.isEmpty()) {
|
||||||
|
controller->saveSets(saveData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CardSetList::enableAllBatched(ICardSetPriorityController *controller)
|
||||||
|
{
|
||||||
|
defaultSort();
|
||||||
|
|
||||||
|
QVector<ICardSetPriorityController::SetSaveData> saveData;
|
||||||
|
saveData.reserve(size());
|
||||||
|
for (int i = 0; i < size(); ++i) {
|
||||||
|
CardSetPtr set = at(i);
|
||||||
|
if (set.isNull()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto sortKey = static_cast<unsigned int>(i);
|
||||||
|
set->setSortKeyInMemory(sortKey);
|
||||||
|
if (!set->getIsKnownIgnored()) {
|
||||||
|
set->setIsKnownInMemory(true);
|
||||||
|
}
|
||||||
|
set->setEnabledInMemory(true);
|
||||||
|
saveData.append({set->getShortName(), sortKey, true, set->getIsKnown()});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!saveData.isEmpty()) {
|
||||||
|
controller->saveSets(saveData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CardSetList::markAllAsKnownBatched(ICardSetPriorityController *controller)
|
||||||
|
{
|
||||||
|
QVector<ICardSetPriorityController::SetSaveData> saveData;
|
||||||
|
for (int i = 0; i < size(); ++i) {
|
||||||
|
CardSetPtr set = at(i);
|
||||||
|
if (set.isNull()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool changed = false;
|
||||||
|
if (!set->getIsKnown() && !set->getIsKnownIgnored()) {
|
||||||
|
set->setIsKnownInMemory(true);
|
||||||
|
set->setEnabledInMemory(false);
|
||||||
|
changed = true;
|
||||||
|
} else if (set->getIsKnownIgnored() && !set->getEnabled()) {
|
||||||
|
set->setEnabledInMemory(true);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
saveData.append({set->getShortName(), static_cast<unsigned int>(set->getSortKey()), set->getEnabled(),
|
||||||
|
set->getIsKnown()});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!saveData.isEmpty()) {
|
||||||
|
controller->saveSets(saveData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,36 +40,37 @@ public:
|
||||||
void sortByKey();
|
void sortByKey();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Reassigns sort keys based on the current order.
|
* @brief Enables every set with fresh sort keys, persisting in one flush.
|
||||||
*
|
*
|
||||||
* Calls defaultSort() and then assigns sequential sort keys
|
* Assigns sequential sort keys in default-sorted order and marks every set
|
||||||
* to all sets according to their resulting positions, replacing
|
* known and enabled, writing all records through the controller's batched
|
||||||
* any existing sort keys to ensure consistent ordering.
|
* saveSets() in a single settings flush instead of ~3 QSettings rewrite
|
||||||
|
* cycles per set, which froze the GUI thread for seconds on a first run
|
||||||
|
* with a full database.
|
||||||
|
* @param controller The set priority controller to persist the records with.
|
||||||
*/
|
*/
|
||||||
void guessSortKeys();
|
void enableAllBatched(ICardSetPriorityController *controller);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Enables all sets that are unknown or ignored.
|
* @brief Enables all sets that are unknown or ignored, persisting in one flush.
|
||||||
*
|
*
|
||||||
* Sets that are not marked as known and not ignored are marked as known
|
* Sets that are not marked as known and not ignored are marked as known
|
||||||
* and enabled. Ignored-known sets are also enabled, but remain ignored.
|
* and enabled. Ignored-known sets are also enabled, but remain ignored.
|
||||||
|
* Only changed sets are written, so the common case of a few new sets costs
|
||||||
|
* a single settings flush no matter how large the database is.
|
||||||
|
* @param controller The set priority controller to persist the records with.
|
||||||
*/
|
*/
|
||||||
void enableAllUnknown();
|
void enableAllUnknownBatched(ICardSetPriorityController *controller);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Enables all sets in the list.
|
* @brief Marks all sets as known and adjusts their enabled state, persisting in one flush.
|
||||||
*
|
|
||||||
* Equivalent to calling `setEnabled(true)` on each entry.
|
|
||||||
*/
|
|
||||||
void enableAll();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @brief Marks all sets as known and adjusts their enabled state.
|
|
||||||
*
|
*
|
||||||
* Unknown, non-ignored sets become known and disabled.
|
* Unknown, non-ignored sets become known and disabled.
|
||||||
* Ignored-known sets are enabled if they were previously disabled.
|
* Ignored-known sets are enabled if they were previously disabled.
|
||||||
|
* Only changed sets are written; when nothing is unknown no flush happens at all.
|
||||||
|
* @param controller The set priority controller to persist the records with.
|
||||||
*/
|
*/
|
||||||
void markAllAsKnown();
|
void markAllAsKnownBatched(ICardSetPriorityController *controller);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Counts the number of sets that are currently enabled.
|
* @brief Counts the number of sets that are currently enabled.
|
||||||
|
|
|
||||||
|
|
@ -41,3 +41,5 @@ target_link_libraries(
|
||||||
libcockatrice_deck_list PUBLIC libcockatrice_interfaces libcockatrice_protocol libcockatrice_utility
|
libcockatrice_deck_list PUBLIC libcockatrice_interfaces libcockatrice_protocol libcockatrice_utility
|
||||||
${QT_CORE_MODULE}
|
${QT_CORE_MODULE}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
target_precompile_headers(libcockatrice_deck_list PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@ bool parse(QTextStream &in,
|
||||||
|
|
||||||
// Regex for advanced card parsing
|
// Regex for advanced card parsing
|
||||||
static const QRegularExpression reMultiplier(R"(^[xX\(\[]*(\d+)[xX\*\)\]]* ?(.+))");
|
static const QRegularExpression reMultiplier(R"(^[xX\(\[]*(\d+)[xX\*\)\]]* ?(.+))");
|
||||||
|
static const QRegularExpression reTabQuantity(R"(^\d+[xX]?$)");
|
||||||
|
|
||||||
// Regex for extracting set code and collector number with attached symbols
|
// Regex for extracting set code and collector number with attached symbols
|
||||||
static const QRegularExpression reHyphenFormat(R"(\((\w{3,})\)\s+(\w{3,})-(\d+[^\w\s]*))");
|
static const QRegularExpression reHyphenFormat(R"(\((\w{3,})\)\s+(\w{3,})-(\d+[^\w\s]*))");
|
||||||
|
|
@ -110,8 +111,34 @@ bool parse(QTextStream &in,
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
QString cardName = match.captured().simplified();
|
const QString rawLine = match.captured();
|
||||||
|
QString cardName = rawLine.simplified();
|
||||||
bool sideboard = false;
|
bool sideboard = false;
|
||||||
|
int amount = 1;
|
||||||
|
|
||||||
|
// Card names never contain tab characters, so a leading numeric field
|
||||||
|
// marks a tabular record emitted by third-party deck tools (e.g. Delver
|
||||||
|
// Lens "2\tCard Name\tSet Name"). The first two columns are the quantity
|
||||||
|
// and the card name; any further columns are printer/set metadata and
|
||||||
|
// are discarded.
|
||||||
|
bool hasTabQuantity = false;
|
||||||
|
const auto tabFields = rawLine.split('\t');
|
||||||
|
int firstField = 0;
|
||||||
|
while (firstField < tabFields.size() && tabFields.at(firstField).trimmed().isEmpty()) {
|
||||||
|
++firstField;
|
||||||
|
}
|
||||||
|
if (firstField + 1 < tabFields.size()) {
|
||||||
|
const QString quantity = tabFields.at(firstField).trimmed();
|
||||||
|
if (reTabQuantity.match(quantity).hasMatch()) {
|
||||||
|
hasTabQuantity = true;
|
||||||
|
QString digits = quantity;
|
||||||
|
if (digits.endsWith('x') || digits.endsWith('X')) {
|
||||||
|
digits.chop(1);
|
||||||
|
}
|
||||||
|
amount = digits.toInt();
|
||||||
|
cardName = tabFields.at(firstField + 1).simplified();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Sideboard detection
|
// Sideboard detection
|
||||||
if (sBStart < 0) {
|
if (sBStart < 0) {
|
||||||
|
|
@ -156,11 +183,12 @@ bool parse(QTextStream &in,
|
||||||
}
|
}
|
||||||
|
|
||||||
// check if a specific amount is mentioned
|
// check if a specific amount is mentioned
|
||||||
int amount = 1;
|
if (!hasTabQuantity) {
|
||||||
match = reMultiplier.match(cardName);
|
match = reMultiplier.match(cardName);
|
||||||
if (match.hasMatch()) {
|
if (match.hasMatch()) {
|
||||||
amount = match.captured(1).toInt();
|
amount = match.captured(1).toInt();
|
||||||
cardName = match.captured(2);
|
cardName = match.captured(2);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normalize the card name
|
// Normalize the card name
|
||||||
|
|
|
||||||
|
|
@ -18,3 +18,5 @@ add_dependencies(libcockatrice_filters libcockatrice_card)
|
||||||
target_include_directories(libcockatrice_filters PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
target_include_directories(libcockatrice_filters PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
||||||
|
|
||||||
target_link_libraries(libcockatrice_filters PUBLIC libcockatrice_card ${QT_CORE_MODULE})
|
target_link_libraries(libcockatrice_filters PUBLIC libcockatrice_card ${QT_CORE_MODULE})
|
||||||
|
|
||||||
|
target_precompile_headers(libcockatrice_filters PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")
|
||||||
|
|
|
||||||
|
|
@ -31,3 +31,5 @@ add_library(libcockatrice_interfaces STATIC ${MOC_SOURCES})
|
||||||
target_include_directories(libcockatrice_interfaces PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
target_include_directories(libcockatrice_interfaces PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
||||||
|
|
||||||
target_link_libraries(libcockatrice_interfaces PUBLIC libcockatrice_utility ${QT_CORE_MODULE})
|
target_link_libraries(libcockatrice_interfaces PUBLIC libcockatrice_utility ${QT_CORE_MODULE})
|
||||||
|
|
||||||
|
target_precompile_headers(libcockatrice_interfaces PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ public:
|
||||||
QString shortName;
|
QString shortName;
|
||||||
unsigned int sortKey;
|
unsigned int sortKey;
|
||||||
bool enabled;
|
bool enabled;
|
||||||
|
bool isKnown = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -57,6 +58,14 @@ public:
|
||||||
*/
|
*/
|
||||||
virtual QStringList getEnabledSetNames() const = 0;
|
virtual QStringList getEnabledSetNames() const = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Persists per-set options for every entry in a single settings flush.
|
||||||
|
*
|
||||||
|
* Each individual setSortKey/setEnabled/setIsKnown call re-opens and re-syncs
|
||||||
|
* the whole .ini when backed by QSettings, so persisting hundreds of sets
|
||||||
|
* one option at a time freezes the GUI thread for seconds. Callers that touch
|
||||||
|
* more than a handful of sets must bundle the records and call this once.
|
||||||
|
*/
|
||||||
virtual void saveSets(const QVector<SetSaveData> &data) = 0;
|
virtual void saveSets(const QVector<SetSaveData> &data) = 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,3 +29,5 @@ add_library(
|
||||||
target_include_directories(libcockatrice_models_database PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
target_include_directories(libcockatrice_models_database PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
||||||
|
|
||||||
target_link_libraries(libcockatrice_models_database PUBLIC libcockatrice_card libcockatrice_filters ${QT_CORE_MODULE})
|
target_link_libraries(libcockatrice_models_database PUBLIC libcockatrice_card libcockatrice_filters ${QT_CORE_MODULE})
|
||||||
|
|
||||||
|
target_precompile_headers(libcockatrice_models_database PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")
|
||||||
|
|
|
||||||
|
|
@ -266,7 +266,7 @@ void SetsModel::save(CardDatabase *db)
|
||||||
const bool enabled = enabledSets.contains(sets[i]);
|
const bool enabled = enabledSets.contains(sets[i]);
|
||||||
sets[i]->setSortKeyInMemory(sortKey);
|
sets[i]->setSortKeyInMemory(sortKey);
|
||||||
sets[i]->setEnabledInMemory(enabled);
|
sets[i]->setEnabledInMemory(enabled);
|
||||||
saveData.append({sets[i]->getShortName(), sortKey, enabled});
|
saveData.append({sets[i]->getShortName(), sortKey, enabled, sets[i]->getIsKnown()});
|
||||||
}
|
}
|
||||||
|
|
||||||
db->getPriorityController()->saveSets(saveData);
|
db->getPriorityController()->saveSets(saveData);
|
||||||
|
|
|
||||||
|
|
@ -16,3 +16,5 @@ target_include_directories(libcockatrice_models_deck_list PUBLIC ${CMAKE_CURRENT
|
||||||
target_link_libraries(
|
target_link_libraries(
|
||||||
libcockatrice_models_deck_list PUBLIC libcockatrice_card libcockatrice_deck_list ${QT_CORE_MODULE}
|
libcockatrice_models_deck_list PUBLIC libcockatrice_card libcockatrice_deck_list ${QT_CORE_MODULE}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
target_precompile_headers(libcockatrice_models_deck_list PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")
|
||||||
|
|
|
||||||
|
|
@ -19,3 +19,5 @@ target_link_libraries(
|
||||||
PUBLIC ${COCKATRICE_QT_VERSION_NAME}::Network ${COCKATRICE_QT_VERSION_NAME}::WebSockets libcockatrice_protocol
|
PUBLIC ${COCKATRICE_QT_VERSION_NAME}::Network ${COCKATRICE_QT_VERSION_NAME}::WebSockets libcockatrice_protocol
|
||||||
libcockatrice_network_server_remote
|
libcockatrice_network_server_remote
|
||||||
)
|
)
|
||||||
|
|
||||||
|
target_precompile_headers(libcockatrice_network_client_abstract PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")
|
||||||
|
|
|
||||||
|
|
@ -19,3 +19,5 @@ target_link_libraries(
|
||||||
PUBLIC ${COCKATRICE_QT_VERSION_NAME}::Network ${COCKATRICE_QT_VERSION_NAME}::WebSockets
|
PUBLIC ${COCKATRICE_QT_VERSION_NAME}::Network ${COCKATRICE_QT_VERSION_NAME}::WebSockets
|
||||||
libcockatrice_network_client_abstract
|
libcockatrice_network_client_abstract
|
||||||
)
|
)
|
||||||
|
|
||||||
|
target_precompile_headers(libcockatrice_network_client_local PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")
|
||||||
|
|
|
||||||
|
|
@ -19,3 +19,5 @@ target_link_libraries(
|
||||||
PUBLIC ${COCKATRICE_QT_VERSION_NAME}::Network ${COCKATRICE_QT_VERSION_NAME}::WebSockets
|
PUBLIC ${COCKATRICE_QT_VERSION_NAME}::Network ${COCKATRICE_QT_VERSION_NAME}::WebSockets
|
||||||
libcockatrice_network_client_abstract libcockatrice_interfaces libcockatrice_utility libcockatrice_protocol
|
libcockatrice_network_client_abstract libcockatrice_interfaces libcockatrice_utility libcockatrice_protocol
|
||||||
)
|
)
|
||||||
|
|
||||||
|
target_precompile_headers(libcockatrice_network_client_remote PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")
|
||||||
|
|
|
||||||
|
|
@ -15,3 +15,5 @@ add_dependencies(libcockatrice_network_server_local libcockatrice_protocol)
|
||||||
target_include_directories(libcockatrice_network_server_local PUBLIC .)
|
target_include_directories(libcockatrice_network_server_local PUBLIC .)
|
||||||
|
|
||||||
target_link_libraries(libcockatrice_network_server_local PUBLIC ${COCKATRICE_QT_MODULES} libcockatrice_protocol)
|
target_link_libraries(libcockatrice_network_server_local PUBLIC ${COCKATRICE_QT_MODULES} libcockatrice_protocol)
|
||||||
|
|
||||||
|
target_precompile_headers(libcockatrice_network_server_local PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ set(CMAKE_AUTOUIC ON)
|
||||||
set(CMAKE_AUTORCC ON)
|
set(CMAKE_AUTORCC ON)
|
||||||
|
|
||||||
set(HEADERS
|
set(HEADERS
|
||||||
|
game/dump_zone_range.h
|
||||||
game/server_abstract_participant.h
|
game/server_abstract_participant.h
|
||||||
game/server_abstract_player.h
|
game/server_abstract_player.h
|
||||||
game/server_arrow.h
|
game/server_arrow.h
|
||||||
|
|
@ -33,6 +34,7 @@ qt6_wrap_cpp(MOC_SOURCES ${HEADERS})
|
||||||
add_library(
|
add_library(
|
||||||
libcockatrice_network_server_remote STATIC
|
libcockatrice_network_server_remote STATIC
|
||||||
${MOC_SOURCES}
|
${MOC_SOURCES}
|
||||||
|
game/dump_zone_range.cpp
|
||||||
game/server_abstract_participant.cpp
|
game/server_abstract_participant.cpp
|
||||||
game/server_abstract_player.cpp
|
game/server_abstract_player.cpp
|
||||||
game/server_arrow.cpp
|
game/server_arrow.cpp
|
||||||
|
|
@ -62,3 +64,5 @@ target_link_libraries(
|
||||||
libcockatrice_network_server_remote PUBLIC libcockatrice_protocol libcockatrice_utility libcockatrice_rng
|
libcockatrice_network_server_remote PUBLIC libcockatrice_protocol libcockatrice_utility libcockatrice_rng
|
||||||
libcockatrice_deck_list ${COCKATRICE_QT_MODULES}
|
libcockatrice_deck_list ${COCKATRICE_QT_MODULES}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
target_precompile_headers(libcockatrice_network_server_remote PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
#include "dump_zone_range.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
std::pair<qsizetype, qsizetype> dumpZoneCardRange(qsizetype cardCount, int numberCards, bool isReversed)
|
||||||
|
{
|
||||||
|
qsizetype count = 0;
|
||||||
|
if (numberCards == -1) {
|
||||||
|
count = cardCount;
|
||||||
|
} else if (numberCards > 0) {
|
||||||
|
count = std::min<qsizetype>(numberCards, cardCount);
|
||||||
|
}
|
||||||
|
count = std::max<qsizetype>(count, 0);
|
||||||
|
|
||||||
|
const qsizetype begin = isReversed ? cardCount - count : 0;
|
||||||
|
return {begin, begin + count};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
#ifndef DUMP_ZONE_RANGE_H
|
||||||
|
#define DUMP_ZONE_RANGE_H
|
||||||
|
|
||||||
|
#include <QtGlobal>
|
||||||
|
#include <utility>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Resolve a client's zone dump request against a zone's real card count.
|
||||||
|
*
|
||||||
|
* @c number_cards arrives from the client unvalidated. -1 means "the whole zone", which
|
||||||
|
* is the convention the client uses when sizing its own view of one. Every other negative
|
||||||
|
* value, and every value larger than the zone, has to be brought into range rather than
|
||||||
|
* used as an index: with a reversed request the zone offset is
|
||||||
|
* @c cardCount - @c numberCards, which runs off the front of the list when the request
|
||||||
|
* exceeds the zone and off the back when @c numberCards is -1.
|
||||||
|
*
|
||||||
|
* Kept in its own translation unit so it can be exercised without the rest of
|
||||||
|
* server_abstract_player.cpp, which refers to the RNG global that only the servatrice
|
||||||
|
* binary defines.
|
||||||
|
*
|
||||||
|
* @param cardCount number of cards the zone actually holds
|
||||||
|
* @param numberCards the requested count, -1 for the whole zone
|
||||||
|
* @param isReversed take the trailing cards instead of the leading ones
|
||||||
|
* @return the half-open index range [begin, end) into the zone's card list, always
|
||||||
|
* contained in [0, cardCount)
|
||||||
|
*/
|
||||||
|
std::pair<qsizetype, qsizetype> dumpZoneCardRange(qsizetype cardCount, int numberCards, bool isReversed);
|
||||||
|
|
||||||
|
#endif // DUMP_ZONE_RANGE_H
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
#include "server_abstract_player.h"
|
#include "server_abstract_player.h"
|
||||||
|
|
||||||
|
#include "dump_zone_range.h"
|
||||||
#include "game/server_abstract_participant.h"
|
#include "game/server_abstract_participant.h"
|
||||||
#include "game/server_arrowtarget.h"
|
#include "game/server_arrowtarget.h"
|
||||||
#include "libcockatrice/protocol/pb/card_attributes.pb.h"
|
#include "libcockatrice/protocol/pb/card_attributes.pb.h"
|
||||||
|
|
@ -1435,23 +1436,23 @@ Server_AbstractPlayer::cmdDumpZone(const Command_DumpZone &cmd, ResponseContaine
|
||||||
|
|
||||||
int numberCards = cmd.number_cards();
|
int numberCards = cmd.number_cards();
|
||||||
const QList<Server_Card *> &cards = zone->getCards();
|
const QList<Server_Card *> &cards = zone->getCards();
|
||||||
|
const auto cardRange = dumpZoneCardRange(cards.size(), numberCards, cmd.is_reversed());
|
||||||
|
|
||||||
auto *re = new Response_DumpZone;
|
auto *re = new Response_DumpZone;
|
||||||
ServerInfo_Zone *zoneInfo = re->mutable_zone_info();
|
ServerInfo_Zone *zoneInfo = re->mutable_zone_info();
|
||||||
zoneInfo->set_name(zone->getName().toStdString());
|
zoneInfo->set_name(zone->getName().toStdString());
|
||||||
zoneInfo->set_type(zone->getType());
|
zoneInfo->set_type(zone->getType());
|
||||||
zoneInfo->set_with_coords(zone->hasCoords());
|
zoneInfo->set_with_coords(zone->hasCoords());
|
||||||
zoneInfo->set_card_count(numberCards < cards.size() ? cards.size() : numberCards);
|
zoneInfo->set_card_count(cards.size());
|
||||||
|
|
||||||
for (int i = 0; (i < cards.size()) && (i < numberCards || numberCards == -1); ++i) {
|
for (qsizetype findId = cardRange.first; findId < cardRange.second; ++findId) {
|
||||||
const auto &findId = cmd.is_reversed() ? cards.size() - numberCards + i : i;
|
Server_Card *card = cards.at(findId);
|
||||||
Server_Card *card = cards[findId];
|
|
||||||
QString displayedName = card->getFaceDown() ? QString() : card->getName();
|
QString displayedName = card->getFaceDown() ? QString() : card->getName();
|
||||||
ServerInfo_Card *cardInfo = zoneInfo->add_card_list();
|
ServerInfo_Card *cardInfo = zoneInfo->add_card_list();
|
||||||
cardInfo->set_provider_id(card->getProviderId().toStdString());
|
cardInfo->set_provider_id(card->getProviderId().toStdString());
|
||||||
cardInfo->set_name(displayedName.toStdString());
|
cardInfo->set_name(displayedName.toStdString());
|
||||||
if (zone->getType() == ServerInfo_Zone::HiddenZone) {
|
if (zone->getType() == ServerInfo_Zone::HiddenZone) {
|
||||||
cardInfo->set_id(findId);
|
cardInfo->set_id(static_cast<int>(findId));
|
||||||
} else {
|
} else {
|
||||||
cardInfo->set_id(card->getId());
|
cardInfo->set_id(card->getId());
|
||||||
cardInfo->set_x(card->getX());
|
cardInfo->set_x(card->getX());
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,10 @@ public:
|
||||||
{
|
{
|
||||||
return deck;
|
return deck;
|
||||||
}
|
}
|
||||||
|
bool getSideboardLocked() const
|
||||||
|
{
|
||||||
|
return sideboardLocked;
|
||||||
|
}
|
||||||
bool getReadyStart() const
|
bool getReadyStart() const
|
||||||
{
|
{
|
||||||
return readyStart;
|
return readyStart;
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,8 @@
|
||||||
#include <QTimer>
|
#include <QTimer>
|
||||||
#include <google/protobuf/descriptor.h>
|
#include <google/protobuf/descriptor.h>
|
||||||
#include <google/protobuf/message.h>
|
#include <google/protobuf/message.h>
|
||||||
|
#include <libcockatrice/deck_list/deck_list.h>
|
||||||
|
#include <libcockatrice/protocol/pb/context_deck_select.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/context_ping_changed.pb.h>
|
#include <libcockatrice/protocol/pb/context_ping_changed.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/event_delete_arrow.pb.h>
|
#include <libcockatrice/protocol/pb/event_delete_arrow.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/event_game_closed.pb.h>
|
#include <libcockatrice/protocol/pb/event_game_closed.pb.h>
|
||||||
|
|
@ -825,6 +827,48 @@ void Server_Game::createGameJoinedEvent(Server_AbstractParticipant *joiningParti
|
||||||
}
|
}
|
||||||
|
|
||||||
rc.enqueuePostResponseItem(ServerMessage::GAME_EVENT_CONTAINER, prepareGameEvent(event2, -1));
|
rc.enqueuePostResponseItem(ServerMessage::GAME_EVENT_CONTAINER, prepareGameEvent(event2, -1));
|
||||||
|
|
||||||
|
// A newly joined player never saw the "Player X has loaded a deck" messages
|
||||||
|
// (they are only broadcast when a deck is selected), so replay a deck-select
|
||||||
|
// context for every player that already has a deck loaded. It is delivered
|
||||||
|
// privately to the joining participant, whose client renders it as a log
|
||||||
|
// entry in the same way as a fresh deck selection. Skipped on reconnect
|
||||||
|
// (resuming) — the player already saw these — and once the game has started,
|
||||||
|
// when the message is no longer useful and would only spam the log.
|
||||||
|
if (!resuming && !gameStarted) {
|
||||||
|
for (auto *participant : participants.values()) {
|
||||||
|
if (participant == joiningParticipant) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
auto *deckOwner = qobject_cast<Server_Player *>(participant);
|
||||||
|
if (deckOwner == nullptr) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const DeckList *deckList = deckOwner->getDeckList();
|
||||||
|
if (deckList == nullptr) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Event_PlayerPropertiesChanged event;
|
||||||
|
event.mutable_player_properties()->set_sideboard_locked(deckOwner->getSideboardLocked());
|
||||||
|
event.mutable_player_properties()->set_deck_hash(deckList->getDeckHash().toStdString());
|
||||||
|
|
||||||
|
Context_DeckSelect deckSelect;
|
||||||
|
deckSelect.set_deck_hash(deckList->getDeckHash().toStdString());
|
||||||
|
deckSelect.set_sideboard_size(deckList->getSideboardSize());
|
||||||
|
if (getShareDecklistsOnLoad()) {
|
||||||
|
deckSelect.set_deck_list(deckList->writeToString_Native().toStdString());
|
||||||
|
}
|
||||||
|
|
||||||
|
GameEventContext context;
|
||||||
|
context.GetReflection()
|
||||||
|
->MutableMessage(&context, deckSelect.GetDescriptor()->FindExtensionByName("ext"))
|
||||||
|
->CopyFrom(deckSelect);
|
||||||
|
|
||||||
|
rc.enqueuePostResponseItem(ServerMessage::GAME_EVENT_CONTAINER,
|
||||||
|
prepareGameEvent(event, deckOwner->getPlayerId(), &context));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void Server_Game::sendGameEventContainer(GameEventContainer *cont,
|
void Server_Game::sendGameEventContainer(GameEventContainer *cont,
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,39 @@ public:
|
||||||
virtual void observeGameStartDurationMs(qint64 /* elapsedMs */)
|
virtual void observeGameStartDurationMs(qint64 /* elapsedMs */)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* @brief Record a failed login attempt for the (address, username) pair; returns true if now locked out.
|
||||||
|
*
|
||||||
|
* Keyed by pair so a successful login as one user cannot wipe out another
|
||||||
|
* user's failure history from the same address (e.g. a guest login or the
|
||||||
|
* attacker's own account clearing the way for further guessing).
|
||||||
|
*/
|
||||||
|
virtual bool recordFailedLogin(const QString & /*ipAddress*/, const QString & /*userName*/)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @brief True if the (address, username) pair is already locked out of logging in.
|
||||||
|
*
|
||||||
|
* Consulted before any authentication work (database round trip, password
|
||||||
|
* verification) so a blocked pair cannot burn server CPU per attempt.
|
||||||
|
*/
|
||||||
|
virtual bool isLoginRateLimited(const QString & /*ipAddress*/, const QString & /*userName*/)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* @brief Seconds remaining before the login rate limit for the (address, username) pair lifts.
|
||||||
|
* Returns 0 if not limited.
|
||||||
|
*/
|
||||||
|
virtual int loginRateLimitRemainingSeconds(const QString & /*ipAddress*/, const QString & /*userName*/)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
/** @brief Clear any failed-login lockout for the (address, username) pair, e.g. after a successful login. */
|
||||||
|
virtual void clearFailedLogins(const QString & /*ipAddress*/, const QString & /*userName*/)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
Server_DatabaseInterface *getDatabaseInterface() const;
|
Server_DatabaseInterface *getDatabaseInterface() const;
|
||||||
int getNextLocalGameId()
|
int getNextLocalGameId()
|
||||||
|
|
|
||||||
|
|
@ -592,6 +592,17 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd
|
||||||
QString reasonStr;
|
QString reasonStr;
|
||||||
int banSecondsLeft = 0;
|
int banSecondsLeft = 0;
|
||||||
QString connectionType = getConnectionType();
|
QString connectionType = getConnectionType();
|
||||||
|
// Throttle before doing any work: a locked-out address must not get a full
|
||||||
|
// database round trip and password verification on every attempt.
|
||||||
|
if (server->isLoginRateLimited(getAddress(), userName)) {
|
||||||
|
int remaining = server->loginRateLimitRemainingSeconds(getAddress(), userName);
|
||||||
|
if (remaining > 0) {
|
||||||
|
auto *re = new Response_Login;
|
||||||
|
re->set_denied_end_time(QDateTime::currentDateTime().addSecs(remaining).toSecsSinceEpoch());
|
||||||
|
rc.setResponseExtension(re);
|
||||||
|
}
|
||||||
|
return Response::RespTooManyRequests;
|
||||||
|
}
|
||||||
AuthenticationResult res = server->loginUser(this, userName, password, needsHash, reasonStr, banSecondsLeft,
|
AuthenticationResult res = server->loginUser(this, userName, password, needsHash, reasonStr, banSecondsLeft,
|
||||||
clientId, clientVersion, connectionType);
|
clientId, clientVersion, connectionType);
|
||||||
switch (res) {
|
switch (res) {
|
||||||
|
|
@ -605,10 +616,20 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd
|
||||||
return Response::RespUserIsBanned;
|
return Response::RespUserIsBanned;
|
||||||
}
|
}
|
||||||
case NotLoggedIn:
|
case NotLoggedIn:
|
||||||
|
if (server->recordFailedLogin(getAddress(), userName)) {
|
||||||
|
int remaining = server->loginRateLimitRemainingSeconds(getAddress(), userName);
|
||||||
|
if (remaining > 0) {
|
||||||
|
auto *re = new Response_Login;
|
||||||
|
re->set_denied_end_time(QDateTime::currentDateTime().addSecs(remaining).toSecsSinceEpoch());
|
||||||
|
rc.setResponseExtension(re);
|
||||||
|
}
|
||||||
|
return Response::RespTooManyRequests;
|
||||||
|
}
|
||||||
return Response::RespWrongPassword;
|
return Response::RespWrongPassword;
|
||||||
case WouldOverwriteOldSession:
|
case WouldOverwriteOldSession:
|
||||||
return Response::RespWouldOverwriteOldSession;
|
return Response::RespWouldOverwriteOldSession;
|
||||||
case UsernameInvalid: {
|
case UsernameInvalid: {
|
||||||
|
server->recordFailedLogin(getAddress(), userName);
|
||||||
auto *re = new Response_Login;
|
auto *re = new Response_Login;
|
||||||
re->set_denied_reason_str(reasonStr.toStdString());
|
re->set_denied_reason_str(reasonStr.toStdString());
|
||||||
rc.setResponseExtension(re);
|
rc.setResponseExtension(re);
|
||||||
|
|
@ -619,10 +640,12 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd
|
||||||
case ClientIdRequired:
|
case ClientIdRequired:
|
||||||
return Response::RespClientIdRequired;
|
return Response::RespClientIdRequired;
|
||||||
case UserIsInactive:
|
case UserIsInactive:
|
||||||
|
server->recordFailedLogin(getAddress(), userName);
|
||||||
return Response::RespAccountNotActivated;
|
return Response::RespAccountNotActivated;
|
||||||
case PasswordChangeRequired:
|
case PasswordChangeRequired:
|
||||||
return Response::RespPasswordChangeRequired;
|
return Response::RespPasswordChangeRequired;
|
||||||
default:
|
default:
|
||||||
|
server->clearFailedLogins(getAddress(), userName);
|
||||||
authState = res;
|
authState = res;
|
||||||
usingRealPassword = needsHash;
|
usingRealPassword = needsHash;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,3 +24,5 @@ target_include_directories(
|
||||||
libcockatrice_protocol PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
|
libcockatrice_protocol PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}
|
||||||
${CMAKE_CURRENT_BINARY_DIR} # points to the generated headers
|
${CMAKE_CURRENT_BINARY_DIR} # points to the generated headers
|
||||||
)
|
)
|
||||||
|
|
||||||
|
target_precompile_headers(libcockatrice_protocol PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,7 @@ set(PROTO_FILES
|
||||||
command_report_details.proto
|
command_report_details.proto
|
||||||
command_report_list.proto
|
command_report_list.proto
|
||||||
command_report_my_list.proto
|
command_report_my_list.proto
|
||||||
|
command_report_reopen.proto
|
||||||
command_report_resolve.proto
|
command_report_resolve.proto
|
||||||
command_report_stats.proto
|
command_report_stats.proto
|
||||||
command_report_user_info.proto
|
command_report_user_info.proto
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
syntax = "proto2";
|
||||||
|
import "moderator_commands.proto";
|
||||||
|
|
||||||
|
message Command_ReportReopen {
|
||||||
|
extend ModeratorCommand {
|
||||||
|
optional Command_ReportReopen ext = 1206;
|
||||||
|
}
|
||||||
|
required int32 report_id = 1;
|
||||||
|
optional string reopen_note = 2;
|
||||||
|
}
|
||||||
|
|
@ -26,6 +26,7 @@ message ModeratorCommand {
|
||||||
REPLAY_DOWNLOAD_BY_GAME_ID = 1203;
|
REPLAY_DOWNLOAD_BY_GAME_ID = 1203;
|
||||||
REPORT_USER_INFO = 1204;
|
REPORT_USER_INFO = 1204;
|
||||||
REPORT_STATS = 1205;
|
REPORT_STATS = 1205;
|
||||||
|
REPORT_REOPEN = 1206;
|
||||||
}
|
}
|
||||||
extensions 100 to max;
|
extensions 100 to max;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,4 +33,7 @@ message ServerInfo_Report {
|
||||||
optional string chat_log = 14;
|
optional string chat_log = 14;
|
||||||
|
|
||||||
optional string resolution_note = 15;
|
optional string resolution_note = 15;
|
||||||
|
optional string resolved_by_name = 16;
|
||||||
|
|
||||||
|
optional int32 reported_user_level = 17;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@ add_library(
|
||||||
libcockatrice/rng/sfmt/SFMT.c
|
libcockatrice/rng/sfmt/SFMT.c
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# SFMT is vendored and its own headers trip UBSan's alignment and vptr checks.
|
||||||
|
cockatrice_disable_sanitizers_for(libcockatrice/rng/sfmt/SFMT.c)
|
||||||
|
cockatrice_disable_coverage_for(libcockatrice/rng/sfmt/SFMT.c)
|
||||||
|
|
||||||
target_include_directories(libcockatrice_rng PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
target_include_directories(libcockatrice_rng PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
|
||||||
|
|
||||||
target_link_libraries(libcockatrice_rng PUBLIC ${QT_CORE_MODULE})
|
target_link_libraries(libcockatrice_rng PUBLIC ${QT_CORE_MODULE})
|
||||||
|
|
|
||||||
|
|
@ -71,3 +71,5 @@ target_include_directories(
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(libcockatrice_settings PUBLIC libcockatrice_card libcockatrice_utility ${QT_CORE_MODULE})
|
target_link_libraries(libcockatrice_settings PUBLIC libcockatrice_card libcockatrice_utility ${QT_CORE_MODULE})
|
||||||
|
|
||||||
|
target_precompile_headers(libcockatrice_settings PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@ void CardDatabaseSettings::saveSets(const QVector<ICardSetPriorityController::Se
|
||||||
s.beginGroup(entry.shortName);
|
s.beginGroup(entry.shortName);
|
||||||
s.setValue("sortKey", entry.sortKey);
|
s.setValue("sortKey", entry.sortKey);
|
||||||
s.setValue("enabled", entry.enabled);
|
s.setValue("enabled", entry.enabled);
|
||||||
|
s.setValue("isKnown", entry.isKnown);
|
||||||
s.endGroup();
|
s.endGroup();
|
||||||
}
|
}
|
||||||
s.endGroup();
|
s.endGroup();
|
||||||
|
|
@ -121,5 +122,6 @@ void CardDatabaseSettings::saveSets(const QVector<ICardSetPriorityController::Se
|
||||||
SetOptions &o = setOptionsCache[entry.shortName];
|
SetOptions &o = setOptionsCache[entry.shortName];
|
||||||
o.sortKey = entry.sortKey;
|
o.sortKey = entry.sortKey;
|
||||||
o.enabled = entry.enabled;
|
o.enabled = entry.enabled;
|
||||||
|
o.isKnown = entry.isKnown;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
#include "libcockatrice/settings/settings_manager.h"
|
#include "libcockatrice/settings/settings_manager.h"
|
||||||
|
|
||||||
|
#include <QDir>
|
||||||
|
#include <QStringList>
|
||||||
#include <QVariant>
|
#include <QVariant>
|
||||||
|
|
||||||
class QObject;
|
class QObject;
|
||||||
|
|
@ -121,3 +123,52 @@ void PathsSettings::setSpoilerDatabasePath(const QString &_spoilerDatabasePath)
|
||||||
setValue(_spoilerDatabasePath, "spoilerDatabase");
|
setValue(_spoilerDatabasePath, "spoilerDatabase");
|
||||||
emit cardDatabasePathChanged();
|
emit cardDatabasePathChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
QStringList PathsSettings::pathsInsideDir(const QString &baseDir) const
|
||||||
|
{
|
||||||
|
QStringList offenders;
|
||||||
|
const auto check = [&offenders, &baseDir](const QString &path) {
|
||||||
|
if (!path.isEmpty() && isInsideDir(path, baseDir)) {
|
||||||
|
offenders.append(QDir::cleanPath(path));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
check(getDeckPath());
|
||||||
|
check(getFiltersPath());
|
||||||
|
check(getReplaysPath());
|
||||||
|
check(getPicsPath());
|
||||||
|
check(getCustomPicsPath());
|
||||||
|
check(getThemesPath());
|
||||||
|
check(getCardDatabasePath());
|
||||||
|
check(getCustomCardDatabasePath());
|
||||||
|
check(getTokenDatabasePath());
|
||||||
|
check(getSpoilerCardDatabasePath());
|
||||||
|
check(getRedirectCachePath());
|
||||||
|
|
||||||
|
offenders.removeDuplicates();
|
||||||
|
return offenders;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PathsSettings::isInsideDir(const QString &path, const QString &baseDir)
|
||||||
|
{
|
||||||
|
#if defined(Q_OS_WIN) || defined(Q_OS_MACOS)
|
||||||
|
// Windows and default macOS volumes are case-insensitive filesystems.
|
||||||
|
constexpr Qt::CaseSensitivity caseSensitivity = Qt::CaseInsensitive;
|
||||||
|
#else
|
||||||
|
constexpr Qt::CaseSensitivity caseSensitivity = Qt::CaseSensitive;
|
||||||
|
#endif
|
||||||
|
const QString base = QDir::cleanPath(baseDir);
|
||||||
|
const QString cleaned = QDir::cleanPath(path);
|
||||||
|
const QString prefix = base.endsWith(QLatin1Char('/')) ? base : base + QLatin1Char('/');
|
||||||
|
return cleaned == base || cleaned.startsWith(prefix, caseSensitivity);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool PathsSettings::getAppDirWarningAcknowledged() const
|
||||||
|
{
|
||||||
|
return getValue("appDirWarningAcknowledged").toBool();
|
||||||
|
}
|
||||||
|
|
||||||
|
void PathsSettings::setAppDirWarningAcknowledged(bool _appDirWarningAcknowledged)
|
||||||
|
{
|
||||||
|
setValue(_appDirWarningAcknowledged, "appDirWarningAcknowledged");
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
#include "settings_manager.h"
|
#include "settings_manager.h"
|
||||||
|
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
#include <QStringList>
|
||||||
#include <libcockatrice/interfaces/interface_paths_settings_provider.h>
|
#include <libcockatrice/interfaces/interface_paths_settings_provider.h>
|
||||||
#include <qtmetamacros.h>
|
#include <qtmetamacros.h>
|
||||||
|
|
||||||
|
|
@ -27,6 +28,24 @@ public:
|
||||||
[[nodiscard]] QString getSpoilerCardDatabasePath() const override;
|
[[nodiscard]] QString getSpoilerCardDatabasePath() const override;
|
||||||
[[nodiscard]] QString getRedirectCachePath() const override;
|
[[nodiscard]] QString getRedirectCachePath() const override;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Returns the configured paths that resolve inside baseDir.
|
||||||
|
*
|
||||||
|
* Comparison is lexical (QDir::cleanPath); symlinks are not resolved, so a
|
||||||
|
* path that only reaches baseDir through a symlink is not reported.
|
||||||
|
*/
|
||||||
|
[[nodiscard]] QStringList pathsInsideDir(const QString &baseDir) const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Whether path resolves inside baseDir, as a single-path check.
|
||||||
|
*
|
||||||
|
* A path counts as inside when it equals baseDir or is nested below it;
|
||||||
|
* a sibling sharing the name prefix (baseDir + "2") does not.
|
||||||
|
*/
|
||||||
|
[[nodiscard]] static bool isInsideDir(const QString &path, const QString &baseDir);
|
||||||
|
|
||||||
|
[[nodiscard]] bool getAppDirWarningAcknowledged() const;
|
||||||
|
|
||||||
void setDeckPath(const QString &_deckPath);
|
void setDeckPath(const QString &_deckPath);
|
||||||
void setFiltersPath(const QString &_filtersPath);
|
void setFiltersPath(const QString &_filtersPath);
|
||||||
void setReplaysPath(const QString &_replaysPath);
|
void setReplaysPath(const QString &_replaysPath);
|
||||||
|
|
@ -37,14 +56,17 @@ public:
|
||||||
void setCustomCardDatabasePath(const QString &_customCardDatabasePath);
|
void setCustomCardDatabasePath(const QString &_customCardDatabasePath);
|
||||||
void setTokenDatabasePath(const QString &_tokenDatabasePath);
|
void setTokenDatabasePath(const QString &_tokenDatabasePath);
|
||||||
void setSpoilerDatabasePath(const QString &_spoilerDatabasePath);
|
void setSpoilerDatabasePath(const QString &_spoilerDatabasePath);
|
||||||
|
void setAppDirWarningAcknowledged(bool _appDirWarningAcknowledged);
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void cardDatabasePathChanged();
|
void cardDatabasePathChanged();
|
||||||
void picsPathChanged();
|
void picsPathChanged();
|
||||||
void themeChanged();
|
void themeChanged();
|
||||||
|
|
||||||
private:
|
public:
|
||||||
explicit PathsSettings(const QString &settingPath, QObject *parent = nullptr);
|
explicit PathsSettings(const QString &settingPath, QObject *parent = nullptr);
|
||||||
|
|
||||||
|
private:
|
||||||
PathsSettings(const PathsSettings & /*other*/);
|
PathsSettings(const PathsSettings & /*other*/);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -642,6 +642,34 @@ static void migrateLegacyMessages(const QString &settingsPath)
|
||||||
legacySetting.endGroup();
|
legacySetting.endGroup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* gamefilters.ini keys renamed to camelCase by #7084. Profiles that migrated from the legacy
|
||||||
|
* native store before that rename still hold the old snake_case keys, which nothing reads
|
||||||
|
* anymore, so their saved filters silently stopped loading.
|
||||||
|
*/
|
||||||
|
static const QMap<QString, QString> &renamedGameFilterKeys()
|
||||||
|
{
|
||||||
|
static const QMap<QString, QString> keyMap = {
|
||||||
|
{"hide_buddies_only_games", "hideBuddiesOnlyGames"},
|
||||||
|
{"hide_full_games", "hideFullGames"},
|
||||||
|
{"hide_games_that_started", "hideGamesThatStarted"},
|
||||||
|
{"hide_password_protected_games", "hidePasswordProtectedGames"},
|
||||||
|
{"hide_ignored_user_games", "hideIgnoredUserGames"},
|
||||||
|
{"hide_not_buddy_created_games", "hideNotBuddyCreatedGames"},
|
||||||
|
{"hide_open_decklist_games", "hideOpenDecklistGames"},
|
||||||
|
{"game_name_filter", "gameNameFilter"},
|
||||||
|
{"creator_name_filter", "hostNameFilter"},
|
||||||
|
{"min_players", "minPlayers"},
|
||||||
|
{"max_players", "maxPlayers"},
|
||||||
|
{"max_game_age_time", "maxGameAgeTime"},
|
||||||
|
{"show_only_if_spectators_can_watch", "showOnlyIfSpectatorsCanWatch"},
|
||||||
|
{"show_spectator_password_protected", "showSpectatorPasswordProtected"},
|
||||||
|
{"show_only_if_spectators_can_chat", "showOnlyIfSpectatorsCanChat"},
|
||||||
|
{"show_only_if_spectators_can_see_hands", "showOnlyIfSpectatorsCanSeeHands"},
|
||||||
|
};
|
||||||
|
return keyMap;
|
||||||
|
}
|
||||||
|
|
||||||
static void migrateLegacyGameFilters(const QString &settingsPath)
|
static void migrateLegacyGameFilters(const QString &settingsPath)
|
||||||
{
|
{
|
||||||
QSettings legacySetting;
|
QSettings legacySetting;
|
||||||
|
|
@ -652,24 +680,7 @@ static void migrateLegacyGameFilters(const QString &settingsPath)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const QMap<QString, QString> filterKeyMap = {
|
const QMap<QString, QString> &filterKeyMap = renamedGameFilterKeys();
|
||||||
{"hide_buddies_only_games", "hideBuddiesOnlyGames"},
|
|
||||||
{"hide_full_games", "hideFullGames"},
|
|
||||||
{"hide_games_that_started", "hideGamesThatStarted"},
|
|
||||||
{"hide_password_protected_games", "hidePasswordProtectedGames"},
|
|
||||||
{"hide_ignored_user_games", "hideIgnoredUserGames"},
|
|
||||||
{"hide_not_buddy_created_games", "hideNotBuddyCreatedGames"},
|
|
||||||
{"hide_open_decklist_games", "hideOpenDecklistGames"},
|
|
||||||
{"game_name_filter", "gameNameFilter"},
|
|
||||||
{"creator_name_filter", "creatorNameFilter"},
|
|
||||||
{"min_players", "minPlayers"},
|
|
||||||
{"max_players", "maxPlayers"},
|
|
||||||
{"max_game_age_time", "maxGameAgeTime"},
|
|
||||||
{"show_only_if_spectators_can_watch", "showOnlyIfSpectatorsCanWatch"},
|
|
||||||
{"show_spectator_password_protected", "showSpectatorPasswordProtected"},
|
|
||||||
{"show_only_if_spectators_can_chat", "showOnlyIfSpectatorsCanChat"},
|
|
||||||
{"show_only_if_spectators_can_see_hands", "showOnlyIfSpectatorsCanSeeHands"},
|
|
||||||
};
|
|
||||||
|
|
||||||
QSettings filtersIni(settingsPath + "gamefilters.ini", QSettings::IniFormat);
|
QSettings filtersIni(settingsPath + "gamefilters.ini", QSettings::IniFormat);
|
||||||
for (auto it = filterKeyMap.constBegin(); it != filterKeyMap.constEnd(); ++it) {
|
for (auto it = filterKeyMap.constBegin(); it != filterKeyMap.constEnd(); ++it) {
|
||||||
|
|
@ -686,6 +697,68 @@ static void migrateLegacyGameFilters(const QString &settingsPath)
|
||||||
legacySetting.endGroup();
|
legacySetting.endGroup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static const QString LEGACY_GAME_FILTERS_GAME_TYPE_PREFIX = QStringLiteral("filter_games/game_type/");
|
||||||
|
static const QString GAME_FILTERS_GAME_TYPE_PREFIX = QStringLiteral("filter_games/gameType/");
|
||||||
|
|
||||||
|
static bool gameFiltersCamelCaseMigrationPending(const QString &settingsPath)
|
||||||
|
{
|
||||||
|
if (!QFile::exists(settingsPath + "gamefilters.ini")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QSettings filtersIni(settingsPath + "gamefilters.ini", QSettings::IniFormat);
|
||||||
|
const QMap<QString, QString> &keyMap = renamedGameFilterKeys();
|
||||||
|
for (auto it = keyMap.constBegin(); it != keyMap.constEnd(); ++it) {
|
||||||
|
if (filtersIni.contains("filter_games/" + it.key()) && !filtersIni.contains("filter_games/" + it.value())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Orphaned by #7084, which migrated the legacy host-name filter to a key nothing reads.
|
||||||
|
if (filtersIni.contains("filter_games/creatorNameFilter") && !filtersIni.contains("filter_games/hostNameFilter")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
for (const auto &key : filtersIni.allKeys()) {
|
||||||
|
if (key.startsWith(LEGACY_GAME_FILTERS_GAME_TYPE_PREFIX) &&
|
||||||
|
!filtersIni.contains(GAME_FILTERS_GAME_TYPE_PREFIX +
|
||||||
|
key.mid(LEGACY_GAME_FILTERS_GAME_TYPE_PREFIX.size()))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SettingsMigration::migrateGameFiltersToCamelCase(const QString &settingsPath)
|
||||||
|
{
|
||||||
|
if (!gameFiltersCamelCaseMigrationPending(settingsPath)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
QSettings filtersIni(settingsPath + "gamefilters.ini", QSettings::IniFormat);
|
||||||
|
const QMap<QString, QString> &keyMap = renamedGameFilterKeys();
|
||||||
|
for (auto it = keyMap.constBegin(); it != keyMap.constEnd(); ++it) {
|
||||||
|
const QString oldKey = "filter_games/" + it.key();
|
||||||
|
const QString newKey = "filter_games/" + it.value();
|
||||||
|
if (filtersIni.contains(oldKey) && !filtersIni.contains(newKey)) {
|
||||||
|
filtersIni.setValue(newKey, filtersIni.value(oldKey));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (filtersIni.contains("filter_games/creatorNameFilter") && !filtersIni.contains("filter_games/hostNameFilter")) {
|
||||||
|
filtersIni.setValue("filter_games/hostNameFilter", filtersIni.value("filter_games/creatorNameFilter"));
|
||||||
|
}
|
||||||
|
for (const auto &key : filtersIni.allKeys()) {
|
||||||
|
if (key.startsWith(LEGACY_GAME_FILTERS_GAME_TYPE_PREFIX)) {
|
||||||
|
const QString newKey = GAME_FILTERS_GAME_TYPE_PREFIX + key.mid(LEGACY_GAME_FILTERS_GAME_TYPE_PREFIX.size());
|
||||||
|
if (!filtersIni.contains(newKey)) {
|
||||||
|
filtersIni.setValue(newKey, filtersIni.value(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The old keys are deliberately kept: pre-#7084 builds still read them, so removing them
|
||||||
|
// would break downgrades, and nothing in the current code reads them anymore.
|
||||||
|
filtersIni.sync();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
bool SettingsMigration::migrateLegacySettings(const QString &settingsPath)
|
bool SettingsMigration::migrateLegacySettings(const QString &settingsPath)
|
||||||
{
|
{
|
||||||
QSettings personalIni(settingsPath + "personal.ini", QSettings::IniFormat);
|
QSettings personalIni(settingsPath + "personal.ini", QSettings::IniFormat);
|
||||||
|
|
@ -744,6 +817,10 @@ bool SettingsMigration::isMigrationPending(const QString &settingsPath)
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (gameFiltersCamelCaseMigrationPending(settingsPath)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
QSettings personalIni(settingsPath + "personal.ini", QSettings::IniFormat);
|
QSettings personalIni(settingsPath + "personal.ini", QSettings::IniFormat);
|
||||||
if (personalIni.value("migration/legacy_complete", false).toBool()) {
|
if (personalIni.value("migration/legacy_complete", false).toBool()) {
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,15 @@ public:
|
||||||
static bool migrateSettingsFromGlobalIni(const QString &settingsPath);
|
static bool migrateSettingsFromGlobalIni(const QString &settingsPath);
|
||||||
static bool migrateLegacySettings(const QString &settingsPath);
|
static bool migrateLegacySettings(const QString &settingsPath);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves pre-camelCase gamefilters.ini keys (snake_case, game_type/<hash>) to the camelCase
|
||||||
|
* keys used since #7084, for profiles that already migrated before that rename and therefore
|
||||||
|
* never pass through migrateLegacySettings again. Copies values only where the new key is
|
||||||
|
* absent, never deletes the old keys, and returns true when anything was copied. Idempotent,
|
||||||
|
* so it runs on every startup.
|
||||||
|
*/
|
||||||
|
static bool migrateGameFiltersToCamelCase(const QString &settingsPath);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true when at least one of the two migrations would still do work, i.e. when a
|
* Returns true when at least one of the two migrations would still do work, i.e. when a
|
||||||
* backup of the settings directory is worth taking before they run.
|
* backup of the settings directory is worth taking before they run.
|
||||||
|
|
|
||||||
|
|
@ -49,3 +49,5 @@ target_link_libraries(libcockatrice_utility PUBLIC libcockatrice_rng OpenSSL::Cr
|
||||||
set(ORACLE_LIBS)
|
set(ORACLE_LIBS)
|
||||||
|
|
||||||
include_directories(${${COCKATRICE_QT_VERSION_NAME}Core_INCLUDE_DIRS})
|
include_directories(${${COCKATRICE_QT_VERSION_NAME}Core_INCLUDE_DIRS})
|
||||||
|
|
||||||
|
target_precompile_headers(libcockatrice_utility PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h")
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,9 @@ if(ZLIB_FOUND)
|
||||||
include_directories(${ZLIB_INCLUDE_DIRS})
|
include_directories(${ZLIB_INCLUDE_DIRS})
|
||||||
add_definitions("-DHAS_ZLIB")
|
add_definitions("-DHAS_ZLIB")
|
||||||
list(APPEND oracle_SOURCES src/zip/unzip.cpp src/zip/zipglobal.cpp)
|
list(APPEND oracle_SOURCES src/zip/unzip.cpp src/zip/zipglobal.cpp)
|
||||||
|
# minizip and zlib are vendored.
|
||||||
|
cockatrice_disable_sanitizers_for(src/zip/unzip.cpp src/zip/zipglobal.cpp)
|
||||||
|
cockatrice_disable_coverage_for(src/zip/unzip.cpp src/zip/zipglobal.cpp)
|
||||||
else()
|
else()
|
||||||
message(STATUS "Oracle: zlib not found; ZIP support disabled")
|
message(STATUS "Oracle: zlib not found; ZIP support disabled")
|
||||||
endif()
|
endif()
|
||||||
|
|
@ -89,6 +92,9 @@ if(LIBLZMA_FOUND)
|
||||||
include_directories(${LIBLZMA_INCLUDE_DIRS})
|
include_directories(${LIBLZMA_INCLUDE_DIRS})
|
||||||
add_definitions("-DHAS_LZMA")
|
add_definitions("-DHAS_LZMA")
|
||||||
list(APPEND oracle_SOURCES src/lzma/decompress.cpp)
|
list(APPEND oracle_SOURCES src/lzma/decompress.cpp)
|
||||||
|
# The LZMA SDK is vendored.
|
||||||
|
cockatrice_disable_sanitizers_for(src/lzma/decompress.cpp)
|
||||||
|
cockatrice_disable_coverage_for(src/lzma/decompress.cpp)
|
||||||
else()
|
else()
|
||||||
message(STATUS "Oracle: LibLZMA not found; xz support disabled")
|
message(STATUS "Oracle: LibLZMA not found; xz support disabled")
|
||||||
endif()
|
endif()
|
||||||
|
|
|
||||||
|
|
@ -85,5 +85,8 @@ int main(int argc, char *argv[])
|
||||||
QTimer::singleShot(0, &wizard, [&wizard]() { wizard.runInBackground(); });
|
QTimer::singleShot(0, &wizard, [&wizard]() { wizard.runInBackground(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
return app.exec();
|
const int execResult = app.exec();
|
||||||
|
// A cancel only counts when nothing was written yet: closing the wizard
|
||||||
|
// after the card database was already saved on disk is still an update.
|
||||||
|
return (wizard.wasCancelled() && !wizard.hasSavedDatabase()) ? 1 : execResult;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -128,12 +128,20 @@ void OracleWizard::accept()
|
||||||
|
|
||||||
void OracleWizard::reject()
|
void OracleWizard::reject()
|
||||||
{
|
{
|
||||||
|
cancelled = true;
|
||||||
|
|
||||||
// The wizard is being closed while a page may still run a worker on the
|
// The wizard is being closed while a page may still run a worker on the
|
||||||
// importer. Ask it to stop before the wizard (and the importer child) is
|
// importer. Ask it to stop before the wizard (and the importer child) is
|
||||||
// destroyed, so the worker thread never touches freed memory.
|
// destroyed, so the worker thread never touches freed memory.
|
||||||
if (auto *active = dynamic_cast<OracleWizardPage *>(currentPage())) {
|
if (auto *active = dynamic_cast<OracleWizardPage *>(currentPage())) {
|
||||||
active->cancelWork();
|
active->cancelWork();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const auto replies = nam->findChildren<QNetworkReply *>();
|
||||||
|
for (QNetworkReply *reply : replies) {
|
||||||
|
reply->abort();
|
||||||
|
}
|
||||||
|
|
||||||
QWizard::reject();
|
QWizard::reject();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue