diff --git a/.ci/Arch/Dockerfile b/.ci/Arch/Dockerfile index b08e568f3..4dc2f5e90 100644 --- a/.ci/Arch/Dockerfile +++ b/.ci/Arch/Dockerfile @@ -1,9 +1,9 @@ -from archlinux:latest +FROM archlinux:latest RUN pacman --sync --refresh --sysupgrade --needed --noconfirm \ - base-devel \ ccache \ cmake \ + gcc \ git \ gtest \ mariadb-libs \ diff --git a/.ci/Debian12/Dockerfile b/.ci/Debian12/Dockerfile index 4da910dc0..03f33b38e 100644 --- a/.ci/Debian12/Dockerfile +++ b/.ci/Debian12/Dockerfile @@ -1,16 +1,17 @@ -FROM debian:12 +FROM debian:12-slim RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - build-essential \ ca-certificates \ ccache \ cmake \ + dpkg-dev \ file \ g++ \ git \ libcurl4-openssl-dev \ libgl-dev \ + libgtest-dev \ liblzma-dev \ libmariadb-dev-compat \ libprotobuf-dev \ diff --git a/.ci/Debian13/Dockerfile b/.ci/Debian13/Dockerfile index 94f711398..8ee8cccff 100644 --- a/.ci/Debian13/Dockerfile +++ b/.ci/Debian13/Dockerfile @@ -1,16 +1,17 @@ -FROM debian:13 +FROM debian:13-slim RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - build-essential \ ca-certificates \ ccache \ cmake \ + dpkg-dev \ file \ g++ \ git \ libcurl4-openssl-dev \ libgl-dev \ + libgtest-dev \ liblzma-dev \ libmariadb-dev-compat \ libprotobuf-dev \ diff --git a/.ci/Fedora44/Dockerfile b/.ci/Fedora44/Dockerfile index 15204fa1a..d02649b4d 100644 --- a/.ci/Fedora44/Dockerfile +++ b/.ci/Fedora44/Dockerfile @@ -1,11 +1,12 @@ FROM fedora:44 -RUN dnf install -y \ +RUN dnf install -y --nodocs \ ca-certificates \ ccache \ cmake \ gcc-c++ \ git \ + gtest-devel \ libcurl-devel \ mariadb-devel \ ninja-build \ diff --git a/.ci/Fedora43/Dockerfile b/.ci/Fedora45/Dockerfile similarity index 85% rename from .ci/Fedora43/Dockerfile rename to .ci/Fedora45/Dockerfile index 8aa345651..5527aac87 100644 --- a/.ci/Fedora43/Dockerfile +++ b/.ci/Fedora45/Dockerfile @@ -1,11 +1,12 @@ -FROM fedora:43 +FROM fedora:45 -RUN dnf install -y \ +RUN dnf install -y --nodocs \ ca-certificates \ ccache \ cmake \ gcc-c++ \ git \ + gtest-devel \ libcurl-devel \ mariadb-devel \ ninja-build \ diff --git a/.ci/Servatrice_Debian12/Dockerfile b/.ci/Servatrice_Debian12/Dockerfile index f1a16843a..8c166a85b 100644 --- a/.ci/Servatrice_Debian12/Dockerfile +++ b/.ci/Servatrice_Debian12/Dockerfile @@ -1,10 +1,10 @@ -FROM debian:12 +FROM debian:12-slim RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - build-essential \ ccache \ cmake \ + dpkg-dev \ file \ g++ \ git \ diff --git a/.ci/Ubuntu24.04/Dockerfile b/.ci/Ubuntu24.04/Dockerfile index 43497dd3b..47f927f41 100644 --- a/.ci/Ubuntu24.04/Dockerfile +++ b/.ci/Ubuntu24.04/Dockerfile @@ -2,15 +2,16 @@ FROM ubuntu:24.04 RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - build-essential \ ca-certificates \ ccache \ cmake \ + dpkg-dev \ file \ g++ \ git \ libcurl4-openssl-dev \ libgl-dev \ + libgtest-dev \ liblzma-dev \ libmariadb-dev-compat \ libprotobuf-dev \ diff --git a/.ci/Ubuntu26.04/Dockerfile b/.ci/Ubuntu26.04/Dockerfile index 4dc861ae9..f50a3cec2 100644 --- a/.ci/Ubuntu26.04/Dockerfile +++ b/.ci/Ubuntu26.04/Dockerfile @@ -2,16 +2,17 @@ FROM ubuntu:26.04 RUN apt-get update && \ DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - build-essential \ ca-certificates \ ccache \ cmake \ + dpkg-dev \ file \ g++ \ gcovr \ git \ libcurl4-openssl-dev \ libgl-dev \ + libgtest-dev \ liblzma-dev \ libmariadb-dev-compat \ libprotobuf-dev \ diff --git a/.ci/compile.sh b/.ci/compile.sh index 9be8ba741..c58563f61 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -20,6 +20,7 @@ # --profile writes an MSBuild performance summary and binary log, Windows only # 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 --suffix --server --test --sanitize --coverage --ccache --dir --profile) +# boolean env flags are enabled with the value 1 (e.g. MAKE_SERVER=1) # exitcode: 1 for failure, 3 for invalid arguments # Read arguments @@ -138,17 +139,21 @@ done set -e -# Setup -./servatrice/check_schema_version.sh -if [[ ! $BUILDTYPE ]]; then +# Schema version consistency (only relevant to the server) +if [[ ${MAKE_SERVER:-} == "1" ]]; then + ./servatrice/check_schema_version.sh +fi + +# Use default values if unset +if [[ -z $BUILDTYPE ]]; then BUILDTYPE=Release fi -if [[ ! $BUILD_DIR ]]; then +if [[ -z $BUILD_DIR ]]; then BUILD_DIR="build" fi mkdir -p "$BUILD_DIR" -if [[ $MAKE_COVERAGE ]]; then +if [[ ${MAKE_COVERAGE:-} == "1" ]]; 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 @@ -163,40 +168,37 @@ export CMAKE_POLICY_VERSION_MINIMUM=3.10 # Add cmake flags flags=("-DCMAKE_BUILD_TYPE=$BUILDTYPE") -if [[ $MAKE_SERVER ]]; then +if [[ ${MAKE_SERVER:-} == "1" ]]; then flags+=("-DWITH_SERVER=1") fi -if [[ $MAKE_NO_CLIENT ]]; then +if [[ ${MAKE_NO_CLIENT:-} == "1" ]]; then flags+=("-DWITH_CLIENT=0" "-DWITH_ORACLE=0") fi -if [[ $MAKE_TEST ]]; then +if [[ ${MAKE_TEST:-} == "1" ]]; then flags+=("-DTEST=1") fi -if [[ $MAKE_SANITIZE ]]; then +if [[ ${MAKE_SANITIZE:-} == "1" ]]; 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). + # Every leg states the flag explicitly rather than relying on the CMake + # default, so a build directory reused from a sanitized configuration can + # never leak libasan/libubsan into a shipped binary. flags+=("-DENABLE_SANITIZERS=OFF") fi -if [[ $MAKE_COVERAGE ]]; then +if [[ ${MAKE_COVERAGE:-} == "1" ]]; then flags+=("-DENABLE_COVERAGE=ON") fi -if [[ $USE_CCACHE ]]; then +if [[ ${USE_CCACHE:-} == "1" ]]; then flags+=("-DUSE_CCACHE=1") - # PCH-aware caching is required or ccache refuses to cache any TU that - # consumes a precompiled header, silently recompiling everything on every run. - ccache --set-config sloppiness=pch_defines,time_macros - if [[ $CCACHE_SIZE ]]; then - # note, this setting persists after running the script + if [[ -n $CCACHE_SIZE ]]; then + # This setting persists after running the script ccache --max-size "$CCACHE_SIZE" fi fi -if [[ $PACKAGE_TYPE ]]; then +if [[ -n $PACKAGE_TYPE ]]; then flags+=("-DCPACK_GENERATOR=$PACKAGE_TYPE") fi -if [[ $USE_VCPKG ]]; then +if [[ ${USE_VCPKG:-} == "1" ]]; then flags+=("-DUSE_VCPKG=1") flags+=("-DVCPKG_INSTALL_OPTIONS=--x-abi-tools-use-exact-versions") fi @@ -206,6 +208,11 @@ if [[ ${WITH_CRASH_REPORTING:-} == "ON" ]]; then # developer builds never reach Sentry. flags+=("-DWITH_CRASH_REPORTING=ON") fi +if [[ ${COCKATRICE_DEBUG_INFO_LEVEL:-} ]]; then + # CI lowers the DWARF level so one build configuration fits its compiler-cache bound; + # see the option in CMakeLists.txt. Empty keeps the built-in flags. + flags+=("-DCOCKATRICE_DEBUG_INFO_LEVEL=$COCKATRICE_DEBUG_INFO_LEVEL") +fi if [[ ${BUILDTYPE:-Release} == "Release" && -n ${COCKATRICE_SENTRY_DSN:-} ]]; then # Release-only: Debug builds stay DSN-free so crashes from developer builds # never reach Sentry. Empty (local builds, forks without the secret, and non-tag @@ -219,7 +226,8 @@ fi buildflags=(--config "$BUILDTYPE") function ccachestatsverbose() { - # note, verbose only works on newer ccache, discard the error + ccache --version + # Verbose only works on newer ccache, discard the error local got if got="$(ccache --show-stats --verbose 2>/dev/null)"; then echo "$got" @@ -259,42 +267,44 @@ if [[ $RUNNER_OS == macOS ]]; then # this works independent of the qt version as there should be only one version installed on the runner at a time export QTDIR - if [[ $TARGET_MACOS_VERSION ]]; then - # CMAKE_OSX_DEPLOYMENT_TARGET is a vanilla cmake flag needed to compile to target macOS version + if [[ -n $TARGET_MACOS_VERSION ]]; then + # CMAKE_OSX_DEPLOYMENT_TARGET is a vanilla CMake flag needed to compile to the target macOS version flags+=("-DCMAKE_OSX_DEPLOYMENT_TARGET=$TARGET_MACOS_VERSION") - # vcpkg dependencies need a vcpkg triplet file to compile to the target macOS version - # an easy way is to copy the x64-osx.cmake file and modify it - triplets_dir="/tmp/cmake/triplets" - triplet_version="custom-triplet" - triplet_file="$triplets_dir/$triplet_version.cmake" - arch=$(uname -m) - if [[ $arch == x86_64 ]]; then - arch="x64" + if [[ ${USE_VCPKG:-} == "1" ]]; then + # vcpkg dependencies need a vcpkg triplet file to compile to the target macOS version + # An easy way is to copy the x64-osx.cmake file and modify it + triplets_dir="/tmp/cmake/triplets" + triplet_version="custom-triplet" + triplet_file="$triplets_dir/$triplet_version.cmake" + arch=$(uname -m) + if [[ $arch == x86_64 ]]; then + arch="x64" + fi + mkdir -p "$triplets_dir" + triplet_source="../vcpkg/triplets/$arch-osx.cmake" + if [[ ! -f "$triplet_source" ]]; then + triplet_source="../vcpkg/triplets/community/$arch-osx.cmake" + fi + cp "$triplet_source" "$triplet_file" + # The triplet is what vcpkg hashes into the ABI hash of every port, so two legs that + # 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_HOST_TRIPLET=$triplet_version") + flags+=("-DVCPKG_TARGET_TRIPLET=$triplet_version") + echo "::group::Generated triplet $triplet_file" + cat "$triplet_file" + echo "::endgroup::" fi - mkdir -p "$triplets_dir" - triplet_source="../vcpkg/triplets/$arch-osx.cmake" - if [[ ! -f "$triplet_source" ]]; then - triplet_source="../vcpkg/triplets/community/$arch-osx.cmake" - fi - cp "$triplet_source" "$triplet_file" - # The triplet is what vcpkg hashes into the ABI hash of every port, so two legs that - # 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_HOST_TRIPLET=$triplet_version") - flags+=("-DVCPKG_TARGET_TRIPLET=$triplet_version") - echo "::group::Generated triplet $triplet_file" - cat "$triplet_file" - echo "::endgroup::" fi - echo "::group::Signing Certificate" + echo "::group::Setup signing certificate" if [[ -n "$MACOS_CERTIFICATE_NAME" ]]; then echo "$MACOS_CERTIFICATE" | base64 --decode >"certificate.p12" security create-keychain -p "$MACOS_CI_KEYCHAIN_PWD" build.keychain @@ -309,9 +319,13 @@ if [[ $RUNNER_OS == macOS ]]; then fi echo "::endgroup::" - if [[ $MAKE_PACKAGE ]]; then - # Workaround https://github.com/actions/runner-images/issues/7522 - # have hdiutil repeat the command 10 times in hope of success. + if [[ ${MAKE_PACKAGE:-} == "1" ]]; then + # Workaround https://github.com/actions/runner-images/issues/7522: XProtect holds a + # freshly written DMG for a moment ("Resource busy"), and a mount that outlived a + # failed detach stays busy for the rest of the job. Retry every hdiutil call through + # a wrapper that widens the delay so the scan can finish, retries a detach with + # -force, and kills a wedged DiskImages helper instead of letting it hold the job + # until the 100 minute timeout. # The wrapper has to exit non-zero once it gives up: CPack reads that status to # decide whether the disk image was created, so a zero exit turns a failed hdiutil # into a much later, misleading "Problem copying the package" error that hides @@ -320,17 +334,74 @@ if [[ $RUNNER_OS == macOS ]]; then # shellcheck disable=SC2016 echo '#!/bin/bash attempts=10 - i=0 - while true; do - hdiutil "$@" && exit 0 - rc=$? - if (( ++i >= attempts )); then - echo "::error::hdiutil $1 failed $i times (last status $rc), giving up" >&2 - exit "$rc" + timeout_seconds=${HDIUTIL_TIMEOUT:-300} + subcommand=$1 + # CPACK_DMG_VOLUME_NAME in CMakeLists.txt; a -volname argument wins when there is one. + volume_name=Cockatrice + arg_count=$# + for (( i = 1; i < arg_count; i++ )); do + if [[ ${!i} == -volname ]] && (( i + 1 <= arg_count )); then + (( i++ )) + volume_name=${!i} fi - echo "hdiutil $1 attempt $i/$attempts failed (status $rc), retrying" >&2 - sleep 1 - done' >"$hdiutil_script" + done + + # macOS ships no timeout(1), so a watchdog kills the supervised call. It writes to + # /dev/null because a process holding the output pipe open would make CPack wait for + # the watchdog instead of for hdiutil; it is killed once the call returns. + run_supervised() { + "$@" & + pid=$! + ( sleep "$timeout_seconds" && kill -9 "$pid" ) >/dev/null 2>&1 & + watchdog=$! + wait "$pid" + rc=$? + kill "$watchdog" 2>/dev/null + wait "$watchdog" 2>/dev/null + return "$rc" + } + + force_detach_stale_mount() { + [[ -d /Volumes/$volume_name ]] || return 0 + echo "hdiutil $subcommand: force-detaching stale /Volumes/$volume_name" >&2 + run_supervised hdiutil detach -force "/Volumes/$volume_name" >/dev/null 2>&1 + return 0 + } + + timeouts=0 + attempt=0 + delay=1 + while (( attempt < attempts )); do + (( attempt++ )) + cmd=(hdiutil "$@") + # A busy volume is what a detach fails on, and -force is what clears it. + if (( attempt > 1 )) && [[ $subcommand == detach ]]; then + cmd=(hdiutil detach -force "${@:2}") + fi + run_supervised "${cmd[@]}" + rc=$? + (( rc == 0 )) && exit 0 + if (( rc == 137 )); then + (( timeouts++ )) + echo "hdiutil $subcommand attempt $attempt/$attempts timed out after ${timeout_seconds}s" >&2 + # A wedged DiskImages helper is not the busy-file flake: two kills are all the + # evidence the loop needs before failing is a better use of the job budget. + if (( timeouts >= 2 )); then + echo "::error::hdiutil $subcommand timed out $timeouts times, giving up" >&2 + exit "$rc" + fi + else + echo "hdiutil $subcommand attempt $attempt/$attempts failed (status $rc), retrying" >&2 + fi + (( attempt < attempts )) || break + if [[ $subcommand == create || $subcommand == attach ]]; then + force_detach_stale_mount + fi + sleep "$delay" + (( delay = delay * 2 > 30 ? 30 : delay * 2 )) + done + echo "::error::hdiutil $subcommand failed $attempt times (last status $rc), giving up" >&2 + exit "$rc"' >"$hdiutil_script" chmod +x "$hdiutil_script" flags+=(-DCPACK_COMMAND_HDIUTIL="$hdiutil_script") fi @@ -340,7 +411,7 @@ elif [[ $RUNNER_OS == Windows ]]; then # cmake only hands MSBuild a job count when one is asked for, and without it # 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 + if [[ -z $BUILD_PARALLEL_LEVEL ]]; then BUILD_PARALLEL_LEVEL="${NUMBER_OF_PROCESSORS:-1}" fi buildflags+=(--parallel "$BUILD_PARALLEL_LEVEL") @@ -366,21 +437,38 @@ elif [[ $RUNNER_OS == Windows ]]; then fi fi -if [[ $USE_CCACHE ]]; then +if [[ ${USE_CCACHE:-} == "1" ]]; then echo "::group::Show ccache stats" ccachestatsverbose echo "::endgroup::" fi -echo "::group::Configure cmake" +# Configure CMake +echo "::group::Generate build system" cmake --version -echo "Running cmake with flags: ${flags[*]}" +echo "Running CMake configuration with the following flags: ${flags[*]}" cmake .. "${flags[@]}" echo "::endgroup::" +if [[ ${USE_CCACHE:-} == "1" ]]; then + # PCH-aware caching is required or ccache refuses to cache any TU that consumes a + # precompiled header, silently recompiling everything on every run. The mtime and ctime + # entries cover files created during the build itself: the moc/uic/rcc output and the + # protobuf headers are generated after ccache starts, so without them every TU including + # one is read as a race and never cached. Checked-out sources are older than the compile + # that follows them and do not trip the check. + # + # This has to run after configure: CMakeLists.txt sets sloppiness=pch_defines,time_macros + # at configure time, and --set-config replaces the whole list rather than extending it, so + # setting it earlier would leave the build below with two entries. Re-asserting all four + # here is what actually reaches the compiler; the order is the point. + ccache --set-config sloppiness=pch_defines,time_macros,include_file_mtime,include_file_ctime +fi + +# Build echo "::group::Build project" cpuinfo -echo "Running cmake --build with flags: ${buildflags[*]}" +echo "Running CMake with the following build flags: ${buildflags[*]}" cmake --build . "${buildflags[@]}" echo "::endgroup::" @@ -392,16 +480,16 @@ if [[ $BUILD_PROFILE && $RUNNER_OS == Windows && -f msbuild-perf.log ]]; then echo "::endgroup::" fi -if [[ $USE_CCACHE ]]; then - if [[ $CCACHE_EVICTION_AGE ]]; then - echo "::group::evict ccache files older than $CCACHE_EVICTION_AGE" +if [[ ${USE_CCACHE:-} == "1" ]]; then + if [[ -n $CCACHE_EVICTION_AGE ]]; then + echo "::group::Evict ccache files older than $CCACHE_EVICTION_AGE" ccache --evict-older-than "$CCACHE_EVICTION_AGE" echo "::endgroup::" fi echo "::group::Show ccache stats again" ccachestatsverbose echo "::endgroup::" -elif [[ $CCACHE_EVICTION_AGE ]]; then +elif [[ -n $CCACHE_EVICTION_AGE ]]; then echo "::error file=$0::ccache eviction is enabled while ccache is disabled!" fi @@ -418,9 +506,9 @@ if [[ $RUNNER_OS == macOS ]]; then echo "::endgroup::" fi -if [[ $MAKE_TEST ]]; then +if [[ ${MAKE_TEST:-} == "1" ]]; then echo "::group::Run tests" - if [[ $MAKE_SANITIZE ]]; then + if [[ ${MAKE_SANITIZE:-} == "1" ]]; 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 @@ -438,11 +526,12 @@ if [[ $MAKE_TEST ]]; then # 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 --version ctest -C "$BUILDTYPE" --output-on-failure -j "${CTEST_JOBS:-4}" echo "::endgroup::" fi -if [[ $MAKE_COVERAGE ]]; then +if [[ ${MAKE_COVERAGE:-} == "1" ]]; 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" @@ -467,7 +556,7 @@ if [[ $MAKE_COVERAGE ]]; then echo "::endgroup::" fi -if [[ $RUNNER_OS == Windows && ! $MAKE_NO_CLIENT ]]; then +if [[ $RUNNER_OS == Windows && ${MAKE_NO_CLIENT:-} != "1" ]]; then echo "::group::Probe the installer's holder lookup" # The working directory is still the build dir here: the package steps below are what # leave and re-enter it, and that is what made a $BUILD_DIR-prefixed path name @@ -667,18 +756,21 @@ POWERSHELL echo "::endgroup::" fi -if [[ $MAKE_INSTALL ]]; then +# Install +if [[ ${MAKE_INSTALL:-} == "1" ]]; then echo "::group::Install" cmake --build . --target install --config "$BUILDTYPE" echo "::endgroup::" fi -if [[ $MAKE_PACKAGE ]]; then +# Package +if [[ ${MAKE_PACKAGE:-} == "1" ]]; then echo "::group::Create package" + cpack --version cmake --build . --target package --config "$BUILDTYPE" echo "::endgroup::" - if [[ $PACKAGE_SUFFIX ]]; then + if [[ -n $PACKAGE_SUFFIX ]]; then echo "::group::Update package name" cd .. BUILD_DIR="$BUILD_DIR" .ci/name_build.sh "$PACKAGE_SUFFIX" diff --git a/.ci/docker.sh b/.ci/docker.sh index 64ea6baa7..c78ba3c31 100644 --- a/.ci/docker.sh +++ b/.ci/docker.sh @@ -181,6 +181,11 @@ function RUN () if [[ ${WITH_CRASH_REPORTING:-} ]]; then args+=(--env "WITH_CRASH_REPORTING=${WITH_CRASH_REPORTING}") fi + # Same reason: the DWARF level reaches compile.sh only if it is handed to the container, + # and without it CI would cache objects twice the size its bounds were measured for. + if [[ ${COCKATRICE_DEBUG_INFO_LEVEL:-} ]]; then + args+=(--env "COCKATRICE_DEBUG_INFO_LEVEL=${COCKATRICE_DEBUG_INFO_LEVEL}") + fi # shellcheck disable=2086 docker run "${args[@]}" $RUN_ARGS "$IMAGE_NAME" bash "$BUILD_SCRIPT" $RUN_OPTS "$@" return $? diff --git a/.ci/release_template.md b/.ci/release_template.md index 23b475150..8eb4c7ebd 100644 --- a/.ci/release_template.md +++ b/.ci/release_template.md @@ -21,8 +21,8 @@ Available pre-compiled binaries for installation: • Ubuntu 24.04 LTS Noble Numbat • Debian 13 Trixie • Debian 12 Bookworm + • Fedora 45 • Fedora 44 - • Fedora 43 General Linux support is available via a flatpak package hosted at Flathub (x86 & ARM)! Thanks to courtesy of @FFY00, the app is also available in Arch Linux's official extra repository. diff --git a/.dockerignore b/.dockerignore index 8e0b69332..fc07b2b9d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,6 @@ build/ .github/ .tx/ cockatrice/ -doc/ +docs/ oracle/ Dockerfile diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 404cac62e..3085bde24 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -15,8 +15,11 @@ updates: # Check for updates once a month schedule: interval: "monthly" + # A new version is not considered for a version update until x days after its release (default = 3) + cooldown: + default-days: 3 # Limit the amout of open PR's (default = 5, disabled = 0, security updates are not impacted) - open-pull-requests-limit: 2 + open-pull-requests-limit: 3 # # Enable version updates for Docker # Not yet possible to bump from one LTS version to the next and skip others, see: @@ -27,6 +30,9 @@ updates: # # Check for updates once a week # schedule: # interval: "weekly" + # # A new version is not considered for a version update until x days after its release (default = 3) + # cooldown: + # default-days: 3 # # Limit the amout of open PR's (default = 5, disabled = 0, security updates are not impacted) # open-pull-requests-limit: 1 @@ -37,5 +43,8 @@ updates: # Check for updates to GitHub Actions once a week schedule: interval: "weekly" + # A new version is not considered for a version update until x days after its release (default = 3) + cooldown: + default-days: 3 # Limit the amout of open PR's (default = 5, disabled = 0, security updates are not impacted) - open-pull-requests-limit: 2 + open-pull-requests-limit: 5 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 75494dc3e..a2c460d62 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -40,10 +40,10 @@ jobs: steps: - name: "Checkout repository" - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: "Initialize CodeQL" - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -92,6 +92,6 @@ jobs: run: cmake --build build - name: "Perform CodeQL Analysis" - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@33d7b85da1a04171e211befe2c739303b9a4c066 # v4.39.3 with: category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index df054ea26..731bff41b 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -15,7 +15,7 @@ on: - '!**.md' - '!.github/**' - '!.tx/**' - - '!doc/**' + - '!docs/**' - '.github/workflows/desktop-build.yml' - 'CMakeLists.txt' - 'vcpkg.json' @@ -28,17 +28,39 @@ on: - '!**.md' - '!.github/**' - '!.tx/**' - - '!doc/**' + - '!docs/**' - '.github/workflows/desktop-build.yml' - 'CMakeLists.txt' - 'vcpkg.json' - 'vcpkg' # needed to match submodule bumps (gitlink) + # The legs the pull-request matrix leaves out run here instead, so the legacy distros and + # the slow macOS runner stay covered without gating a pull request on them. Tags still + # build every leg, because a tag is what ships. + schedule: + - cron: '23 3 * * *' # Cancel earlier, unfinished runs of this workflow on the same branch (unless on tag --> release) concurrency: - group: "${{ github.workflow }} @ ${{ github.ref_name }}" + # The event name is part of the group because a nightly run and a push run on master share + # refs/heads/master but must not cancel each other: the nightly would drop the push's vcpkg + # import, and a merge landing during the nightly would drop that night's coverage. + group: "${{ github.workflow }} @ ${{ github.ref_name }} (${{ github.event_name }})" cancel-in-progress: ${{ github.ref_type != 'tag' }} +# Qt's online installer ships its base package as separate archives. Installing only the +# ones Cockatrice loads drops the full 25+ archive download to this handful (Windows Qt +# cache: 440 MB → 180 MB, macOS thin Qt: ~200 MB → 60 MB). The addons Cockatrice also needs +# (Multimedia, WebSockets, ShaderTools, ImageFormats) are not base archives: they stay in +# each leg's qt_modules. +# +# The list mirrors what cmake/FindQtRuntime.cmake requires, so a component that is missing +# here fails configure with "No suitable version of Qt was found" instead of at runtime: +# qtbase supplies Core, Gui, Widgets, Network, Sql, Xml, Concurrent and PrintSupport; +# qtdeclarative supplies Qml, Quick and QuickWidgets; qtsvg supplies Svg; qttools supplies +# Linguist (lrelease/lupdate); qttranslations ships Qt's own catalogs. +env: + QT_ARCHIVES: qtbase qtdeclarative qtsvg qttools qttranslations + jobs: configure: name: Configure @@ -69,7 +91,7 @@ jobs: - name: "Checkout" if: steps.configure.outputs.tag != null - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 # fetch all history for all branches and tags @@ -81,7 +103,7 @@ jobs: TAG: ${{ steps.configure.outputs.tag }} run: .ci/prep_release.sh - - name: "Create release" + - name: "Create or update draft release" if: steps.configure.outputs.tag != null id: create_release shell: bash @@ -93,20 +115,42 @@ jobs: body_path: ${{ steps.prepare.outputs.body_path }} prerelease: ${{ steps.prepare.outputs.is_beta }} run: | - args=() - [[ $prerelease == yes ]] && args+=(--prerelease) + # A release for this tag may already exist: a maintainer can save a draft in + # the GitHub UI before pushing the tag, or an earlier run may have left one. + # Never touch a published release, and never overwrite a draft that already + # has a body (generated notes or a maintainer's edits) - only fill in an + # empty draft. Prints " "; empty when there is none. + release_state="$(gh release view "$tag_name" --json isDraft,body \ + --template '{{.isDraft}} {{len (or .body "")}}' 2>/dev/null || true)" - gh release create "$tag_name" --verify-tag --draft "${args[@]}" \ - --target "$target" \ - --title "$release_name" \ - --notes-file "$body_path" + if [[ -z "$release_state" ]]; then + args=() + [[ $prerelease == yes ]] && args+=(--prerelease) + + gh release create "$tag_name" --verify-tag --draft "${args[@]}" \ + --target "$target" \ + --title "$release_name" \ + --notes-file "$body_path" + elif [[ "$release_state" == "true 0" ]]; then + echo "Filling in the empty draft release $tag_name" + args=(--prerelease=false) + [[ $prerelease == yes ]] && args=(--prerelease) + + gh release edit "$tag_name" "${args[@]}" \ + --title "$release_name" \ + --notes-file "$body_path" + elif [[ "$release_state" == true* ]]; then + echo "::warning::Draft release $tag_name already has a body, leaving it untouched" + else + echo "::warning::Release $tag_name is already published, leaving it untouched" + fi vcpkg-baseline: name: vcpkg baseline runs-on: ubuntu-slim steps: - name: "Checkout" - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # vcpkg-tool reads versions/baseline.json out of the commit named by # `builtin-baseline`, through the `vcpkg` submodule's git history. A submodule @@ -141,6 +185,8 @@ jobs: allow-failure: yes package: skip # We are packaged in Arch already + debug_test: yes # see the note on Fedora 45 below + ccache_size: 850M # Debug plus the suite: 680 MB working set at -g1 - distro: Servatrice_Debian version: 12 @@ -148,78 +194,155 @@ jobs: package: DEB server_only: yes test: skip - - - distro: Debian - version: 12 - - package: DEB - test: skip # Running tests on all distros is superfluous + # 4.7 min and the server alone: nothing here for a compiler cache to save. + persist_ccache: 'false' - distro: Debian version: 13 package: DEB + ccache_size: 800M # Release plus the suite: 625 MB working set at -g1 + # The single Fedora leg used to build Debug for the suite and Release for the + # package in one job (measured on Fedora 44: 30.8 min, the only leg slower than + # Windows), so the package compile waited behind the Debug one. Two legs run in + # parallel and each keeps a cache that holds exactly one configuration. + # + # Arch and Fedora 45 are the two newest toolchains, and the -Wno-error= exceptions + # in CMakeLists.txt exist for exactly their warnings. This leg compiles Debug + # because only Debug passes -Werror, so it is where a new warning still fails. - distro: Fedora - version: 43 + version: 45 + variant: debug # separate image and ccache from the release leg below - package: RPM - test: skip # Running tests on all distros is superfluous - - - distro: Fedora - version: 44 - - package: RPM - - - distro: Ubuntu - version: 24.04 - - package: DEB - test: skip # Running tests on all distros is superfluous - - - distro: Ubuntu - version: 26.04 - - package: DEB - - # 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 + debug_test: yes + ccache_size: 850M # Debug plus the suite: 660 MB of objects at -g1 + + - distro: Fedora + version: 45 + variant: release # separate image and ccache from the debug leg above + + package: RPM + test: skip # The debug leg above runs this distro's suite + ccache_size: 800M # Release plus the package: 614 MB of objects at -g1 + + - distro: Ubuntu + version: 26.04 + + package: DEB + test: skip # Debian 13 covers the uninstrumented suite on pull requests + # 18.1 min, shorter than the Windows leg. The sanitize leg in the nightly + # matrix runs this distro's instrumented suite. + persist_ccache: 'false' name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }} needs: configure + # The nightly legs live in build-linux-nightly below. A leg is in exactly one of the two + # matrices, so nothing that reviews a change waits for a distro nobody is changing for. + if: github.event_name != 'schedule' runs-on: ubuntu-latest # https://github.com/actions/runner-images continue-on-error: ${{ matrix.allow-failure == 'yes' }} timeout-minutes: 70 env: CACHE: ${{ github.workspace }}/.cache/${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }} # directory for caching docker image and ccache 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 + # The bound is sized against the repository's 10 GB cache quota + # (https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy), + # not against what one leg would like. Measured 2026-11-07, the quota holds 2.97 GB + # of these four ccache entries, 0.92 GB of Qt, 1.24 GB of vcpkg, 0.91 GB of buildkit + # layers (mode=min in docker-release.yml) and 4.26 GB of CodeQL trap caches: one entry + # per analyzed commit, about 1.07 GB each, of which every one but the newest is dead. + # That is 10.30 GB against the 10 GB quota, so every save past this point costs some + # other entry, and least-recently-used order reaches the buildkit layers before the + # trap caches. prune-caches below keeps only the newest trap cache; the live total is + # then 7.1 GB, which is what leaves room for the Windows and macOS compiler caches. + # + # Measured 2026-11-04: one configuration costs 1.2 GB with full DWARF and 660 MB with + # -g1 (COCKATRICE_DEBUG_INFO_LEVEL below), and the size of the cache decides the hit + # rate far more sharply than its fill suggests. A 6G cache served 1059 of 1059 calls; + # the 700M bound on the same leg served 14%, because ccache fills to the limit and then + # evicts the entries the next build is about to ask for. A cache that cannot hold a + # whole configuration therefore buys almost nothing, and the quota goes to fewer legs + # at a size that holds one: + # + # Arch Debug + suite 680 MB working set 850M + # Debian 13 Release + suite 625 MB 800M + # Fedora 45 debug Debug + suite 660 MB 850M + # Fedora 45 release Release + package 614 MB 800M + # Windows 10 Release + package 890 MB working set 950M + # macOS 14/15 Release + package 0.5 GB ccache at -g1 700M + # macOS 27 Debug Debug + suite 0.5 GB ccache at -g1 700M + # + # That is 6.35 GB of bounds; what lands in the quota is 5.15 GB of compiler cache, + # 2.97 GB stored by the Linux legs, 0.95 GB by Windows and 1.23 GB by the three macOS + # legs, for 9.3 GB of 10 in total. + # So only legs worth their share get one. A leg keeps no cache when it duplicates + # another leg's coverage (Ubuntu 26.04 behind Debian 13's Release + suite) or finishes + # well inside the Windows leg (Servatrice covers the server alone in 4.7 min). The + # macOS legs compile the client with a different toolchain (Apple clang), and one + # configuration writes only ~0.5 GB, so all three fit beside the Windows entry even + # though a macOS cache served 42% and saved about 32s on a 20 min leg. The four Linux + # legs that stay compile the client on the toolchains a change can break and are the + # ones a cache shortens by the most. + # Measured job totals on master 2026-11-04 also show the ceiling this leaves: + # Windows 10 is 29.5 min and compiles for 23.7 of it, so Windows sets the + # pull-request wall clock no matter how warm these legs are. That is why the Windows + # leg keeps the largest bound (see build-vcpkg): MSVC has no -g level to shrink, and + # with /Z7 the whole configuration is cacheable, PCH included. + # + # Steady-state master data corrected this on 2026-11-07: Arch 90.1% and Debian 13 + # 96.75% per-run hits at the 850M/800M bounds, Fedora 45 debug 99.18% and release + # 99.2%. The 75%/82% seen the day before were warming runs - the -g1 switch had + # invalidated the cache one commit earlier - not thrashing at the bound. A cold 3G + # run on the same day measured one configuration at 680 MB (Arch) and 625 MB + # (Debian 13), so those bounds already hold the working set with ~170 MB of slack for + # commit-to-commit churn, and raising them buys hit rate the numbers do not show a + # need for. The three macOS entries are 700M each because one configuration wrote a + # 0.5 GB ccache directory at -g1 and level 0 shrinks it further, so the bound holds the + # whole configuration with room to spare. + # + # Re-add a leg only with a bound that holds its whole configuration: a smaller one is + # worse than none. + # + # Every key listed in LIVE_CCACHE_KEYS in prune-caches below must match this matrix. + CCACHE_SIZE: ${{ matrix.ccache_size || '700M' }} + # Debug information dominates a cached object: with the built-in flags one + # configuration is 1.2 GB and no per-leg bound can hold it, so CI lowers the level to + # 1 (file and line, which is what a failing test or a sanitizer backtrace reports) and + # one configuration halves. Empty on tags and on the nightly legs, which ship binaries + # and keep full DWARF for the Sentry symbols; a tag restores a cache it cannot use and + # is slower, which is accepted because a tag is rare and only pull requests gate. + COCKATRICE_DEBUG_INFO_LEVEL: ${{ github.ref_type != 'tag' && '1' || '' }} CMAKE_GENERATOR: 'Ninja' NAME: ${{ matrix.distro }}${{ matrix.version }} IMAGE_SUFFIX: ${{ matrix.variant }} + # Which build the suite runs in, decided once per leg here so the steps' if: conditions + # and their shells cannot drift apart. A step's own env is not visible to its if:, which + # is why these live at job level rather than on the release step. + # DEBUG_TEST the instrumented legs (symbols and instrumentation matter) and the two + # newest distro toolchains, which only fail on a warning in Debug. + # RUN_TESTS every other leg that tests runs the suite against the release build it may + # also package, so testing and packaging cost one compile instead of two. + DEBUG_TEST: ${{ matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes' }} + RUN_TESTS: ${{ matrix.test != 'skip' && !(matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes') }} + # Whether this leg restores and saves its compiler cache. Not every pull-request leg + # keeps one: a cache only pays for itself when its bound holds a whole configuration + # (see the sizing comment above), and a leg that duplicates another leg's coverage + # spends quota without adding any. The nightly-only legs keep none either. Fedora 45 + # states it per leg: its two configurations have their own job and key, so neither + # bound is shared with a build whose objects it would evict. + PERSIST_CCACHE: ${{ matrix.persist_ccache || 'true' }} - steps: + # Shared with build-linux-nightly so the two leg sets run the same steps. + steps: &linux-steps - name: "Checkout" - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: "Restore compiler cache (ccache)" id: restore_ccache - uses: actions/cache/restore@v6 + if: env.PERSIST_CCACHE == 'true' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: ccache-${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }} path: ${{ env.CACHE }}/.ccache @@ -228,8 +351,11 @@ jobs: shell: bash run: source .ci/docker.sh --build + # The instrumented legs and the two newest distro toolchains build and test Debug; every + # other leg runs its suite against the release build below, so testing and packaging cost + # one compile instead of two. - name: "Build debug and test" - if: matrix.test != 'skip' + if: env.DEBUG_TEST == 'true' shell: bash env: sanitize: '${{ matrix.sanitize }}' @@ -263,21 +389,29 @@ jobs: - name: "Upload coverage artifact" if: matrix.coverage == 'yes' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: coverage-report path: coverage/ if-no-files-found: warn - - name: "Build release package" + # One build serves both purposes: the suite runs against the same configuration that + # gets packaged, so a packaging leg no longer pays for a full Debug compile it then + # throws away. macOS and Windows have always tested their release build. + # + # Instrumented and debug-test legs reach this step only when they also package; their + # suite already ran in the debug step above, so the tests are not repeated here. + - name: "Build release, test and package" id: build - if: matrix.package != 'skip' + if: matrix.package != 'skip' || env.RUN_TESTS == 'true' shell: bash env: SUFFIX: '-${{ matrix.distro }}${{ matrix.version }}' package: '${{ matrix.package }}' server_only: '${{ matrix.server_only }}' - WITH_CRASH_REPORTING: 'ON' + # Only a leg that packages builds the shipping configuration; a release build that + # only runs tests stays free of the crash reporting SDK. + WITH_CRASH_REPORTING: ${{ matrix.package != 'skip' && 'ON' || '' }} # Tag builds only: artifacts from master pushes and PR branches must not report to # Sentry. Forks get no secrets on pull_request, so they keep the mock fallback either way. # configure.outputs.tag is set for ANY tag, so beta (Development) tags get the DSN @@ -287,6 +421,7 @@ jobs: source .ci/docker.sh args=() [[ $server_only == yes ]] && args+=(--no-client) + [[ $RUN_TESTS == true ]] && args+=(--test) [[ $GITHUB_REF == "refs/heads/master" ]] && args+=(--evict-ccache "$CCACHE_EVICTION_AGE") args+=(--ccache "$CCACHE_SIZE") args+=(--cmake-generator "$CMAKE_GENERATOR") @@ -296,7 +431,14 @@ jobs: # configures, and empty keeps the mock fallback. export RUN_ARGS="${RUN_ARGS:-} -e COCKATRICE_SENTRY_DSN" - RUN --server --release --package "$package" "${args[@]}" + # --package takes an optional type, so the flag and its argument have to travel + # together or the type would become the next flag. + package_args=() + [[ -n $package && $package != skip ]] && package_args+=(--package "$package") + + # compile.sh runs the tests before the package target, so a failing suite stops the + # leg before anything is built into an installer. + RUN --server --release "${package_args[@]}" "${args[@]}" - name: "Install sentry-cli" if: matrix.package != 'skip' && needs.configure.outputs.tag != null @@ -343,9 +485,17 @@ jobs: # comes from the client event itself (COCKATRICE_SENTRY_RELEASE). sentry-cli debug-files upload --org cockatrice --project cockatrice-desktop "${paths[@]}" + # Pull requests and tags only restore; only a push to refs/heads/master writes. A PR save + # would replace the objects the next master build restores, and a PR that compiles a + # different configuration would evict the warm set for everyone. Tags build with full + # DWARF (COCKATRICE_DEBUG_INFO_LEVEL is empty), so a tag's cache would poison the -g1 + # set if it shared the master scope; it does not, because the save is master-only. The + # nightly legs persist nothing at all (PERSIST_CCACHE is false) for the same reason: + # a schedule shares refs/heads/master with the push. + # # Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342 - name: "Delete remote compiler cache (ccache)" - if: github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit + if: github.ref == 'refs/heads/master' && env.PERSIST_CCACHE == 'true' && steps.restore_ccache.outputs.cache-hit continue-on-error: true env: CACHE_PRIMARY_KEY: ${{ steps.restore_ccache.outputs.cache-primary-key }} @@ -356,8 +506,8 @@ jobs: fi - name: "Save updated compiler cache (ccache)" - if: github.ref == 'refs/heads/master' - uses: actions/cache/save@v6 + if: github.ref == 'refs/heads/master' && env.PERSIST_CCACHE == 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: ${{ steps.restore_ccache.outputs.cache-primary-key }} path: ${{ env.CACHE }}/.ccache @@ -365,7 +515,7 @@ jobs: - name: "Upload artifact" id: upload_artifact if: matrix.package != 'skip' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: archive: false if-no-files-found: error @@ -385,7 +535,7 @@ jobs: - name: "Attest binary provenance" id: attestation if: steps.upload_release.outcome == 'success' - uses: actions/attest@v4 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 with: show-summary: false subject-path: ${{ steps.build.outputs.path }} @@ -398,35 +548,111 @@ jobs: GH_TOKEN: ${{ github.token }} run: gh attestation verify "$BUILD_PATH" --repo Cockatrice/Cockatrice + build-linux-nightly: + strategy: + fail-fast: false + matrix: + include: + - distro: Debian + version: 12 # previous stable, still covered by the nightly + + package: DEB + test: skip # Running tests on all distros is superfluous + + - distro: Fedora + version: 44 # previous release, still covered by the nightly + + package: RPM + test: skip # Running tests on all distros is superfluous + + - distro: Ubuntu + version: 24.04 # previous LTS, still covered by the nightly + + package: DEB + test: skip # Running tests on all distros is superfluous + + # The one leg that runs the suite under AddressSanitizer and + # UndefinedBehaviorSanitizer. It runs nightly rather than on pull requests: it is + # not on the pull-request critical path (Windows sets that), and its 1.2G cache + # was the largest single entry in the 10 GB quota. Debian 13 and Fedora 45 keep + # covering the uninstrumented suite on pull requests, so nothing is lost there. + # + # It packages nothing. A sanitized Debug build is not something to ship; the + # pull-request Ubuntu 26.04 leg publishes this distro's DEB. + - distro: Ubuntu + version: 26.04 + variant: sanitize # separate image, same distro as the coverage leg below + + package: skip + sanitize: yes + # A sanitizer finding must not redden the nightly run: vcpkg-cache-import only + # imports the archives of a run whose conclusion is success. The job itself + # still reports failure. + allow-failure: yes + # Scratch only (nightly legs persist no cache); sized so the instrumented + # objects, which are larger than the 614 MB measured, do not thrash mid-build. + ccache_size: 1200M + + # The one leg that runs instrumented for line coverage and publishes the report as + # a workflow artifact. It stays 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. + # + # It moved here from build-linux: gcovr.cfg sets no fail_under, so the leg cannot + # fail a build, and nothing consumes the report per change, which made it an + # artifact nobody read on every pull request. On the default branch it becomes a + # trend instead. Image and ccache keys come from distro, version and variant, so + # they are unchanged and the warm cache that leg built still matches. + - distro: Ubuntu + version: 26.04 + variant: cover # separate image and ccache, same distro + + coverage: yes + package: skip + # A report must not redden the nightly run: vcpkg-cache-import only imports the + # archives of a run whose conclusion is success. + allow-failure: yes + + name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }} + needs: configure + # A nightly and a tag build cover the legs a pull request or a merge does not wait for: + # the previous release of each distro, and the instrumented legs. + if: github.event_name == 'schedule' || github.ref_type == 'tag' + runs-on: ubuntu-latest # https://github.com/actions/runner-images + continue-on-error: ${{ matrix.allow-failure == 'yes' }} + timeout-minutes: 70 + env: + CACHE: ${{ github.workspace }}/.cache/${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }} # directory for caching docker image and ccache + CCACHE_EVICTION_AGE: 7d + # These legs do not keep a compiler cache: they run once a day (and on tags) and gate + # nothing, so their share of the 10 GB quota goes to the pull-request legs instead. + # A cold nightly build costs minutes on a runner nobody waits for; a cold pull request + # build costs a review. CCACHE_SIZE is still passed so ccache trims its scratch dir. + PERSIST_CCACHE: 'false' + CCACHE_SIZE: ${{ matrix.ccache_size || '600M' }} + CMAKE_GENERATOR: 'Ninja' + NAME: ${{ matrix.distro }}${{ matrix.version }} + IMAGE_SUFFIX: ${{ matrix.variant }} + # The shared steps below gate on these, so they have to be derived here too. Left + # undefined they silently skipped the build the leg exists for: the coverage leg + # found build/ empty and its report collection failed on every nightly and tag run. + # Same derivation as the pull request job above. + DEBUG_TEST: ${{ matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes' }} + RUN_TESTS: ${{ matrix.test != 'skip' && !(matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes') }} + steps: *linux-steps + build-vcpkg: strategy: fail-fast: false matrix: include: - os: macOS - target: 13 # EOL 2025-09-15 - runner: macos-15-intel # https://github.com/actions/runner-images/blob/main/images/macos/macos-15-Readme.md - # macos-26-intel is very slow and fails in CPack during DMG config if not increasing Finder timeout - - ccache_eviction_age: 7d - cmake_generator: Ninja - make_package: 1 - override_target: 13 - package_suffix: "-macOS13_Intel" - qt_version: 6.11.* - qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets - soc: Intel - type: Release - 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" - - - os: macOS - target: 14 # EOL 2026-?? - runner: macos-26 # https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md + target: 14 # EOL 2026-09-14 + runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md ccache_eviction_age: 7d + ccache_size: 700M # Release + package: ~0.4 GB at level 0 cmake_generator: Ninja make_package: 1 override_target: 14 @@ -439,13 +665,13 @@ jobs: 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" - os: macOS - target: 15 - runner: macos-26 # https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md + target: 15 # EOL 2027-?? + runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md ccache_eviction_age: 7d + ccache_size: 700M # Release + package: ~0.4 GB at level 0 cmake_generator: Ninja make_package: 1 override_target: 15 @@ -457,13 +683,13 @@ jobs: use_ccache: 1 vcpkg_deployment_target: 14 vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/' - xcode: "26.6" - os: macOS - target: 26 - runner: macos-26 # https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md + target: 27 + runner: xcode-27 # https://github.com/actions/runner-images/blob/main/images/macos/xcode-27-arm64-Readme.md ccache_eviction_age: 7d + ccache_size: 700M # Debug + suite: ~0.5 GB at level 0 cmake_generator: Ninja qt_version: 6.11.* qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets @@ -472,7 +698,6 @@ jobs: use_ccache: 1 vcpkg_deployment_target: 14 vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/' - xcode: "26.6" - os: Windows target: 10 @@ -489,6 +714,9 @@ jobs: qt_version: 6.11.* qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets type: Release + ccache_eviction_age: 7d + ccache_size: 950M # Release plus the package: 890 MB working set without /Z7 + use_ccache: 1 # 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. @@ -497,36 +725,65 @@ jobs: name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }} needs: [configure, vcpkg-baseline] + # The nightly leg lives in build-vcpkg-nightly below, next to the Linux nightly legs. + if: github.event_name != 'schedule' runs-on: ${{ matrix.runner }} timeout-minutes: 100 env: + # The Windows leg and all three pull-request macOS legs keep a compiler cache. ccache + # drives MSVC as long as the compiler emits /Z7, which CMakeLists.txt sets for exactly + # this reason, and it drives clang on macOS the same way; both cache the PCH-backed + # targets with the sloppiness that .ci/compile.sh re-asserts. Every leg builds without + # debug information on non-tag refs, so one macOS configuration writes ~0.4-0.5 GB and + # the 700M bound holds it with room for commit-to-commit churn (see build-linux for the + # quota arithmetic). CCACHE_DIR: ${{ github.workspace }}/.cache/ - CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy + CCACHE_SIZE: ${{ matrix.ccache_size || '700M' }} + PERSIST_CCACHE: ${{ matrix.persist_ccache || 'true' }} + # MSVC has no -g level, so there is nothing to ask for on Windows: level 0 is the one + # value with a meaning there, and it drops /Z7 rather than lowering a level. Pull-request + # builds carry no debug information, which is most of what a compiler cache stores; a + # tag is the build whose binary is shipped and symbolicated, so it keeps the full set. + COCKATRICE_DEBUG_INFO_LEVEL: ${{ github.ref_type != 'tag' && '0' || '' }} - steps: + # Shared with build-vcpkg-nightly so the two leg sets run the same steps. + steps: &vcpkg-steps - name: "Checkout" - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: recursive - name: "[Windows] Add msbuild to PATH" if: matrix.os == 'Windows' - uses: microsoft/setup-msbuild@v3 + uses: microsoft/setup-msbuild@30375c66a4eea26614e0d39710365f22f8b0af57 # v3.0.0 with: msbuild-architecture: x64 + - name: "[Windows] Setup ccache" + if: matrix.os == 'Windows' && matrix.use_ccache == 1 + shell: bash + run: choco install ccache --no-progress -y + - name: "[macOS] Setup ccache" if: matrix.os == 'macOS' && matrix.use_ccache == 1 run: brew install ccache - - name: "[macOS] Restore compiler cache (ccache)" - if: matrix.os == 'macOS' && matrix.use_ccache == 1 + - name: "Restore compiler cache (ccache)" + if: matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' id: restore_ccache - uses: actions/cache/restore@v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - key: ccache-${{ matrix.runner }}_${{ matrix.override_target }}-Xcode${{ matrix.xcode }} + key: ccache-${{ matrix.os }}${{ matrix.target }} path: ${{ env.CCACHE_DIR }} + # Every macOS leg that keeps a cache restores the master entry for its own target. + # Measured on master 2026-11-07 (before the debug-information change), one macOS + # configuration wrote a 0.5 GB ccache directory at -g1 against the 700M bound, and the + # restored cache served 42% of calls. Level 0 shrinks that directory to ~0.4 GB, so the + # bound holds the whole configuration and the three entries fit the quota (see the + # sizing note in build-linux). macOS 14 and 15 build the same Release + package + # configuration for two deployment targets, but each is cheap enough to keep its own. + - name: "[macOS] Install aqtinstall" if: matrix.os == 'macOS' run: pipx install aqtinstall @@ -543,17 +800,18 @@ jobs: - name: "[macOS] Restore thin Qt ${{ steps.resolve_qt_version.outputs.version }}" if: matrix.os == 'macOS' id: restore_qt - uses: actions/cache/restore@v6 + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: - key: Qt-${{ steps.resolve_qt_version.outputs.version }}-macOS-${{ matrix.soc }}-${{ matrix.qt_modules }}-thin + key: Qt-${{ steps.resolve_qt_version.outputs.version }}-macOS-${{ matrix.soc }}-${{ matrix.qt_modules }}-${{ env.QT_ARCHIVES }}-thin path: ${{ github.workspace }}/Qt # Using jurplel/install-qt-action to install Qt without using brew # Qt build using vcpkg either just fails or takes too long to build - name: "[macOS] Install fat Qt ${{ matrix.qt_version }}" if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true' - uses: jurplel/install-qt-action@v4 + uses: jurplel/install-qt-action@b344308e4483fc892e0d27b3cd1b173ebdd95f06 # v4.5.0 with: + archives: ${{ env.QT_ARCHIVES }} cache: false # cache-key-prefix: Qt dir: ${{ github.workspace }} # thinning script depends on this location @@ -566,17 +824,18 @@ jobs: - name: "[macOS] Cache thin Qt libraries" if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true' && github.ref == 'refs/heads/master' - uses: actions/cache/save@v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: ${{ steps.restore_qt.outputs.cache-primary-key }} path: ${{ github.workspace }}/Qt - name: "[Windows] Install Qt ${{ matrix.qt_version }}" if: matrix.os == 'Windows' - uses: jurplel/install-qt-action@v4 + uses: jurplel/install-qt-action@b344308e4483fc892e0d27b3cd1b173ebdd95f06 # v4.5.0 with: # Qt 6.11.0 only works with aqtinstall directly from git until aqtinstall 3.4 is released aqtsource: git+https://github.com/miurahr/aqtinstall.git + archives: ${{ env.QT_ARCHIVES }} cache: ${{ github.ref == 'refs/heads/master' }} cache-key-prefix: Qt modules: ${{ matrix.qt_modules }} @@ -585,7 +844,7 @@ jobs: - name: "[Windows] Install NSIS" if: matrix.os == 'Windows' shell: bash - run: choco install nsis + run: choco install nsis --no-progress # Ninja invokes cl.exe directly and needs the MSVC environment on PATH. - name: "[Windows] Set up MSVC environment" @@ -596,7 +855,7 @@ jobs: - name: "Setup vcpkg cache" id: vcpkg-cache - uses: TAServers/vcpkg-cache@v3 + uses: TAServers/vcpkg-cache@4f07be616a4604dd29f3c6885975cf2c04abee71 # v3.4.2 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 @@ -639,7 +898,6 @@ jobs: # configure.outputs.tag is set for ANY tag, so beta (Development) tags get the DSN # and the symbol upload below just like full releases. COCKATRICE_SENTRY_DSN: ${{ needs.configure.outputs.tag != null && secrets.SENTRY_DSN || '' }} - DEVELOPER_DIR: '/Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer' GITHUB_TOKEN: ${{ github.token }} # needed for vcpkg dependency graph updates, see VCPKG_FEATURE_FLAGS MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }} MACOS_CERTIFICATE_NAME: ${{ secrets.PROD_MACOS_CERTIFICATE_NAME }} @@ -666,6 +924,21 @@ jobs: WITH_CRASH_REPORTING: ${{ matrix.type == 'Release' && 'ON' || '' }} run: .ci/compile.sh --server --test --vcpkg + # What the compiler cache has to hold for this leg. ccache stores the objects it + # compiles, so their total is the working set a bound has to fit, and the cache + # directory shows what this build actually wrote. Debug information dominates both, + # which is why the level above is reported next to them. + - name: "Report compiler cache footprint" + if: always() + shell: bash + run: | + echo "=== ccache directory" + du -sh "$CCACHE_DIR" 2>/dev/null || echo "(none)" + echo "=== objects and archives" + find build -type f \( -name '*.o' -o -name '*.obj' -o -name '*.a' -o -name '*.lib' \) -exec du -k {} + 2>/dev/null \ + | awk '{n++; s+=$1} END {printf "%d files, %.2f GB\n", n, s/1048576}' + echo "=== debug info level in use: '${COCKATRICE_DEBUG_INFO_LEVEL}'" + - name: "Install sentry-cli" if: needs.configure.outputs.tag != null && matrix.make_package == '1' # Same as the Linux leg above: the install downloads from Sentry's registry, so a @@ -842,7 +1115,7 @@ jobs: - 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 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: if-no-files-found: error # a count above zero with nothing staged is a bug name: vcpkg-new-${{ steps.vcpkg_new.outputs.slug }} @@ -850,9 +1123,11 @@ jobs: retention-days: 1 # Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342 - - 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 + - name: "Delete remote compiler cache (ccache)" + if: matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit continue-on-error: true + # Bash, not the Windows runner's default pwsh, which cannot parse this `if`. + shell: bash env: CACHE_PRIMARY_KEY: ${{ steps.restore_ccache.outputs.cache-primary-key }} GH_TOKEN: ${{ github.token }} @@ -861,9 +1136,9 @@ jobs: echo "Cache deleted successfully" fi - - name: "[macOS] Cache updated compiler cache (ccache)" - if: matrix.os == 'macOS' && matrix.use_ccache == 1 && github.ref == 'refs/heads/master' - uses: actions/cache/save@v6 + - name: "Cache updated compiler cache (ccache)" + if: matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: key: ${{ steps.restore_ccache.outputs.cache-primary-key }} path: ${{ env.CCACHE_DIR }} @@ -918,7 +1193,7 @@ jobs: - name: "Upload artifact" if: matrix.make_package id: upload_artifact - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: archive: false if-no-files-found: error @@ -950,7 +1225,7 @@ jobs: # debug identifier, and the PE is what carries the unwind information. - name: "[Windows] Upload debug symbols (exe + pdb)" if: matrix.os == 'Windows' && needs.configure.outputs.tag != null - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: if-no-files-found: error name: ${{ steps.build.outputs.name }}-Symbols @@ -965,7 +1240,7 @@ jobs: - name: "Attest binary provenance" if: steps.upload_release.outcome == 'success' id: attestation - uses: actions/attest@v4 + uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2 with: show-summary: false subject-path: ${{ steps.build.outputs.path }} @@ -978,6 +1253,36 @@ jobs: GH_TOKEN: ${{ github.token }} run: gh attestation verify "$BUILD_PATH" --repo Cockatrice/Cockatrice + build-vcpkg-nightly: + strategy: + fail-fast: false + matrix: + include: + - os: macOS + target: 13 # EOL 2025-09-15 + runner: macos-15-intel # https://github.com/actions/runner-images/blob/main/images/macos/macos-15-Readme.md + # macos-26-intel is very slow and fails in CPack during DMG config if not increasing Finder timeout + + cmake_generator: Ninja + make_package: 1 + override_target: 13 + package_suffix: "-macOS13_Intel" + qt_version: 6.11.* + qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets + soc: Intel + type: Release + vcpkg_scope: 'vcpkg/macos13-x64-xcode26.3/' + 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' || '' }} + needs: configure + # The EOL target on the slowest runner is too slow to gate a change, so it runs in the + # nightly and on tags only, where the vcpkg cache import also has to accept schedule runs. + if: github.event_name == 'schedule' || github.ref_type == 'tag' + runs-on: ${{ matrix.runner }} + timeout-minutes: 100 + steps: *vcpkg-steps + prune-caches: name: Prune caches # Only master writes caches at all, so only master may clean them up too @@ -986,38 +1291,80 @@ jobs: 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. + # The repository holds 10 GB 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. The same is true of a compiler cache of a leg that no + # longer keeps one: its restore would keep touching it, so the live key list below is + # what retires it. + # + # docker-release's image builds keep their layer cache in the same quota: one blob per + # layer, keyed buildkit-blob-1-, plus one index--1- entry per image + # scope that lists them. They are written with mode=min, so only the final image stage + # is kept and the set stays small. A layer a build actually needs is downloaded, which + # refreshes its access date, so an old date means the builds have stopped using it and + # age is the right test. Dropping a blob an index still lists costs the next image build + # one rebuilt layer; docker-release gates nothing, so that is cheaper than keeping the + # entries forever. + # + # codeql.yml's analyze job keeps its extracted database as one entry per analyzed commit, + # keyed codeql-trap----, about 1.07 GB each. Each run + # restores the newest entry by prefix and saves its own, so every run that analyzes a new + # commit makes the previous entry dead, and the action never deletes them: four had + # accumulated by 2026-11-07 (4.26 GB, 40% of the quota). Retiring them cannot wait for + # the age rule, because they are not the oldest entries and so not what least-recently- + # used eviction reaches: the buildkit layers are. Only the newest can be restored again, + # and a miss costs one re-extraction in a job that reports rather than gates, so keeping + # exactly that one is safe. - 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. + # Must list every vcpkg_scope of the build-vcpkg and build-vcpkg-nightly + # matrices 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/' + # Must list the ccache key of every leg that keeps a compiler cache + # (PERSIST_CCACHE in build-linux and build-vcpkg), or a live entry is deleted. + # The nightly legs and the pull-request legs that finish under the Windows leg + # keep none, so their leftover entries are reclaimed here instead of waiting out + # the age rule. + LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Debian13 ccache-Fedora45debug ccache-Fedora45release ccache-Windows10 ccache-macOS14 ccache-macOS15 ccache-macOS27' 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} " + live_ccache=" ${LIVE_CCACHE_KEYS} " + # The one trap cache a later run can still restore, by prefix. ISO-8601 sorts + # lexicographically, so this is a plain string compare. Entries scoped to another + # ref are deleted by the ref rule below whatever this says. + newest_trap="$(awk -F'\t' '$2 == "refs/heads/master" && $3 ~ /^codeql-trap-/ && $4 > m { m = $4; k = $3 } END { print k }' "$RUNNER_TEMP/caches.tsv")" + 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 + # Only ever touch the key shapes our workflows create (buildkit-* and index-* + # come from docker-release.yml, codeql-trap-* from codeql-action in codeql.yml), + # so that an entry of some other workflow can never be deleted by accident case "$key" in - vcpkg/* | ccache-* | Qt*) ;; + vcpkg/* | ccache-* | Qt* | buildkit-* | index-* | codeql-trap-*) ;; *) continue ;; esac reason="" if [[ $ref != refs/heads/master ]]; then reason="scoped to $ref" + elif [[ $key == ccache-* ]]; then + # An exact token compare, because one key can be a prefix of another: + # ccache-Fedora45 is a prefix of ccache-Fedora45debug and + # ccache-Fedora45release, and only the latter two are live. + if [[ $live_ccache != *" $key "* ]]; then + reason="no pull-request leg keeps it" + fi elif [[ $key == vcpkg/* ]]; then # "/", cut the hash off the end to get the scope scope="vcpkg/${key#vcpkg/}" @@ -1025,6 +1372,10 @@ jobs: if [[ $live != *" $scope "* ]]; then reason="no matrix leg uses $scope" fi + elif [[ $key == codeql-trap-* ]]; then + if [[ $key != "$newest_trap" ]]; then + reason="superseded by the newest trap cache" + fi fi if [[ -z $reason ]]; then stamp="$(date -u -d "$accessed" +%s 2>/dev/null || echo 0)" diff --git a/.github/workflows/desktop-lint.yml b/.github/workflows/desktop-lint.yml index 93ba79464..be08420ca 100644 --- a/.github/workflows/desktop-lint.yml +++ b/.github/workflows/desktop-lint.yml @@ -1,7 +1,7 @@ name: Code Style (C++) permissions: - contents: read + contents: read # needed to checkout repo on: # Push trigger not needed for linting, we do not allow direct pushes to master @@ -12,7 +12,7 @@ on: - '!.ci/**' - '!.github/**' - '!.tx/**' - - '!doc/**' + - '!docs/**' - '.ci/lint_cpp.sh' - '.github/workflows/desktop-lint.yml' - '.clang-format' @@ -21,11 +21,12 @@ on: jobs: format: - runs-on: ubuntu-slim + name: "Check formatting" + runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md steps: - name: "Checkout" - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 20 # should be enough to find merge base diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 937386940..3b8515bff 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -56,7 +56,7 @@ jobs: include: - label: x86 platform: linux/amd64 - runner: ubuntu-latest # https://github.com/actions/runner-images + runner: ubuntu-latest # https://github.com/actions/runner-images#available-images - label: arm platform: linux/arm64 @@ -67,29 +67,35 @@ jobs: steps: - name: "Checkout" - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ case(github.event_name == 'workflow_dispatch', format('refs/tags/{0}', inputs.tag), '') }} - name: "Set up Docker buildx" - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: "Login to GitHub Container Registry (GHCR)" if: (github.event_name == 'release' && github.event.release.prerelease == false) || github.event_name == 'workflow_dispatch' id: login - uses: docker/login-action@v4 + uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ github.token }} # Don't push for non-publishing triggers + # + # The layer cache shares the repository's 10 GB Actions cache quota with the compiler + # caches (see CCACHE_SIZE in desktop-build.yml). mode=min keeps only the final image + # stage: the build stage sits behind `COPY . .`, so its compile layer is invalidated by + # every source change and was never read back, yet it was the bulk of the ~2 GB this + # cache held. The final runtime stage is the part that still pays off. - name: "Build image" if: steps.login.outcome != 'success' - uses: docker/build-push-action@v7 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: cache-from: type=gha,scope=${{ env.CACHE_SCOPE }} - cache-to: ${{ case(github.ref == 'refs/heads/master', format('type=gha,mode=max,scope={0}', env.CACHE_SCOPE), '') }} + cache-to: ${{ case(github.ref == 'refs/heads/master', format('type=gha,mode=min,scope={0}', env.CACHE_SCOPE), '') }} context: . platforms: ${{ matrix.platform }} push: false @@ -98,10 +104,10 @@ jobs: - name: "Build image and push by digest" if: steps.login.outcome == 'success' id: build - uses: docker/build-push-action@v7 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: cache-from: type=gha,scope=${{ env.CACHE_SCOPE }} - cache-to: type=gha,mode=max,scope=${{ env.CACHE_SCOPE }} + cache-to: type=gha,mode=min,scope=${{ env.CACHE_SCOPE }} context: . labels: | org.opencontainers.image.description=${{ env.OCI_DESCRIPTION }} @@ -109,7 +115,7 @@ jobs: org.opencontainers.image.url=${{ env.OCI_URL }} outputs: type=image,name=${{ env.GHCR_IMAGE }},name-canonical=true,push=true,push-by-digest=true platforms: ${{ matrix.platform }} - provenance: mode=max # Do not pass secrets as build arguments with this option + provenance: mode=max # do not pass secrets as build arguments with this option sbom: true - name: "Export digest" @@ -122,7 +128,7 @@ jobs: - name: "Upload digest" if: steps.login.outcome == 'success' - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: if-no-files-found: error name: digest-${{ matrix.label }} @@ -139,14 +145,14 @@ jobs: steps: - name: "Download digests" - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: path: ${{ runner.temp }}/digests pattern: digest-* merge-multiple: true - name: "Login to GitHub Container Registry (GHCR)" - uses: docker/login-action@v4 + uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -154,7 +160,7 @@ jobs: - name: "Docker metadata" id: metadata - uses: docker/metadata-action@v6 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: ${{ env.GHCR_IMAGE }} flavor: | diff --git a/.github/workflows/documentation-build.yml b/.github/workflows/documentation-build.yml index 91ee14e3e..fbc9db61e 100644 --- a/.github/workflows/documentation-build.yml +++ b/.github/workflows/documentation-build.yml @@ -1,4 +1,4 @@ -name: Generate Docs +name: Build Documentation permissions: contents: read # write permission to the destination repo come from 'deploy_key' @@ -6,7 +6,7 @@ permissions: on: pull_request: paths: - - 'doc/doxygen/**' + - 'docs/**' - '.github/workflows/documentation-build.yml' - 'Doxyfile' release: @@ -24,12 +24,12 @@ env: jobs: docs: - name: Doxygen - runs-on: ubuntu-latest + name: "Doxygen" + runs-on: ubuntu-latest # https://github.com/actions/runner-images#available-images steps: - name: "Checkout" - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: submodules: recursive @@ -40,9 +40,9 @@ jobs: dot -V - name: "Install Doxygen" - uses: ssciwr/doxygen-install@v2 + uses: ssciwr/doxygen-install@fa3e94d34131d3990b6b8ba939ab982964aa3758 # v2.0.3 with: - version: "1.18.0" + version: "1.18.0" # https://www.doxygen.nl/manual/changelog.html - name: "Update Doxygen Configuration" shell: bash @@ -65,13 +65,13 @@ jobs: - name: "Deploy to cockatrice.github.io/docs" if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' - uses: peaceiris/actions-gh-pages@v4 + uses: peaceiris/actions-gh-pages@84c30a85c19949d7eee79c4ff27748b70285e453 # v4.1.0 with: deploy_key: ${{ secrets.DOCS_DEPLOY_KEY }} destination_dir: docs # docs will be available at https://cockatrice.github.io/docs/ external_repository: Cockatrice/cockatrice.github.io publish_branch: master - publish_dir: ./docs/html + publish_dir: ./docs-output/html - name: Link to Documentation Page if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' diff --git a/.github/workflows/translations-pull.yml b/.github/workflows/translations-pull.yml index da3df1714..e2da19a9e 100644 --- a/.github/workflows/translations-pull.yml +++ b/.github/workflows/translations-pull.yml @@ -16,20 +16,19 @@ on: jobs: translations: + name: "Pull languages" # Do not run the scheduled workflow on forks if: github.event_name != 'schedule' || github.repository_owner == 'Cockatrice' - - name: Pull languages - runs-on: ubuntu-slim + runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md steps: - name: "Checkout repo" - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: "Pull translated strings from Transifex" # Do not run this step for PR's from forks, they don't have access to the secret if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false - uses: transifex/cli-action@v2 + uses: transifex/cli-action@584fd205cbe598773b5a81ce711fa44842678189 # v2 with: # Used config file: https://github.com/Cockatrice/Cockatrice/blob/master/.tx/config # Docs: https://github.com/transifex/cli#pulling-files-from-transifex @@ -39,7 +38,7 @@ jobs: - name: "Create pull request" if: github.event_name != 'pull_request' id: create_pr - uses: peter-evans/create-pull-request@v8 + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: add-paths: | cockatrice/translations/*.ts diff --git a/.github/workflows/translations-push.yml b/.github/workflows/translations-push.yml index 17645cab2..9f59fbdfc 100644 --- a/.github/workflows/translations-push.yml +++ b/.github/workflows/translations-push.yml @@ -16,15 +16,14 @@ on: jobs: translations: + name: "Push strings" # Do not run the scheduled workflow on forks if: github.event_name != 'schedule' || github.repository_owner == 'Cockatrice' - - name: Push strings - runs-on: ubuntu-slim + runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md steps: - name: "Checkout repo" - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: "Install lupdate" shell: bash @@ -37,20 +36,22 @@ jobs: FILE: cockatrice/cockatrice_en@source.ts id: cockatrice shell: bash - run: > - DIRS="cockatrice/src $(find . -maxdepth 1 -type d -name 'libcockatrice_*')" + run: | + export DIRS="cockatrice/src $(find . -maxdepth 1 -type d -name 'libcockatrice_*')" .ci/update_translation_source_strings.sh - name: "Update Oracle translation source" id: oracle shell: bash env: - DIRS: 'oracle/src' - FILE: 'oracle/oracle_en@source.ts' + DIRS: oracle/src + FILE: oracle/oracle_en@source.ts run: .ci/update_translation_source_strings.sh - name: "Render template" id: template + # Not pinnable to a SHA: the action downloads its release binary by ref name, + # so a commit ref matches no release tag. uses: chuhlomin/render-template/binary@v1 with: template: .ci/update_translation_source_strings_template.md @@ -62,7 +63,7 @@ jobs: - name: "Create pull request" if: github.event_name != 'pull_request' id: create_pr - uses: peter-evans/create-pull-request@v8 + uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8.1.1 with: add-paths: | cockatrice/cockatrice_en@source.ts diff --git a/.github/workflows/vcpkg-cache-import.yml b/.github/workflows/vcpkg-cache-import.yml index 3cc7b2203..699d8324b 100644 --- a/.github/workflows/vcpkg-cache-import.yml +++ b/.github/workflows/vcpkg-cache-import.yml @@ -4,12 +4,14 @@ name: Import vcpkg cache # 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. +# Only master pushes and the nightly schedule run on master 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. +# A scheduled run is the default branch at the commit a master push already built, so +# it carries the same trust as a push. # # 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 @@ -21,8 +23,10 @@ permissions: on: workflow_run: - workflows: ["Build Desktop"] - types: [completed] + workflows: + - 'Build Desktop' + types: + - completed # Every import writes the same scopes, so let them queue instead of racing each other concurrency: @@ -31,19 +35,20 @@ concurrency: jobs: list: - name: List new vcpkg entries + 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: >- + if: | github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' && + (github.event.workflow_run.event == 'push' || github.event.workflow_run.event == 'schedule') && 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. @@ -53,7 +58,7 @@ jobs: steps: - name: "Download new vcpkg entries" - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: vcpkg-new-* path: incoming @@ -64,7 +69,7 @@ jobs: shell: bash run: | if [[ $ACTIONS_CACHE_MODE != *write* ]]; then - echo "::error::expected a write-capable cache, got '$ACTIONS_CACHE_MODE'" + echo "::error::Expected a write-capable cache, got '$ACTIONS_CACHE_MODE'" exit 1 fi @@ -86,14 +91,16 @@ jobs: # 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" + 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 + needs: list + runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md + cache-mode: write + strategy: # One job per archive, they run in parallel and each only uploads a single file fail-fast: false @@ -103,7 +110,7 @@ jobs: steps: - name: "Download new vcpkg entries" - uses: actions/download-artifact@v8 + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 with: pattern: ${{ matrix.entry.artifact }} path: incoming @@ -111,7 +118,7 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} - name: "Save vcpkg entry" - uses: actions/cache/save@v6 + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 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 diff --git a/.gitignore b/.gitignore index 54939bdcf..ef88b1f58 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,4 @@ compile_commands.json .cache .gdb_history cockatrice/resources/config/qtlogging.ini -docs/ +docs-output/ diff --git a/.gitmodules b/.gitmodules index 732fec7ca..e6f115eb5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -2,5 +2,8 @@ path = vcpkg url = https://github.com/microsoft/vcpkg.git [submodule "doxygen-awesome-css"] - path = doc/doxygen/theme + path = docs/doxygen/theme url = https://github.com/jothepro/doxygen-awesome-css.git +[submodule "external/qttesting"] + path = external/qttesting + url = https://github.com/Kitware/QtTesting.git diff --git a/CMakeLists.txt b/CMakeLists.txt index 8a25fe2c6..71f195385 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,11 +1,11 @@ # Cockatrice's main CMakeLists.txt # -# This is basically a wrapper to enable/disable the compilation -# of the different projects: servatrice, cockatrice, test +# This is basically a wrapper to enable/disable the compilation of the different projects: +# Cockatrice, Oracle, Servatrice, Test # This file sets all the variables shared between the projects # like the installation path, compilation flags etc.. -# 3.16 required for Qt6 and target_precompile_headers() +# CMake 3.16 is required for Qt6 and target_precompile_headers() cmake_minimum_required(VERSION 3.16) # Use compiler cache (ccache) @@ -20,6 +20,8 @@ option(WITH_CLIENT "Build Cockatrice client" ON) option(WITH_ORACLE "Build Cockatrice card database tool (Oracle)" ON) # Compile Servatrice option(WITH_SERVER "Build Cockatrice server (Servatrice)" OFF) +# Compile GUI tests (QtTesting widget-level playback) +option(WITH_GUI_TEST "Build the QtTesting-based GUI test harness" OFF) # Compile tests option(TEST "Build tests" OFF) # Build with AddressSanitizer and UndefinedBehaviorSanitizer @@ -29,25 +31,29 @@ option(ENABLE_COVERAGE "Build instrumented for GCC line coverage" OFF) # Use vcpkg regardless of OS option(USE_VCPKG "Use vcpkg regardless of OS" OFF) -# Default to "Release" build type -# User-provided value for CMAKE_BUILD_TYPE must be checked before the PROJECT() call -if(DEFINED CMAKE_BUILD_TYPE) - set(CMAKE_BUILD_TYPE - ${CMAKE_BUILD_TYPE} - CACHE STRING "Type of build" +# The GUI test harness lives in a submodule; a checkout without it must still configure. +if(WITH_GUI_TEST AND NOT EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/external/qttesting/CMakeLists.txt") + message(WARNING "WITH_GUI_TEST is ON but external/qttesting is missing, so GUI tests are disabled.\n" + "Run 'git submodule update --init external/qttesting' to enable them." ) -else() + set(WITH_GUI_TEST OFF) +endif() + +# Default to "Release" build type +# A user-provided CMAKE_BUILD_TYPE must be checked before the PROJECT() call +if(NOT CMAKE_BUILD_TYPE) set(CMAKE_BUILD_TYPE Release - CACHE STRING "Type of build" + CACHE STRING "Build type" ) endif() -# ccache does not support MSVC and must not auto-engage on Windows -# (it is installed unintentionally on the Windows CI runner). -# NOTE: this keys off the target OS, so a mingw/Ninja configuration on Windows -# also opts out of ccache even though the GNUCXX branch below supports it. -if(USE_CCACHE AND NOT WIN32) +# ccache drives MSVC too (since 4.7), but only when the compiler writes debug +# information into the object file: /Zi and /ZI keep it in a shared PDB, and ccache +# reports them as unsupported rather than risk a stale one. The MSVC flags below +# therefore use /Z7. MSVC precompiled headers are cached as well, with the same +# sloppiness the other toolchains need. +if(USE_CCACHE) find_program(CCACHE_PROGRAM ccache) if(CCACHE_PROGRAM) # Support Unix Makefiles and Ninja @@ -55,12 +61,16 @@ if(USE_CCACHE AND NOT WIN32) # PCH-aware caching, matching .ci/compile.sh: without this ccache refuses # to cache any TU that consumes a precompiled header, so every PCH-backed # target recompiles from scratch on each build. + # The include_file_mtime/include_file_ctime entries that CI also sets are + # deliberately not set here: --set-config writes the user's global + # ccache.conf, and those two would turn off the modified-during-compile + # check for every project on the machine, not just this one. execute_process(COMMAND ${CCACHE_PROGRAM} --set-config sloppiness=pch_defines,time_macros) message(STATUS "Found CCache ${CCACHE_PROGRAM}") + else() + # An explicit opt-in must not disappear silently. + message(STATUS "ccache requested but not found; building without a compiler cache") endif() -elseif(USE_CCACHE AND WIN32) - # An explicit opt-in must not disappear silently on Windows. - message(STATUS "ccache disabled: not supported for the MSVC toolchain on Windows") endif() if(WIN32 OR USE_VCPKG) @@ -71,6 +81,8 @@ if(WIN32 OR USE_VCPKG) ) # Qt path set by user or env var if(QTDIR + OR QTDIR32 + OR QTDIR64 OR DEFINED ENV{QTDIR} OR DEFINED ENV{QTDIR32} OR DEFINED ENV{QTDIR64} @@ -92,20 +104,21 @@ endif() # Version can be overriden by git tags, see cmake/getversion.cmake project("Cockatrice" VERSION 3.2.0) -# 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) set(GIT_TAG_RELEASENAME "Reality Check") endif() -# Use c++20 for all targets +# Use C++20 for all targets set(CMAKE_CXX_STANDARD 20 CACHE STRING "C++ ISO Standard" ) -set(CMAKE_CXX_STANDARD_REQUIRED True) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) # Set conventional loops -set(CMAKE_ALLOW_LOOSE_LOOP_CONSTRUCTS true) +set(CMAKE_ALLOW_LOOSE_LOOP_CONSTRUCTS ON) # Search path for cmake modules set(COCKATRICE_CMAKE_PATH "${PROJECT_SOURCE_DIR}/cmake") @@ -118,31 +131,31 @@ include(createversionfile) # Define a proper install path if(UNIX) - if(APPLE) - # macOS - # Due to the special bundle structure ignore - # the prefix eventually set by the user. + if(APPLE) # macOS + # Due to the special bundle structure ignore the prefix eventually set by the user set(CMAKE_INSTALL_PREFIX ${CMAKE_BINARY_DIR}/release) - # Force ccache usage if available - get_property(RULE_LAUNCH_COMPILE GLOBAL PROPERTY RULE_LAUNCH_COMPILE) - if(RULE_LAUNCH_COMPILE) - message(STATUS "Force enabling CCache usage under macOS") - # Set up wrapper scripts - configure_file("${COCKATRICE_CMAKE_PATH}/launch-c.in" launch-c) - configure_file("${COCKATRICE_CMAKE_PATH}/launch-cxx.in" launch-cxx) - execute_process(COMMAND chmod a+rx "${CMAKE_BINARY_DIR}/launch-c" "${CMAKE_BINARY_DIR}/launch-cxx") + # Force ccache usage if requested and available + if(USE_CCACHE) + get_property(RULE_LAUNCH_COMPILE GLOBAL PROPERTY RULE_LAUNCH_COMPILE) + if(RULE_LAUNCH_COMPILE) + message(STATUS "Force enabling ccache usage under macOS") - # Set Xcode project attributes to route compilation through our scripts - set(CMAKE_XCODE_ATTRIBUTE_CC "${CMAKE_BINARY_DIR}/launch-c") - set(CMAKE_XCODE_ATTRIBUTE_CXX "${CMAKE_BINARY_DIR}/launch-cxx") - set(CMAKE_XCODE_ATTRIBUTE_LD "${CMAKE_BINARY_DIR}/launch-c") - set(CMAKE_XCODE_ATTRIBUTE_LDPLUSPLUS "${CMAKE_BINARY_DIR}/launch-cxx") + # Set up wrapper scripts + configure_file("${COCKATRICE_CMAKE_PATH}/launch-c.in" launch-c) + configure_file("${COCKATRICE_CMAKE_PATH}/launch-cxx.in" launch-cxx) + execute_process(COMMAND chmod a+rx "${CMAKE_BINARY_DIR}/launch-c" "${CMAKE_BINARY_DIR}/launch-cxx") + + # Set Xcode project attributes to route compilation through our scripts + set(CMAKE_XCODE_ATTRIBUTE_CC "${CMAKE_BINARY_DIR}/launch-c") + set(CMAKE_XCODE_ATTRIBUTE_CXX "${CMAKE_BINARY_DIR}/launch-cxx") + set(CMAKE_XCODE_ATTRIBUTE_LD "${CMAKE_BINARY_DIR}/launch-c") + set(CMAKE_XCODE_ATTRIBUTE_LDPLUSPLUS "${CMAKE_BINARY_DIR}/launch-cxx") + endif() endif() - else() - # Linux / BSD + else() # Linux / BSD if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) - #fix package build + # Fix package build if(PREFIX) set(CMAKE_INSTALL_PREFIX ${PREFIX}) else() @@ -150,47 +163,68 @@ if(UNIX) endif() endif() endif() -elseif(WIN32) +elseif(WIN32) # Windows (including 64-bit) set(CMAKE_INSTALL_PREFIX ${CMAKE_BINARY_DIR}/rundir/${CMAKE_BUILD_TYPE}) endif() -# Define proper compilation flags -if(MSVC) - # Disable Warning C4251, C++20 compatibility, Multi-threaded Builds, Warn Detection, Unwind Semantics, Debug Symbols - set(CMAKE_CXX_FLAGS "/wd4251 /Zc:__cplusplus /std:c++20 /permissive- /W4 /MP /EHsc /Zi") - # Visual Studio: Maximum Optimization, Multi-threaded DLL - set(CMAKE_CXX_FLAGS_RELEASE "/Ox /MD") - # Visual Studio: No Optimization, Multi-threaded Debug DLL +# Define compiler flags +if(MSVC) # Microsoft Visual C++ compiler + # /EHsc Enable standard C++ exception handling + # /MP Enable parallel compilation + # /permissive- Enable more standards-conforming behavior + # /utf-8 Set source file encoding and execution character set to UTF-8 + # /W4 Enable warning level 4 + # /Zc:__cplusplus Enable correct __cplusplus reporting + # /Z7 Generate debugging information in each object file (ccache-cacheable) + # instead of a compiler-shared PDB, which ccache rejects + set(CMAKE_CXX_FLAGS "/EHsc /MP /permissive- /utf-8 /W4 /Zc:__cplusplus /Z7") + # /O2 Balanced optimization + # /MD Link against the multi-threaded DLL runtime library (Release CRT) + set(CMAKE_CXX_FLAGS_RELEASE "/O2 /MD") + # /Od Disable optimization + # /MDd Link against the multi-threaded Debug DLL runtime library (Debug CRT) set(CMAKE_CXX_FLAGS_DEBUG "/Od /MDd") - # Generate PDB, even when in release (So developers can better analyze crash logs) + # Generate PDBs, even in release builds, so developers can better analyze crash logs + # /DEBUG Enable debug symbols and PDB generation + # /OPT:REF Remove unreferenced functions/data + # /OPT:ICF Fold identical COMDAT functions/data set(CMAKE_EXE_LINKER_FLAGS_RELEASE "${CMAKE_EXE_LINKER_FLAGS_RELEASE} /DEBUG /OPT:REF /OPT:ICF") add_compile_definitions(_SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING) -elseif(CMAKE_COMPILER_IS_GNUCXX) - # linux/gcc, bsd/gcc, windows/mingw +elseif(CMAKE_CXX_COMPILER_ID STREQUAL "GNU") # GCC compiler include(CheckCXXCompilerFlag) - set(CMAKE_CXX_FLAGS_RELEASE "-s -O2") + # -O2 Balanced optimization + # -s Remove symbols from the executable + set(CMAKE_CXX_FLAGS_RELEASE "-O2 -s") + + # -ggdb Produce GDB debugging symbols + # -O0 Disable optimization + # -Wall Enable a broad set of useful warnings + # -Wextra Enable a set of extra warnings + # -Werror Treat warnings as compilation errors if(WARNING_AS_ERROR) set(CMAKE_CXX_FLAGS_DEBUG "-ggdb -O0 -Wall -Wextra -Werror") else() set(CMAKE_CXX_FLAGS_DEBUG "-ggdb -O0 -Wall -Wextra") endif() - if(APPLE) - set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=gnu++20") - endif() - + # -Wcast-align Catch unsafe pointer casts + # -Wmissing-declarations Catch defined functions without a prior declaration + # -Wno-error=extra Downgrade some -Wextra warnings from errors to warnings + # -Wno-error=delete-non-virtual-dtor Downgrade delete-non-virtual-dtor from error to warning + # -Wno-error=sign-compare Downgrade signed/unsigned comparison from error to warning + # -Wno-error=missing-declarations Downgrade -Wmissing-declarations from error to warning + # -Wno-error=sfinae-incomplete GCC 16+: Qt MOC + protobuf forward decls trigger this set(ADDITIONAL_DEBUG_FLAGS -Wcast-align -Wmissing-declarations - -Wno-long-long -Wno-error=extra -Wno-error=delete-non-virtual-dtor -Wno-error=sign-compare -Wno-error=missing-declarations - -Wno-error=sfinae-incomplete # GCC 16+: Qt MOC + protobuf forward decls trigger this + -Wno-error=sfinae-incomplete ) foreach(FLAG ${ADDITIONAL_DEBUG_FLAGS}) @@ -202,9 +236,16 @@ elseif(CMAKE_COMPILER_IS_GNUCXX) # Reduce compiler I/O by using pipes between stages instead of temp files set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe") -else() - # other: osx/llvm, bsd/llvm +elseif(CMAKE_CXX_COMPILER_ID MATCHES "Clang") # Clang and AppleClang compilers + # -O2 Balanced optimization set(CMAKE_CXX_FLAGS_RELEASE "-O2") + + # -g Include debug information + # -O0 Disable optimization + # -Wall Enable a broad set of useful warnings + # -Wextra Enable a set of extra warnings + # -Werror Treat warnings as compilation errors + # -Wno-unused-parameter Suppress warnings about unused function parameters (common in Qt callbacks) if(WARNING_AS_ERROR) set(CMAKE_CXX_FLAGS_DEBUG "-g -O0 -Wall -Wextra -Werror -Wno-unused-parameter") else() @@ -213,6 +254,8 @@ else() # Reduce compiler I/O by using pipes between stages instead of temp files set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe") +else() # Unknown compiler + message(WARNING "Unknown C++ compiler: ${CMAKE_CXX_COMPILER_ID}") endif() # Always included, because they define cockatrice_disable_sanitizers_for() and @@ -222,9 +265,37 @@ include(Sanitizers) include(Coverage) include(CrashReporting) -# GNU systems need to define the Mersenne exponent for the RNG to compile w/o warning +# Debug information is what makes a cached object large, and a build configuration needs more +# room than a per-leg cache bound can spare when it carries full DWARF: ccache then fills to +# its limit and evicts the entries the next build is about to ask for, so it serves a few +# percent of calls while holding most of a build. CI sets a lower level so one whole +# configuration fits; level 1 keeps file and line, which is what a failing test or a sanitizer +# backtrace reports. Empty (the default) leaves local, distro and release builds exactly as +# they were. This comes after CrashReporting, so the -g it adds for Sentry symbols cannot +# raise the level again. +set(COCKATRICE_DEBUG_INFO_LEVEL + "" + CACHE STRING "DWARF level for Debug and Release objects (empty keeps the built-in flags)" +) +if(NOT COCKATRICE_DEBUG_INFO_LEVEL STREQUAL "") + if(MSVC) + # MSVC has no -g level, so /Z7 is either there or it is not. Level 0 is the only value + # that means something here: without /Z7 the objects carry no debug information, which is + # what a cache that has to fit a bound holds. Anything else is the full debug info. + if(COCKATRICE_DEBUG_INFO_LEVEL STREQUAL "0") + string(REPLACE " /Z7" "" CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS}") + else() + message(WARNING "COCKATRICE_DEBUG_INFO_LEVEL is ignored on MSVC except 0") + endif() + else() + string(APPEND CMAKE_CXX_FLAGS_DEBUG " -g${COCKATRICE_DEBUG_INFO_LEVEL}") + string(APPEND CMAKE_CXX_FLAGS_RELEASE " -g${COCKATRICE_DEBUG_INFO_LEVEL}") + endif() +endif() + +# GNU systems need to define the Mersenne exponent for SFMT, so the RNG compiles without warnings if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") - add_definitions("-DSFMT_MEXP=19937") + add_compile_definitions(SFMT_MEXP=19937) endif() find_package(Threads REQUIRED) @@ -236,13 +307,13 @@ else() set(_lib_suffix 32) endif() -if(DEFINED QTDIR${_lib_suffix}) +if(NOT "${QTDIR${_lib_suffix}}" STREQUAL "") list(APPEND CMAKE_PREFIX_PATH "${QTDIR${_lib_suffix}}") -elseif(DEFINED QTDIR) +elseif(NOT "${QTDIR}" STREQUAL "") list(APPEND CMAKE_PREFIX_PATH "${QTDIR}") -elseif(DEFINED ENV{QTDIR${_lib_suffix}}) +elseif(NOT "$ENV{QTDIR${_lib_suffix}}" STREQUAL "") list(APPEND CMAKE_PREFIX_PATH "$ENV{QTDIR${_lib_suffix}}") -elseif(DEFINED ENV{QTDIR}) +elseif(NOT "$ENV{QTDIR}" STREQUAL "") list(APPEND CMAKE_PREFIX_PATH "$ENV{QTDIR}") endif() @@ -277,15 +348,15 @@ if(${Protobuf_VERSION} VERSION_LESS "3.21.0.0" AND NOT EXISTS "${Protobuf_PROTOC message(FATAL_ERROR "No protoc command found!") endif() -#Find OpenSSL -if(WIN32) +# Find OpenSSL +if(WIN32) # Windows (including 64-bit) find_package(OpenSSL REQUIRED) if(OPENSSL_FOUND) include_directories(${OPENSSL_INCLUDE_DIRS}) endif() endif() -#Find VCredist +# Find Visual C++ Redistributable if(MSVC) find_package(VCredistRuntime) endif() @@ -461,6 +532,34 @@ if(WITH_ORACLE) set(CPACK_INSTALL_CMAKE_PROJECTS "Oracle;Oracle;ALL;/" ${CPACK_INSTALL_CMAKE_PROJECTS}) endif() +if(WITH_GUI_TEST) + set(QtTesting_QT_VERSION "6") + # QtTesting registers its own unit tests when BUILD_TESTING is on. They target the + # Qt version the harness was written for and fail on Qt 6.11 (toolbutton + # long-press recording), and they are not Cockatrice's tests, so suppress them + # for the duration of the subdirectory instead of painting ctest red. + if(DEFINED BUILD_TESTING) + set(_cockatrice_saved_build_testing ${BUILD_TESTING}) + endif() + set(BUILD_TESTING + OFF + CACHE BOOL "Build the testing tree." FORCE + ) + add_subdirectory(external/qttesting) + if(DEFINED _cockatrice_saved_build_testing) + set(BUILD_TESTING + ${_cockatrice_saved_build_testing} + CACHE BOOL "Build the testing tree." FORCE + ) + else() + unset(BUILD_TESTING CACHE) + endif() + # cl.exe has no warnings-as-errors here, and it rejects -Wno-error with D8021. + if(NOT MSVC) + target_compile_options(qttesting PRIVATE -Wno-error) + endif() +endif() + if(TEST) include(CTest) add_subdirectory(tests) @@ -470,3 +569,10 @@ if(Qt6_FOUND AND Qt6_VERSION_MINOR GREATER_EQUAL 3) # Qt6.3+ requires project finalization to support translations qt_finalize_project() endif() + +# Print compiler identification at configuration time +message(STATUS "C++ compiler: ${CMAKE_CXX_COMPILER_ID}") +message(STATUS "C++ compiler version: ${CMAKE_CXX_COMPILER_VERSION}") +message(STATUS "C++ compiler path: ${CMAKE_CXX_COMPILER}") +message(STATUS "C++ standard: ${CMAKE_CXX_STANDARD}") +message(STATUS "C++ extensions: ${CMAKE_CXX_EXTENSIONS}") diff --git a/Doxyfile b/Doxyfile index c12ff4a8a..0d508ca38 100644 --- a/Doxyfile +++ b/Doxyfile @@ -74,7 +74,7 @@ PROJECT_ICON = cockatrice/resources/cockatrice.png # entered, it will be relative to the location where Doxygen was started. If # left blank the current directory will be used. -OUTPUT_DIRECTORY = docs +OUTPUT_DIRECTORY = docs-output # If the CREATE_SUBDIRS tag is set to YES then Doxygen will create up to 4096 # sub-directories (in 2 levels) under the output directory of each output format @@ -874,7 +874,7 @@ FILE_VERSION_FILTER = # DoxygenLayout.xml, Doxygen will parse it automatically even if the LAYOUT_FILE # tag is left empty. -LAYOUT_FILE = doc/doxygen/DoxygenLayout.xml +LAYOUT_FILE = docs/doxygen/DoxygenLayout.xml # The CITE_BIB_FILES tag can be used to specify one or more bib files containing # the reference definitions. This must be a list of .bib files. The .bib @@ -1091,8 +1091,8 @@ RECURSIVE = YES EXCLUDE = build/ \ cmake/ \ cmake-build-debug/ \ - doc/doxygen/theme/docs/ \ - doc/doxygen/theme/include/ \ + docs/doxygen/theme/docs/ \ + docs/doxygen/theme/include/ \ vcpkg/ # The EXCLUDE_SYMLINKS tag can be used to select whether or not files or @@ -1144,7 +1144,7 @@ EXAMPLE_RECURSIVE = NO # that contain images that are to be included in the documentation (see the # \image command). -IMAGE_PATH = doc/doxygen/images +IMAGE_PATH = docs/doxygen/images # The INPUT_FILTER tag can be used to specify a program that Doxygen should # invoke to filter for each input file. Doxygen will invoke the filter program @@ -1183,7 +1183,7 @@ INPUT_FILTER = # need to set EXTENSION_MAPPING for the extension otherwise the files are not # properly processed by Doxygen. -FILTER_PATTERNS = "*.proto=python doc/doxygen/filters/proto2cpp.py" +FILTER_PATTERNS = "*.proto=python docs/doxygen/filters/proto2cpp.py" # If the FILTER_SOURCE_FILES tag is set to YES, the input filter (if set using # INPUT_FILTER) will also be used to filter the input files that are used for @@ -1414,7 +1414,7 @@ HTML_FILE_EXTENSION = .html # of the possible markers and block names see the documentation. # This tag requires that the tag GENERATE_HTML is set to YES. -HTML_HEADER = doc/doxygen/html/header.html +HTML_HEADER = docs/doxygen/html/header.html # The HTML_FOOTER tag can be used to specify a user-defined HTML footer for each # generated HTML page. If the tag is left blank Doxygen will generate a standard @@ -1424,7 +1424,7 @@ HTML_HEADER = doc/doxygen/html/header.html # that Doxygen normally uses. # This tag requires that the tag GENERATE_HTML is set to YES. -HTML_FOOTER = doc/doxygen/html/footer.html +HTML_FOOTER = docs/doxygen/html/footer.html # The HTML_STYLESHEET tag can be used to specify a user-defined cascading style # sheet that is used by each HTML page. It can be used to fine-tune the look of @@ -1454,9 +1454,9 @@ HTML_STYLESHEET = # documentation. # This tag requires that the tag GENERATE_HTML is set to YES. -HTML_EXTRA_STYLESHEET = doc/doxygen/theme/doxygen-awesome.css \ - doc/doxygen/css/hide_nav_sync.css \ - doc/doxygen/css/cockatrice_docs_style.css +HTML_EXTRA_STYLESHEET = docs/doxygen/theme/doxygen-awesome.css \ + docs/doxygen/css/hide_nav_sync.css \ + docs/doxygen/css/cockatrice_docs_style.css # The HTML_EXTRA_FILES tag can be used to specify one or more extra images or # other source files which should be copied to the HTML output directory. Note @@ -1466,7 +1466,7 @@ HTML_EXTRA_STYLESHEET = doc/doxygen/theme/doxygen-awesome.css \ # files will be copied as-is; there are no commands or markers available. # This tag requires that the tag GENERATE_HTML is set to YES. -HTML_EXTRA_FILES = doc/doxygen/js/graph_toggle.js +HTML_EXTRA_FILES = docs/doxygen/js/graph_toggle.js # The HTML_COLORSTYLE tag can be used to specify if the generated HTML output # should be rendered with a dark or light theme. diff --git a/README.md b/README.md index fa9272c0f..03f02e540 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ Join our [Discord community](https://discord.gg/3Z9yzmA) to connect with other p ### Code -To commit code to the project, please review our [Contribution Guidelines](https://github.com/Cockatrice/Cockatrice/blob/master/.github/CONTRIBUTING.md) first.
+To commit code to the project, please review our [Contribution Guidelines](https://github.com/Cockatrice/Cockatrice/blob/master/docs/CONTRIBUTING.md) first.
We maintain two tags for contributors to easier find issues to potentially work on: - [![Good first issue](https://img.shields.io/github/issues/cockatrice/cockatrice/Good%20First%20Issue)](https://github.com/Cockatrice/Cockatrice/issues?utf8=%E2%9C%93&q=is%3Aopen%20is%3Aissue%20label%3A%22Good%20first%20issue%22%20)
Issues tagged in this way provide a simple way to get started. They don't require much experience to be worked on. @@ -103,7 +103,7 @@ There are various places where useful information for different needs are mainta - [Official Wiki](https://github.com/Cockatrice/Cockatrice/wiki) `Community supported` - [Official Webpage](https://cockatrice.github.io/) - [Official README](https://github.com/Cockatrice/Cockatrice/blob/master/README.md) `This file` -- [Official CONTRIBUTING](https://github.com/Cockatrice/Cockatrice/blob/master/.github/CONTRIBUTING.md) +- [Official CONTRIBUTING](https://github.com/Cockatrice/Cockatrice/blob/master/docs/CONTRIBUTING.md) Cockatrice tries to use the [Google Developer Documentation Style Guide](https://developers.google.com/style/) to ensure consistent documentation. We encourage everybody to improve the documentation by suggesting edits based on this guide. @@ -163,7 +163,7 @@ The following flags (with their non-default values) can be passed to `cmake`: | `-DTEST=1` | Enable regression tests
**Note:** `make test` to run tests, *googletest* will be downloaded if not available | > [!TIP] -> Build code documentation ([Doxygen](https://www.doxygen.nl)) according to our [configuration](https://github.com/Cockatrice/Cockatrice/blob/master/Doxyfile) into `/docs`: +> Build code documentation ([Doxygen](https://www.doxygen.nl)) according to our [configuration](https://github.com/Cockatrice/Cockatrice/blob/master/Doxyfile) into `/docs-output`: > ```bash > doxygen Doxyfile > ``` diff --git a/Testing/README.md b/Testing/README.md new file mode 100644 index 000000000..cb990ec29 --- /dev/null +++ b/Testing/README.md @@ -0,0 +1,66 @@ +# GUI tests + +Widget-level playback tests for the Cockatrice client, driven by +[QtTesting](https://github.com/Kitware/QtTesting) (`external/qttesting`, a submodule). +Each `*_test.xml` file is a list of events that QtTesting replays against the running +application; a test passes when every event is handled without error. + +## Running + +```bash +git submodule update --init external/qttesting # once +cmake -S . -B build/debug -DCMAKE_BUILD_TYPE=Debug -DWITH_SERVER=0 -DWITH_GUI_TEST=ON +cmake --build build/debug -j 24 + +./Testing/run_test.sh --all # headless, offscreen +./Testing/run_test.sh Testing/smoke_test.xml # one file +./Testing/run_test.sh --visual Testing/smoke_test.xml # shows the window +``` + +`WITH_GUI_TEST` is on by default; a checkout without the submodule configures with a +warning and no harness. `COCKATRICE_BIN` points the runner at another build, and +`TEST_TIMEOUT` (seconds, default 180) bounds a single test — a failing event inside a +modal dialog can otherwise leave the process alive. The client also hard-exits a few +seconds after playback returns, whatever the result. + +The tests are **not** part of `ctest`: they need a card database and a developer's +settings, so they stay a local loop for now. + +## Caveats + +- **Not hermetic.** The client reads the local settings and card database, so a test can + pass on one machine and fail on another. Avoid asserting things that restored + workspace state or user preferences decide (for example the number of open tabs). +- **English labels only.** Menu entries are addressed by their (translated) text, so the + tests expect the source language. Widgets are addressed by objectName paths, which are + stable across translations. +- Actions that open a dialog are followed by a `pause` event: playback continues in the + dialog's own event loop, and the pause gives the UI time to build itself. +- Widget paths include Qt-internal names where a QTabWidget is involved + (`qt_tabwidget_stackedwidget`), and the deck views in a game are indexed + (`tabbedDeckView_0`, `tabbedDeckView_1`). + +## Writing a test + +A test is a `` list. Widgets are named by their path from the main window, e.g. +`MainWindow/tabSupervisor/stackedWidget/TabGame/phasesMenu`. Useful commands: + +| Command | Arguments | Effect | +|---------|-----------|--------| +| `activate` | action or menu name | triggers a QAction (menus are opened first) | +| `key` | Qt key code | presses that key on the focused widget | +| `pause` | milliseconds | waits, letting the UI settle | +| `clickCard` | `card,zone` | double click the card found in that zone (plays it from hand) | +| `flipCard` | `card,zone` | right-click → *Turn Over* | +| `cardAction` | `card,zone,label` | right-click → first menu entry whose label contains `label` (submenus included) | +| `moveCard` | `card,srcZone,dstZone` | drags the card onto the destination zone | +| `drawArrow` | `srcCard,srcZone,dstCard,dstZone` | right-drag an arrow between two cards | +| `createToken` | `name,color,pt` | creates a token as the active local player | +| `selectItem` | `item label` | selects the tree/list entry with that label | +| `typeText` | `text` | types text one key event at a time | +| `expectProperty` | `property,value` | fails playback unless the widget property matches | + +The card, item and property commands are `pqCockatriceEventPlayer` +(`cockatrice/src/game_graphics/pq_cockatrice_event_player.h`). They resolve cards by name +and zone through the `QGraphicsScene` and send real mouse events at the card's current +viewport position, so a test does not depend on the window size or screen resolution. diff --git a/Testing/card_actions_test.xml b/Testing/card_actions_test.xml new file mode 100644 index 000000000..8d6de4c2b --- /dev/null +++ b/Testing/card_actions_test.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Testing/deck_editor_open_test.xml b/Testing/deck_editor_open_test.xml new file mode 100644 index 000000000..ab74b1c63 --- /dev/null +++ b/Testing/deck_editor_open_test.xml @@ -0,0 +1,4 @@ + + + + diff --git a/Testing/deck_editor_search_test.xml b/Testing/deck_editor_search_test.xml new file mode 100644 index 000000000..6641ac3b2 --- /dev/null +++ b/Testing/deck_editor_search_test.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Testing/dialog_lifecycle_test.xml b/Testing/dialog_lifecycle_test.xml new file mode 100644 index 000000000..00510b802 --- /dev/null +++ b/Testing/dialog_lifecycle_test.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Testing/game_mechanics_test.xml b/Testing/game_mechanics_test.xml new file mode 100644 index 000000000..efef78707 --- /dev/null +++ b/Testing/game_mechanics_test.xml @@ -0,0 +1,101 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Testing/integration_test.xml b/Testing/integration_test.xml new file mode 100644 index 000000000..33191d1ac --- /dev/null +++ b/Testing/integration_test.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Testing/local_game_lifecycle_test.xml b/Testing/local_game_lifecycle_test.xml new file mode 100644 index 000000000..bdc964921 --- /dev/null +++ b/Testing/local_game_lifecycle_test.xml @@ -0,0 +1,178 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Testing/menu_open_close_test.xml b/Testing/menu_open_close_test.xml new file mode 100644 index 000000000..d894b47b5 --- /dev/null +++ b/Testing/menu_open_close_test.xml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/Testing/nonmodal_manage_sets_test.xml b/Testing/nonmodal_manage_sets_test.xml new file mode 100644 index 000000000..5fe2bfc0c --- /dev/null +++ b/Testing/nonmodal_manage_sets_test.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/Testing/nonmodal_tip_test.xml b/Testing/nonmodal_tip_test.xml new file mode 100644 index 000000000..0f4ffc825 --- /dev/null +++ b/Testing/nonmodal_tip_test.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/Testing/nonmodal_view_log_test.xml b/Testing/nonmodal_view_log_test.xml new file mode 100644 index 000000000..3369f1fd5 --- /dev/null +++ b/Testing/nonmodal_view_log_test.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/Testing/run_test.sh b/Testing/run_test.sh new file mode 100755 index 000000000..8ff3fe028 --- /dev/null +++ b/Testing/run_test.sh @@ -0,0 +1,118 @@ +#!/bin/bash +# Run QtTesting XML test files against the Cockatrice build. +# +# Usage: +# ./Testing/run_test.sh [--visual] [--all | ...] +# +# Options: +# --visual Run with native display (shows the GUI window). +# Default is headless (QT_QPA_PLATFORM=offscreen). +# --all Run every *.xml file in Testing/. +# +# Environment: +# COCKATRICE_BIN Path to the client binary (default: build/debug/cockatrice/cockatrice, +# or the .app bundle binary on macOS). +# TEST_TIMEOUT Seconds a single test may run before it is killed (default: 180). +# Uses timeout, or gtimeout from coreutils on macOS. +# +# Requires the debug build to exist at build/debug/cockatrice/cockatrice. + +set -euo pipefail +shopt -s nullglob + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd -P)" +if [ -n "${COCKATRICE_BIN:-}" ]; then + COCKATRICE="$COCKATRICE_BIN" +elif [ "$(uname -s)" = "Darwin" ]; then + # A macOS build produces an app bundle, not a bare executable. + COCKATRICE="$REPO_ROOT/build/debug/cockatrice/cockatrice.app/Contents/MacOS/cockatrice" +else + COCKATRICE="$REPO_ROOT/build/debug/cockatrice/cockatrice" +fi +TEST_DIR="$REPO_ROOT/Testing" +TEST_TIMEOUT="${TEST_TIMEOUT:-180}" + +# Stock macOS has no `timeout`; coreutils provides `gtimeout`. Without either the +# runner still works, but a hung test is not killed. +TIMEOUT=() +if command -v timeout >/dev/null 2>&1; then + TIMEOUT=(timeout --foreground "$TEST_TIMEOUT") +elif command -v gtimeout >/dev/null 2>&1; then + TIMEOUT=(gtimeout --foreground "$TEST_TIMEOUT") +else + echo "Warning: neither timeout nor gtimeout found; tests are not time-limited." >&2 +fi + +if [ ! -f "$COCKATRICE" ]; then + echo "Error: $COCKATRICE not found. Build first:" >&2 + echo " cmake --build build/debug" >&2 + exit 1 +fi + +VISUAL=0 +if [ "${1:-}" = "--visual" ]; then + VISUAL=1 + shift +fi + +if [ "${1:-}" = "--all" ]; then + shift + TEST_FILES=("$TEST_DIR"/*.xml) + if [ ${#TEST_FILES[@]} -eq 0 ]; then + echo "No *.xml files found in $TEST_DIR" >&2 + exit 1 + fi +elif [ $# -ge 1 ]; then + TEST_FILES=("$@") +else + echo "Usage: $0 [--visual] [--all | ...]" >&2 + echo " Run a single test: $0 Testing/smoke_test.xml" >&2 + echo " Run all tests: $0 --all" >&2 + echo " Run visually: $0 --visual Testing/smoke_test.xml" >&2 + echo " Run all visually: $0 --visual --all" >&2 + exit 1 +fi + +PASS=0 +FAIL=0 +FAILED_TESTS=() + +run_one() { + local file="$1" + + local -a cmd=() + if [ "${#TIMEOUT[@]}" -gt 0 ]; then + cmd=("${TIMEOUT[@]}") + fi + cmd+=("$COCKATRICE" --no-connect --test "$file") + + if [ "$VISUAL" -eq 1 ]; then + "${cmd[@]}" + else + env QT_QPA_PLATFORM=offscreen "${cmd[@]}" + fi +} + +for f in "${TEST_FILES[@]}"; do + name="$(basename "$f")" + printf "%-40s " "$name" + if run_one "$f"; then + echo "PASS" + PASS=$((PASS + 1)) + else + echo "FAIL" + FAIL=$((FAIL + 1)) + FAILED_TESTS+=("$name") + fi +done + +TOTAL=$((PASS + FAIL)) +echo "" +echo "Results: $PASS/$TOTAL passed, $FAIL failed" +if [ "$FAIL" -gt 0 ]; then + echo "Failed:" + for t in "${FAILED_TESTS[@]}"; do + echo " $t" + done + exit 1 +fi diff --git a/Testing/settings_dialog_test.xml b/Testing/settings_dialog_test.xml new file mode 100644 index 000000000..2dd9dda0a --- /dev/null +++ b/Testing/settings_dialog_test.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/Testing/settings_navigation_test.xml b/Testing/settings_navigation_test.xml new file mode 100644 index 000000000..c112966e0 --- /dev/null +++ b/Testing/settings_navigation_test.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Testing/settings_search_test.xml b/Testing/settings_search_test.xml new file mode 100644 index 000000000..ba60251f0 --- /dev/null +++ b/Testing/settings_search_test.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Testing/smoke_test.xml b/Testing/smoke_test.xml new file mode 100644 index 000000000..a31edbdd4 --- /dev/null +++ b/Testing/smoke_test.xml @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/cmake/FindQtRuntime.cmake b/cmake/FindQtRuntime.cmake index 2d74aca4d..7b644276d 100644 --- a/cmake/FindQtRuntime.cmake +++ b/cmake/FindQtRuntime.cmake @@ -1,6 +1,6 @@ # Find a compatible Qt version # Inputs: WITH_SERVER, WITH_CLIENT, WITH_ORACLE -# Optional Input: QT6_DIR -- Hint as to where Qt6 lives on the system +# Optional Input: Qt6_DIR -- Directory containing Qt6Config.cmake # Output: COCKATRICE_QT_VERSION_NAME -- Example values: Qt6 # Output: SERVATRICE_QT_MODULES # Output: COCKATRICE_QT_MODULES @@ -57,7 +57,17 @@ find_package( QUIET HINTS ${Qt6_DIR} ) if(NOT Qt6_FOUND) - message(FATAL_ERROR "No suitable version of Qt was found") + set(_QT_DIAGNOSTIC "") + if(Qt6_NOT_FOUND_MESSAGE) + set(_QT_DIAGNOSTIC "\nQt package diagnostic: ${Qt6_NOT_FOUND_MESSAGE}") + endif() + message(FATAL_ERROR + "No suitable version of Qt was found. Qt 6.4.2 or newer with these components is required: " + "${REQUIRED_QT_COMPONENTS};Linguist.\n" + "Set QTDIR or CMAKE_PREFIX_PATH to the Qt installation prefix, or Qt6_DIR to the directory " + "containing Qt6Config.cmake." + "${_QT_DIAGNOSTIC}" + ) endif() set(COCKATRICE_QT_VERSION_NAME Qt6) diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 642d998a0..27ded5621 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -15,6 +15,7 @@ set(cockatrice_SOURCES src/client/network/update/client/client_update_checker.cpp src/client/network/update/client/release_channel.cpp src/client/network/update/card_spoiler/spoiler_background_updater.cpp + src/client/network/update/card_spoiler/spoiler_file.cpp src/client/crash/crash_reporter.cpp src/client/latency_graph_widget.cpp src/client/latency_status_widget.cpp @@ -56,6 +57,7 @@ set(cockatrice_SOURCES src/interface/widgets/dialogs/dlg_settings.cpp src/interface/widgets/dialogs/dlg_startup_card_check.cpp src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp + src/interface/widgets/dialogs/dlg_turn_order.cpp src/interface/widgets/dialogs/dlg_update.cpp src/interface/widgets/dialogs/dlg_view_log.cpp src/interface/widgets/dialogs/override_printing_warning.cpp @@ -149,11 +151,17 @@ set(cockatrice_SOURCES src/interface/widgets/utility/reversed_completer_model.cpp src/interface/pixel_map_generator.cpp src/interface/theme_config.cpp + src/interface/theme_icon.cpp + src/interface/theme_icon_badge.cpp + src/interface/theme_icon_fill.cpp + src/interface/theme_icon_ink.cpp + src/interface/theme_icon_sticker.cpp src/interface/theme_manager.cpp src/interface/palette_editor/color_button.cpp src/interface/palette_editor/palette_generator.cpp src/interface/palette_editor/quick_setup_panel.cpp src/interface/palette_editor/palette_grid_widget.cpp + src/interface/palette_editor/font_grid_widget.cpp src/interface/palette_editor/palette_editor_dialog.cpp src/interface/widgets/cards/additional_info/color_identity_widget.cpp src/interface/widgets/cards/additional_info/deck_color_identity.cpp @@ -164,6 +172,7 @@ set(cockatrice_SOURCES src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp src/interface/widgets/cards/card_group_display_widgets/flat_card_group_display_widget.cpp src/interface/widgets/cards/card_group_display_widgets/overlapped_card_group_display_widget.cpp + src/interface/widgets/cards/card_hover_widget.cpp src/interface/widgets/cards/card_info_display_widget.cpp src/interface/widgets/cards/card_info_frame_widget.cpp src/interface/widgets/cards/card_info_picture_art_crop_widget.cpp @@ -212,6 +221,8 @@ set(cockatrice_SOURCES src/interface/widgets/deck_editor/deck_state_manager.cpp src/interface/widgets/deck_editor/deck_zone_dialog.cpp src/interface/widgets/deck_editor/printing_disabled_info_widget.cpp + src/interface/widgets/game_hosting/create_game_form_widget.cpp + src/interface/widgets/game_hosting/game_creation_options.cpp src/interface/widgets/general/background_sources.cpp src/interface/widgets/general/display/background_plate_widget.cpp src/interface/widgets/general/display/banner_widget.cpp @@ -249,6 +260,8 @@ set(cockatrice_SOURCES src/interface/widgets/playmat/playmat_settings_dialog.cpp src/interface/widgets/quick_settings/settings_button_widget.cpp src/interface/widgets/quick_settings/settings_popup_widget.cpp + src/interface/widgets/replay/replay_event_index.cpp + src/interface/widgets/replay/replay_event_sidebar.cpp src/interface/widgets/replay/replay_manager.cpp src/interface/widgets/replay/replay_quick_settings_widget.cpp src/interface/widgets/replay/replay_timeline_widget.cpp @@ -285,9 +298,14 @@ set(cockatrice_SOURCES src/interface/widgets/settings_page/storage_settings_page.cpp src/interface/widgets/settings_page/user_interface_settings_page.cpp src/interface/widgets/utility/custom_line_edit.cpp + src/interface/widgets/utility/epoch_format.h src/interface/widgets/utility/get_text_with_max.cpp + src/interface/widgets/utility/pill_painter.cpp + src/interface/widgets/utility/pill_painter.h src/interface/widgets/utility/report_utils.cpp src/interface/widgets/utility/report_utils.h + src/interface/widgets/utility/table_activation.cpp + src/interface/widgets/utility/table_activation.h src/interface/widgets/utility/sequence_edit.cpp src/interface/widgets/utility/visibility_change_listener.cpp src/interface/widgets/utility/visibility_change_listener.h @@ -300,6 +318,7 @@ set(cockatrice_SOURCES src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.cpp src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.cpp + src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.cpp src/interface/widgets/visual_database_display/visual_database_display_widget.cpp src/interface/widgets/visual_database_display/visual_database_filter_display_widget.cpp src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp @@ -375,9 +394,12 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp src/interface/widgets/tabs/api/edhrec/tab_edhrec.cpp src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp + src/interface/widgets/tabs/alt_table_delegate.cpp + src/interface/widgets/tabs/report_status_delegate.cpp src/interface/widgets/tabs/tab.cpp src/interface/widgets/tabs/tab_account.cpp src/interface/widgets/tabs/tab_admin.cpp + src/interface/widgets/tabs/tab_alts.cpp src/interface/widgets/tabs/tab_card_art_rules.cpp src/interface/widgets/tabs/tab_deck_editor.cpp src/interface/widgets/tabs/tab_deck_storage.cpp @@ -449,6 +471,8 @@ set(cockatrice_SOURCES src/interface/intents/intent_open_shared_deck.h src/interface/intents/url_parser.cpp src/interface/intents/url_parser.h + src/interface/widgets/server/user/user_games_model.cpp + src/interface/widgets/server/user/user_games_model.h src/interface/widgets/server/user/user_info_popup.cpp src/interface/widgets/server/user/user_info_popup.h ) @@ -492,7 +516,15 @@ if(APPLE) set(cockatrice_SOURCES ${cockatrice_SOURCES} ${CMAKE_CURRENT_SOURCE_DIR}/resources/appicon.icns) endif(APPLE) -qt_add_resources(cockatrice_RESOURCES_RCC ${cockatrice_RESOURCES}) +# The fonts and card artwork produce a large C++ byte array with qt_add_resources. +# Embed the data in the compiled resource object instead, keeping compiler memory +# independent of that payload. Qt's two-pass resource build is unsupported on iOS. +if(IOS) + qt_add_resources(cockatrice_RESOURCES_RCC ${cockatrice_RESOURCES}) +else() + qt_add_big_resources(cockatrice_RESOURCES_RCC ${cockatrice_RESOURCES}) +endif() +set_source_files_properties(${cockatrice_RESOURCES} PROPERTIES SKIP_AUTORCC ON) # Declare path variables set(ICONDIR @@ -518,6 +550,7 @@ qt_add_executable( WIN32 MACOSX_BUNDLE ${cockatrice_SOURCES} + ${cockatrice_RESOURCES} ${cockatrice_RESOURCES_RCC} ${cockatrice_MOC_SRCS} MANUAL_FINALIZATION @@ -564,6 +597,14 @@ target_link_libraries( cockatrice_target_enable_crash_reporting(cockatrice) +if(WITH_GUI_TEST) + # These two translate between XML test files and the QtTesting event pipeline; + # they need the QtTesting headers, so they only exist with the harness. + target_sources(cockatrice PRIVATE src/game_graphics/pq_cockatrice_event_player.cpp src/interface/recording.cpp) + target_link_libraries(cockatrice PRIVATE QtTesting) + target_compile_definitions(cockatrice PRIVATE WITH_GUI_TEST) +endif() + if(UNIX) if(APPLE) set(MACOSX_BUNDLE_INFO_STRING "${PROJECT_NAME}") diff --git a/cockatrice/cockatrice.qrc b/cockatrice/cockatrice.qrc index 4ec3d2385..e398033e9 100644 --- a/cockatrice/cockatrice.qrc +++ b/cockatrice/cockatrice.qrc @@ -6,19 +6,18 @@ resources/hand.svg resources/hr.jpg - resources/icons/arrow_bottom_green.svg - resources/icons/arrow_down_green.svg + resources/icons/arrow_down.svg resources/icons/arrow_history.svg - resources/icons/arrow_left_green.svg + resources/icons/arrow_left.svg resources/icons/arrow_redo.svg - resources/icons/arrow_right_blue.svg - resources/icons/arrow_right_green.svg - resources/icons/arrow_top_green.svg - resources/icons/arrow_up_green.svg + resources/icons/arrow_right.svg + resources/icons/arrow_right_sideboard.svg + resources/icons/arrow_to_bottom.svg + resources/icons/arrow_to_top.svg + resources/icons/arrow_up.svg resources/icons/arrow_undo.svg resources/icons/circle_half_stroke.svg resources/icons/clearsearch.svg - resources/icons/cogwheel.svg resources/icons/conceded.svg resources/icons/decrement.svg resources/icons/delete.svg @@ -28,7 +27,6 @@ resources/icons/filter.svg resources/icons/floppy_disk.svg resources/icons/forgot_password.svg - resources/icons/gear.svg resources/icons/increment.svg resources/icons/info.svg resources/icons/lock.svg @@ -41,7 +39,6 @@ resources/icons/reload.svg resources/icons/remove_row.svg resources/icons/rename.svg - resources/icons/scale_balanced.svg resources/icons/scales.svg resources/icons/scroll.svg resources/icons/search.svg @@ -52,7 +49,7 @@ resources/icons/swap.svg resources/icons/sync.svg resources/icons/tab_changed.svg - resources/icons/update.png + resources/icons/tags.svg resources/icons/view.svg resources/icons/mana/B.svg diff --git a/cockatrice/resources/config/qtlogging.ini b/cockatrice/resources/config/qtlogging.ini index 7ac0d9ca4..e4b845af9 100644 --- a/cockatrice/resources/config/qtlogging.ini +++ b/cockatrice/resources/config/qtlogging.ini @@ -10,6 +10,7 @@ # or set = false to disable logging #main = true +#recording = true #qt_translator = true #window_main.* = true #release_channel = true diff --git a/cockatrice/resources/help/search.md b/cockatrice/resources/help/search.md index fd0a12507..f29e57bec 100644 --- a/cockatrice/resources/help/search.md +++ b/cockatrice/resources/help/search.md @@ -22,6 +22,11 @@ In this list of examples below, each entry has an explanation and can be clicked
[t:basic](#t:basic) (Any card with the type basic)
[t:arcane t:instant](#t:arcane t:instant) (Any card with the types arcane and instant)
+
Tags:
+
[tags:ramp](#tags:ramp) (Any card tagged "ramp")
+
[tags:draw tags:ramp](#tags:draw tags:ramp) (Any card tagged both "draw" and "ramp")
+
[tags:draw OR tags:ramp](#tags:draw OR tags:ramp) (Any card tagged "draw" or "ramp")
+
Colors:
[c:w](#c:w) (Any card that is white)
[c:wu](#c:wu) (Any card that is white or blue)
diff --git a/cockatrice/resources/icons/arrow_bottom_green.svg b/cockatrice/resources/icons/arrow_bottom_green.svg deleted file mode 100644 index 3a45053ad..000000000 --- a/cockatrice/resources/icons/arrow_bottom_green.svg +++ /dev/null @@ -1,793 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - diff --git a/cockatrice/resources/icons/arrow_down.svg b/cockatrice/resources/icons/arrow_down.svg new file mode 100644 index 000000000..0ff237f52 --- /dev/null +++ b/cockatrice/resources/icons/arrow_down.svg @@ -0,0 +1,4 @@ + + + + diff --git a/cockatrice/resources/icons/arrow_down_green.svg b/cockatrice/resources/icons/arrow_down_green.svg deleted file mode 100644 index 574c12060..000000000 --- a/cockatrice/resources/icons/arrow_down_green.svg +++ /dev/null @@ -1,793 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - diff --git a/cockatrice/resources/icons/arrow_history.svg b/cockatrice/resources/icons/arrow_history.svg index d130758e6..29374d23a 100644 --- a/cockatrice/resources/icons/arrow_history.svg +++ b/cockatrice/resources/icons/arrow_history.svg @@ -1,8 +1,5 @@ - - - - history - - - - \ No newline at end of file + + + + + diff --git a/cockatrice/resources/icons/arrow_left.svg b/cockatrice/resources/icons/arrow_left.svg new file mode 100644 index 000000000..7a88deddb --- /dev/null +++ b/cockatrice/resources/icons/arrow_left.svg @@ -0,0 +1,4 @@ + + + + diff --git a/cockatrice/resources/icons/arrow_left_green.svg b/cockatrice/resources/icons/arrow_left_green.svg deleted file mode 100644 index 1b9eb0684..000000000 --- a/cockatrice/resources/icons/arrow_left_green.svg +++ /dev/null @@ -1,791 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - diff --git a/cockatrice/resources/icons/arrow_redo.svg b/cockatrice/resources/icons/arrow_redo.svg index dd89e3314..141260269 100644 --- a/cockatrice/resources/icons/arrow_redo.svg +++ b/cockatrice/resources/icons/arrow_redo.svg @@ -1,40 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + diff --git a/cockatrice/resources/icons/arrow_right.svg b/cockatrice/resources/icons/arrow_right.svg new file mode 100644 index 000000000..f0ac5d5bf --- /dev/null +++ b/cockatrice/resources/icons/arrow_right.svg @@ -0,0 +1,4 @@ + + + + diff --git a/cockatrice/resources/icons/arrow_right_blue.svg b/cockatrice/resources/icons/arrow_right_blue.svg deleted file mode 100644 index 334cd68fd..000000000 --- a/cockatrice/resources/icons/arrow_right_blue.svg +++ /dev/null @@ -1,789 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - diff --git a/cockatrice/resources/icons/arrow_right_green.svg b/cockatrice/resources/icons/arrow_right_green.svg deleted file mode 100644 index db491985d..000000000 --- a/cockatrice/resources/icons/arrow_right_green.svg +++ /dev/null @@ -1,789 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - diff --git a/cockatrice/resources/icons/arrow_right_sideboard.svg b/cockatrice/resources/icons/arrow_right_sideboard.svg new file mode 100644 index 000000000..79a8cd5d3 --- /dev/null +++ b/cockatrice/resources/icons/arrow_right_sideboard.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/cockatrice/resources/icons/arrow_to_bottom.svg b/cockatrice/resources/icons/arrow_to_bottom.svg new file mode 100644 index 000000000..291a527d2 --- /dev/null +++ b/cockatrice/resources/icons/arrow_to_bottom.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/cockatrice/resources/icons/arrow_to_top.svg b/cockatrice/resources/icons/arrow_to_top.svg new file mode 100644 index 000000000..aac38cc33 --- /dev/null +++ b/cockatrice/resources/icons/arrow_to_top.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/cockatrice/resources/icons/arrow_top_green.svg b/cockatrice/resources/icons/arrow_top_green.svg deleted file mode 100644 index c515c6a1d..000000000 --- a/cockatrice/resources/icons/arrow_top_green.svg +++ /dev/null @@ -1,793 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - diff --git a/cockatrice/resources/icons/arrow_undo.svg b/cockatrice/resources/icons/arrow_undo.svg index 2e182ffb2..c748b37b8 100644 --- a/cockatrice/resources/icons/arrow_undo.svg +++ b/cockatrice/resources/icons/arrow_undo.svg @@ -1,40 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + diff --git a/cockatrice/resources/icons/arrow_up.svg b/cockatrice/resources/icons/arrow_up.svg new file mode 100644 index 000000000..ac32b5c92 --- /dev/null +++ b/cockatrice/resources/icons/arrow_up.svg @@ -0,0 +1,4 @@ + + + + diff --git a/cockatrice/resources/icons/arrow_up_green.svg b/cockatrice/resources/icons/arrow_up_green.svg deleted file mode 100644 index 1d0f61112..000000000 --- a/cockatrice/resources/icons/arrow_up_green.svg +++ /dev/null @@ -1,792 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - diff --git a/cockatrice/resources/icons/circle_half_stroke.svg b/cockatrice/resources/icons/circle_half_stroke.svg index 42e4dbca2..c16f84858 100644 --- a/cockatrice/resources/icons/circle_half_stroke.svg +++ b/cockatrice/resources/icons/circle_half_stroke.svg @@ -1 +1,4 @@ - \ No newline at end of file + + + + diff --git a/cockatrice/resources/icons/clearsearch.svg b/cockatrice/resources/icons/clearsearch.svg index be50d68d6..49d883c68 100644 --- a/cockatrice/resources/icons/clearsearch.svg +++ b/cockatrice/resources/icons/clearsearch.svg @@ -1,276 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/cogwheel.svg b/cockatrice/resources/icons/cogwheel.svg deleted file mode 100644 index 8c7aad617..000000000 --- a/cockatrice/resources/icons/cogwheel.svg +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/cockatrice/resources/icons/conceded.svg b/cockatrice/resources/icons/conceded.svg index a487f18bb..b9faf047c 100644 --- a/cockatrice/resources/icons/conceded.svg +++ b/cockatrice/resources/icons/conceded.svg @@ -1,103 +1,4 @@ - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/decrement.svg b/cockatrice/resources/icons/decrement.svg index 0ceddf69f..033245f85 100644 --- a/cockatrice/resources/icons/decrement.svg +++ b/cockatrice/resources/icons/decrement.svg @@ -1,205 +1,3 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - + + diff --git a/cockatrice/resources/icons/delete.svg b/cockatrice/resources/icons/delete.svg index 008d8d429..d79312f1e 100644 --- a/cockatrice/resources/icons/delete.svg +++ b/cockatrice/resources/icons/delete.svg @@ -1,357 +1,5 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - + + + + diff --git a/cockatrice/resources/icons/dragon.svg b/cockatrice/resources/icons/dragon.svg index f45af39ef..cc48d1f5c 100644 --- a/cockatrice/resources/icons/dragon.svg +++ b/cockatrice/resources/icons/dragon.svg @@ -1 +1,6 @@ - \ No newline at end of file + + + + + + diff --git a/cockatrice/resources/icons/dropdown_collapsed.svg b/cockatrice/resources/icons/dropdown_collapsed.svg index 52e833e65..429c24974 100644 --- a/cockatrice/resources/icons/dropdown_collapsed.svg +++ b/cockatrice/resources/icons/dropdown_collapsed.svg @@ -1,10 +1,3 @@ - - - - - ]> - - + + diff --git a/cockatrice/resources/icons/dropdown_expanded.svg b/cockatrice/resources/icons/dropdown_expanded.svg index fa3ee205c..0b24aeb48 100644 --- a/cockatrice/resources/icons/dropdown_expanded.svg +++ b/cockatrice/resources/icons/dropdown_expanded.svg @@ -1,10 +1,3 @@ - - - - - ]> - - + + diff --git a/cockatrice/resources/icons/filter.svg b/cockatrice/resources/icons/filter.svg index d53d9ba29..d9cdf879c 100644 --- a/cockatrice/resources/icons/filter.svg +++ b/cockatrice/resources/icons/filter.svg @@ -1,12 +1,3 @@ - - - - - - - - \ No newline at end of file + + + diff --git a/cockatrice/resources/icons/floppy_disk.svg b/cockatrice/resources/icons/floppy_disk.svg index 3c5b5054d..10b9e8172 100644 --- a/cockatrice/resources/icons/floppy_disk.svg +++ b/cockatrice/resources/icons/floppy_disk.svg @@ -1 +1,5 @@ - \ No newline at end of file + + + + + diff --git a/cockatrice/resources/icons/forgot_password.svg b/cockatrice/resources/icons/forgot_password.svg index f938ca3f4..3bfb3bdb1 100644 --- a/cockatrice/resources/icons/forgot_password.svg +++ b/cockatrice/resources/icons/forgot_password.svg @@ -1,6 +1,6 @@ - - - - - + + + + + diff --git a/cockatrice/resources/icons/gear.svg b/cockatrice/resources/icons/gear.svg deleted file mode 100644 index fdee3a297..000000000 --- a/cockatrice/resources/icons/gear.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/cockatrice/resources/icons/increment.svg b/cockatrice/resources/icons/increment.svg index 6d5f7a85a..52dbdb2aa 100644 --- a/cockatrice/resources/icons/increment.svg +++ b/cockatrice/resources/icons/increment.svg @@ -1,199 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - + + + diff --git a/cockatrice/resources/icons/info.svg b/cockatrice/resources/icons/info.svg index 00b23cc2c..1305f6b3d 100644 --- a/cockatrice/resources/icons/info.svg +++ b/cockatrice/resources/icons/info.svg @@ -1,43 +1,5 @@ - - - - - - - - - - + + + + diff --git a/cockatrice/resources/icons/lock.svg b/cockatrice/resources/icons/lock.svg index b7ebf1a53..f150ece49 100644 --- a/cockatrice/resources/icons/lock.svg +++ b/cockatrice/resources/icons/lock.svg @@ -1,750 +1,4 @@ - - - - - - - - - padlock - - - - icon - - lock - security - secure - padlock - signs_and_symbols - - - - - AJ Ashton - - - - - AJ Ashton - - - - - AJ Ashton - - - - image/svg+xml - - - en - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/not_ready_start.svg b/cockatrice/resources/icons/not_ready_start.svg index 73afda9db..c20a5cfe6 100644 --- a/cockatrice/resources/icons/not_ready_start.svg +++ b/cockatrice/resources/icons/not_ready_start.svg @@ -1,368 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/pen_to_square.svg b/cockatrice/resources/icons/pen_to_square.svg index 60ff1d9c4..7d3e161ac 100644 --- a/cockatrice/resources/icons/pen_to_square.svg +++ b/cockatrice/resources/icons/pen_to_square.svg @@ -1 +1,4 @@ - \ No newline at end of file + + + + diff --git a/cockatrice/resources/icons/pencil.svg b/cockatrice/resources/icons/pencil.svg index c8b81f698..d274e98db 100644 --- a/cockatrice/resources/icons/pencil.svg +++ b/cockatrice/resources/icons/pencil.svg @@ -1,123 +1,4 @@ - - - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/pin.svg b/cockatrice/resources/icons/pin.svg index 71d4c9c9c..9c173286b 100644 --- a/cockatrice/resources/icons/pin.svg +++ b/cockatrice/resources/icons/pin.svg @@ -1,24 +1,4 @@ - - - - - + + + - diff --git a/cockatrice/resources/icons/player.svg b/cockatrice/resources/icons/player.svg index e83b24654..9fbe39a11 100644 --- a/cockatrice/resources/icons/player.svg +++ b/cockatrice/resources/icons/player.svg @@ -1,142 +1,8 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - + + + + + + + diff --git a/cockatrice/resources/icons/ready_start.svg b/cockatrice/resources/icons/ready_start.svg index 238d2aa46..4c4ea5d2d 100644 --- a/cockatrice/resources/icons/ready_start.svg +++ b/cockatrice/resources/icons/ready_start.svg @@ -1,81 +1,3 @@ - - - - - - - - - - - - image/svg+xml - - - - - - - - - + + diff --git a/cockatrice/resources/icons/reload.svg b/cockatrice/resources/icons/reload.svg index 102995876..e09b1b7ef 100644 --- a/cockatrice/resources/icons/reload.svg +++ b/cockatrice/resources/icons/reload.svg @@ -1,7 +1,4 @@ - - - - Svg Vector Icons : http://www.onlinewebfonts.com/icon - - \ No newline at end of file + + + + diff --git a/cockatrice/resources/icons/remove_row.svg b/cockatrice/resources/icons/remove_row.svg index 6f133cdb6..5d00ed57c 100644 --- a/cockatrice/resources/icons/remove_row.svg +++ b/cockatrice/resources/icons/remove_row.svg @@ -1,356 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - + + + diff --git a/cockatrice/resources/icons/rename.svg b/cockatrice/resources/icons/rename.svg index c68a00246..bab850808 100644 --- a/cockatrice/resources/icons/rename.svg +++ b/cockatrice/resources/icons/rename.svg @@ -1,10 +1,7 @@ - - - - - - - + + + + + + diff --git a/cockatrice/resources/icons/scale_balanced.svg b/cockatrice/resources/icons/scale_balanced.svg deleted file mode 100644 index fae468d38..000000000 --- a/cockatrice/resources/icons/scale_balanced.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/cockatrice/resources/icons/scales.svg b/cockatrice/resources/icons/scales.svg index a410921dd..4b2e16fcc 100644 --- a/cockatrice/resources/icons/scales.svg +++ b/cockatrice/resources/icons/scales.svg @@ -1,6 +1,7 @@ - - - - - + + + + + + diff --git a/cockatrice/resources/icons/scroll.svg b/cockatrice/resources/icons/scroll.svg index f4828e679..edcf29d11 100644 --- a/cockatrice/resources/icons/scroll.svg +++ b/cockatrice/resources/icons/scroll.svg @@ -1 +1,6 @@ - \ No newline at end of file + + + + + + diff --git a/cockatrice/resources/icons/search.svg b/cockatrice/resources/icons/search.svg index 05439a12e..be4a87f00 100644 --- a/cockatrice/resources/icons/search.svg +++ b/cockatrice/resources/icons/search.svg @@ -1,198 +1,4 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - \ No newline at end of file + + + + diff --git a/cockatrice/resources/icons/settings.svg b/cockatrice/resources/icons/settings.svg index 77a2ccfa9..3942a0916 100644 --- a/cockatrice/resources/icons/settings.svg +++ b/cockatrice/resources/icons/settings.svg @@ -1,280 +1,4 @@ - - - - - - - - Ruota dentata grigia - - - - unsorted - - - - - Open Clip Art Library - - - - - Architetto Francesco Rollandin - - - - - Architetto Francesco Rollandin - - - - image/svg+xml - - - en - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/share.svg b/cockatrice/resources/icons/share.svg index a84f55847..9d6ff21a0 100644 --- a/cockatrice/resources/icons/share.svg +++ b/cockatrice/resources/icons/share.svg @@ -1,25 +1,7 @@ - - - - - - - - - + + + + + + diff --git a/cockatrice/resources/icons/sort_arrow_down.svg b/cockatrice/resources/icons/sort_arrow_down.svg index adf10f9f0..e10b401f2 100644 --- a/cockatrice/resources/icons/sort_arrow_down.svg +++ b/cockatrice/resources/icons/sort_arrow_down.svg @@ -1 +1,7 @@ - \ No newline at end of file + + + + + + + diff --git a/cockatrice/resources/icons/spectator.svg b/cockatrice/resources/icons/spectator.svg index c580c448d..19242f058 100644 --- a/cockatrice/resources/icons/spectator.svg +++ b/cockatrice/resources/icons/spectator.svg @@ -1,106 +1,4 @@ - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/swap.svg b/cockatrice/resources/icons/swap.svg index a18ca2f06..4eddb8c08 100644 --- a/cockatrice/resources/icons/swap.svg +++ b/cockatrice/resources/icons/swap.svg @@ -1,12 +1,6 @@ - - - - - - - - \ No newline at end of file + + + + + + diff --git a/cockatrice/resources/icons/sync.svg b/cockatrice/resources/icons/sync.svg index e287b1993..fa00b3024 100644 --- a/cockatrice/resources/icons/sync.svg +++ b/cockatrice/resources/icons/sync.svg @@ -1,6 +1,6 @@ - - - - - + + + + + diff --git a/cockatrice/resources/icons/tab_changed.svg b/cockatrice/resources/icons/tab_changed.svg index ae09ef8fd..1d9b8d09c 100644 --- a/cockatrice/resources/icons/tab_changed.svg +++ b/cockatrice/resources/icons/tab_changed.svg @@ -1,116 +1,4 @@ - - - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - + + + diff --git a/cockatrice/resources/icons/tags.svg b/cockatrice/resources/icons/tags.svg new file mode 100644 index 000000000..e3defcd0d --- /dev/null +++ b/cockatrice/resources/icons/tags.svg @@ -0,0 +1,4 @@ + + + + diff --git a/cockatrice/resources/icons/update.png b/cockatrice/resources/icons/update.png deleted file mode 100644 index 825c0aee7..000000000 Binary files a/cockatrice/resources/icons/update.png and /dev/null differ diff --git a/cockatrice/resources/icons/view.svg b/cockatrice/resources/icons/view.svg index e5676d825..19242f058 100644 --- a/cockatrice/resources/icons/view.svg +++ b/cockatrice/resources/icons/view.svg @@ -1,29 +1,4 @@ - - - - - - - - - - - - - - - - image/svg+xml - - - - - - - - - - - - - \ No newline at end of file + + + + diff --git a/cockatrice/src/client/crash/crash_reporter.cpp b/cockatrice/src/client/crash/crash_reporter.cpp index 07a0389c7..cba84994c 100644 --- a/cockatrice/src/client/crash/crash_reporter.cpp +++ b/cockatrice/src/client/crash/crash_reporter.cpp @@ -614,3 +614,19 @@ QString CrashReporter::redactCockatriceUrls(const QString &line) redacted.append(line.mid(pos)); return redacted; } + +void CrashReporter::addBreadcrumb(const char *category, const char *message) +{ +#if COCKATRICE_WITH_CRASH_REPORTING + if (category == nullptr || message == nullptr) { + return; + } + sentry_value_t crumb = sentry_value_new_breadcrumb("default", message); + sentry_value_set_by_key(crumb, "category", sentry_value_new_string(category)); + sentry_value_set_by_key(crumb, "level", sentry_value_new_string("info")); + sentry_add_breadcrumb(crumb); +#else + Q_UNUSED(category); + Q_UNUSED(message); +#endif +} diff --git a/cockatrice/src/client/crash/crash_reporter.h b/cockatrice/src/client/crash/crash_reporter.h index aecc6ade3..f06103188 100644 --- a/cockatrice/src/client/crash/crash_reporter.h +++ b/cockatrice/src/client/crash/crash_reporter.h @@ -167,6 +167,17 @@ public: static QString sanitizeLogLine(const QString &line); static QString redactCockatriceUrls(const QString &line); + /** + * @brief Record a fixed-text lifecycle breadcrumb on the active Sentry scope. + * + * Native crashes whose stack walk loses the in-app frames (e.g. a QMap lookup + * that dies inside Qt's string comparison) otherwise arrive with no hint of + * what the client was doing. Only pass compile-time string literals: no user + * names, card names, paths or URLs, so no personal data can leak through this + * channel. Thread-safe; a no-op in OFF builds or before init(). + */ + static void addBreadcrumb(const char *category, const char *message); + private: bool ready = false; QString sentryDatabasePath; diff --git a/cockatrice/src/client/network/interfaces/deck_analysis_url.h b/cockatrice/src/client/network/interfaces/deck_analysis_url.h new file mode 100644 index 000000000..425056270 --- /dev/null +++ b/cockatrice/src/client/network/interfaces/deck_analysis_url.h @@ -0,0 +1,42 @@ +/** + * @file deck_analysis_url.h + * @ingroup ApiInterfaces + */ + +#ifndef DECK_ANALYSIS_URL_H +#define DECK_ANALYSIS_URL_H + +#include +#include + +/** + * Validates a deck address returned by a deck analysis service. + * + * Only the expected host (optionally "www."-prefixed), HTTPS, default ports and + * URLs without user information are accepted. Legacy HTTP addresses for the same + * service are upgraded to HTTPS in place instead of being rejected. + * + * Relative addresses must be resolved against the response URL by the caller. + * + * @return true when @p url names a deck on @p serviceHost; false otherwise. + */ +inline bool normalizeAnalysisUrl(QUrl &url, const QString &serviceHost) +{ + const QString host = url.host().toLower(); + const bool legacyHttp = url.scheme() == QStringLiteral("http"); + const int defaultPort = legacyHttp ? 80 : 443; + if (!url.isValid() || (!legacyHttp && url.scheme() != QStringLiteral("https")) || !url.userInfo().isEmpty() || + !url.path().startsWith('/') || (url.port() != -1 && url.port() != defaultPort) || + (host != serviceHost && host != QStringLiteral("www.") + serviceHost)) { + return false; + } + // Both services accept HTTPS. Older public metadata may still identify the + // same deck with HTTP; retain its host/path/query while using HTTPS to open it. + if (legacyHttp) { + url.setScheme(QStringLiteral("https")); + url.setPort(-1); + } + return true; +} + +#endif diff --git a/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp b/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp index 508f0bdc9..22be120c4 100644 --- a/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp +++ b/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp @@ -1,5 +1,6 @@ #include "deck_stats_interface.h" +#include "deck_analysis_url.h" #include "libcockatrice/card/card_info.h" #include "libcockatrice/card/database/card_database_querier.h" #include "libcockatrice/deck_list/tree/inner_deck_list_node.h" @@ -10,12 +11,14 @@ #include #include #include +#include #include #include #include #include +#include #include -#include +#include #include #include #include @@ -29,30 +32,56 @@ DeckStatsInterface::DeckStatsInterface(QObject *parent) : QObject(parent) connect(manager, &QNetworkAccessManager::finished, this, &DeckStatsInterface::queryFinished); } +void DeckStatsInterface::showError(const QString &message) +{ + // The notice belongs to the originating editor, not this one-shot request. + // Retire the request before opening UI: opening a window may reenter Qt. + QWidget *owner = qobject_cast(parent()); + auto *notice = new QMessageBox(QMessageBox::Critical, tr("Deck analysis"), message, QMessageBox::Ok, owner); + notice->setTextFormat(Qt::PlainText); + notice->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard); + notice->setAttribute(Qt::WA_DeleteOnClose); + deleteLater(); + notice->open(); +} + void DeckStatsInterface::queryFinished(QNetworkReply *reply) { - if (reply->error() != QNetworkReply::NoError) { - QMessageBox::critical(nullptr, tr("Error"), reply->errorString()); - reply->deleteLater(); - deleteLater(); + if (replyHandled) { return; } - - QString data(reply->readAll()); + replyHandled = true; + // All terminal paths release the reply before showing UI or invoking an + // external URL handler, either of which may destroy the originating editor. reply->deleteLater(); - - static const QRegularExpression rx("error() != QNetworkReply::NoError) { + showError(reply->errorString()); return; } - QString deckUrl = match.captured(1); - QDesktopServices::openUrl(deckUrl); + const QString data = QString::fromUtf8(reply->readAll()); + static const QRegularExpression rx( + R"(]*\bproperty\s*=\s*(['"])og:url\1)[^>]*\bcontent\s*=\s*(['"])(.*?)\2)", + QRegularExpression::CaseInsensitiveOption | QRegularExpression::DotMatchesEverythingOption); + const auto match = rx.match(data); + if (!match.hasMatch()) { + showError(tr("The reply from Deckstats did not contain a deck address.")); + return; + } + const QString address = QTextDocumentFragment::fromHtml(match.captured(3)).toPlainText(); + QUrl deckUrl = reply->url().resolved(QUrl(address, QUrl::StrictMode)); + if (address.trimmed().isEmpty() || !normalizeAnalysisUrl(deckUrl, QStringLiteral("deckstats.net"))) { + showError(tr("The reply from Deckstats contained an invalid deck address.")); + return; + } + + QPointer guard(this); deleteLater(); + if (!QDesktopServices::openUrl(deckUrl) && guard) { + showError(tr("Could not open your browser. Open this address to see the analyzed deck:\n%1") + .arg(deckUrl.toDisplayString())); + } } void DeckStatsInterface::getAnalyzeRequestData(const DeckList &deck, QByteArray &data) @@ -60,12 +89,8 @@ void DeckStatsInterface::getAnalyzeRequestData(const DeckList &deck, QByteArray DeckList deckWithoutTokens; copyDeckWithoutTokens(deck, deckWithoutTokens); - QUrl params; - QUrlQuery urlQuery; - urlQuery.addQueryItem("deck", deckWithoutTokens.writeToString_Plain()); - urlQuery.addQueryItem("decktitle", deck.getName()); - params.setQuery(urlQuery); - data.append(params.query(QUrl::EncodeReserved).toUtf8()); + data.append("deck=" + QUrl::toPercentEncoding(deckWithoutTokens.writeToString_Plain())); + data.append("&decktitle=" + QUrl::toPercentEncoding(deck.getName())); } void DeckStatsInterface::analyzeDeck(const DeckList &deck) @@ -84,7 +109,9 @@ void DeckStatsInterface::copyDeckWithoutTokens(const DeckList &source, DeckList { auto copyIfNotAToken = [&destination](const auto node, const auto card) { CardInfoPtr dbCard = CardDatabaseManager::query()->getCardInfo(card->getName()); - if (dbCard && !dbCard->getIsToken() && node->getName() != DECK_ZONE_MAYBEBOARD) { + // Missing local card data is not evidence that a card is a token. Keep + // its original name and quantity so the service analyzes the whole deck. + if ((!dbCard || !dbCard->getIsToken()) && node->getName() != DECK_ZONE_MAYBEBOARD) { DecklistCardNode *addedCard = destination.addCard(card->getName(), node->getName(), -1); addedCard->setNumber(card->getNumber()); } diff --git a/cockatrice/src/client/network/interfaces/deck_stats_interface.h b/cockatrice/src/client/network/interfaces/deck_stats_interface.h index e7791075c..b70646403 100644 --- a/cockatrice/src/client/network/interfaces/deck_stats_interface.h +++ b/cockatrice/src/client/network/interfaces/deck_stats_interface.h @@ -11,6 +11,7 @@ #include class QByteArray; +class QString; class QNetworkAccessManager; class QNetworkReply; class DeckList; @@ -20,6 +21,8 @@ class DeckStatsInterface : public QObject Q_OBJECT private: QNetworkAccessManager *manager; + bool replyHandled = false; + void showError(const QString &message); /** * Deckstats doesn't recognize token cards, and instead tries to find the diff --git a/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp b/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp index 34dd03765..a57877b6a 100644 --- a/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp +++ b/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp @@ -1,5 +1,6 @@ #include "tapped_out_interface.h" +#include "deck_analysis_url.h" #include "libcockatrice/card/card_info.h" #include "libcockatrice/card/database/card_database_querier.h" #include "libcockatrice/deck_list/tree/inner_deck_list_node.h" @@ -13,15 +14,17 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include -#include #include +#include #include #include #include @@ -35,54 +38,67 @@ TappedOutInterface::TappedOutInterface(QObject *parent) : QObject(parent) connect(manager, &QNetworkAccessManager::finished, this, &TappedOutInterface::queryFinished); } +void TappedOutInterface::showError(const QString &message) +{ + // The notice belongs to the originating editor, not this one-shot request. + // Retire the request before opening UI: opening a window may reenter Qt. + QWidget *owner = qobject_cast(parent()); + auto *notice = new QMessageBox(QMessageBox::Critical, tr("Deck analysis"), message, QMessageBox::Ok, owner); + notice->setTextFormat(Qt::PlainText); + notice->setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::TextSelectableByKeyboard); + notice->setAttribute(Qt::WA_DeleteOnClose); + deleteLater(); + notice->open(); +} + void TappedOutInterface::queryFinished(QNetworkReply *reply) { + if (replyHandled) { + return; + } + replyHandled = true; + // All terminal paths release the reply before showing UI or invoking an + // external URL handler, either of which may destroy the originating editor. + reply->deleteLater(); if (reply->error() != QNetworkReply::NoError) { - QMessageBox::critical(nullptr, tr("Error"), reply->errorString()); - reply->deleteLater(); - deleteLater(); + showError(reply->errorString()); return; } - int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - if (reply->hasRawHeader("Location")) { - /* - * If the reply contains a "Location" header, a relative URL to the deck on TappedOut - * can be extracted from the header. The http status is a 302 "redirect". - */ - QString deckUrl = reply->rawHeader("Location"); - qCInfo(TappedOutInterfaceLog) << "Tappedout: good reply, http status" << httpStatus << "location" << deckUrl; - QDesktopServices::openUrl("https://tappedout.net" + deckUrl); - } else { - /* - * Otherwise, the deck has not been parsed correctly. Error messages can be extracted - * from the html. Css pseudo selector for errors: $("div.alert-danger > ul > li") - */ - QString data(reply->readAll()); - QStringList errorMessageList = {tr("Unable to analyze the deck.")}; - - static const QRegularExpression rx("
(.*?)"); - auto match = rx.match(data); - if (match.hasMatch()) { - QString errors = match.captured(1); - static const QRegularExpression rx2("
  • (.*?)
  • "); - static const QRegularExpression rxremove("<[^>]*>"); - auto matchIterator = rx2.globalMatch(errors); - while (matchIterator.hasNext()) { - auto match2 = matchIterator.next(); - errorMessageList.append(match2.captured(1).remove(rxremove).simplified()); - } + const int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (httpStatus >= 200 && httpStatus < 400 && reply->hasRawHeader("Location")) { + const QByteArray location = reply->rawHeader("Location"); + QUrl deckUrl = reply->url().resolved(QUrl::fromEncoded(location, QUrl::StrictMode)); + if (location.trimmed().isEmpty() || !normalizeAnalysisUrl(deckUrl, QStringLiteral("tappedout.net"))) { + showError(tr("The reply from TappedOut contained an invalid deck address.")); + return; } - - QString errorMessage = errorMessageList.join("\n"); - qCWarning(TappedOutInterfaceLog) << "Tappedout: bad reply, http status" << httpStatus << "size" << data.size() - << "message" << errorMessage; - - QMessageBox::critical(nullptr, tr("Error"), errorMessage); + QPointer guard(this); + deleteLater(); + if (!QDesktopServices::openUrl(deckUrl) && guard) { + showError(tr("Could not open your browser. Open this address to see the analyzed deck:\n%1") + .arg(deckUrl.toDisplayString())); + } + return; } - reply->deleteLater(); - deleteLater(); + const QString data = QString::fromUtf8(reply->readAll()); + QStringList errorMessageList = {tr("Unable to analyze the deck on TappedOut.")}; + static const QRegularExpression errorsRx("
    (.*?)", + QRegularExpression::DotMatchesEverythingOption); + const auto match = errorsRx.match(data); + if (match.hasMatch()) { + static const QRegularExpression itemRx("
  • (.*?)
  • ", QRegularExpression::DotMatchesEverythingOption); + auto items = itemRx.globalMatch(match.captured(1)); + while (items.hasNext()) { + const QString error = QTextDocumentFragment::fromHtml(items.next().captured(1)).toPlainText().simplified(); + if (!error.isEmpty()) { + errorMessageList.append(error); + } + } + } + qCWarning(TappedOutInterfaceLog) << "TappedOut analysis failed, HTTP status" << httpStatus; + showError(errorMessageList.join('\n')); } void TappedOutInterface::getAnalyzeRequestData(const DeckList &deck, QByteArray &data) @@ -90,13 +106,9 @@ void TappedOutInterface::getAnalyzeRequestData(const DeckList &deck, QByteArray DeckList mainboard, sideboard; copyDeckSplitMainAndSide(deck, mainboard, sideboard); - QUrl params; - QUrlQuery urlQuery; - urlQuery.addQueryItem("name", deck.getName()); - urlQuery.addQueryItem("mainboard", mainboard.writeToString_Plain(false, true)); - urlQuery.addQueryItem("sideboard", sideboard.writeToString_Plain(false, true)); - params.setQuery(urlQuery); - data.append(params.query(QUrl::EncodeReserved).toUtf8()); + data.append("name=" + QUrl::toPercentEncoding(deck.getName())); + data.append("&mainboard=" + QUrl::toPercentEncoding(mainboard.writeToString_Plain(false, true))); + data.append("&sideboard=" + QUrl::toPercentEncoding(sideboard.writeToString_Plain(false, true))); } void TappedOutInterface::analyzeDeck(const DeckList &deck) @@ -117,7 +129,9 @@ void TappedOutInterface::copyDeckSplitMainAndSide(const DeckList &source, DeckLi { auto copyMainOrSide = [&mainboard, &sideboard](const auto node, const auto card) { CardInfoPtr dbCard = CardDatabaseManager::query()->getCardInfo(card->getName()); - if (!dbCard || dbCard->getIsToken() || node->getName() == DECK_ZONE_MAYBEBOARD) { + // Exclude confirmed tokens, but preserve cards missing from the local + // database, including newly released cards in an imported deck. + if ((dbCard && dbCard->getIsToken()) || node->getName() == DECK_ZONE_MAYBEBOARD) { return; } diff --git a/cockatrice/src/client/network/interfaces/tapped_out_interface.h b/cockatrice/src/client/network/interfaces/tapped_out_interface.h index 615991b07..27e8c2273 100644 --- a/cockatrice/src/client/network/interfaces/tapped_out_interface.h +++ b/cockatrice/src/client/network/interfaces/tapped_out_interface.h @@ -14,6 +14,7 @@ inline Q_LOGGING_CATEGORY(TappedOutInterfaceLog, "tapped_out_interface"); class QByteArray; +class QString; class QNetworkAccessManager; class QNetworkReply; class DeckList; @@ -29,6 +30,8 @@ class TappedOutInterface : public QObject Q_OBJECT private: QNetworkAccessManager *manager; + bool replyHandled = false; + void showError(const QString &message); void copyDeckSplitMainAndSide(const DeckList &source, DeckList &mainboard, DeckList &sideboard); private slots: diff --git a/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.cpp b/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.cpp index 7127b0a29..2b307735d 100644 --- a/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.cpp +++ b/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.cpp @@ -15,14 +15,18 @@ static const QString ARCHIDEKT_SUFFIX = "/?format=json"; static const QString MOXFIELD_BASE = "https://api.moxfield.com/v2/decks/all/"; static const QString MOXFIELD_SUFFIX = "/"; +static const QString DECKSTATS_BASE = "https://deckstats.net/decks/"; static const QString DECKSTATS_SUFFIX = "?include_comments=1&export_mtgarena=1"; +static const QString MANABOX_BASE = "https://cloud.manabox.app/decks/"; + bool parseDeckUrl(const QString &url, ParsedDeckInfo &outInfo) { static QRegularExpression rxTappedOut("tappedout\\.net/(?:mtg-decks/)?([^/?#]+)"); static QRegularExpression rxArchidekt("archidekt\\.com/decks/(\\d+)"); static QRegularExpression rxMoxfield("moxfield\\.com/decks/([a-zA-Z0-9_-]+)"); static QRegularExpression rxDeckstats("deckstats\\.net/decks/(\\d+/[a-zA-Z0-9_-]+)"); + static QRegularExpression rxManabox("manabox\\.app/decks/([a-zA-Z0-9_-]+)"); QRegularExpressionMatch match; @@ -49,11 +53,18 @@ bool parseDeckUrl(const QString &url, ParsedDeckInfo &outInfo) return true; } else if ((match = rxDeckstats.match(url)).hasMatch()) { QString deckPath = match.captured(1); - outInfo = ParsedDeckInfo{.baseUrl = "https://deckstats.net/decks/", + outInfo = ParsedDeckInfo{.baseUrl = DECKSTATS_BASE, .deckID = deckPath, - .fullUrl = "https://deckstats.net/decks/" + deckPath + DECKSTATS_SUFFIX, + .fullUrl = DECKSTATS_BASE + deckPath + DECKSTATS_SUFFIX, .provider = DeckProvider::Deckstats}; return true; + } else if ((match = rxManabox.match(url)).hasMatch()) { + QString deckPath = match.captured(1); + outInfo = ParsedDeckInfo{.baseUrl = MANABOX_BASE, + .deckID = deckPath, + .fullUrl = MANABOX_BASE + deckPath, + .provider = DeckProvider::Manabox}; + return true; } return false; diff --git a/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.h b/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.h index bbdd70c97..d3bff976f 100644 --- a/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.h +++ b/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.h @@ -15,6 +15,7 @@ enum class DeckProvider Archidekt, Moxfield, Deckstats, + Manabox, Unknown }; diff --git a/cockatrice/src/client/network/parsers/interface_json_deck_parser.h b/cockatrice/src/client/network/parsers/interface_json_deck_parser.h index 87fde4d54..7e15d472f 100644 --- a/cockatrice/src/client/network/parsers/interface_json_deck_parser.h +++ b/cockatrice/src/client/network/parsers/interface_json_deck_parser.h @@ -8,12 +8,19 @@ #define INTERFACE_JSON_DECK_PARSER_H #include "../../../interface/deck_loader/card_node_function.h" +#include "libcockatrice/deck_list/tree/inner_deck_list_node.h" #include #include +#include +#include +#include +#include #include #include +inline Q_LOGGING_CATEGORY(InterfaceJsonDeckParserLog, "interface_json_deck_parser"); + class IJsonDeckParser { public: @@ -32,9 +39,6 @@ public: QString deckName = obj.value("name").toString(); QString deckDescription = obj.value("description").toString(); - deckList.setName(deckName); - deckList.setComments(deckDescription); - QString outputText; QTextStream outStream(&outputText); @@ -50,8 +54,14 @@ public: outStream << quantity << ' ' << cardName << " (" << setName << ") " << collectorNumber << '\n'; } + outStream.flush(); + outStream.seek(0); deckList.loadFromStream_Plain(outStream, false, CardNameNormalizer()); + deckList.setName(deckName); + deckList.setComments(deckDescription); deckList.forEachCard(CardNodeFunction::ResolveProviderId()); + deckList.setName(deckName); + deckList.setComments(deckDescription); return deckList; } @@ -67,9 +77,6 @@ public: QString deckName = obj.value("name").toString(); QString deckDescription = obj.value("description").toString(); - deckList.setName(deckName); - deckList.setComments(deckDescription); - QString outputText; QTextStream outStream(&outputText); @@ -97,8 +104,14 @@ public: outStream << quantity << ' ' << cardName << " (" << setName << ") " << collectorNumber << '\n'; } + outStream.flush(); + outStream.seek(0); deckList.loadFromStream_Plain(outStream, false, CardNameNormalizer()); + deckList.setName(deckName); + deckList.setComments(deckDescription); deckList.forEachCard(CardNodeFunction::ResolveProviderId()); + deckList.setName(deckName); + deckList.setComments(deckDescription); QJsonObject commandersObj = obj.value("commanders").toObject(); if (!commandersObj.isEmpty()) { @@ -114,6 +127,113 @@ public: } } + // Moxfield stores Oathbreaker signature spells in their own board, + // separate from both mainboard and commanders. They are playable deck + // cards, but must not replace the Oathbreaker as the banner card. + const QJsonObject signatureSpells = obj.value("signatureSpells").toObject(); + for (auto it = signatureSpells.begin(); it != signatureSpells.end(); ++it) { + const QJsonObject entry = it.value().toObject(); + const QJsonObject card = entry.value("card").toObject(); + const QString name = card.value("name").toString(); + const int quantity = entry.value("quantity").toInt(); + if (name.isEmpty() || quantity <= 0) { + continue; + } + const QString set = card.value("set").toString().toUpper(); + const QString collectorNumber = card.value("cn").toString(); + const QString providerId = card.value("scryfall_id").toString(); + deckList.addCards(name, quantity, DECK_ZONE_MAIN, -1, set, collectorNumber, providerId); + } + + return deckList; + } +}; + +class ManaboxJsonParser : public IJsonDeckParser +{ + enum BoardType : quint32 + { + MainBoard = 0, + SideBoard = 1, + Commander = 2, + MaybeBoard = 3, + }; + +public: + DeckList parse(const QJsonObject &obj) override + { + DeckList deckList; + + QString deckName = obj.value("name").toString(); + + QString outputText; + QString mainboardText; + QString sideboardText; + QTextStream mainboardStream(&mainboardText); + QTextStream sideboardStream(&sideboardText); + QTextStream outStream(&outputText); + + auto commanderList = QList(); + auto maybeboardList = QList(); + for (auto entry : obj.value("cards").toArray()) { + auto card = entry.toObject(); + + auto collectorNumber = card.value("collectorNumber").toString(); + auto cardName = card.value("name").toString(); + auto boardType = static_cast(card.value("boardCategory").toInt()); + auto setName = card.value("setId").toString(); + auto quantity = card.value("quantity").toInt(); + auto providerId = card.value("scryfallId").toString(); + + switch (boardType) { + case MainBoard: + mainboardStream << quantity << ' ' << cardName << " (" << setName << ") " << collectorNumber + << '\n'; + break; + case SideBoard: + sideboardStream << "SB: " << quantity << ' ' << cardName << " (" << setName << ") " + << collectorNumber << '\n'; + break; + case MaybeBoard: + maybeboardList.append(card); + break; + case Commander: + commanderList.append(card); + break; + default: + qCWarning(InterfaceJsonDeckParserLog) + << "Unknown ManaBox Zone" << card.value("boardCategory").toInt() << "for" << cardName; + break; + } + } + + outStream << mainboardText << sideboardText; + + deckList.loadFromStream_Plain(outStream, false, CardNameNormalizer()); + deckList.setName(deckName); + deckList.forEachCard(CardNodeFunction::ResolveProviderId()); + + for (const auto &maybeboardCard : maybeboardList) { + QString cardName = maybeboardCard.value("name").toString(); + QString setName = maybeboardCard.value("setId").toString().toUpper(); + QString collectorNumber = maybeboardCard.value("collectorNumber").toString(); + QString providerId = maybeboardCard.value("scryfallId").toString(); + + for (int x = 0; x < maybeboardCard.value("quantity").toInt(); x++) { + deckList.addCard(cardName, DECK_ZONE_MAYBEBOARD, -1, setName, collectorNumber, providerId); + } + } + for (const auto &commanderCard : commanderList) { + + QString commanderName = commanderCard.value("name").toString(); + QString setName = commanderCard.value("setId").toString().toUpper(); + QString collectorNumber = commanderCard.value("collectorNumber").toString(); + QString providerId = commanderCard.value("scryfallId").toString(); + + deckList.setBannerCard({commanderName, providerId}); + deckList.addCard(commanderName, DECK_ZONE_MAIN, -1, setName, collectorNumber, providerId); + } + return deckList; } }; diff --git a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp index 62668c4d9..d74414d4a 100644 --- a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp +++ b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp @@ -2,253 +2,305 @@ #include "../../../../main.h" #include "../../../settings/cache_settings.h" +#include "spoiler_file.h" -#include -#include -#include +#include #include -#include #include #include #include -#include -#include -#include -#include #include #include #include -#include #include -#include +#include #include #include -#include -#include +#include #include #include #include #include #include -#include -#include #include -#define SPOILERS_STATUS_URL "https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/SpoilerSeasonEnabled" -#define SPOILERS_URL "https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/spoiler.xml" - -SpoilerBackgroundUpdater::SpoilerBackgroundUpdater(QObject *apParent) : QObject(apParent), cardUpdateProcess(nullptr) +namespace { - isSpoilerDownloadEnabled = SettingsCache::instance().downloads().getDownloadSpoilersStatus(); - if (isSpoilerDownloadEnabled) { - // Start the process of checking if we're in spoiler season - // File exists means we're in spoiler season - startSpoilerDownloadProcess(SPOILERS_STATUS_URL, false); +const QUrl SPOILERS_STATUS_URL( + QStringLiteral("https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/SpoilerSeasonEnabled")); +const QUrl SPOILERS_URL(QStringLiteral("https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/spoiler.xml")); +// Request coordination stays on the GUI thread; reload workers never access it. +QPointer currentUpdater; +QPointer> activeReload; +quint64 newestOperation = 0; + +bool removeSpoilerFile(const QString &path) +{ + QFile file(path); + if (!file.exists()) { + return false; + } + if (!file.remove()) { + qCWarning(SpoilerBackgroundUpdaterLog) << "Could not remove spoiler database" << path << file.errorString(); + return false; + } + return true; +} +} // namespace + +SpoilerBackgroundUpdater::SpoilerBackgroundUpdater(QObject *parent, bool forceReload) + : QObject(parent), enabledAtStart(SettingsCache::instance().downloads().getDownloadSpoilersStatus()), + forceReload(forceReload), targetPath(SettingsCache::instance().paths().getSpoilerCardDatabasePath()), + operationId(++newestOperation), manager(NetworkManagerFactory::create(this)), notificationIcon(trayIcon) +{ + connect(&SettingsCache::instance().downloads(), &DownloadSettings::downloadSpoilerStatusChanged, this, + &SpoilerBackgroundUpdater::cancelIfSettingsChanged); + connect(&SettingsCache::instance().paths(), &PathsSettings::cardDatabasePathChanged, this, + &SpoilerBackgroundUpdater::cancelIfSettingsChanged); + // Even a disabled/no-op operation completes after its caller can connect signals. + QTimer::singleShot(0, this, &SpoilerBackgroundUpdater::begin); +} + +void SpoilerBackgroundUpdater::begin() +{ + if (completed) { + return; + } + if (cancelled || operationId != newestOperation || + (reloadOnly ? SettingsCache::instance().downloads().getDownloadSpoilersStatus() + : (!enabledAtStart || !SettingsCache::instance().downloads().getDownloadSpoilersStatus())) || + targetPath != SettingsCache::instance().paths().getSpoilerCardDatabasePath()) { + finish(); + return; + } + const auto previous = currentUpdater; + if (activeReload) { + // The existing database API stores its final load status after its parser + // mutex is released. Do not overlap updater-owned calls to that API. + connect(activeReload, &QFutureWatcher::finished, this, &SpoilerBackgroundUpdater::begin, + static_cast(Qt::QueuedConnection | Qt::SingleShotConnection)); + if (previous && previous != this) { + previous->cancel(); + } + return; + } + currentUpdater = this; + QPointer guard(this); + if (previous && previous != this) { + previous->cancel(); + } + if (!guard) { + return; + } + if (!isCurrent()) { + finish(); + } else if (reloadOnly) { + if (QFileInfo::exists(targetPath) && !removeSpoilerFile(targetPath)) { + finish(tr("The spoiler database could not be removed. Check its file permissions and use Reload Card " + "Database to retry.")); + return; + } + reloadDatabase(false); } else { - qCInfo(SpoilerBackgroundUpdaterLog) << "Spoilers Disabled"; + downloadFromURL(SPOILERS_STATUS_URL, false); } } -void SpoilerBackgroundUpdater::startSpoilerDownloadProcess(QString url, bool saveResults) +bool SpoilerBackgroundUpdater::isCurrent() const { - auto spoilerURL = QUrl(url); - downloadFromURL(spoilerURL, saveResults); + return !completed && !cancelled && currentUpdater == this && operationId == newestOperation && + (reloadOnly != SettingsCache::instance().downloads().getDownloadSpoilersStatus()) && + targetPath == SettingsCache::instance().paths().getSpoilerCardDatabasePath(); } -void SpoilerBackgroundUpdater::downloadFromURL(QUrl url, bool saveResults) +void SpoilerBackgroundUpdater::cancelIfSettingsChanged() { - auto *nam = NetworkManagerFactory::create(this); - auto request = QNetworkRequest(url); - request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); - QNetworkReply *reply = nam->get(request); + if (reloadOnly == SettingsCache::instance().downloads().getDownloadSpoilersStatus() || + targetPath != SettingsCache::instance().paths().getSpoilerCardDatabasePath()) { + cancel(); + } +} +void SpoilerBackgroundUpdater::cancel() +{ + if (completed) { + return; + } + cancelled = true; + const auto reply = activeReply; + activeReply.clear(); + QPointer guard(this); + if (reply) { + reply->abort(); + if (reply) { + reply->deleteLater(); + } + } + // A running reload owns no updater state. Allow its completion to retire its + // watcher before a heap-owned updater is released by the caller. + if (guard && !reloading) { + finish(); + } +} + +void SpoilerBackgroundUpdater::downloadFromURL(const QUrl &url, bool saveResults) +{ + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); + request.setTransferTimeout(60000); + activeReply = manager->get(request); if (saveResults) { - // This will write out to the file (used for spoiler.xml) - connect(reply, &QNetworkReply::finished, this, &SpoilerBackgroundUpdater::actDownloadFinishedSpoilersFile); + connect(activeReply, &QNetworkReply::finished, this, + &SpoilerBackgroundUpdater::actDownloadFinishedSpoilersFile); } else { - // This will check the status (used to see if we're in spoiler season or not) - connect(reply, &QNetworkReply::finished, this, &SpoilerBackgroundUpdater::actCheckIfSpoilerSeasonEnabled); + connect(activeReply, &QNetworkReply::finished, this, &SpoilerBackgroundUpdater::actCheckIfSpoilerSeasonEnabled); + } +} + +void SpoilerBackgroundUpdater::actCheckIfSpoilerSeasonEnabled() +{ + auto *reply = qobject_cast(sender()); + if (!reply || reply != activeReply) { + return; + } + activeReply.clear(); + reply->deleteLater(); + if (!isCurrent()) { + finish(); + return; + } + if (reply->error() == QNetworkReply::NoError) { + downloadFromURL(SPOILERS_URL, true); + } else if (reply->error() == QNetworkReply::ContentNotFoundError) { + const bool existed = QFileInfo::exists(targetPath); + seasonEnded = existed; + if (existed && !removeSpoilerFile(targetPath)) { + finish(tr("The spoiler season has ended, but the old spoiler database could not be removed.")); + return; + } + if (existed || forceReload) { + reloadDatabase(false); + } else { + finish(); + } + } else { + finish(tr("Could not check spoiler availability: %1").arg(reply->errorString())); } } void SpoilerBackgroundUpdater::actDownloadFinishedSpoilersFile() { - // Check for server reply - auto *reply = dynamic_cast(sender()); - QNetworkReply::NetworkError errorCode = reply->error(); - - if (errorCode == QNetworkReply::NoError) { - spoilerData = reply->readAll(); - - // Save the spoiler.xml file to the disk - saveDownloadedFile(spoilerData); - - reply->deleteLater(); - emit spoilerCheckerDone(); + auto *reply = qobject_cast(sender()); + if (!reply || reply != activeReply) { + return; + } + activeReply.clear(); + reply->deleteLater(); + if (!isCurrent()) { + finish(); + return; + } + if (reply->error() != QNetworkReply::NoError) { + finish(tr("Could not download spoilers: %1").arg(reply->errorString())); + return; + } + spoilerData = reply->readAll(); + QString error; + const auto result = SpoilerFile::save(targetPath, spoilerData, [this] { return isCurrent(); }, error); + if (result == SpoilerFile::SaveResult::Failed) { + finish(isCurrent() ? error : QString()); + } else if (result == SpoilerFile::SaveResult::Written || forceReload) { + reloadDatabase(result == SpoilerFile::SaveResult::Written); } else { - qCWarning(SpoilerBackgroundUpdaterLog) << "Error downloading spoilers file" << errorCode; - emit spoilerCheckerDone(); + finish(); } } -bool SpoilerBackgroundUpdater::deleteSpoilerFile() +void SpoilerBackgroundUpdater::reloadDatabase(bool updated) { - QString fileName = SettingsCache::instance().paths().getSpoilerCardDatabasePath(); - QFileInfo fi(fileName); - QDir fileDir(fi.path()); - QFile file(fileName); - - // Delete the spoiler.xml file - if (file.exists() && file.remove()) { - qCInfo(SpoilerBackgroundUpdaterLog) << "Deleting spoiler.xml"; - return true; - } - - qCInfo(SpoilerBackgroundUpdaterLog) << "Error: Spoiler.xml not found or not deleted"; - return false; -} - -void SpoilerBackgroundUpdater::actCheckIfSpoilerSeasonEnabled() -{ - auto *response = dynamic_cast(sender()); - QNetworkReply::NetworkError errorCode = response->error(); - - if (errorCode == QNetworkReply::ContentNotFoundError) { - // Spoiler season is offline at this point, so the spoiler.xml file can be safely deleted - // The user should run Oracle to get the latest card information - if (deleteSpoilerFile() && trayIcon) { - trayIcon->showMessage(tr("Spoilers season has ended"), tr("Deleting spoiler.xml. Please run Oracle")); + reloading = true; + auto *database = CardDatabaseManager::getInstance(); + // The job outlives an individual request, so destroying that request cannot + // let a newer updater overlap the worker that is still completing. + auto *watcher = new QFutureWatcher(QCoreApplication::instance()); + activeReload = watcher; + connect(watcher, &QFutureWatcher::finished, watcher, [watcher] { + if (activeReload == watcher) { + activeReload.clear(); } - - qCInfo(SpoilerBackgroundUpdaterLog) << "Spoiler Season Offline"; - emit spoilerCheckerDone(); - } else if (errorCode == QNetworkReply::NoError) { - qCInfo(SpoilerBackgroundUpdaterLog) << "Spoiler Service Online"; - startSpoilerDownloadProcess(SPOILERS_URL, true); - } else if (errorCode == QNetworkReply::HostNotFoundError) { - if (trayIcon) { - trayIcon->showMessage(tr("Spoilers download failed"), tr("No internet connection")); - } - - qCWarning(SpoilerBackgroundUpdaterLog) << "Spoiler download failed due to no internet connection"; - emit spoilerCheckerDone(); - } else { - if (trayIcon) { - trayIcon->showMessage(tr("Spoilers download failed"), tr("Error") + " " + (short)errorCode); - } - - qCWarning(SpoilerBackgroundUpdaterLog) << "Spoiler download failed with reason" << errorCode; - emit spoilerCheckerDone(); - } -} - -bool SpoilerBackgroundUpdater::saveDownloadedFile(QByteArray data) -{ - QString fileName = SettingsCache::instance().paths().getSpoilerCardDatabasePath(); - QFileInfo fi(fileName); - QDir fileDir(fi.path()); - - if (!fileDir.exists() && !fileDir.mkpath(fileDir.absolutePath())) { - return false; - } - - // Check if the data matches. If it does, then spoilers are up to date. - if (getHash(fileName) == getHash(data)) { - if (trayIcon) { - trayIcon->showMessage(tr("Spoilers already up to date"), tr("No new spoilers added")); - } - - qCInfo(SpoilerBackgroundUpdaterLog) << "Spoilers Up to Date"; - return false; - } - - QFile file(fileName); - if (!file.open(QIODevice::WriteOnly)) { - qCWarning(SpoilerBackgroundUpdaterLog) << "Spoiler Service Error: File open (w) failed for" << fileName; - file.close(); - return false; - } - - if (file.write(data) == -1) { - qCWarning(SpoilerBackgroundUpdaterLog) << "Spoiler Service Error: File write (w) failed for" << fileName; - file.close(); - return false; - } - - file.close(); - - // Data written, so reload the card database - qCInfo(SpoilerBackgroundUpdaterLog) << "Spoiler Service Data Written"; - // 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(this); - connect(watcher, &QFutureWatcher::finished, this, [watcher] { watcher->deleteLater(); - CardDatabaseManager::getInstance()->checkUnknownSets(); }); - watcher->setFuture(QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); })); - - // If the user has notifications enabled, let them know - // when the database was last updated - if (trayIcon) { - QList lines = data.split('\n'); - - for (const QByteArray &line : lines) { - if (line.contains("Created At:")) { - QString timeStamp = QString(line).replace("Created At:", "").trimmed(); - timeStamp.chop(6); // Remove " (UTC)" - - auto utcTime = QLocale().toDateTime(timeStamp, "ddd, MMM dd yyyy, hh:mm:ss"); -#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0) - utcTime.setTimeZone(QTimeZone::UTC); -#else - utcTime.setTimeSpec(Qt::UTC); -#endif - - QString localTime = utcTime.toLocalTime().toString("MMM d, hh:mm"); - - trayIcon->showMessage(tr("Spoilers have been updated!"), tr("Last change:") + " " + localTime); - emit spoilersUpdatedSuccessfully(); - return true; - } + connect(watcher, &QFutureWatcher::finished, this, [this, watcher, database, updated] { + bool loaded = false; + try { + loaded = watcher->result() && database->getLoadStatus() == Ok; + } catch (const std::exception &error) { + qCWarning(SpoilerBackgroundUpdaterLog) << "Spoiler database reload failed:" << error.what(); } - } - - return true; + reloading = false; + if (!isCurrent()) { + finish(); + } else if (!loaded) { + finish(reloadOnly + ? tr("Could not reload card data after disabling spoilers. Use Reload Card Database to retry.") + : tr("Could not reload card data after updating spoilers. Use Update Spoilers to retry the " + "reload.")); + } else { + finish({}, updated); + } + }); + watcher->setFuture(QtConcurrent::run([database] { + database->reloadCardDatabasesAndNotify(); + return true; + })); } -QByteArray SpoilerBackgroundUpdater::getHash(const QString fileName) +void SpoilerBackgroundUpdater::finish(const QString &error, bool updated) { - QFile file(fileName); - - if (file.open(QFile::ReadOnly)) { - // Only read the first 512 bytes (enough to get the "created" tag) - const QByteArray bytes = file.read(512); - - QCryptographicHash hash(QCryptographicHash::Algorithm::Md5); - hash.addData(bytes); - - qCInfo(SpoilerBackgroundUpdaterLog) << "File Hash =" << hash.result(); - - file.close(); - return hash.result(); - } else { - qCWarning(SpoilerBackgroundUpdaterLog) << "getHash ReadOnly failed!"; - file.close(); - return QByteArray(); + if (completed) { + return; + } + completed = true; + spoilerData = QByteArray(); + if (currentUpdater == this) { + currentUpdater.clear(); + } + QPointer guard(this); + if (!error.isEmpty() && !cancelled) { + qCWarning(SpoilerBackgroundUpdaterLog) << error; + if (notificationIcon) { + notificationIcon->showMessage(tr("Spoiler update failed"), error); + } + if (!guard) { + return; + } + emit spoilerUpdateFailed(error); + } else if (updated && !cancelled) { + if (notificationIcon) { + notificationIcon->showMessage(tr("Spoilers have been updated!"), + tr("The card database now includes the downloaded spoilers.")); + } + } else if (seasonEnded && !cancelled && notificationIcon) { + notificationIcon->showMessage(tr("Spoiler season has ended"), + tr("The spoiler database was removed. Please run Oracle to get released cards.")); + } + if (guard) { + emit spoilerCheckerDone(); } } -QByteArray SpoilerBackgroundUpdater::getHash(QByteArray data) +SpoilerBackgroundUpdater *SpoilerBackgroundUpdater::current() { - // Only read the first 512 bytes (enough to get the "created" tag) - const QByteArray bytes = data.left(512); - - QCryptographicHash hash(QCryptographicHash::Algorithm::Md5); - hash.addData(bytes); - - qCInfo(SpoilerBackgroundUpdaterLog) << "Data Hash =" << hash.result(); - - return hash.result(); + return currentUpdater; +} + +SpoilerBackgroundUpdater *SpoilerBackgroundUpdater::deleteSpoilerFile() +{ + // Deletion and the following reload share the operation lifetime. Callers can + // observe either failure before the queued operation starts, and an explicit + // retry still reloads when the file has already been removed. + auto *updater = new SpoilerBackgroundUpdater(QCoreApplication::instance(), true); + updater->reloadOnly = true; + connect(updater, &SpoilerBackgroundUpdater::spoilerCheckerDone, updater, &QObject::deleteLater); + return updater; } diff --git a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.h b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.h index a6c4ed00f..fd90eb688 100644 --- a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.h +++ b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.h @@ -2,7 +2,6 @@ * @file spoiler_background_updater.h * @ingroup Client */ -//! \todo Document this file. #ifndef COCKATRICE_SPOILER_DOWNLOADER_H #define COCKATRICE_SPOILER_DOWNLOADER_H @@ -10,10 +9,13 @@ #include #include #include +#include #include #include -class QProcess; +class QNetworkAccessManager; +class QNetworkReply; +class QSystemTrayIcon; class QUrl; inline Q_LOGGING_CATEGORY(SpoilerBackgroundUpdaterLog, "spoiler_background_updater"); @@ -22,30 +24,48 @@ class SpoilerBackgroundUpdater : public QObject { Q_OBJECT public: - explicit SpoilerBackgroundUpdater(QObject *apParent = nullptr); + explicit SpoilerBackgroundUpdater(QObject *parent = nullptr, bool forceReload = false); inline QString getCardUpdaterBinaryName() { return "oracle"; } - QByteArray getHash(const QString fileName); - QByteArray getHash(QByteArray data); - static bool deleteSpoilerFile(); + // Queues removal and reload; the application owns the returned updater and + // deletes it on completion. Callers can connect its failure/completion signals. + static SpoilerBackgroundUpdater *deleteSpoilerFile(); + // The operation currently running, if any. The updater outlives the page that + // started it, so a reopened page uses this to observe the in-flight update. + static SpoilerBackgroundUpdater *current(); private slots: + void begin(); void actDownloadFinishedSpoilersFile(); void actCheckIfSpoilerSeasonEnabled(); + void cancelIfSettingsChanged(); private: - bool isSpoilerDownloadEnabled; - QProcess *cardUpdateProcess; + const bool enabledAtStart; + const bool forceReload; + const QString targetPath; + const quint64 operationId; + QNetworkAccessManager *manager; + QPointer activeReply; + QPointer notificationIcon; QByteArray spoilerData; - void startSpoilerDownloadProcess(QString url, bool saveResults); - void downloadFromURL(QUrl url, bool saveResults); - bool saveDownloadedFile(QByteArray data); + bool completed = false; + bool cancelled = false; + bool reloading = false; + bool seasonEnded = false; + bool reloadOnly = false; + + bool isCurrent() const; + void cancel(); + void finish(const QString &error = {}, bool updated = false); + void downloadFromURL(const QUrl &url, bool saveResults); + void reloadDatabase(bool updated); signals: - void spoilersUpdatedSuccessfully(); void spoilerCheckerDone(); + void spoilerUpdateFailed(const QString &message); }; -#endif // COCKATRICE_SPOILER_DOWNLOADER_H \ No newline at end of file +#endif // COCKATRICE_SPOILER_DOWNLOADER_H diff --git a/cockatrice/src/client/network/update/card_spoiler/spoiler_file.cpp b/cockatrice/src/client/network/update/card_spoiler/spoiler_file.cpp new file mode 100644 index 000000000..554ea1108 --- /dev/null +++ b/cockatrice/src/client/network/update/card_spoiler/spoiler_file.cpp @@ -0,0 +1,109 @@ +#include "spoiler_file.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +QByteArray hashFile(const QString &path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + return {}; + } + QCryptographicHash hash(QCryptographicHash::Sha256); + return hash.addData(&file) ? hash.result() : QByteArray(); +} +} // namespace + +namespace SpoilerFile +{ +QString validate(const QByteArray &data) +{ + QXmlStreamReader xml(data); + if (!xml.readNextStartElement() || xml.name() != QStringLiteral("cockatrice_carddatabase")) { + return QCoreApplication::translate( + "SpoilerBackgroundUpdater", + "The download is not a Cockatrice card database. The previous spoiler file was preserved."); + } + bool versionOk = false; + const int version = xml.attributes().value(QStringLiteral("version")).toInt(&versionOk); + if (!versionOk || (version != 3 && version != 4)) { + return QCoreApplication::translate( + "SpoilerBackgroundUpdater", + "The spoiler database uses an unsupported format. The previous spoiler file was preserved."); + } + bool hasCards = false; + int depth = 1; + while (!xml.atEnd()) { + const auto token = xml.readNext(); + if (token == QXmlStreamReader::StartElement) { + ++depth; + if (depth == 2 && xml.name() == QStringLiteral("cards")) { + hasCards = true; + } + } else if (token == QXmlStreamReader::EndElement) { + --depth; + } + } + if (xml.hasError() || !hasCards) { + return QCoreApplication::translate( + "SpoilerBackgroundUpdater", + "The spoiler database is incomplete or invalid. The previous spoiler file was preserved."); + } + return {}; +} + +SaveResult +save(const QString &targetPath, const QByteArray &data, const std::function &isCurrent, QString &error) +{ + error = validate(data); + if (!error.isEmpty()) { + return SaveResult::Failed; + } + if (!isCurrent()) { + error = QCoreApplication::translate("SpoilerBackgroundUpdater", + "The spoiler update was cancelled because its settings changed."); + return SaveResult::Failed; + } + if (hashFile(targetPath) == QCryptographicHash::hash(data, QCryptographicHash::Sha256)) { + return SaveResult::Unchanged; + } + const QDir directory = QFileInfo(targetPath).dir(); + if (!directory.exists() && !directory.mkpath(QStringLiteral("."))) { + error = QCoreApplication::translate("SpoilerBackgroundUpdater", + "Could not create the spoiler database directory: %1") + .arg(directory.absolutePath()); + return SaveResult::Failed; + } + QSaveFile file(targetPath); + file.setDirectWriteFallback(false); + if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size() || !file.flush()) { + error = QCoreApplication::translate( + "SpoilerBackgroundUpdater", + "Could not save the spoiler database to %1: %2. The previous file was preserved.") + .arg(targetPath, file.errorString()); + return SaveResult::Failed; + } + if (!isCurrent()) { + file.cancelWriting(); + error = QCoreApplication::translate("SpoilerBackgroundUpdater", + "The spoiler update was cancelled because its settings changed."); + return SaveResult::Failed; + } + if (!file.commit()) { + error = QCoreApplication::translate( + "SpoilerBackgroundUpdater", + "Could not replace the spoiler database at %1: %2. The previous file was preserved.") + .arg(targetPath, file.errorString()); + return SaveResult::Failed; + } + return SaveResult::Written; +} + +} // namespace SpoilerFile diff --git a/cockatrice/src/client/network/update/card_spoiler/spoiler_file.h b/cockatrice/src/client/network/update/card_spoiler/spoiler_file.h new file mode 100644 index 000000000..3a138cdea --- /dev/null +++ b/cockatrice/src/client/network/update/card_spoiler/spoiler_file.h @@ -0,0 +1,24 @@ +#ifndef COCKATRICE_SPOILER_FILE_H +#define COCKATRICE_SPOILER_FILE_H + +#include +#include +#include + +namespace SpoilerFile +{ +enum class SaveResult +{ + Failed, + Unchanged, + Written +}; + +QString validate(const QByteArray &data); +// Validate before opening the destination, compare complete files, and replace +// atomically. Check operation ownership both before writing and before committing. +SaveResult +save(const QString &targetPath, const QByteArray &data, const std::function &isCurrent, QString &error); +} // namespace SpoilerFile + +#endif // COCKATRICE_SPOILER_FILE_H diff --git a/cockatrice/src/client/network/update/client/update_downloader.cpp b/cockatrice/src/client/network/update/client/update_downloader.cpp index 8868876fa..8b5ef3d6c 100644 --- a/cockatrice/src/client/network/update/client/update_downloader.cpp +++ b/cockatrice/src/client/network/update/client/update_downloader.cpp @@ -1,15 +1,16 @@ #include "update_downloader.h" -#include -#include #include -#include +#include #include #include #include +#include +#include #include #include #include +#include UpdateDownloader::UpdateDownloader(QObject *parent) : QObject(parent), response(nullptr) { @@ -23,48 +24,116 @@ void UpdateDownloader::beginDownload(QUrl downloadUrl) originalUrl = downloadUrl; } + if (auto *old = std::exchange(response, nullptr)) { + old->abort(); + old->deleteLater(); + } + + errorReported = false; + cancelRequested = false; + + // The transfer timeout comes from the manager (NetworkManagerFactory). response = netMan->get(QNetworkRequest(downloadUrl)); connect(response, &QNetworkReply::finished, this, &UpdateDownloader::fileFinished); + connect(response, &QNetworkReply::errorOccurred, this, &UpdateDownloader::downloadError); connect(response, &QNetworkReply::downloadProgress, this, &UpdateDownloader::downloadProgress); - connect(this, &UpdateDownloader::stopDownload, response, &QNetworkReply::abort); + connect(this, &UpdateDownloader::stopDownload, this, &UpdateDownloader::cancelDownload); } -void UpdateDownloader::downloadError(QNetworkReply::NetworkError) +void UpdateDownloader::downloadError(QNetworkReply::NetworkError networkError) { - if (response == nullptr) { + // A reply that was superseded by beginDownload() can still deliver a + // queued error; it must not be reported against the current reply. + if (response == nullptr || sender() != response || errorReported) { return; } - emit error(response->errorString().toUtf8()); + // A transfer timeout aborts the reply, so it arrives as OperationCanceledError. + // Only an abort the user asked for is suppressed; every other cancel is a + // stalled transfer and has to be reported. + if (networkError == QNetworkReply::OperationCanceledError) { + if (cancelRequested) { + return; + } + + errorReported = true; + emit error(tr("The download timed out. Check your connection and try again.")); + return; + } + + errorReported = true; + emit error(response->errorString()); +} + +void UpdateDownloader::cancelDownload() +{ + cancelRequested = true; + if (response != nullptr) { + response->abort(); + } } void UpdateDownloader::fileFinished() { - // If we finished but there's a redirect, follow it - QVariant redirect = response->attribute(QNetworkRequest::RedirectionTargetAttribute); - if (!redirect.isNull()) { - beginDownload(redirect.toUrl()); + // An aborted, superseded reply also emits finished(). Only the reply + // currently stored in response may be consumed. + if (response == nullptr || sender() != response) { return; } - // Handle any errors we had - if (response->error()) { - emit error(response->errorString()); + // errorOccurred normally reports this first; keep the check for errors that + // only surface when the reply finishes. + if (response->error() != QNetworkReply::NoError) { + downloadError(response->error()); + response->deleteLater(); + response = nullptr; return; } - // Work out the file name of the download - QString fileName = QDir::temp().path() + QDir::separator() + originalUrl.toString().section('/', -1); + // Give the update its own private directory. The name is unpredictable, so a + // local user cannot pre-create or symlink the path, and the original file + // name (including the installer suffix) is preserved inside it. + QTemporaryDir downloadDir(QDir::temp().filePath(QStringLiteral("cockatrice-update-XXXXXX"))); + if (!downloadDir.isValid()) { + emit error(tr("Could not create a temporary directory for the download.")); + response->deleteLater(); + response = nullptr; + return; + } + downloadDir.setAutoRemove(false); - // Save the build in a temporary directory - QFile file(fileName); + const QString remoteName = QFileInfo(originalUrl.path()).fileName(); + const QString fileName = downloadDir.filePath(remoteName.isEmpty() ? QStringLiteral("update") : remoteName); + + QSaveFile file(fileName); if (!file.open(QIODevice::WriteOnly)) { - emit error(tr("Could not open the file for reading.")); + downloadDir.remove(); + emit error(tr("Could not open the file for writing: %1").arg(file.errorString())); + response->deleteLater(); + response = nullptr; return; } - file.write(response->readAll()); - file.close(); + const QByteArray data = response->readAll(); + if (file.write(data) != data.size()) { + file.cancelWriting(); + downloadDir.remove(); + emit error(tr("Could not write the downloaded file to disk.")); + response->deleteLater(); + response = nullptr; + return; + } + + if (!file.commit()) { + downloadDir.remove(); + emit error(tr("Could not save the downloaded file: %1").arg(file.errorString())); + response->deleteLater(); + response = nullptr; + return; + } + + response->deleteLater(); + response = nullptr; // Emit the success signal with a URL to the download file emit downloadSuccessful(QUrl::fromLocalFile(fileName)); diff --git a/cockatrice/src/client/network/update/client/update_downloader.h b/cockatrice/src/client/network/update/client/update_downloader.h index 8aeefb22a..7704eca38 100644 --- a/cockatrice/src/client/network/update/client/update_downloader.h +++ b/cockatrice/src/client/network/update/client/update_downloader.h @@ -32,10 +32,13 @@ private: QUrl originalUrl; QNetworkAccessManager *netMan; QNetworkReply *response; + bool errorReported = false; + bool cancelRequested = false; private slots: void fileFinished(); void downloadProgress(qint64 bytesRead, qint64 totalBytes); void downloadError(QNetworkReply::NetworkError); + void cancelDownload(); }; #endif // COCKATRICE_UPDATEDOWNLOADER_H diff --git a/cockatrice/src/client/settings/cache_settings.cpp b/cockatrice/src/client/settings/cache_settings.cpp index 08eccf1e4..b40e26377 100644 --- a/cockatrice/src/client/settings/cache_settings.cpp +++ b/cockatrice/src/client/settings/cache_settings.cpp @@ -6,6 +6,7 @@ #include "shortcuts_settings.h" #include +#include #include #include #include @@ -47,7 +48,32 @@ Q_GLOBAL_STATIC(SettingsCache, settingsCache) SettingsCache &SettingsCache::instance() { - return *settingsCache; // returns a QT managed singleton reference + SettingsCache &cache = *settingsCache; // returns a QT managed singleton reference + // The release channels own QNetworkAccessManagers, which must be destroyed + // while the application object is alive. ~SettingsCache() runs during + // static teardown, after QApplication is gone, so hook the application's + // shutdown instead. Retried here because the first access may predate the + // application object. + cache.installShutdownHook(); + return cache; +} + +void SettingsCache::installShutdownHook() +{ + if (shutdownHookInstalled) { + return; + } + QCoreApplication *application = QCoreApplication::instance(); + if (application == nullptr) { + return; + } + connect(application, &QCoreApplication::aboutToQuit, this, [this] { + for (ReleaseChannel *releaseChannel : std::as_const(releaseChannels)) { + delete releaseChannel; + } + releaseChannels.clear(); + }); + shutdownHookInstalled = true; } QString SettingsCache::getDataPath() diff --git a/cockatrice/src/client/settings/cache_settings.h b/cockatrice/src/client/settings/cache_settings.h index 9aeb88a18..8ccf6f76b 100644 --- a/cockatrice/src/client/settings/cache_settings.h +++ b/cockatrice/src/client/settings/cache_settings.h @@ -90,8 +90,13 @@ private: QString themeName; QList releaseChannels; + bool shutdownHookInstalled = false; bool isPortableBuild; + // Connects the aboutToQuit handler that deletes the release channels while + // the application object is still alive; a no-op before it exists. + void installShutdownHook(); + [[nodiscard]] QString getSafeConfigPath(QString configEntry, QString defaultPath) const; [[nodiscard]] QString getSafeConfigFilePath(QString configEntry, QString defaultPath) const; void loadPaths(); diff --git a/cockatrice/src/client/settings/shortcut_treeview.cpp b/cockatrice/src/client/settings/shortcut_treeview.cpp index 4a5bafb01..2501798cb 100644 --- a/cockatrice/src/client/settings/shortcut_treeview.cpp +++ b/cockatrice/src/client/settings/shortcut_treeview.cpp @@ -1,5 +1,6 @@ #include "shortcut_treeview.h" +#include "../../interface/theme_manager.h" #include "cache_settings.h" #include "shortcuts_settings.h" @@ -78,6 +79,18 @@ ShortcutTreeView::ShortcutTreeView(QWidget *parent) : QTreeView(parent) connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this, &ShortcutTreeView::refreshShortcuts); + + // Group headers store their font on the item; re-apply the role family on + // font changes (setFont emits itemChanged, which repaints the rows). + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { + QFont groupFont = themeManager->appFont(AppFont::InterfaceDefault); + groupFont.setBold(true); + for (int row = 0; row < shortcutsModel->rowCount(); ++row) { + if (auto *groupItem = shortcutsModel->item(row)) { + groupItem->setFont(groupFont); + } + } + }); } void ShortcutTreeView::populateShortcutsModel() @@ -93,7 +106,8 @@ void ShortcutTreeView::populateShortcutsModel() curParent = parentItems.value(group); } else { curParent = new QStandardItem(group); - static QFont font = curParent->font(); + // Family comes from the InterfaceDefault theme role; bold stays caller-side. + QFont font = themeManager->appFont(AppFont::InterfaceDefault); font.setBold(true); curParent->setFont(font); parentItems.insert(group, curParent); diff --git a/cockatrice/src/client/settings/shortcuts_settings.h b/cockatrice/src/client/settings/shortcuts_settings.h index 24790a407..94b981d83 100644 --- a/cockatrice/src/client/settings/shortcuts_settings.h +++ b/cockatrice/src/client/settings/shortcuts_settings.h @@ -313,6 +313,12 @@ private: {"DeckViewContainer/forceStartGameButton", ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Force Start"), parseSequenceString(""), ShortcutGroup::Game_Lobby)}, + {"GameSelector/aQuickHostGame", ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Host Game"), + parseSequenceString("Ctrl+N"), + ShortcutGroup::Game_Lobby)}, + {"GameSelector/aReviewGame", ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Game Settings"), + parseSequenceString("Ctrl+Shift+N"), + ShortcutGroup::Game_Lobby)}, {"Player/aCCMagenta", ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Add Card Counter (F)"), parseSequenceString(""), ShortcutGroup::Card_Counters)}, @@ -569,6 +575,9 @@ private: {"Player/aRevealToAll", ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Reveal Selected Cards to All Players"), parseSequenceString(""), ShortcutGroup::Playing_Area)}, + {"Player/aShowHoverPanel", ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Show Hover Panel for Card"), + parseSequenceString(""), + ShortcutGroup::Playing_Area)}, {"Player/aMoveToBottomLibrary", ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Bottom of Library"), parseSequenceString("Ctrl+B"), ShortcutGroup::Move_selected)}, diff --git a/cockatrice/src/filters/filter_builder.cpp b/cockatrice/src/filters/filter_builder.cpp index 22679abb1..890da0f62 100644 --- a/cockatrice/src/filters/filter_builder.cpp +++ b/cockatrice/src/filters/filter_builder.cpp @@ -1,6 +1,6 @@ #include "filter_builder.h" -#include "../interface/pixel_map_generator.h" +#include "../interface/theme_icon.h" #include "../interface/widgets/utility/custom_line_edit.h" #include @@ -30,7 +30,8 @@ FilterBuilder::FilterBuilder(QWidget *parent) : QWidget(parent) typeCombo->addItem(CardFilter::typeName(static_cast(i)), QVariant(i)); } - QPushButton *ok = new QPushButton(themePixmap(QStringLiteral("icons/increment")), QString()); + QPushButton *ok = new QPushButton( + loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive, IconStyle::Sticker), QString()); ok->setObjectName("ok"); ok->setMaximumSize(20, 20); diff --git a/cockatrice/src/filters/filter_tree_model.cpp b/cockatrice/src/filters/filter_tree_model.cpp index 9b55ce2fd..66fe39821 100644 --- a/cockatrice/src/filters/filter_tree_model.cpp +++ b/cockatrice/src/filters/filter_tree_model.cpp @@ -1,5 +1,7 @@ #include "filter_tree_model.h" +#include "../interface/theme_manager.h" + #include #include #include @@ -39,6 +41,12 @@ FilterTreeModel::FilterTreeModel(QObject *parent) : QAbstractItemModel(parent) connect(fTree, &FilterTree::postInsertRow, this, &FilterTreeModel::proxyEndInsertRow); connect(fTree, &FilterTree::preRemoveRow, this, &FilterTreeModel::proxyBeginRemoveRow); connect(fTree, &FilterTree::postRemoveRow, this, &FilterTreeModel::proxyEndRemoveRow); + // FontRole data reads the DeckList role live; rebroadcast so attached + // views re-query instead of waiting for an incidental repaint. + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { + emit layoutAboutToBeChanged(); + emit layoutChanged(); + }); } FilterTreeModel::~FilterTreeModel() @@ -202,7 +210,8 @@ QVariant FilterTreeModel::data(const QModelIndex &index, int role) const switch (role) { case Qt::FontRole: if (!node->isLeaf()) { - QFont f; + // Family comes from the DeckList theme role; bold stays caller-side. + QFont f = themeManager->appFont(AppFont::DeckList); f.setBold(true); return f; } diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.cpp b/cockatrice/src/game_graphics/board/abstract_card_item.cpp index 0e8b6ced3..f2c0966e4 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.cpp +++ b/cockatrice/src/game_graphics/board/abstract_card_item.cpp @@ -3,6 +3,7 @@ #include "../../client/settings/cache_settings.h" #include "../../interface/card_localization.h" #include "../../interface/card_picture_loader/card_picture_loader.h" +#include "../../interface/theme_manager.h" #include "../card_dimensions.h" #include "../game_scene.h" #include "../z_values.h" @@ -12,6 +13,7 @@ #include "libcockatrice/card/printing/exact_card.h" #include "libcockatrice/utility/card_ref.h" +#include #include #include #include @@ -20,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -28,6 +31,7 @@ #include #include #include +#include #include #include #include @@ -41,9 +45,19 @@ class QSizeF; +// Width the hovered card smooth-zooms to: exactly the hover panel picture size, so +// all four corners coincide when the panel swaps in over it. +static constexpr qreal HOVER_ZOOM_WIDTH = 270.0; +// The pre-panel hover enlargement. This stays the hover behavior when the smooth zoom is +// turned off (see setHoverHighlighted). +static constexpr qreal LEGACY_HOVER_SCALE = 1.1; +static constexpr int HOVER_ZOOM_IN_MS = 220; +static constexpr int HOVER_ZOOM_OUT_MS = 140; +static constexpr int FADE_DURATION_MS = 140; + AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef, PlayerLogic *_owner, int _id) : ArrowTarget(_owner, parent), state(new CardState(this, nullptr, cardRef, _id)), tapAngle(0), - bgColor(Qt::transparent), isHovered(false), realZValue(0) + bgColor(Qt::transparent), isHovered(false), isHoverHighlighted(false), realZValue(0) { setCursor(Qt::OpenHandCursor); setFlag(ItemIsSelectable); @@ -53,12 +67,38 @@ AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef connect(state, &CardState::cardInfoChanged, this, &AbstractCardItem::onCardInfoChanged); connect(state, &CardState::cardRefChanged, this, &AbstractCardItem::onCardRefChanged); connect(state, &CardState::tappedChanged, this, &AbstractCardItem::onTappedChanged); - connect(state, &CardState::facedownChanged, this, [this] { update(); }); + connect(state, &CardState::facedownChanged, this, [this](bool faceDown) { + update(); + emit faceDownChanged(faceDown); + }); connect(state, &CardState::colorChanged, this, [this] { cacheBgColor(); update(); }); + hoverZoom = new QPropertyAnimation(this, "cardScale", this); + hoverZoom->setEasingCurve(QEasingCurve::InOutCubic); + // Settling the unhover waits for the shrink-out to finish: the stacking override, + // the clip escape and the ancestor lift all stay put while the card is still large, + // so siblings and the clip container cannot cut through the shrinking card. + connect(hoverZoom, &QPropertyAnimation::finished, this, [this] { + if (!isHovered) { + // The cursor may still rest on the card (e.g. wheel-up reverts the zoom), + // in which case the highlight keeps it raised. + setZValue(isHoverHighlighted ? ZValues::HOVERED_CARD : realZValue); + // Final repaint at the settled footprint: with no scale delta the animation + // emits no per-frame updates, and zones would keep a stale clip hole behind. + if (scene()) { + scene()->update(sceneBoundingRect()); + } + emit hoverShrinkFinished(this); + } + }); + + opacityAnim = new QPropertyAnimation(this, "cardOpacity", this); + opacityAnim->setDuration(FADE_DURATION_MS); + opacityAnim->setEasingCurve(QEasingCurve::OutCubic); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::displayCardNamesChanged, this, [this] { update(); }); connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, @@ -130,7 +170,7 @@ void AbstractCardItem::setRealZValue(qreal _zValue) // During hover, zValue is overridden to HOVERED_CARD. Layout operations // like reorganizeCards() call setRealZValue() on all cards including the // hovered one — skip setZValue() here to avoid clobbering the override. - if (!isHovered) { + if (!isHovered && !isHoverHighlighted) { setZValue(_zValue); } } @@ -154,7 +194,7 @@ void AbstractCardItem::transformPainter(QPainter *painter, const QSizeF &transla painter->rotate(angle); painter->translate(-translatedSize.width() / 2, -translatedSize.height() / 2); - QFont f; + QFont f = themeManager->appFont(AppFont::CardText); f.setPixelSize(fontSize * scale); painter->setFont(f); @@ -228,9 +268,9 @@ void AbstractCardItem::paint(QPainter *painter, const QStyleOptionGraphicsItem * painter->setRenderHint(QPainter::Antialiasing, false); - if (isSelected() || isHovered) { + if (isSelected() || isHovered || isHoverHighlighted) { QPen pen; - if (isHovered) { + if (isHovered || isHoverHighlighted) { pen.setColor(Qt::yellow); } if (isSelected()) { @@ -256,14 +296,86 @@ void AbstractCardItem::setHovered(bool _hovered) // Mark the hovered card's current scene footprint dirty so overlapped // sibling zones (e.g. StackZone) repaint after the card moves away. if (scene()) { - scene()->update(sceneBoundingRect()); + const QRectF footprint = sceneBoundingRect(); + scene()->update(footprint); + // The hole disappears the moment the card is no longer hovered, even though the + // card has not changed size yet: tell the zones so they repaint it shut. + emit hoverFootprintChanged(footprint, footprint); } } isHovered = _hovered; - setZValue(_hovered ? ZValues::HOVERED_CARD : realZValue); - setScale(_hovered && SettingsCache::instance().cardsDisplay().getScaleCards() ? 1.1 : 1); - setTransformOriginPoint(_hovered ? CardDimensions::WIDTH_HALF_F : 0, _hovered ? CardDimensions::HEIGHT_HALF_F : 0); + // The stacking override is one-directional: it applies the moment the hover starts + // and only drops once the shrink-out finishes (see the hoverZoom finished handler). + // Restoring it here would let siblings paint over the still-enlarged card. + if (_hovered) { + setZValue(ZValues::HOVERED_CARD); + } + // Scale about the center in both directions: at scale 1 the origin is moot, + // and keeping it centered avoids drift while shrinking back down. + setTransformOriginPoint(CardDimensions::WIDTH_HALF_F, CardDimensions::HEIGHT_HALF_F); + + // Smooth-zoom up to the hover picture size (instead of the old 1.1 snap) so the + // hover panel can then swap in over the zoomed card with matching corners. + // The target is constant ON-SCREEN pixels: divide out the board view zoom so + // fullscreen (or zoomed) boards don't overshoot while the fixed-size panel matches. + qreal target = 1.0; + if (_hovered && SettingsCache::instance().cardsDisplay().getScaleCards()) { + const qreal w = boundingRect().width(); + qreal viewScale = 1.0; + if (scene() && !scene()->views().isEmpty()) { + if (const auto *view = scene()->views().first()) { + viewScale = qAbs(view->transform().m11()); + } + } + if (w > 0.0 && viewScale > 0.0) { + target = qBound(1.0, HOVER_ZOOM_WIDTH / (w * viewScale), 6.0); + } + } + hoverZoom->stop(); + hoverZoom->setStartValue(scale()); + hoverZoom->setEndValue(target); + hoverZoom->setDuration(_hovered ? HOVER_ZOOM_IN_MS : HOVER_ZOOM_OUT_MS); + hoverZoom->start(); + update(); +} + +void AbstractCardItem::setHoverHighlighted(bool highlighted) +{ + if (isHoverHighlighted == highlighted) { + return; + } + + if (highlighted) { + // The card-info widget listens for this: hovering still updates it right away. + processHoverEvent(); + } + + isHoverHighlighted = highlighted; + if (highlighted) { + setZValue(ZValues::HOVERED_CARD); + } + + // Fallback to the historical hover scale when the smooth hover zoom is turned off and + // the panel is not driven by hover: contact alone must still enlarge the card to the + // old 1.1x while scaleCards is on. + auto &display = SettingsCache::instance().cardsDisplay(); + const bool legacyHoverScale = display.getScaleCards() && + display.getHoverZoomTrigger() == CardsDisplaySettings::HoverPanelTriggerOff && + display.getHoverPanelTrigger() != CardsDisplaySettings::HoverPanelTriggerHover; + if (legacyHoverScale) { + // Centered like the smooth zoom, so growing and shrinking never drift. + setTransformOriginPoint(CardDimensions::WIDTH_HALF_F, CardDimensions::HEIGHT_HALF_F); + hoverZoom->stop(); + hoverZoom->setStartValue(scale()); + hoverZoom->setEndValue(highlighted ? LEGACY_HOVER_SCALE : 1.0); + hoverZoom->setDuration(highlighted ? HOVER_ZOOM_IN_MS : HOVER_ZOOM_OUT_MS); + hoverZoom->start(); + } else if (!highlighted && !isHovered && hoverZoom->state() != QAbstractAnimation::Running) { + // A zoomed card keeps its raised z until the shrink-out finishes (its finished + // handler restores it); a highlight-only card has nothing to unwind. + setZValue(realZValue); + } update(); } @@ -272,6 +384,64 @@ void AbstractCardItem::setColor(const QString &_color) state->setColor(_color); } +qreal AbstractCardItem::getCardScale() const +{ + return scale(); +} + +void AbstractCardItem::setCardScale(qreal _scale) +{ + if (qFuzzyCompare(_scale, scale())) { + return; + } + // The scale animates every frame: keep the scene index in step or hover + // picking tests a stale rect and the hover oscillates on/off by itself. + // Repaint the union of the old and new footprints, not just the card: zones + // dodge the zoomed card via a clip hole computed at paint time, and without + // a repaint they keep a stale hole — covering the card mid-animation and + // leaving a black rect behind once the hover ends. + const QRectF oldFootprint = sceneBoundingRect(); + prepareGeometryChange(); + setScale(_scale); + const QRectF newFootprint = sceneBoundingRect(); + if (scene()) { + scene()->update(oldFootprint.united(newFootprint)); + // Marking the scene region dirty is not enough for cached items: a zone that cuts a + // hover hole out of itself keeps the hole from its last repaint, which then shows as + // an uncovered black rectangle under the zoom. + emit hoverFootprintChanged(oldFootprint, newFootprint); + } else { + update(); + } +} + +qreal AbstractCardItem::getCardOpacity() const +{ + return opacity(); +} + +void AbstractCardItem::setCardOpacity(qreal _opacity) +{ + setOpacity(_opacity); + update(); +} + +void AbstractCardItem::fadeCardOut() +{ + opacityAnim->stop(); + opacityAnim->setStartValue(opacity()); + opacityAnim->setEndValue(0.0); + opacityAnim->start(); +} + +void AbstractCardItem::fadeCardIn() +{ + opacityAnim->stop(); + opacityAnim->setStartValue(opacity()); + opacityAnim->setEndValue(1.0); + opacityAnim->start(); +} + void AbstractCardItem::cacheBgColor() { QChar colorChar; @@ -309,6 +479,7 @@ void AbstractCardItem::cacheBgColor() void AbstractCardItem::onTappedChanged(bool newTapped, bool canAnimate) { + emit tappedChanged(newTapped); if (SettingsCache::instance().cardsDisplay().getTapAnimation() && canAnimate) { static_cast(scene())->registerAnimationItem(this); } else { diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.h b/cockatrice/src/game_graphics/board/abstract_card_item.h index eee8ec743..82058f08a 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.h +++ b/cockatrice/src/game_graphics/board/abstract_card_item.h @@ -23,10 +23,13 @@ class PlayerLogic; class CardInfo; class QPainter; class QPoint; +class QPropertyAnimation; class AbstractCardItem : public ArrowTarget, public IAnimatedItem { Q_OBJECT + Q_PROPERTY(qreal cardScale READ getCardScale WRITE setCardScale) + Q_PROPERTY(qreal cardOpacity READ getCardOpacity WRITE setCardOpacity) protected: CardState *state; int tapAngle; @@ -34,9 +37,12 @@ protected: private: bool isHovered; + bool isHoverHighlighted; qreal realZValue; bool pendingSelectionCollapse; int selectionCollapseGeneration = 0; ///< Guards the deferred collapse timer against stale firings + QPropertyAnimation *hoverZoom = nullptr; + QPropertyAnimation *opacityAnim = nullptr; private slots: void pixmapUpdated(); void onCardInfoChanged(); @@ -57,6 +63,19 @@ signals: void playSelectedFaceDown(AbstractCardItem *card); void hideSelected(AbstractCardItem *card); void selectionChanged(AbstractCardItem *card, bool selected); + void tappedChanged(bool tapped); + void faceDownChanged(bool faceDown); + /** @brief Emitted when the hover shrink-out animation runs to completion while unhovered. */ + void hoverShrinkFinished(AbstractCardItem *card); + /** + * @brief Emitted when the hover zoom changes this card's scene footprint. + * + * Zones (and anything else that dodges the zoomed card) draw from the live footprint at + * paint time but are cached: they need this to invalidate, or a stale hole is left behind. + * Also emitted once when the hover ends, with both rects equal, because the hole + * disappears without the card changing size. + */ + void hoverFootprintChanged(const QRectF &oldFootprint, const QRectF &newFootprint); public: enum @@ -116,6 +135,23 @@ public: } void setRealZValue(qreal _zValue); void setHovered(bool _hovered); + /** + * @brief Shows the hover outline and stacking lift, plus the historical 1.1x hover + * scale whenever no zoom trigger is configured. + * + * The board zoom is trigger-gated; the hover feedback (card info, outline, raised + * stacking) still follows the cursor immediately on contact. With the zoom trigger + * off and the panel not on hover, the old 1.1x enlargement (gated by scaleCards) + * stays in effect so upgrading does not silently drop it. + */ + void setHoverHighlighted(bool highlighted); + qreal getCardScale() const; + void setCardScale(qreal scale); + qreal getCardOpacity() const; + void setCardOpacity(qreal opacity); + /** @brief Fades the card out under the hover panel, or back in when it closes. */ + void fadeCardOut(); + void fadeCardIn(); bool getIsHovered() const { return isHovered; diff --git a/cockatrice/src/game_graphics/board/abstract_graphics_item.cpp b/cockatrice/src/game_graphics/board/abstract_graphics_item.cpp index 50e80d359..3a740aa98 100644 --- a/cockatrice/src/game_graphics/board/abstract_graphics_item.cpp +++ b/cockatrice/src/game_graphics/board/abstract_graphics_item.cpp @@ -1,5 +1,7 @@ #include "abstract_graphics_item.h" +#include "../../interface/theme_manager.h" + #include #include #include @@ -20,7 +22,7 @@ void AbstractGraphicsItem::paintNumberEllipse(int number, painter->save(); QString numStr = QString::number(number); - QFont font("Serif"); + QFont font = themeManager->cardTextFont(); font.setPixelSize(fontSize); font.setWeight(QFont::Bold); diff --git a/cockatrice/src/game_graphics/board/arrow_item.cpp b/cockatrice/src/game_graphics/board/arrow_item.cpp index f7df98ab5..b07a156bd 100644 --- a/cockatrice/src/game_graphics/board/arrow_item.cpp +++ b/cockatrice/src/game_graphics/board/arrow_item.cpp @@ -88,7 +88,7 @@ void ArrowItem::onTargetDestroyed() // Drag and attach arrows are never inserted into the arrow registry and // have no server-side counterpart, so no deletion event can clean them // up. Delete them locally when either endpoint is destroyed. - delArrow(); + cancel(); return; } @@ -103,6 +103,12 @@ void ArrowItem::delArrow() deleteLater(); } +void ArrowItem::cancel() +{ + ungrabMouse(); + delArrow(); +} + void ArrowItem::updatePath() { if (!targetItem) { @@ -350,6 +356,17 @@ void ArrowDragItem::addChildArrow(ArrowDragItem *child) childArrows.append(child); } +void ArrowDragItem::cancel() +{ + for (const auto &child : childArrows) { + if (child) { + child->cancel(); + } + } + childArrows.clear(); + ArrowItem::cancel(); +} + void ArrowDragItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) { if (targetLocked || !startItem) { @@ -393,8 +410,10 @@ void ArrowDragItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) targetItem ? updatePath() : updatePath(endPos); update(); - for (auto *child : childArrows) { - child->mouseMoveEvent(event); + for (const auto &child : childArrows) { + if (child) { + child->mouseMoveEvent(event); + } } } @@ -403,10 +422,7 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) if (!startItem) { // The source card was destroyed while the arrow was being drawn. // Clean up the arrow and its children instead of leaking them. - delArrow(); - for (auto *child : childArrows) { - child->mouseReleaseEvent(event); - } + cancel(); return; } @@ -415,7 +431,7 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) // For now, we can safely assume that the start item is always a card. // The target item can be a player as well. if (!startCard) { - delArrow(); + cancel(); return; } @@ -435,7 +451,7 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) } else if (auto *targetPlayer = qgraphicsitem_cast(targetItem)) { arrow.targetPlayerId = targetPlayer->getOwner()->getPlayerInfo()->getId(); } else { - delArrow(); + cancel(); return; } @@ -448,9 +464,12 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) } delArrow(); - for (auto *child : childArrows) { - child->mouseReleaseEvent(event); + for (const auto &child : childArrows) { + if (child) { + child->mouseReleaseEvent(event); + } } + childArrows.clear(); } // ArrowAttachItem @@ -471,6 +490,17 @@ void ArrowAttachItem::addChildArrow(ArrowAttachItem *child) childArrows.append(child); } +void ArrowAttachItem::cancel() +{ + for (const auto &child : childArrows) { + if (child) { + child->cancel(); + } + } + childArrows.clear(); + ArrowItem::cancel(); +} + void ArrowAttachItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) { if (targetLocked || !startItem) { @@ -509,8 +539,10 @@ void ArrowAttachItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) targetItem ? updatePath() : updatePath(endPos); update(); - for (auto *child : childArrows) { - child->mouseMoveEvent(event); + for (const auto &child : childArrows) { + if (child) { + child->mouseMoveEvent(event); + } } } @@ -519,17 +551,16 @@ void ArrowAttachItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) if (!startItem) { // The source card was destroyed while the arrow was being drawn. // Clean up the arrow and its children instead of leaking them. - delArrow(); - for (auto *child : childArrows) { - child->mouseReleaseEvent(event); - } + cancel(); return; } // Attaching could move startItem under the current cursor position, causing all children to retarget to it right // before they are processed. Prevent that. - for (auto *child : childArrows) { - child->setTargetLocked(true); + for (const auto &child : childArrows) { + if (child) { + child->setTargetLocked(true); + } } if (targetItem && targetItem != startItem) { @@ -541,7 +572,10 @@ void ArrowAttachItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) } delArrow(); - for (auto *child : childArrows) { - child->mouseReleaseEvent(event); + for (const auto &child : childArrows) { + if (child) { + child->mouseReleaseEvent(event); + } } + childArrows.clear(); } diff --git a/cockatrice/src/game_graphics/board/arrow_item.h b/cockatrice/src/game_graphics/board/arrow_item.h index 64a48cd14..c70a1030b 100644 --- a/cockatrice/src/game_graphics/board/arrow_item.h +++ b/cockatrice/src/game_graphics/board/arrow_item.h @@ -70,6 +70,8 @@ public: void onTargetDestroyed(); void delArrow(); + /// Abandons an in-progress drag arrow, discarding any child arrows with it. + virtual void cancel(); void updatePath(); void updatePath(const QPointF &endPoint); void startDrawAnimation(); @@ -112,12 +114,13 @@ class ArrowDragItem : public ArrowItem private: PlayerLogic *player; int deleteInPhase; - QList childArrows; + QList> childArrows; QMetaObject::Connection positionConnection; public: ArrowDragItem(PlayerLogic *_owner, ArrowTarget *_startItem, const QColor &_color, int _deleteInPhase); void addChildArrow(ArrowDragItem *child); + void cancel() override; protected: void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override; @@ -129,12 +132,13 @@ class ArrowAttachItem : public ArrowItem Q_OBJECT private: PlayerLogic *player; - QList childArrows; + QList> childArrows; QMetaObject::Connection positionConnection; public: explicit ArrowAttachItem(ArrowTarget *_startItem); void addChildArrow(ArrowAttachItem *child); + void cancel() override; protected: void mouseMoveEvent(QGraphicsSceneMouseEvent *event) override; diff --git a/cockatrice/src/game_graphics/board/card_item.cpp b/cockatrice/src/game_graphics/board/card_item.cpp index acd0764c9..9cc57f2b6 100644 --- a/cockatrice/src/game_graphics/board/card_item.cpp +++ b/cockatrice/src/game_graphics/board/card_item.cpp @@ -117,6 +117,11 @@ void CardItem::deleteLater() void CardItem::setZone(CardZoneLogic *_zone) { state->setZone(_zone); + // A card faded out under the open hover panel must come back as soon as it changes + // zones; otherwise it stays invisible at its destination until the panel closes. + if (opacity() < 1.0) { + fadeCardIn(); + } } void CardItem::retranslateUi() @@ -294,6 +299,13 @@ void CardItem::drawArrow(const QColor &arrowColor) return; } + // A held shortcut repeats the action before the previous arrow is placed. + // That arrow has lost the mouse grab to the new one, so it would never + // receive the release that deletes it; discard it (and its children). + if (pendingArrow) { + pendingArrow->cancel(); + } + auto *game = owner->getGame(); PlayerLogic *arrowOwner = game->getPlayerManager()->getActiveLocalPlayer(game->getGameState()->getActivePlayer()); int phase = 0; // 0 means to not set the phase @@ -303,6 +315,7 @@ void CardItem::drawArrow(const QColor &arrowColor) } ArrowDragItem *arrow = new ArrowDragItem(arrowOwner, this, arrowColor, phase); scene()->addItem(arrow); + pendingArrow = arrow; arrow->grabMouse(); for (const auto &item : scene()->selectedItems()) { @@ -326,8 +339,14 @@ void CardItem::drawAttachArrow() return; } + // See drawArrow(): a repeated trigger must not orphan the previous arrow. + if (pendingArrow) { + pendingArrow->cancel(); + } + auto *arrow = new ArrowAttachItem(this); scene()->addItem(arrow); + pendingArrow = arrow; arrow->grabMouse(); for (const auto &item : scene()->selectedItems()) { diff --git a/cockatrice/src/game_graphics/board/card_item.h b/cockatrice/src/game_graphics/board/card_item.h index 01dacc021..ae22d9493 100644 --- a/cockatrice/src/game_graphics/board/card_item.h +++ b/cockatrice/src/game_graphics/board/card_item.h @@ -13,11 +13,13 @@ #include #include #include +#include #include #include #include #include +class ArrowItem; class CardDragItem; class ServerInfo_Card; class PlayerLogic; @@ -34,6 +36,7 @@ class CardItem : public AbstractCardItem Q_OBJECT private: CardDragItem *dragItem; + QPointer pendingArrow; void prepareDelete(); void handleClickedToPlay(bool shiftHeld); diff --git a/cockatrice/src/game_graphics/board/counter_general.cpp b/cockatrice/src/game_graphics/board/counter_general.cpp index 4a8bbd6a4..23618e6bf 100644 --- a/cockatrice/src/game_graphics/board/counter_general.cpp +++ b/cockatrice/src/game_graphics/board/counter_general.cpp @@ -1,6 +1,7 @@ #include "counter_general.h" #include "../../interface/pixel_map_generator.h" +#include "../../interface/theme_manager.h" #include "abstract_graphics_item.h" #include @@ -41,7 +42,7 @@ void GeneralCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * / painter->drawPixmap(QPoint(0, 0), pixmap); if (value) { - QFont f("Serif"); + QFont f = themeManager->cardTextFont(); f.setPixelSize(qMax((int)(radius * scaleFactor), 10)); f.setWeight(QFont::Bold); painter->setPen(Qt::black); diff --git a/cockatrice/src/game_graphics/deckview/deck_view.cpp b/cockatrice/src/game_graphics/deckview/deck_view.cpp index 6e0b0eaaa..f62d33335 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view.cpp @@ -240,8 +240,8 @@ void DeckViewCardContainer::paint(QPainter *painter, const QStyleOptionGraphicsI painter->drawLine(QPointF(0, separatorY), QPointF(width, separatorY)); painter->setPen(QColor(Qt::white)); - QFont f("Serif"); - f.setStyleHint(QFont::Serif); + // Family comes from the DeckList theme role; pixel sizes/weights unchanged. + QFont f = themeManager->deckListFont(); f.setPixelSize(24); f.setWeight(QFont::Bold); painter->setFont(f); @@ -292,8 +292,8 @@ QList> DeckViewCardContainer::getRowsAndCols() const int DeckViewCardContainer::getCardTypeTextWidth() const { - QFont f("Serif"); - f.setStyleHint(QFont::Serif); + // Family comes from the DeckList theme role; pixel size/weight unchanged. + QFont f = themeManager->deckListFont(); f.setPixelSize(16); f.setWeight(QFont::Bold); QFontMetrics fm(f); @@ -366,6 +366,15 @@ void DeckViewCardContainer::setWidth(qreal _width) DeckViewScene::DeckViewScene(QObject *parent) : QGraphicsScene(parent), locked(true), deck(0), optimalAspectRatio(1.0) { + // Zone headers read the DeckList role at paint time and cache their + // rendering, so touch every item to invalidate the caches and pick up the + // new family on font changes (containers cannot connect this themselves: + // QGraphicsItem is not a QObject). + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { + for (auto *item : items()) { + item->update(); + } + }); } DeckViewScene::~DeckViewScene() diff --git a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp index 3bef9e59e..fe63d7a6f 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp @@ -71,6 +71,8 @@ void ToggleButton::setState(bool _state) DeckViewContainer::DeckViewContainer(int _playerId, TabGame *parent) : QWidget(nullptr), visualDeckStorageWidget(nullptr), parentGame(parent), playerId(_playerId) { + setObjectName("deckViewContainer"); + loadLocalButton = new QPushButton; loadRemoteButton = new QPushButton; loadFromClipboardButton = new QPushButton; @@ -80,6 +82,14 @@ DeckViewContainer::DeckViewContainer(int _playerId, TabGame *parent) forceStartGameButton = new QPushButton; sideboardLockButton = new ToggleButton; + loadLocalButton->setObjectName("loadLocalButton"); + loadRemoteButton->setObjectName("loadRemoteButton"); + loadFromClipboardButton->setObjectName("loadFromClipboardButton"); + readyStartButton->setObjectName("readyStartButton"); + forceStartGameButton->setObjectName("forceStartGameButton"); + unloadDeckButton->setObjectName("unloadDeckButton"); + sideboardLockButton->setObjectName("sideboardLockButton"); + connect(loadLocalButton, &QPushButton::clicked, this, &DeckViewContainer::loadLocalDeck); connect(loadRemoteButton, &QPushButton::clicked, this, &DeckViewContainer::loadRemoteDeck); connect(loadFromClipboardButton, &QPushButton::clicked, this, &DeckViewContainer::loadFromClipboard); diff --git a/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.cpp b/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.cpp index 90cbed9a1..75ede0ca8 100644 --- a/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.cpp +++ b/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.cpp @@ -14,6 +14,7 @@ class TabGame; TabbedDeckViewContainer::TabbedDeckViewContainer(int _playerId, TabGame *parent) : QTabWidget(nullptr), playerId(_playerId), parentGame(parent) { + setObjectName(QString("tabbedDeckView_%1").arg(_playerId)); setTabsClosable(true); connect(this, &QTabWidget::tabCloseRequested, this, &TabbedDeckViewContainer::closeTab); diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index db1040006..b4c6e1690 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -1,6 +1,8 @@ #include "game_scene.h" #include "../client/settings/cache_settings.h" +#include "../interface/theme_manager.h" +#include "../interface/widgets/cards/card_hover_widget.h" #include "animated_item.h" #include "board/abstract_card_item.h" #include "board/arrow_item.h" @@ -10,16 +12,21 @@ #include "player/menu/player_menu.h" #include "player/player_graphics_item.h" #include "player/player_target.h" +#include "z_values.h" #include "zones/card_zone.h" #include "zones/select_zone.h" +#include "zones/table_zone.h" #include "zones/view_zone.h" #include "zones/view_zone_widget.h" #include +#include #include #include #include +#include #include +#include #include #include #include @@ -28,19 +35,23 @@ #include #include #include +#include #include #include -#include +#include +#include #include #include #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -64,6 +75,30 @@ GameScene::GameScene(PhasesToolbar *_phasesToolbar, QObject *parent) addItem(phasesToolbar); connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::minPlayersForMultiColumnLayoutChanged, this, &GameScene::rearrange); + // CardText items (P/T boxes, counters, player names) read the role at + // paint time and cache their rendering, so touch every item to invalidate + // the caches and pick up the new family on font changes. + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { + for (auto *item : items()) { + item->update(); + } + }); + + // Card hover popup (cardHoverWidget is created lazily on first show) + hoverActivateTimer = new QTimer(this); + hoverActivateTimer->setSingleShot(true); + hoverActivateTimer->setInterval(500); + connect(hoverActivateTimer, &QTimer::timeout, this, &GameScene::onHoverActivateTimeout); + + hoverPanelTimer = new QTimer(this); + hoverPanelTimer->setSingleShot(true); + hoverPanelTimer->setInterval(200); + connect(hoverPanelTimer, &QTimer::timeout, this, &GameScene::onHoverPanelTimeout); + + hoverGraceTimer = new QTimer(this); + hoverGraceTimer->setSingleShot(true); + hoverGraceTimer->setInterval(200); + connect(hoverGraceTimer, &QTimer::timeout, this, &GameScene::onHoverGraceTimeout); rearrange(); } @@ -83,6 +118,9 @@ GameScene::~GameScene() delete animationTimer; animationTimer = nullptr; + delete hoverActivateTimer; + delete hoverPanelTimer; + delete hoverGraceTimer; // Delete all ArrowItems before QGraphicsScene's base destructor runs. // QGraphicsScene::~QGraphicsScene() destroys items in arbitrary order. @@ -229,6 +267,23 @@ void GameScene::onCardRightClicked(AbstractCardItem *abstractCard, QPoint screen return; } + // The zoom and panel triggers are independently bindable: a right-click fires + // whichever phases listen to it. When the panel is the trigger it carries every + // action itself, so the context menu stays suppressed; when right-click only zooms, + // the menu still opens, since nothing else would summon it. + auto &display = SettingsCache::instance().cardsDisplay(); + const bool zoomFired = display.getHoverZoomTrigger() == CardsDisplaySettings::HoverPanelTriggerRightClick; + const bool panelFired = display.getHoverPanelTrigger() == CardsDisplaySettings::HoverPanelTriggerRightClick; + if (zoomFired || panelFired) { + fireTriggers(card, zoomFired, panelFired); + if (panelFired) { + return; + } + } + + // Hide the hover popup when the context menu opens + hideCardHoverPopup(); + setActiveCard(card); if (auto *menu = view->getPlayerMenu()->updateCardMenu(card->getState())) { @@ -432,6 +487,37 @@ QList GameScene::collectActivePlayers(int &firstPlayerIndex) cons } } } + + // Lay out the table in seat order rather than id order. Players missing + // from the tracked order keep id order at the end. + QList seatOrder; + for (auto *pgItem : playerViews.values()) { + if (PlayerLogic *p = pgItem->getLogic()) { + seatOrder = p->getGame()->getGameState()->getSeatOrder(); + break; + } + } + if (!seatOrder.isEmpty()) { + std::sort(activePlayers.begin(), activePlayers.end(), [&](PlayerLogic *a, PlayerLogic *b) { + int indexA = seatOrder.indexOf(a->getPlayerInfo()->getId()); + int indexB = seatOrder.indexOf(b->getPlayerInfo()->getId()); + if (indexA == -1) { + indexA = seatOrder.size() + a->getPlayerInfo()->getId(); + } + if (indexB == -1) { + indexB = seatOrder.size() + b->getPlayerInfo()->getId(); + } + return indexA < indexB; + }); + // Recompute the local-player index after sorting. + firstPlayerIndex = 0; + for (int i = 0; i < activePlayers.size(); ++i) { + if (activePlayers[i]->getPlayerInfo()->getLocal()) { + firstPlayerIndex = i; + break; + } + } + } return activePlayers; } @@ -649,7 +735,6 @@ void GameScene::requestArrowDeletion(int playerId, int arrowId) emit arrowDeletionRequested(playerId, arrowId); } } - void GameScene::onCardZoneChanged(CardState *card, bool sameZone) { auto *item = qobject_cast(card->parent()); @@ -669,6 +754,11 @@ void GameScene::onCardZoneChanged(CardState *card, bool sameZone) for (auto *arrow : toDelete) { deleteArrow(arrow->getCreatorId(), arrow->getId()); } + + // The hover panel stays open on a moved card: follow it instead of stranding. + if (!sameZone && cardHoverWidget && cardHoverWidget->getCardItem() == item) { + positionCardHoverPopup(); + } } void GameScene::onCardViewRefreshRequested(CardState *card, bool zoneChanged, PlayerLogic *newOwner) @@ -705,27 +795,137 @@ void GameScene::clearArrowsForPlayerLocally(int playerId) } } } - // ---------- Hover Handling ---------- +CardItem *GameScene::getHoveredCard() const +{ + return hoveredCard; +} + void GameScene::updateHover(const QPointF &scenePos) { - auto itemList = items(scenePos, Qt::IntersectsItemBoundingRect, Qt::DescendingOrder, getViewTransform()); + updateHoveredCard(pickCardAt(scenePos)); +} +CardItem *GameScene::pickCardAt(const QPointF &scenePos) +{ + auto itemList = items(scenePos, Qt::IntersectsItemBoundingRect, Qt::DescendingOrder, getViewTransform()); CardZone *zone = findTopmostZone(itemList); CardItem *topCard = zone ? findTopmostCardInZone(itemList, zone) : nullptr; - updateHoveredCard(topCard); + if (!topCard && hoveredCard) { + const QRectF guard = hoveredCard->sceneBoundingRect().adjusted(-8, -8, 8, 8); + if (guard.contains(scenePos)) { + // The zoomed card overflows onto neighboring zones, where naive topmost-zone + // picking attributes the cursor to the zone underneath. While the cursor is + // still over the zoomed footprint (with hysteresis for edge jiggle), keep it. + // A genuinely different topmost card still takes over. + return hoveredCard; + } + } + return topCard; } void GameScene::updateHoveredCard(CardItem *newCard) { + // Safety: if the popup's card was destroyed, hide the popup immediately + if (cardHoverWidget && cardHoverWidget->isVisible() && !cardHoverWidget->hasCard()) { + hideCardHoverPopup(); + } + // The hovered card may have been destroyed without ending the hover: its QPointer + // nulled itself, but the ancestor lift is still outstanding. Restore it now. + if (!hoveredCard && !hoverLiftedAncestors.isEmpty()) { + restoreHoverLift(); + } + + // Hovering alone never starts anything anymore: the trigger (dwell, click, wheel, + // shortcut) resolves first, then the zoom follows. This keeps the feature calm. + bool wasHoveringCard = (hoveredCard != nullptr); if (hoveredCard && (newCard != hoveredCard)) { endCardHover(hoveredCard); + hoveredCard = nullptr; } - if (newCard && (newCard != hoveredCard)) { - beginCardHover(newCard); + + // The hover outline and the card info follow the cursor on contact: only the board + // zoom and the panel wait for their trigger. A highlight-only card has no zoom to + // unwind, so it is dropped directly instead of through endCardHover(). + if (hoverHighlightCard && hoverHighlightCard != newCard) { + hoverHighlightCard->setHoverHighlighted(false); + } + hoverHighlightCard = newCard; + if (newCard) { + newCard->setHoverHighlighted(true); + } + + auto &display = SettingsCache::instance().cardsDisplay(); + const int zoomTrigger = display.getHoverZoomTrigger(); + const int panelTrigger = display.getHoverPanelTrigger(); + const bool doubleConfirm = display.getHoverPanelDoubleConfirm() && zoomTrigger == panelTrigger; + + if (newCard) { + // Cancel any pending grace timer (mouse is back on a card) + hoverGracePending = false; + hoverGraceTimer->stop(); + + if (newCard == hoveredCard) { + // Steady state: zoom already up. Refresh an open panel, otherwise nothing — + // in particular this must not re-fire an armed double-confirm. + if (cardHoverWidget && cardHoverWidget->isVisible() && cardHoverWidget->getCardItem() == newCard) { + cardHoverWidget->refreshDisplay(); + } + return; + } + if (cardHoverWidget && cardHoverWidget->isVisible() && !cardHoverWidget->isClosing() && + cardHoverWidget->getCardItem() == newCard) { + // Panel open but zoom dropped (e.g. survived a Leave) — zoom back and refresh. + beginCardHover(newCard); + hoveredCard = newCard; + cardHoverWidget->refreshDisplay(); + return; + } + if (cardHoverWidget && cardHoverWidget->isVisible() && !cardHoverWidget->isClosing()) { + // A different card is hovered while the panel is up: close the old panel + // first, then fall through to first contact so the new card follows the + // normal dwell instead of the old panel trailing the cursor. + hideCardHoverPopup(); + confirmArmedCard.clear(); + } + if (zoomTrigger != CardsDisplaySettings::HoverPanelTriggerHover && + panelTrigger != CardsDisplaySettings::HoverPanelTriggerHover) { + // Mouse contact alone drives neither phase in this config. + return; + } + if (doubleConfirm && confirmArmedCard == newCard) { + // Repeat hover on an armed card: zoom and show at once, no second dwell. + beginCardHover(newCard); + hoveredCard = newCard; + confirmArmedCard.clear(); + showCardHoverPopup(newCard); + return; + } + if (newCard != hoverPopupTarget) { + // First contact (or a new card): dwell, then resolve on timeout. + hoverPopupTarget = newCard; + hoverActivateTimer->start(display.getHoverZoomDelayMs()); + } + } else { + // No card hovered: drop any pending dwell/panel timers (kills stale shows) and start + // the grace timer only when the mouse just left a card with the panel open. + hoverPopupTarget = nullptr; + hoverPanelTarget = nullptr; + hoverActivateTimer->stop(); + hoverPanelTimer->stop(); + // Non-hover zoom arms only make sense while approaching the card: leaving + // re-arms the zoom for the next explicit trigger instead of showing the panel. + // (Hover-trigger arms survive the leave so re-entering completes the repeat.) + if (zoomTrigger != CardsDisplaySettings::HoverPanelTriggerHover) { + confirmArmedCard.clear(); + } + if (wasHoveringCard && cardHoverWidget && cardHoverWidget->isVisible() && + !cardHoverWidget->isMouseOverWidget() && !hoverGracePending) { + hoverGracePending = true; + hoverGraceTimer->start(); + } } - hoveredCard = newCard; } void GameScene::beginCardHover(CardItem *card) @@ -734,16 +934,340 @@ void GameScene::beginCardHover(CardItem *card) if (auto *zone = SelectZone::findOwningSelectZone(card)) { zone->escapeClipForHover(card); } + // Lift the whole ancestor chain above their siblings: the zoomed card overflows its + // own zone, and Qt orders whole subtrees level by level, so no single z-value wins + // every comparison on its own. Harmless elsewhere (areas and zones tile without + // overlapping; arrows, zone views and drag items keep their higher layers). + // Restore first: if a previous hover never ended (its card was destroyed mid-hover), + // its chain is still outstanding and must land before a new one is recorded. + restoreHoverLift(); + QGraphicsItem *ancestor = card->parentItem(); + while (ancestor) { + // The chain is QGraphicsObject-derived in practice; a plain QGraphicsItem + // ancestor cannot be guarded, so it keeps its z-value instead of risking an + // unrestorable lift. + if (QGraphicsObject *ancestorObject = ancestor->toGraphicsObject()) { + hoverLiftedAncestors.append({QPointer(ancestorObject), ancestor->zValue()}); + ancestor->setZValue(ZValues::HOVERED_AREA); + } + ancestor = ancestor->parentItem(); + } + liftOwner = card; + connect(card, &AbstractCardItem::hoverShrinkFinished, this, &GameScene::onHoverShrinkFinished, + Qt::UniqueConnection); + connect(card, &AbstractCardItem::hoverFootprintChanged, this, &GameScene::onHoverFootprintChanged, + Qt::UniqueConnection); } void GameScene::endCardHover(CardItem *card) { - if (auto *zone = SelectZone::findOwningSelectZone(card)) { - zone->restoreClipAfterHover(card); - } + // Only the zoom-out starts here: the z-value, the clip escape and the ancestor + // lift stay put until the shrink finishes (onHoverShrinkFinished), so siblings and + // the clip container cannot cut through the still-enlarged card. card->setHovered(false); } +void GameScene::onHoverShrinkFinished(AbstractCardItem *card) +{ + auto *cardItem = qobject_cast(card); + if (!cardItem) { + return; + } + // Per-card and idempotent: always settle, even if a newer hover owns the lift. + if (auto *zone = SelectZone::findOwningSelectZone(cardItem)) { + zone->restoreClipAfterHover(cardItem); + } + if (liftOwner != cardItem) { + // A newer hover already owns the lift (or nothing does): leave it alone. + return; + } + restoreHoverLift(); +} + +void GameScene::onHoverFootprintChanged(const QRectF &oldFootprint, const QRectF &newFootprint) +{ + const QRectF dirty = oldFootprint.united(newFootprint); + if (dirty.isEmpty()) { + return; + } + // TableZone cuts the hovered card out of its own paint, but it is cached: the card + // changing size does not dirty the zone, so the previous hole would stay transparent in + // the cache — an uncovered black rectangle under the zoom whenever the card grows across + // a zone boundary. Nudge only the zones the footprint touches; the scene repaint itself + // is already scheduled by the card. + const auto overlapping = items(dirty, Qt::IntersectsItemBoundingRect); + for (QGraphicsItem *item : overlapping) { + if (auto *zone = qobject_cast(item->toGraphicsObject())) { + zone->update(); + } + } +} + +void GameScene::restoreHoverLift() +{ + for (const auto &[lifted, zValue] : hoverLiftedAncestors) { + if (lifted) { + lifted->setZValue(zValue); + } + } + hoverLiftedAncestors.clear(); + liftOwner.clear(); +} + +// ---------- Card Hover Popup ---------- + +void GameScene::showCardHoverPopup(CardItem *card) +{ + if (!card || !card->getOwner()) { + return; + } + + PlayerGraphicsItem *playerView = playerViews.value(card->getOwner()->getPlayerInfo()->getId()); + if (!playerView) { + return; + } + + hoverActivateTimer->stop(); + hoverPanelTimer->stop(); + hoverGracePending = false; + hoverGraceTimer->stop(); + hoverPopupTarget = card; + hoverPanelTarget = nullptr; + confirmArmedCard.clear(); + + if (!cardHoverWidget) { + auto *viewport = views().isEmpty() ? nullptr : views().first()->viewport(); + cardHoverWidget = new CardHoverWidget(viewport); + connect(cardHoverWidget, &CardHoverWidget::hoverEntered, this, &GameScene::onHoverWidgetEntered); + connect(cardHoverWidget, &CardHoverWidget::hoverLeft, this, &GameScene::onHoverWidgetLeft); + connect(cardHoverWidget, &CardHoverWidget::dismissRequested, this, &GameScene::hideCardHoverPopup); + + // Keep the popup anchored to its card while the view scrolls, zooms, or resizes + if (!views().isEmpty()) { + auto *view = views().first(); + connect(view->horizontalScrollBar(), &QScrollBar::valueChanged, this, &GameScene::positionCardHoverPopup); + connect(view->verticalScrollBar(), &QScrollBar::valueChanged, this, &GameScene::positionCardHoverPopup); + if (viewport) { + viewport->installEventFilter(this); + } + } + } + + // Crossfade: the opaque panel picture sits exactly over the zoomed card, so fade + // the board card out underneath it (masks any residual size/corner mismatch) and + // fade any previously shown card back in. + CardItem *oldCard = cardHoverWidget->hasCard() ? cardHoverWidget->getCardItem() : nullptr; + + cardHoverWidget->setCard(card, playerView); + positionCardHoverPopup(); + cardHoverWidget->showAnimated(); + + if (oldCard && oldCard != card) { + oldCard->fadeCardIn(); + } + card->fadeCardOut(); +} + +void GameScene::hideCardHoverPopup() +{ + // Hiding is terminal: drop all pending hover timing with it, or a dwell/panel + // timer that resolves afterwards pops the panel back up (e.g. over the context + // menu that hideCardHoverPopup was just called for). + hoverActivateTimer->stop(); + hoverPanelTimer->stop(); + hoverGracePending = false; + hoverGraceTimer->stop(); + if (cardHoverWidget) { + // The panel is gone: whatever card it covered must come back. + if (CardItem *coveredCard = cardHoverWidget->getCardItem()) { + coveredCard->fadeCardIn(); + } + if (cardHoverWidget->isVisible()) { + cardHoverWidget->hideAnimated(); + } + } + hoverPopupTarget = nullptr; + hoverPanelTarget = nullptr; +} + +void GameScene::positionCardHoverPopup() +{ + if (!cardHoverWidget || !cardHoverWidget->hasCard()) { + return; + } + + auto *viewport = views().isEmpty() ? nullptr : views().first()->viewport(); + if (!viewport) { + return; + } + + QRectF sceneRect = cardHoverWidget->getCardItem()->sceneBoundingRect(); + QTransform vpTransform = getViewportTransform(); + cardHoverWidget->positionNextTo(sceneRect, vpTransform, viewport); +} + +void GameScene::onHoverActivateTimeout() +{ + // Dwell resolved: the hover drives whichever phases listen to it. The cursor must + // still be over the pending card (rect test, not a fresh pick, so pick flakiness + // cannot veto it); otherwise this was a stale timer. + CardItem *target = hoverPopupTarget; + hoverPopupTarget = nullptr; + if (!target || views().isEmpty()) { + return; + } + auto *view = views().first(); + const QPointF cursorScene = view->mapToScene(view->viewport()->mapFromGlobal(QCursor::pos())); + if (!target->sceneBoundingRect().contains(cursorScene)) { + return; + } + auto &display = SettingsCache::instance().cardsDisplay(); + const bool zoomPhase = display.getHoverZoomTrigger() == CardsDisplaySettings::HoverPanelTriggerHover; + const bool panelPhase = display.getHoverPanelTrigger() == CardsDisplaySettings::HoverPanelTriggerHover; + const bool effectiveDouble = + display.getHoverPanelDoubleConfirm() && display.getHoverZoomTrigger() == display.getHoverPanelTrigger(); + if (hoveredCard && hoveredCard != target) { + endCardHover(hoveredCard); + } + if (zoomPhase) { + if (hoveredCard != target) { + beginCardHover(target); + hoveredCard = target; + } + if (effectiveDouble) { + confirmArmedCard = target; + } + } + if (panelPhase && !effectiveDouble) { + hoverPanelTarget = target; + hoverPanelTimer->start(display.getHoverPanelDelayMs()); + } +} + +void GameScene::onHoverPanelTimeout() +{ + // Panel delay resolved: show, but only if the target card is still the one under + // the cursor (same cheap rect check — no dependence on pick stability). + CardItem *target = hoverPanelTarget; + hoverPanelTarget = nullptr; + if (!target || views().isEmpty()) { + return; + } + auto *view = views().first(); + const QPointF cursorScene = view->mapToScene(view->viewport()->mapFromGlobal(QCursor::pos())); + if (!target->sceneBoundingRect().contains(cursorScene)) { + return; + } + ensureZoomed(target); + showCardHoverPopup(target); +} + +void GameScene::onHoverGraceTimeout() +{ + hoverGracePending = false; + if (cardHoverWidget && cardHoverWidget->isVisible() && cardHoverWidget->isMouseOverWidget()) { + return; + } + hideCardHoverPopup(); +} + +void GameScene::summonHoverPanel() +{ + // Summon for whatever is under the cursor right now; hovering is not required. + if (views().isEmpty()) { + return; + } + auto *view = views().first(); + const QPointF cursorScene = view->mapToScene(view->viewport()->mapFromGlobal(QCursor::pos())); + if (CardItem *card = pickCardAt(cursorScene)) { + auto &display = SettingsCache::instance().cardsDisplay(); + fireTriggers(card, display.getHoverZoomTrigger() == CardsDisplaySettings::HoverPanelTriggerShortcut, + display.getHoverPanelTrigger() == CardsDisplaySettings::HoverPanelTriggerShortcut); + } +} + +void GameScene::ensureZoomed(CardItem *card) +{ + if (hoveredCard != card) { + if (hoveredCard) { + endCardHover(hoveredCard); + } + beginCardHover(card); + hoveredCard = card; + } +} + +void GameScene::fireTriggers(CardItem *card, bool zoomFired, bool panelFired) +{ + if (!card || !card->getOwner()) { + return; + } + + if (cardHoverWidget && cardHoverWidget->isVisible() && !cardHoverWidget->isClosing() && + cardHoverWidget->getCardItem() == card) { + cardHoverWidget->refreshDisplay(); + return; + } + + // Explicit triggers preempt any pending hover timing. + hoverPopupTarget = nullptr; + hoverPanelTarget = nullptr; + hoverActivateTimer->stop(); + hoverPanelTimer->stop(); + + auto &display = SettingsCache::instance().cardsDisplay(); + const bool effectiveDouble = + display.getHoverPanelDoubleConfirm() && display.getHoverZoomTrigger() == display.getHoverPanelTrigger(); + + if (zoomFired && panelFired) { + // Both phases on one action: one-step zooms and shows at once (the crossfade + // covers it); double-confirm arms on first fire and shows on repeat. + if (effectiveDouble) { + if (confirmArmedCard == card) { + confirmArmedCard.clear(); + ensureZoomed(card); + showCardHoverPopup(card); + } else { + confirmArmedCard = card; + ensureZoomed(card); + } + } else { + ensureZoomed(card); + showCardHoverPopup(card); + } + return; + } + if (zoomFired) { + ensureZoomed(card); + } + if (panelFired) { + ensureZoomed(card); + showCardHoverPopup(card); + } +} + +void GameScene::onHoverWidgetEntered() +{ + hoverGracePending = false; + hoverGraceTimer->stop(); +} + +void GameScene::onHoverWidgetLeft() +{ + if (!hoveredCard && cardHoverWidget && cardHoverWidget->isVisible() && !hoverGracePending) { + hoverGracePending = true; + hoverGraceTimer->start(); + } +} + +bool GameScene::eventFilter(QObject *watched, QEvent *event) +{ + if (event->type() == QEvent::Resize) { + positionCardHoverPopup(); + } + return QGraphicsScene::eventFilter(watched, event); +} + CardZone *GameScene::findTopmostZone(const QList &items) { for (QGraphicsItem *item : items) { @@ -899,7 +1423,50 @@ bool GameScene::event(QEvent *event) { if (event->type() == QEvent::GraphicsSceneMouseMove) { updateHover(static_cast(event)->scenePos()); + } else if (event->type() == QEvent::GraphicsSceneWheel) { + // Wheel trigger: wheeling over a card fires whichever phases listen to it + // instead of scrolling. Fully consumed here: forwarding to the base dispatcher + // as well would double-deliver it to items under the cursor (e.g. zone views, + // which scroll on wheel). Scrolling up instead reverts an active zoom/panel — + // backing out of the gesture undoes it; with nothing to revert the event falls + // through to normal scrolling. + auto &display = SettingsCache::instance().cardsDisplay(); + const bool zoomFired = display.getHoverZoomTrigger() == CardsDisplaySettings::HoverPanelTriggerWheel; + const bool panelFired = display.getHoverPanelTrigger() == CardsDisplaySettings::HoverPanelTriggerWheel; + if (zoomFired || panelFired) { + auto *wheelEvent = static_cast(event); + // QGraphicsSceneWheelEvent carries delta()/pixelDelta(), not angleDelta(). + const int wheelUp = !wheelEvent->pixelDelta().isNull() + ? wheelEvent->pixelDelta().y() + : (wheelEvent->orientation() == Qt::Vertical ? wheelEvent->delta() : 0); + if (wheelUp > 0) { + if ((cardHoverWidget && cardHoverWidget->isVisible()) || hoveredCard) { + hoverPopupTarget = nullptr; + hoverPanelTarget = nullptr; + confirmArmedCard.clear(); + hideCardHoverPopup(); + if (hoveredCard) { + endCardHover(hoveredCard); + hoveredCard = nullptr; + } + event->accept(); + return true; + } + } else if (CardItem *card = pickCardAt(wheelEvent->scenePos())) { + fireTriggers(card, zoomFired, panelFired); + event->accept(); + return true; + } + } } else if (event->type() == QEvent::Leave) { + // The hover panel opens directly over the zoomed card under the cursor, which the + // scene observes as a Leave. The cursor never actually left: if it is over the open + // panel, keep the hover (dropping it here shrinks the card from under the panel and + // starves the popup logic, so the menu never shows). A genuine leave of the window + // misses the panel and still ends the hover as before. + if (cardHoverWidget && cardHoverWidget->isVisible() && cardHoverWidget->isMouseOverWidget()) { + return QGraphicsScene::event(event); + } updateHoveredCard(nullptr); } diff --git a/cockatrice/src/game_graphics/game_scene.h b/cockatrice/src/game_graphics/game_scene.h index 0d4db70a8..9c8c422a3 100644 --- a/cockatrice/src/game_graphics/game_scene.h +++ b/cockatrice/src/game_graphics/game_scene.h @@ -11,8 +11,10 @@ #include #include #include +#include #include #include +#include #include #include #include @@ -30,6 +32,7 @@ class CardZoneLogic; class IAnimatedItem; class QGraphicsItem; +class QGraphicsObject; class QPoint; struct ArrowData; template class QSharedPointer; @@ -48,6 +51,8 @@ class ServerInfo_Card; class PhasesToolbar; class QBasicTimer; class QObject; +class QTimer; +class CardHoverWidget; /** * @class GameScene @@ -73,16 +78,41 @@ private: ArrowRegistry arrowRegistry; ///< ID registry for arrow graphics items QList zoneViews; ///< Active zone view widgets QSize viewSize; ///< Current view size - QPointer hoveredCard; ///< Currently hovered card - QPointer activeCard; ///< Card the currently open context menu targets - QBasicTimer *animationTimer; ///< Timer for scene animations - QHash animatedItems; ///< Items currently animating + QPointer hoveredCard; ///< Currently hovered card (zoomed) + /// Card the cursor is resting on. It carries the hover outline and the card-info + /// update immediately, independently of the zoom/panel triggers. + QPointer hoverHighlightCard; + QPointer activeCard; ///< Card the currently open context menu targets + /// Card armed for double-confirm panel summon (first trigger zooms, repeat shows). + QPointer confirmArmedCard; + /// Ancestor chain of the hovered card with pre-hover Z-values. Lifted while hovering + /// because Qt orders subtrees level by level: the card z alone cannot beat a sibling + /// zone, and the top ancestor alone cannot beat same-area sibling zones either — every + /// level from the zone up must win its own comparison. Guarded pointers: if the card + /// is destroyed mid-hover (or an ancestor goes away first), the guards null out and + /// the next restore skips them instead of touching freed memory. An orphaned lift is + /// invisible — areas and zones tile without overlapping and all UI layers still win — + /// and any outstanding lift is restored by the next hover transition. + QList, qreal>> hoverLiftedAncestors; + /// Card whose ancestor chain is currently lifted (cleared together with the chain). + QPointer liftOwner; + QBasicTimer *animationTimer; ///< Timer for scene animations + QHash animatedItems; ///< Items currently animating QHash animationItemConnections; ///< destroyed->removeAnimatedItem handles per animated item int playerRotation; ///< Rotation offset for player layout bool rearranging = false; ///< Guard against re-entrant rearrange bool needsReArrange = false; ///< Pending rearrange requested during a pass + // Card hover popup + QPointer cardHoverWidget; ///< The hover popup widget + QTimer *hoverActivateTimer; ///< Dwell delay before the hover zoom kicks in + QTimer *hoverPanelTimer; ///< Delay after the zoom before the tab panel shows + QTimer *hoverGraceTimer; ///< Grace period before hiding popup (200ms) + QPointer hoverPopupTarget; ///< Card the popup is intended for + QPointer hoverPanelTarget; ///< Card the panel timer will show + bool hoverGracePending = false; ///< Grace timer armed (mouse left a card or the popup) + /** * @brief Updates which card is currently hovered based on scene coordinates. * @param scenePos Scene position of the cursor. @@ -96,6 +126,23 @@ private: void beginCardHover(CardItem *card); /** @brief Deactivates hover state and restores the card to its clip container. */ void endCardHover(CardItem *card); + /** @brief Restores every lifted ancestor to its pre-hover Z-value and drops the chain. */ + void restoreHoverLift(); + /** @brief Settles a finished shrink-out: clip container and ancestor lift restore. */ + void onHoverShrinkFinished(AbstractCardItem *card); + /** + * @brief Repaints the zones that dodge the zoomed card when its footprint moves. + * + * Those zones cut the hovered card out of their paint, but they are cached: another + * item's geometry change does not dirty them, so without this the previous hole stays + * transparent in the cache and a black rectangle shows under the zoom. + */ + void onHoverFootprintChanged(const QRectF &oldFootprint, const QRectF &newFootprint); + + // Card hover popup methods + void showCardHoverPopup(CardItem *card); + void hideCardHoverPopup(); + void positionCardHoverPopup(); public: /** @@ -227,6 +274,21 @@ public: /** @brief Updates hovered card highlighting. */ void updateHoveredCard(CardItem *newCard); + /** @brief Topmost card at a scene position, without side effects. */ + CardItem *pickCardAt(const QPointF &scenePos); + + /** + * @brief Fires the zoom and/or panel trigger for a card (one explicit user action). + * When both fire on one action, double-confirm arms on first fire and shows on repeat. + */ + void fireTriggers(CardItem *card, bool zoomFired, bool panelFired); + + /** @brief Starts the hover zoom for a card without touching panel state. */ + void ensureZoomed(CardItem *card); + + /** @brief The currently hovered card, if any (used by zones to dodge it while painting). */ + CardItem *getHoveredCard() const; + /** * @brief Registers an item for animation updates with the shared scene timer. * @@ -293,10 +355,22 @@ public slots: */ void onCardViewRefreshRequested(CardState *card, bool zoneChanged, PlayerLogic *newOwner); + // Card hover popup slots + void onHoverActivateTimeout(); + void onHoverPanelTimeout(); + void onHoverGraceTimeout(); + void onHoverWidgetEntered(); + void onHoverWidgetLeft(); + /** @brief Summons the hover panel for the currently hovered card, if any. */ + void summonHoverPanel(); + protected: /** @brief Handles hover updates. */ bool event(QEvent *event) override; + /** @brief Repositions the hover popup when the viewport resizes. */ + bool eventFilter(QObject *watched, QEvent *event) override; + /** @brief Handles animation timer updates. */ void timerEvent(QTimerEvent *event) override; diff --git a/cockatrice/src/game_graphics/game_view.cpp b/cockatrice/src/game_graphics/game_view.cpp index cb59b3d7d..59f374435 100644 --- a/cockatrice/src/game_graphics/game_view.cpp +++ b/cockatrice/src/game_graphics/game_view.cpp @@ -2,12 +2,14 @@ #include "../client/settings/cache_settings.h" #include "../client/settings/shortcuts_settings.h" +#include "../interface/theme_manager.h" #include "game_scene.h" #include #include #include #include +#include #include #include #include @@ -24,6 +26,21 @@ #include #include +namespace +{ +QFont deckOverlayFont(int pixelSize, bool bold) +{ + // The drag/total counters and the tally columns are in-game overlays, not + // deck list text: keep the monospace family the stylesheets used to set so + // digit widths stay stable as counts tick and `white-space: pre` lines up. + QFont font(QStringLiteral("monospace")); + font.setStyleHint(QFont::Monospace); + font.setPixelSize(pixelSize); + font.setBold(bold); + return font; +} +} // namespace + // QRubberBand calls raise() in showEvent() and changeEvent() to stay on top of siblings. // This subclass disables that behavior so dragCountLabel can appear above it. class SelectionRubberBand : public QRubberBand @@ -48,6 +65,7 @@ protected: GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, parent), rubberBand(0) { + setObjectName("gameView"); setBackgroundBrush(QBrush(QColor(0, 0, 0))); setRenderHints(QPainter::TextAntialiasing | QPainter::Antialiasing); setViewportUpdateMode(BoundingRectViewportUpdate); @@ -69,21 +87,24 @@ GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, par connect(aCloseMostRecentZoneView, &QAction::triggered, scene, &GameScene::closeMostRecentZoneView); addAction(aCloseMostRecentZoneView); + + aShowHoverPanel = new QAction(this); + connect(aShowHoverPanel, &QAction::triggered, scene, &GameScene::summonHoverPanel); + addAction(aShowHoverPanel); connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this, &GameView::refreshShortcuts); refreshShortcuts(); rubberBand = new SelectionRubberBand(QRubberBand::Rectangle, this); const QString baseProperties = "color: white; " - "font-family: monospace; " "background-color: rgba(0, 0, 0, 160); " "border-radius: 3px; " "padding: 1px 2px; " "white-space: pre;"; - const QString dragCountLabelStyle = baseProperties + "font-size: 14px; font-weight: bold;"; - const QString totalCountLabelStyle = baseProperties + "font-size: 16px; font-weight: bold;"; - const QString subtypeTallyLabelStyle = baseProperties + "font-size: 12px;"; + const QString dragCountLabelStyle = baseProperties; + const QString totalCountLabelStyle = baseProperties; + const QString subtypeTallyLabelStyle = baseProperties; dragCountLabel = new QLabel(this); dragCountLabel->setStyleSheet(dragCountLabelStyle); @@ -100,6 +121,27 @@ GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, par tallyLayout->setContentsMargins(2, 2, 2, 2); tallyLayout->setSpacing(2); tallyContainer->hide(); + + applyDeckOverlayFonts(); +} + +void GameView::applyDeckOverlayFonts() +{ + dragCountLabel->setFont(deckOverlayFont(14, true)); + totalCountLabel->setFont(deckOverlayFont(16, true)); + tallyContainer->setFont(deckOverlayFont(12, false)); + for (int i = 0; i < tallyLayout->count(); ++i) { + if (auto *label = qobject_cast(tallyLayout->itemAt(i)->widget())) { + // Count column (odd positions) keeps the bold emphasis. + const bool count = i % 2 == 1; + label->setFont(deckOverlayFont(count ? 14 : 12, count)); + } + } + + // The container keeps the size computed from the previous fonts, so force + // rebuildTallyLabels() to re-measure it instead of reusing cachedTallyRows. + cachedTallyRows.clear(); + updateTotalSelectionCount(); } void GameView::resizeEvent(QResizeEvent *event) @@ -208,6 +250,7 @@ void GameView::refreshShortcuts() { aCloseMostRecentZoneView->setShortcuts( SettingsCache::instance().shortcuts().getShortcut("Player/aCloseMostRecentZoneView")); + aShowHoverPanel->setShortcuts(SettingsCache::instance().shortcuts().getShortcut("Player/aShowHoverPanel")); } void GameView::clearTallyLabels() @@ -219,9 +262,8 @@ QSize GameView::rebuildTallyLabels(const QList &entries) { clearTallyLabels(); - const QString nameStyle = QStringLiteral("color: white; font-size: 12px; background: transparent;"); - const QString countStyle = - QStringLiteral("color: white; font-size: 14px; font-weight: bold; background: transparent;"); + const QString nameStyle = QStringLiteral("color: white; background: transparent;"); + const QString countStyle = QStringLiteral("color: white; background: transparent;"); int totalHeight = 0; int maxNameWidth = 0; @@ -231,11 +273,13 @@ QSize GameView::rebuildTallyLabels(const QList &entries) for (const TallyRow &entry : entries) { auto *nameLabel = new QLabel(entry.name, tallyContainer); nameLabel->setStyleSheet(nameStyle); + nameLabel->setFont(deckOverlayFont(12, false)); nameLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); tallyLayout->addWidget(nameLabel, row, 0); auto *countLabel = new QLabel(entry.value, tallyContainer); countLabel->setStyleSheet(countStyle); + countLabel->setFont(deckOverlayFont(14, true)); countLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); tallyLayout->addWidget(countLabel, row, 1); diff --git a/cockatrice/src/game_graphics/game_view.h b/cockatrice/src/game_graphics/game_view.h index e57267734..52c06ebcb 100644 --- a/cockatrice/src/game_graphics/game_view.h +++ b/cockatrice/src/game_graphics/game_view.h @@ -29,6 +29,7 @@ class GameView : public QGraphicsView Q_OBJECT private: QAction *aCloseMostRecentZoneView; + QAction *aShowHoverPanel; QRubberBand *rubberBand; QLabel *dragCountLabel; QLabel *totalCountLabel; @@ -50,6 +51,7 @@ private slots: void refreshShortcuts(); void updateTotalSelectionCount(const QSize &viewSize = QSize()); void setFocusDisabled(bool disabled); + void applyDeckOverlayFonts(); public slots: void updateSceneRect(const QRectF &rect); diff --git a/cockatrice/src/game_graphics/log/message_log_widget.cpp b/cockatrice/src/game_graphics/log/message_log_widget.cpp index 046a1ae90..9fe89c04b 100644 --- a/cockatrice/src/game_graphics/log/message_log_widget.cpp +++ b/cockatrice/src/game_graphics/log/message_log_widget.cpp @@ -4,6 +4,8 @@ #include "../../client/settings/card_counter_settings.h" #include "../../client/sound_engine.h" #include "../../interface/card_localization.h" +#include "../../interface/theme_icon.h" +#include "../../interface/theme_manager.h" #include "../../interface/widgets/server/chat_view/chat_view.h" #include "../board/card_item.h" #include "../board/translate_counter_name.h" @@ -12,8 +14,12 @@ #include #include #include +#include +#include #include #include +#include +#include #include #include #include @@ -58,13 +64,13 @@ MessageLogWidget::getFromStr(CardZoneLogic *zone, QString cardName, int position QString zoneName = zone->getName(); if (zoneName == ZoneNames::TABLE) { - fromStr = tr(" from play"); + fromStr = tr("from play"); } else if (zoneName == ZoneNames::GRAVE) { - fromStr = tr(" from their graveyard"); + fromStr = tr("from their graveyard"); } else if (zoneName == ZoneNames::EXILE) { - fromStr = tr(" from exile"); + fromStr = tr("from exile"); } else if (zoneName == ZoneNames::HAND) { - fromStr = tr(" from their hand"); + fromStr = tr("from their hand"); } else if (zoneName == ZoneNames::DECK) { if (position == 0) { if (cardName.isEmpty()) { @@ -76,9 +82,9 @@ MessageLogWidget::getFromStr(CardZoneLogic *zone, QString cardName, int position cardNameContainsStartZone = true; } else { if (ownerChange) { - fromStr = tr(" from the top of %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName()); + fromStr = tr("from the top of %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName()); } else { - fromStr = tr(" from the top of their library"); + fromStr = tr("from the top of their library"); } } } else if (position == zone->getCards().size()) { @@ -91,35 +97,41 @@ MessageLogWidget::getFromStr(CardZoneLogic *zone, QString cardName, int position cardNameContainsStartZone = true; } else { if (ownerChange) { - fromStr = tr(" from the bottom of %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName()); + fromStr = tr("from the bottom of %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName()); } else { - fromStr = tr(" from the bottom of their library"); + fromStr = tr("from the bottom of their library"); } } } else { if (ownerChange) { - fromStr = tr(" from %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName()); + fromStr = tr("from %1's library").arg(zone->getPlayer()->getPlayerInfo()->getName()); } else { - fromStr = tr(" from their library"); + fromStr = tr("from their library"); } } } else if (zoneName == ZoneNames::SIDEBOARD) { - fromStr = tr(" from sideboard"); + fromStr = tr("from sideboard"); } else if (zoneName == ZoneNames::STACK) { - fromStr = tr(" from the stack"); + fromStr = tr("from the stack"); } else { - fromStr = tr(" from custom zone '%1'").arg(zoneName); + fromStr = tr("from custom zone '%1'").arg(zoneName); } if (!cardNameContainsStartZone) { cardName.clear(); } + // The separator space is added here rather than stored in the tr() string so + // translators never have to preserve an invisible leading blank. + if (!fromStr.isEmpty()) { + fromStr.prepend(' '); + } return {cardName, fromStr}; } void MessageLogWidget::containerProcessingDone() { currentContext = MessageContext_None; + contextJudgeName.clear(); messageSuffix = messagePrefix = QString(); } @@ -605,6 +617,20 @@ void MessageLogWidget::logReverseTurn(PlayerLogic *player, bool reversed) .arg(reversed ? tr("reversed") : tr("normal"))); } +void MessageLogWidget::logTurnOrderChanged(QStringList playerNames, bool randomized) +{ + QStringList sanitizedNames; + for (const QString &name : playerNames) { + sanitizedNames.append(sanitizeHtml(name)); + } + const QString order = sanitizedNames.join(" -> "); + if (randomized) { + appendHtmlServerMessage(tr("Randomized turn order: %1.").arg(order)); + } else { + appendHtmlServerMessage(tr("New turn order set: %1.").arg(order)); + } +} + void MessageLogWidget::logRollDie(PlayerLogic *player, int sides, const QList &rolls) { if (rolls.length() == 1) { @@ -841,8 +867,27 @@ void MessageLogWidget::logUndoDrawFailed(PlayerLogic *player) void MessageLogWidget::setContextJudgeName(QString name) { - messagePrefix = QString(""); - messageSuffix = QString(" [ %1]").arg(sanitizeHtml(name)); + contextJudgeName = std::move(name); + refreshJudgeSuffix(); +} + +void MessageLogWidget::refreshJudgeSuffix() +{ + const QPixmap pixmap = loadThemedIcon(QStringLiteral("icons/scales")).pixmap(QSize(24, 24)); + if (!pixmap.isNull()) { + // Rich text cannot resolve `currentColor`; register a palette-rendered + // image so already-appended judge tags repaint on a theme switch. + document()->addResource(QTextDocument::ImageResource, QUrl(QStringLiteral("cockatrice:scales")), pixmap); + document()->markContentsDirty(0, document()->characterCount()); + } + + if (contextJudgeName.isEmpty()) { + return; + } + + messagePrefix = QStringLiteral("").arg(palette().color(QPalette::WindowText).name()); + messageSuffix = + QStringLiteral(" [ %1]").arg(sanitizeHtml(contextJudgeName)); } void MessageLogWidget::appendHtmlServerMessage(const QString &html, bool optionalIsBold, QString optionalFontColor) @@ -891,4 +936,6 @@ void MessageLogWidget::connectToPlayerEventHandler(PlayerEventHandler *playerEve MessageLogWidget::MessageLogWidget(TabSupervisor *_tabSupervisor, AbstractGame *_game, QWidget *parent) : ChatView(_tabSupervisor, _game, true, parent), currentContext(MessageContext_None) { + connect(themeManager, &ThemeManager::paletteChanged, this, &MessageLogWidget::refreshJudgeSuffix); + connect(themeManager, &ThemeManager::themeChanged, this, &MessageLogWidget::refreshJudgeSuffix); } diff --git a/cockatrice/src/game_graphics/log/message_log_widget.h b/cockatrice/src/game_graphics/log/message_log_widget.h index 5642a76e9..3ec5ff93c 100644 --- a/cockatrice/src/game_graphics/log/message_log_widget.h +++ b/cockatrice/src/game_graphics/log/message_log_widget.h @@ -39,8 +39,10 @@ private: MessageContext currentContext; QString messagePrefix, messageSuffix; + QString contextJudgeName; static QPair getFromStr(CardZoneLogic *zone, QString cardName, int position, bool ownerChange); + void refreshJudgeSuffix(); public: void connectToPlayerEventHandler(PlayerEventHandler *player); @@ -94,6 +96,7 @@ public slots: int amount, bool isLentToAnotherPlayer); void logReverseTurn(PlayerLogic *player, bool reversed); + void logTurnOrderChanged(QStringList playerNames, bool randomized); void logRollDie(PlayerLogic *player, int sides, const QList &rolls); void logSay(PlayerLogic *player, QString message); void logSetActivePhase(int phase); diff --git a/cockatrice/src/game_graphics/player/menu/card_menu.cpp b/cockatrice/src/game_graphics/player/menu/card_menu.cpp index 9f00c0cba..4f3520814 100644 --- a/cockatrice/src/game_graphics/player/menu/card_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/card_menu.cpp @@ -123,7 +123,7 @@ CardMenu::CardMenu(PlayerGraphicsItem *_player, CardItem *_card, bool _shortcuts aRevealToAll = new QAction(this); - mCardCounters = new QMenu; + mCardCounters = new QMenu(this); // Card counters for (int i = 0; i < 6; ++i) { @@ -225,7 +225,7 @@ void CardMenu::createTableMenu(bool canModifyCard) } addSeparator(); addAction(aClone); - addMenu(new MoveMenu(player)); + addMenu(new MoveMenu(player, this)); addSeparator(); addAction(aAttach); if (card->getAttachedTo()) { @@ -233,7 +233,7 @@ void CardMenu::createTableMenu(bool canModifyCard) } addAction(aDrawArrow); addSeparator(); - addMenu(new PtMenu(player)); + addMenu(new PtMenu(player, this)); addAction(aSetAnnotation); addSeparator(); addAction(aReduceLifeByPower); @@ -275,7 +275,7 @@ void CardMenu::createStackMenu(bool canModifyCard) addAction(aPlayFacedown); addSeparator(); addAction(aClone); - addMenu(new MoveMenu(player)); + addMenu(new MoveMenu(player, this)); addSeparator(); addAction(aAttach); addAction(aDrawArrow); @@ -304,7 +304,7 @@ void CardMenu::createGraveyardOrExileMenu(bool canModifyCard) addAction(aPlayFacedown); addSeparator(); addAction(aClone); - addMenu(new MoveMenu(player)); + addMenu(new MoveMenu(player, this)); addSeparator(); addAction(aAttach); addAction(aDrawArrow); @@ -342,7 +342,7 @@ void CardMenu::createHandOrCustomZoneMenu(bool canModifyCard) addSeparator(); addAction(aClone); - addMenu(new MoveMenu(player)); + addMenu(new MoveMenu(player, this)); // actions that are really wonky when done from deck or sideboard if (card->getZone()->getName() == ZoneNames::HAND) { @@ -366,7 +366,7 @@ void CardMenu::createHandOrCustomZoneMenu(bool canModifyCard) void CardMenu::createZonelessMenu(bool canModifyCard) { if (canModifyCard) { - addMenu(new MoveMenu(player)); + addMenu(new MoveMenu(player, this)); } } @@ -415,7 +415,7 @@ void CardMenu::addRelatedCardView() } addSeparator(); - auto viewRelatedCards = new QMenu(tr("View related cards")); + auto viewRelatedCards = new QMenu(tr("View related cards"), this); addMenu(viewRelatedCards); for (const CardRelation *relatedCard : relatedCards) { QString relatedCardName = relatedCard->getName(); @@ -463,16 +463,16 @@ void CardMenu::addRelatedCardActions() relatedCardName = relatedCard.getName(); // "name" } - QString text = tr("Token: "); + QString text; if (cardRelation->getDoesAttach()) { - text += - tr(cardRelation->getDoesTransform() ? "Transform into " : "Attach to ") + "\"" + relatedCardName + "\""; + text = cardRelation->getDoesTransform() ? tr("Token: Transform into \"%1\"").arg(relatedCardName) + : tr("Token: Attach to \"%1\"").arg(relatedCardName); } else if (cardRelation->getIsVariable()) { - text += "X " + relatedCardName; + text = tr("Token: X %1").arg(relatedCardName); } else if (cardRelation->getDefaultCount() != 1) { - text += QString::number(cardRelation->getDefaultCount()) + "x " + relatedCardName; + text = tr("Token: %1x %2").arg(cardRelation->getDefaultCount()).arg(relatedCardName); } else { - text += relatedCardName; + text = tr("Token: %1").arg(relatedCardName); } if (createRelatedCards == nullptr) { @@ -587,4 +587,4 @@ void CardMenu::setShortcutsActive() aRemoveCounter[i]->setShortcuts(shortcuts.getShortcut("Player/aRC" + colorWords[i])); aSetCounter[i]->setShortcuts(shortcuts.getShortcut("Player/aSC" + colorWords[i])); } -} \ No newline at end of file +} diff --git a/cockatrice/src/game_graphics/player/menu/hand_menu.cpp b/cockatrice/src/game_graphics/player/menu/hand_menu.cpp index e992f3ff5..8ef47d4cb 100644 --- a/cockatrice/src/game_graphics/player/menu/hand_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/hand_menu.cpp @@ -6,6 +6,9 @@ #include "../player_graphics_item.h" #include +#include +#include +#include #include #include #include @@ -67,6 +70,15 @@ HandMenu::HandMenu(PlayerGraphicsItem *_player, QWidget *parent) : TearOffMenu(p aRevealRandomHandCardToAll->setData(-1); connect(aRevealRandomHandCardToAll, &QAction::triggered, this, &HandMenu::onRevealRandomHandCardTriggered); + mRevealRandomHandCards = addMenu(QString()); + mRevealRandomHandCards->setObjectName("revealRandomHandCardsMenu"); + connect(mRevealRandomHandCards, &QMenu::aboutToShow, this, + &HandMenu::populateRevealRandomHandCardsMenuWithActivePlayers); + aRevealRandomHandCardsToAll = new QAction(this); + aRevealRandomHandCardsToAll->setData(-1); + connect(aRevealRandomHandCardsToAll, &QAction::triggered, this, &HandMenu::onRevealRandomHandCardsTriggered); + mRevealRandomHandCards->addAction(aRevealRandomHandCardsToAll); + // We still need to add these actions to menu here so that the shortcuts are active right away mRevealHand->addAction(aRevealHandToAll); mRevealRandomHandCard->addAction(aRevealRandomHandCardToAll); @@ -146,6 +158,8 @@ void HandMenu::retranslateUi() mRevealRandomHandCard->setTitle(tr("Reveal r&andom card to...")); aRevealRandomHandCardToAll->setText(tr("All players")); + mRevealRandomHandCards->setTitle(tr("Reveal a chosen number of random cards to...")); + aRevealRandomHandCardsToAll->setText(tr("All players")); } } @@ -233,3 +247,51 @@ void HandMenu::onRevealRandomHandCardTriggered() const int targetId = action->data().toInt(); player->getLogic()->getPlayerActions()->actRevealRandomHandCard(targetId); } + +void HandMenu::populateRevealRandomHandCardsMenuWithActivePlayers() +{ + const bool hasCards = !player->getLogic()->getHandZone()->getCards().isEmpty(); + mRevealRandomHandCards->clear(); + aRevealRandomHandCardsToAll->setEnabled(hasCards); + mRevealRandomHandCards->addAction(aRevealRandomHandCardsToAll); + mRevealRandomHandCards->addSeparator(); + const auto &players = player->getLogic()->getGame()->getPlayerManager()->getPlayers().values(); + for (auto *other : players) { + if (other == player->getLogic()) { + continue; + } + auto *action = mRevealRandomHandCards->addAction(other->getPlayerInfo()->getName()); + action->setData(other->getPlayerInfo()->getId()); + action->setEnabled(hasCards); + connect(action, &QAction::triggered, this, &HandMenu::onRevealRandomHandCardsTriggered); + } +} + +void HandMenu::onRevealRandomHandCardsTriggered() +{ + const auto *action = qobject_cast(sender()); + const int handSize = player->getLogic()->getHandZone()->getCards().size(); + if (!action || handSize == 0) { + return; + } + const int targetId = action->data().toInt(); + auto *actions = player->getLogic()->getPlayerActions(); + auto *dialog = new QInputDialog(dialogParent()); + dialog->setAttribute(Qt::WA_DeleteOnClose); + dialog->setWindowTitle(tr("Reveal Random Hand Cards")); + dialog->setLabelText(tr("Number of distinct random cards to reveal to %1:").arg(action->text())); + dialog->setInputMode(QInputDialog::IntInput); + dialog->setIntRange(1, handSize); + dialog->setIntValue(qMin(2, handSize)); + connect(dialog, &QDialog::accepted, actions, + [dialog, actions, targetId] { actions->actRevealRandomHandCards(targetId, dialog->intValue()); }); + dialog->open(); +} + +QWidget *HandMenu::dialogParent() const +{ + if (auto *view = player->scene() ? player->scene()->views().value(0) : nullptr) { + return view->window(); + } + return nullptr; +} diff --git a/cockatrice/src/game_graphics/player/menu/hand_menu.h b/cockatrice/src/game_graphics/player/menu/hand_menu.h index de7726064..03ecd7544 100644 --- a/cockatrice/src/game_graphics/player/menu/hand_menu.h +++ b/cockatrice/src/game_graphics/player/menu/hand_menu.h @@ -42,8 +42,11 @@ private slots: void populateRevealRandomHandCardMenuWithActivePlayers(); void onRevealHandTriggered(); void onRevealRandomHandCardTriggered(); + void populateRevealRandomHandCardsMenuWithActivePlayers(); + void onRevealRandomHandCardsTriggered(); private: + QWidget *dialogParent() const; PlayerGraphicsItem *player; QAction *aViewHand = nullptr; @@ -62,6 +65,9 @@ private: QMenu *mRevealRandomHandCard = nullptr; QAction *aRevealRandomHandCardToAll = nullptr; + QMenu *mRevealRandomHandCards = nullptr; + QAction *aRevealRandomHandCardsToAll = nullptr; + QMenu *mMoveHandMenu = nullptr; QAction *aMoveHandToTopLibrary = nullptr; QAction *aMoveHandToBottomLibrary = nullptr; diff --git a/cockatrice/src/game_graphics/player/menu/move_menu.cpp b/cockatrice/src/game_graphics/player/menu/move_menu.cpp index b81c4619b..24bfa3b0c 100644 --- a/cockatrice/src/game_graphics/player/menu/move_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/move_menu.cpp @@ -10,7 +10,7 @@ #include #include -MoveMenu::MoveMenu(PlayerGraphicsItem *player) : QMenu(tr("Move to")) +MoveMenu::MoveMenu(PlayerGraphicsItem *player, QWidget *parent) : QMenu(tr("Move to"), parent) { aMoveToTopLibrary = new QAction(this); aMoveToTopLibrary->setData(cmMoveToTopLibrary); diff --git a/cockatrice/src/game_graphics/player/menu/move_menu.h b/cockatrice/src/game_graphics/player/menu/move_menu.h index f1f239abd..a2cb304aa 100644 --- a/cockatrice/src/game_graphics/player/menu/move_menu.h +++ b/cockatrice/src/game_graphics/player/menu/move_menu.h @@ -17,7 +17,7 @@ class MoveMenu : public QMenu Q_OBJECT public: - explicit MoveMenu(PlayerGraphicsItem *player); + explicit MoveMenu(PlayerGraphicsItem *player, QWidget *parent); void setShortcutsActive(); void retranslateUi(); diff --git a/cockatrice/src/game_graphics/player/menu/player_menu.cpp b/cockatrice/src/game_graphics/player/menu/player_menu.cpp index 582586ac2..27949356f 100644 --- a/cockatrice/src/game_graphics/player/menu/player_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/player_menu.cpp @@ -23,6 +23,7 @@ #include #include #include +#include #include #include #include @@ -70,6 +71,11 @@ PlayerMenu::PlayerMenu(PlayerGraphicsItem *_player) : QObject(_player), player(_ connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this, &PlayerMenu::refreshShortcuts); + + // Re-evaluate shortcuts when the game starts or ends, so the lobby only exposes the roll-die shortcut. + connect(player->getLogic()->getGame()->getGameMetaInfo(), &GameMetaInfo::startedChanged, this, + &PlayerMenu::refreshShortcuts); + refreshShortcuts(); retranslateUi(); @@ -144,6 +150,12 @@ void PlayerMenu::refreshShortcuts() void PlayerMenu::setShortcutsActive() { shortcutsActive = true; + + if (!isGameStarted()) { + applyLobbyShortcuts(); + return; + } + for (auto *c : managedComponents) { c->setShortcutsActive(); } @@ -156,5 +168,24 @@ void PlayerMenu::setShortcutsInactive() for (auto *c : managedComponents) { c->setShortcutsInactive(); } + emit shortcutsDeactivated(); +} + +bool PlayerMenu::isGameStarted() const +{ + return player->getLogic()->getGame()->getGameMetaInfo()->started(); +} + +void PlayerMenu::applyLobbyShortcuts() +{ + // Everything stays unbound until the game starts; only the roll-die shortcut is usable in the lobby. + for (auto *c : managedComponents) { + c->setShortcutsInactive(); + } + + if (utilityMenu) { + utilityMenu->setLobbyShortcutsActive(); + } + emit shortcutsDeactivated(); } \ No newline at end of file diff --git a/cockatrice/src/game_graphics/player/menu/player_menu.h b/cockatrice/src/game_graphics/player/menu/player_menu.h index e21a689de..56de3c918 100644 --- a/cockatrice/src/game_graphics/player/menu/player_menu.h +++ b/cockatrice/src/game_graphics/player/menu/player_menu.h @@ -73,17 +73,24 @@ public: return utilityMenu; } + /** @brief Whether this player currently holds its shortcuts and the game has started. */ [[nodiscard]] bool getShortcutsActive() const { - return shortcutsActive; + return shortcutsActive && isGameStarted(); } - /** @brief Bind keyboard shortcuts. Called when this player gains focus. */ + /** @brief Bind keyboard shortcuts. Only the roll-die shortcut is bound before the game starts. */ void setShortcutsActive(); /** @brief Unbind keyboard shortcuts. Called when this player loses focus. */ void setShortcutsInactive(); private: + /** @brief True once the game has started; false while players are still in the deck-selection lobby. */ + [[nodiscard]] bool isGameStarted() const; + + /** @brief Unbinds everything except the shortcuts that remain usable before the game starts. */ + void applyLobbyShortcuts(); + PlayerGraphicsItem *player; TearOffMenu *playerMenu; QMenu *countersMenu; diff --git a/cockatrice/src/game_graphics/player/menu/pt_menu.cpp b/cockatrice/src/game_graphics/player/menu/pt_menu.cpp index e6c65fcfb..67e98bd76 100644 --- a/cockatrice/src/game_graphics/player/menu/pt_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/pt_menu.cpp @@ -9,7 +9,7 @@ #include #include -PtMenu::PtMenu(PlayerGraphicsItem *player) : QMenu(tr("Power / toughness")) +PtMenu::PtMenu(PlayerGraphicsItem *player, QWidget *parent) : QMenu(tr("Power / toughness"), parent) { PlayerActions *playerActions = player->getLogic()->getPlayerActions(); diff --git a/cockatrice/src/game_graphics/player/menu/pt_menu.h b/cockatrice/src/game_graphics/player/menu/pt_menu.h index aaf17cead..987f033f8 100644 --- a/cockatrice/src/game_graphics/player/menu/pt_menu.h +++ b/cockatrice/src/game_graphics/player/menu/pt_menu.h @@ -18,7 +18,7 @@ class PtMenu : public QMenu Q_OBJECT public: - explicit PtMenu(PlayerGraphicsItem *player); + explicit PtMenu(PlayerGraphicsItem *player, QWidget *parent); void retranslateUi(); void setShortcutsActive(); diff --git a/cockatrice/src/game_graphics/player/menu/utility_menu.cpp b/cockatrice/src/game_graphics/player/menu/utility_menu.cpp index 3b8affff5..249fc57e6 100644 --- a/cockatrice/src/game_graphics/player/menu/utility_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/utility_menu.cpp @@ -150,4 +150,13 @@ void UtilityMenu::setShortcutsInactive() aCreateAnotherToken->setShortcut(QKeySequence()); aIncrementAllCardCounters->setShortcut(QKeySequence()); } +} + +void UtilityMenu::setLobbyShortcutsActive() +{ + if (!player->getLogic()->getPlayerInfo()->getLocalOrJudge()) { + return; + } + + aRollDie->setShortcuts(SettingsCache::instance().shortcuts().getShortcut("Player/aRollDie")); } \ No newline at end of file diff --git a/cockatrice/src/game_graphics/player/menu/utility_menu.h b/cockatrice/src/game_graphics/player/menu/utility_menu.h index 009e4aca3..625c7aa62 100644 --- a/cockatrice/src/game_graphics/player/menu/utility_menu.h +++ b/cockatrice/src/game_graphics/player/menu/utility_menu.h @@ -32,6 +32,9 @@ public slots: public: explicit UtilityMenu(PlayerGraphicsItem *player, QMenu *playerMenu); + /** @brief Binds only the shortcuts that remain usable before the game starts. */ + void setLobbyShortcutsActive(); + [[nodiscard]] bool createAnotherTokenActionExists() const { return aCreateAnotherToken != nullptr; diff --git a/cockatrice/src/game_graphics/player/player_list_widget.cpp b/cockatrice/src/game_graphics/player/player_list_widget.cpp index 7b358010f..bc27d85f9 100644 --- a/cockatrice/src/game_graphics/player/player_list_widget.cpp +++ b/cockatrice/src/game_graphics/player/player_list_widget.cpp @@ -1,6 +1,7 @@ #include "player_list_widget.h" #include "../../interface/pixel_map_generator.h" +#include "../../interface/theme_icon.h" #include "../../interface/widgets/server/user/user_context_menu.h" #include "libcockatrice/protocol/pb/serverinfo_user.pb.h" #include "user_level.h" @@ -67,13 +68,13 @@ PlayerListWidget::PlayerListWidget(TabSupervisor *_tabSupervisor, QWidget *parent) : QTreeWidget(parent), tabSupervisor(_tabSupervisor), client(_client), game(_game), gameStarted(false) { - readyIcon = themePixmap(QStringLiteral("icons/ready_start")); - notReadyIcon = themePixmap(QStringLiteral("icons/not_ready_start")); - concededIcon = themePixmap(QStringLiteral("icons/conceded")); - playerIcon = loadColorAdjustedPixmap("theme:icons/player"); - judgeIcon = loadColorAdjustedPixmap("theme:icons/scales"); - spectatorIcon = loadColorAdjustedPixmap("theme:icons/spectator"); - lockIcon = themePixmap(QStringLiteral("icons/lock")); + readyIcon = loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive, IconStyle::Badge); + notReadyIcon = loadThemedIcon(QStringLiteral("icons/not_ready_start"), AppColor::Negative, IconStyle::Badge); + concededIcon = loadThemedIcon(QStringLiteral("icons/conceded")); + playerIcon = loadThemedIcon(QStringLiteral("icons/player")); + judgeIcon = loadThemedIcon(QStringLiteral("icons/scales")); + spectatorIcon = loadThemedIcon(QStringLiteral("icons/spectator")); + lockIcon = loadThemedIcon(QStringLiteral("icons/lock")); if (tabSupervisor) { itemDelegate = new PlayerListItemDelegate(this); diff --git a/cockatrice/src/game_graphics/player/player_target.cpp b/cockatrice/src/game_graphics/player/player_target.cpp index 1cd97e7b5..0d10ba4ab 100644 --- a/cockatrice/src/game_graphics/player/player_target.cpp +++ b/cockatrice/src/game_graphics/player/player_target.cpp @@ -2,6 +2,7 @@ #include "../../client/settings/cache_settings.h" #include "../../interface/pixel_map_generator.h" +#include "../../interface/theme_manager.h" #include "../board/abstract_counter.h" #include "../board/abstract_graphics_item.h" #include "../board/arrow_target.h" @@ -76,7 +77,7 @@ void PlayerCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * /* QRectF translatedRect = path.controlPointRect(); QSize translatedSize = translatedRect.size().toSize(); - QFont font("Serif"); + QFont font = themeManager->cardTextFont(); font.setWeight(QFont::Bold); font.setPixelSize(qMax(qRound(translatedSize.height() / 1.3), 9)); painter->setFont(font); @@ -226,7 +227,7 @@ void PlayerTarget::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*o name = name.mid(0, 10) + "..."; } - QFont font; + QFont font = themeManager->appFont(AppFont::CardText); font.setPixelSize(qMax(qRound(translatedNameRect.height() / 1.5), 9)); painter->setFont(font); painter->setPen(Qt::white); diff --git a/cockatrice/src/game_graphics/pq_cockatrice_event_player.cpp b/cockatrice/src/game_graphics/pq_cockatrice_event_player.cpp new file mode 100644 index 000000000..c6376ef75 --- /dev/null +++ b/cockatrice/src/game_graphics/pq_cockatrice_event_player.cpp @@ -0,0 +1,628 @@ +#include "pq_cockatrice_event_player.h" + +#include "../../interface/widgets/tabs/tab_game.h" +#include "board/card_item.h" +#include "game_scene.h" +#include "game_view.h" +#include "zones/card_zone.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +Q_LOGGING_CATEGORY(PQ_COCKATRICE_EVENT_PLAYER, "cockatrice.guiTest.eventPlayer") + +namespace +{ +/** + * Strips Qt's mnemonic markers so a test can name a menu entry by the label the + * user sees ("Turn Over" rather than "T&urn Over"). + */ +QString normalizeActionText(const QString &text) +{ + QString normalized = text; + normalized.remove('&'); + return normalized.simplified(); +} + +/** + * Maps a printable character onto the key a US keyboard would report for it, so + * typing produces the same QKeyEvents a recording would. + */ +Qt::Key keyForCharacter(QChar character) +{ + if (character.isLetter()) { + return static_cast(Qt::Key_A + (character.toUpper().unicode() - 'A')); + } + if (character.isDigit()) { + return static_cast(Qt::Key_0 + (character.unicode() - '0')); + } + switch (character.unicode()) { + case ' ': + return Qt::Key_Space; + case '\n': + return Qt::Key_Return; + case '\t': + return Qt::Key_Tab; + case '-': + return Qt::Key_Minus; + case '_': + return Qt::Key_Underscore; + case '/': + return Qt::Key_Slash; + case ',': + return Qt::Key_Comma; + case '.': + return Qt::Key_Period; + case '\'': + return Qt::Key_Apostrophe; + case ';': + return Qt::Key_Semicolon; + case ':': + return Qt::Key_Colon; + case '"': + return Qt::Key_QuoteDbl; + case '&': + return Qt::Key_Ampersand; + case '(': + return Qt::Key_ParenLeft; + case ')': + return Qt::Key_ParenRight; + case '[': + return Qt::Key_BracketLeft; + case ']': + return Qt::Key_BracketRight; + default: + return Qt::Key_unknown; + } +} + +/** Depth-first search for the first model index whose label matches @p text. */ +QModelIndex findModelIndexByText(const QAbstractItemModel *model, const QString &text, const QModelIndex &parent = {}) +{ + for (int row = 0; row < model->rowCount(parent); ++row) { + const QModelIndex index = model->index(row, 0, parent); + if (index.data(Qt::DisplayRole).toString() == text) { + return index; + } + const QModelIndex child = findModelIndexByText(model, text, index); + if (child.isValid()) { + return child; + } + } + return {}; +} + +bool needsShift(QChar character) +{ + if (character.isUpper()) { + return true; + } + return QStringLiteral("_:\"&()").contains(character); +} +} // namespace + +pqCockatriceEventPlayer::pqCockatriceEventPlayer(QObject *parent) : pqWidgetEventPlayer(parent) +{ +} + +bool pqCockatriceEventPlayer::playEvent(QObject *object, const QString &command, const QString &arguments, bool &error) +{ + // A known command that fails is a handled-but-failed event: QtTesting logs + // "Unhandled event" when playEvent returns false, so report through `error` + // (the contract playExpectProperty already follows) and still return true. + const auto run = [&error](bool ok) { + if (!ok) { + error = true; + } + return true; + }; + + if (command == "clickCard") { + return run(playClickCard(object, arguments)); + } else if (command == "flipCard") { + return run(playFlipCard(object, arguments)); + } else if (command == "cardAction") { + return run(playCardAction(object, arguments)); + } else if (command == "moveCard") { + return run(playMoveCard(object, arguments)); + } else if (command == "drawArrow") { + return run(playDrawArrow(object, arguments)); + } else if (command == "createToken") { + return run(playCreateToken(object, arguments)); + } else if (command == "selectItem") { + return run(playSelectItem(object, arguments)); + } else if (command == "typeText") { + return run(playTypeText(object, arguments)); + } else if (command == "expectProperty") { + return playExpectProperty(object, arguments, error); + } + return false; +} + +QGraphicsItem * +pqCockatriceEventPlayer::findCardInScene(QGraphicsScene *scene, const QString &cardName, const QString &zoneName) +{ + for (QGraphicsItem *gi : scene->items()) { + if (gi->type() != typeCard) { + continue; + } + auto *card = static_cast(gi); + if (!card->getZone() || card->getZone()->getName() != zoneName) { + continue; + } + QString actualName = card->getName(); + if (actualName == cardName || actualName.endsWith(" " + cardName)) { + return card; + } + } + return nullptr; +} + +QGraphicsItem *pqCockatriceEventPlayer::findZoneInScene(QGraphicsScene *scene, const QString &zoneName) +{ + for (QGraphicsItem *gi : scene->items()) { + if (gi->type() != typeZone) { + continue; + } + auto *zone = static_cast(gi); + if (zone->getLogic() && zone->getLogic()->getName() == zoneName) { + return zone; + } + } + return nullptr; +} + +QAction *pqCockatriceEventPlayer::findActionInMenu(QMenu *menu, const QString &actionText) +{ + if (!menu || actionText.isEmpty()) { + return nullptr; + } + for (QAction *action : menu->actions()) { + if (action->isSeparator()) { + continue; + } + if (normalizeActionText(action->text()).contains(actionText, Qt::CaseInsensitive)) { + return action; + } + if (QMenu *subMenu = action->menu()) { + if (QAction *found = findActionInMenu(subMenu, actionText)) { + return found; + } + } + } + return nullptr; +} + +QMenu *pqCockatriceEventPlayer::visiblePopupMenu() +{ + for (QWidget *w : QApplication::topLevelWidgets()) { + auto *menu = qobject_cast(w); + if (menu && menu->isVisible()) { + return menu; + } + } + return nullptr; +} + +QPoint pqCockatriceEventPlayer::viewportPoint(QGraphicsView *view, const QGraphicsItem *item) +{ + return view->mapFromScene(item->sceneBoundingRect().center()); +} +void pqCockatriceEventPlayer::sendMouseEvent(QGraphicsView *view, + QEvent::Type type, + const QPoint &pos, + Qt::MouseButton button, + Qt::MouseButtons buttons) +{ + QMouseEvent event(type, pos, view->viewport()->mapToGlobal(pos), button, buttons, Qt::NoModifier); + qApp->notify(view->viewport(), &event); +} + +bool pqCockatriceEventPlayer::playClickCard(QObject *object, const QString &arguments) +{ + auto *gv = qobject_cast(object); + if (!gv) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "clickCard: target widget is not a QGraphicsView"; + return false; + } + + QStringList parts = arguments.split(','); + if (parts.size() < 2) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "clickCard: arguments must be 'cardName,zoneName', got:" << arguments; + return false; + } + + QGraphicsScene *scene = gv->scene(); + if (!scene) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "clickCard: no scene on the QGraphicsView"; + return false; + } + + auto *target = findCardInScene(scene, parts[0].trimmed(), parts[1].trimmed()); + if (!target) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "clickCard: card" << parts[0].trimmed() << "not found in zone" << parts[1].trimmed(); + return false; + } + + const QPoint pos = viewportPoint(gv, target); + // A double click is the play gesture from hand: press/release, then the + // double-click event and its release, exactly as a recording produces it. + sendMouseEvent(gv, QEvent::MouseButtonPress, pos, Qt::LeftButton, Qt::LeftButton); + sendMouseEvent(gv, QEvent::MouseButtonRelease, pos, Qt::LeftButton, Qt::LeftButton); + sendMouseEvent(gv, QEvent::MouseButtonDblClick, pos, Qt::LeftButton, Qt::LeftButton); + sendMouseEvent(gv, QEvent::MouseButtonRelease, pos, Qt::LeftButton, Qt::LeftButton); + + return true; +} + +bool pqCockatriceEventPlayer::playFlipCard(QObject *object, const QString &arguments) +{ + auto *gv = qobject_cast(object); + if (!gv) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "flipCard: target widget is not a QGraphicsView"; + return false; + } + + QStringList parts = arguments.split(','); + if (parts.size() < 2) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "flipCard: arguments must be 'cardName,zoneName', got:" << arguments; + return false; + } + + QGraphicsScene *scene = gv->scene(); + if (!scene) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "flipCard: no scene"; + return false; + } + + auto *card = qgraphicsitem_cast(findCardInScene(scene, parts[0].trimmed(), parts[1].trimmed())); + if (!card) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "flipCard: card" << parts[0].trimmed() << "not found in zone" << parts[1].trimmed(); + return false; + } + + return invokeCardMenuAction("flipCard", gv, card, QStringLiteral("Turn Over")); +} + +bool pqCockatriceEventPlayer::playCardAction(QObject *object, const QString &arguments) +{ + auto *gv = qobject_cast(object); + if (!gv) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "cardAction: target widget is not a QGraphicsView"; + return false; + } + + QStringList parts = arguments.split(','); + if (parts.size() < 3) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "cardAction: arguments must be 'cardName,zoneName,actionText', got:" << arguments; + return false; + } + + QGraphicsScene *scene = gv->scene(); + if (!scene) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "cardAction: no scene"; + return false; + } + + auto *card = qgraphicsitem_cast(findCardInScene(scene, parts[0].trimmed(), parts[1].trimmed())); + if (!card) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "cardAction: card" << parts[0].trimmed() << "not found in zone" << parts[1].trimmed(); + return false; + } + + return invokeCardMenuAction("cardAction", gv, card, parts.mid(2).join(',').trimmed()); +} + +bool pqCockatriceEventPlayer::invokeCardMenuAction(const char *command, + QGraphicsView *view, + CardItem *card, + const QString &actionText) +{ + const QPoint pos = viewportPoint(view, card); + sendMouseEvent(view, QEvent::MouseButtonPress, pos, Qt::RightButton, Qt::RightButton); + sendMouseEvent(view, QEvent::MouseButtonRelease, pos, Qt::RightButton, Qt::NoButton); + qApp->processEvents(); + + QMenu *menu = visiblePopupMenu(); + if (!menu) { + qCWarning(PQ_COCKATRICE_EVENT_PLAYER) << command << ": no popup menu appeared after right-click"; + return false; + } + + QAction *action = findActionInMenu(menu, actionText); + if (!action) { + qCWarning(PQ_COCKATRICE_EVENT_PLAYER) << command << ": no context menu action matching" << actionText; + menu->close(); + return false; + } + + action->trigger(); + menu->close(); + qApp->processEvents(); + return true; +} + +bool pqCockatriceEventPlayer::playMoveCard(QObject *object, const QString &arguments) +{ + auto *gv = qobject_cast(object); + if (!gv) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "moveCard: target widget is not a QGraphicsView"; + return false; + } + + QStringList parts = arguments.split(','); + if (parts.size() < 3) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "moveCard: arguments must be 'cardName,srcZoneName,dstZoneName', got:" << arguments; + return false; + } + + const QString cardName = parts[0].trimmed(); + const QString srcZoneName = parts[1].trimmed(); + const QString dstZoneName = parts[2].trimmed(); + + QGraphicsScene *scene = gv->scene(); + if (!scene) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "moveCard: no scene"; + return false; + } + + auto *card = findCardInScene(scene, cardName, srcZoneName); + if (!card) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "moveCard: card" << cardName << "not found in zone" << srcZoneName; + return false; + } + + auto *dstZone = findZoneInScene(scene, dstZoneName); + if (!dstZone) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "moveCard: destination zone" << dstZoneName << "not found"; + return false; + } + + const QPoint srcPos = viewportPoint(gv, card); + const QPoint dstPos = viewportPoint(gv, dstZone); + + // A drag has to travel past Qt's start distance before the scene turns it + // into a card drag item, otherwise the move is interpreted as a click. + const int dragDistance = 2 * QApplication::startDragDistance() + 10; + const QPoint pastDragStart = srcPos + QPoint(dragDistance, 0); + + sendMouseEvent(gv, QEvent::MouseButtonPress, srcPos, Qt::LeftButton, Qt::LeftButton); + sendMouseEvent(gv, QEvent::MouseMove, pastDragStart, Qt::LeftButton, Qt::LeftButton); + qApp->processEvents(); + sendMouseEvent(gv, QEvent::MouseMove, dstPos, Qt::LeftButton, Qt::LeftButton); + qApp->processEvents(); + sendMouseEvent(gv, QEvent::MouseButtonRelease, dstPos, Qt::LeftButton, Qt::NoButton); + qApp->processEvents(); + + return true; +} + +bool pqCockatriceEventPlayer::playDrawArrow(QObject *object, const QString &arguments) +{ + auto *gv = qobject_cast(object); + if (!gv) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "drawArrow: target widget is not a QGraphicsView"; + return false; + } + + QStringList parts = arguments.split(','); + if (parts.size() < 4) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "drawArrow: arguments must be 'srcCard,srcZone,dstCard,dstZone', got:" << arguments; + return false; + } + + const QString srcCardName = parts[0].trimmed(); + const QString srcZoneName = parts[1].trimmed(); + const QString dstCardName = parts[2].trimmed(); + const QString dstZoneName = parts[3].trimmed(); + + QGraphicsScene *scene = gv->scene(); + if (!scene) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "drawArrow: no scene"; + return false; + } + + auto *srcCard = findCardInScene(scene, srcCardName, srcZoneName); + if (!srcCard) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "drawArrow: source card" << srcCardName << "not found in zone" << srcZoneName; + return false; + } + + auto *dstCard = findCardInScene(scene, dstCardName, dstZoneName); + if (!dstCard) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "drawArrow: dest card" << dstCardName << "not found in zone" << dstZoneName; + return false; + } + + const QPoint srcPos = viewportPoint(gv, srcCard); + const QPoint dstPos = viewportPoint(gv, dstCard); + + const int dragDistance = 2 * QApplication::startDragDistance() + 10; + const QPoint pastDragStart = srcPos + QPoint(dragDistance, 0); + + sendMouseEvent(gv, QEvent::MouseButtonPress, srcPos, Qt::RightButton, Qt::RightButton); + sendMouseEvent(gv, QEvent::MouseMove, pastDragStart, Qt::RightButton, Qt::RightButton); + qApp->processEvents(); + sendMouseEvent(gv, QEvent::MouseMove, dstPos, Qt::RightButton, Qt::RightButton); + sendMouseEvent(gv, QEvent::MouseButtonRelease, dstPos, Qt::RightButton, Qt::NoButton); + qApp->processEvents(); + + return true; +} + +bool pqCockatriceEventPlayer::playCreateToken(QObject *object, const QString &arguments) +{ + auto *gv = qobject_cast(object); + if (!gv) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: target widget is not a QGraphicsView"; + return false; + } + + QStringList parts = arguments.split(','); + if (parts.size() < 3) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: arguments must be 'name,color,pt', got:" << arguments; + return false; + } + + QString tokenName = parts[0].trimmed(); + QString tokenColor = parts[1].trimmed(); + QString tokenPt = parts[2].trimmed(); + + TabGame *tabGame = nullptr; + QWidget *w = gv; + while (w) { + tabGame = qobject_cast(w); + if (tabGame) { + break; + } + w = w->parentWidget(); + } + if (!tabGame) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: TabGame not found"; + return false; + } + + AbstractGame *game = tabGame->getGame(); + if (!game) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: no game"; + return false; + } + + int activePlayerId = game->getGameState()->getActivePlayer(); + PlayerLogic *player = game->getPlayerManager()->getActiveLocalPlayer(activePlayerId); + if (!player) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "createToken: no active local player"; + return false; + } + + TokenInfo info; + info.name = tokenName; + info.color = tokenColor; + info.pt = tokenPt; + info.destroy = true; + info.faceDown = false; + + player->getPlayerActions()->actCreateToken(info); + + for (int i = 0; i < 10; ++i) { + qApp->processEvents(); + } + + return true; +} + +bool pqCockatriceEventPlayer::playSelectItem(QObject *object, const QString &arguments) +{ + const QString text = arguments.trimmed(); + if (text.isEmpty()) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "selectItem: needs the label of the item to select"; + return false; + } + + if (auto *tree = qobject_cast(object)) { + QTreeWidgetItemIterator item(tree); + while (*item) { + if ((*item)->text(0) == text) { + tree->setCurrentItem(*item); + qApp->processEvents(); + return true; + } + ++item; + } + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "selectItem: no tree item labelled" << text; + return false; + } + + if (auto *view = qobject_cast(object)) { + const QModelIndex index = findModelIndexByText(view->model(), text); + if (!index.isValid()) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "selectItem: no item labelled" << text; + return false; + } + view->setCurrentIndex(index); + qApp->processEvents(); + return true; + } + + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "selectItem: target is neither a tree widget nor an item view"; + return false; +} + +bool pqCockatriceEventPlayer::playTypeText(QObject *object, const QString &arguments) +{ + auto *widget = qobject_cast(object); + if (!widget) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "typeText: target is not a QWidget"; + return false; + } + + for (const QChar character : arguments) { + const Qt::Key key = keyForCharacter(character); + const Qt::KeyboardModifiers modifiers = needsShift(character) ? Qt::ShiftModifier : Qt::NoModifier; + QKeyEvent press(QEvent::KeyPress, key, modifiers, QString(character)); + QKeyEvent release(QEvent::KeyRelease, key, modifiers, QString(character)); + qApp->notify(widget, &press); + qApp->notify(widget, &release); + } + qApp->processEvents(); + + return true; +} + +bool pqCockatriceEventPlayer::playExpectProperty(QObject *object, const QString &arguments, bool &error) +{ + if (!object) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "expectProperty: no target object"; + error = true; + return true; + } + + QStringList parts = arguments.split(','); + if (parts.size() < 2) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "expectProperty: arguments must be 'propertyName,expectedValue', got:" << arguments; + error = true; + return true; + } + + const QString propertyName = parts[0].trimmed(); + const QString expected = parts.mid(1).join(','); + + const QVariant actual = object->property(propertyName.toUtf8().constData()); + if (!actual.isValid()) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) + << "expectProperty:" << object->objectName() << "has no property" << propertyName; + error = true; + return true; + } + + if (actual.toString() != expected) { + qCCritical(PQ_COCKATRICE_EVENT_PLAYER) << "expectProperty:" << object->objectName() << propertyName << "is" + << actual.toString() << "but the test expected" << expected; + error = true; + return true; + } + + return true; +} diff --git a/cockatrice/src/game_graphics/pq_cockatrice_event_player.h b/cockatrice/src/game_graphics/pq_cockatrice_event_player.h new file mode 100644 index 000000000..89d88af77 --- /dev/null +++ b/cockatrice/src/game_graphics/pq_cockatrice_event_player.h @@ -0,0 +1,91 @@ +#ifndef PQ_COCKATRICE_EVENT_PLAYER_H +#define PQ_COCKATRICE_EVENT_PLAYER_H + +#include "pqWidgetEventPlayer.h" + +#include + +class CardItem; +class QAction; +class QGraphicsItem; +class QGraphicsScene; +class QGraphicsView; +class QMenu; + +/** + * Custom event player for Cockatrice game interactions. + * + * Card commands are resolution independent: the card is looked up in the + * QGraphicsScene by name and zone, then mouse events are sent at the card's + * current viewport position. That keeps recordings usable across screen sizes + * and window geometries. + * + * Commands: + * clickCard "cardName,zoneName" + * left press/release + double click — plays a card from hand, taps a + * permanent, opens the deck view entry, depending on the zone + * flipCard "cardName,zoneName" + * right-click → "Turn Over" (shorthand for cardAction) + * cardAction "cardName,zoneName,actionText" + * right-click the card and trigger the first context menu action whose + * label contains actionText; submenus are searched as well, so + * "Add counter (A)" works + * moveCard "cardName,srcZoneName,dstZoneName" + * left-drag the card onto the destination zone — plays a card, moves it + * to the graveyard/exile, puts it back into hand, ... + * drawArrow "srcCard,srcZone,dstCard,dstZone" + * right-drag an arrow from one card to another + * createToken "name,color,pt" + * create a token through the active local player, as the table context + * menu's "Create token" dialog would + * selectItem "item text" + * selects the first item of a tree/list widget whose label matches, for + * navigating the settings navigation tree or similar label-driven views + * typeText "some text" + * types the text into the widget one key event at a time — use it for + * deck lists and search boxes instead of one event per character + * expectProperty "propertyName,expectedValue" + * assertion: playback fails unless object->property(propertyName) + * equals expectedValue — for example "count,2" on the tab widget + */ +class pqCockatriceEventPlayer : public pqWidgetEventPlayer +{ + Q_OBJECT + typedef pqWidgetEventPlayer Superclass; + +public: + explicit pqCockatriceEventPlayer(QObject *parent = nullptr); + + using Superclass::playEvent; + + bool playEvent(QObject *object, const QString &command, const QString &arguments, bool &error) override; + +private: + bool playClickCard(QObject *object, const QString &arguments); + bool playFlipCard(QObject *object, const QString &arguments); + bool playCardAction(QObject *object, const QString &arguments); + bool playMoveCard(QObject *object, const QString &arguments); + bool playDrawArrow(QObject *object, const QString &arguments); + bool playCreateToken(QObject *object, const QString &arguments); + bool playSelectItem(QObject *object, const QString &arguments); + bool playTypeText(QObject *object, const QString &arguments); + bool playExpectProperty(QObject *object, const QString &arguments, bool &error); + + /** @brief Triggers the context menu entry @p actionText after right-clicking @p card. */ + bool invokeCardMenuAction(const char *command, QGraphicsView *view, CardItem *card, const QString &actionText); + + static QGraphicsItem *findCardInScene(QGraphicsScene *scene, const QString &cardName, const QString &zoneName); + static QGraphicsItem *findZoneInScene(QGraphicsScene *scene, const QString &zoneName); + static QAction *findActionInMenu(QMenu *menu, const QString &actionText); + static QMenu *visiblePopupMenu(); + + /** @brief Viewport position of the item's centre, as the mouse events have to be sent. */ + static QPoint viewportPoint(QGraphicsView *view, const QGraphicsItem *item); + static void sendMouseEvent(QGraphicsView *view, + QEvent::Type type, + const QPoint &pos, + Qt::MouseButton button, + Qt::MouseButtons buttons); +}; + +#endif // PQ_COCKATRICE_EVENT_PLAYER_H diff --git a/cockatrice/src/game_graphics/z_values.h b/cockatrice/src/game_graphics/z_values.h index c6e7f2c8a..8e198102b 100644 --- a/cockatrice/src/game_graphics/z_values.h +++ b/cockatrice/src/game_graphics/z_values.h @@ -34,6 +34,10 @@ constexpr qreal OVERLAY_BASE = ZValueLayerManager::OVERLAY_BASE; // Overlay layer Z-values for items that should appear above normal cards constexpr qreal HOVERED_CARD = ZValueLayerManager::overlayZValue(1.0); +// Lifts the hovered card's whole player area above sibling areas (and their zone +// decorations) while hovering. One level only: Qt compares subtrees at their highest +// differing ancestor, so the top ancestor decides. Below arrows/views/drags on purpose. +constexpr qreal HOVERED_AREA = ZValueLayerManager::overlayZValue(2.0); constexpr qreal ARROWS = ZValueLayerManager::overlayZValue(3.0); constexpr qreal ZONE_VIEW_WIDGET = ZValueLayerManager::overlayZValue(4.0); constexpr qreal DRAG_ITEM = ZValueLayerManager::overlayZValue(5.0); diff --git a/cockatrice/src/game_graphics/zones/table_zone.cpp b/cockatrice/src/game_graphics/zones/table_zone.cpp index 0dffab353..dd2b3e38e 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.cpp +++ b/cockatrice/src/game_graphics/zones/table_zone.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -34,6 +35,7 @@ #include #include #include +#include #include namespace google @@ -118,6 +120,19 @@ bool TableZone::isInverted() const void TableZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/) { + // The hovered (zoomed) card may spill over this zone: cut it out of every layer + // below so washes, indicators, dimming and the land divider never paint over it, + // whatever the stacking order is. + const std::optional hoverHole = hoveredCardHole(); + const bool clipped = hoverHole.has_value(); + if (clipped && hoverHole->isEmpty()) { + // The zoom covers this zone entirely: there is nothing left of it to paint. + return; + } + if (clipped) { + painter->setClipPath(*hoverHole); + } + if (playmatActive) { // Subtle overlay to distinguish table zone from stack zone painter->fillRect(boundingRect(), QColor(0, 0, 0, 60)); @@ -142,6 +157,39 @@ void TableZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*opti } paintLandDivider(painter); + + if (clipped) { + painter->setClipping(false); + } +} + +/** + * @brief This zone's paint area minus the hovered card's painted outline, in zone coordinates. + * + * @c std::nullopt when no card is hovered or it does not overlap this zone; an *empty* path when + * the zoom covers this zone entirely, which means nothing of it is left to paint. Lets every + * paint layer dodge the zoomed card instead of painting over it. + */ +std::optional TableZone::hoveredCardHole() const +{ + auto *scene = qobject_cast(this->scene()); + if (!scene) { + return std::nullopt; + } + CardItem *hovered = scene->getHoveredCard(); + if (!hovered) { + return std::nullopt; + } + // Cut out the card's painted outline, not its bounding rectangle: the rectangle's corners + // are never covered by the artwork (rounded corners, antialiased edges), so they would stay + // black around the zoom. + const QPainterPath cardOutline = sceneTransform().inverted().map(hovered->sceneTransform().map(hovered->shape())); + if (!cardOutline.boundingRect().intersects(boundingRect())) { + return std::nullopt; + } + QPainterPath zoneArea; + zoneArea.addRect(boundingRect()); + return zoneArea.subtracted(cardOutline); } void TableZone::onPlaymatChanged(bool active) diff --git a/cockatrice/src/game_graphics/zones/table_zone.h b/cockatrice/src/game_graphics/zones/table_zone.h index 8aa4936ad..6beb54d30 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.h +++ b/cockatrice/src/game_graphics/zones/table_zone.h @@ -18,6 +18,7 @@ #include #include #include +#include #include class CardDragItem; @@ -26,6 +27,7 @@ class CardZoneLogic; class QColor; class QGraphicsItem; class QPainter; +class QPainterPath; template class QList; /** @@ -221,6 +223,13 @@ private: void paintZoneOutline(QPainter *painter); void paintLandDivider(QPainter *painter); + /** + * @brief This zone's paint area minus the hovered card's painted outline, in zone coordinates. + * + * @c std::nullopt when no card is hovered or it does not overlap this zone; an *empty* path when + * the hovered card covers this zone entirely, in which case nothing of the zone is visible. + */ + [[nodiscard]] std::optional hoveredCardHole() const; /* Calculates card stack widths so mapping functions work properly diff --git a/cockatrice/src/game_graphics/zones/view_zone.cpp b/cockatrice/src/game_graphics/zones/view_zone.cpp index be2e471d4..1716fc24d 100644 --- a/cockatrice/src/game_graphics/zones/view_zone.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone.cpp @@ -1,21 +1,27 @@ #include "view_zone.h" #include "../../client/settings/cache_settings.h" +#include "../../interface/theme_manager.h" #include "../board/card_drag_item.h" #include "../board/card_item.h" #include "../card_dimensions.h" +#include "../z_values.h" #include "libcockatrice/card/printing/exact_card.h" #include "libcockatrice/filters/filter_string.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/serverinfo_zone.pb.h" #include "libcockatrice/utility/card_ref.h" +#include #include #include #include +#include +#include #include #include #include +#include #include #include #include @@ -34,12 +40,85 @@ class QGraphicsItem; +namespace +{ +constexpr qreal MARKER_HORIZONTAL_PADDING = 4.0; +constexpr qreal MARKER_VERTICAL_PADDING = 2.0; +constexpr qreal MARKER_CORNER_RADIUS = 4.0; +constexpr qreal MARKER_INSET = 2.0; +constexpr int MARKER_FONT_SIZE = 9; +// Vertical pitch of a card column: each card shows this much before the next +// card overlaps it. Marker placement uses it to stay on the visible strip. +constexpr qreal CARD_STACK_OFFSET = CardDimensions::HEIGHT_F / 3.0; + +QFont markerFont() +{ + QFont font = themeManager != nullptr ? themeManager->appFont(AppFont::CardText) : QFont(); + font.setPixelSize(MARKER_FONT_SIZE); + font.setWeight(QFont::Bold); + return font; +} +} // namespace + +LibraryEdgeMarker::LibraryEdgeMarker(QGraphicsItem *parent) : QGraphicsItem(parent) +{ + setAcceptedMouseButtons(Qt::NoButton); + setAcceptHoverEvents(false); +} + +void LibraryEdgeMarker::setLabel(const QString &newLabel, const QString &toolTip) +{ + if (label == newLabel && this->toolTip() == toolTip) { + return; + } + + prepareGeometryChange(); + label = newLabel; + + const QFontMetrics metrics(markerFont()); + markerRect = QRectF(0, 0, metrics.horizontalAdvance(label) + 2 * MARKER_HORIZONTAL_PADDING, + metrics.height() + 2 * MARKER_VERTICAL_PADDING); + + setToolTip(toolTip); + update(); +} + +QRectF LibraryEdgeMarker::boundingRect() const +{ + return markerRect; +} + +void LibraryEdgeMarker::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/) +{ + if (label.isEmpty()) { + return; + } + + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + painter->setFont(markerFont()); + + // ToolTipBase/ToolTipText are the palette's designed background/text pair + // for small overlays, so the chip keeps its contrast in every theme. + const QPalette palette = qApp->palette(); + painter->setPen(QPen(palette.color(QPalette::Mid), 1)); + painter->setBrush(palette.color(QPalette::ToolTipBase)); + painter->drawRoundedRect(markerRect.adjusted(0.5, 0.5, -0.5, -0.5), MARKER_CORNER_RADIUS, MARKER_CORNER_RADIUS); + + painter->setPen(palette.color(QPalette::ToolTipText)); + painter->drawText(markerRect, Qt::AlignCenter, label); + painter->restore(); +} + /** * @param parent the parent QGraphicsWidget containing the reveal zone */ ZoneViewZone::ZoneViewZone(ZoneViewZoneLogic *_logic, QGraphicsItem *parent) : SelectZone(_logic, parent), bRect(QRectF()), minRows(0), groupBy(CardList::NoSort), sortBy(CardList::NoSort) { + libraryEdgeMarker = new LibraryEdgeMarker(this); + libraryEdgeMarker->setVisible(false); + auto *viewLogic = qobject_cast(getLogic()); if (!(viewLogic->getRevealZone() && !viewLogic->getWriteableRevealZone())) { viewLogic->getOriginalZone()->getViews().append(viewLogic); @@ -183,6 +262,8 @@ void ZoneViewZone::reorganizeCards() gridSize = positionCardsForDisplay(cardsToDisplay); } + updateLibraryEdgeMarker(cardsToDisplay); + // determine bounding rect qreal aleft = 0; qreal atop = 0; @@ -232,7 +313,7 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca lastColumnProp = columnProp; qreal x = col * CardDimensions::WIDTH_F; - qreal y = row * CardDimensions::HEIGHT_F / 3; + qreal y = row * CARD_STACK_OFFSET; cItem->setPos(HORIZONTAL_PADDING + x, VERTICAL_PADDING + y); cItem->setRealZValue(i); longestRow = qMax(row, longestRow); @@ -261,7 +342,7 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca for (int i = 0; i < cardCount; i++) { auto *cItem = qobject_cast(cards.at(i)->parent()); qreal x = (i / rows) * CardDimensions::WIDTH_F; - qreal y = (i % rows) * CardDimensions::HEIGHT_F / 3; + qreal y = (i % rows) * CARD_STACK_OFFSET; cItem->setPos(HORIZONTAL_PADDING + x, VERTICAL_PADDING + y); cItem->setRealZValue(i); } @@ -270,6 +351,75 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca } } +void ZoneViewZone::updateLibraryEdgeMarker(const CardList &displayedCards) +{ + auto *viewLogic = qobject_cast(getLogic()); + if (viewLogic == nullptr || !viewLogic->isLibraryEdgeView() || displayedCards.isEmpty()) { + libraryEdgeMarkerAnchorPos = QPointF(); + libraryEdgeMarker->hide(); + return; + } + + const bool topEdge = viewLogic->showsTopOfLibrary(); + CardState *edgeState = topEdge ? displayedCards.first() : displayedCards.last(); + auto *edgeCard = edgeState == nullptr ? nullptr : qobject_cast(edgeState->parent()); + if (edgeCard == nullptr) { + libraryEdgeMarkerAnchorPos = QPointF(); + libraryEdgeMarker->hide(); + return; + } + + libraryEdgeMarkerAnchorPos = edgeCard->pos(); + applyLibraryEdgeMarkerText(); + positionLibraryEdgeMarker(); + + // Above the hovered-card layer: the chip sits inside its card, so hovering + // that card must not let the card repaint over the indicator. + libraryEdgeMarker->setZValue(ZValues::HOVERED_CARD + 0.5); + libraryEdgeMarker->show(); +} + +void ZoneViewZone::positionLibraryEdgeMarker() +{ + // The chip has to stay on the part of its card that is not covered by the + // next card in the column. The top card only shows its top strip, and its + // name is drawn top-left, so the chip goes to the right of that strip. The + // bottom card is fully visible, so the chip goes to its bottom-left, away + // from the bottom-right power/toughness. + const auto *viewLogic = qobject_cast(getLogic()); + const QRectF markerRect = libraryEdgeMarker->boundingRect(); + const bool topEdge = viewLogic != nullptr && viewLogic->showsTopOfLibrary(); + const QPointF offset = topEdge + ? QPointF(CardDimensions::WIDTH_F - markerRect.width() - MARKER_INSET, + CARD_STACK_OFFSET - markerRect.height() - MARKER_INSET) + : QPointF(MARKER_INSET, CardDimensions::HEIGHT_F - markerRect.height() - MARKER_INSET); + libraryEdgeMarker->setPos(libraryEdgeMarkerAnchorPos + offset); +} + +void ZoneViewZone::applyLibraryEdgeMarkerText() +{ + const auto *viewLogic = qobject_cast(getLogic()); + if (viewLogic == nullptr) { + return; + } + + const bool topEdge = viewLogic->showsTopOfLibrary(); + //: Short badge on the card at the top or bottom of a viewed library. The full phrase is in the tooltip. + libraryEdgeMarker->setLabel(topEdge ? tr("Top") : tr("Bottom"), + topEdge ? tr("This card is on top of the library") + : tr("This card is at the bottom of the library")); +} + +void ZoneViewZone::retranslateEdgeMarker() +{ + if (libraryEdgeMarker->isVisible()) { + // The translated label can be a different width, so reposition the chip + // to keep it inside its card. + applyLibraryEdgeMarkerText(); + positionLibraryEdgeMarker(); + } +} + void ZoneViewZone::setFilterString(const QString &_filterString) { const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay(); diff --git a/cockatrice/src/game_graphics/zones/view_zone.h b/cockatrice/src/game_graphics/zones/view_zone.h index f91b24834..9593ceeb9 100644 --- a/cockatrice/src/game_graphics/zones/view_zone.h +++ b/cockatrice/src/game_graphics/zones/view_zone.h @@ -9,9 +9,11 @@ #include "select_zone.h" +#include #include #include #include +#include #include #include #include @@ -33,6 +35,30 @@ inline Q_LOGGING_CATEGORY(ViewZoneLog, "view_zone"); class Response; class ServerInfo_Card; class QGraphicsSceneWheelEvent; +class QPainter; +class QStyleOptionGraphicsItem; +class QWidget; + +/** + * A small chip drawn on the card at the edge of a viewed library. + * + * It is a sibling of the card items rather than part of the zone widget, so it + * stays attached to its card when the view reflows into several columns or is + * resized. ZoneViewZone owns and positions it. + */ +class LibraryEdgeMarker : public QGraphicsItem +{ +public: + explicit LibraryEdgeMarker(QGraphicsItem *parent = nullptr); + + void setLabel(const QString &label, const QString &toolTip); + [[nodiscard]] QRectF boundingRect() const override; + void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; + +private: + QRectF markerRect; + QString label; +}; /** * A CardZone that is a view into another CardZone. @@ -56,6 +82,8 @@ private: FilterString filterString = FilterString(""); CardList::SortOption groupBy, sortBy; bool pileView; + LibraryEdgeMarker *libraryEdgeMarker; + QPointF libraryEdgeMarkerAnchorPos; struct GridSize { @@ -64,6 +92,9 @@ private: }; GridSize positionCardsForDisplay(CardList &cards, CardList::SortOption pileOption = CardList::NoSort); + void updateLibraryEdgeMarker(const CardList &displayedCards); + void applyLibraryEdgeMarkerText(); + void positionLibraryEdgeMarker(); void handleDropEvent(const QList &dragItems, CardZoneLogic *startZone, const QPoint &dropPoint) override; @@ -79,6 +110,10 @@ public: { return optimumRect; } + + /** @brief Re-applies the translated library edge marker text after a language change. */ + void retranslateEdgeMarker(); + public slots: void addToViews(); void removeFromViews(); diff --git a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp index b1ca42ee1..bd8da9407 100644 --- a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp @@ -3,6 +3,7 @@ #include "../../client/settings/cache_settings.h" #include "../../filters/syntax_help.h" #include "../../interface/pixel_map_generator.h" +#include "../../interface/theme_icon.h" #include "../card_dimensions.h" #include "../z_values.h" #include "libcockatrice/utility/macros.h" @@ -93,8 +94,10 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player, searchEdit.setFocusPolicy(Qt::ClickFocus); searchEdit.setPlaceholderText(tr("Search by card name (or search expressions)")); searchEdit.setClearButtonEnabled(true); - searchEdit.addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition); - auto help = searchEdit.addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); + searchEdit.addAction(loadThemedIcon(QStringLiteral("icons/search"), IconTint::quiet()), + QLineEdit::LeadingPosition); + auto help = searchEdit.addAction(loadThemedIcon(QStringLiteral("icons/info"), IconTint::quiet()), + QLineEdit::TrailingPosition); connect(help, &QAction::triggered, this, [this] { createSearchSyntaxHelpWindow(&searchEdit); }); @@ -293,6 +296,7 @@ void ZoneViewWidget::retranslateUi() shuffleCheckBox.setText(tr("shuffle when closing")); pileViewCheckBox.setText(tr("pile view")); + zone->retranslateEdgeMarker(); } void ZoneViewWidget::stopWindowDrag() diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_image_formats.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader_image_formats.h new file mode 100644 index 000000000..8c91a9592 --- /dev/null +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_image_formats.h @@ -0,0 +1,69 @@ +#ifndef COCKATRICE_CARD_PICTURE_LOADER_IMAGE_FORMATS_H +#define COCKATRICE_CARD_PICTURE_LOADER_IMAGE_FORMATS_H + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace CardPictureLoaderImageFormats +{ +inline QByteArray acceptHeader() +{ + // Decoder plugins differ across distributions. Prefer only formats this + // runtime can read, then retain valid wildcards for other image endpoints. + static const QByteArray header = [] { + const auto supported = QImageReader::supportedMimeTypes(); + QByteArrayList types; + for (const QByteArray &preferred : + {QByteArray("image/avif"), QByteArray("image/webp"), QByteArray("image/apng"), QByteArray("image/png"), + QByteArray("image/jpeg")}) { + if (supported.contains(preferred)) { + types.append(preferred); + } + } + for (const QByteArray &type : supported) { + if (type.startsWith("image/") && !types.contains(type)) { + types.append(type); + } + } + types.append("image/*;q=0.5"); + types.append("*/*;q=0.1"); + return types.join(','); + }(); + return header; +} + +struct DecodedImage +{ + QImage image; + QString error; +}; + +inline DecodedImage readImage(QIODevice *device) +{ + static constexpr int riffHeaderSize = 12; // RIFF_HEADER_SIZE from webp/format_constants.h + const auto header = device->peek(riffHeaderSize); + if (header.startsWith("RIFF") && header.endsWith("WEBP")) { + // Preserve the existing first-frame behavior for animated WebP files. + QBuffer buffer; + buffer.setData(device->readAll()); + QMovie movie(&buffer); + movie.start(); + movie.stop(); + return DecodedImage{.image = movie.currentImage(), .error = movie.lastErrorString()}; + } + + QImageReader reader; + reader.setDecideFormatFromContent(true); + reader.setDevice(device); + QImage image = reader.read(); + return DecodedImage{.image = image, .error = image.isNull() ? reader.errorString() : QString()}; +} +} // namespace CardPictureLoaderImageFormats + +#endif // COCKATRICE_CARD_PICTURE_LOADER_IMAGE_FORMATS_H diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_request_queue.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader_request_queue.h new file mode 100644 index 000000000..e00f43002 --- /dev/null +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_request_queue.h @@ -0,0 +1,40 @@ +#ifndef COCKATRICE_CARD_PICTURE_LOADER_REQUEST_QUEUE_H +#define COCKATRICE_CARD_PICTURE_LOADER_REQUEST_QUEUE_H + +#include +#include +#include +#include +#include + +class CardPictureLoaderWorkerWork; + +/** + * @brief One queued picture request. + * + * The work pointer is null once the owning work item was destroyed; the URL still + * owes its queued status entry a completion, so it must be retired from the queue. + */ +using CardPictureLoaderRequest = QPair>; + +/** + * @brief Removes entry.first for every entry whose second (a QPointer) is null. + * + * The removed URLs are returned in queue order and the surviving entries keep + * theirs. The caller emits one completion per returned URL: every queued request + * retires exactly one status entry, including the ones whose work item vanished. + */ +template QList takeCancelledRequests(QQueue &queue) +{ + QList cancelled; + for (int i = 0; i < queue.size();) { + if (queue.at(i).second) { + ++i; + } else { + cancelled.append(queue.takeAt(i).first); + } + } + return cancelled; +} + +#endif // COCKATRICE_CARD_PICTURE_LOADER_REQUEST_QUEUE_H diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp index 3ab5eb684..d376d8204 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp @@ -2,11 +2,13 @@ #include "../../client/settings/cache_settings.h" #include "card_picture_loader_cache_method.h" +#include "card_picture_loader_image_formats.h" #include "card_picture_loader_local.h" #include "card_picture_loader_worker_work.h" #include "card_picture_to_load.h" #include "libcockatrice/card/printing/exact_card.h" #include "libcockatrice/utility/server_rate_limiter.h" +#include "redirect_chain.h" #include #include @@ -100,6 +102,22 @@ CardPictureLoaderWorker::CardPictureLoaderWorker() connect(&SettingsCache::instance().downloads(), &DownloadSettings::hostRequestLimitsChanged, this, [this] { hostRequestLimits = SettingsCache::instance().downloads().getHostRequestLimits(); }); + connect(&SettingsCache::instance().downloads(), &DownloadSettings::picDownloadChanged, this, [this] { + picDownload = SettingsCache::instance().downloads().getPicDownload(); + if (!picDownload) { + // Waiting for network quota or 429 recovery cannot help a cache-only lookup. + // Retire cancelled status entries just as the normal dispatcher does. + while (!requestLoadQueue.isEmpty()) { + const auto entry = requestLoadQueue.takeFirst(); + if (entry.second) { + makeRequest(entry.first, entry.second); + } else { + emit imageRequestSucceeded(entry.first); + } + } + updateTimerState(); + } + }); } CardPictureLoaderWorker::~CardPictureLoaderWorker() @@ -136,52 +154,49 @@ QThread *CardPictureLoaderWorker::workerThread() const void CardPictureLoaderWorker::queueRequest(const QUrl &url, CardPictureLoaderWorkerWork *worker) { - QUrl cachedRedirect = getCachedRedirect(url); - if (!cachedRedirect.isEmpty()) { - queueRequest(cachedRedirect, worker); - return; - } - if (static_cast( - SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) == - CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE && - cache->metaData(url).isValid()) { + const QUrl resolvedUrl = resolveCachedRedirect(url); + if (!picDownload || (static_cast( + SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) == + CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE && + cache->metaData(resolvedUrl).isValid() && !worker->shouldBypassCache(resolvedUrl))) { // A request that will be served from the disk cache never touches the network and therefore // doesn't use up any of the rate limit, so it gets to skip the queue. - makeRequest(url, worker); + // Every reply retires a status, including online cache hits and deferred + // 429 retries. Pair that completion with this request before dispatch. + emit imageRequestQueued(resolvedUrl, worker->cardToDownload.getCard(), worker->cardToDownload.getSetName()); + makeRequest(resolvedUrl, worker); return; } - requestLoadQueue.append(qMakePair(url, worker)); - emit imageRequestQueued(url, worker->cardToDownload.getCard(), worker->cardToDownload.getSetName()); + requestLoadQueue.append(qMakePair(resolvedUrl, QPointer(worker))); + emit imageRequestQueued(resolvedUrl, worker->cardToDownload.getCard(), worker->cardToDownload.getSetName()); processQueuedRequests(); } QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPictureLoaderWorkerWork *worker) { - // Check for cached redirects - QUrl cachedRedirect = getCachedRedirect(url); - if (!cachedRedirect.isEmpty()) { - // The status bar still needs to reclaim this URL's widget even when we hand the request back - // for a deferred retry instead of dispatching it onto the network. + // Redirect mappings can be learned while a request waits in the queue. + // Resolve again without recursion, and honor the target host's backoff. + const QUrl resolvedUrl = resolveCachedRedirect(url); + if (resolvedUrl != url) { emit imageRequestSucceeded(url); - // The redirect target is a different host, which may itself be in 429 backoff; hand the - // entry back to its worker so it waits the backoff out instead of dispatching straight - // onto the backed-off host. - if (CardPictureLoaderWorkerWork::rateLimiter().isRateLimited(cachedRedirect.host(), - QDateTime::currentDateTime())) { - worker->scheduleDeferredRetry(cachedRedirect.host()); + if (requestTouchesNetwork(resolvedUrl, worker) && CardPictureLoaderWorkerWork::rateLimiter().isRateLimited( + resolvedUrl.host(), QDateTime::currentDateTime())) { + worker->scheduleDeferredRetry(resolvedUrl.host()); return nullptr; } - return makeRequest(cachedRedirect, worker); + // The original status was retired above; track the resolved request + // separately so its eventual completion has a matching queued entry. + emit imageRequestQueued(resolvedUrl, worker->cardToDownload.getCard(), worker->cardToDownload.getSetName()); } - QNetworkRequest req(url); + QNetworkRequest req(resolvedUrl); req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); - req.setRawHeader("Accept", "image/avif,image/webp,image/apng,image/,/*;q=0.8"); + req.setRawHeader("Accept", CardPictureLoaderImageFormats::acceptHeader()); // Cached entries are served straight from the disk cache even when picture downloads are // enabled: re-fetching an already-cached image would burn the rate limit for nothing. Only a // genuine cache miss goes to the network, and only when downloads are enabled. - bool useNetworkCache = !requestTouchesNetwork(url); + bool useNetworkCache = !requestTouchesNetwork(resolvedUrl, worker); req.setAttribute(QNetworkRequest::CacheLoadControlAttribute, useNetworkCache ? QNetworkRequest::AlwaysCache : QNetworkRequest::AlwaysNetwork); @@ -190,7 +205,7 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture // Track in-flight replies per host so the unlocked fast path can bound how many requests it // issues at once, instead of creating replies that time out before Qt opens a connection. - const QString host = url.host(); + const QString host = resolvedUrl.host(); hostInFlight.insert(host, hostInFlight.value(host) + 1); // Release the in-flight slot when the reply is destroyed, not when it emits `finished`, and use @@ -201,8 +216,22 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture connect(reply, &QObject::destroyed, this, [this, host] { hostInFlight.insert(host, qMax(0, hostInFlight.value(host) - 1)); }); + // Retire the status even if the work object is destroyed before the reply + // finishes. Connect this before the work callback, which may enqueue a retry. + connect(reply, &QNetworkReply::finished, this, [this, reply] { emit imageRequestSucceeded(reply->url()); }); + // Connect reply handling; the work object is the context so its handler dies with it. - connect(reply, &QNetworkReply::finished, worker, [worker, reply] { worker->handleNetworkReply(reply); }); + const QPointer guardedWorker(worker); + connect(reply, &QNetworkReply::finished, worker, [guardedWorker, reply] { + if (guardedWorker) { + guardedWorker->handleNetworkReply(reply); + } + }); + // A work item may be cancelled before the reply finishes. In that case its + // context-bound handler is disconnected, but the reply must still release + // the host's in-flight slot and any network resources. + connect(worker, &QObject::destroyed, reply, [reply] { reply->abort(); }); + connect(reply, &QNetworkReply::finished, reply, &QObject::deleteLater); return reply; } @@ -267,6 +296,9 @@ void CardPictureLoaderWorker::dispatchQueuedRequest() // allowance: dispatch their queued requests back-to-back, bounded by their 429 backoff and the // per-host in-flight cap so a large burst can't queue replies that time out before Qt opens a // connection for them. + for (const QUrl &cancelled : takeCancelledRequests(requestLoadQueue)) { + emit imageRequestSucceeded(cancelled); + } for (int i = 0; i < requestLoadQueue.size();) { const auto &request = requestLoadQueue.at(i); // Dispatch decisions must key on the host the request will actually go to, not the URL that @@ -275,13 +307,14 @@ void CardPictureLoaderWorker::dispatchQueuedRequest() const QUrl resolvedUrl = resolveCachedRedirect(request.first); const QString host = resolvedUrl.host(); if (isUnlockedHost(host)) { - if (CardPictureLoaderWorkerWork::rateLimiter().isRateLimited(host, now)) { + if (requestTouchesNetwork(resolvedUrl, request.second) && + CardPictureLoaderWorkerWork::rateLimiter().isRateLimited(host, now)) { ++i; continue; } if (hostInFlight.value(host) < MAX_IN_FLIGHT_PER_HOST) { - makeRequest(resolvedUrl, request.second); - requestLoadQueue.removeAt(i); + const auto entry = requestLoadQueue.takeAt(i); + makeRequest(entry.first, entry.second); dispatched = true; continue; } @@ -344,6 +377,9 @@ void CardPictureLoaderWorker::updateTimerState() bool CardPictureLoaderWorker::processSingleRequest() { QDateTime now = QDateTime::currentDateTime(); + for (const QUrl &cancelled : takeCancelledRequests(requestLoadQueue)) { + emit imageRequestSucceeded(cancelled); + } for (int i = 0; i < requestLoadQueue.size(); ++i) { // Copy the entry: takeAt(i) below erases within the list this reference points into. const auto request = requestLoadQueue.at(i); @@ -356,13 +392,14 @@ bool CardPictureLoaderWorker::processSingleRequest() // instead of leaving it parked in the queue with no reply pending. Only applies to // requests that will actually touch the network: one that will be served from the disk // cache costs nothing and shouldn't wait out the 429. - if (requestTouchesNetwork(request.first) && + if (requestTouchesNetwork(resolvedUrl, request.second) && CardPictureLoaderWorkerWork::rateLimiter().isRateLimited(host, now)) { // The queued URL is usually a cached-redirect target whose host differs from // cardToDownload.getCurrentUrl(), so scheduleDeferredRetry() (which waits out the // blocked host's deadline) is used instead of startNextPicDownload() looping on the // original host. auto entry = requestLoadQueue.takeAt(i); + emit imageRequestSucceeded(entry.first); if (host != entry.first.host()) { // A cached redirect target is what is blocked, which the work object would not // discover from its own URL; wait out that specific host (with jitter) instead. @@ -393,15 +430,21 @@ bool CardPictureLoaderWorker::processSingleRequest() if (allowance > 0) { hostQuotaRemaining.insert(host, allowance - 1); auto entry = requestLoadQueue.takeAt(i); - makeRequest(resolvedUrl, entry.second); + makeRequest(entry.first, entry.second); return true; } } return false; } -bool CardPictureLoaderWorker::requestTouchesNetwork(const QUrl &url) const +bool CardPictureLoaderWorker::requestTouchesNetwork(const QUrl &url, const CardPictureLoaderWorkerWork *worker) const { + if (!picDownload) { + return false; + } + if (worker->shouldBypassCache(url)) { + return true; + } bool useNetworkCache = static_cast( SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) == CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE && @@ -477,33 +520,19 @@ void CardPictureLoaderWorker::cacheRedirect(const QUrl &originalUrl, const QUrl // saveRedirectCache(); } +void CardPictureLoaderWorker::invalidateRedirect(const QUrl &url) +{ + redirectCache.remove(url); +} + void CardPictureLoaderWorker::removedCachedUrl(const QUrl &url) { networkManager->cache()->remove(url); } -QUrl CardPictureLoaderWorker::getCachedRedirect(const QUrl &originalUrl) const +QUrl CardPictureLoaderWorker::resolveCachedRedirect(const QUrl &url) { - if (redirectCache.contains(originalUrl)) { - return redirectCache[originalUrl].first; - } - return {}; -} - -QUrl CardPictureLoaderWorker::resolveCachedRedirect(const QUrl &url) const -{ - // Follow the whole cached-redirect chain so dispatch keys on the host that is really hit. The - // depth bound keeps a corrupt or self-referencing cache entry from spinning us forever. - QUrl resolved = url; - int depth = 0; - while (depth++ < MAX_REDIRECT_CHAIN_DEPTH) { - QUrl target = getCachedRedirect(resolved); - if (target.isEmpty() || target == resolved) { - break; - } - resolved = target; - } - return resolved; + return resolveImageRedirects(url, redirectCache, MAX_REDIRECT_CHAIN_DEPTH); } void CardPictureLoaderWorker::loadRedirectCache() diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.h index 4b42c6e59..f7bbbff02 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.h @@ -1,10 +1,13 @@ #ifndef PICTURE_LOADER_WORKER_H #define PICTURE_LOADER_WORKER_H +#include "card_picture_loader_request_queue.h" + #include #include #include #include +#include #include #include #include @@ -139,6 +142,9 @@ public slots: /** @brief Caches a redirect mapping between original and redirected URL. */ void cacheRedirect(const QUrl &originalUrl, const QUrl &redirectUrl); + /** @brief Removes a stale redirect mapping. */ + void invalidateRedirect(const QUrl &url); + /** @brief Removes a URL from the network cache. */ void removedCachedUrl(const QUrl &url); @@ -150,7 +156,7 @@ private: QString cacheFilePath; ///< Path to persistent redirect cache file static constexpr int CacheTTLInDays = 30; ///< Time-to-live for redirect cache entries (days) bool picDownload; ///< Whether downloading images from network is enabled - QQueue> requestLoadQueue; ///< Queue of pending network requests + QQueue requestLoadQueue; ///< Queue of pending network requests QTimer requestTimer; ///< Timer to reset the request quota QTimer dispatchTimer; ///< Timer pacing individual network requests @@ -185,12 +191,9 @@ private: */ [[nodiscard]] bool isUnlockedHost(const QString &host) const; - /** @brief Returns cached redirect URL for the given original URL, if available. */ - [[nodiscard]] QUrl getCachedRedirect(const QUrl &originalUrl) const; - /** @brief Whether a request for this URL would actually touch the network, rather than being served from the disk * cache. */ - [[nodiscard]] bool requestTouchesNetwork(const QUrl &url) const; + [[nodiscard]] bool requestTouchesNetwork(const QUrl &url, const CardPictureLoaderWorkerWork *worker) const; /** * @brief Follows the cached-redirect chain to the URL that will actually be requested. @@ -200,7 +203,7 @@ private: * Dispatch decisions (unlocked-host fast path, 429 backoff, in-flight cap) must key on the host * a request really goes to, not the URL that merely redirects to it. */ - [[nodiscard]] QUrl resolveCachedRedirect(const QUrl &url) const; + [[nodiscard]] QUrl resolveCachedRedirect(const QUrl &url); /** @brief Loads redirect cache from disk. */ void loadRedirectCache(); diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp index 92524384a..f0f2dc86a 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp @@ -1,22 +1,20 @@ #include "card_picture_loader_worker_work.h" #include "../../client/settings/cache_settings.h" +#include "card_picture_loader_image_formats.h" #include "card_picture_loader_worker.h" #include "card_picture_to_load.h" #include "libcockatrice/card/card_info.h" #include "libcockatrice/card/printing/exact_card.h" #include "libcockatrice/utility/server_rate_limiter.h" -#include #include #include #include #include -#include #include #include #include -#include #include #include #include @@ -25,6 +23,8 @@ #include #include #include +#include +#include ServerRateLimiter CardPictureLoaderWorkerWork::s_rateLimiter; #include @@ -50,23 +50,32 @@ CardPictureLoaderWorkerWork::CardPictureLoaderWorkerWork(CardPictureLoaderWorker // Hook up signals to the orchestrator connect(this, &CardPictureLoaderWorkerWork::requestImageDownload, worker, &CardPictureLoaderWorker::queueRequest); connect(this, &CardPictureLoaderWorkerWork::urlRedirected, worker, &CardPictureLoaderWorker::cacheRedirect); + connect(this, &CardPictureLoaderWorkerWork::redirectInvalidated, worker, + &CardPictureLoaderWorker::invalidateRedirect); connect(this, &CardPictureLoaderWorkerWork::cachedUrlInvalidated, worker, &CardPictureLoaderWorker::removedCachedUrl); connect(this, &CardPictureLoaderWorkerWork::imageLoaded, worker, &CardPictureLoaderWorker::handleImageLoaded); - connect(this, &CardPictureLoaderWorkerWork::requestSucceeded, worker, - &CardPictureLoaderWorker::imageRequestSucceeded); connect(this, &CardPictureLoaderWorkerWork::rateLimited, worker, &CardPictureLoaderWorker::onHostRateLimited); // Hook up signals to settings connect(&SettingsCache::instance().downloads(), SIGNAL(picDownloadChanged()), this, SLOT(picDownloadChanged())); + deferredRetry = new QTimer(this); + deferredRetry->setSingleShot(true); + connect(deferredRetry, &QTimer::timeout, this, [this] { + s_rateLimiter.clearExpired(QDateTime::currentDateTime()); + cardToDownload.resetIndices(); + startNextPicDownload(); + }); + startNextPicDownload(); } void CardPictureLoaderWorkerWork::startNextPicDownload() { + redirectVisited.clear(); QDateTime now = QDateTime::currentDateTime(); - while (!cardToDownload.getCurrentUrl().isEmpty() && + while (picDownload && !cardToDownload.getCurrentUrl().isEmpty() && s_rateLimiter.isRateLimited(QUrl(cardToDownload.getCurrentUrl()).host(), now)) { QString host = QUrl(cardToDownload.getCurrentUrl()).host(); if (s_rateLimiter.rounds(host) == 1) { @@ -96,7 +105,11 @@ void CardPictureLoaderWorkerWork::startNextPicDownload() QString picUrl = cardToDownload.getCurrentUrl(); if (picUrl.isEmpty()) { - scheduleDeferredRetry(); + if (picDownload) { + scheduleDeferredRetry(); + } else { + concludeImageLoad(QImage()); + } } else { QUrl url(picUrl); qCDebug(CardPictureLoaderWorkerWorkLog).nospace() @@ -127,27 +140,38 @@ void CardPictureLoaderWorkerWork::picDownloadFailed() void CardPictureLoaderWorkerWork::handleNetworkReply(QNetworkReply *reply) { - QVariant redirectTarget = reply->attribute(QNetworkRequest::RedirectionTargetAttribute); - bool redirectFailure = false; + const QVariant redirectTarget = reply->attribute(QNetworkRequest::RedirectionTargetAttribute); + const int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + const bool isRedirect = + statusCode == 301 || statusCode == 302 || statusCode == 303 || statusCode == 307 || statusCode == 308; + bool redirectFailure = isRedirect && !redirectTarget.isValid(); if (redirectTarget.isValid()) { - QUrl url = reply->request().url(); - QUrl redirectUrl = redirectTarget.toUrl(); - if (redirectUrl.isRelative()) { - redirectUrl = url.resolved(redirectUrl); - } - if (url == redirectUrl) { - qCWarning(CardPictureLoaderWorkerWorkLog) << "recursive redirect detected!"; + const QUrl url = reply->url(); + const QUrl redirectUrl = url.resolved(redirectTarget.toUrl()); + redirectVisited.insert(url); + if (!redirectUrl.isValid() || redirectUrl.isEmpty() || redirectVisited.contains(redirectUrl) || + redirectVisited.size() > 10) { + qCWarning(CardPictureLoaderWorkerWorkLog) << "Invalid, cyclic or overlong redirect from" << url; redirectFailure = true; } else { emit urlRedirected(url, redirectUrl); } } - if (redirectFailure || reply->error()) { + if (redirectFailure) { + for (const QUrl &visited : std::as_const(redirectVisited)) { + emit redirectInvalidated(visited); + } + // Do not retry a cached cyclic redirect as a corrupt image: advance to + // the next configured source, just as for an ordinary failed response. + if (reply->attribute(QNetworkRequest::SourceIsFromCacheAttribute).toBool()) { + emit cachedUrlInvalidated(reply->url()); + } + picDownloadFailed(); + } else if (reply->error()) { handleFailedReply(reply); } else { handleSuccessfulReply(reply); - emit requestSucceeded(reply->url()); } reply->deleteLater(); @@ -184,6 +208,15 @@ void CardPictureLoaderWorkerWork::handleFailedReply(const QNetworkReply *reply) QDateTime backoffUntil = s_rateLimiter.on429(host, now, retryAfterMs); emit rateLimited(host); + // Downloads may have been disabled while this request was in flight. + // Record the server's limit for future online requests, but resume the + // cache-only search now instead of scheduling a new network backoff. + if (!picDownload) { + cardToDownload.resetIndices(); + startNextPicDownload(); + return; + } + if (s_rateLimiter.rounds(host) == 1) { qCWarning(CardPictureLoaderWorkerWorkLog).nospace() << "PictureLoader: [card: " << cardToDownload.getCard().getName() @@ -208,7 +241,9 @@ void CardPictureLoaderWorkerWork::handleFailedReply(const QNetworkReply *reply) emit cachedUrlInvalidated(reply->url()); - emit requestImageDownload(reply->url(), this); + if (!retryCachedUrl(reply->url())) { + picDownloadFailed(); + } } else { qCDebug(CardPictureLoaderWorkerWorkLog).nospace() << "PictureLoader: [card: " << cardToDownload.getCard().getName() @@ -226,9 +261,8 @@ void CardPictureLoaderWorkerWork::handleSuccessfulReply(QNetworkReply *reply) // List of status codes from https://doc.qt.io/qt-6/qnetworkreply.html#redirected int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - if (statusCode == 301 || statusCode == 302 || statusCode == 303 || statusCode == 305 || statusCode == 307 || - statusCode == 308) { - QUrl redirectUrl = reply->header(QNetworkRequest::LocationHeader).toUrl(); + if (statusCode == 301 || statusCode == 302 || statusCode == 303 || statusCode == 307 || statusCode == 308) { + QUrl redirectUrl = reply->url().resolved(reply->attribute(QNetworkRequest::RedirectionTargetAttribute).toUrl()); qCDebug(CardPictureLoaderWorkerWorkLog).nospace() << "PictureLoader: [card: " << cardToDownload.getCard().getName() << " set: " << cardToDownload.getSetName() << "]: following " << (isFromCache ? "cached redirect" : "redirect") << " to " @@ -252,15 +286,23 @@ void CardPictureLoaderWorkerWork::handleSuccessfulReply(QNetworkReply *reply) return; } - QImage image = tryLoadImageFromReply(reply); + const auto decoded = CardPictureLoaderImageFormats::readImage(reply); + const QImage &image = decoded.image; if (image.isNull()) { qCDebug(CardPictureLoaderWorkerWorkLog).nospace() << "PictureLoader: [card: " << cardToDownload.getCard().getName() << " set: " << cardToDownload.getSetName() << "]: Possible " << (isFromCache ? "cached" : "downloaded") << " picture at " - << reply->url().toDisplayString() << " could not be loaded: " << reply->errorString(); + << reply->url().toDisplayString() << " could not be loaded: " << decoded.error; - picDownloadFailed(); + // HTTP success can still contain an invalid or unsupported image. Remove + // that cache entry so future loads do not keep selecting the same bytes. + emit cachedUrlInvalidated(reply->url()); + if (!isFromCache || !retryCachedUrl(reply->url())) { + // Fresh bad responses advance to the next source; cached-only mode + // must never turn a decoder failure into a network download. + picDownloadFailed(); + } } else { qCDebug(CardPictureLoaderWorkerWorkLog).nospace() << "PictureLoader: [card: " << cardToDownload.getCard().getName() << " set: " << cardToDownload.getSetName() @@ -271,27 +313,21 @@ void CardPictureLoaderWorkerWork::handleSuccessfulReply(QNetworkReply *reply) } } -QImage CardPictureLoaderWorkerWork::tryLoadImageFromReply(QNetworkReply *reply) +bool CardPictureLoaderWorkerWork::shouldBypassCache(const QUrl &url) const { - static constexpr int riffHeaderSize = 12; // RIFF_HEADER_SIZE from webp/format_constants.h - auto replyHeader = reply->peek(riffHeaderSize); + return picDownload && retriedCacheUrls.contains(url); +} - if (replyHeader.startsWith("RIFF") && replyHeader.endsWith("WEBP")) { - auto imgBuf = QBuffer(this); - imgBuf.setData(reply->readAll()); - - auto movie = QMovie(&imgBuf); - movie.start(); - movie.stop(); - - return movie.currentImage(); +bool CardPictureLoaderWorkerWork::retryCachedUrl(const QUrl &url) +{ + if (!picDownload || retriedCacheUrls.contains(url)) { + return false; } - - QImageReader imgReader; - imgReader.setDecideFormatFromContent(true); - imgReader.setDevice(reply); - - return imgReader.read(); + // Record before emitting: the queue can dispatch synchronously, and removing + // an unreadable cache entry can fail. The retry must bypass that entry. + retriedCacheUrls.insert(url); + emit requestImageDownload(url, this); + return true; } void CardPictureLoaderWorkerWork::scheduleDeferredRetry(const QString &preferredHost) @@ -324,12 +360,7 @@ void CardPictureLoaderWorkerWork::scheduleDeferredRetry(const QString &preferred << " set: " << cardToDownload.getSetName() << "]: All URLs exhausted, scheduling deferred retry in " << waitMs << "ms"; - QTimer::singleShot(waitMs, this, [this] { - s_rateLimiter.clearExpired(QDateTime::currentDateTime()); - - cardToDownload.resetIndices(); - startNextPicDownload(); - }); + deferredRetry->start(static_cast(qMin(waitMs, std::numeric_limits::max()))); } void CardPictureLoaderWorkerWork::concludeImageLoad(const QImage &image) @@ -341,4 +372,9 @@ void CardPictureLoaderWorkerWork::concludeImageLoad(const QImage &image) void CardPictureLoaderWorkerWork::picDownloadChanged() { picDownload = SettingsCache::instance().downloads().getPicDownload(); + if (!picDownload && deferredRetry->isActive()) { + deferredRetry->stop(); + cardToDownload.resetIndices(); + startNextPicDownload(); + } } diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.h index c3dbcc6e8..da9420773 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.h @@ -6,7 +6,10 @@ #include #include #include +#include #include +#include +#include #include class ExactCard; @@ -47,6 +50,9 @@ public: /** @brief Shared per-server 429 backoff state. */ static const ServerRateLimiter &rateLimiter(); + /** @brief A corrupt cached response gets one network retry per URL and work item. */ + [[nodiscard]] bool shouldBypassCache(const QUrl &url) const; + /** * @brief Starts downloading the next URL for this card. * @@ -74,7 +80,12 @@ public slots: void handleNetworkReply(QNetworkReply *reply); private: - bool picDownload; ///< Whether network downloading is enabled + bool picDownload; ///< Whether network downloading is enabled + QTimer *deferredRetry = nullptr; ///< Owned retry, cancellable when downloads are disabled + QSet retriedCacheUrls; + + bool retryCachedUrl(const QUrl &url); + QSet redirectVisited; ///< Redirect chain for the current source URL static ServerRateLimiter s_rateLimiter; ///< Shared per-server 429 backoff state @@ -87,13 +98,6 @@ private: /** @brief Processes a successful network reply. */ void handleSuccessfulReply(QNetworkReply *reply); - /** - * @brief Attempts to read an image from a network reply. - * @param reply The network reply - * @return The loaded QImage or an empty image if reading failed - */ - QImage tryLoadImageFromReply(QNetworkReply *reply); - /** * @brief Finalizes the image loading process. * @param image The loaded image (empty if failed) @@ -116,15 +120,15 @@ signals: */ void imageLoaded(const ExactCard &card, const QImage &image); - /** @brief Emitted when a network request completes successfully. */ - void requestSucceeded(const QUrl &url); - /** @brief Request that a URL be downloaded. */ void requestImageDownload(const QUrl &url, CardPictureLoaderWorkerWork *instance); /** @brief Emitted when a URL has been redirected. */ void urlRedirected(const QUrl &originalUrl, const QUrl &redirectUrl); + /** @brief A redirect mapping is unsafe and should be removed. */ + void redirectInvalidated(const QUrl &url); + /** @brief Emitted when a server returned HTTP 429. */ void rateLimited(const QString &host); diff --git a/cockatrice/src/interface/card_picture_loader/redirect_chain.h b/cockatrice/src/interface/card_picture_loader/redirect_chain.h new file mode 100644 index 000000000..ffcfe6b10 --- /dev/null +++ b/cockatrice/src/interface/card_picture_loader/redirect_chain.h @@ -0,0 +1,37 @@ +#ifndef COCKATRICE_REDIRECT_CHAIN_H +#define COCKATRICE_REDIRECT_CHAIN_H + +#include +#include +#include +#include +#include +#include + +// Resolve previously accepted redirects without recursion. Invalid chains are +// removed so subsequent requests can rediscover them from the source server. +inline QUrl resolveImageRedirects(const QUrl &url, QHash> &cache, int maxHops) +{ + QUrl resolved = url; + QSet visited; + for (int depth = 0; depth <= maxHops; ++depth) { + if (visited.contains(resolved)) { + break; + } + const auto it = cache.constFind(resolved); + if (it == cache.cend() || it.value().first.isEmpty()) { + return resolved; + } + if (depth == maxHops) { + break; + } + visited.insert(resolved); + resolved = it.value().first; + } + for (const QUrl &entry : std::as_const(visited)) { + cache.remove(entry); + } + return url; +} + +#endif diff --git a/cockatrice/src/interface/deck_loader/deck_loader.cpp b/cockatrice/src/interface/deck_loader/deck_loader.cpp index d413a6c04..c8fa2a6f8 100644 --- a/cockatrice/src/interface/deck_loader/deck_loader.cpp +++ b/cockatrice/src/interface/deck_loader/deck_loader.cpp @@ -1,5 +1,6 @@ #include "deck_loader.h" +#include "../theme_manager.h" #include "libcockatrice/card/card_info.h" #include "libcockatrice/card/database/card_database_querier.h" #include "libcockatrice/deck_list/tree/abstract_deck_list_card_node.h" @@ -38,6 +39,7 @@ #include #include #include +#include #include #include #include @@ -371,8 +373,12 @@ bool DeckLoader::saveToStream_Plain(QTextStream &out, saveToStream_DeckHeader(out, deckList); } - // loop zones - for (auto zoneNode : deckList.getZoneNodes()) { + // Plain deck readers expect the main deck before the sideboard. Reorder a + // copy so native saves and the relative order of other zones stay intact. + auto zones = deckList.getZoneNodes(); + std::stable_partition(zones.begin(), zones.end(), + [](const auto *zone) { return zone->getName() == DECK_ZONE_MAIN; }); + for (auto zoneNode : zones) { saveToStream_DeckZone(out, zoneNode, addComments, addSetNameAndNumber); // end of zone @@ -579,6 +585,8 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode if (!cards.isEmpty()) { QTextBlockFormat blockFormat; QTextCharFormat charFormat; + // Family comes from the DeckList theme role; sizes/weights unchanged. + charFormat.setFontFamilies({themeManager->deckListFont().family()}); charFormat.setFontPointSize(11); charFormat.setFontWeight(QFont::Bold); cursor->insertBlock(blockFormat, charFormat); @@ -592,6 +600,7 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode const AbstractDecklistCardNode *card = cards[i]; QTextCharFormat cellCharFormat; + cellCharFormat.setFontFamilies({themeManager->deckListFont().family()}); cellCharFormat.setFontPointSize(9); QTextTableCell cell = table->cellAt(i, 0); @@ -613,6 +622,7 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode QTextBlockFormat blockFormat; QTextCharFormat charFormat; + charFormat.setFontFamilies({themeManager->deckListFont().family()}); charFormat.setFontPointSize(14); charFormat.setFontWeight(QFont::Bold); @@ -640,14 +650,18 @@ void DeckLoader::printDeckList(QPrinter *printer, const DeckList &deckList) { QTextDocument doc; - QFont font("Serif"); - font.setStyleHint(QFont::Serif); + // Family comes from the DeckList theme role; the print sizes/weights below stay unchanged. + // Print output is a one-shot snapshot and intentionally does not subscribe + // to fontsChanged (there is no live view to repaint); reprinting picks up + // the current role font. + QFont font = themeManager->deckListFont(); doc.setDefaultFont(font); QTextCursor cursor(&doc); QTextBlockFormat headerBlockFormat; QTextCharFormat headerCharFormat; + headerCharFormat.setFontFamilies({themeManager->deckListFont().family()}); headerCharFormat.setFontPointSize(16); headerCharFormat.setFontWeight(QFont::Bold); diff --git a/cockatrice/src/interface/palette_editor/color_button.cpp b/cockatrice/src/interface/palette_editor/color_button.cpp index a7db23d31..ae9bd6297 100644 --- a/cockatrice/src/interface/palette_editor/color_button.cpp +++ b/cockatrice/src/interface/palette_editor/color_button.cpp @@ -1,5 +1,7 @@ #include "color_button.h" +#include "../theme_manager.h" + #include #include #include @@ -21,6 +23,7 @@ ColorButton::ColorButton(QWidget *parent) : QToolButton(parent) setCursor(Qt::PointingHandCursor); setToolTip(tr("Click to pick a color")); connect(this, &QToolButton::clicked, this, &ColorButton::pickColor); + connect(themeManager, &ThemeManager::fontsChanged, this, &ColorButton::updateSwatch); } void ColorButton::setColor(const QColor &c) @@ -73,7 +76,8 @@ void ColorButton::updateSwatch() // Hex label — black or white for contrast int luma = 0.299 * color.red() + 0.587 * color.green() + 0.114 * color.blue(); painter.setPen((luma > 128 && color.alpha() > 80) ? QColor(0, 0, 0, 180) : QColor(255, 255, 255, 200)); - QFont f = font(); + // Family comes from the InterfaceDefault theme role; the 8px size stays caller-side. + QFont f = themeManager->appFont(AppFont::InterfaceDefault); f.setPixelSize(8); painter.setFont(f); painter.drawText(rect(), Qt::AlignCenter, color.name().toUpper()); diff --git a/cockatrice/src/interface/palette_editor/font_grid_widget.cpp b/cockatrice/src/interface/palette_editor/font_grid_widget.cpp new file mode 100644 index 000000000..44ad59551 --- /dev/null +++ b/cockatrice/src/interface/palette_editor/font_grid_widget.cpp @@ -0,0 +1,136 @@ +#include "font_grid_widget.h" + +#include "../theme_manager.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +static constexpr int MIN_FONT_SIZE = 6; +static constexpr int MAX_FONT_SIZE = 72; +static constexpr int FALLBACK_FONT_SIZE = 10; + +static const QMap APP_FONT_DESCRIPTIONS = { + {AppFont::InterfaceDefault, QT_TR_NOOP("Default font for menus, dialogs and widgets")}, + {AppFont::ChatLog, QT_TR_NOOP("Font for chat messages and logs")}, + {AppFont::CardText, QT_TR_NOOP("Font for text rendered on cards")}, + {AppFont::DeckList, QT_TR_NOOP("Font for deck lists and the deck editor")}, +}; + +static QString roleDisplayName(AppFont::Role role) +{ + switch (role) { + case AppFont::InterfaceDefault: + return FontGridWidget::tr("Interface default"); + case AppFont::ChatLog: + return FontGridWidget::tr("Chat log"); + case AppFont::CardText: + return FontGridWidget::tr("Card text"); + case AppFont::DeckList: + return FontGridWidget::tr("Deck list"); + default: + return QString(); + } +} + +FontGridWidget::FontGridWidget(QWidget *parent) : QWidget(parent) +{ + auto *grid = new QGridLayout(this); + grid->setSpacing(3); + grid->setContentsMargins(12, 8, 12, 8); + grid->setColumnStretch(0, 0); + grid->setColumnStretch(1, 1); + grid->setColumnStretch(2, 0); + + QMetaEnum fontEnum = QMetaEnum::fromType(); + for (int i = 0; i < fontEnum.keyCount(); ++i) { + auto role = static_cast(fontEnum.value(i)); + + auto *label = new QLabel(this); + grid->addWidget(label, i, 0); + + auto *familyCombo = new QFontComboBox(this); + grid->addWidget(familyCombo, i, 1); + connect(familyCombo, &QFontComboBox::currentFontChanged, this, [this] { emit fontsChanged(); }); + + auto *sizeSpin = new QSpinBox(this); + sizeSpin->setRange(MIN_FONT_SIZE, MAX_FONT_SIZE); + grid->addWidget(sizeSpin, i, 2); + connect(sizeSpin, &QSpinBox::valueChanged, this, [this] { emit fontsChanged(); }); + + label->setBuddy(familyCombo); + + fontRows[role] = {label, familyCombo, sizeSpin}; + } + + retranslateUi(); +} + +void FontGridWidget::loadFonts(const FontConfig &cfg) +{ + // Suppress fontsChanged while materializing fallback rows so the initial + // load never reads as a user edit (mirrors paletteGrid blocking). + blockSignals(true); + QMetaEnum fontEnum = QMetaEnum::fromType(); + for (int i = 0; i < fontEnum.keyCount(); ++i) { + auto role = static_cast(fontEnum.value(i)); + const FontRow &row = fontRows[role]; + + // Roles absent from the file resolve the same way the runtime does: an unset + // role falls back to the config's InterfaceDefault, and only then to the + // startup font. appFont() would return the live preview instead, so Reset and + // a first load must not measure against it. + const QFont fallback = cfg.fonts.value(AppFont::InterfaceDefault, themeManager->defaultAppFont(role)); + const QFont font = cfg.fonts.value(role, fallback); + row.familyCombo->setCurrentFont(font); + + int size = font.pointSize(); + if (size <= 0) { + size = qApp->font().pointSize(); + } + if (size <= 0) { + size = FALLBACK_FONT_SIZE; + } + row.sizeSpin->setValue(qBound(MIN_FONT_SIZE, size, MAX_FONT_SIZE)); + } + blockSignals(false); +} + +FontConfig FontGridWidget::currentFontConfig() const +{ + FontConfig cfg; + QMetaEnum fontEnum = QMetaEnum::fromType(); + for (int i = 0; i < fontEnum.keyCount(); ++i) { + auto role = static_cast(fontEnum.value(i)); + const FontRow &row = fontRows.value(role); + + // Canonical family + point size only: a fresh QFont carries default + // (non-bold, non-italic) weight/style, so nothing combo-side leaks in. + QFont font(row.familyCombo->currentFont().family()); + font.setPointSize(row.sizeSpin->value()); + cfg.fonts[role] = font; + } + return cfg; +} + +void FontGridWidget::retranslateUi() +{ + QMetaEnum fontEnum = QMetaEnum::fromType(); + for (int i = 0; i < fontEnum.keyCount(); ++i) { + auto role = static_cast(fontEnum.value(i)); + const FontRow &row = fontRows.value(role); + + row.label->setText(roleDisplayName(role)); + row.label->setToolTip(tr(APP_FONT_DESCRIPTIONS.value(role, ""))); + row.familyCombo->setToolTip(tr(APP_FONT_DESCRIPTIONS.value(role, ""))); + row.familyCombo->setAccessibleName(roleDisplayName(role)); + row.sizeSpin->setToolTip(tr("Point size for %1").arg(roleDisplayName(role))); + row.sizeSpin->setAccessibleName(tr("Point size for %1").arg(roleDisplayName(role))); + row.sizeSpin->setSuffix(tr(" pt")); + } +} diff --git a/cockatrice/src/interface/palette_editor/font_grid_widget.h b/cockatrice/src/interface/palette_editor/font_grid_widget.h new file mode 100644 index 000000000..8c80306d3 --- /dev/null +++ b/cockatrice/src/interface/palette_editor/font_grid_widget.h @@ -0,0 +1,43 @@ +#ifndef COCKATRICE_FONT_GRID_WIDGET_H +#define COCKATRICE_FONT_GRID_WIDGET_H + +#include "../theme_config.h" + +#include +#include +#include + +class QFontComboBox; +class QGridLayout; +class QLabel; +class QSpinBox; + +// Per-role font editors — one (family, point size) row per AppFont::Role. +// Owns the load/read round-trip for FontConfig but has no file I/O, mirroring +// PaletteGridWidget. Only family and point size are edited; weight and italic +// stay caller-side concerns and are stripped when reading back. +class FontGridWidget : public QWidget +{ + Q_OBJECT +public: + explicit FontGridWidget(QWidget *parent = nullptr); + + void loadFonts(const FontConfig &cfg); + FontConfig currentFontConfig() const; + void retranslateUi(); + +signals: + void fontsChanged(); + +private: + struct FontRow + { + QLabel *label = nullptr; + QFontComboBox *familyCombo = nullptr; + QSpinBox *sizeSpin = nullptr; + }; + + QMap fontRows; +}; + +#endif // COCKATRICE_FONT_GRID_WIDGET_H diff --git a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp index b6271291d..5bcfbb251 100644 --- a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp +++ b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp @@ -2,6 +2,7 @@ #include "../theme_config.h" #include "../theme_manager.h" +#include "font_grid_widget.h" #include "palette_generator.h" #include "palette_grid_widget.h" #include "quick_setup_panel.h" @@ -41,6 +42,7 @@ PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QSt // Load both scheme configs upfront so switching is instant loadSchemes(); + loadFonts(); loadedScheme = themeManager->isDarkMode(themeDirPath) ? "Dark" : "Light"; @@ -125,6 +127,33 @@ void PaletteEditorDialog::setupUi() paletteGrid->setVisible(false); root->addWidget(paletteGrid, 1); + // Font section — sibling to the palette grid, sharing its collapsible + // advanced area pattern. Fonts are shared per theme, not split by scheme. + fontToggleButton = new QPushButton(this); + fontToggleButton->setCheckable(true); + fontToggleButton->setChecked(false); + fontToggleButton->setFlat(true); + fontToggleButton->setStyleSheet("QPushButton { text-align: left; padding: 5px 12px; font-weight: bold; }" + "QPushButton:checked { }"); + root->addWidget(fontToggleButton); + + fontSeparator = makeSeparator(); + fontSeparator->setVisible(false); + root->addWidget(fontSeparator); + + fontSection = new QWidget; + auto *fontSectionLayout = new QVBoxLayout(fontSection); + fontSectionLayout->setContentsMargins(0, 0, 0, 0); + fontSectionLayout->setSpacing(0); + fontNoteLabel = new QLabel(fontSection); + fontNoteLabel->setContentsMargins(12, 4, 12, 0); + fontNoteLabel->setWordWrap(true); + fontSectionLayout->addWidget(fontNoteLabel); + fontGrid = new FontGridWidget(fontSection); + fontSectionLayout->addWidget(fontGrid); + fontSection->setVisible(false); + root->addWidget(fontSection); + // Footer root->addWidget(makeSeparator()); footer = new QWidget; @@ -157,6 +186,10 @@ void PaletteEditorDialog::setupUi() connect(autoApplyTimer, &QTimer::timeout, this, &PaletteEditorDialog::onApply); connect(quickSetupPanel, &QuickSetupPanel::valueChanged, this, &PaletteEditorDialog::onGenerateFromAccent); connect(paletteGrid, &PaletteGridWidget::paletteChanged, this, [this] { autoApplyTimer->start(); }); + connect(fontGrid, &FontGridWidget::fontsChanged, this, [this] { + fontsDirty = true; + autoApplyTimer->start(); + }); connect(revertButton, &QPushButton::clicked, this, &PaletteEditorDialog::onRevertToDefault); connect(resetBtn, &QPushButton::clicked, this, &PaletteEditorDialog::onReset); connect(saveBtn, &QPushButton::clicked, this, &PaletteEditorDialog::onSave); @@ -166,15 +199,32 @@ void PaletteEditorDialog::setupUi() paletteGridToggleButton->setText(open ? tr("▼ Edit Palette") : tr("▶ Edit Palette")); paletteGridSeparator->setVisible(open); paletteGrid->setVisible(open); - - if (open) { - setMinimumHeight(680); - resize(width(), 680); - } else { - setMinimumHeight(220); - adjustSize(); // shrinks to fit just the visible content - } + updateAdvancedHeight(); }); + connect(fontToggleButton, &QPushButton::toggled, this, [this](bool open) { + fontToggleButton->setText(open ? tr("▼ Edit Fonts") : tr("▶ Edit Fonts")); + fontSeparator->setVisible(open); + fontSection->setVisible(open); + updateAdvancedHeight(); + }); +} + +void PaletteEditorDialog::updateAdvancedHeight() +{ + int height = 220; + if (paletteGridToggleButton->isChecked()) { + height += 460; + } + if (fontToggleButton->isChecked()) { + height += 200; + } + setMinimumHeight(height); + + if (paletteGrid->isVisible() || fontSection->isVisible()) { + resize(width(), height); + } else { + adjustSize(); // shrinks to fit just the visible content + } } void PaletteEditorDialog::retranslateUi() @@ -189,12 +239,16 @@ void PaletteEditorDialog::retranslateUi() editingLabel->setText(tr("Editing:")); paletteGridToggleButton->setText(tr("▶ Edit Palette")); paletteGridToggleButton->setToolTip(tr("Show or hide the per-role colour grid for manual tweaks")); + fontToggleButton->setText(fontSection->isVisible() ? tr("▼ Edit Fonts") : tr("▶ Edit Fonts")); + fontToggleButton->setToolTip(tr("Show or hide the per-purpose font settings for this theme")); + fontNoteLabel->setText(tr("Fonts belong to theme %1 (shared by Light and Dark schemes)").arg(themeName)); + fontGrid->retranslateUi(); revertButton->setText(tr("↺ Revert to theme default")); resetBtn->setText(tr("Reset")); saveBtn->setText(tr("Save")); - resetBtn->setToolTip(tr("Discard unsaved edits and restore the last saved palette")); - saveBtn->setToolTip(tr("Write palette-%1.toml and reload the theme").arg(loadedScheme.toLower())); + resetBtn->setToolTip(tr("Discard unsaved edits and restore the last saved palette and fonts")); + saveBtn->setToolTip(tr("Write palette-%1.toml and fonts.toml, then reload the theme").arg(loadedScheme.toLower())); if (saveDir.isEmpty() || !ThemeManager::isDirReallyWritable(saveDir)) { saveBtn->setEnabled(false); @@ -228,6 +282,13 @@ void PaletteEditorDialog::loadSchemes() } } +void PaletteEditorDialog::loadFonts() +{ + savedFontConfig = ThemeManager::loadDefaultFontConfig(themeDirPath, themeName); + fontGrid->loadFonts(savedFontConfig); + fontsDirty = false; +} + void PaletteEditorDialog::seedAccentFromScheme(const QString &scheme) { QColor seed = workingConfig.value(scheme).colors.value(QPalette::Active).value(QPalette::Highlight); @@ -258,6 +319,9 @@ void PaletteEditorDialog::onGenerateFromAccent(const QColor &accent, int intensi void PaletteEditorDialog::onApply() { themeManager->previewPalette(paletteGrid->currentPaletteConfig(), loadedScheme); + if (fontsDirty) { + themeManager->previewFonts(fontGrid->currentFontConfig()); + } } void PaletteEditorDialog::onSave() @@ -308,6 +372,20 @@ void PaletteEditorDialog::onSave() savedConfig[it.key()] = it.value(); } + // Persist the shared per-theme fonts when they changed. + if (fontsDirty) { + const FontConfig fontCfg = fontGrid->currentFontConfig(); + if (fontCfg != savedFontConfig) { + if (!ThemeManager::saveFontConfig(saveDir, fontCfg)) { + QMessageBox::warning(this, tr("Save failed"), + tr("Could not write %1 to:\n%2").arg(FontConfig::fileName(), saveDir)); + return; + } + savedFontConfig = fontCfg; + } + fontsDirty = false; + } + themeManager->reloadCurrentTheme(); accept(); } @@ -316,6 +394,12 @@ void PaletteEditorDialog::onReset() { workingConfig[loadedScheme] = savedConfig[loadedScheme]; paletteGrid->loadPalette(savedConfig[loadedScheme]); + // Restore the live theme before re-reading the grid: loadFonts resolves + // unset roles, and the preview has currentFonts pointing at the edited + // config until the theme is reloaded. + themeManager->reloadCurrentTheme(); + fontGrid->loadFonts(savedFontConfig); + fontsDirty = false; seedAccentFromScheme(loadedScheme); } @@ -350,6 +434,27 @@ void PaletteEditorDialog::onRevertToDefault() workingConfig[loadedScheme] = def; paletteGrid->loadPalette(def); seedAccentFromScheme(loadedScheme); + + // Revert the shared per-theme fonts the same way: delete the user-written + // fonts.toml so the theme falls back to its shipped default, then reflect + // the resolved fonts in the editor. + QFile::remove(QDir(saveDir).absoluteFilePath(FontConfig::fileName())); + + // Reload the live theme so the font revert takes effect immediately. + themeManager->reloadCurrentTheme(); + + const FontConfig fontDef = ThemeManager::loadDefaultFontConfig(themeDirPath, themeName); + savedFontConfig = fontDef; + fontGrid->loadFonts(fontDef); + fontsDirty = false; +} + +void PaletteEditorDialog::reject() +{ + // Discard any unsaved live preview (palette and fonts) so closing without + // Save restores the active theme instead of leaking the preview globally. + themeManager->reloadCurrentTheme(); + QDialog::reject(); } void PaletteEditorDialog::changeEvent(QEvent *e) diff --git a/cockatrice/src/interface/palette_editor/palette_editor_dialog.h b/cockatrice/src/interface/palette_editor/palette_editor_dialog.h index e29a70cb0..5e47536b5 100644 --- a/cockatrice/src/interface/palette_editor/palette_editor_dialog.h +++ b/cockatrice/src/interface/palette_editor/palette_editor_dialog.h @@ -15,6 +15,7 @@ class QComboBox; class QDialogButtonBox; class QPushButton; class PaletteGridWidget; +class FontGridWidget; class QuickSetupPanel; class QFrame; class QWidget; @@ -25,6 +26,7 @@ class PaletteEditorDialog : public QDialog public: explicit PaletteEditorDialog(const QString &themeDirPath, const QString &themeName, QWidget *parent = nullptr); void loadSchemes(); + void reject() override; private slots: void onSave(); @@ -38,8 +40,10 @@ private: void setupUi(); void retranslateUi(); void refreshChromePalettes(); + void updateAdvancedHeight(); void loadScheme(const QString &scheme); // snapshot current, switch, load void seedAccentFromScheme(const QString &scheme); + void loadFonts(); // shared per theme, not split by light/dark // Sub-widgets QWidget *header; @@ -49,6 +53,11 @@ private: PaletteGridWidget *paletteGrid = nullptr; QPushButton *paletteGridToggleButton = nullptr; QFrame *paletteGridSeparator = nullptr; + FontGridWidget *fontGrid = nullptr; + QPushButton *fontToggleButton = nullptr; + QFrame *fontSeparator = nullptr; + QWidget *fontSection = nullptr; + QLabel *fontNoteLabel = nullptr; QWidget *footer; QComboBox *schemeComboBox = nullptr; QDialogButtonBox *buttonBox = nullptr; @@ -68,10 +77,18 @@ private: QMap workingConfig; QMap savedConfig; + // Fonts are shared per theme (fonts.toml, not split by light/dark), so a + // single saved config suffices; the live edits are read from fontGrid. + FontConfig savedFontConfig; + // Set only by user-driven fontsChanged after the initial load completes. + // Gates both previewFonts and the fonts.toml write so a palette-only Save + // never materializes fallback fonts to disk. + bool fontsDirty = false; + QTimer *autoApplyTimer = nullptr; protected: void changeEvent(QEvent *e) override; }; -#endif // COCKATRICE_PALETTE_EDITOR_DIALOG_H \ No newline at end of file +#endif // COCKATRICE_PALETTE_EDITOR_DIALOG_H diff --git a/cockatrice/src/interface/palette_editor/palette_generator.cpp b/cockatrice/src/interface/palette_editor/palette_generator.cpp index c98dcbe03..94ad5492c 100644 --- a/cockatrice/src/interface/palette_editor/palette_generator.cpp +++ b/cockatrice/src/interface/palette_editor/palette_generator.cpp @@ -1,6 +1,7 @@ #include "palette_generator.h" #include "../theme_config.h" +#include "../theme_icon_ink.h" #include #include @@ -167,6 +168,26 @@ PaletteConfig fromAccent(const QColor &accent, int intensity, const QString &sch // the lightened, desaturated companion used for button-gradient highlights. cfg.appColors[AppColor::AccentStrong] = hl; cfg.appColors[AppColor::AccentSoft] = hsl(accent.lightness() + 60, qRound(accent.hslSaturation() * 70 / 100.0)); + // Neutral icon ink follows the generated accent at the same three emphasis + // levels a hand-authored theme stores, so the preview matches what the icon + // engine renders before the palette is saved. The harder of the two surfaces + // is the one the floor has to be solved against. + const QColor iconSurface = ThemeIconInk::harderSurface(bg_win, bg_btn, winText); + const QColor iconInk = ThemeIconInk::glyphInk(winText, iconSurface, hl); + cfg.appColors[AppColor::Icon] = iconInk; + cfg.appColors[AppColor::IconStrong] = ThemeIconInk::forEmphasis(iconInk, iconSurface, IconEmphasis::Primary); + cfg.appColors[AppColor::IconSoft] = ThemeIconInk::forEmphasis(iconInk, iconSurface, IconEmphasis::Quiet); + // Semantic roles stay independent of the accent: a generated palette must + // still paint add/ready green, remove/not-ready red, information blue, + // hidden information violet, marks amber and movement teal. + for (const AppColor::Role role : + {AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft, AppColor::Negative, + AppColor::NegativeStrong, AppColor::NegativeSoft, AppColor::Informational, AppColor::InformationalStrong, + AppColor::InformationalSoft, AppColor::Hidden, AppColor::HiddenStrong, AppColor::HiddenSoft, AppColor::Marked, + AppColor::MarkedStrong, AppColor::MarkedSoft, AppColor::Movement, AppColor::MovementStrong, + AppColor::MovementSoft}) { + cfg.appColors[role] = defaultAppColor(role, dark); + } // BrightText QColor bright; diff --git a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp index 463c4038d..e7d91d330 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -60,8 +61,39 @@ static const QMap ROLE_DESCRIPTIONS = { }; static const QMap APP_ROLE_DESCRIPTIONS = { - {AppColor::AccentStrong, QT_TR_NOOP("Vivid primary accent (e.g. home-tab button gradient start)")}, - {AppColor::AccentSoft, QT_TR_NOOP("Lightened, desaturated accent (e.g. home-tab button gradient end)")}, + {AppColor::AccentStrong, + QT_TRANSLATE_NOOP("PaletteGridWidget", "Vivid primary accent (e.g. home-tab button gradient start)")}, + {AppColor::AccentSoft, + QT_TRANSLATE_NOOP("PaletteGridWidget", "Lightened, desaturated accent (e.g. home-tab button gradient end)")}, + {AppColor::Icon, QT_TRANSLATE_NOOP("PaletteGridWidget", "Neutral icon ink at the default action weight")}, + {AppColor::IconStrong, + QT_TRANSLATE_NOOP("PaletteGridWidget", "Neutral icon ink for the one action a surface exists for")}, + {AppColor::IconSoft, + QT_TRANSLATE_NOOP("PaletteGridWidget", "Neutral icon ink for a glyph that labels rather than acts")}, + {AppColor::Positive, + QT_TRANSLATE_NOOP("PaletteGridWidget", "Positive state and additive actions (ready, add, increment)")}, + {AppColor::PositiveStrong, QT_TRANSLATE_NOOP("PaletteGridWidget", "Positive state at primary emphasis")}, + {AppColor::PositiveSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Positive state at quiet emphasis")}, + {AppColor::Negative, + QT_TRANSLATE_NOOP("PaletteGridWidget", "Negative state and removal actions (not ready, remove, decrement)")}, + {AppColor::NegativeStrong, QT_TRANSLATE_NOOP("PaletteGridWidget", "Negative state at primary emphasis")}, + {AppColor::NegativeSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Negative state at quiet emphasis")}, + {AppColor::Informational, + QT_TRANSLATE_NOOP("PaletteGridWidget", + "Informational state that is neither a gain nor a loss (face-down, exile)")}, + {AppColor::InformationalStrong, QT_TRANSLATE_NOOP("PaletteGridWidget", "Informational state at primary emphasis")}, + {AppColor::InformationalSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Informational state at quiet emphasis")}, + {AppColor::Hidden, + QT_TRANSLATE_NOOP("PaletteGridWidget", "Hidden information (face-down play, peek at the card face)")}, + {AppColor::HiddenStrong, QT_TRANSLATE_NOOP("PaletteGridWidget", "Hidden information at primary emphasis")}, + {AppColor::HiddenSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Hidden information at quiet emphasis")}, + {AppColor::Marked, QT_TRANSLATE_NOOP("PaletteGridWidget", "Marks left on a card (annotation, draw arrow)")}, + {AppColor::MarkedStrong, QT_TRANSLATE_NOOP("PaletteGridWidget", "Marks on a card at primary emphasis")}, + {AppColor::MarkedSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Marks on a card at quiet emphasis")}, + {AppColor::Movement, + QT_TRANSLATE_NOOP("PaletteGridWidget", "Cards moving without a gain or a loss (library placements)")}, + {AppColor::MovementStrong, QT_TRANSLATE_NOOP("PaletteGridWidget", "Movement at primary emphasis")}, + {AppColor::MovementSoft, QT_TRANSLATE_NOOP("PaletteGridWidget", "Movement at quiet emphasis")}, }; PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent) @@ -76,6 +108,18 @@ PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent) layout = QtUtils::makeLayout(this); layout->addWidget(scroll); + + connect(themeManager, &ThemeManager::fontsChanged, this, &PaletteGridWidget::applyInterfaceDefaultFont); +} + +void PaletteGridWidget::applyInterfaceDefaultFont() +{ + // The editor's own chrome follows the InterfaceDefault theme role; bold stays caller-side. + for (auto *label : headerLabels) { + QFont headerFont = themeManager->appFont(AppFont::InterfaceDefault); + headerFont.setBold(true); + label->setFont(headerFont); + } } void PaletteGridWidget::buildGrid(QWidget *host) @@ -101,7 +145,8 @@ void PaletteGridWidget::buildGrid(QWidget *host) auto *label = new QLabel(groupHeaders[col], host); label->setAlignment(Qt::AlignCenter); label->setToolTip(groupTips[col]); - label->setFont(QtUtils::boldFont(label->font())); + // Family comes from the InterfaceDefault theme role; bold stays caller-side. + label->setFont(QtUtils::boldFont(themeManager->appFont(AppFont::InterfaceDefault))); label->setAutoFillBackground(true); label->setContentsMargins(4, 4, 4, 4); grid->addWidget(label, 0, col + 1); @@ -146,7 +191,7 @@ void PaletteGridWidget::buildGrid(QWidget *host) auto *appHeader = new QLabel(tr("App colors"), host); appHeader->setToolTip(tr("Application-specific colors layered on top of the Qt palette")); - appHeader->setFont(QtUtils::boldFont(appHeader->font())); + appHeader->setFont(QtUtils::boldFont(themeManager->appFont(AppFont::InterfaceDefault))); appHeader->setAutoFillBackground(true); appHeader->setContentsMargins(4, 4, 4, 4); grid->addWidget(appHeader, appHeaderRow, 0, 1, 4); @@ -166,7 +211,7 @@ void PaletteGridWidget::buildGrid(QWidget *host) } auto *label = new QLabel(QString(appEnum.valueToKey(role)), host); - label->setToolTip(APP_ROLE_DESCRIPTIONS.value(role, {})); + label->setToolTip(QCoreApplication::translate("PaletteGridWidget", APP_ROLE_DESCRIPTIONS.value(role, {}))); label->setContentsMargins(4, 2, 8, 2); grid->addWidget(label, row, 0); diff --git a/cockatrice/src/interface/palette_editor/palette_grid_widget.h b/cockatrice/src/interface/palette_editor/palette_grid_widget.h index f1ff41ec2..7c527a064 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.h +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.h @@ -34,6 +34,10 @@ private: void changeEvent(QEvent *e); void refreshChromePalettes(); +private slots: + void applyInterfaceDefaultFont(); + +private: QMap> colorButtons; QMap appColorButtons; QScrollArea *scroll; diff --git a/cockatrice/src/interface/palette_editor/quick_setup_panel.cpp b/cockatrice/src/interface/palette_editor/quick_setup_panel.cpp index 04f98dcc3..00eac3253 100644 --- a/cockatrice/src/interface/palette_editor/quick_setup_panel.cpp +++ b/cockatrice/src/interface/palette_editor/quick_setup_panel.cpp @@ -1,5 +1,6 @@ #include "quick_setup_panel.h" +#include "../theme_manager.h" #include "color_button.h" #include @@ -28,10 +29,15 @@ QuickSetupPanel::QuickSetupPanel(QWidget *parent) : QWidget(parent) labelLow = new QLabel(this); labelHigh = new QLabel(this); - QFont small = labelLow->font(); - small.setPointSizeF(small.pointSizeF() * 0.82); - labelLow->setFont(small); - labelHigh->setFont(small); + // Family comes from the InterfaceDefault theme role; the *0.82 size stays caller-side. + auto applySmallFont = [this]() { + QFont small = themeManager->appFont(AppFont::InterfaceDefault); + small.setPointSizeF(small.pointSizeF() * 0.82); + labelLow->setFont(small); + labelHigh->setFont(small); + }; + applySmallFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, applySmallFont); QPalette dimmed = labelLow->palette(); dimmed.setColor(QPalette::WindowText, qApp->palette().color(QPalette::Mid)); labelLow->setPalette(dimmed); diff --git a/cockatrice/src/interface/pixel_map_generator.cpp b/cockatrice/src/interface/pixel_map_generator.cpp index 2f5c4b6dd..2705d74ca 100644 --- a/cockatrice/src/interface/pixel_map_generator.cpp +++ b/cockatrice/src/interface/pixel_map_generator.cpp @@ -1,6 +1,7 @@ #include "pixel_map_generator.h" #include "libcockatrice/network/server/remote/user_level.h" +#include "theme_icon.h" #include "theme_manager.h" #include @@ -15,7 +16,6 @@ #include #include #include -#include #include #include #include @@ -436,33 +436,20 @@ QIcon UserLevelPixmapGenerator::generateIconWithColorOverride(int height, QMap UserLevelPixmapGenerator::iconCache; -QPixmap LockPixmapGenerator::generatePixmap(int height) -{ - - int key = height; - if (pmCache.contains(key)) { - return pmCache.value(key); - } - - QPixmap pixmap = tryLoadImage(QStringLiteral("theme:") + themeManager->assetPath(QStringLiteral("icons/lock")), - QSize(height, height), true); - pmCache.insert(key, pixmap); - return pixmap; -} - -QMap LockPixmapGenerator::pmCache; - QPixmap DropdownIconPixmapGenerator::generatePixmap(int height, bool expanded) { - QString key = QString::number(expanded) + ":" + QString::number(height); + const QString key = QString::number(expanded) + ":" + QString::number(height); if (pmCache.contains(key)) { return pmCache.value(key); } - QString name = expanded ? "dropdown_expanded" : "dropdown_collapsed"; - QPixmap pixmap = tryLoadImage(QStringLiteral("theme:") + themeManager->assetPath(QStringLiteral("icons/") + name), - QSize(height, height), true); - + // Render through the themed icon engine: it keeps the glyph's aspect ratio, + // follows the palette, and renders at the device pixel ratio instead of + // handing a DPR-1 bitmap to a label that would upscale and blur it on a 2x + // display. + const QString prefix = QStringLiteral("icons/") + (expanded ? "dropdown_expanded" : "dropdown_collapsed"); + const QPixmap pixmap = + loadThemedIcon(prefix, IconTint::quiet()).pixmap(QSize(height, height), qApp->devicePixelRatio()); pmCache.insert(key, pixmap); return pixmap; } @@ -520,30 +507,11 @@ QPixmap ManaSymbolPixmapGenerator::generatePixmap(const QString &symbol, const Q QHash ManaSymbolPixmapGenerator::masterCache; QHash ManaSymbolPixmapGenerator::scaledCache; -QPixmap loadColorAdjustedPixmap(const QString &name) -{ - // Prefer an authored scheme-qualified variant when one exists for this asset. - const QString variant = themeManager->schemeVariantPath(QStringView(name).mid(QStringLiteral("theme:").size())); - if (!variant.isEmpty()) { - return QPixmap(QStringLiteral("theme:") + variant); - } - - // Legacy fallback: runtime-invert for dark mode when no authored variant. - if (qApp->palette().windowText().color().lightness() > 200) { - QImage img(name); - img.invertPixels(); - QPixmap result; - result.convertFromImage(img); - return result; - } else { - return QPixmap(name); - } -} - QPixmap themePixmap(QStringView prefix) { const QString resolved = themeManager->assetPath(prefix); - return QPixmap(QStringLiteral("theme:") + resolved); + const QPixmap sentinel = loadSentinelSvgPixmap(resolved); + return sentinel.isNull() ? QPixmap(QStringLiteral("theme:") + resolved) : sentinel; } void clearPixmapGeneratorCaches() @@ -553,7 +521,6 @@ void clearPixmapGeneratorCaches() PingPixmapGenerator::clear(); CountryPixmapGenerator::clear(); UserLevelPixmapGenerator::clear(); - LockPixmapGenerator::clear(); DropdownIconPixmapGenerator::clear(); ManaSymbolPixmapGenerator::clear(); } diff --git a/cockatrice/src/interface/pixel_map_generator.h b/cockatrice/src/interface/pixel_map_generator.h index 7c01bb332..951f4231b 100644 --- a/cockatrice/src/interface/pixel_map_generator.h +++ b/cockatrice/src/interface/pixel_map_generator.h @@ -106,19 +106,6 @@ public: } }; -class LockPixmapGenerator -{ -private: - static QMap pmCache; - -public: - static QPixmap generatePixmap(int height); - static void clear() - { - pmCache.clear(); - } -}; - class DropdownIconPixmapGenerator { private: @@ -160,13 +147,11 @@ public: } }; -QPixmap loadColorAdjustedPixmap(const QString &name); - -// Loads a "theme:" asset (with no file extension in prefix), preferring the +// Returns a "theme:" asset (with no file extension in prefix), preferring the // scheme-qualified variant (prefix-dark / prefix-light, resolved via -// ThemeManager::assetPath) and falling back to the plain asset. Callers load -// the returned path directly. Use for scheme-sensitive pixmaps like -// backgrounds, the card back, and the app logo. +// ThemeManager::assetPath) and falling back to the plain asset. Token-driven +// SVGs resolve their `currentColor` sentinel from the palette. Use for +// scheme-sensitive pixmaps like backgrounds, the card back, and the app logo. QPixmap themePixmap(QStringView prefix); // Clears every PixmapGenerator's static cache so scheme variants are diff --git a/cockatrice/src/interface/recording.cpp b/cockatrice/src/interface/recording.cpp new file mode 100644 index 000000000..2b11255f9 --- /dev/null +++ b/cockatrice/src/interface/recording.cpp @@ -0,0 +1,102 @@ +#include "recording.h" + +#include "pqEventTypes.h" + +#include +#include +#include +#include + +XMLEventObserver::XMLEventObserver(QObject *parent) : pqEventObserver(parent) +{ +} + +XMLEventObserver::~XMLEventObserver() = default; + +void XMLEventObserver::setStream(QTextStream *stream) +{ + if (xmlStream) { + xmlStream->writeEndElement(); + xmlStream->writeEndDocument(); + xmlStream.reset(); + } + if (Stream) { + *Stream << xmlString; + } + xmlString.clear(); + pqEventObserver::setStream(stream); + if (Stream) { + xmlStream.reset(new QXmlStreamWriter(&xmlString)); + xmlStream->setAutoFormatting(true); + xmlStream->writeStartDocument(); + xmlStream->writeStartElement(QStringLiteral("events")); + } +} + +void XMLEventObserver::onRecordEvent(const QString &widget, + const QString &command, + const QString &arguments, + const int &eventType) +{ + if (!xmlStream) { + return; + } + + xmlStream->writeStartElement(QStringLiteral("event")); + xmlStream->writeAttribute(QStringLiteral("widget"), widget); + if (eventType == pqEventTypes::ACTION_EVENT) { + xmlStream->writeAttribute(QStringLiteral("command"), command); + } else { // pqEventTypes::CHECK_EVENT + xmlStream->writeAttribute(QStringLiteral("property"), command); + } + xmlStream->writeAttribute(QStringLiteral("arguments"), arguments); + xmlStream->writeEndElement(); +} + +XMLEventSource::XMLEventSource(QObject *parent) : pqEventSource(parent) +{ +} + +XMLEventSource::~XMLEventSource() = default; + +void XMLEventSource::setContent(const QString &xmlFileName) +{ + xmlStream.reset(); + + QFile xml(xmlFileName); + if (!xml.open(QIODevice::ReadOnly)) { + qCWarning(RecordingLog) << "Could not read test file" << xmlFileName; + return; + } + + xmlStream.reset(new QXmlStreamReader(xml.readAll())); +} + +int XMLEventSource::getNextEvent(QString &widget, QString &command, QString &arguments, int &eventType) +{ + if (!xmlStream) { + return DONE; + } + + while (!xmlStream->atEnd()) { + if (xmlStream->readNext() != QXmlStreamReader::StartElement || xmlStream->name() != QStringLiteral("event")) { + continue; + } + + const QXmlStreamAttributes attributes = xmlStream->attributes(); + eventType = pqEventTypes::ACTION_EVENT; + widget = attributes.value(QStringLiteral("widget")).toString(); + command = attributes.value(QStringLiteral("command")).toString(); + if (command.isEmpty()) { + // A recorded check event carries a property instead of a command. + command = attributes.value(QStringLiteral("property")).toString(); + } + arguments = attributes.value(QStringLiteral("arguments")).toString(); + return SUCCESS; + } + + if (xmlStream->hasError()) { + qCWarning(RecordingLog) << "Malformed test file:" << xmlStream->errorString(); + } + return DONE; +} diff --git a/cockatrice/src/interface/recording.h b/cockatrice/src/interface/recording.h new file mode 100644 index 000000000..a5d813ea9 --- /dev/null +++ b/cockatrice/src/interface/recording.h @@ -0,0 +1,54 @@ +#ifndef COCKATRICE_RECORDING_H +#define COCKATRICE_RECORDING_H + +#include "pqEventObserver.h" +#include "pqEventSource.h" + +#include +#include + +inline Q_LOGGING_CATEGORY(RecordingLog, "recording"); + +class QXmlStreamReader; +class QXmlStreamWriter; + +/** + * @brief Writes a session recording as an XML test file. + * + * The events are buffered in memory and flushed to the stream when recording + * ends, because the stream is handed over once the file name is known. + */ +class XMLEventObserver : public pqEventObserver +{ + QScopedPointer xmlStream; + QString xmlString; + +public: + explicit XMLEventObserver(QObject *parent); + ~XMLEventObserver() override; + +protected: + void setStream(QTextStream *stream) override; + void onRecordEvent(const QString &widget, + const QString &command, + const QString &arguments, + const int &eventType) override; +}; + +/** + * @brief Plays the events of an XML test file back, one at a time. + */ +class XMLEventSource : public pqEventSource +{ + QScopedPointer xmlStream; + +public: + explicit XMLEventSource(QObject *parent); + ~XMLEventSource() override; + +protected: + void setContent(const QString &xmlFileName) override; + int getNextEvent(QString &widget, QString &command, QString &arguments, int &eventType) override; +}; + +#endif // COCKATRICE_RECORDING_H diff --git a/cockatrice/src/interface/theme_config.cpp b/cockatrice/src/interface/theme_config.cpp index 652e322c8..525a46905 100644 --- a/cockatrice/src/interface/theme_config.cpp +++ b/cockatrice/src/interface/theme_config.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -304,4 +305,220 @@ QPalette PaletteConfig::apply(QPalette base) const } return base; -} \ No newline at end of file +} + +bool FontConfig::hasFonts() const +{ + return !fonts.isEmpty(); +} + +QString FontConfig::toToml() const +{ + QMetaEnum roleEnum = QMetaEnum::fromType(); + + QString entries; + + for (auto it = fonts.cbegin(); it != fonts.cend(); ++it) { + const char *roleName = roleEnum.valueToKey(it.key()); + + if (!roleName) { + continue; + } + + QString family = it.value().family().trimmed(); + int pointSize = it.value().pointSize(); + + if (family.isEmpty() || pointSize <= 0) { + continue; + } + + entries += QString("%1 = \"%2, %3\"\n").arg(QString(roleName), -20).arg(family).arg(pointSize); + } + + if (entries.isEmpty()) { + return {}; + } + + return "[Fonts]\n" + entries + "\n"; +} + +QString FontConfig::fileName() +{ + return "fonts.toml"; +} + +FontConfig FontConfig::fromFile(const QString &filePath) +{ + FontConfig cfg; + + QFile f(filePath); + + if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) { + return cfg; + } + + QMetaEnum roleEnum = QMetaEnum::fromType(); + + QString currentSection; + + QTextStream in(&f); + + while (!in.atEnd()) { + QString line = in.readLine().trimmed(); + + if (line.isEmpty() || line.startsWith('#') || line.startsWith(';')) { + continue; + } + + if (line.startsWith('[') && line.endsWith(']')) { + currentSection = line.mid(1, line.length() - 2).trimmed(); + continue; + } + + if (currentSection.compare("Fonts", Qt::CaseInsensitive) != 0) { + continue; + } + + int eq = line.indexOf('='); + + if (eq < 0) { + continue; + } + + QString key = line.left(eq).trimmed(); + QString value = line.mid(eq + 1).trimmed(); + + // Strip inline comments if preceded by whitespace + for (int i = 1; i < value.size(); ++i) { + if ((value[i] == '#' || value[i] == ';') && value[i - 1].isSpace()) { + value = value.left(i).trimmed(); + break; + } + } + + // Strip surrounding quotes, if any + if (value.size() >= 2 && + ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith('\'') && value.endsWith('\'')))) { + value = value.mid(1, value.size() - 2).trimmed(); + } + + int comma = value.lastIndexOf(','); + + if (comma < 0) { + continue; + } + + QString family = value.left(comma).trimmed(); + + // Tolerate a partially-quoted family, e.g. "Inter", 10 + if (family.size() >= 2 && + ((family.startsWith('"') && family.endsWith('"')) || (family.startsWith('\'') && family.endsWith('\'')))) { + family = family.mid(1, family.size() - 2).trimmed(); + } + + bool ok = false; + int pointSize = value.mid(comma + 1).trimmed().toInt(&ok); + + if (!ok || family.isEmpty() || pointSize <= 0) { + continue; + } + + if (key.startsWith("AppFont::")) { + key = key.mid(QStringLiteral("AppFont::").size()); + } + + int roleInt = roleEnum.keyToValue(key.toUtf8().constData()); + + if (roleInt < 0) { + continue; + } + + // Canonical family + point size only: a fresh QFont carries default + // (non-bold, non-italic) weight/style, so nothing caller-side leaks in. + // No validation against installed fonts; Qt substitution applies. + QFont font(family); + font.setPointSize(pointSize); + cfg.fonts[static_cast(roleInt)] = font; + } + + return cfg; +} + +FontConfig FontConfig::fromThemeDir(const QString &themeDirPath) +{ + if (themeDirPath.isEmpty()) { + return {}; + } + + return fromFile(QDir(themeDirPath).absoluteFilePath(fileName())); +} + +QColor defaultAppColor(AppColor::Role role, bool dark) +{ + switch (role) { + case AppColor::AccentStrong: + case AppColor::AccentSoft: + // The neutral icon family is palette-derived like the accent: a theme + // that leaves it unauthored (System, and every theme written before the + // roles existed) keeps the live platform palette's WindowText instead + // of pinning one scheme's ink. + case AppColor::Icon: + case AppColor::IconStrong: + case AppColor::IconSoft: + return {}; + // The semantic family follows the same chroma ladder as the authored + // themes. A theme that authors none of it has no per-surface palette to + // measure against, so these are authored against the representative + // platform surfaces the System theme runs on. + case AppColor::Positive: + return QColor(dark ? QStringLiteral("#6dd58c") : QStringLiteral("#1a7d3a")); + case AppColor::PositiveStrong: + return QColor(dark ? QStringLiteral("#32c05c") : QStringLiteral("#007e28")); + case AppColor::PositiveSoft: + return QColor(dark ? QStringLiteral("#8ab196") : QStringLiteral("#4c7659")); + case AppColor::Negative: + return QColor(dark ? QStringLiteral("#ff8a80") : QStringLiteral("#b3261e")); + case AppColor::NegativeStrong: + return QColor(dark ? QStringLiteral("#ff8378") : QStringLiteral("#da0b00")); + case AppColor::NegativeSoft: + return QColor(dark ? QStringLiteral("#c79e9b") : QStringLiteral("#935f5c")); + // Informational is the third semantic family: a state that is neither a + // gain nor a loss (a face-down play, the exile zone). It carries the + // same three-level ladder so its emphasis is authored, not computed. + case AppColor::Informational: + return QColor(dark ? QStringLiteral("#7aa9f2") : QStringLiteral("#1c64c4")); + case AppColor::InformationalStrong: + return QColor(dark ? QStringLiteral("#69aeff") : QStringLiteral("#0a4fb5")); + case AppColor::InformationalSoft: + return QColor(dark ? QStringLiteral("#9db2cd") : QStringLiteral("#526a8a")); + // Hidden is the game's hidden-information cluster: a face-down play and + // peeking at a card face. Violet keeps it apart from the blue + // Informational family, which carries public information (reveal). + case AppColor::Hidden: + return QColor(dark ? QStringLiteral("#c6a5fa") : QStringLiteral("#6d28d9")); + case AppColor::HiddenStrong: + return QColor(dark ? QStringLiteral("#c99bff") : QStringLiteral("#4c0f9e")); + case AppColor::HiddenSoft: + return QColor(dark ? QStringLiteral("#b3a6c9") : QStringLiteral("#6a5a8c")); + // Marked is the marks a player leaves on a card: an annotation or a + // draw arrow. Amber names a persistent, removable mark rather than a + // gain or a loss, so annotation and arrow finally share a colour. + case AppColor::Marked: + return QColor(dark ? QStringLiteral("#e2a63a") : QStringLiteral("#9a5b05")); + case AppColor::MarkedStrong: + return QColor(dark ? QStringLiteral("#ffb020") : QStringLiteral("#8a4f02")); + case AppColor::MarkedSoft: + return QColor(dark ? QStringLiteral("#c2ab7d") : QStringLiteral("#7b6a4a")); + // Movement is a card changing position without a gain or a loss: the + // library placements (top, bottom, X from top). Teal keeps it apart + // from the green "into play" moves and the blue informational states. + case AppColor::Movement: + return QColor(dark ? QStringLiteral("#4cc2b5") : QStringLiteral("#0e7a72")); + case AppColor::MovementStrong: + return QColor(dark ? QStringLiteral("#2dd4bf") : QStringLiteral("#096b62")); + case AppColor::MovementSoft: + return QColor(dark ? QStringLiteral("#a3c1bc") : QStringLiteral("#4a6b67")); + } + + return {}; +} diff --git a/cockatrice/src/interface/theme_config.h b/cockatrice/src/interface/theme_config.h index 8b91613f5..0848baeb8 100644 --- a/cockatrice/src/interface/theme_config.h +++ b/cockatrice/src/interface/theme_config.h @@ -2,6 +2,7 @@ #define COCKATRICE_THEME_CONFIG_H #include +#include #include #include #include @@ -12,6 +13,13 @@ // set. Stored in the same palette-.toml under an [AppColors] section // and editable from the palette editor, so theme authors can control colors // beyond what Qt's palette can express. +// +// The icon families carry one role per emphasis level. Icon is the neutral +// glyph ink; IconStrong and IconSoft are its primary and quiet levels. Positive, +// Negative, Informational, Hidden, Marked and Movement follow the same pattern, +// so an emphasis never has to be computed from a hue at paint time: the theme +// authors all three levels. Naming a variant explicitly (`IconStrong`) pins that +// level; naming the family (`Icon`) lets the caller's emphasis choose. namespace AppColor { Q_NAMESPACE @@ -19,10 +27,64 @@ enum Role { AccentStrong, AccentSoft, + Icon, + IconStrong, + IconSoft, + Positive, + PositiveStrong, + PositiveSoft, + Negative, + NegativeStrong, + NegativeSoft, + Informational, + InformationalStrong, + InformationalSoft, + Hidden, + HiddenStrong, + HiddenSoft, + Marked, + MarkedStrong, + MarkedSoft, + Movement, + MovementStrong, + MovementSoft, }; Q_ENUM_NS(Role) } // namespace AppColor +// Application-specific font roles, one configurable family + point size per +// purpose. Stored in a shared fonts.toml under a [Fonts] section (one file per +// theme, not split by light/dark). Only family and point size are stored; +// weight/italic are stripped on load and stay caller-side concerns. +namespace AppFont +{ +Q_NAMESPACE +enum Role +{ + InterfaceDefault, + ChatLog, + CardText, + DeckList, +}; +Q_ENUM_NS(Role) +} // namespace AppFont + +/** + * @brief Fallback for an application color role when the active theme does not + * define one. + * + * Accent roles and the neutral Icon family return an invalid QColor because + * ThemeManager derives them from the palette: the accent from the highlight, + * the icon ink from the live WindowText held to the label floor. Semantic roles + * return an opaque default that suits the scheme, so themes written before the + * role existed still render it. The System theme deliberately leaves the + * neutral family unauthored: the palette file it picks cannot know the platform + * scheme, so a fixed ladder would be invisible on the other one. It derives + * neutral ink from the live platform palette instead of pinning one scheme's + * colors. + */ +QColor defaultAppColor(AppColor::Role role, bool dark); + struct ThemeConfig { QString colorScheme; @@ -60,4 +122,33 @@ struct PaletteConfig QPalette apply(QPalette base) const; }; -#endif // COCKATRICE_THEME_CONFIG_H \ No newline at end of file +/** @brief Per-purpose font configuration, stored as family + point size only. + * + * Serialized to a shared fonts.toml file with a [Fonts] section, e.g. + * @code + * [Fonts] + * InterfaceDefault = "Inter, 10" + * @endcode + */ +struct FontConfig +{ + QMap fonts; + + bool operator==(const FontConfig &rhs) const + { + return fonts == rhs.fonts; + } + bool operator!=(const FontConfig &rhs) const + { + return !(*this == rhs); + } + bool hasFonts() const; + QString toToml() const; + + static QString fileName(); + + static FontConfig fromFile(const QString &filePath); + static FontConfig fromThemeDir(const QString &themeDirPath); +}; + +#endif // COCKATRICE_THEME_CONFIG_H diff --git a/cockatrice/src/interface/theme_icon.cpp b/cockatrice/src/interface/theme_icon.cpp new file mode 100644 index 000000000..3a71f149c --- /dev/null +++ b/cockatrice/src/interface/theme_icon.cpp @@ -0,0 +1,493 @@ +#include "theme_icon.h" + +#include "libcockatrice/utility/svg_recolor.h" +#include "theme_icon_badge.h" +#include "theme_icon_ink.h" +#include "theme_icon_sticker.h" +#include "theme_manager.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +/** + * @brief The role an emphasis level selects inside a base role's family. + * + * The theme authors all three levels, so emphasis is a lookup instead of a + * color computation. Naming a variant (`IconStrong`, `PositiveSoft`) is an + * explicit request and keeps that role at every emphasis; only the base roles + * take part in the mapping. Accent roles have no neutral middle member, so they + * are always used as named. + */ +AppColor::Role emphasisRole(AppColor::Role role, IconEmphasis emphasis) +{ + switch (role) { + case AppColor::Icon: + return emphasis == IconEmphasis::Primary ? AppColor::IconStrong + : emphasis == IconEmphasis::Quiet ? AppColor::IconSoft + : AppColor::Icon; + case AppColor::Positive: + return emphasis == IconEmphasis::Primary ? AppColor::PositiveStrong + : emphasis == IconEmphasis::Quiet ? AppColor::PositiveSoft + : AppColor::Positive; + case AppColor::Negative: + return emphasis == IconEmphasis::Primary ? AppColor::NegativeStrong + : emphasis == IconEmphasis::Quiet ? AppColor::NegativeSoft + : AppColor::Negative; + case AppColor::Informational: + return emphasis == IconEmphasis::Primary ? AppColor::InformationalStrong + : emphasis == IconEmphasis::Quiet ? AppColor::InformationalSoft + : AppColor::Informational; + case AppColor::Hidden: + return emphasis == IconEmphasis::Primary ? AppColor::HiddenStrong + : emphasis == IconEmphasis::Quiet ? AppColor::HiddenSoft + : AppColor::Hidden; + case AppColor::Marked: + return emphasis == IconEmphasis::Primary ? AppColor::MarkedStrong + : emphasis == IconEmphasis::Quiet ? AppColor::MarkedSoft + : AppColor::Marked; + case AppColor::Movement: + return emphasis == IconEmphasis::Primary ? AppColor::MovementStrong + : emphasis == IconEmphasis::Quiet ? AppColor::MovementSoft + : AppColor::Movement; + default: + return role; + } +} + +/** + * @brief The scheme the active application palette is painting for. + * + * The fill needs the surface scheme to choose the badge glyph ink, and it cannot + * read it from the tone: an authored quiet level is a dark color on the dark + * scheme too. The theme's resolved scheme is the same source the palette file + * and the default app colors are chosen from, so the fill follows it instead of + * re-deriving the scheme from the live Window lightness, which can disagree + * when a theme pins a scheme the platform palette does not match. + */ +ThemeIconFill::Scheme activeScheme() +{ + if (themeManager != nullptr) { + return themeManager->isDarkModeActive() ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light; + } + + // No manager (test binaries link the icon engine without the app): fall + // back to the live palette, which is the only scheme signal left. + const QPalette &palette = qApp->palette(); + return palette.color(QPalette::Active, QPalette::Window).lightness() < 128 ? ThemeIconFill::Scheme::Dark + : ThemeIconFill::Scheme::Light; +} + +/** + * @brief The ink a disabled glyph is painted with. + * + * A disabled icon used to be the palette's disabled text, a fixed gray whose + * contrast swings with the surface: on the darkest themes it out-reads the quiet + * level, so an enabled quiet glyph looks more disabled than a disabled one. + * Deriving it from the theme's own quiet ink keeps the order structural. + */ +QColor disabledGlyphInk(const QPalette &palette) +{ + const QColor quiet = themeManager->appColor(AppColor::IconSoft); + const QColor surface = ThemeIconInk::harderSurface(palette.color(QPalette::Active, QPalette::Window), + palette.color(QPalette::Active, QPalette::Button), quiet); + return ThemeIconInk::disabledInk(quiet, surface); +} + +/** + * @brief Reads an SVG through the `theme:` search path. + */ +QByteArray readSvg(const QString &resolvedPath) +{ + QFile file(QStringLiteral("theme:") + resolvedPath); + if (!file.open(QIODevice::ReadOnly)) { + return {}; + } + return file.readAll(); +} + +/** + * @brief Renders SVG bytes, scaled to @p size when one is given. + * + * An empty size means the asset's intrinsic size; @p devicePixelRatio only + * applies in that case so HiDPI callers still get a crisp glyph. + */ +QPixmap renderSvg(const QByteArray &data, const QSize &size, qreal devicePixelRatio) +{ + QSvgRenderer renderer(data); + if (!renderer.isValid()) { + return {}; + } + + QSize renderSize = size; + if (renderSize.isEmpty()) { + renderSize = renderer.defaultSize(); + renderSize = + QSize(qRound(renderSize.width() * devicePixelRatio), qRound(renderSize.height() * devicePixelRatio)); + } + if (!renderSize.isValid() || renderSize.isEmpty()) { + return {}; + } + + QPixmap pixmap(renderSize); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + renderer.render(&painter); + return pixmap; +} + +bool hasSentinel(const QByteArray &svg) +{ + return !svg.isEmpty() && QString::fromUtf8(svg).contains(QStringLiteral("currentColor"), Qt::CaseInsensitive); +} + +QString cacheKey(const QString &path, const QByteArray &variant, const QSize &size, qreal devicePixelRatio) +{ + return path + QLatin1Char('|') + QString::fromLatin1(variant) + QLatin1Char('|') + QString::number(size.width()) + + QLatin1Char('x') + QString::number(size.height()) + QLatin1Char('@') + QString::number(devicePixelRatio); +} + +/** + * @brief QIcon engine rendering a `theme:` SVG with `currentColor` resolved on demand. + * + * Qt's SVG renderer has no ambient color, so the sentinel is substituted from + * the current palette on every pixmap request. Asset resolution, the file bytes + * and the intrinsic size are cached until the theme changes: a repaint then + * does no file I/O at all. The render cache is keyed by resolved path and pixel + * size plus a variant: the palette color for sentinel SVGs, the icon mode for + * assets that carry their own colors, and the tone plus glyph color for a badge. + * A palette change therefore produces new keys instead of reusing art built + * under the previous palette, and the non-sentinel path (raster overrides, plain + * SVGs) is cached like any other. + * + * The engine only resolves and caches. The badge shape itself is painted by + * ThemeIconBadge so the compositing stays out of the resolution path. + */ +class ThemeSvgIconEngine final : public QObject, public QIconEngine +{ +public: + ThemeSvgIconEngine(QString _prefix, IconTint _tint, IconStyle _style) + : prefix(std::move(_prefix)), tint(_tint), style(_style) + { + if (themeManager) { + // A theme switch changes the resolved asset (scheme-qualified + // variants, raster overrides) and a palette change invalidates + // every cached colour, so both drop the caches instead of + // re-reading theme.cfg and the assets on every repaint. + connect(themeManager, &ThemeManager::themeChanged, this, &ThemeSvgIconEngine::invalidate); + connect(themeManager, &ThemeManager::paletteChanged, this, &ThemeSvgIconEngine::invalidate); + } + } + + QIconEngine *clone() const override + { + return new ThemeSvgIconEngine(prefix, tint, style); + } + + void paint(QPainter *painter, const QRect &rect, QIcon::Mode mode, QIcon::State) override + { + const qreal devicePixelRatio = painter->device() ? painter->device()->devicePixelRatioF() : 1.0; + const QSize logicalSize = fitToSize(rect.size()); + if (logicalSize.isEmpty()) { + return; + } + const QSize pixelSize(qRound(logicalSize.width() * devicePixelRatio), + qRound(logicalSize.height() * devicePixelRatio)); + const QPixmap pixmap = render(pixelSize, devicePixelRatio, mode); + if (!pixmap.isNull()) { + // QRect::center() would floor and drop the last row/column of a + // 16x16 glyph; the center has to be fractional so glyphs stay + // unshifted and unsclipped in item views. + const QSizeF contentSize = pixmap.deviceIndependentSize(); + const QRectF targetRect( + QRectF(rect).center() - QPointF(contentSize.width() / 2.0, contentSize.height() / 2.0), contentSize); + painter->drawPixmap(targetRect, pixmap, QRectF(pixmap.rect())); + } + } + + QPixmap pixmap(const QSize &size, QIcon::Mode mode, QIcon::State) override + { + return render(fitToSize(size), 1.0, mode); + } + + QPixmap scaledPixmap(const QSize &size, QIcon::Mode mode, QIcon::State, qreal scale) override + { + const QSize logicalSize = fitToSize(size); +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + const QSize pixelSize(qRound(logicalSize.width() * scale), qRound(logicalSize.height() * scale)); +#else + // Qt < 6.8 passes the device-dependent size to the engine. + const QSize pixelSize = logicalSize; +#endif + return render(pixelSize, scale, mode); + } + + QSize actualSize(const QSize &size, QIcon::Mode, QIcon::State) override + { + return fitToSize(size); + } + +private: + void invalidate() + { + hasResolvedAsset = false; + hasIntrinsicSize = false; + cache.clear(); + } + + void ensureResolved() const + { + if (hasResolvedAsset && (!themeManager || themeManager->getCurrentThemePath() == resolvedThemePath)) { + return; + } + resolvedThemePath = themeManager ? themeManager->getCurrentThemePath() : QString(); + resolvedPath = themeManager ? themeManager->assetPath(prefix) : QString(); + resolvedAssetIsSvg = resolvedPath.endsWith(QStringLiteral(".svg"), Qt::CaseInsensitive); + resolvedSvg = resolvedAssetIsSvg ? readSvg(resolvedPath) : QByteArray(); + resolvedHasSentinel = hasSentinel(resolvedSvg); + hasResolvedAsset = true; + hasIntrinsicSize = false; + } + + QSize intrinsicSize() const + { + ensureResolved(); + if (!hasIntrinsicSize) { + if (resolvedAssetIsSvg) { + QSvgRenderer renderer(resolvedSvg); + intrinsicSizeCache = renderer.isValid() ? renderer.defaultSize() : QSize(); + } else { + // A theme may override a glyph with a raster; take its real + // aspect ratio instead of assuming the 24x24 SVG default, so + // actualSize() and the rendered pixmap agree. + QImageReader reader(QStringLiteral("theme:") + resolvedPath); + intrinsicSizeCache = reader.size(); + } + if (intrinsicSizeCache.isEmpty()) { + intrinsicSizeCache = QSize(24, 24); + } + hasIntrinsicSize = true; + } + return intrinsicSizeCache; + } + + QSize fitToSize(const QSize &size) const + { + if (size.isEmpty()) { + return size; + } + return intrinsicSize().scaled(size, Qt::KeepAspectRatio); + } + + QPixmap fromAsset(const QSize &pixelSize, QIcon::Mode mode) const + { + QPixmap pixmap; + if (resolvedAssetIsSvg) { + // Render authored SVG art through QSvgRenderer rather than QPixmap. + // Loading an `.svg` through QPixmap needs Qt's SVG *image-format* + // plugin, which is a separate package on Linux distributions and is + // absent from minimal installs and from CI (it is a Recommends of + // qt6-svg-dev, so `--no-install-recommends` drops it). Art a theme + // ships must not disappear because of a packaging choice, and the + // renderer is already linked for the sentinel path. + pixmap = renderSvg(resolvedSvg, pixelSize.isEmpty() ? intrinsicSize() : pixelSize, 1.0); + } else { + pixmap = QPixmap(QStringLiteral("theme:") + resolvedPath); + if (!pixelSize.isEmpty() && pixmap.size() != pixelSize) { + pixmap = pixmap.scaled(pixelSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } + } + if (mode == QIcon::Disabled && !pixmap.isNull()) { + pixmap = QApplication::style()->generatedIconPixmap(QIcon::Disabled, pixmap, nullptr); + } + return pixmap; + } + + QPixmap render(const QSize &pixelSize, qreal devicePixelRatio, QIcon::Mode mode) const + { + ensureResolved(); + + // Badge and Sticker are shape treatments, so they only apply to sentinel + // art: an authored asset keeps its own drawing and follows the line/asset + // path. Disabled also drops back to the line glyph: a filled disc or + // sticker at full mass does not read as unavailable next to the thin line + // glyphs around it, and the tint already removes the semantic tone. + const bool treated = style != IconStyle::Line && resolvedHasSentinel && mode != QIcon::Disabled; + const bool badge = treated && style == IconStyle::Badge; + const bool sticker = treated && style == IconStyle::Sticker; + // Same cache for all paths: sentinel art varies by palette color, art + // with its own colors varies by mode (Disabled is generated). A color + // variant is never a bare digit, so the key spaces cannot collide. A + // badge also varies by glyph color, because the same tone can switch + // between a white and a near-black glyph at different lightnesses. + // The selected-row contrast fallback exists for a thin glyph painted + // directly on the highlight. A badge or sticker brings its own ring and + // contrast glyph, so repainting its whole tone with HighlightedText + // would collapse every badge on a selected row into one gray disc. + const QColor tone = resolvedHasSentinel ? tint.resolve(treated ? QIcon::Normal : mode) : QColor(); + QByteArray variant; + if (badge) { + variant = QByteArrayLiteral("B|") + tone.name(QColor::HexRgb).toLatin1() + '|' + + ThemeIconBadge::glyphColor(tone, activeScheme()).name(QColor::HexRgb).toLatin1(); + } else if (sticker) { + variant = QByteArrayLiteral("S|") + tone.name(QColor::HexRgb).toLatin1(); + } else if (resolvedHasSentinel) { + variant = tone.name(QColor::HexRgb).toLatin1(); + } else { + variant = QByteArray::number(static_cast(mode)); + } + const QString key = cacheKey(resolvedPath, variant, pixelSize, devicePixelRatio); + const auto cached = cache.constFind(key); + if (cached != cache.constEnd()) { + return cached.value(); + } + + QPixmap pixmap; + if (badge) { + // Geometry is computed from the device-pixel size, and the ratio is + // attached below like every other path. + pixmap = ThemeIconBadge::renderBadge(resolvedSvg, tone, pixelSize, activeScheme()); + } else if (sticker) { + pixmap = ThemeIconSticker::renderSticker(resolvedSvg, tone, pixelSize); + } else if (resolvedHasSentinel) { + pixmap = renderSvg(resolveSvgColorSentinel(resolvedSvg, variant), pixelSize, devicePixelRatio); + } else { + // Authored art carries its own colors: use Qt's normal loader so + // raster overrides and non-SVG assets keep working. + pixmap = fromAsset(pixelSize, mode); + } + if (!pixmap.isNull()) { + // Set the ratio before caching: assigning it to a cached pixmap + // afterwards detaches (deep-copies) it on every paint. + pixmap.setDevicePixelRatio(devicePixelRatio); + } + if (cache.size() >= 64) { + cache.clear(); + } + cache.insert(key, pixmap); + return pixmap; + } + + QString prefix; + IconTint tint; + IconStyle style; + mutable QHash cache; + mutable QString resolvedPath; + mutable QString resolvedThemePath; + mutable QByteArray resolvedSvg; + mutable QSize intrinsicSizeCache; + mutable bool resolvedAssetIsSvg = false; + mutable bool resolvedHasSentinel = false; + mutable bool hasResolvedAsset = false; + mutable bool hasIntrinsicSize = false; +}; + +} // namespace + +IconTint::IconTint(QPalette::ColorRole role, IconEmphasis _emphasis) : paletteRole(role), emphasis(_emphasis) +{ +} + +IconTint::IconTint(AppColor::Role role, IconEmphasis _emphasis) + : source(Source::AppColor), appRole(role), emphasis(_emphasis) +{ +} + +IconTint IconTint::primary() +{ + return IconTint(QPalette::WindowText, IconEmphasis::Primary); +} + +IconTint IconTint::quiet() +{ + return IconTint(QPalette::WindowText, IconEmphasis::Quiet); +} + +QColor IconTint::resolve(QIcon::Mode mode) const +{ + const QPalette &palette = qApp->palette(); + const QColor accent = palette.color(QPalette::Active, QPalette::Highlight); + + if (source == Source::AppColor) { + // A disabled control must not keep signalling its semantic color. + if (mode == QIcon::Disabled) { + return disabledGlyphInk(palette); + } + // The theme authors one color per emphasis level, so the paint path + // uses the resolved role verbatim. The filled treatments only shape the + // tone's value to give a disc or a fill depth; hue and saturation stay + // the author's. + const QColor color = themeManager->appColor(emphasisRole(appRole, emphasis)); + if (mode != QIcon::Selected) { + return color; + } + // Selected rows paint their icon on the highlight colour. Keep the + // semantic tint when it stays legible there; otherwise the glyph would + // vanish into the selection. + constexpr double MIN_SELECTED_CONTRAST = 3.0; + return ThemeIconInk::contrastRatio(color, accent) >= MIN_SELECTED_CONTRAST + ? color + : palette.color(QPalette::Active, QPalette::HighlightedText); + } + + const QPalette::ColorGroup group = mode == QIcon::Disabled ? QPalette::Disabled : QPalette::Active; + const QPalette::ColorRole effective = + mode == QIcon::Selected && paletteRole == QPalette::WindowText ? QPalette::HighlightedText : paletteRole; + // A neutral label role resolves to the theme's authored icon ink. A disabled + // one uses the dimmed quiet ink so it always sits below the quiet level; + // semantic roles keep the color the palette gave them. + if (effective == QPalette::WindowText || effective == QPalette::Text) { + return group == QPalette::Disabled ? disabledGlyphInk(palette) + : themeManager->appColor(emphasisRole(AppColor::Icon, emphasis)); + } + return palette.color(group, effective); +} + +QIcon loadThemedIcon(QStringView prefix, IconTint tint, IconStyle style) +{ + return QIcon(new ThemeSvgIconEngine(prefix.toString(), tint, style)); +} + +QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, IconTint tint, const QSize &size, IconStyle style) +{ + if (!resolvedPath.endsWith(QStringLiteral(".svg"), Qt::CaseInsensitive)) { + return {}; + } + + const QByteArray svg = readSvg(resolvedPath); + if (!hasSentinel(svg)) { + return {}; + } + + const QColor tone = tint.resolve(QIcon::Normal); + if (style == IconStyle::Badge || style == IconStyle::Sticker) { + QSize canvas = size; + if (canvas.isEmpty()) { + canvas = QSvgRenderer(svg).defaultSize(); + } + return style == IconStyle::Badge ? ThemeIconBadge::renderBadge(svg, tone, canvas, activeScheme()) + : ThemeIconSticker::renderSticker(svg, tone, canvas); + } + + return renderSvg(resolveSvgColorSentinel(svg, tone.name(QColor::HexRgb).toLatin1()), size, 1.0); +} diff --git a/cockatrice/src/interface/theme_icon.h b/cockatrice/src/interface/theme_icon.h new file mode 100644 index 000000000..82500292a --- /dev/null +++ b/cockatrice/src/interface/theme_icon.h @@ -0,0 +1,171 @@ +/** + * @file theme_icon.h + * @ingroup Interface + */ + +#ifndef THEME_ICON_H +#define THEME_ICON_H + +#include "theme_config.h" + +#include +#include +#include +#include +#include +#include +#include + +/** + * @brief Selects how a `currentColor` glyph is painted. + * + * The tint decides the tone. The style decides how much shape carries it. + * + * Line is the default and matches what every caller did before this axis + * existed: the recolored glyph alone, at the author's stroke weight. Use it for + * navigation, menu and toolbar glyphs, where a row of badges would compete with + * the content. + * + * Badge reproduces the pre-#7595 ready/not-ready art: a filled disc, a dark ring + * and a contrast glyph derived from the tone. A filled shape draws more + * attention than a line, so it is opt-in per call site. It was made for the + * ready/not-ready icons. The add/remove/delete icons were filled glyphs with an + * outline and no disc, so badging them would produce art the UI never had. + * Authored art that carries its own colors is never badged and falls back to the + * asset path. Disabled icons fall back to the line glyph too, because a filled + * disc at full mass does not read as unavailable. + * + * Sticker reproduces the other half of the pre-#7595 set: the filled add, remove + * and delete glyphs, painted with a tone gradient and a dark outline and no + * disc. It shares the disabled fallback for the same reason. + */ +enum class IconStyle +{ + Line, ///< the recolored glyph alone + Badge, ///< filled disc + dark ring + contrast glyph + Sticker, ///< filled glyph + dark outline, no disc +}; + +/** + * @brief Selects which authored level of a color family a glyph uses. + * + * Tint says which family, style says how much shape, emphasis says how loud. + * The three are independent: a primary action can be a line glyph in the + * theme's primary ink, and a quiet one can be a sticker in that family's quiet + * level. + * + * Each family (neutral icon, positive, negative) stores one color per level, so + * emphasis is a lookup rather than a computation: the palette editor's rows are + * the whole source of truth. Secondary is the default and the color every icon + * had before the axis existed, so existing call sites keep their look. Primary + * is for the one action a surface exists for. Quiet is for glyphs that label + * rather than act, such as the magnifier inside a search field: legible, but + * never competing with the buttons next to it. + */ +enum class IconEmphasis +{ + Quiet, // labels rather than acts + Secondary, // the default action weight + Primary, // the action the surface exists for +}; + +/** + * @brief Selects the color that replaces the `currentColor` sentinel. + * + * A palette role follows Qt's structural palette. An application color carries + * meaning that QPalette cannot express, such as positive and negative states. + * Callers pick the role by intent; the theme owns the concrete value. Emphasis + * is part of the same decision: both answer how the glyph is inked. + */ +class IconTint +{ +public: + IconTint() = default; + IconTint(QPalette::ColorRole role, IconEmphasis emphasis = IconEmphasis::Secondary); // implicit for role callers + IconTint(AppColor::Role role, IconEmphasis emphasis = IconEmphasis::Secondary); // implicit for semantic callers + + /** @brief A neutral glyph at the family's primary level, for the surface's primary action. */ + static IconTint primary(); + + /** @brief A dimmed neutral glyph, for a control that labels rather than acts. */ + static IconTint quiet(); + + /** + * @brief Resolves the concrete color for an icon mode. + * + * A disabled application color resolves to the derived disabled ink, the + * quiet level receded toward the surface, so an unavailable control does not + * keep its semantic color. A selected palette color keeps the + * WindowText -> HighlightedText swap. + * + * Application colors are returned verbatim: the theme author stores one + * value per emphasis level, so the paint path does not rewrite it. The + * filled treatments only shape that tone's value to give a shape depth; the + * hue and saturation stay the author's. + * + * Neutral label roles (WindowText, Text) resolve to icon ink rather than the + * raw palette color: an unauthored neutral family derives it from the live + * WindowText and holds the glyph to a contrast floor, because a 2px glyph in + * body-text ink reads thinner than the text beside it. A theme that authors + * the neutral icon roles overrides that derivation. Semantic roles keep the + * hue the call site asked for. + * + * Emphasis selects the family's authored variant, so the palette editor's + * rows are the whole source of truth. A caller that names a variant + * explicitly (a strong or soft role) pins that level at every emphasis. + */ + QColor resolve(QIcon::Mode mode) const; + +private: + enum class Source + { + Palette, + AppColor, + }; + + Source source = Source::Palette; + QPalette::ColorRole paletteRole = QPalette::WindowText; + AppColor::Role appRole = AppColor::AccentStrong; + IconEmphasis emphasis = IconEmphasis::Secondary; +}; + +/** + * @brief Builds a palette-aware icon from a `theme:` asset. + * + * SVG glyphs may use the `currentColor` sentinel. A plain QPixmap snapshot + * cannot follow palette changes, so this returns a QIcon whose engine resolves + * the sentinel from the current palette every time it is asked for a pixmap. + * Icons therefore stay legible when the user switches between light and dark + * themes without recreating any actions or buttons. + * + * @param prefix Asset prefix relative to the `theme:` search path, with no file + * extension (for example `icons/search`), matching themePixmap(). + * @param tint Color that replaces the sentinel: a palette role or a semantic + * application color. Defaults to WindowText. + * @param style Treatment of the glyph: Line, Badge or Sticker. Defaults to Line. + * @return An icon that follows the application palette. + */ +QIcon loadThemedIcon(QStringView prefix, IconTint tint = {}, IconStyle style = IconStyle::Line); + +/** + * @brief Renders a resolved theme SVG when it carries the `currentColor` sentinel. + * + * Non-SVG assets and SVGs without the sentinel return a null pixmap so callers + * can fall back to their usual loading path (including legacy inversion). + * + * @param resolvedPath Asset path relative to the `theme:` search path, as + * returned by ThemeManager::assetPath (includes extension). + * @param tint Color that replaces the sentinel: a palette role or a semantic + * application color. Defaults to WindowText. + * @param size Pixel size to render at; an empty size uses the SVG's intrinsic size. + * @param style Treatment of the glyph: Line, Badge or Sticker. A treatment on a + * non-sentinel asset stays null, like the line path. + * @return The recolored pixmap, or a null pixmap when the asset is not a + * sentinel-carrying SVG. + */ +QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, + IconTint tint = {}, + const QSize &size = {}, + IconStyle style = IconStyle::Line); + +#endif // THEME_ICON_H diff --git a/cockatrice/src/interface/theme_icon_badge.cpp b/cockatrice/src/interface/theme_icon_badge.cpp new file mode 100644 index 000000000..d5b07b3c6 --- /dev/null +++ b/cockatrice/src/interface/theme_icon_badge.cpp @@ -0,0 +1,120 @@ +#include "theme_icon_badge.h" + +#include "libcockatrice/utility/svg_recolor.h" +#include "theme_icon_fill.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +// The pre-#7595 badge art filled almost the whole canvas: a 24px icon carried a +// 23px disc with a hairline black outline. Keep the disc large and the ring +// thin so the glyph still has room at 16px. +constexpr double RING_WIDTH_RATIO = 0.04; +// The sentinel paths sit inside a padded 24-unit viewBox (the check spans 4..20), +// so the glyph box has to be nearly the whole disc for the engraved mark to +// match the old art's weight. At half the disc it read as a smudge at 15px. +constexpr double GLYPH_DIAMETER_RATIO = 0.9; +// The sentinel set is drawn at 2 units in a 24-unit viewBox, which turns into a +// hairline once the glyph is scaled into a 15px disc. The old badge carried a +// band near a tenth of the disc diameter, so the badge decides the glyph weight +// instead of inheriting the authored one. +constexpr qreal GLYPH_STROKE_WIDTH = 3.0; +constexpr int RING_DARKER = 300; +constexpr int GLYPH_DARKER = 400; +} // namespace + +namespace +{ +struct DiscFill +{ + QColor top; + QColor bottom; +}; + +DiscFill discFill(const QColor &tone) +{ + return {ThemeIconFill::topColor(tone), ThemeIconFill::bottomColor(tone)}; +} +} // namespace + +namespace ThemeIconBadge +{ +QColor glyphColor(const QColor &tone, ThemeIconFill::Scheme scheme) +{ + // The badge carries the ink the pre-sticker badge did: white on the light + // scheme, and the tone darkened to an outline-dark ink on the dark scheme. + // The lifted disc is light enough there that white sits near 2.7:1 at the + // top, so the dark ink is the deliberate choice rather than a contrast + // measurement that a palette tweak could flip. Deriving it from the tone + // keeps themes in control, and the scheme is explicit because an authored + // quiet level is a dark tone on the dark scheme too. + return scheme == ThemeIconFill::Scheme::Dark ? tone.darker(GLYPH_DARKER) : QColor(Qt::white); +} + +QSize glyphSize(const QSize &pixelSize) +{ + if (pixelSize.isEmpty()) { + return {}; + } + const qreal side = qMin(pixelSize.width(), pixelSize.height()); + const qreal ringWidth = qMax(1.0, side * RING_WIDTH_RATIO); + const qreal discDiameter = side - 2.0 * ringWidth; + const int glyphSide = qMax(1, qRound(discDiameter * GLYPH_DIAMETER_RATIO)); + return QSize(glyphSide, glyphSide); +} + +QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixelSize, ThemeIconFill::Scheme scheme) +{ + if (svg.isEmpty() || !tone.isValid() || pixelSize.isEmpty()) { + return {}; + } + + QPixmap pixmap(pixelSize); + pixmap.fill(Qt::transparent); + + const qreal side = qMin(pixelSize.width(), pixelSize.height()); + const qreal ringWidth = qMax(1.0, side * RING_WIDTH_RATIO); + const qreal discDiameter = side - 2.0 * ringWidth; + + QRectF discRect(0, 0, discDiameter, discDiameter); + discRect.moveCenter(QRectF(QPointF(0, 0), QSizeF(pixelSize)).center()); + + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + + QLinearGradient gradient(discRect.topLeft(), discRect.bottomLeft()); + const DiscFill fill = discFill(tone); + gradient.setColorAt(0.0, fill.top); + gradient.setColorAt(1.0, fill.bottom); + painter.setPen(Qt::NoPen); + painter.setBrush(gradient); + painter.drawEllipse(discRect); + + // The ring closes the disc: its outer edge touches the canvas, its inner + // edge is the disc boundary, so the two meet without a seam. + painter.setBrush(Qt::NoBrush); + painter.setPen(QPen(tone.darker(RING_DARKER), ringWidth)); + const qreal ringRadius = (side - ringWidth) / 2.0; + painter.drawEllipse(discRect.center(), ringRadius, ringRadius); + + const QByteArray glyphSvg = resolveSvgStrokeWidth( + resolveSvgColorSentinel(svg, glyphColor(tone, scheme).name(QColor::HexRgb).toLatin1()), GLYPH_STROKE_WIDTH); + QSvgRenderer renderer(glyphSvg); + if (renderer.isValid()) { + const QSize size = glyphSize(pixelSize); + const QRectF glyphRect( + QPointF(discRect.center().x() - size.width() / 2.0, discRect.center().y() - size.height() / 2.0), + QSizeF(size)); + renderer.render(&painter, glyphRect); + } + + return pixmap; +} +} // namespace ThemeIconBadge diff --git a/cockatrice/src/interface/theme_icon_badge.h b/cockatrice/src/interface/theme_icon_badge.h new file mode 100644 index 000000000..f1c392d02 --- /dev/null +++ b/cockatrice/src/interface/theme_icon_badge.h @@ -0,0 +1,78 @@ +/** + * @file theme_icon_badge.h + * @ingroup Interface + */ + +#ifndef THEME_ICON_BADGE_H +#define THEME_ICON_BADGE_H + +#include "theme_icon_fill.h" + +#include +#include +#include +#include + +/** + * @brief Paints the badge treatment of a `currentColor` glyph. + * + * A tint alone changes the color of a 2px line, but the UI drew ready and + * not-ready states as a filled badge before the one-glyph cutover. The badge + * treatment keeps that shape: a filled disc, a dark ring and a contrast glyph, + * all derived from the tone the tint resolves to. The theme still owns the + * color. Callers only choose between a line and a badge. + * + * A badge survives a busy toolbar and a tinted row better than a hairline, and + * its glyph can be light or dark regardless of the surrounding text. The cost is + * more attention per control, and the glyph color follows the scheme it is drawn + * for instead of the window's text color. + * + * Everything here works in device pixels. The icon engine renders at the + * device-pixel size it already computed and sets the pixmap's device pixel ratio + * afterwards, so badge geometry must not assume logical pixels. + */ +namespace ThemeIconBadge +{ +/** + * @brief Glyph color for a badge disc. + * + * White on the light scheme, the authored tone darkened to an outline-dark ink + * on the dark scheme. The dark-scheme tones fill the disc light enough that + * white would sit near 2.7:1 at its top, so that scheme keeps the darkened ink + * the badge had before the filled treatments shared a fill. The scheme is passed + * in rather than inferred from the tone, because an authored quiet level is a + * dark color on the dark scheme as well. + * + * @param tone Disc color, already resolved for the icon mode. + * @param scheme Scheme the badge is painted for. + */ +QColor glyphColor(const QColor &tone, ThemeIconFill::Scheme scheme); + +/** @brief Size of the engraved glyph for a badge of @p pixelSize, in device pixels. */ +QSize glyphSize(const QSize &pixelSize); + +/** + * @brief Renders a sentinel-carrying SVG as a badge. + * + * The disc fills the square canvas minus the ring with the same gradient the + * sticker fill uses, the ring darkens the tone, and the sentinel is resolved to + * the contrast glyph. The SVG is scaled to nearly the whole disc, because the + * sentinel paths are padded inside their viewBox and a smaller box loses the + * stroke at 16px. + * + * This treatment reproduces the pre-#7595 ready/not-ready art, which was a + * filled disc. It is not a generic replacement for every semantic glyph: the + * old add/remove icons were filled glyphs with an outline and no disc, so + * badging them would invent a look the UI never had. + * + * @param svg Sentinel-carrying SVG source; `currentColor` is resolved internally. + * @param tone Disc color, already resolved for the icon mode. + * @param pixelSize Canvas size in device pixels; a non-square canvas centers + * the badge and leaves the remaining area transparent. + * @param scheme Scheme the badge is painted for; selects the glyph ink. + * @return The composited badge, or a null pixmap for empty or invalid input. + */ +QPixmap renderBadge(const QByteArray &svg, const QColor &tone, const QSize &pixelSize, ThemeIconFill::Scheme scheme); +} // namespace ThemeIconBadge + +#endif // THEME_ICON_BADGE_H diff --git a/cockatrice/src/interface/theme_icon_fill.cpp b/cockatrice/src/interface/theme_icon_fill.cpp new file mode 100644 index 000000000..79690acea --- /dev/null +++ b/cockatrice/src/interface/theme_icon_fill.cpp @@ -0,0 +1,22 @@ +#include "theme_icon_fill.h" + +namespace +{ +// The bottom end of every filled treatment. The authored tone is the top, so a +// single darkening keeps the emphasis level the palette author picked and still +// gives the shape the lit-from-above depth the pre-#7595 art had. +constexpr int GRADIENT_BOTTOM_DARKER = 140; +} // namespace + +namespace ThemeIconFill +{ +QColor topColor(const QColor &tone) +{ + return tone; +} + +QColor bottomColor(const QColor &tone) +{ + return tone.darker(GRADIENT_BOTTOM_DARKER); +} +} // namespace ThemeIconFill diff --git a/cockatrice/src/interface/theme_icon_fill.h b/cockatrice/src/interface/theme_icon_fill.h new file mode 100644 index 000000000..c0e8ee6eb --- /dev/null +++ b/cockatrice/src/interface/theme_icon_fill.h @@ -0,0 +1,54 @@ +/** + * @file theme_icon_fill.h + * @ingroup Interface + */ + +#ifndef THEME_ICON_FILL_H +#define THEME_ICON_FILL_H + +#include + +/** + * @brief Picks the two colors of a filled treatment's vertical gradient. + * + * The pre-#7595 icons were filled with a lighter top over a darker bottom. The + * badge and the sticker share one fill rule so they sit next to each other with + * the same green or red. + * + * The authored tone is the top of that gradient: the palette author stores one + * color per emphasis level, so the treatment only adds depth by darkening the + * bottom. Hue, saturation and value all stay the author's, so the rendered + * ladder is the authored ladder on every scheme. + */ +namespace ThemeIconFill +{ +/** + * @brief Which scheme a treatment is painted for. + * + * The scheme is a property of the surface, not of the tone: an authored quiet + * level is deliberately darker than its secondary level on the dark scheme too, + * so it cannot be inferred from the tone's value. Callers know the surface + * palette and pass it explicitly. + */ +enum class Scheme +{ + Light, + Dark, +}; + +/** + * @brief Returns the top color of the fill gradient. + * @param tone Glyph color the treatment is rendered with. + * @return The authored tone, unchanged. + */ +QColor topColor(const QColor &tone); + +/** + * @brief Returns the bottom color of the fill gradient. + * @param tone Glyph color the treatment is rendered with. + * @return The authored tone, darkened for depth. + */ +QColor bottomColor(const QColor &tone); +} // namespace ThemeIconFill + +#endif // THEME_ICON_FILL_H diff --git a/cockatrice/src/interface/theme_icon_ink.cpp b/cockatrice/src/interface/theme_icon_ink.cpp new file mode 100644 index 000000000..7ed458f70 --- /dev/null +++ b/cockatrice/src/interface/theme_icon_ink.cpp @@ -0,0 +1,299 @@ +#include "theme_icon_ink.h" + +#include +#include + +namespace +{ +// How much of the theme accent a neutral glyph borrows. A quarter still reads as +// near-black at 16-24px, which is what made the first cut look unchanged, so the +// glyph takes most of the accent's hue. It stops short of the accent itself: the +// glyph keeps its own contrast against the surface instead of becoming a second +// accent fill. Every shipped theme accents in blue or violet, so the borrowed hue +// stays neutral chrome instead of implying a state. +constexpr double ACCENT_MIX = 0.70; +// The Positive family owns green and the Negative family owns red. An accent in +// either band is a semantic color the theme did not mean as chrome, so the +// neutral glyph keeps the accent's intensity but not its hue. +constexpr int SEMANTIC_GREEN_START = 80; +constexpr int SEMANTIC_GREEN_END = 165; +constexpr int SEMANTIC_RED_START = 340; +constexpr int SEMANTIC_RED_END = 25; +// Slate blue: the chrome hue every shipped theme already leans toward, and the +// least likely to be mistaken for a state. +constexpr int NEUTRAL_CHROME_HUE = 210; +// An icon that labels a control is carrying text; see ui-assets.md. +constexpr double LABEL_CONTRAST_FLOOR = 4.5; +// A primary glyph is the most chromatic member of its family. Contrast cannot +// order the ladder: on a dark surface the highest-contrast tone is the palest +// one, so a contrast target makes the primary look like a washed-out secondary +// and the ladder reads inverted. Primary instead raises the saturation by this +// many HSV points over the secondary and then solves the value back to the text +// floor, so it is louder in color at the same legibility. +constexpr int PRIMARY_SATURATION_STEP = 64; +// Quiet recedes by carrying less of the family's chroma, not by dropping closer +// to the surface: the disabled level owns the low-contrast end. It keeps this +// share of the secondary level's saturation. +constexpr int QUIET_SATURATION_PERCENT = 45; +// The quiet level shares the text floor with the other two levels. +constexpr double QUIET_CONTRAST_FLOOR = LABEL_CONTRAST_FLOOR; +// A disabled glyph recedes past the quiet level, so it is blended this much +// further into the surface... +constexpr double DISABLED_BLEND = 0.55; +// ...and then held to a floor below the quiet one so it stays findable. +constexpr double DISABLED_CONTRAST_FLOOR = 2.5; +// 8-bit quantization can drop a solved color just under the ratio it was solved +// for, so every search aims this much above the floor. Without it a palette +// value generated at exactly 4.5:1 measures 4.45:1 after the round trip. +constexpr double CONTRAST_ROUNDING_MARGIN = 0.10; +// Binary-search depth for reachContrast(). 24 halvings resolve to well under +// one 8-bit channel step, so the result is stable across platforms. +constexpr int CONTRAST_SEARCH_STEPS = 24; +} // namespace + +namespace ThemeIconInk +{ +double relativeLuminance(const QColor &color) +{ + const auto linear = [](double channel) { + channel /= 255.0; + return channel <= 0.03928 ? channel / 12.92 : std::pow((channel + 0.055) / 1.055, 2.4); + }; + return 0.2126 * linear(color.red()) + 0.7152 * linear(color.green()) + 0.0722 * linear(color.blue()); +} + +double contrastRatio(const QColor &first, const QColor &second) +{ + const double a = relativeLuminance(first); + const double b = relativeLuminance(second); + return (qMax(a, b) + 0.05) / (qMin(a, b) + 0.05); +} + +QColor blend(const QColor &color, const QColor &target, double amount) +{ + const double clamped = qBound(0.0, amount, 1.0); + const auto channel = [clamped](int from, int to) { return qRound(from + (to - from) * clamped); }; + return QColor(channel(color.red(), target.red()), channel(color.green(), target.green()), + channel(color.blue(), target.blue())); +} + +QColor reachContrast(const QColor &color, const QColor &background, double minRatio) +{ + const double target = minRatio + CONTRAST_ROUNDING_MARGIN; + if (contrastRatio(color, background) >= target) { + return color; + } + // Move toward whichever pole the background leaves room for. A medium-light + // surface is lighter than mid-gray but still too light to reach a text floor + // with white, so "light background means move toward black" is the wrong + // test: compare what each pole can actually reach. + const double blackRatio = contrastRatio(QColor(Qt::black), background); + const double whiteRatio = contrastRatio(QColor(Qt::white), background); + const QColor pole = blackRatio >= whiteRatio ? QColor(Qt::black) : QColor(Qt::white); + if (qMax(blackRatio, whiteRatio) < target) { + return pole; + } + + // The ratio is monotone along the path from color to pole, so a bisection + // finds the first point that clears the floor. Stepping the opposite way + // from the pole keeps the glyph as close to its palette color as possible. + double low = 0.0; + double high = 1.0; + for (int step = 0; step < CONTRAST_SEARCH_STEPS; ++step) { + const double middle = (low + high) / 2.0; + if (contrastRatio(blend(color, pole, middle), background) >= minRatio) { + high = middle; + } else { + low = middle; + } + } + return blend(color, pole, high); +} + +bool isSemanticHue(int hue) +{ + if (hue < 0) { + return false; + } + return (hue >= SEMANTIC_GREEN_START && hue <= SEMANTIC_GREEN_END) || hue >= SEMANTIC_RED_START || + hue <= SEMANTIC_RED_END; +} + +int neutralChromeHue() +{ + return NEUTRAL_CHROME_HUE; +} + +QColor harderSurface(const QColor &first, const QColor &second, const QColor &ink) +{ + const double inkLuminance = relativeLuminance(ink); + const auto distance = [inkLuminance](const QColor &surface) { + return std::abs(relativeLuminance(surface) - inkLuminance); + }; + return distance(first) <= distance(second) ? first : second; +} + +QColor glyphInk(const QColor &text, const QColor &surface, const QColor &accent, double accentMix) +{ + // A green or red accent is a Positive or Negative color, so borrowing its + // hue would paint neutral chrome the same color as a semantic state. Keep + // the accent's intensity, replace its hue with the chrome hue. + QColor source = accent; + if (isSemanticHue(accent.hue())) { + source = QColor::fromHsv(NEUTRAL_CHROME_HUE, accent.hsvSaturation(), qMax(accent.value(), 128)); + } + return reachContrast(blend(text, source, accentMix), surface, LABEL_CONTRAST_FLOOR).toRgb(); +} + +double defaultAccentMix() +{ + return ACCENT_MIX; +} + +double labelContrastFloor() +{ + return LABEL_CONTRAST_FLOOR; +} + +namespace +{ +/** @brief Whether black reaches a higher contrast against @p surface than white. */ +bool darkerPoleWins(const QColor &surface) +{ + return ThemeIconInk::contrastRatio(QColor(Qt::black), surface) >= + ThemeIconInk::contrastRatio(QColor(Qt::white), surface); +} + +/** @brief The luminance a color needs to reach @p ratio against @p surface. */ +double targetLuminance(const QColor &surface, double ratio) +{ + const double surfaceLuminance = ThemeIconInk::relativeLuminance(surface); + return darkerPoleWins(surface) ? (surfaceLuminance + 0.05) / ratio - 0.05 + : (surfaceLuminance + 0.05) * ratio - 0.05; +} + +/** + * @brief The value that puts @p hue at @p saturation on @p surface's floor. + * + * Luminance is monotone in HSV value at a fixed hue and saturation, so a + * bisection finds the boundary: the closest tone to the surface that still + * clears the floor, on either scheme. The direction depends on the surface: + * toward black (a light surface) the floor is a luminance ceiling, so the + * search keeps the highest passing value; toward white (a dark surface) it is a + * floor, so the search keeps the lowest passing value. Returns an invalid color + * when the tone cannot reach the floor at that saturation at all. + */ +QColor solveValue(int hue, int saturation, const QColor &surface, double floor) +{ + const bool towardBlack = darkerPoleWins(surface); + const double target = targetLuminance(surface, floor + CONTRAST_ROUNDING_MARGIN); + // Toward black a tone is legible at or below the target luminance, toward + // white at or above it. Testing the wrong side is what let a saturated + // violet at full value look "too dark" on a light surface and fall back to + // the unchanged secondary. + const auto passes = [hue, saturation, target, towardBlack](int value) { + const double luminance = ThemeIconInk::relativeLuminance(QColor::fromHsv(hue, saturation, value)); + return towardBlack ? luminance <= target : luminance >= target; + }; + + if (passes(255)) { + // Toward black, full value is the palest tone: if even that clears the + // floor, the whole ramp does and the surface wants the authored tone. + // Toward white it is the palest tone but also the most legible one, so + // the search still starts from the failing end. + if (towardBlack) { + return QColor::fromHsv(hue, saturation, 255).toRgb(); + } + } else if (!towardBlack) { + // Toward white the floor needs the palest tone; no darker value can + // reach it at this saturation. + return {}; + } + + int passing = towardBlack ? 0 : 255; + int failing = towardBlack ? 255 : 0; + for (int search = 0; search < CONTRAST_SEARCH_STEPS; ++search) { + const int middle = (passing + failing) / 2; + if (passes(middle)) { + passing = middle; + } else { + failing = middle; + } + } + return QColor::fromHsv(hue, saturation, passing).toRgb(); +} + +/** + * @brief Raises a color's saturation, then solves its value for the floor. + * + * Saturation is raised before the value is solved, because reaching a contrast + * floor by adding white is what desaturates a tone in the first place. If the + * hue cannot reach the floor at the raised saturation inside the gamut, the step + * shrinks until it can, so an already-vivid tone keeps what it has. A gray has + * no saturation to raise and falls back to the contrast search. + */ +QColor chromaticStep(const QColor &color, const QColor &surface, double floor, int stepPoints) +{ + if (color.hue() < 0) { + return ThemeIconInk::reachContrast(color, surface, floor); + } + + const int hue = color.hue(); + const int secondary = color.hsvSaturation(); + for (int saturation = qMin(255, secondary + stepPoints); saturation > secondary; --saturation) { + const QColor solved = solveValue(hue, saturation, surface, floor); + if (solved.isValid()) { + return solved; + } + } + return ThemeIconInk::reachContrast(color, surface, floor); +} + +/** + * @brief Mutes a color's saturation, then solves its value for the floor. + * + * The mirror of chromaticStep: quiet stays on the same floor as the other + * levels and recedes by carrying less of the family's chroma, so it cannot drop + * below the disabled level's contrast by construction. A gray has no saturation + * to mute and falls back to the contrast search. + */ +QColor mutedStep(const QColor &color, const QColor &surface, double floor, int percent) +{ + if (color.hue() < 0) { + return ThemeIconInk::reachContrast(color, surface, floor); + } + + const int saturation = qBound(0, qRound(color.hsvSaturation() * percent / 100.0), 255); + const QColor solved = solveValue(color.hue(), saturation, surface, floor); + return solved.isValid() ? solved : ThemeIconInk::reachContrast(color, surface, floor); +} +} // namespace + +QColor forEmphasis(const QColor &color, const QColor &surface, IconEmphasis emphasis) +{ + switch (emphasis) { + case IconEmphasis::Primary: + return chromaticStep(color, surface, LABEL_CONTRAST_FLOOR, PRIMARY_SATURATION_STEP).toRgb(); + case IconEmphasis::Quiet: + return mutedStep(color, surface, QUIET_CONTRAST_FLOOR, QUIET_SATURATION_PERCENT).toRgb(); + case IconEmphasis::Secondary: + break; + } + return color; +} + +QColor disabledInk(const QColor &quiet, const QColor &surface) +{ + return reachContrast(blend(quiet, surface, DISABLED_BLEND), surface, DISABLED_CONTRAST_FLOOR).toRgb(); +} + +double primaryContrastFloor() +{ + return LABEL_CONTRAST_FLOOR; +} + +double quietContrastFloor() +{ + return QUIET_CONTRAST_FLOOR; +} +} // namespace ThemeIconInk diff --git a/cockatrice/src/interface/theme_icon_ink.h b/cockatrice/src/interface/theme_icon_ink.h new file mode 100644 index 000000000..dfa585722 --- /dev/null +++ b/cockatrice/src/interface/theme_icon_ink.h @@ -0,0 +1,166 @@ +/** + * @file theme_icon_ink.h + * @ingroup Interface + */ + +#ifndef THEME_ICON_INK_H +#define THEME_ICON_INK_H + +#include "theme_icon.h" + +#include + +/** + * @brief Turns a text color into the ink a glyph is painted with. + * + * A glyph is not a word. Body text is read in sentences, at a size where a 2px + * stroke would be unusually heavy; a glyph is a 16px mark of the same stroke, + * and antialiasing eats its edges, so the same color that reads as solid type + * reads as a thin gray line beside it. Painting icons with `WindowText` verbatim + * is what makes them recede. + * + * The rule here is deliberately small: give the glyph a little of the theme's + * accent chroma, then hold it to a contrast floor against the surface it sits + * on. The chroma is what makes it read as an icon rather than as leftover text; + * the floor keeps a theme with a weak text color from producing a glyph that is + * not legible. Neither step invents a color: both are derived from the palette + * the theme already ships. An unauthored neutral family stops at the floor: the + * live platform palette is the only source that knows the actual scheme, so a + * chroma pinned in a palette file could belong to the other one. + * + * The rule only applies to the neutral label roles. A semantic tint + * (`AppColor::Positive`, `QPalette::Highlight`, `QPalette::Link`) is a hue the + * call site chose on purpose, so it is left alone. + */ +namespace ThemeIconInk +{ +/** + * @brief Whether @p hue belongs to the band the Positive or Negative family owns. + * + * A green accent is a Positive color and a red accent is a Negative one, whether + * or not the theme means it that way. Neutral chrome must not borrow either hue, + * or "settings" and "add" are painted the same color. + */ +bool isSemanticHue(int hue); + +/** @brief The hue neutral chrome falls back to when the accent is semantic. */ +int neutralChromeHue(); + +/** + * @brief The harder of two surfaces to keep an ink legible on. + * + * Contrast is monotone in the luminance distance between an ink and its + * surface, so the surface nearest @p ink is the one it reads worst on. Choosing + * that one keeps a single authored ink above the floor on both. + */ +QColor harderSurface(const QColor &first, const QColor &second, const QColor &ink); + +/** + * @brief WCAG relative luminance of an opaque color. + */ +double relativeLuminance(const QColor &color); + +/** + * @brief WCAG contrast ratio between two opaque colors, from 1.0 to 21.0. + */ +double contrastRatio(const QColor &first, const QColor &second); + +/** + * @brief Mixes two colors, @p amount = 0 returning @p color and 1 returning @p target. + * + * RGB interpolation, with no gamma or hue-space correction: the inputs are + * palette colors that already sit in sRGB, and the result has to be predictable + * enough for a theme author to reason about it. + */ +QColor blend(const QColor &color, const QColor &target, double amount); + +/** + * @brief Pushes @p color away from @p background until it reaches @p minRatio. + * + * Moves @p color toward whichever pole, black or white, actually reaches + * @p minRatio against @p background, so the hue survives the adjustment. + * Returns @p color unchanged when it already meets the floor, which is the + * common case for a shipped palette. + * + * @param color Color to adjust. + * @param background Surface the color is drawn on. + * @param minRatio Contrast floor, as a WCAG ratio. + * @return The adjusted color; the nearer pole when even that cannot reach it. + */ +QColor reachContrast(const QColor &color, const QColor &background, double minRatio); + +/** + * @brief Accent share used by production callers of glyphInk(). + * + * Exposed so the preview sheet and the tests name the value they pin instead of + * repeating the literal, and so the default argument below has exactly one + * source: the constant in theme_icon_ink.cpp. + */ +double defaultAccentMix(); + +/** + * @brief Icon ink for a neutral glyph, given the palette it is drawn from. + * + * @param text Neutral text color, normally `QPalette::WindowText`. + * @param surface Surface the glyph is drawn on, normally `QPalette::Window`. + * @param accent The theme accent, normally `QPalette::Highlight`. + * @param accentMix How much of the accent color to borrow. Defaults to + * defaultAccentMix(); previews and tests sweep it to compare + * strengths. Production passes 0 for an unauthored neutral + * family, where the live platform palette is the only source + * that knows the actual scheme. + * @return The ink to resolve `currentColor` to, as an RGB color. + */ +QColor glyphInk(const QColor &text, const QColor &surface, const QColor &accent, double accentMix = defaultAccentMix()); + +/** + * @brief Contrast floor a glyph that labels a control has to clear. + * + * 4.5:1, the Text-level WCAG ratio: an icon that is the only label for a button + * is carrying text, and the icon guidelines in `.opencode/docs/ui-assets.md` + * already ask for that when there is no visible label. + */ +double labelContrastFloor(); + +/** + * @brief Applies an emphasis level to an already-hued glyph color. + * + * The three levels share one legibility floor and differ by chroma, because + * contrast cannot order them: on a dark surface the highest-contrast tone is the + * palest one, so a contrast target inverts the ladder. Primary is the most + * chromatic member of its family, secondary keeps the color untouched (the + * no-op the default needs), and quiet mutes the color to a fraction of its + * saturation before solving it back to the same floor. + * + * @param color Hue-resolved glyph color. + * @param surface Surface the glyph is drawn on. + * @param emphasis How much attention the glyph claims. + * @return The emphasized color, as an RGB color. + */ +QColor forEmphasis(const QColor &color, const QColor &surface, IconEmphasis emphasis); + +/** + * @brief The ink a disabled glyph is painted with, given the quiet level. + * + * A disabled glyph is the quiet ink receded further toward the surface and held + * to a floor below the quiet one. The palette's own disabled text is a fixed + * gray whose contrast varies with the surface, so on the darkest themes it + * out-reads the quiet level and an enabled glyph looks more disabled than a + * disabled one. Deriving the disabled ink from the quiet ink makes that order + * structural instead of incidental. + */ +QColor disabledInk(const QColor &quiet, const QColor &surface); + +/** + * @brief Contrast floor the primary level reaches, so it stays legible. + * + * Primary is ordered by chroma, not contrast, so it shares the text floor + * rather than clearing a higher one. + */ +double primaryContrastFloor(); + +/** @brief Contrast floor the quiet level reaches, shared with the other levels. */ +double quietContrastFloor(); +} // namespace ThemeIconInk + +#endif // THEME_ICON_INK_H diff --git a/cockatrice/src/interface/theme_icon_sticker.cpp b/cockatrice/src/interface/theme_icon_sticker.cpp new file mode 100644 index 000000000..135ba71b8 --- /dev/null +++ b/cockatrice/src/interface/theme_icon_sticker.cpp @@ -0,0 +1,87 @@ +#include "theme_icon_sticker.h" + +#include "libcockatrice/utility/svg_recolor.h" +#include "theme_icon_fill.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +// The pre-#7595 add/remove art filled about 80% of the canvas: the sentinel +// paths are padded inside their 24-unit viewBox, so the drawing box has to be +// slightly larger than the canvas for the ink to reach that far. +constexpr qreal STICKER_SCALE = 1.06; +// Old arm thickness was about a fifth of the canvas, with a rim near 5%. In the +// 24-unit viewBox that is a 4.5-unit band with a 0.9-unit rim on each side. +constexpr qreal STICKER_STROKE_WIDTH = 4.5; +constexpr qreal STICKER_OUTLINE_WIDTH = 0.9; +// The old art's outline was black. Darkening by 300 reads as black on a light +// scheme (the shipped tones are already dark there) while staying a visible dark +// rim on a dark one, where a neutral black would vanish into the window. +constexpr int OUTLINE_DARKER = 300; +} // namespace + +namespace ThemeIconSticker +{ +QColor outlineColor(const QColor &tone) +{ + return tone.darker(OUTLINE_DARKER); +} + +QPixmap renderSticker(const QByteArray &svg, const QColor &tone, const QSize &pixelSize) +{ + if (svg.isEmpty() || !tone.isValid() || pixelSize.isEmpty()) { + return {}; + } + + QPixmap pixmap(pixelSize); + pixmap.fill(Qt::transparent); + + const qreal side = qMin(pixelSize.width(), pixelSize.height()); + const qreal box = side * STICKER_SCALE; + const QRectF glyphRect(QPointF((pixelSize.width() - box) / 2.0, (pixelSize.height() - box) / 2.0), + QSizeF(box, box)); + + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + + // Outline first, at the band width plus one rim on each side; the fill pass + // then covers the middle and leaves only the rim visible. + const QByteArray outlineSvg = + resolveSvgStrokeWidth(resolveSvgColorSentinel(svg, outlineColor(tone).name(QColor::HexRgb).toLatin1()), + STICKER_STROKE_WIDTH + 2.0 * STICKER_OUTLINE_WIDTH); + QSvgRenderer outlineRenderer(outlineSvg); + if (outlineRenderer.isValid()) { + outlineRenderer.render(&painter, glyphRect); + } + + // The fill needs its own pass so the gradient can be clipped to the glyph: + // painting it onto the composited pixmap would tint the outline as well. + QPixmap fillPixmap(pixelSize); + fillPixmap.fill(Qt::transparent); + { + QPainter fillPainter(&fillPixmap); + fillPainter.setRenderHint(QPainter::Antialiasing, true); + + const QByteArray fillSvg = resolveSvgStrokeWidth( + resolveSvgColorSentinel(svg, tone.name(QColor::HexRgb).toLatin1()), STICKER_STROKE_WIDTH); + QSvgRenderer fillRenderer(fillSvg); + if (fillRenderer.isValid()) { + fillRenderer.render(&fillPainter, glyphRect); + fillPainter.setCompositionMode(QPainter::CompositionMode_SourceIn); + QLinearGradient gradient(glyphRect.topLeft(), glyphRect.bottomLeft()); + gradient.setColorAt(0.0, ThemeIconFill::topColor(tone)); + gradient.setColorAt(1.0, ThemeIconFill::bottomColor(tone)); + fillPainter.fillRect(QRectF(QPointF(0, 0), QSizeF(pixelSize)), gradient); + } + } + painter.drawPixmap(0, 0, fillPixmap); + + return pixmap; +} +} // namespace ThemeIconSticker diff --git a/cockatrice/src/interface/theme_icon_sticker.h b/cockatrice/src/interface/theme_icon_sticker.h new file mode 100644 index 000000000..d9e5ec3ba --- /dev/null +++ b/cockatrice/src/interface/theme_icon_sticker.h @@ -0,0 +1,61 @@ +/** + * @file theme_icon_sticker.h + * @ingroup Interface + */ + +#ifndef THEME_ICON_STICKER_H +#define THEME_ICON_STICKER_H + +#include +#include +#include +#include + +/** + * @brief Paints the sticker treatment of a `currentColor` glyph. + * + * The pre-#7595 add, remove and delete icons were filled shapes with a black + * outline and no disc. A tint alone cannot reproduce that: it produces a + * hairline, and a badge disc is a different icon language. The sticker + * treatment rebuilds the look from the same sentinel art by drawing the glyph + * twice, once with a dark outline and once with a vertical tone gradient on top. + * + * A sticker is louder than a line, which suits an add or a destructive action. + * It needs a size where the tone band is at least two device pixels wide. + * Callers opt in. Navigation and menus keep the line treatment. + * + * Everything here works in device pixels, like the badge treatment: the engine + * renders at the size it already computed and attaches the device pixel ratio + * afterwards. + */ +namespace ThemeIconSticker +{ +/** + * @brief Returns the dark rim color the sticker is painted with. + * + * The old art had a black outline. Deriving it from the tone reads as black on a + * light scheme without a literal, and stays visible on a dark one, where a pure + * black rim would disappear into the window. + * + * @param tone Glyph color the sticker is rendered with. + * @return A much darker version of the tone. + */ +QColor outlineColor(const QColor &tone); + +/** + * @brief Renders a sentinel-carrying SVG as a filled glyph with a black outline. + * + * The glyph is drawn larger than the canvas so the padded sentinel paths fill + * the icon the way the old art did, with a vertical tone gradient as the fill + * and a near-black rim. Authored art is never drawn through this path. + * + * @param svg Sentinel-carrying SVG source; `currentColor` is resolved internally. + * @param tone Glyph color, already resolved for the icon mode. + * @param pixelSize Canvas size in device pixels; a non-square canvas centers + * the glyph and leaves the remaining area transparent. + * @return The composited sticker, or a null pixmap for empty or invalid input. + */ +QPixmap renderSticker(const QByteArray &svg, const QColor &tone, const QSize &pixelSize); +} // namespace ThemeIconSticker + +#endif // THEME_ICON_STICKER_H diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index 893cdc795..3a2ecf9cf 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -3,6 +3,7 @@ #include "../../client/settings/cache_settings.h" #include "pixel_map_generator.h" #include "theme_config.h" +#include "theme_icon_ink.h" #include #include @@ -12,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -127,8 +129,10 @@ static QString usableDefaultStyle(const QString &style) ThemeManager::ThemeManager(QObject *parent) : QObject(parent) { defaultStyleName = usableDefaultStyle(qApp->style()->objectName()); - // Capture the untouched application palette before any theme is applied. + // Capture the untouched application palette and font before any theme is + // applied. defaultPalette = qApp->palette(); + defaultFont = qApp->font(); ensureThemeDirectoryExists(); #if (QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)) connect(QGuiApplication::styleHints(), &QStyleHints::colorSchemeChanged, this, [this] { @@ -440,6 +444,46 @@ PaletteConfig ThemeManager::loadDefaultPaletteConfig(const QString &themeDirPath return cfg; } +FontConfig ThemeManager::loadFontConfig(const QString &themeDirPath) +{ + if (themeDirPath.isEmpty()) { + return {}; + } + return FontConfig::fromThemeDir(themeDirPath); +} + +FontConfig ThemeManager::loadDefaultFontConfig(const QString &themeDirPath, const QString &themeName) +{ + FontConfig cfg = FontConfig::fromThemeDir(themeDirPath); + if (!cfg.hasFonts()) { + // The shipped fonts.toml may live in the system theme directory rather + // than the resolved (user) theme directory, so built-in themes still + // get their curated fonts. + cfg = FontConfig::fromThemeDir(QDir(systemThemesBasePath()).absoluteFilePath(themeName)); + } + return cfg; +} + +bool ThemeManager::saveFontConfig(const QString &themeDirPath, const FontConfig &cfg) +{ + if (themeDirPath.isEmpty()) { + return false; + } + + QDir dir(themeDirPath); + if (!dir.exists()) { + dir.mkpath("."); + } + + QFile f(dir.absoluteFilePath(FontConfig::fileName())); + if (!f.open(QIODevice::WriteOnly | QIODevice::Text | QIODevice::Truncate)) { + return false; + } + + QTextStream(&f) << cfg.toToml(); + return true; +} + bool ThemeManager::commitPalette(const QString &themeDirPath, const QString &colorScheme, const PaletteConfig &cfg) { if (!savePaletteConfig(themeDirPath, colorScheme, cfg)) { @@ -488,6 +532,24 @@ void ThemeManager::previewPalette(const PaletteConfig &cfg, const QString &schem applyStyleAndPalette(themeName, themeCfg, cfg, scheme); } +void ThemeManager::previewFonts(const FontConfig &cfg) +{ + currentFonts = cfg; + + // QApplication::setFont already sends a font change to every widget, so a + // repolish pass is not needed for fonts (unlike the palette path). + const auto it = cfg.fonts.constFind(AppFont::InterfaceDefault); + if (it != cfg.fonts.constEnd()) { + qApp->setFont(it.value()); + } else { + // A config without InterfaceDefault means "no override": restore the + // startup font instead of leaving the last preview applied. + qApp->setFont(defaultFont); + } + + emit fontsChanged(); +} + void ThemeManager::applyStyleAndPalette(const QString &themeName, const ThemeConfig &themeCfg, const PaletteConfig &palCfg, @@ -578,19 +640,119 @@ QColor ThemeManager::appColor(AppColor::Role role) const // unconditionally keeps every Qt version rendering identically. const QColor accent = qApp->palette().color(QPalette::Active, QPalette::Highlight); - if (role == AppColor::AccentSoft) { - constexpr int SOFT_SATURATION_PERCENT = 70; - constexpr int SOFT_LIGHTNESS_OFFSET = 60; + switch (role) { + case AppColor::AccentStrong: + return accent; + case AppColor::AccentSoft: { + constexpr int SOFT_SATURATION_PERCENT = 70; + constexpr int SOFT_LIGHTNESS_OFFSET = 60; - // Light end of the gradient: same hue, softened and lightened - return QColor::fromHsl(qMax(0, accent.hslHue()), - qBound(0, qRound(accent.hslSaturation() * SOFT_SATURATION_PERCENT / 100.0), 255), - qBound(0, accent.lightness() + SOFT_LIGHTNESS_OFFSET, 255)); + // Light end of the gradient: same hue, softened and lightened + return QColor::fromHsl(qMax(0, accent.hslHue()), + qBound(0, qRound(accent.hslSaturation() * SOFT_SATURATION_PERCENT / 100.0), 255), + qBound(0, accent.lightness() + SOFT_LIGHTNESS_OFFSET, 255)); + } + case AppColor::Icon: + case AppColor::IconStrong: + case AppColor::IconSoft: { + // Legacy fallback for a theme written before the icon roles + // existed, or one that deliberately leaves the neutral family + // unauthored (System). No accent chroma is borrowed here: the + // palette file that would carry it cannot know the platform scheme, + // so neutral ink is the live WindowText held to the label floor. + // The three levels are the ink rule this series shipped, applied + // once here so authored roles never get recomputed at paint time. + const QPalette &palette = qApp->palette(); + const QColor text = palette.color(QPalette::Active, QPalette::WindowText); + const QColor surface = ThemeIconInk::harderSurface(palette.color(QPalette::Active, QPalette::Window), + palette.color(QPalette::Active, QPalette::Button), text); + const QColor ink = ThemeIconInk::glyphInk(text, surface, accent, 0.0); + switch (role) { + case AppColor::IconStrong: + return ThemeIconInk::forEmphasis(ink, surface, IconEmphasis::Primary); + case AppColor::IconSoft: + return ThemeIconInk::forEmphasis(ink, surface, IconEmphasis::Quiet); + case AppColor::Icon: + default: + return ink; + } + } + case AppColor::Positive: + case AppColor::PositiveStrong: + case AppColor::PositiveSoft: + case AppColor::Negative: + case AppColor::NegativeStrong: + case AppColor::NegativeSoft: + case AppColor::Informational: + case AppColor::InformationalStrong: + case AppColor::InformationalSoft: + case AppColor::Hidden: + case AppColor::HiddenStrong: + case AppColor::HiddenSoft: + case AppColor::Marked: + case AppColor::MarkedStrong: + case AppColor::MarkedSoft: + case AppColor::Movement: + case AppColor::MovementStrong: + case AppColor::MovementSoft: + // Semantic colors are independent of the accent: a missing role must + // not silently paint "remove" in the theme's highlight color. + return defaultAppColor(role, isDarkModeActive()); } return accent; } +QFont ThemeManager::appFont(AppFont::Role role) const +{ + const auto it = currentFonts.fonts.constFind(role); + if (it != currentFonts.fonts.constEnd()) { + return it.value(); + } + + // Role fallbacks preserve the pre-theme defaults when no fonts.toml is + // present: an unset role resolves to the application font. Surfaces that + // hardcoded a different family ask for it explicitly: counter painters + // through cardTextFont(), the deck view headers and printed deck list + // through deckListFont(), the report dialogs through + // report_utils::reportLogFont(). Only the family basis is fixed there; + // sizes stay caller-side. + return qApp->font(); +} + +bool ThemeManager::hasAppFont(AppFont::Role role) const +{ + return currentFonts.fonts.contains(role); +} + +QFont ThemeManager::cardTextFont() const +{ + if (hasAppFont(AppFont::CardText)) { + return appFont(AppFont::CardText); + } + QFont serifFont(QStringLiteral("Serif")); + serifFont.setStyleHint(QFont::Serif); + return serifFont; +} + +QFont ThemeManager::deckListFont() const +{ + if (hasAppFont(AppFont::DeckList)) { + return appFont(AppFont::DeckList); + } + QFont serifFont(QStringLiteral("Serif")); + serifFont.setStyleHint(QFont::Serif); + return serifFont; +} + +QFont ThemeManager::defaultAppFont(AppFont::Role /*role*/) const +{ + // No per-role scaling and no preview: an unset role resolves to the font + // captured at startup, which is what the theme falls back to once the + // editor closes. + return defaultFont; +} + void ThemeManager::themeChangedSlot() { QString themeName = SettingsCache::instance().getThemeName(); @@ -642,6 +804,19 @@ void ThemeManager::themeChangedSlot() applyStyleAndPalette(themeName, themeCfg, palette, activeScheme); + // ── Load fonts: shared per theme, no light/dark split ─────────────────── + FontConfig fonts = loadDefaultFontConfig(dirPath, themeName); + currentFonts = fonts; + const auto fontIt = currentFonts.fonts.constFind(AppFont::InterfaceDefault); + if (fontIt != currentFonts.fonts.constEnd()) { + qApp->setFont(fontIt.value()); + } else { + // No theme font: restore the pristine application font captured at + // startup, so a reverted preview cannot leak into the live theme. + qApp->setFont(defaultFont); + } + emit fontsChanged(); + QStringList resources; if (!dirPath.isEmpty()) { resources << dir.absolutePath(); diff --git a/cockatrice/src/interface/theme_manager.h b/cockatrice/src/interface/theme_manager.h index c1ab65685..3bcfd7b3c 100644 --- a/cockatrice/src/interface/theme_manager.h +++ b/cockatrice/src/interface/theme_manager.h @@ -11,6 +11,7 @@ #include #include +#include #include #include #include @@ -47,10 +48,15 @@ private: // palette is applied. Used as the base when a theme supplies no palette, so // switching away from a custom palette restores the original colours. QPalette defaultPalette; + // Pristine application font captured at startup, before any theme font is + // applied. Restored when a theme (or a preview) supplies no InterfaceDefault + // font, so switching away from a themed font cannot leave it behind. + QFont defaultFont; QString currentThemePath; std::array brushes; QStringMap availableThemes; QMap currentAppColors; + FontConfig currentFonts; /* Internal cache for multiple backgrounds */ @@ -105,6 +111,9 @@ public: // Load/save per-scheme palette colors static PaletteConfig loadPaletteConfig(const QString &themeDirPath, const QString &colorScheme); static bool savePaletteConfig(const QString &themeDirPath, const QString &colorScheme, const PaletteConfig &cfg); + // Load/save shared per-theme fonts (fonts.toml, not split by scheme) + static FontConfig loadFontConfig(const QString &themeDirPath); + static bool saveFontConfig(const QString &themeDirPath, const FontConfig &cfg); // Resolve prefix to a scheme-qualified "theme:" path. Existence is probed // internally across the formats themes may ship (.png/.jpg/.svg), so // callers load the returned path directly. Prefers "-" @@ -123,6 +132,9 @@ public: // theme directory when it is absent from the resolved (user) directory. static PaletteConfig loadDefaultPaletteConfig(const QString &themeDirPath, const QString &themeName, const QString &colorScheme); + // Load the theme's shipped default fonts, falling back to the system theme + // directory when fonts.toml is absent from the resolved (user) directory. + static FontConfig loadDefaultFontConfig(const QString &themeDirPath, const QString &themeName); /** @brief Writes cfg to disk as the theme's palette-.toml and updates the * theme's stored colour scheme to match. Shared by PaletteEditorDialog::onSave * and FirstRunWizard's theme step so the two "generate + keep" paths can't drift. */ @@ -132,10 +144,32 @@ public: void reloadCurrentTheme(); void previewPalette(const PaletteConfig &cfg, const QString &scheme); + void previewFonts(const FontConfig &cfg); // Resolves an application color role: the theme's stored [AppColors] value - // when present, otherwise a palette-accent-derived fallback. + // when present, otherwise a palette-accent-derived fallback for accents and + // a scheme-appropriate semantic default for positive / negative. QColor appColor(AppColor::Role role) const; + /** @brief Resolves an application font role: the theme's stored [Fonts] + * value when present, otherwise the application font. Roles that + * had a historical hardcoded family ask for it through + * cardTextFont() and deckListFont() instead. */ + QFont appFont(AppFont::Role role) const; + /** @brief Whether the active theme defines this role in fonts.toml. */ + bool hasAppFont(AppFont::Role role) const; + /** @brief The CardText role with its historical Serif fallback. Used by the + * counter painters, which were Serif before the role existed. */ + QFont cardTextFont() const; + /** @brief The DeckList role with its historical Serif fallback. Used by the + * two surfaces that hardcoded Serif before the role existed (the + * deck view zone headers and the printed deck list); every other + * DeckList reader falls back to the application font. */ + QFont deckListFont() const; + /** @brief Resolves an application font role against the startup font only, + * ignoring any previewed config in currentFonts. Editors that show + * "what this role resolves to" must use this, because appFont() + * reflects a live preview while the editor is open. */ + QFont defaultAppFont(AppFont::Role role) const; QBrush &getBgBrush(Role zone); QBrush getExtraBgBrush(Role zone, int zoneId = 0); @@ -144,6 +178,7 @@ protected slots: signals: void themeChanged(); void paletteChanged(); + void fontsChanged(); }; extern ThemeManager *themeManager; diff --git a/cockatrice/src/interface/widgets/cards/art_crop_attribution.cpp b/cockatrice/src/interface/widgets/cards/art_crop_attribution.cpp index c4b347047..8475a0ed9 100644 --- a/cockatrice/src/interface/widgets/cards/art_crop_attribution.cpp +++ b/cockatrice/src/interface/widgets/cards/art_crop_attribution.cpp @@ -1,5 +1,6 @@ #include "art_crop_attribution.h" +#include "../../theme_manager.h" #include "libcockatrice/card/printing/printing_info.h" #include @@ -35,7 +36,10 @@ QRectF paintArtAttribution(QPainter &painter, painter.save(); + // Family comes from the CardText theme role; the incoming painter size + // stays the basis for the scale factor exactly as before. QFont font = painter.font(); + font.setFamily(themeManager->appFont(AppFont::CardText).family()); font.setPointSizeF(qMax(6.0, font.pointSizeF() * scale)); painter.setFont(font); diff --git a/cockatrice/src/interface/widgets/cards/card_art_utils.cpp b/cockatrice/src/interface/widgets/cards/card_art_utils.cpp index ebf79282e..a7e633ad2 100644 --- a/cockatrice/src/interface/widgets/cards/card_art_utils.cpp +++ b/cockatrice/src/interface/widgets/cards/card_art_utils.cpp @@ -2,6 +2,8 @@ #include "libcockatrice/card/card_info.h" +#include +#include #include #include #include @@ -18,4 +20,23 @@ QPixmap rotateSidewaysLayoutArt(const QPixmap &art, const ExactCard &card) transform.rotate(90); return art.transformed(transform, Qt::SmoothTransformation); } -} // namespace CardArtUtils \ No newline at end of file + +QPixmap cropCardArt(const QPixmap &art, const ExactCard &card) +{ + if (art.isNull()) { + return {}; + } + + const QPixmap source = rotateSidewaysLayoutArt(art, card); + const bool landscape = card.getInfo().getUiAttributes().landscapeOrientation; + const QSize size = source.size(); + const int marginX = size.width() * 0.07; + // Landscape name boxes extend below 5% of the upright image. Remove the + // whole box here, rather than relying on the banner's narrow viewport to + // hide it: the same saved offset is also used by the taller profile popup. + const int topMargin = size.height() * (landscape ? 0.15 : 0.11); + const int bottomMargin = size.height() * 0.45; + const QRect crop(marginX, topMargin, size.width() - 2 * marginX, size.height() - topMargin - bottomMargin); + return source.copy(crop.intersected(source.rect())); +} +} // namespace CardArtUtils diff --git a/cockatrice/src/interface/widgets/cards/card_art_utils.h b/cockatrice/src/interface/widgets/cards/card_art_utils.h index 5c331a12c..ac3b82380 100644 --- a/cockatrice/src/interface/widgets/cards/card_art_utils.h +++ b/cockatrice/src/interface/widgets/cards/card_art_utils.h @@ -20,6 +20,13 @@ namespace CardArtUtils * @return @p art rotated 90° clockwise for sideways-layout cards, else @p art. */ QPixmap rotateSidewaysLayoutArt(const QPixmap &art, const ExactCard &card); + +/** + * Return the upright artwork used by banner previews, user rows and profile + * popups. The crop excludes conventional name and rules boxes before callers apply + * their saved pan and zoom, so a taller popup cannot expose the card frame. + */ +QPixmap cropCardArt(const QPixmap &art, const ExactCard &card); } // namespace CardArtUtils -#endif // CARD_ART_UTILS_H \ No newline at end of file +#endif // CARD_ART_UTILS_H diff --git a/cockatrice/src/interface/widgets/cards/card_hover_widget.cpp b/cockatrice/src/interface/widgets/cards/card_hover_widget.cpp new file mode 100644 index 000000000..27625db52 --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/card_hover_widget.cpp @@ -0,0 +1,1736 @@ +#include "card_hover_widget.h" + +#include "../../../client/settings/cache_settings.h" +#include "../../../client/settings/card_counter_settings.h" +#include "../../../game_graphics/board/card_item.h" +#include "../../../game_graphics/game_scene.h" +#include "../../../game_graphics/player/player_graphics_item.h" +#include "../../theme_icon.h" +#include "card_info_picture_widget.h" +#include "card_info_text_widget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static constexpr int CARD_IMAGE_WIDTH = 270; +static constexpr int TAB_MIN_WIDTH = 250; +static constexpr int TAB_MAX_WIDTH = 300; +static constexpr int TAB_MIN_HEIGHT = 360; +static constexpr int NAV_BUTTON_WIDTH = 78; +static constexpr int NAV_ICON_SIZE = 24; +static constexpr int POPUP_GAP = 6; +static constexpr int FADE_DURATION_MS = 140; +static constexpr int REFRESH_DEBOUNCE_MS = 30; +static constexpr int OVERLAY_BTN_SIZE = 24; +static constexpr int OVERLAY_CORNER_RADIUS = 4; + +// P/T pill: centred on the card's printed P/T box, in fractions of the picture rect. +static constexpr double PT_PILL_ANCHOR_X = 0.92; +static constexpr double PT_PILL_ANCHOR_Y = 0.94; +static constexpr int PT_PILL_EDGE_INSET = 2; +static constexpr int PT_PILL_MIN_WIDTH = 48; +static constexpr int PT_PILL_MIN_HEIGHT = 22; +static constexpr int PT_PILL_TEXT_PADDING = 4; + +// Hover trigger: the bottom-right quadrant of the art, around the printed P/T box. +static constexpr double PT_HOVER_REGION_WIDTH = 0.35; +static constexpr double PT_HOVER_REGION_HEIGHT = 0.20; +// Halo around the resting pill that still counts as "on the P/T box" for opening the rows. +static constexpr int PT_OPEN_HALO = 4; + +enum HoverPanelPage +{ + PAGE_INFO, + PAGE_MAIN, + PAGE_MOVE, + PAGE_COUNTER, + PAGE_TOKEN +}; + +// The action grid is what the panel exists for, so it carries the primary ink; +// the category tabs only navigate between pages and stay quiet. Both use the +// line glyph: the filled treatments read as a second state, and every cell in +// the grid would carry one. +static QIcon categoryTabIcon(const QString &prefix) +{ + return loadThemedIcon(prefix, IconTint::quiet()); +} + +// Every action carries a hue naming a game fact the glyph cannot: a gain, a +// loss, information, hidden information, a mark on the card or a move. Keeping +// one line glyph per action means a row of different families still reads as a +// single grid instead of a set of buttons from different toolbars. +static QIcon semanticActionIcon(const QString &prefix, AppColor::Role role) +{ + return loadThemedIcon(prefix, IconTint(role, IconEmphasis::Primary)); +} + +CardHoverWidget::CardHoverWidget(QWidget *parent) : QFrame(parent), currentCard(nullptr), currentPlayer(nullptr) +{ + setObjectName("cardHoverWidget"); + setFrameShape(QFrame::StyledPanel); + setWindowFlags(Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint | Qt::ToolTip); + setAttribute(Qt::WA_DeleteOnClose, false); + setAttribute(Qt::WA_ShowWithoutActivating, true); + setMouseTracking(true); + setContentsMargins(0, 0, 0, 0); + + applyThemeStyle(); + + setWindowOpacity(0.0); + + fadeIn = new QPropertyAnimation(this, "windowOpacity", this); + fadeIn->setDuration(FADE_DURATION_MS); + fadeIn->setStartValue(0.0); + fadeIn->setEndValue(1.0); + fadeIn->setEasingCurve(QEasingCurve::OutCubic); + + fadeOut = new QPropertyAnimation(this, "windowOpacity", this); + fadeOut->setDuration(FADE_DURATION_MS); + fadeOut->setStartValue(1.0); + fadeOut->setEndValue(0.0); + fadeOut->setEasingCurve(QEasingCurve::OutCubic); + connect(fadeOut, &QPropertyAnimation::finished, this, &QWidget::hide); + + refreshTimer = new QTimer(this); + refreshTimer->setSingleShot(true); + refreshTimer->setInterval(REFRESH_DEBOUNCE_MS); + connect(refreshTimer, &QTimer::timeout, this, &CardHoverWidget::onCardChanged); + + createImageArea(); + createContentStack(); + createNavBar(); + + mainLayout = new QHBoxLayout(this); + mainLayout->setContentsMargins(8, 8, 8, 8); + mainLayout->setSpacing(8); + mainLayout->addWidget(imageContainer, 0); + mainLayout->addWidget(sideBar, 0); + mainLayout->addWidget(contentStack, 1); + setLayout(mainLayout); + + showPage(PAGE_INFO); +} + +void CardHoverWidget::applyThemeStyle() +{ + const QPalette &pal = QApplication::palette(); + const QString window = pal.color(QPalette::Window).name(); + const QString windowText = pal.color(QPalette::WindowText).name(); + const QString base = pal.color(QPalette::Base).name(); + const QString text = pal.color(QPalette::Text).name(); + const QString mid = pal.color(QPalette::Mid).name(); + const QString highlight = pal.color(QPalette::Highlight).name(); + const QString highlightedText = pal.color(QPalette::HighlightedText).name(); + const QString disabled = pal.color(QPalette::PlaceholderText).name(); + + // Opaque theme colors throughout — translucency proved too hard to read. + // The dark scrims over the card artwork itself are kept, they sit on the image, not the panel. + // Guard: setStyleSheet() itself emits PaletteChange, which would otherwise + // re-enter applyThemeStyle() via changeEvent() forever (freeze, then stack overflow). + QString sheet = QStringLiteral("QFrame#cardHoverWidget {" + " background-color: %1;" + " border: 1px solid %2;" + " border-radius: 10px;" + "}" + "QScrollArea { background: transparent; border: none; }" + "QToolButton#cardHoverNavBtn {" + " background: transparent; color: %4;" + " border: 1px solid transparent; border-radius: 8px;" + " padding: 7px 4px; font-size: 11px;" + "}" + "QToolButton#cardHoverNavBtn:hover {" + " background: %3; border-color: %2;" + "}" + "QToolButton#cardHoverNavBtn:checked {" + " background: %5; color: %6; border-color: %5;" + "}" + "QLabel#cardHoverName {" + " color: %8; font-weight: bold; font-size: 12px;" + "}" + "QPushButton#cardHoverActionBtn {" + " background: %3; color: %4;" + " border: 1px solid %2; border-radius: 6px;" + " padding: 5px 10px; font-size: 12px; text-align: center;" + "}" + "QPushButton#cardHoverActionBtn:hover {" + " background: %5; color: %6; border-color: %5;" + "}" + "QPushButton#cardHoverActionBtn:pressed {" + " background: %5; color: %6;" + "}" + "QPushButton#cardHoverActionBtn:checked { background: %5; color: %6; }" + "QPushButton#cardHoverActionBtn:disabled { color: %7; }" + "QToolButton#cardHoverGridBtn {" + " background: %3; color: %4;" + " border: 1px solid %2; border-radius: 6px;" + " padding: 6px 4px; font-size: 11px;" + "}" + "QToolButton#cardHoverGridBtn:hover {" + " background: %5; color: %6; border-color: %5;" + "}" + "QToolButton#cardHoverGridBtn:pressed {" + " background: %5; color: %6;" + "}" + "QToolButton#cardHoverGridBtn:checked { background: %5; color: %6; }" + "QToolButton#cardHoverGridBtn:disabled { color: %7; }" + "QLabel#cardHoverSectionHeader {" + " background: %2; color: %8;" + " border-radius: 4px; padding: 3px 6px;" + " font-weight: bold; font-size: 11px;" + "}" + "QLabel#cardHoverNote { color: %7; font-style: italic; font-size: 11px; }" + "QLineEdit#cardHoverNameEdit {" + " color: %4; background: %3; border: 1px solid %2;" + " border-radius: 3px; padding: 2px;" + " font-weight: bold; font-size: 13px;" + "}" + // Accent edge on the resting P/T pill: the cue that the + // control rows live behind it, and the divider once they lift out. + "QLabel#cardHoverPTPill {" + " border-top: 2px solid %5;" + "}") + .arg(window, mid, base, text, highlight, highlightedText, disabled, windowText); + if (sheet == styleSheet()) { + return; + } + setStyleSheet(sheet); +} + +// ---------- Overlay styling (dark scrims over the card artwork) ---------- + +static QString overlayBgStyle() +{ + return QStringLiteral("background-color: rgba(0, 0, 0, 180);" + "border-radius: %1px;") + .arg(OVERLAY_CORNER_RADIUS); +} + +/** @brief Clamps @p start so that a run of @p size stays within [@p origin, @p origin + @p extent]. */ +static int clampToSpan(int origin, int extent, int size, int start) +{ + if (size >= extent) { + return origin; + } + return qBound(origin, start, origin + extent - size); +} + +// ---------- Image area with overlaid controls ---------- + +void CardHoverWidget::createImageArea() +{ + imageContainer = new QWidget(this); + imageContainer->setObjectName("cardHoverImageContainer"); + imageContainer->setMouseTracking(true); + imageContainer->setFixedWidth(CARD_IMAGE_WIDTH); + + pic = new CardInfoPictureWidget(imageContainer); + pic->setObjectName("cardHoverPic"); + pic->setMouseTracking(true); + pic->setHoverToZoomEnabled(false); + pic->setScaleFactor(135); // full-picture expanded size (~270px wide) + + auto *picLayout = new QVBoxLayout(imageContainer); + picLayout->setContentsMargins(0, 0, 0, 0); + picLayout->setSpacing(0); + picLayout->addWidget(pic); + imageContainer->setLayout(picLayout); + + createNameBarOverlay(); + createPTOverlay(); + + pic->installEventFilter(this); + // The artwork rect is only known after the pixmap is drawn (letterboxing, sideways-card + // rotation), so re-anchor the overlays once it lands. + connect(pic, &CardInfoPictureWidget::artRectChanged, this, &CardHoverWidget::positionOverlays, + Qt::QueuedConnection); +} + +// ---------- Name bar overlay (top of card) ---------- + +void CardHoverWidget::createNameBarOverlay() +{ + nameBarOverlay = new QWidget(imageContainer); + nameBarOverlay->setObjectName("cardHoverNameBar"); + nameBarOverlay->setMouseTracking(true); + nameBarOverlay->setStyleSheet(overlayBgStyle()); + + nameLabel = new QLabel(nameBarOverlay); + nameLabel->setObjectName("cardHoverNameLabel"); + nameLabel->setAlignment(Qt::AlignCenter); + nameLabel->setStyleSheet( + "color: white; font-weight: bold; font-size: 13px; background: transparent; padding: 2px;"); + nameLabel->setWordWrap(false); + + nameEdit = new QLineEdit(nameBarOverlay); + nameEdit->setObjectName("cardHoverNameEdit"); + nameEdit->setAlignment(Qt::AlignCenter); + nameEdit->setFixedHeight(22); + nameEdit->setPlaceholderText(tr("Set annotation...")); + nameEdit->setVisible(false); + connect(nameEdit, &QLineEdit::editingFinished, this, [this]() { + if (!nameEdit->isVisible()) { + return; + } + if (canModifyCurrentCard()) { + if (auto *actions = currentCard->getOwner()->getPlayerActions()) { + QList cardList{currentCard->getState()}; + actions->actSetAnnotation(cardList, nameEdit->text()); + } + } + nameEdit->setVisible(false); + nameLabel->setVisible(true); + refreshDisplay(); + }); + + annotationLabel = new QLabel(nameBarOverlay); + annotationLabel->setObjectName("cardHoverAnnotationLabel"); + annotationLabel->setAlignment(Qt::AlignCenter); + annotationLabel->setStyleSheet( + "color: #dddddd; font-style: italic; font-size: 11px; background: transparent; padding: 1px;"); + annotationLabel->setWordWrap(false); + annotationLabel->setVisible(false); + + auto *nameLayout = new QVBoxLayout(nameBarOverlay); + nameLayout->setContentsMargins(6, 3, 6, 3); + nameLayout->setSpacing(1); + nameLayout->addWidget(nameLabel); + nameLayout->addWidget(nameEdit); + nameLayout->addWidget(annotationLabel); + nameBarOverlay->setLayout(nameLayout); + + nameLabel->installEventFilter(this); +} + +// ---------- P/T overlay (bottom-right of card): pill at rest, controls on hover ---------- + +void CardHoverWidget::createPTOverlay() +{ + ptOverlay = new QWidget(imageContainer); + ptOverlay->setObjectName("cardHoverPTOverlay"); + ptOverlay->setMouseTracking(true); + ptOverlay->setStyleSheet(overlayBgStyle()); + + auto getActions = [this]() -> PlayerActions * { + if (currentCard && currentCard->getOwner()) { + return currentCard->getOwner()->getPlayerActions(); + } + return nullptr; + }; + auto cardList = [this]() -> QList { + return currentCard && currentCard->getState() ? QList{currentCard->getState()} + : QList(); + }; + + // Control rows: hidden at rest, shown while the P/T area is hovered. + ptControls = new QWidget(ptOverlay); + ptControls->setObjectName("cardHoverPTControls"); + ptControls->setMouseTracking(true); + ptControls->setStyleSheet(QStringLiteral("background: transparent;")); + + // Value pill: the resting state, and the bottom (never-moving) row of the stack. + // The accent top edge is the only cue that controls sit behind the pill; it doubles + // as the divider between the pill and the rows once they are lifted out. + ptPillLabel = new QLabel(ptOverlay); + ptPillLabel->setObjectName("cardHoverPTPill"); + ptPillLabel->setAlignment(Qt::AlignCenter); + // The accent top edge (the only cue that controls sit behind the pill) comes from the + // palette-driven sheet in applyThemeStyle(), so it follows theme switches. + ptPillLabel->setStyleSheet(QStringLiteral("color: white; font-weight: bold; font-size: 12px;" + " background: transparent; padding: 0 %1px;") + .arg(PT_PILL_TEXT_PADDING)); + ptPillLabel->setMinimumHeight(PT_PILL_MIN_HEIGHT); + // Seed text: the resting size has to exist before the first card is shown. + ptPillLabel->setText(QStringLiteral("00/00")); + + ptPowerLabel = new QLabel(ptControls); + ptPowerLabel->setObjectName("cardHoverPTPowerValue"); + ptPowerLabel->setAlignment(Qt::AlignCenter); + ptPowerLabel->setStyleSheet( + "color: white; font-weight: bold; font-size: 12px; background: transparent; padding: 0 2px;"); + ptPowerLabel->setMinimumWidth(28); + + ptToughLabel = new QLabel(ptControls); + ptToughLabel->setObjectName("cardHoverPTToughValue"); + ptToughLabel->setAlignment(Qt::AlignCenter); + ptToughLabel->setStyleSheet( + "color: white; font-weight: bold; font-size: 12px; background: transparent; padding: 0 2px;"); + ptToughLabel->setMinimumWidth(28); + + btnDecP = createOverlayButton("-P", tr("Decrease power"), [getActions, cardList]() { + if (auto *a = getActions()) { + a->actDecP(cardList()); + } + }); + btnIncP = createOverlayButton("+P", tr("Increase power"), [getActions, cardList]() { + if (auto *a = getActions()) { + a->actIncP(cardList()); + } + }); + btnDecT = createOverlayButton("-T", tr("Decrease toughness"), [getActions, cardList]() { + if (auto *a = getActions()) { + a->actDecT(cardList()); + } + }); + btnIncT = createOverlayButton("+T", tr("Increase toughness"), [getActions, cardList]() { + if (auto *a = getActions()) { + a->actIncT(cardList()); + } + }); + btnSetPT = createOverlayButton("SET", tr("Set power/toughness..."), [this, getActions, cardList]() { + runModalAction([getActions, cardList]() { + if (auto *a = getActions()) { + a->actRequestSetPTDialog(cardList()); + } + }); + }); + btnResetPT = createOverlayButton("RST", tr("Reset power/toughness"), [getActions, cardList]() { + if (auto *a = getActions()) { + a->actResetPT(cardList()); + } + }); + + auto *powerRow = new QHBoxLayout(); + powerRow->setSpacing(2); + powerRow->addWidget(btnDecP); + powerRow->addWidget(ptPowerLabel); + powerRow->addWidget(btnIncP); + + auto *toughRow = new QHBoxLayout(); + toughRow->setSpacing(2); + toughRow->addWidget(btnDecT); + toughRow->addWidget(ptToughLabel); + toughRow->addWidget(btnIncT); + + auto *btnRow = new QHBoxLayout(); + btnRow->setSpacing(2); + btnRow->addWidget(btnSetPT); + btnRow->addWidget(btnResetPT); + + auto *controlsLayout = new QVBoxLayout(ptControls); + controlsLayout->setContentsMargins(4, 3, 4, 3); + controlsLayout->setSpacing(2); + controlsLayout->addLayout(powerRow); + controlsLayout->addLayout(toughRow); + controlsLayout->addLayout(btnRow); + ptControls->setLayout(controlsLayout); + + auto *ptLayout = new QVBoxLayout(ptOverlay); + ptLayout->setContentsMargins(0, 0, 0, 0); + ptLayout->setSpacing(2); + ptLayout->addWidget(ptControls); + ptLayout->addWidget(ptPillLabel); + ptOverlay->setLayout(ptLayout); + + // Both states are driven from sizes cached here: hiding the controls would shrink + // layout()->sizeHint(), so the two states must not be measured from the live layout. + ptFullSize = ptLayout->sizeHint(); + ptControls->setVisible(false); + ptCompactSize = measureCompactPTSize(); + ptPillLabel->clear(); + ptOverlay->resize(ptCompactSize); + + // The whole subtree feeds the hover state: moving onto a button must not read as a leave. + ptOverlay->installEventFilter(this); + const QList ptChildren = ptOverlay->findChildren(); + for (QWidget *child : ptChildren) { + child->installEventFilter(this); + } + + ptOverlay->setVisible(false); +} + +// ---------- Nav strip: vertical icon buttons with horizontal text ---------- + +void CardHoverWidget::createNavBar() +{ + sideBar = new QWidget(this); + sideBar->setObjectName("cardHoverSideBar"); + + navNameLabel = new QLabel(sideBar); + navNameLabel->setObjectName("cardHoverName"); + navNameLabel->setAlignment(Qt::AlignCenter); + navNameLabel->setWordWrap(true); + + navGroup = new QButtonGroup(sideBar); + navGroup->setExclusive(true); + + navInfoBtn = createNavButton(tr("Info"), categoryTabIcon(QStringLiteral("icons/info")), PAGE_INFO); + navMainBtn = createNavButton(tr("Main"), categoryTabIcon(QStringLiteral("icons/settings")), PAGE_MAIN); + navMoveBtn = createNavButton(tr("Move"), categoryTabIcon(QStringLiteral("icons/swap")), PAGE_MOVE); + navCounterBtn = createNavButton(tr("Counters"), categoryTabIcon(QStringLiteral("icons/increment")), PAGE_COUNTER); + navTokenBtn = createNavButton(tr("Tokens"), categoryTabIcon(QStringLiteral("icons/dragon")), PAGE_TOKEN); + + connect(navGroup, &QButtonGroup::idClicked, this, [this](int id) { contentStack->setCurrentIndex(id); }); + + auto *barLayout = new QVBoxLayout(sideBar); + barLayout->setContentsMargins(0, 0, 0, 0); + barLayout->setSpacing(4); + barLayout->setAlignment(Qt::AlignTop); + barLayout->addWidget(navNameLabel); + barLayout->addWidget(navInfoBtn); + barLayout->addWidget(navMainBtn); + barLayout->addWidget(navMoveBtn); + barLayout->addWidget(navCounterBtn); + barLayout->addWidget(navTokenBtn); + barLayout->addStretch(1); + sideBar->setLayout(barLayout); +} + +QToolButton *CardHoverWidget::createNavButton(const QString &title, const QIcon &icon, int pageIndex) +{ + auto *btn = new QToolButton(sideBar); + btn->setObjectName("cardHoverNavBtn"); + btn->setText(title); + btn->setToolTip(title); + btn->setIcon(icon); + btn->setIconSize(QSize(NAV_ICON_SIZE, NAV_ICON_SIZE)); + btn->setToolButtonStyle(Qt::ToolButtonTextUnderIcon); + btn->setCheckable(true); + btn->setAutoExclusive(true); + btn->setMinimumWidth(NAV_BUTTON_WIDTH); + navGroup->addButton(btn, pageIndex); + return btn; +} + +void CardHoverWidget::showPage(int index) +{ + contentStack->setCurrentIndex(index); + if (auto *btn = navGroup->button(index)) { + btn->setChecked(true); + } +} + +// ---------- Content pages (no nested tab bars) ---------- + +void CardHoverWidget::createContentStack() +{ + contentStack = new QStackedWidget(this); + contentStack->setMinimumWidth(TAB_MIN_WIDTH); + contentStack->setMaximumWidth(TAB_MAX_WIDTH); + contentStack->setMinimumHeight(TAB_MIN_HEIGHT); + + infoPage = new QWidget(contentStack); + text = new CardInfoTextWidget(infoPage); + text->setObjectName("cardHoverText"); + connect(text, &CardInfoTextWidget::linkActivated, this, [this](const QString &cardName) { + ExactCard card = CardDatabaseManager::query()->guessCard({cardName}); + if (!card.isEmpty()) { + text->setCard(card); + } + }); + + auto *infoScroll = new QScrollArea(infoPage); + infoScroll->setWidget(text); + infoScroll->setWidgetResizable(true); + infoScroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + infoScroll->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + infoScroll->setFrameShape(QFrame::NoFrame); + + infoPageLayout = new QVBoxLayout(infoPage); + infoPageLayout->setContentsMargins(4, 4, 4, 4); + infoPageLayout->addWidget(infoScroll, 1); + infoPage->setLayout(infoPageLayout); + + mainPage = makeStackPage(mainPageLayout); + movePage = makeStackPage(movePageLayout); + counterPage = makeStackPage(counterPageLayout); + tokenPage = makeStackPage(tokenPageLayout); + + contentStack->addWidget(infoPage); + contentStack->addWidget(mainPage); + contentStack->addWidget(movePage); + contentStack->addWidget(counterPage); + contentStack->addWidget(tokenPage); +} + +QWidget *CardHoverWidget::makeStackPage(QVBoxLayout *&layout) +{ + auto *page = new QWidget(contentStack); + auto *inner = new QWidget(page); + layout = new QVBoxLayout(inner); + layout->setContentsMargins(4, 4, 4, 4); + layout->setSpacing(4); + layout->setAlignment(Qt::AlignTop); + inner->setLayout(layout); + + auto *scroll = new QScrollArea(page); + scroll->setWidget(inner); + scroll->setWidgetResizable(true); + scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + scroll->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + scroll->setFrameShape(QFrame::NoFrame); + + auto *outer = new QVBoxLayout(page); + outer->setContentsMargins(0, 0, 0, 0); + outer->addWidget(scroll, 1); + page->setLayout(outer); + + return page; +} + +QPushButton *CardHoverWidget::createSmallButton(const QString &text, const std::function &slot, QWidget *parent) +{ + return createSmallButton(text, QString(), slot, parent); +} + +// Packs icon-over-text tool buttons into a uniform grid so action pages use the full width. +class ButtonGrid +{ +public: + explicit ButtonGrid(QVBoxLayout *layout, int columns = 2) : columns(columns) + { + grid = new QGridLayout(); + grid->setContentsMargins(0, 0, 0, 0); + grid->setSpacing(4); + layout->addLayout(grid); + } + + void addSection(QWidget *header) + { + if (col != 0) { + ++row; + col = 0; + } + grid->addWidget(header, row++, 0, 1, columns); + } + + void addButton(QWidget *button) + { + grid->addWidget(button, row, col++); + if (col >= columns) { + col = 0; + ++row; + } + } + +private: + QGridLayout *grid; + int columns; + int row = 0; + int col = 0; +}; + +QToolButton *CardHoverWidget::createGridButton(const QString &text, + const QIcon &icon, + const std::function &slot, + QWidget *parent, + const QString &tooltip) +{ + auto *btn = new QToolButton(parent); + btn->setObjectName("cardHoverGridBtn"); + btn->setText(text); + // Short cell labels can clip; the full text is always one hover away. + btn->setToolTip(tooltip.isEmpty() ? text : tooltip); + btn->setIcon(icon); + btn->setIconSize(QSize(22, 22)); + btn->setToolButtonStyle(Qt::ToolButtonTextUnderIcon); + btn->setMinimumHeight(58); + btn->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + connect(btn, &QToolButton::clicked, this, [slot]() { slot(); }); + return btn; +} + +QPushButton *CardHoverWidget::createSmallButton(const QString &text, + const QString &tooltip, + const std::function &slot, + QWidget *parent) +{ + auto *btn = new QPushButton(text, parent ? parent : contentStack); + btn->setObjectName("cardHoverActionBtn"); + btn->setMinimumHeight(30); + if (!tooltip.isEmpty()) { + btn->setToolTip(tooltip); + } + connect(btn, &QPushButton::clicked, this, [slot]() { slot(); }); + return btn; +} + +QPushButton * +CardHoverWidget::createOverlayButton(const QString &text, const QString &tooltip, const std::function &slot) +{ + const QPalette &pal = QApplication::palette(); + QColor bg = pal.color(QPalette::Dark); + bg.setAlpha(180); + QColor hoverBg = pal.color(QPalette::Mid); + hoverBg.setAlpha(200); + QColor pressBg = pal.color(QPalette::Shadow); + pressBg.setAlpha(200); + QColor fg = pal.color(QPalette::ButtonText); + + auto *btn = new QPushButton(text, imageContainer); + btn->setObjectName("cardHoverOverlayBtn"); + btn->setToolTip(tooltip); + btn->setFixedSize(OVERLAY_BTN_SIZE + 8, OVERLAY_BTN_SIZE); + btn->setFlat(true); + btn->setStyleSheet(QStringLiteral("QPushButton {" + " color: %1; font-size: 10px; font-weight: bold;" + " background: %2; border: none;" + " border-radius: 3px;" + "}" + "QPushButton:hover { background: %3; }" + "QPushButton:pressed { background: %4; }") + .arg(fg.name(), bg.name(), hoverBg.name(), pressBg.name())); + connect(btn, &QPushButton::clicked, this, [slot]() { slot(); }); + return btn; +} + +QLabel *CardHoverWidget::createSectionHeader(const QString &text, QWidget *parent) +{ + auto *lbl = new QLabel(text, parent); + lbl->setObjectName("cardHoverSectionHeader"); + lbl->setAlignment(Qt::AlignLeft | Qt::AlignVCenter); + lbl->setMinimumHeight(22); + return lbl; +} + +QLabel *CardHoverWidget::createNoteLabel(const QString &text, QWidget *parent) +{ + auto *lbl = new QLabel(text, parent); + lbl->setObjectName("cardHoverNote"); + lbl->setAlignment(Qt::AlignCenter); + lbl->setWordWrap(true); + return lbl; +} + +// ---------- Live updates: stay in sync while the popup is open ---------- + +void CardHoverWidget::connectCardSignals() +{ + if (!currentCard) { + return; + } + if (CardState *state = currentCard->getState()) { + connect(state, &CardState::stateChanged, this, &CardHoverWidget::scheduleRefresh); + } + connect(currentCard, &CardItem::tappedChanged, this, &CardHoverWidget::scheduleRefresh); + connect(currentCard, &CardItem::faceDownChanged, this, &CardHoverWidget::scheduleRefresh); + connect(currentCard, &QObject::destroyed, this, &CardHoverWidget::onCardDestroyed); +} + +void CardHoverWidget::disconnectCardSignals() +{ + if (currentCard) { + if (CardState *state = currentCard->getState()) { + disconnect(state, nullptr, this, nullptr); + } + disconnect(currentCard, nullptr, this, nullptr); + } +} + +void CardHoverWidget::scheduleRefresh() +{ + if (!currentCard) { + return; + } + if (!refreshTimer->isActive()) { + refreshTimer->start(); + } +} + +void CardHoverWidget::onCardChanged() +{ + if (!currentCard) { + return; + } + + // The underlying card may have changed identity (copy effects, flip to another face) + ExactCard now = currentCard->getCard(); + if (now.getCardPtr() != shownCard.getCardPtr()) { + shownCard = now; + pic->setCard(now); + text->setCard(now); + nameLabel->setText(now.getInfo().getName()); + navNameLabel->setText(now.getInfo().getName()); + } + + refreshDisplay(); + rebuildActionsForZone(); + positionOverlays(); +} + +void CardHoverWidget::onCardDestroyed() +{ + // Never touch the sender here: it is mid-teardown. Qt already auto-disconnects + // its signals, so just drop our reference; clearCard() then skips sender access. + currentCard = nullptr; + clearCard(); + hideAnimated(); +} + +// ---------- Grouped action builders (mirror CardMenu per zone) ---------- + +bool CardHoverWidget::canModifyCurrentCard() const +{ + if (!currentCard || !currentCard->getOwner() || !currentPlayer) { + return false; + } + if (!currentPlayer->getLogic()->getPlayerInfo()->getLocalOrJudge()) { + return false; + } + // A revealed zone we cannot write to is read-only even for its owner. + if (auto *view = qobject_cast(currentCard->getZone())) { + if (view->getRevealZone() && !view->getWriteableRevealZone()) { + return false; + } + } + return true; +} + +void CardHoverWidget::rebuildActionsForZone() +{ + clearLayout(mainPageLayout); + clearLayout(movePageLayout); + clearLayout(counterPageLayout); + clearLayout(tokenPageLayout); + + if (!currentCard || !currentPlayer) { + return; + } + + auto *card = currentCard.data(); + QList cardList{card->getState()}; + + const bool canModify = canModifyCurrentCard(); + + QString zoneName = card->getZone() ? card->getZone()->getName() : QString(); + + buildMainTab(cardList, zoneName, canModify); + buildMoveTab(cardList, zoneName, canModify); + buildCounterTab(cardList, zoneName, canModify); + buildTokenTab(card, canModify); +} + +void CardHoverWidget::buildMainTab(const QList &cardList, const QString &zoneName, bool canModify) +{ + auto *layout = mainPageLayout; + auto *card = currentCard.data(); + auto *player = currentPlayer.data(); + auto *actions = player->getLogic()->getPlayerActions(); + auto *gameScene = player->getGameScene(); + auto invoke = [actions, cardList](CardMenuActionType type) { + return [actions, cardList, type]() { actions->cardMenuAction(cardList, type); }; + }; + auto attach = [card]() { card->drawAttachArrow(); }; + auto drawArrow = [card]() { card->drawArrow(Qt::red); }; + + if (!canModify) { + ButtonGrid grid(layout); + grid.addSection(createSectionHeader(tr("Revealed"), mainPage)); + grid.addButton(createGridButton( + tr("Hide"), semanticActionIcon(QStringLiteral("icons/arrow_left"), AppColor::Informational), + [actions, cardList]() { actions->actHide(cardList); }, mainPage)); + grid.addButton(createGridButton(tr("Clone"), + semanticActionIcon(QStringLiteral("icons/increment"), AppColor::Positive), + invoke(cmClone), mainPage)); + grid.addSection(createSectionHeader(tr("Select"), mainPage)); + grid.addButton(createGridButton( + tr("Select All"), semanticActionIcon(QStringLiteral("icons/search"), AppColor::Informational), + [gameScene, card]() { gameScene->selectAllInZone(card); }, mainPage)); + grid.addButton(createGridButton( + tr("Select Column"), semanticActionIcon(QStringLiteral("icons/filter"), AppColor::Informational), + [gameScene, card]() { gameScene->selectSameColumn(card); }, mainPage)); + layout->addStretch(1); + return; + } + + if (zoneName == ZoneNames::TABLE) { + ButtonGrid grid(layout); + grid.addSection(createSectionHeader(tr("Battlefield"), mainPage)); + grid.addButton(createGridButton(tr("Tap / Untap"), + semanticActionIcon(QStringLiteral("icons/sync"), AppColor::Informational), + invoke(cmTap), mainPage)); + auto *doesntUntap = createGridButton( + tr("Skip untapping"), semanticActionIcon(QStringLiteral("icons/lock"), AppColor::Informational), + [actions, cardList]() { actions->cardMenuAction(cardList, cmDoesntUntap); }, mainPage); + doesntUntap->setCheckable(true); + doesntUntap->setChecked(card->getDoesntUntap()); + grid.addButton(doesntUntap); + grid.addButton(createGridButton(tr("Clone"), + semanticActionIcon(QStringLiteral("icons/increment"), AppColor::Positive), + invoke(cmClone), mainPage)); + grid.addButton(createGridButton(tr("Attach to card..."), + semanticActionIcon(QStringLiteral("icons/pin"), AppColor::Positive), attach, + mainPage)); + grid.addButton(createGridButton(tr("Draw arrow..."), + semanticActionIcon(QStringLiteral("icons/share"), AppColor::Marked), drawArrow, + mainPage)); + grid.addButton(createGridButton( + tr("Set annotation..."), semanticActionIcon(QStringLiteral("icons/pencil"), AppColor::Marked), + [this, actions, cardList]() { + runModalAction([actions, cardList]() { actions->actRequestSetAnnotationDialog(cardList); }); + }, + mainPage)); + grid.addButton(createGridButton(tr("Turn Over"), + semanticActionIcon(QStringLiteral("icons/swap"), AppColor::Hidden), + invoke(cmFlip), mainPage)); + if (card->getFaceDown()) { + grid.addButton(createGridButton(tr("Peek at card face"), + semanticActionIcon(QStringLiteral("icons/view"), AppColor::Hidden), + invoke(cmPeek), mainPage)); + } + if (card->getAttachedTo()) { + grid.addButton(createGridButton( + tr("Unattach"), semanticActionIcon(QStringLiteral("icons/arrow_undo"), AppColor::Negative), + [actions, cardList]() { actions->actUnattach(cardList); }, mainPage)); + } + grid.addButton(createGridButton( + tr("Reduce life"), semanticActionIcon(QStringLiteral("icons/arrow_down"), AppColor::Negative), + [actions, cardList]() { actions->actReduceLifeByPower(cardList); }, mainPage)); + grid.addSection(createSectionHeader(tr("Select"), mainPage)); + grid.addButton(createGridButton( + tr("Select All"), semanticActionIcon(QStringLiteral("icons/search"), AppColor::Informational), + [gameScene, card]() { gameScene->selectAllInZone(card); }, mainPage)); + grid.addButton(createGridButton( + tr("Select Row"), semanticActionIcon(QStringLiteral("icons/filter"), AppColor::Informational), + [gameScene, card]() { gameScene->selectSameRow(card); }, mainPage)); + } else if (!zoneName.isEmpty()) { + ButtonGrid grid(layout); + grid.addSection(createSectionHeader(tr("Zone"), mainPage)); + grid.addButton(createGridButton( + tr("Play"), semanticActionIcon(QStringLiteral("icons/arrow_right"), AppColor::Positive), + [actions, cardList]() { actions->actPlay(cardList); }, mainPage)); + grid.addButton(createGridButton( + tr("Play Face Down"), semanticActionIcon(QStringLiteral("icons/arrow_right"), AppColor::Hidden), + [actions, cardList]() { actions->actPlayFacedown(cardList); }, mainPage)); + grid.addButton(createGridButton(tr("Clone"), + semanticActionIcon(QStringLiteral("icons/increment"), AppColor::Positive), + invoke(cmClone), mainPage)); + if (zoneName == ZoneNames::STACK || zoneName == ZoneNames::HAND || zoneName == ZoneNames::GRAVE || + zoneName == ZoneNames::EXILE) { + grid.addButton(createGridButton(tr("Attach to card..."), + semanticActionIcon(QStringLiteral("icons/pin"), AppColor::Positive), attach, + mainPage)); + grid.addButton(createGridButton(tr("Draw arrow..."), + semanticActionIcon(QStringLiteral("icons/share"), AppColor::Marked), + drawArrow, mainPage)); + } + grid.addSection(createSectionHeader(tr("Select"), mainPage)); + grid.addButton(createGridButton( + tr("Select All"), semanticActionIcon(QStringLiteral("icons/search"), AppColor::Informational), + [gameScene, card]() { gameScene->selectAllInZone(card); }, mainPage)); + if (qobject_cast(card->getZone())) { + grid.addButton(createGridButton( + tr("Select Column"), semanticActionIcon(QStringLiteral("icons/filter"), AppColor::Informational), + [gameScene, card]() { gameScene->selectSameColumn(card); }, mainPage)); + } + } else { + layout->addWidget(createNoteLabel(tr("No card actions here — see the Move tab."), mainPage)); + } + + layout->addStretch(1); +} + +void CardHoverWidget::buildMoveTab(const QList &cardList, const QString &zoneName, bool canModify) +{ + auto *layout = movePageLayout; + if (!canModify) { + layout->addWidget(createNoteLabel(tr("Read-only."), movePage)); + layout->addStretch(1); + return; + } + + auto *player = currentPlayer.data(); + auto *actions = player->getLogic()->getPlayerActions(); + auto invoke = [actions, cardList](CardMenuActionType type) { + return [actions, cardList, type]() { actions->cardMenuAction(cardList, type); }; + }; + + ButtonGrid grid(layout); + grid.addSection(createSectionHeader(tr("Move to"), movePage)); + grid.addButton(createGridButton(tr("Top of library"), + semanticActionIcon(QStringLiteral("icons/arrow_to_top"), AppColor::Movement), + invoke(cmMoveToTopLibrary), movePage)); + grid.addButton(createGridButton( + tr("X from top..."), semanticActionIcon(QStringLiteral("icons/search"), AppColor::Movement), + [actions]() { actions->actRequestMoveCardXCardsFromTopDialog(); }, movePage, tr("X from top of library..."))); + grid.addButton(createGridButton(tr("Bottom of library"), + semanticActionIcon(QStringLiteral("icons/arrow_to_bottom"), AppColor::Movement), + invoke(cmMoveToBottomLibrary), movePage)); + grid.addButton(createGridButton(tr("Table"), semanticActionIcon(QStringLiteral("icons/player"), AppColor::Positive), + invoke(cmMoveToTable), movePage)); + grid.addButton(createGridButton(tr("Hand"), + semanticActionIcon(QStringLiteral("icons/arrow_left"), AppColor::Positive), + invoke(cmMoveToHand), movePage)); + grid.addButton(createGridButton(tr("Graveyard"), + semanticActionIcon(QStringLiteral("icons/arrow_down"), AppColor::Negative), + invoke(cmMoveToGraveyard), movePage)); + grid.addButton(createGridButton(tr("Exile"), + semanticActionIcon(QStringLiteral("icons/arrow_right"), AppColor::Informational), + invoke(cmMoveToExile), movePage)); + + // Reveal actions live on hand / custom zones in CardMenu + if (!zoneName.isEmpty() && zoneName != ZoneNames::TABLE && zoneName != ZoneNames::STACK && + zoneName != ZoneNames::GRAVE && zoneName != ZoneNames::EXILE) { + auto *game = player->getLogic()->getGame(); + if (game && game->getPlayerManager()) { + grid.addSection(createSectionHeader(tr("Reveal to..."), movePage)); + grid.addButton(createGridButton( + tr("All players"), semanticActionIcon(QStringLiteral("icons/share"), AppColor::Informational), + [actions, cardList]() { + auto *fake = new QAction(nullptr); + fake->setData(-1); + actions->actReveal(cardList, fake); + fake->deleteLater(); + }, + movePage)); + const auto players = game->getPlayerManager()->getPlayers().values(); + for (auto *other : players) { + if (other == player->getLogic()) { + continue; + } + QString name = other->getPlayerInfo()->getName(); + int id = other->getPlayerInfo()->getId(); + grid.addButton(createGridButton( + name, semanticActionIcon(QStringLiteral("icons/player"), AppColor::Informational), + [actions, cardList, id]() { + auto *fake = new QAction(nullptr); + fake->setData(id); + actions->actReveal(cardList, fake); + fake->deleteLater(); + }, + movePage)); + } + } + } + + layout->addStretch(1); +} + +void CardHoverWidget::buildCounterTab(const QList &cardList, const QString &zoneName, bool canModify) +{ + auto *layout = counterPageLayout; + auto *actions = currentPlayer->getLogic()->getPlayerActions(); + + if (!canModify) { + layout->addWidget(createNoteLabel(tr("Read-only."), counterPage)); + layout->addStretch(1); + return; + } + if (zoneName != ZoneNames::TABLE) { + layout->addWidget(createNoteLabel( + tr("Counters live on battlefield cards. PT and counters can be changed there."), counterPage)); + layout->addStretch(1); + return; + } + + layout->addWidget(createSectionHeader(tr("Power / Toughness"), counterPage)); + layout->addWidget(createSmallButton( + tr("Set power and toughness..."), + [this, actions, cardList]() { + runModalAction([actions, cardList]() { actions->actRequestSetPTDialog(cardList); }); + }, + counterPage)); + layout->addWidget(createSmallButton( + tr("Reset power and toughness"), [actions, cardList]() { actions->actResetPT(cardList); }, counterPage)); + + // One group per counter: color dot plus live value in the header, its actions in + // a compact row beneath it. Rebuilt on every card change, so values never go stale. + const QMap counters = currentCard ? currentCard->getCounters() : QMap{}; + for (int i = 0; i < 6; ++i) { + const QString counterName = SettingsCache::instance().cardCounters().displayName(i); + const QString dot = QStringLiteral("\u25cf ") + .arg(SettingsCache::instance().cardCounters().color(i).name()); + const int value = counters.value(i, 0); + layout->addWidget(createSectionHeader(dot + tr("%1 \u00d7 %2").arg(counterName).arg(value), counterPage)); + + auto *row = new QHBoxLayout(); + row->setSpacing(4); + row->addWidget(createSmallButton( + tr("Add"), [actions, cardList, i]() { actions->actAddCardCounter(cardList, i); }, counterPage)); + if (value > 0) { + row->addWidget(createSmallButton( + tr("Remove"), [actions, cardList, i]() { actions->actRemoveCardCounter(cardList, i); }, counterPage)); + } + row->addWidget(createSmallButton( + tr("Set..."), + [this, actions, cardList, i]() { + runModalAction([actions, cardList, i]() { actions->actRequestSetCardCounterDialog(cardList, i); }); + }, + counterPage)); + layout->addLayout(row); + } + + layout->addStretch(1); +} + +void CardHoverWidget::buildTokenTab(CardItem *card, bool canModify) +{ + auto *layout = tokenPageLayout; + if (!card || !currentPlayer) { + layout->addStretch(1); + return; + } + auto *actions = currentPlayer->getLogic()->getPlayerActions(); + ExactCard exactCard = card->getCard(); + + bool anyToken = false; + if (canModify && !exactCard.isEmpty()) { + QList relatedCards = exactCard.getInfo().getAllRelatedCards(); + bool hasMulti = relatedCards.size() > 1; + int known = 0; + for (CardRelation *relation : relatedCards) { + ExactCard related = + CardDatabaseManager::query()->getCardFromSameSet(relation->getName(), exactCard.getPrinting()); + if (!related) { + related = CardDatabaseManager::query()->getCard({relation->getName()}); + } + if (!related) { + continue; + } + if (known == 0) { + layout->addWidget(createSectionHeader(tr("Tokens"), tokenPage)); + } + ++known; + QString relatedName = related.getInfo().getPowTough().isEmpty() + ? related.getName() + : related.getInfo().getPowTough() + " " + related.getName(); + CardRelation *captured = relation; + CardItem *source = card; + layout->addWidget(createSmallButton( + tr("Token: ") + relatedName, + [this, actions, source, captured]() { + runModalAction([actions, source, captured]() { + actions->actRequestCreateRelatedFromRelationDialog(source->getState(), captured); + }); + }, + tokenPage)); + } + if (hasMulti && known > 0) { + layout->addWidget(createSmallButton( + tr("All tokens"), + [this, actions, card]() { + runModalAction([actions, card]() { actions->actCreateAllRelatedCards(card->getState()); }); + }, + tokenPage)); + } + anyToken = known > 0; + } + + // Save the tab index before adding related-view buttons so the header only shows when useful + int beforeRelated = layout->count(); + addRelatedViewButtons(layout); + bool anyRelated = layout->count() != beforeRelated; + + if (!anyToken && !anyRelated) { + layout->addWidget(createNoteLabel(tr("No tokens or related cards."), tokenPage)); + } + + layout->addStretch(1); +} + +void CardHoverWidget::addRelatedViewButtons(QVBoxLayout *layout) +{ + if (!currentCard) { + return; + } + ExactCard exactCard = currentCard->getCard(); + if (exactCard.isEmpty()) { + return; + } + QList relatedCards = exactCard.getInfo().getAllRelatedCards(); + bool anyKnown = false; + for (const CardRelation *relation : relatedCards) { + if (CardDatabaseManager::query()->getCardInfo(relation->getName())) { + anyKnown = true; + break; + } + } + if (!anyKnown) { + return; + } + QWidget *page = layout == tokenPageLayout ? tokenPage : contentStack; + + layout->addWidget(createSectionHeader(tr("Related cards"), page)); + for (const CardRelation *relation : relatedCards) { + QString name = relation->getName(); + if (!CardDatabaseManager::query()->getCardInfo(name)) { + continue; + } + layout->addWidget(createSmallButton( + name, + [this, name, exactCard]() { + ExactCard related = CardDatabaseManager::query()->getCard({name, exactCard.getPrinting().getUuid()}); + if (!related.isEmpty()) { + text->setCard(related); + showPage(PAGE_INFO); + } + }, + page)); + } +} + +// ---------- Layout helper ---------- + +void CardHoverWidget::clearLayout(QLayout *layout) +{ + if (!layout) { + return; + } + QLayoutItem *item; + while ((item = layout->takeAt(0)) != nullptr) { + if (item->widget()) { + item->widget()->deleteLater(); + } + if (item->layout()) { + clearLayout(item->layout()); + } + delete item; + } +} + +// ---------- Overlay positioning ---------- + +void CardHoverWidget::positionOverlays() +{ + if (!currentCard || !pic) { + return; + } + + nameBarOverlay->show(); + + int containerWidth = imageContainer->width(); + + // Name bar at top — width constrained, height from layout + int nameWidth = containerWidth - 8; + nameBarOverlay->setFixedWidth(nameWidth); + nameBarOverlay->adjustSize(); + nameBarOverlay->move(4, 4); + + // Show/hide based on card data; an empty P/T always falls back to the compact pill + const bool hasPT = !currentCard->getPT().isEmpty(); + if (!hasPT && ptExpanded) { + setPTExpanded(false); + } + applyPTOverlayGeometry(); + ptOverlay->setVisible(hasPT); +} + +void CardHoverWidget::setPTExpanded(bool expanded) +{ + if (!ptOverlay || !ptControls) { + return; + } + // Nothing to expand into when the card has no P/T. + if (expanded && (!currentCard || currentCard->getPT().isEmpty())) { + expanded = false; + } + // The buttons act on our own copy of the card by id, so on another player's card they + // would edit our zone. Offer them only where the action tabs allow edits. + if (expanded && !canModifyCurrentCard()) { + expanded = false; + } + // Mouse movement drives this on every event; only apply a real state change. + if (ptExpanded == expanded) { + return; + } + + ptExpanded = expanded; + ptControls->setVisible(expanded); + if (expanded) { + // The stack covers more of the art than the pill did; keep it above sibling overlays. + ptOverlay->raise(); + } + applyPTOverlayGeometry(); +} + +void CardHoverWidget::applyPTOverlayGeometry() +{ + if (!ptOverlay || !pic || ptCompactSize.isEmpty() || ptFullSize.isEmpty()) { + return; + } + // Anchor on the drawn artwork, not on imageContainer: the container can be taller than the + // picture, and sideways-layout cards are letterboxed inside pic. Both used to drop the + // overlay below the printed P/T box. + const QRect picture = + pic->artRect() + .translated(pic->pos()) + .adjusted(PT_PILL_EDGE_INSET, PT_PILL_EDGE_INSET, -PT_PILL_EDGE_INSET, -PT_PILL_EDGE_INSET); + const QRect compact = compactPillRect(); + if (picture.isEmpty() || compact.isEmpty()) { + return; + } + + // Expanded: same bottom edge and right edge as the pill, so the controls grow upward + // and never appear under the cursor that is resting on the pill. Never narrower than + // the current pill, or a three-digit value would clip against the stack's edge. + const QSize size = + ptExpanded ? QSize(qMax(ptFullSize.width(), ptCompactSize.width()), ptFullSize.height()) : ptCompactSize; + const int x = clampToSpan(picture.x(), picture.width(), size.width(), compact.x() + compact.width() - size.width()); + const int y = + clampToSpan(picture.y(), picture.height(), size.height(), compact.y() + compact.height() - size.height()); + + ptOverlay->setGeometry(x, y, size.width(), size.height()); +} + +QRect CardHoverWidget::compactPillRect() const +{ + if (!pic || ptCompactSize.isEmpty()) { + return {}; + } + const QRect picture = + pic->artRect() + .translated(pic->pos()) + .adjusted(PT_PILL_EDGE_INSET, PT_PILL_EDGE_INSET, -PT_PILL_EDGE_INSET, -PT_PILL_EDGE_INSET); + if (picture.isEmpty()) { + return {}; + } + // Centred on the printed P/T box (bottom-right of the art). + const int anchorX = picture.x() + qRound(picture.width() * PT_PILL_ANCHOR_X); + const int anchorY = picture.y() + qRound(picture.height() * PT_PILL_ANCHOR_Y); + return QRect( + clampToSpan(picture.x(), picture.width(), ptCompactSize.width(), anchorX - ptCompactSize.width() / 2), + clampToSpan(picture.y(), picture.height(), ptCompactSize.height(), anchorY - ptCompactSize.height() / 2), + ptCompactSize.width(), ptCompactSize.height()); +} + +QRect CardHoverWidget::ptOpenRegion() const +{ + const QRect pill = compactPillRect(); + if (pill.isEmpty() || !pic) { + return {}; + } + // The opening target is the P/T box itself (plus a small halo), not the whole bottom-right + // quadrant: a wider trigger lifts the rows out from under a cursor that merely passed by, + // and the click that selects the card would land on a P/T button instead. + return pill.translated(-pic->pos()).adjusted(-PT_OPEN_HALO, -PT_OPEN_HALO, PT_OPEN_HALO, PT_OPEN_HALO); +} + +QRect CardHoverWidget::ptHoverRegion() const +{ + if (!pic) { + return {}; + } + // The band is relative to the drawn artwork, so a letterboxed (sideways-layout) card keeps + // its P/T corner where the printed box actually is. + const QRect art = pic->artRect(); + if (art.isEmpty()) { + return {}; + } + return QRect(art.x() + qRound(art.width() * (1.0 - PT_HOVER_REGION_WIDTH)), + art.y() + qRound(art.height() * (1.0 - PT_HOVER_REGION_HEIGHT)), + qRound(art.width() * PT_HOVER_REGION_WIDTH), qRound(art.height() * PT_HOVER_REGION_HEIGHT)); +} + +bool CardHoverWidget::isCursorOverPTArea() const +{ + if (!ptOverlay || !pic || !imageContainer) { + return false; + } + if (ptOverlay->rect().contains(ptOverlay->mapFromGlobal(QCursor::pos()))) { + return true; + } + return ptHoverRegion().translated(pic->pos()).contains(imageContainer->mapFromGlobal(QCursor::pos())); +} + +QSize CardHoverWidget::measureCompactPTSize() const +{ + if (!ptPillLabel) { + return QSize(PT_PILL_MIN_WIDTH, PT_PILL_MIN_HEIGHT); + } + // QLabel::sizeHint() leaves out the stylesheet padding, so add it back explicitly. + const QSize hint = ptPillLabel->sizeHint(); + return QSize(qMax(PT_PILL_MIN_WIDTH, hint.width() + 2 * PT_PILL_TEXT_PADDING), + qMax(PT_PILL_MIN_HEIGHT, hint.height())); +} + +void CardHoverWidget::resizeEvent(QResizeEvent *event) +{ + QFrame::resizeEvent(event); + positionOverlays(); +} + +void CardHoverWidget::hideEvent(QHideEvent *event) +{ + // A reopened panel for the same card must not come back with the rows still lifted. + setPTExpanded(false); + // The window is already hidden here; repaint whatever it was covering. + invalidateCoveredArea(); + QFrame::hideEvent(event); +} + +// ---------- Display updates ---------- + +void CardHoverWidget::updatePTDisplay() +{ + if (!currentCard) { + ptPowerLabel->clear(); + ptToughLabel->clear(); + ptPillLabel->clear(); + setPTExpanded(false); + return; + } + + QString pt = currentCard->getPT(); + if (pt.isEmpty()) { + ptPowerLabel->setText("--"); + ptToughLabel->setText("--"); + ptPillLabel->setText("--"); + setPTExpanded(false); + } else { + int slashIdx = pt.indexOf('/'); + if (slashIdx >= 0) { + ptPowerLabel->setText(pt.left(slashIdx)); + ptToughLabel->setText(pt.mid(slashIdx + 1)); + } else { + ptPowerLabel->setText(pt); + ptToughLabel->setText("--"); + } + ptPillLabel->setText(pt); + } + + // Three-digit values (100/100, -1/-1) are legal, so the resting pill cannot keep a + // size measured from the seed text: re-measure on every value change and carry the + // width into the expanded stack. + ptCompactSize = measureCompactPTSize(); + applyPTOverlayGeometry(); +} + +// ---------- Card data ---------- + +void CardHoverWidget::setCard(CardItem *card, PlayerGraphicsItem *player) +{ + disconnectCardSignals(); + + if (!card || !player) { + clearCard(); + return; + } + + currentCard = card; + currentPlayer = player; + + ExactCard exactCard = card->getCard(); + shownCard = exactCard; + pic->setCard(exactCard); + text->setCard(exactCard); + + nameLabel->setText(exactCard.getInfo().getName()); + navNameLabel->setText(exactCard.getInfo().getName()); + + connectCardSignals(); + refreshDisplay(); + rebuildActionsForZone(); + // A new card always starts compact; the rows only lift out while P/T is hovered. + setPTExpanded(false); + positionOverlays(); + // The widget may have been disabled by a previous teardown; it is interactive again. + setEnabled(true); +} + +void CardHoverWidget::refreshDisplay() +{ + updatePTDisplay(); + + if (!currentCard) { + annotationLabel->setVisible(false); + return; + } + + QString annotation = currentCard->getAnnotation(); + if (annotation.isEmpty()) { + annotationLabel->setVisible(false); + } else { + annotationLabel->setText(annotation); + annotationLabel->setVisible(true); + } +} + +void CardHoverWidget::clearCard() +{ + // Go non-interactive synchronously: the action buttons below are only deleteLater'd, + // so without this a click landing before deferred deletion runs would invoke a lambda + // holding the destroyed card/player. Disabled widgets deliver no button clicks, which + // also covers the fade-out window (hideAnimated only fades; hide() lands on finish). + setEnabled(false); + disconnectCardSignals(); + refreshTimer->stop(); + currentCard = nullptr; + currentPlayer = nullptr; + shownCard = ExactCard(); + pic->setCard(ExactCard()); + text->setCard(ExactCard()); + nameLabel->clear(); + navNameLabel->clear(); + nameEdit->clear(); + annotationLabel->clear(); + annotationLabel->setVisible(false); + ptPowerLabel->clear(); + ptToughLabel->clear(); + ptPillLabel->clear(); + setPTExpanded(false); + ptOverlay->setVisible(false); + clearLayout(mainPageLayout); + clearLayout(movePageLayout); + clearLayout(counterPageLayout); + clearLayout(tokenPageLayout); + showPage(PAGE_INFO); +} + +bool CardHoverWidget::hasCard() const +{ + return currentCard != nullptr; +} + +// ---------- Positioning: the picture lands exactly over the card, tabs spill to the side ---------- + +void CardHoverWidget::positionNextTo(const QRectF &cardSceneRect, + const QTransform &viewportTransform, + const QWidget *viewport) +{ + QRect cardViewRect = viewportTransform.mapRect(cardSceneRect).toRect(); + QPoint cardCenter = viewport->mapToGlobal(cardViewRect.center()); + + QSize popupSize = sizeHint(); + popupSize = QSize(qMax(popupSize.width(), TAB_MIN_WIDTH + NAV_BUTTON_WIDTH + CARD_IMAGE_WIDTH + 40), + qMax(popupSize.height(), TAB_MIN_HEIGHT + 16)); + + auto *screen = QGuiApplication::screenAt(cardCenter); + if (!screen) { + screen = QGuiApplication::primaryScreen(); + } + QRect screenRect = screen->availableGeometry(); + + // Clamp oversized popups to the screen before choosing a side + popupSize.setWidth(qMin(popupSize.width(), screenRect.width() - 2 * POPUP_GAP)); + popupSize.setHeight(qMin(popupSize.height(), screenRect.height() - 2 * POPUP_GAP)); + + const int mLeft = mainLayout->contentsMargins().left(); + const int mRight = mainLayout->contentsMargins().right(); + const int mTop = mainLayout->contentsMargins().top(); + const int halfImgW = CARD_IMAGE_WIDTH / 2; + // Picture height at full size (the pic has a fixed scale-factor size here; the morph + // collapse happens later in showAnimated). + const int imageH = pic->sizeHint().height(); + + // Center the picture over the card (not corner-pinned) so the overhang is even on + // all sides; only the nav panel spills out to the side. Prefer spilling right, then left. + const int xRight = cardCenter.x() - mLeft - halfImgW; + const int xLeft = cardCenter.x() + mRight + halfImgW - popupSize.width(); + const bool rightFits = xRight + popupSize.width() <= screenRect.right() - POPUP_GAP; + const bool leftFits = xLeft >= screenRect.left() + POPUP_GAP; + popupOnRight = rightFits || !leftFits; + mainLayout->setDirection(popupOnRight ? QBoxLayout::LeftToRight : QBoxLayout::RightToLeft); + + int x = popupOnRight ? xRight : xLeft; + + // Center the picture vertically on the card (it is top-aligned in its container). + int y = cardCenter.y() - mTop - imageH / 2; + if (y + popupSize.height() > screenRect.bottom() - POPUP_GAP) { + y = screenRect.bottom() - popupSize.height() - POPUP_GAP; + } + if (y < screenRect.top() + POPUP_GAP) { + y = screenRect.top() + POPUP_GAP; + } + + x = qMax(screenRect.left() + POPUP_GAP, x); + x = qMin(screenRect.right() - popupSize.width() - POPUP_GAP, x); + + move(x, y); + resize(popupSize); + invalidateCoveredArea(); +} + +void CardHoverWidget::invalidateCoveredArea() +{ + // The panel is an overlay window: moving or hiding it does not reliably invalidate + // the pixels it uncovered in the game view, which then stay stale (a black square) + // until something else repaints that area. Do it explicitly, unioned with the + // previous placement so a move repaints both sides. + QWidget *viewport = parentWidget(); + if (!viewport) { + lastViewportRect = QRect(); + return; + } + + QRect covered; + if (isVisible()) { + covered = QRect(viewport->mapFromGlobal(geometry().topLeft()), size()); + } + const QRect dirty = lastViewportRect.united(covered); + if (!dirty.isEmpty()) { + viewport->update(dirty.adjusted(-1, -1, 1, 1)); + } + lastViewportRect = covered; +} + +// ---------- Show / hide: no window travel, the board zoom is the morph ---------- +// The panel opens instantly at its final geometry (GameScene positioned it with the +// picture centered over the zoomed card) and only fades in. Any geometry animation +// here read as the panel "flying in", so there is none — not even for card switches. + +void CardHoverWidget::showAnimated() +{ + if (!hasCard()) { + return; + } + fadeOut->stop(); + + // Fresh open: fade the whole panel in over the zoomed card. Card switches while + // visible keep full opacity and just show. + bool wasHidden = !isVisible(); + if (wasHidden) { + setWindowOpacity(0.0); + } + show(); + positionOverlays(); + + fadeIn->setStartValue(windowOpacity()); + fadeIn->setEndValue(1.0); + fadeIn->start(); + invalidateCoveredArea(); +} + +void CardHoverWidget::hideAnimated() +{ + if (isClosing()) { + // Repeated hide requests must not restart the fade, or the panel never finishes + // closing while the cursor keeps moving. + return; + } + fadeIn->stop(); + fadeOut->setStartValue(windowOpacity()); + fadeOut->setEndValue(0.0); + fadeOut->start(); +} + +bool CardHoverWidget::isClosing() const +{ + return fadeOut->state() == QAbstractAnimation::Running; +} + +void CardHoverWidget::runModalAction(const std::function &action) +{ + // The panel is a Qt::ToolTip (Qt::Popup | Qt::Sheet): it is always on top and + // holds an input grab. A modal dialog opened while it is still up lands behind + // it and cannot be activated. Ask the scene to dismiss the panel so its hover + // timers stop and the covered card fades back in, then drop the window + // synchronously to release the grab. + emit dismissRequested(); + fadeIn->stop(); + fadeOut->stop(); + hide(); + + // Defer the dialog to the next event-loop turn: the popup grab is not fully + // released until after the current mouse event returns. + QTimer::singleShot(0, this, action); +} + +// ---------- Event filter ---------- + +bool CardHoverWidget::eventFilter(QObject *obj, QEvent *event) +{ + // Handle double-click on name label to start editing annotation. Annotations are set + // by card id on our own zone, so another player's card must not open the editor. + if (obj == nameLabel && event->type() == QEvent::MouseButtonDblClick && canModifyCurrentCard()) { + nameLabel->setVisible(false); + annotationLabel->setVisible(false); + nameEdit->setVisible(true); + nameEdit->setText(currentCard ? currentCard->getAnnotation() : QString()); + nameEdit->setFocus(); + nameEdit->selectAll(); + return true; + } + + // Handle click on card image to select the card in game + if (obj == pic && event->type() == QEvent::MouseButtonPress) { + QMouseEvent *me = static_cast(event); + if (me->button() == Qt::LeftButton && currentCard) { + if (auto *scene = qobject_cast(currentCard->scene())) { + scene->setActiveCard(currentCard); + } + currentCard->setSelected(true); + } + } + + // The P/T trigger is the printed P/T area, not just the pill: hovering that quadrant of + // the art lifts the control rows out. Driving it from pic's mouse moves (instead of an + // invisible child widget over the picture) keeps click-to-select working underneath. + if (obj == pic) { + if (event->type() == QEvent::MouseMove) { + const auto *me = static_cast(event); + const QPoint pos = me->position().toPoint(); + if (ptOpenRegion().contains(pos)) { + setPTExpanded(true); + } else if (!isCursorOverPTArea()) { + setPTExpanded(false); + } + } else if (event->type() == QEvent::Leave && !isCursorOverPTArea()) { + setPTExpanded(false); + } + } + + // P/T overlay: hovering the P/T area lifts the control rows out of the pill. The + // filter sits on the whole subtree so moving onto a button keeps it expanded. + const auto *ptWidget = qobject_cast(obj); + if (ptWidget && ptOverlay && (ptWidget == ptOverlay || ptOverlay->isAncestorOf(ptWidget))) { + if (event->type() == QEvent::Enter) { + setPTExpanded(true); + } else if (event->type() == QEvent::Leave) { + // Moving onto a child widget fires Leave on the parent too; only collapse once + // the cursor has left both the overlay and the printed P/T area. + if (!isCursorOverPTArea()) { + setPTExpanded(false); + } + } + } + + return QFrame::eventFilter(obj, event); +} + +// ---------- Mouse tracking on the popup itself ---------- + +void CardHoverWidget::changeEvent(QEvent *event) +{ + if (event->type() == QEvent::PaletteChange) { + applyThemeStyle(); + } + QFrame::changeEvent(event); +} + +void CardHoverWidget::enterEvent(QEnterEvent *event) +{ + QFrame::enterEvent(event); + emit hoverEntered(); +} + +void CardHoverWidget::leaveEvent(QEvent *event) +{ + QFrame::leaveEvent(event); + // Moving the cursor onto a child widget also triggers leaveEvent on this widget; + // only report a real departure from the popup. + if (rect().contains(mapFromGlobal(QCursor::pos()))) { + return; + } + emit hoverLeft(); +} diff --git a/cockatrice/src/interface/widgets/cards/card_hover_widget.h b/cockatrice/src/interface/widgets/cards/card_hover_widget.h new file mode 100644 index 000000000..acd9d4cc6 --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/card_hover_widget.h @@ -0,0 +1,231 @@ +/** + * @file card_hover_widget.h + * @ingroup CardWidgets + * @brief Hover popup: zoomed picture plus a vertical icon nav strip beside stacked action pages. + * + * The board card smooth-zooms first; this panel then swaps in over it with oracle + * text and every right-click action, grouped by topic. + */ + +#ifndef CARDHOVERWIDGET_H +#define CARDHOVERWIDGET_H + +#include +#include +#include +#include +#include +#include +#include +#include + +class CardInfoPictureWidget; +class CardInfoTextWidget; +class CardItem; +class CardState; +class PlayerGraphicsItem; +class QVBoxLayout; +class QHBoxLayout; +class QPushButton; +class QToolButton; +class QButtonGroup; +class QStackedWidget; +class QIcon; +class QLabel; +class QLineEdit; +class QEnterEvent; +class QHideEvent; +class QPropertyAnimation; +class QResizeEvent; +class QTimer; + +class CardHoverWidget : public QFrame +{ + Q_OBJECT + +public: + explicit CardHoverWidget(QWidget *parent = nullptr); + + void setCard(CardItem *card, PlayerGraphicsItem *player); + void clearCard(); + bool hasCard() const; + CardItem *getCardItem() const + { + return currentCard; + } + + void positionNextTo(const QRectF &cardSceneRect, const QTransform &viewportTransform, const QWidget *viewport); + + void showAnimated(); + void hideAnimated(); + + /** @brief True while the panel is fading out towards a hide. */ + bool isClosing() const; + + /** @brief Refreshes the PT, counter, and annotation labels from the current card. */ + void refreshDisplay(); + + bool isMouseOverWidget() const + { + return rect().contains(mapFromGlobal(QCursor::pos())); + } + +protected: + bool eventFilter(QObject *obj, QEvent *event) override; + void changeEvent(QEvent *event) override; + void enterEvent(QEnterEvent *event) override; + void leaveEvent(QEvent *event) override; + void resizeEvent(QResizeEvent *event) override; + void hideEvent(QHideEvent *event) override; + +signals: + void hoverEntered(); + void hoverLeft(); + /** @brief Asks the scene to dismiss the panel before a modal dialog opens. */ + void dismissRequested(); + +private slots: + void onCardChanged(); + void onCardDestroyed(); + void applyThemeStyle(); + +private: + QPointer currentCard; + QPointer currentPlayer; + ExactCard shownCard; + + // Which side of the card the panel ended up on (drives the image-adjacent layout flip) + bool popupOnRight = true; + + // Viewport area the panel covered at its last placement; used to repaint what the + // overlay window uncovers when it moves or hides. + QRect lastViewportRect; + + // Coalesces rapid state-change bursts into a single refresh + QTimer *refreshTimer; + + // Card image area (kept on the card-adjacent side) with overlaid controls + QHBoxLayout *mainLayout; + QWidget *imageContainer; + CardInfoPictureWidget *pic; + + // Name bar overlay (top of card image) + QWidget *nameBarOverlay; + QLabel *nameLabel; + QLineEdit *nameEdit; + QLabel *annotationLabel; + + // P/T overlay (bottom-right of card image): a compact value pill at rest that + // lifts its control rows out of itself while the P/T area is hovered. + QWidget *ptOverlay; + QWidget *ptControls; + QLabel *ptPillLabel; + bool ptExpanded = false; + QSize ptCompactSize; + QSize ptFullSize; + QLabel *ptPowerLabel; + QLabel *ptToughLabel; + QPushButton *btnIncP; + QPushButton *btnDecP; + QPushButton *btnIncT; + QPushButton *btnDecT; + QPushButton *btnSetPT; + QPushButton *btnResetPT; + + // Vertical icon nav strip plus stacked content pages (no nested tab bars) + QWidget *sideBar; + QLabel *navNameLabel; + QToolButton *navInfoBtn; + QToolButton *navMainBtn; + QToolButton *navMoveBtn; + QToolButton *navCounterBtn; + QToolButton *navTokenBtn; + QButtonGroup *navGroup; + + QStackedWidget *contentStack; + QWidget *infoPage; + QWidget *mainPage; + QWidget *movePage; + QWidget *counterPage; + QWidget *tokenPage; + QVBoxLayout *infoPageLayout; + QVBoxLayout *mainPageLayout; + QVBoxLayout *movePageLayout; + QVBoxLayout *counterPageLayout; + QVBoxLayout *tokenPageLayout; + CardInfoTextWidget *text; + + // Window fade (sole reveal animation: per-layer opacity effects flashed black + // on scroll areas, so the whole panel fades as one) + QPropertyAnimation *fadeIn; + QPropertyAnimation *fadeOut; + + void createImageArea(); + void createNameBarOverlay(); + void createPTOverlay(); + void createNavBar(); + QToolButton *createNavButton(const QString &title, const QIcon &icon, int pageIndex); + void createContentStack(); + QWidget *makeStackPage(QVBoxLayout *&layout); + void showPage(int index); + + /** + * @brief Dismisses the panel, then runs an action that opens a modal dialog. + * + * A Qt::ToolTip window is always on top and holds the input grab, so a modal + * dialog opened from inside the panel lands behind it and cannot be + * activated. Hide the panel synchronously, then defer the action so the grab + * is released before the dialog's event loop starts. + */ + void runModalAction(const std::function &action); + + QLabel *createSectionHeader(const QString &text, QWidget *parent); + QLabel *createNoteLabel(const QString &text, QWidget *parent); + + void connectCardSignals(); + void disconnectCardSignals(); + void scheduleRefresh(); + + void rebuildActionsForZone(); + /** @brief Whether the local player may edit the current card (own card, judge, writable zone). */ + bool canModifyCurrentCard() const; + void buildMainTab(const QList &cardList, const QString &zoneName, bool canModify); + void buildMoveTab(const QList &cardList, const QString &zoneName, bool canModify); + void buildCounterTab(const QList &cardList, const QString &zoneName, bool canModify); + void buildTokenTab(CardItem *card, bool canModify); + void addRelatedViewButtons(QVBoxLayout *layout); + void updatePTDisplay(); + void positionOverlays(); + /** @brief Shows/hides the P/T control rows; the value pill stays at the bottom. */ + void setPTExpanded(bool expanded); + /** @brief Anchors the pill on the printed P/T box, or the expanded stack above it. */ + void applyPTOverlayGeometry(); + /** @brief Resting pill geometry, in @c imageContainer coordinates. */ + QRect compactPillRect() const; + /** @brief Pill plus halo: the only area that opens the rows, in @c pic coordinates. */ + QRect ptOpenRegion() const; + /** @brief Bottom-right band the rows stay open in (never opens them), in @c pic coordinates. */ + QRect ptHoverRegion() const; + /** @brief True while the cursor is over the overlay or the printed P/T area. */ + bool isCursorOverPTArea() const; + /** @brief Re-measures the resting pill from its current text. */ + QSize measureCompactPTSize() const; + /** @brief Repaints the viewport area this window covers and the one it just left. */ + void invalidateCoveredArea(); + + void clearLayout(QLayout *layout); + + QPushButton *createOverlayButton(const QString &text, const QString &tooltip, const std::function &slot); + QToolButton *createGridButton(const QString &text, + const QIcon &icon, + const std::function &slot, + QWidget *parent, + const QString &tooltip = QString()); + QPushButton *createSmallButton(const QString &text, const std::function &slot, QWidget *parent = nullptr); + QPushButton *createSmallButton(const QString &text, + const QString &tooltip, + const std::function &slot, + QWidget *parent = nullptr); +}; + +#endif // CARDHOVERWIDGET_H diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp index 8f02ebcde..0d0c11fdf 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp @@ -175,6 +175,8 @@ void CardInfoPictureWidget::setScaleFactor(const int scale) void CardInfoPictureWidget::updatePixmap() { pixmapDirty = true; + // The artwork rect belongs to the pixmap that is about to be loaded. + artRectCache = QRect(); update(); } @@ -252,6 +254,24 @@ void CardInfoPictureWidget::paintEvent(QPaintEvent *event) // Draw the pre-scaled pixmap directly painter.drawPixmap(targetRect, finalPixmap); + + if (targetRect != artRectCache) { + artRectCache = targetRect; + // Overlays anchored on the artwork (rather than the widget) re-position on this. + emit artRectChanged(); + } +} + +/** + * @brief Rect of the drawn artwork inside this widget. + * + * Mirrors the target rect paintEvent() computes: the pixmap scaled with KeepAspectRatio and + * centred, after the optional sideways-layout rotation. Falls back to rect() until the first + * paint, which is the right answer for the usual portrait card. + */ +QRect CardInfoPictureWidget::artRect() const +{ + return artRectCache.isEmpty() ? rect() : artRectCache; } /** diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h index 0eb731d92..5cd7e912a 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h @@ -41,6 +41,14 @@ public: return exactCard; } [[nodiscard]] QSize sizeHint() const override; + /** + * @brief Rect of the drawn artwork inside this widget. + * + * The pixmap is scaled with KeepAspectRatio and centred, and sideways-layout cards may be + * rotated, so this is usually smaller than rect(). Callers that overlay the picture (rather + * than the widget) must anchor on this. Falls back to rect() until the first paint. + */ + [[nodiscard]] QRect artRect() const; /** * @brief The size a card picture has at @p scaleFactor percent. @@ -60,6 +68,8 @@ signals: void cardScaleFactorChanged(int _scale); void cardChanged(const ExactCard &card); void cardClicked(QMouseEvent *event, const ExactCard &card); + /** @brief Emitted when the drawn artwork rect changes (size, scale factor, rotation, card). */ + void artRectChanged(); protected: void resizeEvent(QResizeEvent *event) override; @@ -83,6 +93,8 @@ private: double scaleFactor = 100; QPixmap resizedPixmap; bool pixmapDirty; + /// Last drawn artwork rect, in widget coordinates; empty until the first paint. + QRect artRectCache; bool hoverToZoomEnabled; bool raiseOnEnter; diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp index 04fe9ab69..56fe0bda4 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp @@ -1,5 +1,7 @@ #include "card_info_picture_with_text_overlay_widget.h" +#include "../../theme_manager.h" + #include #include #include @@ -43,6 +45,7 @@ CardInfoPictureWithTextOverlayWidget::CardInfoPictureWithTextOverlayWidget(QWidg fontSize(fontSize), textAlignment(alignment), highlighted(false) { this->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + connect(themeManager, &ThemeManager::fontsChanged, this, &CardInfoPictureWithTextOverlayWidget::applyCardTextFont); } /** @@ -97,6 +100,13 @@ void CardInfoPictureWithTextOverlayWidget::setTextAlignment(const Qt::Alignment update(); } +void CardInfoPictureWithTextOverlayWidget::applyCardTextFont() +{ + // The overlay paint reads the CardText role font at paint time, so a + // theme font change only needs a repaint here. + update(); +} + void CardInfoPictureWithTextOverlayWidget::setHighlighted(bool _highlighted) { if (highlighted == _highlighted) { @@ -168,8 +178,10 @@ void CardInfoPictureWithTextOverlayWidget::paintEvent(QPaintEvent *event) return; } - // Calculate the optimal font size - QFont font = painter.font(); + // Calculate the optimal font size. The family comes from the CardText + // theme role; the fontSize member stays the starting size and the + // shrink-to-fit logic below is unchanged. + QFont font = themeManager->appFont(AppFont::CardText); int optimalFontSize = fontSize; // Start with the user-defined font size const QFontMetrics baseMetrics(font); int textWidth = pixmapRect.width(); @@ -284,7 +296,7 @@ QSize CardInfoPictureWithTextOverlayWidget::minimumSizeHint() const const QSize pixmapSize = pixmap.isNull() ? QSize(0, 0) : pixmap.size(); // Get the font metrics for the overlay text - QFont font; + QFont font = themeManager->appFont(AppFont::CardText); font.setPointSize(fontSize); const QFontMetrics fontMetrics(font); diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.h b/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.h index 6c547dc13..41019f543 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.h @@ -47,6 +47,9 @@ protected: void mousePressEvent(QMouseEvent *event) override; [[nodiscard]] QSize minimumSizeHint() const override; +private slots: + void applyCardTextFont(); + private: void drawOutlinedText(QPainter &painter, const QRect &textRect, diff --git a/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp index 218d0e964..a94f01085 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp @@ -23,12 +23,25 @@ #include #include +namespace +{ +constexpr int TAGS_PREVIEW_LIMIT = 3; +const QString SHOW_ALL_TAGS_LINK = QStringLiteral("cockatrice://show-all-tags"); +} // namespace + CardInfoTextWidget::CardInfoTextWidget(QWidget *parent) : QFrame(parent) { propsLabel = new QLabel; propsLabel->setOpenExternalLinks(false); propsLabel->setWordWrap(true); - connect(propsLabel, SIGNAL(linkActivated(const QString &)), this, SIGNAL(linkActivated(const QString &))); + connect(propsLabel, &QLabel::linkActivated, this, [this](const QString &link) { + if (link == SHOW_ALL_TAGS_LINK) { + showAllTags = true; + setCard(currentCard); + return; + } + emit linkActivated(link); + }); textLabel = new QTextEdit(); textLabel->setReadOnly(true); @@ -75,6 +88,12 @@ void CardInfoTextWidget::setCard(const ExactCard &exactCard) return; } + // Expanding the tag list re-renders the same card, so only reset the + // collapsed state when a genuinely different card is shown. + if (exactCard != currentCard) { + showAllTags = false; + } + QString text = ""; text += QString("") .arg(tr("Name:"), CardLocalization::displayName(card).toHtmlEscaped()); @@ -94,6 +113,27 @@ void CardInfoTextWidget::setCard(const ExactCard &exactCard) continue; } QString keyText = Mtg::getNicePropertyName(key).toHtmlEscaped() + ":"; + + if (key == Mtg::Tags) { + const QStringList tags = card->getProperty(Mtg::Tags).split(" ", Qt::SkipEmptyParts); + + QString tagsText; + if (!showAllTags && tags.size() > TAGS_PREVIEW_LIMIT) { + const QStringList preview = tags.mid(0, TAGS_PREVIEW_LIMIT); + const int remaining = tags.size() - preview.size(); + // Join with non-breaking spaces so "… and N more" never wraps mid-phrase. + const QString moreText = + tr("and %n more", nullptr, remaining).toHtmlEscaped().split(" ").join(" "); + tagsText = preview.join(", ").toHtmlEscaped() + + QString(" … %2").arg(SHOW_ALL_TAGS_LINK, moreText); + } else { + tagsText = tags.join(", ").toHtmlEscaped(); + } + + text += QString("").arg(keyText, tagsText); + continue; + } + text += QString("").arg(keyText, card->getProperty(key).toHtmlEscaped()); } diff --git a/cockatrice/src/interface/widgets/cards/card_info_text_widget.h b/cockatrice/src/interface/widgets/cards/card_info_text_widget.h index 8223093fe..eae2fb8e3 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_text_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_info_text_widget.h @@ -26,7 +26,8 @@ private: QLabel *propsLabel; QScrollArea *propsScroll; QTextEdit *textLabel; - ExactCard currentCard; ///< Last card set, re-rendered when the card language changes. + ExactCard currentCard; ///< Last card set, re-rendered when the card language changes. + bool showAllTags = false; ///< Whether the potentially long tag list is fully expanded. void setTexts(const QString &propsText, const QString &textText); public: diff --git a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp index d7eef6c18..3ec2053b1 100644 --- a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp @@ -69,6 +69,7 @@ DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent, updateZoneCardCount(); connect(deckListModel, &QAbstractItemModel::rowsInserted, this, &DeckCardZoneDisplayWidget::onCategoryAddition); + connect(deckListModel, &QAbstractItemModel::layoutChanged, this, &DeckCardZoneDisplayWidget::displayCards); if (selectionModel) { connect(selectionModel, &QItemSelectionModel::selectionChanged, this, &DeckCardZoneDisplayWidget::onSelectionChanged); @@ -174,6 +175,9 @@ void DeckCardZoneDisplayWidget::displayCards() for (const QPersistentModelIndex &persistent : rows) { constructAppropriateWidget(persistent); + // Reuse existing widgets when a model sort moves their persistent rows. + // This preserves collapsed groups and card selection during live edits. + cardGroupLayout->insertWidget(persistent.row(), indexToWidgetMap.value(persistent)); } } diff --git a/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.cpp b/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.cpp index 730043d95..c8571ab08 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.cpp @@ -1,6 +1,6 @@ #include "abstract_analytics_panel_widget.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../general/display/banner_widget.h" #include "deck_list_statistics_analyzer.h" @@ -28,7 +28,7 @@ AbstractAnalyticsPanelWidget::AbstractAnalyticsPanelWidget(QWidget *parent, Deck // config button configureButton = new QPushButton(this); - configureButton->setIcon(themePixmap(QStringLiteral("icons/cogwheel"))); + configureButton->setIcon(loadThemedIcon(QStringLiteral("icons/settings"))); configureButton->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); connect(configureButton, &QPushButton::clicked, this, &AbstractAnalyticsPanelWidget::applyConfigFromDialog); bannerAndSettingsLayout->addWidget(configureButton, 0); diff --git a/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp b/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp index 85dba6468..df2a645f1 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp @@ -1,5 +1,6 @@ #include "resizable_panel.h" +#include "../interface/theme_manager.h" #include "abstract_analytics_panel_widget.h" #include "libcockatrice/utility/qt_utils.h" @@ -12,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -93,9 +95,17 @@ ResizablePanel::ResizablePanel(const QString &_typeId, AbstractAnalyticsPanelWid dragButton->setStyleSheet(QString("QPushButton { " "background: qlineargradient(x1:0, y1:0, x2:0, y2:1, stop:0 %1, stop:1 %2); " - "border: none; color: %3; font-size: 10px; }" + "border: none; color: %3; }" "QPushButton:hover { background: %4; }") .arg(mid.name(), dark.name(), placeholderText.name(), midLight.name())); + // Family comes from the InterfaceDefault theme role; the 10px size stays caller-side. + auto applyDragFont = [this]() { + QFont dragFont = themeManager->appFont(AppFont::InterfaceDefault); + dragFont.setPixelSize(10); + dragButton->setFont(dragFont); + }; + applyDragFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, applyDragFont); resizeHandle->setStyleSheet(QString("QWidget { background: qlineargradient(x1:0, y1:0, x2:0, y2:1, " "stop:0 %1, stop:1 %2); }") diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp index 7ed398535..4a9701da2 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp @@ -3,7 +3,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../filters/syntax_help.h" #include "../../key_signals.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../utility/custom_line_edit.h" #include "card_database_display_model.h" #include "card_database_model.h" @@ -40,8 +40,10 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent searchEdit->setObjectName("searchEdit"); searchEdit->setPlaceholderText(tr("Search by card name (or search expressions)")); searchEdit->setClearButtonEnabled(true); - searchEdit->addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition); - auto help = searchEdit->addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); + searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/search"), IconTint::quiet()), + QLineEdit::LeadingPosition); + auto help = searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/info"), IconTint::quiet()), + QLineEdit::TrailingPosition); setFocusProxy(searchEdit); setFocusPolicy(Qt::ClickFocus); @@ -83,13 +85,16 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent &DeckEditorDatabaseDisplayWidget::onRelatedCardClicked); aAddCard = new QAction(QString(), this); - aAddCard->setIcon(themePixmap(QStringLiteral("icons/arrow_right_green"))); + aAddCard->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right"), + IconTint(AppColor::Positive, IconEmphasis::Primary), IconStyle::Sticker)); connect(aAddCard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToMainDeck); auto *tbAddCard = new QToolButton(this); tbAddCard->setDefaultAction(aAddCard); aAddCardToSideboard = new QAction(QString(), this); - aAddCardToSideboard->setIcon(themePixmap(QStringLiteral("icons/arrow_right_blue"))); + // Sideboard is a destination, not a positive state: its distinct glyph + // keeps the target legible without competing with the green main-deck add. + aAddCardToSideboard->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right_sideboard"))); connect(aAddCardToSideboard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToSideboard); auto *tbAddCardToSideboard = new QToolButton(this); tbAddCardToSideboard->setDefaultAction(aAddCardToSideboard); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp index 37dd57459..5df8db7cc 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp @@ -3,7 +3,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" #include "../../key_signals.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../playmat/playmat_settings_dialog.h" #include "../quick_settings/settings_button_widget.h" #include "../tabs/abstract_tab_deck_editor.h" @@ -49,6 +49,7 @@ #include #include #include +#include #include #include #include @@ -187,10 +188,17 @@ void DeckEditorDeckDockWidget::createDeckDock() formatLabel = new QLabel(this); formatComboBox = new QComboBox(this); + formatComboBox->setObjectName("deckFormatComboBox"); formatComboBox->addItem(tr("Loading Database...")); formatComboBox->setEnabled(false); // Disable until loaded commanderBracketWidget = new CommanderBracketWidget(this); + connect(formatComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int index) { + formatComboBox->setPlaceholderText(QString()); + formatComboBox->setToolTip(QString()); + deckStateManager->setFormat(index >= 0 ? formatComboBox->itemData(index).toString() : QString()); + commanderBracketWidget->setDeck(deckStateManager->getDeckListShared()); + }); commentsLabel = new QLabel(); commentsLabel->setObjectName("commentsLabel"); @@ -240,25 +248,25 @@ void DeckEditorDeckDockWidget::createDeckDock() &DeckEditorDeckDockWidget::applyActiveGroupCriteria); aIncrement = new QAction(QString(), this); - aIncrement->setIcon(themePixmap(QStringLiteral("icons/increment"))); + aIncrement->setIcon(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive, IconStyle::Sticker)); connect(aIncrement, &QAction::triggered, this, &DeckEditorDeckDockWidget::actIncrementSelection); auto *tbIncrement = new QToolButton(this); tbIncrement->setDefaultAction(aIncrement); aDecrement = new QAction(QString(), this); - aDecrement->setIcon(themePixmap(QStringLiteral("icons/decrement"))); + aDecrement->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative, IconStyle::Sticker)); connect(aDecrement, &QAction::triggered, this, &DeckEditorDeckDockWidget::actDecrementSelection); auto *tbDecrement = new QToolButton(this); tbDecrement->setDefaultAction(aDecrement); aRemoveCard = new QAction(QString(), this); - aRemoveCard->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + aRemoveCard->setIcon(loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker)); connect(aRemoveCard, &QAction::triggered, this, &DeckEditorDeckDockWidget::actRemoveCard); auto *tbRemoveCard = new QToolButton(this); tbRemoveCard->setDefaultAction(aRemoveCard); aSwapCard = new QAction(QString(), this); - aSwapCard->setIcon(themePixmap(QStringLiteral("icons/swap"))); + aSwapCard->setIcon(loadThemedIcon(QStringLiteral("icons/swap"))); connect(aSwapCard, &QAction::triggered, this, &DeckEditorDeckDockWidget::actSwapSelection); auto *tbSwapCard = new QToolButton(this); tbSwapCard->setDefaultAction(aSwapCard); @@ -355,6 +363,9 @@ void DeckEditorDeckDockWidget::initializeFormats() { QStringList allFormats = CardDatabaseManager::query()->getAllFormatsWithCount().keys(); + // Rebuilding the list is a view update, not a user edit. In particular, + // clear() emits currentIndexChanged(-1), which must not clear the deck. + const QSignalBlocker blocker(formatComboBox); formatComboBox->clear(); // Remove "Loading Database..." formatComboBox->setEnabled(true); @@ -364,28 +375,25 @@ void DeckEditorDeckDockWidget::initializeFormats() formatComboBox->addItem(formatName, formatName); // store the raw key in itemData } - QString format = deckStateManager->getMetadata().gameFormat; - if (!format.isEmpty()) { - formatComboBox->setCurrentIndex(formatComboBox->findData(format)); - } else { - // Ensure no selection is visible initially - formatComboBox->setCurrentIndex(-1); - } - connect(formatComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int index) { - QString formatKey; - if (index >= 0) { - formatKey = formatComboBox->itemData(index).toString(); - deckStateManager->setFormat(formatKey); - } else { - deckStateManager->setFormat(""); // clear format if deselected - } - - commanderBracketWidget->setDeck(deckStateManager->getDeckListShared()); - }); + syncFormatSelection(); commanderBracketWidget->setDeck(deckStateManager->getDeckListShared()); } +void DeckEditorDeckDockWidget::syncFormatSelection() +{ + if (!formatComboBox->isEnabled()) { + return; + } + const QSignalBlocker blocker(formatComboBox); + const QString format = deckStateManager->getMetadata().gameFormat; + const int index = format.isEmpty() ? -1 : formatComboBox->findData(format); + const QString unavailable = index < 0 && !format.isEmpty() ? tr("Unavailable: %1").arg(format) : QString(); + formatComboBox->setPlaceholderText(unavailable); + formatComboBox->setToolTip(unavailable); + formatComboBox->setCurrentIndex(index); +} + ExactCard DeckEditorDeckDockWidget::getCurrentCard() { QModelIndex current = deckView->selectionModel()->currentIndex(); @@ -596,9 +604,7 @@ void DeckEditorDeckDockWidget::syncDisplayWidgetsToModel() updatePlaymatLabel(); updateHash(); - formatComboBox->blockSignals(true); - formatComboBox->setCurrentIndex(formatComboBox->findData(deckStateManager->getMetadata().gameFormat)); - formatComboBox->blockSignals(false); + syncFormatSelection(); commanderBracketWidget->setDeck(deckStateManager->getDeckListShared()); @@ -762,18 +768,6 @@ void DeckEditorDeckDockWidget::actSwapSelection() update(); } -void DeckEditorDeckDockWidget::actDecrementCard(const ExactCard &card, QString zoneName) -{ - if (!card) { - return; - } - if (card.getInfo().getIsToken()) { - zoneName = DECK_ZONE_TOKENS; - } - - deckStateManager->decrementCard(card, zoneName); -} - void DeckEditorDeckDockWidget::actDecrementSelection() { auto selectedRows = getSelectedCardNodeSourceIndices(); @@ -1054,6 +1048,7 @@ void DeckEditorDeckDockWidget::retranslateUi() commentsLabel->setText(tr("&Comments:")); activeGroupCriteriaLabel->setText(tr("Group by:")); formatLabel->setText(tr("Format:")); + syncFormatSelection(); commanderBracketWidget->retranslateUi(); hashLabel1->setText(tr("Hash:")); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h index 97ab8823e..02910e390 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h @@ -70,7 +70,6 @@ public slots: void syncDisplayWidgetsToModel(); void actAddCard(const ExactCard &card, const QString &zoneName); void actIncrementSelection(); - void actDecrementCard(const ExactCard &card, QString zoneName); void actDecrementSelection(); void actSwapCard(const ExactCard &card, const QString &zoneName); void actSwapSelection(); @@ -111,6 +110,7 @@ private: DeckListModel *getModel() const; [[nodiscard]] QModelIndexList getSelectedCardNodeSourceIndices() const; void offsetCountAtIndex(const QModelIndex &idx, bool isIncrement); + void syncFormatSelection(); void addMoveToZoneMenu(QMenu *menu, const QModelIndex &sourceCardIndex, const QString ¤tBoardName); void addChangeBoardMenu(QMenu *menu, const QString &zoneName); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp index adeb5c3e1..6e4701f53 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.cpp @@ -5,7 +5,7 @@ #include "../../../filters/filter_builder.h" #include "../../../filters/filter_tree_model.h" #include "../../key_signals.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../tabs/abstract_tab_deck_editor.h" #include "libcockatrice/filters/filter_card.h" @@ -64,11 +64,12 @@ void DeckEditorFilterDockWidget::createFiltersDock() connect(filterBuilder, &FilterBuilder::add, filterModel, &FilterTreeModel::addFilter); aClearFilterOne = new QAction(QString(), this); - aClearFilterOne->setIcon(themePixmap(QStringLiteral("icons/decrement"))); + aClearFilterOne->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative, IconStyle::Sticker)); connect(aClearFilterOne, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterOne); aClearFilterAll = new QAction(QString(), this); - aClearFilterAll->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); + aClearFilterAll->setIcon( + loadThemedIcon(QStringLiteral("icons/clearsearch"), AppColor::Negative, IconStyle::Sticker)); connect(aClearFilterAll, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterAll); auto *filterDelOne = new QToolButton(); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.cpp index 209dc7273..a1914e1b5 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.cpp @@ -1,6 +1,6 @@ #include "deck_list_history_manager_widget.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../quick_settings/settings_button_widget.h" #include "deck_state_manager.h" #include "libcockatrice/deck_list/deck_list_history_manager.h" @@ -32,7 +32,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de layout = new QHBoxLayout(this); aUndo = new QAction(QString(), this); - aUndo->setIcon(themePixmap(QStringLiteral("icons/arrow_undo"))); + aUndo->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_undo"))); aUndo->setShortcut(QKeySequence::Undo); aUndo->setShortcutContext(Qt::ApplicationShortcut); connect(aUndo, &QAction::triggered, this, &DeckListHistoryManagerWidget::doUndo); @@ -41,7 +41,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de undoButton->setDefaultAction(aUndo); aRedo = new QAction(QString(), this); - aRedo->setIcon(themePixmap(QStringLiteral("icons/arrow_redo"))); + aRedo->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_redo"))); aRedo->setShortcut(QKeySequence::Redo); aRedo->setShortcutContext(Qt::ApplicationShortcut); connect(aRedo, &QAction::triggered, this, &DeckListHistoryManagerWidget::doRedo); @@ -53,7 +53,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de layout->addWidget(redoButton); historyButton = new SettingsButtonWidget(this); - historyButton->setButtonIcon(themePixmap(QStringLiteral("icons/arrow_history"))); + historyButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/arrow_history"))); historyLabel = new QLabel(this); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.cpp index 1e1aefe58..a9c9cad7a 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.cpp @@ -1,5 +1,7 @@ #include "deck_list_style_proxy.h" +#include "../../theme_manager.h" + #include #include #include @@ -8,6 +10,16 @@ #include #include +DeckListStyleProxy::DeckListStyleProxy(QObject *parent) : QIdentityProxyModel(parent) +{ + // FontRole data reads the DeckList role live; rebroadcast so attached + // views re-query instead of waiting for an incidental repaint. + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { + emit layoutAboutToBeChanged(); + emit layoutChanged(); + }); +} + QVariant DeckListStyleProxy::data(const QModelIndex &index, int role) const { QModelIndex src = mapToSource(index); @@ -20,7 +32,8 @@ QVariant DeckListStyleProxy::data(const QModelIndex &index, int role) const bool isCard = src.data(DeckRoles::IsCardRole).toBool(); if (role == Qt::FontRole && !isCard) { - QFont f; + // Family comes from the DeckList theme role; bold stays caller-side. + QFont f = themeManager->appFont(AppFont::DeckList); f.setBold(true); return f; } diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.h b/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.h index e4d1de4f4..3149da6de 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.h @@ -8,7 +8,7 @@ class DeckListStyleProxy : public QIdentityProxyModel { Q_OBJECT public: - using QIdentityProxyModel::QIdentityProxyModel; + explicit DeckListStyleProxy(QObject *parent = nullptr); [[nodiscard]] QVariant data(const QModelIndex &index, int role) const override; }; diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp index a7e231ecf..cbf9dd3c4 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp @@ -211,15 +211,15 @@ QModelIndex DeckStateManager::addCard(const ExactCard &card, const QString &zone return {}; } - QString zone = card.getInfo().getIsToken() ? DECK_ZONE_TOKENS : zoneName; + QString targetZone = card.getInfo().getIsToken() ? DECK_ZONE_TOKENS : zoneName; CardSetPtr set = card.getPrinting().getSet(); QString setName = set ? set->getCorrectedShortName() : ""; QString reason = - tr("Added (%1): %2 (%3) %4").arg(zone, card.getName(), setName, card.getPrinting().getProperty("num")); + tr("Added (%1): %2 (%3) %4").arg(targetZone, card.getName(), setName, card.getPrinting().getProperty("num")); - QModelIndex idx = modifyDeck(reason, [&card, &zone](auto model) { return model->addCard(card, zone); }); + QModelIndex idx = modifyDeck(reason, [&card, &targetZone](auto model) { return model->addCard(card, targetZone); }); if (idx.isValid()) { emit focusIndexChanged(idx, true); @@ -234,10 +234,22 @@ QModelIndex DeckStateManager::decrementCard(const ExactCard &card, const QString return {}; } + QString targetZone = zoneName; + QString providerId = card.getPrinting().getUuid(); QString collectorNumber = card.getPrinting().getProperty("num"); - QModelIndex idx = deckListModel->findCard(card.getName(), zoneName, providerId, collectorNumber); + QModelIndex idx = deckListModel->findCard(card.getName(), targetZone, providerId, collectorNumber); + + // Tokens are usually added to the token zone, but it is possible for imported/hand-edited + // decks to contain them in the main or sideboards. This is why we only try to remove the + // token from the token zone if it does not exist in the given zone, allowing us to still + // remove it from there. + if (!idx.isValid() && card.getInfo().getIsToken() && targetZone != DECK_ZONE_TOKENS) { + targetZone = DECK_ZONE_TOKENS; + idx = deckListModel->findCard(card.getName(), targetZone, providerId, collectorNumber); + } + if (!idx.isValid()) { return {}; } @@ -249,7 +261,7 @@ QModelIndex DeckStateManager::decrementCard(const ExactCard &card, const QString } // old index is no longer safe since rows could have been removed - QModelIndex newIdx = deckListModel->findCard(card.getName(), zoneName, providerId, collectorNumber); + QModelIndex newIdx = deckListModel->findCard(card.getName(), targetZone, providerId, collectorNumber); if (newIdx.isValid()) { emit focusIndexChanged(newIdx, true); } diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h index 54c59eb79..0612147eb 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h @@ -188,17 +188,18 @@ public: ///@} /** - * @brief Adds the given card to the given zone. + * @brief Adds the given card to the given zone. Tokens always go to DECK_ZONE_TOKENS, ignoring the given zone. * Saves the operation to history if successful. * * @param card The card to add - * @param zoneName The zone to add the card to + * @param zoneName The zone to add the card to if the card is not a token * @return The index of the added card */ QModelIndex addCard(const ExactCard &card, const QString &zoneName); /** * @brief Removes 1 copy of the given card from the given zone. + * If the card is a token and is not in the given zone, then it is removed from DECK_ZONE_TOKENS instead. * Saves the operation to history if successful. * * @param card The card to remove diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp index 1f567989e..a30076519 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp @@ -1,7 +1,7 @@ #include "dlg_connect.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../server/handle_public_servers.h" #include "../server/user/user_info_connection.h" #include "libcockatrice/utility/macros.h" @@ -33,11 +33,13 @@ class QWidget; DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) { + setObjectName("connectDialog"); previousHostButton = new QRadioButton(tr("Known Hosts"), this); previousHosts = new QComboBox(this); btnDeleteServer = new QPushButton(this); - btnDeleteServer->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + btnDeleteServer->setIcon( + loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker)); btnDeleteServer->setToolTip(tr("Delete the currently selected saved server")); btnDeleteServer->setFixedWidth(30); @@ -45,7 +47,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) hps = new HandlePublicServers(this); btnRefreshServers = new QPushButton(this); - btnRefreshServers->setIcon(themePixmap(QStringLiteral("icons/sync"))); + btnRefreshServers->setIcon(loadThemedIcon(QStringLiteral("icons/sync"))); btnRefreshServers->setToolTip(tr("Refresh the server list with known public servers")); btnRefreshServers->setFixedWidth(30); @@ -85,6 +87,11 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) passwordLabel->setBuddy(passwordEdit); passwordEdit->setEchoMode(QLineEdit::Password); + hostEdit->setObjectName("connectHostEdit"); + portEdit->setObjectName("connectPortEdit"); + playernameEdit->setObjectName("connectPlayernameEdit"); + passwordEdit->setObjectName("connectPasswordEdit"); + savePasswordCheckBox = new QCheckBox(tr("&Save password")); autoConnectCheckBox = new QCheckBox(tr("A&uto connect")); @@ -115,7 +122,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) updateDisplayInfo(previousHosts->currentText()); btnForgotPassword = new QPushButton(this); - btnForgotPassword->setIcon(themePixmap(QStringLiteral("icons/forgot_password"))); + btnForgotPassword->setIcon(loadThemedIcon(QStringLiteral("icons/forgot_password"))); btnForgotPassword->setToolTip(tr("Reset Password")); btnForgotPassword->setFixedWidth(30); connect(btnForgotPassword, &QPushButton::released, this, &DlgConnect::actForgotPassword); @@ -290,11 +297,13 @@ void DlgConnect::updateDisplayInfo(const QString &saveName) playernameEdit->setFocus(); savePasswordCheckBox->setChecked(savePasswordStatus); storedVerifier.clear(); + savedVerifier.clear(); if (savePasswordStatus) { const QString stored = _data.at(4); if (stored.startsWith("$")) { storedVerifier = stored; + savedVerifier = stored; passwordEdit->clear(); passwordEdit->setPlaceholderText(tr("Password saved")); } else { @@ -331,6 +340,7 @@ void DlgConnect::newHostSelected(bool state) passwordEdit->clear(); passwordEdit->setPlaceholderText(QString()); storedVerifier.clear(); + savedVerifier.clear(); saveEdit->clear(); saveEdit->setPlaceholderText(tr("Unique Server Name")); saveEdit->setDisabled(false); @@ -346,9 +356,19 @@ void DlgConnect::passwordSaved(QT_STATE_CHANGED_T state) Q_UNUSED(state); if (savePasswordCheckBox->isChecked()) { autoConnectCheckBox->setEnabled(true); + // Re-ticking keeps a password the user has started typing; otherwise restore the saved + // login this dialog was opened with, so unticking and changing your mind is harmless. + if (passwordEdit->text().isEmpty()) { + storedVerifier = savedVerifier; + passwordEdit->setPlaceholderText(savedVerifier.isEmpty() ? QString() : tr("Password saved")); + } } else { autoConnectCheckBox->setChecked(false); autoConnectCheckBox->setEnabled(false); + // Unticking means the password typed below is sent for this connection: stop sending the + // saved verifier instead, and stop claiming a password is saved. + storedVerifier.clear(); + passwordEdit->setPlaceholderText(QString()); } } @@ -356,10 +376,11 @@ void DlgConnect::actOk() { ServersSettings &servers = SettingsCache::instance().servers(); - // Never write a newly typed plaintext password to disk when a verifier is already stored: - // the typed value is used for this connection and a fresh verifier is persisted after a - // successful login. Without a stored verifier we keep the previous (plaintext legacy) behavior. - const QString passwordToSave = storedVerifier.isEmpty() ? passwordEdit->text() : storedVerifier; + // Never write a newly typed plaintext password to disk when a verifier is available: the + // typed value is used for this connection and a fresh verifier is persisted after a + // successful login. Without any verifier we keep the previous (plaintext legacy) behavior. + const QString verifierToSave = storedVerifier.isEmpty() ? savedVerifier : storedVerifier; + const QString passwordToSave = verifierToSave.isEmpty() ? passwordEdit->text() : verifierToSave; if (newHostButton->isChecked()) { if (saveEdit->text().isEmpty()) { diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_connect.h b/cockatrice/src/interface/widgets/dialogs/dlg_connect.h index bef5f136e..dad3623f5 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.h @@ -98,6 +98,10 @@ private: QPushButton *btnConnect, *btnForgotPassword, *btnRefreshServers, *btnDeleteServer; QMap> savedHostList; HandlePublicServers *hps; + //! \brief Saved "$scrypt$..." verifier as loaded for the selected profile, kept so that + //! re-ticking "Save password" after unticking restores it. + QString savedVerifier; + //! \brief Verifier in effect for this connection: empty while "Save password" is unticked. QString storedVerifier; const QString placeHolderText = tr("Downloading..."); }; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp index 104b695e6..f5ba5458c 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp @@ -1,325 +1,109 @@ #include "dlg_create_game.h" #include "../../../client/settings/cache_settings.h" -#include "../interface/widgets/tabs/tab_room.h" +#include "../game_hosting/create_game_form_widget.h" +#include "../game_hosting/game_creation_options.h" +#include "../tabs/tab_room.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/room_commands.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_game.pb.h" #include "libcockatrice/protocol/pb/serverinfo_user.pb.h" -#include "libcockatrice/utility/macros.h" +#include "libcockatrice/protocol/pending_command.h" +#include "libcockatrice/settings/game_settings.h" -#include -#include #include -#include -#include #include -#include -#include -#include #include -#include -#include -#include -#include -#include #include -#include -#include -#include -#include -#include -#include - -class QWidget; - -void DlgCreateGame::sharedCtor() -{ - rememberGameSettings = new QCheckBox(tr("Re&member settings")); - descriptionLabel = new QLabel(tr("&Description:")); - descriptionEdit = new QLineEdit; - descriptionEdit->setMaxLength(MAX_NAME_LENGTH); - descriptionLabel->setBuddy(descriptionEdit); - - maxPlayersLabel = new QLabel(tr("P&layers:")); - maxPlayersEdit = new QSpinBox(); - maxPlayersEdit->setMinimum(1); - maxPlayersEdit->setMaximum(100); - maxPlayersEdit->setValue(2); - maxPlayersLabel->setBuddy(maxPlayersEdit); - - auto *generalGrid = new QGridLayout; - generalGrid->addWidget(descriptionLabel, 0, 0); - generalGrid->addWidget(descriptionEdit, 0, 1); - generalGrid->addWidget(maxPlayersLabel, 1, 0); - generalGrid->addWidget(maxPlayersEdit, 1, 1); - generalGroupBox = new QGroupBox(tr("General")); - generalGroupBox->setLayout(generalGrid); - - auto *gameTypeLayout = new QVBoxLayout; - QMapIterator gameTypeIterator(gameTypes); - while (gameTypeIterator.hasNext()) { - gameTypeIterator.next(); - auto *gameTypeRadioButton = new QRadioButton(gameTypeIterator.value(), this); - gameTypeLayout->addWidget(gameTypeRadioButton); - gameTypeCheckBoxes.insert(gameTypeIterator.key(), gameTypeRadioButton); - bool isChecked = SettingsCache::instance().game().getGameTypes().contains(gameTypeIterator.value() + ", "); - gameTypeCheckBoxes[gameTypeIterator.key()]->setChecked(isChecked); - } - auto *gameTypeGroupBox = new QGroupBox(tr("Game type")); - gameTypeGroupBox->setLayout(gameTypeLayout); - - passwordLabel = new QLabel(tr("&Password:")); - passwordEdit = new QLineEdit; - passwordEdit->setMaxLength(MAX_NAME_LENGTH); - passwordLabel->setBuddy(passwordEdit); - - onlyBuddiesCheckBox = new QCheckBox(tr("Only &buddies can join")); - onlyRegisteredCheckBox = new QCheckBox(tr("Only ®istered users can join")); - if (room && room->getUserInfo()->user_level() & ServerInfo_User::IsRegistered) { - onlyRegisteredCheckBox->setChecked(true); - } else { - onlyBuddiesCheckBox->setEnabled(false); - onlyRegisteredCheckBox->setEnabled(false); - } - - auto *joinRestrictionsLayout = new QGridLayout; - joinRestrictionsLayout->addWidget(passwordLabel, 0, 0); - joinRestrictionsLayout->addWidget(passwordEdit, 0, 1); - joinRestrictionsLayout->addWidget(onlyBuddiesCheckBox, 1, 0, 1, 2); - joinRestrictionsLayout->addWidget(onlyRegisteredCheckBox, 2, 0, 1, 2); - - auto *joinRestrictionsGroupBox = new QGroupBox(tr("Joining restrictions")); - joinRestrictionsGroupBox->setLayout(joinRestrictionsLayout); - - spectatorsAllowedCheckBox = new QCheckBox(tr("&Spectators can watch")); - spectatorsAllowedCheckBox->setChecked(true); - connect(spectatorsAllowedCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &DlgCreateGame::spectatorsAllowedChanged); - spectatorsNeedPasswordCheckBox = new QCheckBox(tr("Spectators &need a password to watch")); - spectatorsCanTalkCheckBox = new QCheckBox(tr("Spectators can &chat")); - spectatorsSeeEverythingCheckBox = new QCheckBox(tr("Spectators can see &hands")); - createGameAsSpectatorCheckBox = new QCheckBox(tr("Create game as spectator")); - auto *spectatorsLayout = new QVBoxLayout; - spectatorsLayout->addWidget(spectatorsAllowedCheckBox); - spectatorsLayout->addWidget(spectatorsNeedPasswordCheckBox); - spectatorsLayout->addWidget(spectatorsCanTalkCheckBox); - spectatorsLayout->addWidget(spectatorsSeeEverythingCheckBox); - spectatorsLayout->addWidget(createGameAsSpectatorCheckBox); - spectatorsGroupBox = new QGroupBox(tr("Spectators")); - spectatorsGroupBox->setLayout(spectatorsLayout); - - startingLifeTotalLabel = new QLabel(tr("Starting life total:")); - startingLifeTotalEdit = new QSpinBox(); - startingLifeTotalEdit->setMinimum(1); - startingLifeTotalEdit->setMaximum(99999); ///< Arbitrary but we can raise this when people start complaining. - startingLifeTotalEdit->setValue(20); - startingLifeTotalLabel->setBuddy(startingLifeTotalEdit); - - shareDecklistsOnLoadCheckBox = new QCheckBox(tr("Open decklists in lobby")); - - createGameAsJudgeCheckBox = new QCheckBox(tr("Create game as judge")); - - auto *gameSetupOptionsLayout = new QGridLayout; - gameSetupOptionsLayout->addWidget(startingLifeTotalLabel, 0, 0); - gameSetupOptionsLayout->addWidget(startingLifeTotalEdit, 0, 1); - gameSetupOptionsLayout->addWidget(shareDecklistsOnLoadCheckBox, 1, 0); - if (room && room->getUserInfo()->user_level() & ServerInfo_User::IsJudge) { - gameSetupOptionsLayout->addWidget(createGameAsJudgeCheckBox, 2, 0); - } else { - createGameAsJudgeCheckBox->setChecked(false); - createGameAsJudgeCheckBox->setHidden(true); - } - gameSetupOptionsGroupBox = new QGroupBox(tr("Game setup options")); - gameSetupOptionsGroupBox->setLayout(gameSetupOptionsLayout); - - auto *grid = new QGridLayout; - - // Top row - grid->addWidget(generalGroupBox, 0, 0); - grid->addWidget(joinRestrictionsGroupBox, 0, 1); - - // Middle row: left column - grid->addWidget(gameTypeGroupBox, 1, 0); - - // Middle row: right column (game setup + spectators) - auto *rightLayout = new QVBoxLayout; - rightLayout->addWidget(spectatorsGroupBox, Qt::AlignTop); // top - rightLayout->addWidget(gameSetupOptionsGroupBox); // bottom - - grid->addLayout(rightLayout, 1, 1); - - // Bottom row - grid->addWidget(rememberGameSettings, 3, 0, 1, 2); // span both columns if needed - - buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok); - connect(buttonBox, &QDialogButtonBox::rejected, this, &DlgCreateGame::reject); - - auto *mainLayout = new QVBoxLayout; - mainLayout->addLayout(grid); - mainLayout->addWidget(buttonBox); - - setLayout(mainLayout); - - setFixedHeight(sizeHint().height()); -} DlgCreateGame::DlgCreateGame(TabRoom *_room, const QMap &_gameTypes, QWidget *parent) - : QDialog(parent), room(_room), gameTypes(_gameTypes) + : QDialog(parent), room(_room) { - sharedCtor(); + const bool registered = room && (room->getUserInfo()->user_level() & ServerInfo_User::IsRegistered); + const bool judge = room && (room->getUserInfo()->user_level() & ServerInfo_User::IsJudge); - rememberGameSettings->setChecked(SettingsCache::instance().game().getRememberGameSettings()); - descriptionEdit->setText(SettingsCache::instance().game().getGameDescription()); - maxPlayersEdit->setValue(SettingsCache::instance().game().getMaxPlayers()); - if (room && room->getUserInfo()->user_level() & ServerInfo_User::IsRegistered) { - onlyBuddiesCheckBox->setChecked(SettingsCache::instance().game().getOnlyBuddies()); - onlyRegisteredCheckBox->setChecked(SettingsCache::instance().game().getOnlyRegistered()); + form = new CreateGameFormWidget(_gameTypes, registered, judge, this); + const bool rememberSettings = SettingsCache::instance().game().getRememberGameSettings(); + form->setRememberSettingsChecked(rememberSettings); + if (rememberSettings) { + form->setOptions(GameCreationOptions::fromSettings(SettingsCache::instance().game(), _gameTypes, registered)); } else { - onlyBuddiesCheckBox->setEnabled(false); - onlyRegisteredCheckBox->setEnabled(false); + form->resetToDefaults(); } - spectatorsAllowedCheckBox->setChecked(SettingsCache::instance().game().getSpectatorsAllowed()); - spectatorsNeedPasswordCheckBox->setChecked(SettingsCache::instance().game().getSpectatorsNeedPassword()); - spectatorsCanTalkCheckBox->setChecked(SettingsCache::instance().game().getSpectatorsCanTalk()); - spectatorsSeeEverythingCheckBox->setChecked(SettingsCache::instance().game().getSpectatorsCanSeeEverything()); - createGameAsSpectatorCheckBox->setChecked(SettingsCache::instance().game().getCreateGameAsSpectator()); - startingLifeTotalEdit->setValue(SettingsCache::instance().game().getDefaultStartingLifeTotal()); - shareDecklistsOnLoadCheckBox->setChecked(SettingsCache::instance().game().getShareDecklistsOnLoad()); + form->focusDescription(); - if (!rememberGameSettings->isChecked()) { - actReset(); - } + errorLabel = new QLabel(this); + errorLabel->setObjectName("createGameErrorLabel"); + errorLabel->setWordWrap(true); + errorLabel->setVisible(false); - descriptionEdit->setFocus(); - clearButton = new QPushButton(tr("&Clear")); + buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok, this); + connect(buttonBox, &QDialogButtonBox::rejected, this, &DlgCreateGame::reject); + clearButton = new QPushButton(tr("&Clear"), this); buttonBox->addButton(QDialogButtonBox::Cancel); buttonBox->addButton(clearButton, QDialogButtonBox::ActionRole); connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgCreateGame::actOK); connect(clearButton, &QPushButton::clicked, this, &DlgCreateGame::actReset); + auto *mainLayout = new QVBoxLayout; + mainLayout->addWidget(form); + mainLayout->addWidget(errorLabel); + mainLayout->addWidget(buttonBox); + + setLayout(mainLayout); setWindowTitle(tr("Create game")); } DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMap &_gameTypes, QWidget *parent) - : QDialog(parent), room(0), gameTypes(_gameTypes) + : QDialog(parent), room(nullptr) { - sharedCtor(); - - rememberGameSettings->setEnabled(false); - descriptionEdit->setEnabled(false); - maxPlayersEdit->setEnabled(false); - passwordEdit->setEnabled(false); - onlyBuddiesCheckBox->setEnabled(false); - onlyRegisteredCheckBox->setEnabled(false); - spectatorsAllowedCheckBox->setEnabled(false); - spectatorsNeedPasswordCheckBox->setEnabled(false); - spectatorsCanTalkCheckBox->setEnabled(false); - spectatorsSeeEverythingCheckBox->setEnabled(false); - createGameAsSpectatorCheckBox->setEnabled(false); - startingLifeTotalEdit->setEnabled(false); - shareDecklistsOnLoadCheckBox->setEnabled(false); - - descriptionEdit->setText(QString::fromStdString(gameInfo.description())); - maxPlayersEdit->setValue(gameInfo.max_players()); - onlyBuddiesCheckBox->setChecked(gameInfo.only_buddies()); - onlyRegisteredCheckBox->setChecked(gameInfo.only_registered()); - spectatorsAllowedCheckBox->setChecked(gameInfo.spectators_allowed()); - spectatorsNeedPasswordCheckBox->setChecked(gameInfo.spectators_need_password()); - spectatorsCanTalkCheckBox->setChecked(gameInfo.spectators_can_chat()); - spectatorsSeeEverythingCheckBox->setChecked(gameInfo.spectators_omniscient()); - shareDecklistsOnLoadCheckBox->setChecked(gameInfo.share_decklists_on_load()); - - QSet types; - for (int i = 0; i < gameInfo.game_types_size(); ++i) { - types.insert(gameInfo.game_types(i)); - } - - QMapIterator gameTypeIterator(gameTypes); - while (gameTypeIterator.hasNext()) { - gameTypeIterator.next(); - - QRadioButton *gameTypeCheckBox = gameTypeCheckBoxes.value(gameTypeIterator.key()); - gameTypeCheckBox->setEnabled(false); - gameTypeCheckBox->setChecked(types.contains(gameTypeIterator.key())); - } + form = new CreateGameFormWidget(_gameTypes, false, false, this); + form->setOptions(GameCreationOptions::fromGameInfo(gameInfo, _gameTypes)); + form->setReadOnly(true); + buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok, this); connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgCreateGame::accept); + auto *mainLayout = new QVBoxLayout; + mainLayout->addWidget(form); + mainLayout->addWidget(buttonBox); + + setLayout(mainLayout); setWindowTitle(tr("Game information")); } void DlgCreateGame::actReset() { - descriptionEdit->setText(""); - maxPlayersEdit->setValue(2); - - passwordEdit->setText(""); - onlyBuddiesCheckBox->setChecked(false); - onlyRegisteredCheckBox->setChecked(room && room->getUserInfo()->user_level() & ServerInfo_User::IsRegistered); - - spectatorsAllowedCheckBox->setChecked(true); - spectatorsNeedPasswordCheckBox->setChecked(false); - spectatorsCanTalkCheckBox->setChecked(false); - spectatorsSeeEverythingCheckBox->setChecked(false); - createGameAsSpectatorCheckBox->setChecked(false); - - startingLifeTotalEdit->setValue(20); - shareDecklistsOnLoadCheckBox->setChecked(false); - createGameAsJudgeCheckBox->setChecked(false); - - QMapIterator gameTypeCheckBoxIterator(gameTypeCheckBoxes); - while (gameTypeCheckBoxIterator.hasNext()) { - gameTypeCheckBoxIterator.next(); - // must set auto enclusive to false to be able to set the check to false - gameTypeCheckBoxIterator.value()->setAutoExclusive(false); - gameTypeCheckBoxIterator.value()->setChecked(false); - gameTypeCheckBoxIterator.value()->setAutoExclusive(true); - } - - descriptionEdit->setFocus(); + form->resetToDefaults(); + clearError(); } void DlgCreateGame::actOK() { - Command_CreateGame cmd; - cmd.set_description(descriptionEdit->text().simplified().toStdString()); - cmd.set_password(passwordEdit->text().toStdString()); - cmd.set_max_players(maxPlayersEdit->value()); - cmd.set_only_buddies(onlyBuddiesCheckBox->isChecked()); - cmd.set_only_registered(onlyRegisteredCheckBox->isChecked()); - cmd.set_spectators_allowed(spectatorsAllowedCheckBox->isChecked()); - cmd.set_spectators_need_password(spectatorsNeedPasswordCheckBox->isChecked()); - cmd.set_spectators_can_talk(spectatorsCanTalkCheckBox->isChecked()); - cmd.set_spectators_see_everything(spectatorsSeeEverythingCheckBox->isChecked()); - cmd.set_join_as_judge(createGameAsJudgeCheckBox->isChecked()); - cmd.set_join_as_spectator(createGameAsSpectatorCheckBox->isChecked()); - cmd.set_starting_life_total(startingLifeTotalEdit->value()); - cmd.set_share_decklists_on_load(shareDecklistsOnLoadCheckBox->isChecked()); + clearError(); - auto _gameTypes = QString(); - QMapIterator gameTypeCheckBoxIterator(gameTypeCheckBoxes); - while (gameTypeCheckBoxIterator.hasNext()) { - gameTypeCheckBoxIterator.next(); - if (gameTypeCheckBoxIterator.value()->isChecked()) { - cmd.add_game_type_ids(gameTypeCheckBoxIterator.key()); - _gameTypes += gameTypeCheckBoxIterator.value()->text() + ", "; - } + const GameCreationOptions options = form->options(); + Command_CreateGame cmd = options.toCreateGameCommand(); + + const bool rememberSettings = form->isRememberSettingsChecked(); + auto &gameSettings = SettingsCache::instance().game(); + gameSettings.setRememberGameSettings(rememberSettings); + if (rememberSettings) { + gameSettings.setGameDescription(options.description); + gameSettings.setMaxPlayers(options.maxPlayers); + gameSettings.setOnlyBuddies(options.onlyBuddies); + gameSettings.setOnlyRegistered(options.onlyRegistered); + gameSettings.setSpectatorsAllowed(options.spectatorsAllowed); + gameSettings.setSpectatorsNeedPassword(options.spectatorsNeedPassword); + gameSettings.setSpectatorsCanTalk(options.spectatorsCanTalk); + gameSettings.setSpectatorsCanSeeEverything(options.spectatorsSeeEverything); + gameSettings.setCreateGameAsSpectator(options.joinAsSpectator); + gameSettings.setDefaultStartingLifeTotal(options.startingLifeTotal); + gameSettings.setShareDecklistsOnLoad(options.shareDecklistsOnLoad); + gameSettings.setShufflePlayers(options.shufflePlayers); + gameSettings.setGameTypes(form->selectedGameTypesSettingString()); } - SettingsCache::instance().game().setRememberGameSettings(rememberGameSettings->isChecked()); - if (rememberGameSettings->isChecked()) { - SettingsCache::instance().game().setGameDescription(descriptionEdit->text()); - SettingsCache::instance().game().setMaxPlayers(maxPlayersEdit->value()); - SettingsCache::instance().game().setOnlyBuddies(onlyBuddiesCheckBox->isChecked()); - SettingsCache::instance().game().setOnlyRegistered(onlyRegisteredCheckBox->isChecked()); - SettingsCache::instance().game().setSpectatorsAllowed(spectatorsAllowedCheckBox->isChecked()); - SettingsCache::instance().game().setSpectatorsNeedPassword(spectatorsNeedPasswordCheckBox->isChecked()); - SettingsCache::instance().game().setSpectatorsCanTalk(spectatorsCanTalkCheckBox->isChecked()); - SettingsCache::instance().game().setSpectatorsCanSeeEverything(spectatorsSeeEverythingCheckBox->isChecked()); - SettingsCache::instance().game().setCreateGameAsSpectator(createGameAsSpectatorCheckBox->isChecked()); - SettingsCache::instance().game().setDefaultStartingLifeTotal(startingLifeTotalEdit->value()); - SettingsCache::instance().game().setShareDecklistsOnLoad(shareDecklistsOnLoadCheckBox->isChecked()); - SettingsCache::instance().game().setGameTypes(_gameTypes); - } PendingCommand *pend = room->prepareRoomCommand(cmd); connect(pend, &PendingCommand::finished, this, &DlgCreateGame::checkResponse); room->sendRoomCommand(pend); @@ -334,14 +118,26 @@ void DlgCreateGame::checkResponse(const Response &response) if (response.response_code() == Response::RespOk) { accept(); } else { - QMessageBox::critical(this, tr("Error"), tr("Server error.")); - return; + showError(tr("Server error.")); } } -void DlgCreateGame::spectatorsAllowedChanged(QT_STATE_CHANGED_T state) +void DlgCreateGame::showError(const QString &message) { - spectatorsNeedPasswordCheckBox->setEnabled(state); - spectatorsCanTalkCheckBox->setEnabled(state); - spectatorsSeeEverythingCheckBox->setEnabled(state); + if (!errorLabel) { + return; + } + errorLabel->setText(message); + errorLabel->setVisible(true); + adjustSize(); +} + +void DlgCreateGame::clearError() +{ + if (!errorLabel) { + return; + } + errorLabel->clear(); + errorLabel->setVisible(false); + adjustSize(); } diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h index 71b09997c..414c9398d 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h @@ -2,7 +2,6 @@ * @file dlg_create_game.h * @ingroup RoomDialogs */ -//! \todo Document this file. #ifndef DLG_CREATEGAME_H #define DLG_CREATEGAME_H @@ -10,21 +9,15 @@ #include #include #include -#include #include -class QCheckBox; +class CreateGameFormWidget; class QDialogButtonBox; -class QGroupBox; class QLabel; -class QLineEdit; class QPushButton; -class QRadioButton; -class QSpinBox; class Response; class ServerInfo_Game; class TabRoom; -class QWidget; class DlgCreateGame : public QDialog { @@ -36,26 +29,16 @@ private slots: void actOK(); void actReset(); void checkResponse(const Response &response); - void spectatorsAllowedChanged(QT_STATE_CHANGED_T state); private: TabRoom *room; - QMap gameTypes; - QMap gameTypeCheckBoxes; + CreateGameFormWidget *form = nullptr; + QDialogButtonBox *buttonBox = nullptr; + QPushButton *clearButton = nullptr; + QLabel *errorLabel = nullptr; - QGroupBox *generalGroupBox, *spectatorsGroupBox, *gameSetupOptionsGroupBox; - QLabel *descriptionLabel, *passwordLabel, *maxPlayersLabel, *startingLifeTotalLabel, *shareDecklistsOnLoadLabel; - QLineEdit *descriptionEdit, *passwordEdit; - QSpinBox *maxPlayersEdit, *startingLifeTotalEdit; - QCheckBox *onlyBuddiesCheckBox, *onlyRegisteredCheckBox; - QCheckBox *spectatorsAllowedCheckBox, *spectatorsNeedPasswordCheckBox, *spectatorsCanTalkCheckBox, - *spectatorsSeeEverythingCheckBox, *createGameAsJudgeCheckBox, *createGameAsSpectatorCheckBox; - QCheckBox *shareDecklistsOnLoadCheckBox; - QDialogButtonBox *buttonBox; - QPushButton *clearButton; - QCheckBox *rememberGameSettings; - - void sharedCtor(); + void showError(const QString &message); + void clearError(); }; -#endif +#endif // DLG_CREATEGAME_H diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp index cbf61a394..a6d337a56 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp @@ -1,6 +1,6 @@ #include "dlg_edit_tokens.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/widgets/utility/get_text_with_max.h" #include "card_database_display_model.h" #include "libcockatrice/card/card_info.h" @@ -40,6 +40,7 @@ class QWidget; DlgEditTokens::DlgEditTokens(QWidget *parent) : QDialog(parent), currentCard(nullptr) { + setObjectName("editTokensDialog"); nameLabel = new QLabel(tr("&Name:")); nameEdit = new QLineEdit; nameEdit->setMaxLength(MAX_NAME_LENGTH); @@ -107,10 +108,10 @@ DlgEditTokens::DlgEditTokens(QWidget *parent) : QDialog(parent), currentCard(nul &DlgEditTokens::tokenSelectionChanged); QAction *aAddToken = new QAction(tr("Add token"), this); - aAddToken->setIcon(themePixmap(QStringLiteral("icons/increment"))); + aAddToken->setIcon(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive, IconStyle::Sticker)); connect(aAddToken, &QAction::triggered, this, &DlgEditTokens::actAddToken); QAction *aRemoveToken = new QAction(tr("Remove token"), this); - aRemoveToken->setIcon(themePixmap(QStringLiteral("icons/decrement"))); + aRemoveToken->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative, IconStyle::Sticker)); connect(aRemoveToken, &QAction::triggered, this, &DlgEditTokens::actRemoveToken); auto *databaseToolBar = new QToolBar; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_reset.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_reset.cpp index 6c1b95b43..ca42fbf40 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_reset.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_reset.cpp @@ -56,6 +56,7 @@ DlgForgotPasswordReset::DlgForgotPasswordReset(QWidget *parent) : QDialog(parent playernameEdit->setMaxLength(MAX_NAME_LENGTH); playernameLabel->setBuddy(playernameEdit); + //: Code mailed to the player so they can reset their password. Not a game token. tokenLabel = new QLabel(tr("Token:")); tokenEdit = new QLineEdit(); tokenEdit->setMaxLength(MAX_NAME_LENGTH); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp index 2c0fd3f6f..c1d0676ca 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp @@ -28,12 +28,16 @@ class QWidget; */ AbstractDlgDeckTextEdit::AbstractDlgDeckTextEdit(QWidget *parent) : QDialog(parent) { + setObjectName("dlgDeckTextEdit"); + contentsEdit = new QPlainTextEdit; + contentsEdit->setObjectName("contentsEdit"); refreshButton = new QPushButton(tr("&Refresh")); connect(refreshButton, &QPushButton::clicked, this, &AbstractDlgDeckTextEdit::actRefresh); auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + buttonBox->setObjectName("buttonBox"); buttonBox->addButton(refreshButton, QDialogButtonBox::ActionRole); connect(buttonBox, &QDialogButtonBox::accepted, this, &AbstractDlgDeckTextEdit::actOK); connect(buttonBox, &QDialogButtonBox::rejected, this, &AbstractDlgDeckTextEdit::reject); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp index 1c1ada302..04b0db2e3 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -33,6 +34,7 @@ class QWidget; +static const QStringList SUPPORTED_PROVIDERS = {"Archidekt", "Deckstats", "ManaBox", "Moxfield", "TappedOut"}; DlgLoadDeckFromWebsite::DlgLoadDeckFromWebsite(QWidget *parent) : QDialog(parent) { nam = NetworkManagerFactory::create(this); @@ -41,6 +43,7 @@ DlgLoadDeckFromWebsite::DlgLoadDeckFromWebsite(QWidget *parent) : QDialog(parent setLayout(layout); instructionLabel = new QLabel(this); + instructionLabel->setWordWrap(true); layout->addWidget(instructionLabel); urlEdit = new QLineEdit(this); @@ -64,8 +67,8 @@ DlgLoadDeckFromWebsite::DlgLoadDeckFromWebsite(QWidget *parent) : QDialog(parent void DlgLoadDeckFromWebsite::retranslateUi() { - instructionLabel->setText(tr("Paste a link to a decklist site here to import it.\n(Archidekt, Deckstats, Moxfield, " - "and TappedOut are supported.)")); + instructionLabel->setText(tr("Paste a link to a decklist site here to import it.") + "\n" + + tr("Supported services: %1").arg(SUPPORTED_PROVIDERS.join(", "))); } bool DlgLoadDeckFromWebsite::testValidUrl() @@ -84,8 +87,8 @@ void DlgLoadDeckFromWebsite::accept() if (!jsonParser && info.provider != DeckProvider::Deckstats && info.provider != DeckProvider::TappedOut) { qCWarning(DlgLoadDeckFromWebsiteLog) << "No parser found for provider"; QMessageBox::warning(this, tr("Load Deck from Website"), - tr("No parser available for this deck provider.\n (Archidekt, Deckstats, Moxfield, " - "and TappedOut are supported.)")); + tr("No parser available for this deck provider.") + "\n" + + tr("Supported services: %1").arg(SUPPORTED_PROVIDERS.join(", "))); QDialog::reject(); return; } @@ -152,6 +155,7 @@ void DlgLoadDeckFromWebsite::accept() "https://archidekt.com/decks/9999999\n" "https://deckstats.net/decks/99999/9999999-your-deck-name/en\n" "https://moxfield.com/decks/XYZxx-XYZ99Yyy-xyzXzzz\n" + "https://manabox.app/decks/XYZxxa-YZ99Yyyx_yzXzzz\n" "https://tappedout.net/mtg-decks/your-deck-name/")); QDialog::reject(); } @@ -164,7 +168,9 @@ QSharedPointer DlgLoadDeckFromWebsite::createParserForProvider( return QSharedPointer(new ArchidektJsonParser()); case DeckProvider::Moxfield: return QSharedPointer(new MoxfieldJsonParser()); + case DeckProvider::Manabox: + return QSharedPointer(new ManaboxJsonParser()); default: return QSharedPointer(nullptr); } -} \ No newline at end of file +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_local_game_options.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_local_game_options.cpp index 858deb751..3ebb5c093 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_local_game_options.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_local_game_options.cpp @@ -16,8 +16,11 @@ class QWidget; DlgLocalGameOptions::DlgLocalGameOptions(QWidget *parent) : QDialog(parent) { + setObjectName("localGameDialog"); + numberPlayersLabel = new QLabel(tr("Players:"), this); numberPlayersEdit = new QSpinBox(this); + numberPlayersEdit->setObjectName("numberPlayersEdit"); numberPlayersEdit->setMinimum(1); numberPlayersEdit->setMaximum(8); numberPlayersEdit->setValue(1); @@ -28,10 +31,12 @@ DlgLocalGameOptions::DlgLocalGameOptions(QWidget *parent) : QDialog(parent) generalGrid->addWidget(numberPlayersLabel, 0, 0); generalGrid->addWidget(numberPlayersEdit, 0, 1); generalGroupBox = new QGroupBox(tr("General"), this); + generalGroupBox->setObjectName("generalGroupBox"); generalGroupBox->setLayout(generalGrid); startingLifeTotalLabel = new QLabel(tr("Starting life total:"), this); startingLifeTotalEdit = new QSpinBox(this); + startingLifeTotalEdit->setObjectName("startingLifeTotalEdit"); startingLifeTotalEdit->setMinimum(-999999999); startingLifeTotalEdit->setMaximum(999999999); startingLifeTotalEdit->setValue(20); @@ -42,11 +47,14 @@ DlgLocalGameOptions::DlgLocalGameOptions(QWidget *parent) : QDialog(parent) gameSetupGrid->addWidget(startingLifeTotalLabel, 0, 0); gameSetupGrid->addWidget(startingLifeTotalEdit, 0, 1); gameSetupOptionsGroupBox = new QGroupBox(tr("Game setup options"), this); + gameSetupOptionsGroupBox->setObjectName("gameSetupOptionsGroupBox"); gameSetupOptionsGroupBox->setLayout(gameSetupGrid); rememberSettingsCheckBox = new QCheckBox(tr("Remember settings"), this); + rememberSettingsCheckBox->setObjectName("rememberSettingsCheckBox"); buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + buttonBox->setObjectName("buttonBox"); connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgLocalGameOptions::actOK); connect(buttonBox, &QDialogButtonBox::rejected, this, &DlgLocalGameOptions::reject); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp index dbfe38e26..cf487d930 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp @@ -1,7 +1,7 @@ #include "dlg_manage_sets.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/card_picture_loader/card_picture_loader.h" #include "../interface/widgets/utility/custom_line_edit.h" #include "libcockatrice/card/database/card_database.h" @@ -48,6 +48,7 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent) { + setObjectName("manageSetsWindow"); setOrderIsSorted = false; // left toolbar @@ -57,28 +58,28 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent) setsEditToolBar->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); aTop = new QAction(QString(), this); - aTop->setIcon(themePixmap(QStringLiteral("icons/arrow_top_green"))); + aTop->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_to_top"))); aTop->setToolTip(tr("Move selected set to the top")); aTop->setEnabled(false); connect(aTop, &QAction::triggered, this, &WndSets::actTop); setsEditToolBar->addAction(aTop); aUp = new QAction(QString(), this); - aUp->setIcon(themePixmap(QStringLiteral("icons/arrow_up_green"))); + aUp->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_up"))); aUp->setToolTip(tr("Move selected set up")); aUp->setEnabled(false); connect(aUp, &QAction::triggered, this, &WndSets::actUp); setsEditToolBar->addAction(aUp); aDown = new QAction(QString(), this); - aDown->setIcon(themePixmap(QStringLiteral("icons/arrow_down_green"))); + aDown->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_down"))); aDown->setToolTip(tr("Move selected set down")); aDown->setEnabled(false); connect(aDown, &QAction::triggered, this, &WndSets::actDown); setsEditToolBar->addAction(aDown); aBottom = new QAction(QString(), this); - aBottom->setIcon(themePixmap(QStringLiteral("icons/arrow_bottom_green"))); + aBottom->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_to_bottom"))); aBottom->setToolTip(tr("Move selected set to the bottom")); aBottom->setEnabled(false); connect(aBottom, &QAction::triggered, this, &WndSets::actBottom); @@ -88,7 +89,8 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent) searchField = new LineEditUnfocusable; searchField->setObjectName("searchEdit"); searchField->setPlaceholderText(tr("Search by set name, code, type, or release date")); - searchField->addAction(themePixmap(QStringLiteral("icons/search")), LineEditUnfocusable::LeadingPosition); + searchField->addAction(loadThemedIcon(QStringLiteral("icons/search"), IconTint::quiet()), + LineEditUnfocusable::LeadingPosition); searchField->setClearButtonEnabled(true); setFocusProxy(searchField); @@ -221,6 +223,7 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent) WndSets::~WndSets() { + disconnect(view->header(), &QHeaderView::geometriesChanged, this, &WndSets::saveHeaderState); } void WndSets::closeEvent(QCloseEvent * /*ev*/) diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp index 2a2758ba5..0f429677a 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp @@ -1,11 +1,13 @@ #include "dlg_my_reports.h" +#include "../interface/theme_manager.h" #include "../utility/report_utils.h" #include "abstract_client.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/serverinfo_report.pb.h" #include +#include #include #include #include @@ -24,7 +26,6 @@ #include #include #include -#include #include #include @@ -69,7 +70,8 @@ DlgMyReports::DlgMyReports(AbstractClient *_client, QWidget *parent) auto *chatGroup = new QGroupBox(tr("Chat Log Context")); chatLogEdit = new QTextEdit; chatLogEdit->setReadOnly(true); - chatLogEdit->setFont(QtUtils::monospaceFont()); + applyChatLogFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, &DlgMyReports::applyChatLogFont); auto *chatLayout = new QVBoxLayout(chatGroup); chatLayout->setContentsMargins(4, 4, 4, 4); chatLayout->addWidget(chatLogEdit); @@ -122,6 +124,11 @@ DlgMyReports::DlgMyReports(AbstractClient *_client, QWidget *parent) refreshList(); } +void DlgMyReports::applyChatLogFont() +{ + chatLogEdit->setFont(report_utils::reportLogFont()); +} + void DlgMyReports::refreshList() { selectedReportIdBeforeRefresh = selectedReportId; @@ -223,7 +230,8 @@ void DlgMyReports::onSelectionChanged() connect(pend, &PendingCommand::finished, this, &DlgMyReports::reportDetailsResponse); client->sendCommand(pend); - QString status = table->item(row, COL_STATUS)->text(); + // The cell text is the translated label; the raw status lives in UserRole. + const QString status = table->item(row, COL_STATUS)->data(Qt::UserRole).toString(); bool canComment = (status == "open" || status == "assigned"); commentButton->setEnabled(canComment); commentInput->setEnabled(canComment); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.h b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.h index dc9f90d2d..90772f051 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.h @@ -29,6 +29,7 @@ private slots: void reportDetailsResponse(const Response &response); void addComment(); void addCommentResponse(const Response &response); + void applyChatLogFont(); private: void loadReportDetails(const ServerInfo_Report &report); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp index 9e32e87d1..0b6a9585d 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp @@ -1,7 +1,7 @@ #include "dlg_register.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../server/handle_public_servers.h" #include "../server/user/user_info_connection.h" @@ -34,7 +34,8 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent) previousHosts = new QComboBox(this); btnDeleteServer = new QPushButton(this); - btnDeleteServer->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + btnDeleteServer->setIcon( + loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker)); btnDeleteServer->setToolTip(tr("Delete the currently selected saved server")); btnDeleteServer->setFixedWidth(30); @@ -42,7 +43,7 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent) hps = new HandlePublicServers(this); btnRefreshServers = new QPushButton(this); - btnRefreshServers->setIcon(themePixmap(QStringLiteral("icons/sync"))); + btnRefreshServers->setIcon(loadThemedIcon(QStringLiteral("icons/sync"))); btnRefreshServers->setToolTip(tr("Refresh the server list with known public servers")); btnRefreshServers->setFixedWidth(30); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp index 3ef45020b..c7c32469b 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp @@ -1,10 +1,13 @@ #include "dlg_report_user.h" +#include "../interface/theme_manager.h" +#include "../utility/report_utils.h" #include "abstract_client.h" #include "libcockatrice/protocol/pb/response.pb.h" #include #include +#include #include #include #include @@ -91,7 +94,8 @@ DlgReportUser::DlgReportUser(AbstractClient *_client, auto *chatLayout = new QVBoxLayout(chatGroup); chatLogEdit = new QTextEdit; chatLogEdit->setReadOnly(true); - chatLogEdit->setFont(QtUtils::monospaceFont()); + applyChatLogFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, &DlgReportUser::applyChatLogFont); if (!_autoChatLog.isEmpty()) { chatLogEdit->setPlainText(_autoChatLog); } else { @@ -120,6 +124,11 @@ DlgReportUser::DlgReportUser(AbstractClient *_client, layout->addWidget(buttonBox); } +void DlgReportUser::applyChatLogFont() +{ + chatLogEdit->setFont(report_utils::reportLogFont()); +} + void DlgReportUser::actSubmit() { const QString description = descriptionEdit->toPlainText().trimmed(); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_report_user.h b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.h index c7fce055d..0f2fa5080 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_report_user.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.h @@ -27,6 +27,7 @@ public: private slots: void actSubmit(); void reportResponse(const Response &response); + void applyChatLogFont(); private: static QString categoryLabel(const QString &key); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp index ab870a2c8..3602e67c6 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp @@ -73,6 +73,8 @@ DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent) resize(calculateDialogSize(hostSize, available.size(), minimum)); setSizeGripEnabled(true); + setObjectName("settingsDialog"); + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::updateLanguage); settingsView = new SettingsView(this); @@ -82,6 +84,7 @@ DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent) buttonBox->addStretch(); okButton = new QPushButton; okButton->setDefault(true); + okButton->setObjectName("settingsOkButton"); connect(okButton, &QPushButton::clicked, this, &DlgSettings::close); buttonBox->addWidget(okButton); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp index d29aee27f..49bacd081 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp @@ -2,6 +2,7 @@ #include "../deck_share/shared_deck_preview_widget.h" #include "../general/layout_containers/flow_widget.h" +#include "../interface/theme_manager.h" #include #include @@ -35,9 +36,14 @@ DlgSharedDecksPreview::DlgSharedDecksPreview(QWidget *parent, // QLabels render AutoText and markup would otherwise be shown as rich text. auto *titleLabel = new QLabel(tr("Share: %1").arg((shareName.isEmpty() ? tr("Untitled") : shareName).toHtmlEscaped()), this); - QFont titleFont = QtUtils::boldFont(titleLabel->font()); - titleFont.setPointSize(titleFont.pointSize() + 2); - titleLabel->setFont(titleFont); + // Family comes from the InterfaceDefault theme role; the +2 size and bold stay caller-side. + auto applyTitleFont = [titleLabel]() { + QFont titleFont = QtUtils::boldFont(themeManager->appFont(AppFont::InterfaceDefault)); + titleFont.setPointSize(titleFont.pointSize() + 2); + titleLabel->setFont(titleFont); + }; + applyTitleFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, applyTitleFont); mainLayout->addWidget(titleLabel); if (!serverText.isEmpty()) { diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp index 511ff1c7d..c1df0c963 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp @@ -28,6 +28,7 @@ DlgTipOfTheDay::DlgTipOfTheDay(QWidget *parent) : QDialog(parent) { + setObjectName("tipOfTheDayDialog"); successfulInit = false; QString xmlPath = "theme:tips/tips_of_the_day.xml"; tipDatabase = new TipsOfTheDay(xmlPath, this); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_turn_order.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_turn_order.cpp new file mode 100644 index 000000000..8814f89db --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_turn_order.cpp @@ -0,0 +1,136 @@ +#include "dlg_turn_order.h" + +#include +#include +#include +#include +#include +#include +#include + +DlgTurnOrder::DlgTurnOrder(const QStringList &_playerNames, QWidget *parent) + : QDialog(parent), randomizeRequested(false) +{ + listWidget = new QListWidget(this); + listWidget->addItems(_playerNames); + listWidget->setCurrentRow(0); + connect(listWidget, &QListWidget::currentRowChanged, this, &DlgTurnOrder::selectionChanged); + + moveUpButton = new QPushButton(this); + connect(moveUpButton, &QPushButton::clicked, this, &DlgTurnOrder::actMoveUp); + moveDownButton = new QPushButton(this); + connect(moveDownButton, &QPushButton::clicked, this, &DlgTurnOrder::actMoveDown); + randomizeButton = new QPushButton(this); + connect(randomizeButton, &QPushButton::clicked, this, &DlgTurnOrder::actRandomize); + + // Shown in place of the list while the server is asked to randomize: the + // order is chosen server side, so there is nothing to preview here. + hintLabel = new QLabel(this); + hintLabel->setWordWrap(true); + hintLabel->setAlignment(Qt::AlignCenter); + + auto *buttonColumn = new QVBoxLayout; + buttonColumn->addWidget(moveUpButton); + buttonColumn->addWidget(moveDownButton); + buttonColumn->addWidget(randomizeButton); + buttonColumn->addStretch(); + + auto *listLayout = new QHBoxLayout; + listLayout->addWidget(listWidget); + listLayout->addWidget(hintLabel, 1); + listLayout->addLayout(buttonColumn); + + auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgTurnOrder::actOk); + connect(buttonBox, &QDialogButtonBox::rejected, this, &DlgTurnOrder::reject); + + auto *mainLayout = new QVBoxLayout; + mainLayout->addLayout(listLayout); + mainLayout->addWidget(buttonBox); + setLayout(mainLayout); + + updateButtons(); + + updateMode(); + setMinimumWidth(300); +} + +void DlgTurnOrder::retranslateUi() +{ + setWindowTitle(tr("Set turn order")); + moveUpButton->setText(tr("Move up")); + moveDownButton->setText(tr("Move down")); + randomizeButton->setText(randomizeRequested ? tr("Set order manually") : tr("Randomize")); + hintLabel->setText(tr("The server will randomize the turn order.")); +} + +QStringList DlgTurnOrder::order() const +{ + QStringList playerNames; + for (int i = 0; i < listWidget->count(); ++i) { + playerNames.append(listWidget->item(i)->text()); + } + return playerNames; +} + +void DlgTurnOrder::moveSelected(int delta) +{ + const int row = listWidget->currentRow(); + const int newRow = row + delta; + if (row < 0 || newRow < 0 || newRow >= listWidget->count()) { + return; + } + + QListWidgetItem *item = listWidget->takeItem(row); + listWidget->insertItem(newRow, item); + listWidget->setCurrentRow(newRow); + updateButtons(); +} + +void DlgTurnOrder::updateMode() +{ + listWidget->setVisible(!randomizeRequested); + hintLabel->setVisible(randomizeRequested); + moveUpButton->setVisible(!randomizeRequested); + moveDownButton->setVisible(!randomizeRequested); + retranslateUi(); +} + +void DlgTurnOrder::updateButtons() +{ + const int row = listWidget->currentRow(); + const int count = listWidget->count(); + const bool manualOrder = !randomizeRequested; + moveUpButton->setEnabled(manualOrder && row > 0); + moveDownButton->setEnabled(manualOrder && row >= 0 && row < count - 1); +} + +void DlgTurnOrder::actMoveUp() +{ + moveSelected(-1); +} + +void DlgTurnOrder::actMoveDown() +{ + moveSelected(1); +} + +void DlgTurnOrder::actRandomize() +{ + // Randomizing is a request to the server, not a local shuffle: showing a + // preview would let the host re-open the dialog until the order looks + // favourable for them. + randomizeRequested = !randomizeRequested; + updateMode(); + updateButtons(); +} + +void DlgTurnOrder::actOk() +{ + accept(); +} + +void DlgTurnOrder::selectionChanged() +{ + updateButtons(); +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_turn_order.h b/cockatrice/src/interface/widgets/dialogs/dlg_turn_order.h new file mode 100644 index 000000000..44d8203b9 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_turn_order.h @@ -0,0 +1,51 @@ +/** + * @file dlg_turn_order.h + * @ingroup GameDialogs + */ +//! \todo Document this file. + +#ifndef DLG_TURN_ORDER_H +#define DLG_TURN_ORDER_H + +#include +#include + +class QLabel; +class QListWidget; +class QPushButton; + +class DlgTurnOrder : public QDialog +{ + Q_OBJECT +private: + QListWidget *listWidget; + QLabel *hintLabel; + QPushButton *moveUpButton; + QPushButton *moveDownButton; + QPushButton *randomizeButton; + bool randomizeRequested; + + void moveSelected(int delta); + void updateMode(); + void updateButtons(); + +private slots: + void actMoveUp(); + void actMoveDown(); + void actRandomize(); + void actOk(); + void selectionChanged(); + +public: + explicit DlgTurnOrder(const QStringList &_playerNames, QWidget *parent = nullptr); + + void retranslateUi(); + + [[nodiscard]] bool isRandomizeRequested() const + { + return randomizeRequested; + } + [[nodiscard]] QStringList order() const; +}; + +#endif diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp index ca55c7b41..11dcd7e27 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp @@ -42,6 +42,7 @@ static const QString UPDATE_INSTALLER_OVERRIDE = "Cockatrice-Update-Override.exe DlgUpdate::DlgUpdate(QWidget *parent) : QDialog(parent) { + setObjectName("updateDialog"); // Handle layout statusLabel = new QLabel(this); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_view_log.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_view_log.cpp index d55e800a1..e75cf352a 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_view_log.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_view_log.cpp @@ -18,6 +18,7 @@ class QWidget; DlgViewLog::DlgViewLog(QWidget *parent) : QDialog(parent) { + setObjectName("viewLogDialog"); logArea = new QPlainTextEdit; logArea->setReadOnly(true); diff --git a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp index d22a8d0b3..8d52ff56a 100644 --- a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp +++ b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp @@ -6,7 +6,8 @@ #include "settings_view.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" +#include "../interface/theme_manager.h" #include "../settings_page/abstract_settings_page.h" #include "../settings_page/appearance_settings_page.h" #include "../settings_page/deck_editor_settings_page.h" @@ -119,6 +120,8 @@ QWidget *fromWidgetId(WidgetId id) SettingsView::SettingsView(QWidget *parent) : QWidget(parent), currentTabIndex(0), searchActive(false) { + setObjectName("settingsView"); + setupUi(); connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &SettingsView::retranslateUi); @@ -132,15 +135,18 @@ void SettingsView::setupUi() // Search bar searchEdit = new QLineEdit; searchEdit->setClearButtonEnabled(true); - searchEdit->addAction(themePixmap(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); + searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/search"), IconTint::quiet()), + QLineEdit::LeadingPosition); searchEdit->installEventFilter(this); connect(searchEdit, &QLineEdit::textChanged, this, &SettingsView::onSearchTextChanged); + searchEdit->setObjectName("settingsSearchEdit"); auto *searchLayout = new QHBoxLayout; searchLayout->addWidget(searchEdit); // Pages stacked widget pagesWidget = new QStackedWidget; + pagesWidget->setObjectName("pagesWidget"); auto *generalPage = new GeneralSettingsPage; auto *appearancePage = new AppearanceSettingsPage; @@ -199,12 +205,17 @@ void SettingsView::setupUi() searchDelegate->setPageIcons(icons); searchResultsView->viewport()->update(); }); + // The search delegate reads the InterfaceDefault role at paint time, so a + // font change only needs a repaint, mirroring the theme change above. + connect(themeManager, &ThemeManager::fontsChanged, searchResultsView, + [this] { searchResultsView->viewport()->update(); }); // Build search index after pages are created buildSearchIndex(); // Pages container (stacked widget + search results overlay) pagesContainer = new QWidget; + pagesContainer->setObjectName("pagesContainer"); auto *containerLayout = new QStackedLayout; containerLayout->setStackingMode(QStackedLayout::StackAll); containerLayout->addWidget(pagesWidget); @@ -244,6 +255,7 @@ void SettingsView::setupUi() void SettingsView::setupNavTree() { navTree = new QTreeWidget; + navTree->setObjectName("navTree"); navTree->setHeaderHidden(true); navTree->setRootIsDecorated(true); navTree->setIconSize(QSize(24, 24)); diff --git a/cockatrice/src/interface/widgets/game_hosting/create_game_form_widget.cpp b/cockatrice/src/interface/widgets/game_hosting/create_game_form_widget.cpp new file mode 100644 index 000000000..7d9e8e694 --- /dev/null +++ b/cockatrice/src/interface/widgets/game_hosting/create_game_form_widget.cpp @@ -0,0 +1,260 @@ +#include "create_game_form_widget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +CreateGameFormWidget::CreateGameFormWidget(const QMap &gameTypes, + bool _registeredUser, + bool judge, + QWidget *parent) + : QWidget(parent), registeredUser(_registeredUser) +{ + setObjectName("createGameForm"); + + rememberGameSettings = new QCheckBox(tr("Re&member settings")); + auto *descriptionLabel = new QLabel(tr("&Description:")); + descriptionEdit = new QLineEdit; + descriptionEdit->setMaxLength(MAX_NAME_LENGTH); + descriptionLabel->setBuddy(descriptionEdit); + + auto *maxPlayersLabel = new QLabel(tr("P&layers:")); + maxPlayersEdit = new QSpinBox(); + maxPlayersEdit->setMinimum(1); + maxPlayersEdit->setMaximum(100); + maxPlayersEdit->setValue(2); + maxPlayersLabel->setBuddy(maxPlayersEdit); + + auto *generalGrid = new QGridLayout; + generalGrid->addWidget(descriptionLabel, 0, 0); + generalGrid->addWidget(descriptionEdit, 0, 1); + generalGrid->addWidget(maxPlayersLabel, 1, 0); + generalGrid->addWidget(maxPlayersEdit, 1, 1); + auto *generalGroupBox = new QGroupBox(tr("General")); + generalGroupBox->setLayout(generalGrid); + + auto *gameTypeLayout = new QVBoxLayout; + for (auto it = gameTypes.cbegin(); it != gameTypes.cend(); ++it) { + auto *gameTypeRadioButton = new QRadioButton(it.value(), this); + gameTypeLayout->addWidget(gameTypeRadioButton); + gameTypeCheckBoxes.insert(it.key(), gameTypeRadioButton); + } + auto *gameTypeGroupBox = new QGroupBox(tr("Game type")); + gameTypeGroupBox->setLayout(gameTypeLayout); + + auto *passwordLabel = new QLabel(tr("&Password:")); + passwordEdit = new QLineEdit; + passwordEdit->setMaxLength(MAX_NAME_LENGTH); + passwordLabel->setBuddy(passwordEdit); + + onlyBuddiesCheckBox = new QCheckBox(tr("Only &buddies can join")); + onlyRegisteredCheckBox = new QCheckBox(tr("Only ®istered users can join")); + if (registeredUser) { + onlyRegisteredCheckBox->setChecked(true); + } else { + onlyBuddiesCheckBox->setEnabled(false); + onlyRegisteredCheckBox->setEnabled(false); + } + + auto *joinRestrictionsLayout = new QGridLayout; + joinRestrictionsLayout->addWidget(passwordLabel, 0, 0); + joinRestrictionsLayout->addWidget(passwordEdit, 0, 1); + joinRestrictionsLayout->addWidget(onlyBuddiesCheckBox, 1, 0, 1, 2); + joinRestrictionsLayout->addWidget(onlyRegisteredCheckBox, 2, 0, 1, 2); + + auto *joinRestrictionsGroupBox = new QGroupBox(tr("Joining restrictions")); + joinRestrictionsGroupBox->setLayout(joinRestrictionsLayout); + + spectatorsAllowedCheckBox = new QCheckBox(tr("&Spectators can watch")); + spectatorsAllowedCheckBox->setChecked(true); + connect(spectatorsAllowedCheckBox, &QCheckBox::QT_STATE_CHANGED, this, + &CreateGameFormWidget::spectatorsAllowedChanged); + spectatorsNeedPasswordCheckBox = new QCheckBox(tr("Spectators &need a password to watch")); + spectatorsCanTalkCheckBox = new QCheckBox(tr("Spectators can &chat")); + spectatorsSeeEverythingCheckBox = new QCheckBox(tr("Spectators can see &hands")); + createGameAsSpectatorCheckBox = new QCheckBox(tr("Create game as spectator")); + auto *spectatorsLayout = new QVBoxLayout; + spectatorsLayout->addWidget(spectatorsAllowedCheckBox); + spectatorsLayout->addWidget(spectatorsNeedPasswordCheckBox); + spectatorsLayout->addWidget(spectatorsCanTalkCheckBox); + spectatorsLayout->addWidget(spectatorsSeeEverythingCheckBox); + spectatorsLayout->addWidget(createGameAsSpectatorCheckBox); + auto *spectatorsGroupBox = new QGroupBox(tr("Spectators")); + spectatorsGroupBox->setLayout(spectatorsLayout); + + auto *startingLifeTotalLabel = new QLabel(tr("Starting life total:")); + startingLifeTotalEdit = new QSpinBox(); + startingLifeTotalEdit->setMinimum(1); + startingLifeTotalEdit->setMaximum(99999); ///< Arbitrary but we can raise this when people start complaining. + startingLifeTotalEdit->setValue(20); + startingLifeTotalLabel->setBuddy(startingLifeTotalEdit); + + shareDecklistsOnLoadCheckBox = new QCheckBox(tr("Open decklists in lobby")); + + shufflePlayersCheckBox = new QCheckBox(tr("Randomize turn order (seats) on each game start")); + + createGameAsJudgeCheckBox = new QCheckBox(tr("Create game as judge")); + + auto *gameSetupOptionsLayout = new QGridLayout; + gameSetupOptionsLayout->addWidget(startingLifeTotalLabel, 0, 0); + gameSetupOptionsLayout->addWidget(startingLifeTotalEdit, 0, 1); + gameSetupOptionsLayout->addWidget(shareDecklistsOnLoadCheckBox, 1, 0, 1, 2); + gameSetupOptionsLayout->addWidget(shufflePlayersCheckBox, 2, 0, 1, 2); + if (judge) { + gameSetupOptionsLayout->addWidget(createGameAsJudgeCheckBox, 3, 0, 1, 2); + } else { + createGameAsJudgeCheckBox->setChecked(false); + createGameAsJudgeCheckBox->setHidden(true); + } + auto *gameSetupOptionsGroupBox = new QGroupBox(tr("Game setup options")); + gameSetupOptionsGroupBox->setLayout(gameSetupOptionsLayout); + + auto *grid = new QGridLayout; + + // Top row + grid->addWidget(generalGroupBox, 0, 0); + grid->addWidget(joinRestrictionsGroupBox, 0, 1); + + // Middle row: left column + grid->addWidget(gameTypeGroupBox, 1, 0); + + // Middle row: right column (game setup + spectators) + auto *rightLayout = new QVBoxLayout; + rightLayout->addWidget(spectatorsGroupBox, Qt::AlignTop); // top + rightLayout->addWidget(gameSetupOptionsGroupBox); // bottom + + grid->addLayout(rightLayout, 1, 1); + + // Bottom row + grid->addWidget(rememberGameSettings, 3, 0, 1, 2); // span both columns if needed + + setLayout(grid); +} + +void CreateGameFormWidget::setOptions(const GameCreationOptions &options) +{ + descriptionEdit->setText(options.description); + maxPlayersEdit->setValue(options.maxPlayers); + passwordEdit->setText(options.password); + onlyBuddiesCheckBox->setChecked(options.onlyBuddies); + onlyRegisteredCheckBox->setChecked(options.onlyRegistered); + spectatorsAllowedCheckBox->setChecked(options.spectatorsAllowed); + spectatorsNeedPasswordCheckBox->setChecked(options.spectatorsNeedPassword); + spectatorsCanTalkCheckBox->setChecked(options.spectatorsCanTalk); + spectatorsSeeEverythingCheckBox->setChecked(options.spectatorsSeeEverything); + createGameAsJudgeCheckBox->setChecked(options.joinAsJudge); + createGameAsSpectatorCheckBox->setChecked(options.joinAsSpectator); + startingLifeTotalEdit->setValue(options.startingLifeTotal); + shareDecklistsOnLoadCheckBox->setChecked(options.shareDecklistsOnLoad); + shufflePlayersCheckBox->setChecked(options.shufflePlayers); + + for (auto it = gameTypeCheckBoxes.cbegin(); it != gameTypeCheckBoxes.cend(); ++it) { + it.value()->setChecked(options.gameTypeIds.contains(it.key())); + } +} + +GameCreationOptions CreateGameFormWidget::options() const +{ + GameCreationOptions options; + options.description = descriptionEdit->text(); + options.password = passwordEdit->text(); + options.maxPlayers = maxPlayersEdit->value(); + options.onlyBuddies = onlyBuddiesCheckBox->isChecked(); + options.onlyRegistered = onlyRegisteredCheckBox->isChecked(); + options.spectatorsAllowed = spectatorsAllowedCheckBox->isChecked(); + options.spectatorsNeedPassword = spectatorsNeedPasswordCheckBox->isChecked(); + options.spectatorsCanTalk = spectatorsCanTalkCheckBox->isChecked(); + options.spectatorsSeeEverything = spectatorsSeeEverythingCheckBox->isChecked(); + options.joinAsJudge = createGameAsJudgeCheckBox->isChecked(); + options.joinAsSpectator = createGameAsSpectatorCheckBox->isChecked(); + options.startingLifeTotal = startingLifeTotalEdit->value(); + options.shareDecklistsOnLoad = shareDecklistsOnLoadCheckBox->isChecked(); + options.shufflePlayers = shufflePlayersCheckBox->isChecked(); + + for (auto it = gameTypeCheckBoxes.cbegin(); it != gameTypeCheckBoxes.cend(); ++it) { + if (it.value()->isChecked()) { + options.gameTypeIds.append(it.key()); + } + } + + return options; +} + +QString CreateGameFormWidget::selectedGameTypesSettingString() const +{ + QString gameTypes; + for (auto it = gameTypeCheckBoxes.cbegin(); it != gameTypeCheckBoxes.cend(); ++it) { + if (it.value()->isChecked()) { + gameTypes += it.value()->text() + ", "; + } + } + return gameTypes; +} + +void CreateGameFormWidget::resetToDefaults() +{ + GameCreationOptions defaults; + defaults.onlyRegistered = registeredUser; + setOptions(defaults); + + // An exclusive button group refuses to uncheck its selected button, which setOptions + // cannot do, so clear the exclusive flag while resetting the game type selection. + for (auto *gameTypeCheckBox : gameTypeCheckBoxes) { + gameTypeCheckBox->setAutoExclusive(false); + gameTypeCheckBox->setChecked(false); + gameTypeCheckBox->setAutoExclusive(true); + } + + descriptionEdit->setFocus(); +} + +void CreateGameFormWidget::setReadOnly(bool readOnly) +{ + const bool editable = !readOnly; + rememberGameSettings->setEnabled(editable); + descriptionEdit->setEnabled(editable); + maxPlayersEdit->setEnabled(editable); + passwordEdit->setEnabled(editable); + onlyBuddiesCheckBox->setEnabled(editable && registeredUser); + onlyRegisteredCheckBox->setEnabled(editable && registeredUser); + spectatorsAllowedCheckBox->setEnabled(editable); + spectatorsNeedPasswordCheckBox->setEnabled(editable); + spectatorsCanTalkCheckBox->setEnabled(editable); + spectatorsSeeEverythingCheckBox->setEnabled(editable); + createGameAsSpectatorCheckBox->setEnabled(editable); + startingLifeTotalEdit->setEnabled(editable); + shareDecklistsOnLoadCheckBox->setEnabled(editable); + shufflePlayersCheckBox->setEnabled(editable); + for (auto *gameTypeCheckBox : gameTypeCheckBoxes) { + gameTypeCheckBox->setEnabled(editable); + } +} + +void CreateGameFormWidget::focusDescription() +{ + descriptionEdit->setFocus(); +} + +bool CreateGameFormWidget::isRememberSettingsChecked() const +{ + return rememberGameSettings->isChecked(); +} + +void CreateGameFormWidget::setRememberSettingsChecked(bool checked) +{ + rememberGameSettings->setChecked(checked); +} + +void CreateGameFormWidget::spectatorsAllowedChanged(QT_STATE_CHANGED_T state) +{ + spectatorsNeedPasswordCheckBox->setEnabled(state); + spectatorsCanTalkCheckBox->setEnabled(state); + spectatorsSeeEverythingCheckBox->setEnabled(state); +} diff --git a/cockatrice/src/interface/widgets/game_hosting/create_game_form_widget.h b/cockatrice/src/interface/widgets/game_hosting/create_game_form_widget.h new file mode 100644 index 000000000..43e56006e --- /dev/null +++ b/cockatrice/src/interface/widgets/game_hosting/create_game_form_widget.h @@ -0,0 +1,69 @@ +/** + * @file create_game_form_widget.h + * @ingroup RoomWidgets + */ + +#ifndef CREATE_GAME_FORM_WIDGET_H +#define CREATE_GAME_FORM_WIDGET_H + +#include "game_creation_options.h" + +#include +#include +#include +#include + +class QCheckBox; +class QLineEdit; +class QRadioButton; +class QSpinBox; + +/** + * @brief The editable create-game form, independent of settings, the network, and the room. + * + * The dialog loads @ref GameCreationOptions into it and reads them back; a future host + * activity can do the same without duplicating the widget layout. + */ +class CreateGameFormWidget : public QWidget +{ + Q_OBJECT +public: + CreateGameFormWidget(const QMap &gameTypes, + bool registeredUser, + bool judge, + QWidget *parent = nullptr); + + void setOptions(const GameCreationOptions &options); + [[nodiscard]] GameCreationOptions options() const; + [[nodiscard]] QString selectedGameTypesSettingString() const; + void resetToDefaults(); + void setReadOnly(bool readOnly); + void focusDescription(); + [[nodiscard]] bool isRememberSettingsChecked() const; + void setRememberSettingsChecked(bool checked); + +private slots: + void spectatorsAllowedChanged(QT_STATE_CHANGED_T state); + +private: + bool registeredUser; + QMap gameTypeCheckBoxes; + + QLineEdit *descriptionEdit; + QLineEdit *passwordEdit; + QSpinBox *maxPlayersEdit; + QSpinBox *startingLifeTotalEdit; + QCheckBox *onlyBuddiesCheckBox; + QCheckBox *onlyRegisteredCheckBox; + QCheckBox *spectatorsAllowedCheckBox; + QCheckBox *spectatorsNeedPasswordCheckBox; + QCheckBox *spectatorsCanTalkCheckBox; + QCheckBox *spectatorsSeeEverythingCheckBox; + QCheckBox *createGameAsJudgeCheckBox; + QCheckBox *createGameAsSpectatorCheckBox; + QCheckBox *shareDecklistsOnLoadCheckBox; + QCheckBox *shufflePlayersCheckBox; + QCheckBox *rememberGameSettings; +}; + +#endif // CREATE_GAME_FORM_WIDGET_H diff --git a/cockatrice/src/interface/widgets/game_hosting/game_creation_options.cpp b/cockatrice/src/interface/widgets/game_hosting/game_creation_options.cpp new file mode 100644 index 000000000..acef35ebc --- /dev/null +++ b/cockatrice/src/interface/widgets/game_hosting/game_creation_options.cpp @@ -0,0 +1,95 @@ +#include "game_creation_options.h" + +#include +#include +#include + +GameCreationOptions GameCreationOptions::fromSettings(const IGameSettingsProvider &settings, + const QMap &gameTypes, + bool registeredUser) +{ + GameCreationOptions options; + options.description = settings.getGameDescription(); + options.maxPlayers = settings.getMaxPlayers(); + if (registeredUser) { + options.onlyBuddies = settings.getOnlyBuddies(); + options.onlyRegistered = settings.getOnlyRegistered(); + } + options.spectatorsAllowed = settings.getSpectatorsAllowed(); + options.spectatorsNeedPassword = settings.getSpectatorsNeedPassword(); + options.spectatorsCanTalk = settings.getSpectatorsCanTalk(); + options.spectatorsSeeEverything = settings.getSpectatorsCanSeeEverything(); + options.joinAsSpectator = settings.getCreateGameAsSpectator(); + options.startingLifeTotal = settings.getDefaultStartingLifeTotal(); + options.shareDecklistsOnLoad = settings.getShareDecklistsOnLoad(); + options.shufflePlayers = settings.getShufflePlayers(); + + // The old dialog stored game type names as "Name, " in one string; keep the exact + // substring test so an existing game.ini selects the same types after the split. + const QString savedGameTypes = settings.getGameTypes(); + for (auto it = gameTypes.cbegin(); it != gameTypes.cend(); ++it) { + if (savedGameTypes.contains(it.value() + ", ")) { + options.gameTypeIds.append(it.key()); + } + } + + return options; +} + +GameCreationOptions GameCreationOptions::defaultsFor(bool registeredUser) +{ + GameCreationOptions options; + options.onlyRegistered = registeredUser; + return options; +} + +GameCreationOptions GameCreationOptions::fromGameInfo(const ServerInfo_Game &gameInfo, + const QMap &gameTypes) +{ + GameCreationOptions options; + options.description = QString::fromStdString(gameInfo.description()); + options.maxPlayers = gameInfo.max_players(); + options.onlyBuddies = gameInfo.only_buddies(); + options.onlyRegistered = gameInfo.only_registered(); + options.spectatorsAllowed = gameInfo.spectators_allowed(); + options.spectatorsNeedPassword = gameInfo.spectators_need_password(); + options.spectatorsCanTalk = gameInfo.spectators_can_chat(); + options.spectatorsSeeEverything = gameInfo.spectators_omniscient(); + options.shareDecklistsOnLoad = gameInfo.share_decklists_on_load(); + options.shufflePlayers = gameInfo.shuffle_players(); + + QSet selectedTypes; + for (int i = 0; i < gameInfo.game_types_size(); ++i) { + selectedTypes.insert(gameInfo.game_types(i)); + } + for (auto it = gameTypes.cbegin(); it != gameTypes.cend(); ++it) { + if (selectedTypes.contains(it.key())) { + options.gameTypeIds.append(it.key()); + } + } + + return options; +} + +Command_CreateGame GameCreationOptions::toCreateGameCommand() const +{ + Command_CreateGame cmd; + cmd.set_description(description.simplified().toStdString()); + cmd.set_password(password.toStdString()); + cmd.set_max_players(maxPlayers); + cmd.set_only_buddies(onlyBuddies); + cmd.set_only_registered(onlyRegistered); + cmd.set_spectators_allowed(spectatorsAllowed); + cmd.set_spectators_need_password(spectatorsNeedPassword); + cmd.set_spectators_can_talk(spectatorsCanTalk); + cmd.set_spectators_see_everything(spectatorsSeeEverything); + cmd.set_join_as_judge(joinAsJudge); + cmd.set_join_as_spectator(joinAsSpectator); + cmd.set_starting_life_total(startingLifeTotal); + cmd.set_share_decklists_on_load(shareDecklistsOnLoad); + cmd.set_shuffle_players(shufflePlayers); + for (int gameTypeId : gameTypeIds) { + cmd.add_game_type_ids(gameTypeId); + } + return cmd; +} diff --git a/cockatrice/src/interface/widgets/game_hosting/game_creation_options.h b/cockatrice/src/interface/widgets/game_hosting/game_creation_options.h new file mode 100644 index 000000000..d9a2bc0cc --- /dev/null +++ b/cockatrice/src/interface/widgets/game_hosting/game_creation_options.h @@ -0,0 +1,72 @@ +/** + * @file game_creation_options.h + * @ingroup RoomWidgets + */ + +#ifndef GAME_CREATION_OPTIONS_H +#define GAME_CREATION_OPTIONS_H + +#include "libcockatrice/protocol/pb/room_commands.pb.h" + +#include +#include +#include + +class IGameSettingsProvider; +class ServerInfo_Game; + +/** + * @brief The values needed to create a game, independent of the widgets that edit them. + * + * Pure value object: it holds no widget or settings dependencies, so every host entry + * point can build a @ref Command_CreateGame through the same conversion. + */ +struct GameCreationOptions +{ + QString description; + QString password; + int maxPlayers = 2; + bool onlyBuddies = false; + bool onlyRegistered = false; + bool spectatorsAllowed = true; + bool spectatorsNeedPassword = false; + bool spectatorsCanTalk = false; + bool spectatorsSeeEverything = false; + bool joinAsJudge = false; + bool joinAsSpectator = false; + int startingLifeTotal = 20; + bool shareDecklistsOnLoad = false; + bool shufflePlayers = false; + QList gameTypeIds; + + /** + * @brief Reads the remembered create-game settings. + * + * Join restrictions are only meaningful for registered users, so they are left + * unset for an unregistered host. Game type ids are resolved from the room's map + * by matching the remembered names. + */ + [[nodiscard]] static GameCreationOptions + fromSettings(const IGameSettingsProvider &settings, const QMap &gameTypes, bool registeredUser); + + /** + * @brief The options the host form shows when nothing is remembered. + * + * A registered host defaults to a registered-only game, matching the form's + * ``Clear`` action. + */ + [[nodiscard]] static GameCreationOptions defaultsFor(bool registeredUser); + + /** + * @brief Reads the read-only fields shown in the game information view. + */ + [[nodiscard]] static GameCreationOptions fromGameInfo(const ServerInfo_Game &gameInfo, + const QMap &gameTypes); + + /** + * @brief Builds the room command, simplifying the description's whitespace. + */ + [[nodiscard]] Command_CreateGame toCreateGameCommand() const; +}; + +#endif diff --git a/cockatrice/src/interface/widgets/general/display/banner_widget.cpp b/cockatrice/src/interface/widgets/general/display/banner_widget.cpp index baf46bbba..5cb62db0f 100644 --- a/cockatrice/src/interface/widgets/general/display/banner_widget.cpp +++ b/cockatrice/src/interface/widgets/general/display/banner_widget.cpp @@ -1,9 +1,11 @@ #include "banner_widget.h" #include "../../../pixel_map_generator.h" +#include "../../../theme_manager.h" #include #include +#include #include #include #include @@ -28,7 +30,9 @@ BannerWidget::BannerWidget(QWidget *parent, const QString &text, Qt::Orientation if (transparency > 50) { textColor = " color: white;"; } - bannerLabel->setStyleSheet("font-size: 24px; font-weight: bold;" + textColor); + bannerLabel->setStyleSheet(textColor); + applyInterfaceDefaultFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, &BannerWidget::applyInterfaceDefaultFont); layout->addWidget(iconLabel); layout->addWidget(bannerLabel); @@ -56,6 +60,16 @@ void BannerWidget::setText(const QString &text) const bannerLabel->setText(text); } +void BannerWidget::applyInterfaceDefaultFont() +{ + // Family comes from the InterfaceDefault theme role; the 24px size and + // bold weight previously carried by the stylesheet stay caller-side. + QFont bannerFont = themeManager->appFont(AppFont::InterfaceDefault); + bannerFont.setPixelSize(24); + bannerFont.setWeight(QFont::Bold); + bannerLabel->setFont(bannerFont); +} + void BannerWidget::setClickable(bool _clickable) { clickable = _clickable; diff --git a/cockatrice/src/interface/widgets/general/display/banner_widget.h b/cockatrice/src/interface/widgets/general/display/banner_widget.h index 06556089b..d46250bdb 100644 --- a/cockatrice/src/interface/widgets/general/display/banner_widget.h +++ b/cockatrice/src/interface/widgets/general/display/banner_widget.h @@ -48,6 +48,7 @@ signals: private slots: void toggleBuddyVisibility() const; void updateDropdownIconState() const; + void applyInterfaceDefaultFont(); }; #endif // BANNER_WIDGET_H diff --git a/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.cpp b/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.cpp index 484ac861d..6bb1dcd62 100644 --- a/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.cpp +++ b/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.cpp @@ -1,5 +1,7 @@ #include "bar_chart_widget.h" +#include "../../interface/theme_manager.h" + #include #include #include @@ -19,6 +21,10 @@ BarChartWidget::BarChartWidget(QWidget *parent) : QWidget(parent) { setMouseTracking(true); + + // Labels read the InterfaceDefault role at paint time, so a font change + // only needs a repaint. + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { update(); }); } void BarChartWidget::setBars(const QVector &newBars) @@ -146,7 +152,8 @@ void BarChartWidget::paintEvent(QPaintEvent *) // draw label below bar QRect labelRect(left + i * (barWidth + spacing), bottom, barWidth, LABEL_H); - QFont f = p.font(); + // Family comes from the InterfaceDefault theme role; bold stays caller-side. + QFont f = themeManager->appFont(AppFont::InterfaceDefault); f.setBold(true); p.setFont(f); p.setPen(Qt::black); diff --git a/cockatrice/src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp b/cockatrice/src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp index 5a3130964..359d56772 100644 --- a/cockatrice/src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp +++ b/cockatrice/src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp @@ -1,5 +1,7 @@ #include "segmented_bar_widget.h" +#include "../../interface/theme_manager.h" + #include #include #include @@ -22,6 +24,10 @@ SegmentedBarWidget::SegmentedBarWidget(QString label, QVector segments, setMinimumWidth(36); setMaximumWidth(50); setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Expanding); + + // The label reads the InterfaceDefault role at paint time, so a font + // change only needs a repaint. + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { update(); }); } QSize SegmentedBarWidget::sizeHint() const @@ -95,7 +101,8 @@ void SegmentedBarWidget::paintEvent(QPaintEvent *) // draw label QRect labelRect(0, h - LABEL_H, w, LABEL_H); - QFont f = p.font(); + // Family comes from the InterfaceDefault theme role; bold stays caller-side. + QFont f = themeManager->appFont(AppFont::InterfaceDefault); f.setBold(true); p.setFont(f); p.setPen(Qt::black); diff --git a/cockatrice/src/interface/widgets/general/display/dynamic_font_size_label.cpp b/cockatrice/src/interface/widgets/general/display/dynamic_font_size_label.cpp index 0609eb16a..598c70fe2 100644 --- a/cockatrice/src/interface/widgets/general/display/dynamic_font_size_label.cpp +++ b/cockatrice/src/interface/widgets/general/display/dynamic_font_size_label.cpp @@ -1,5 +1,7 @@ #include "dynamic_font_size_label.h" +#include "../../interface/theme_manager.h" + #include #include #include @@ -28,9 +30,13 @@ void DynamicFontSizeLabel::paintEvent(QPaintEvent *event) // QElapsedTimer timer; // timer.start(); - QFont newFont = font(); + // Family follows the InterfaceDefault theme role; the fitted point size + // stays computed from the widget geometry as before. + QFont newFont = themeManager->appFont(AppFont::InterfaceDefault); float fontSize = getWidgetMaximumFontSize(this, this->text()); newFont.setPointSizeF(fontSize); + // Preserve the caller's bold emphasis, which the role font does not carry. + newFont.setBold(font().bold()); setFont(newFont); // qDebug() << "Font size set to" << fontSize; @@ -40,7 +46,10 @@ void DynamicFontSizeLabel::paintEvent(QPaintEvent *event) float DynamicFontSizeLabel::getWidgetMaximumFontSize(QWidget *widget, const QString &text) { - QFont font = widget->font(); + // Measure with the InterfaceDefault family so the fit matches the painted font. + QFont font = themeManager->appFont(AppFont::InterfaceDefault); + font.setBold(widget->font().bold()); + font.setPointSizeF(widget->font().pointSizeF()); const QRect widgetRect = widget->contentsRect(); const float widgetWidth = widgetRect.width(); const float widgetHeight = widgetRect.height(); diff --git a/cockatrice/src/interface/widgets/general/display/dynamic_font_size_push_button.cpp b/cockatrice/src/interface/widgets/general/display/dynamic_font_size_push_button.cpp index 25e8ca48f..9b5f5bdda 100644 --- a/cockatrice/src/interface/widgets/general/display/dynamic_font_size_push_button.cpp +++ b/cockatrice/src/interface/widgets/general/display/dynamic_font_size_push_button.cpp @@ -1,5 +1,6 @@ #include "dynamic_font_size_push_button.h" +#include "../../interface/theme_manager.h" #include "dynamic_font_size_label.h" #include @@ -20,10 +21,12 @@ void DynamicFontSizePushButton::paintEvent(QPaintEvent *event) // Call the base class paintEvent to preserve any other painting behavior QPushButton::paintEvent(event); - // Adjust the font size dynamically based on the text - QFont newFont = font(); + // Adjust the font size dynamically based on the text. Family follows the + // InterfaceDefault theme role; the fitted size stays computed as before. + QFont newFont = themeManager->appFont(AppFont::InterfaceDefault); float fontSize = DynamicFontSizeLabel::getWidgetMaximumFontSize(this, this->text()); newFont.setPointSizeF(fontSize); + newFont.setBold(font().bold()); setFont(newFont); // Get painter for custom painting diff --git a/cockatrice/src/interface/widgets/general/home_styled_button.cpp b/cockatrice/src/interface/widgets/general/home_styled_button.cpp index 7f23827bc..a2ac7912d 100644 --- a/cockatrice/src/interface/widgets/general/home_styled_button.cpp +++ b/cockatrice/src/interface/widgets/general/home_styled_button.cpp @@ -1,6 +1,9 @@ #include "home_styled_button.h" +#include "../../theme_manager.h" + #include +#include #include #include #include @@ -18,9 +21,21 @@ class QWidget; HomeStyledButton::HomeStyledButton(const QString &text, QPair _gradientColors, QWidget *parent) : QPushButton(text, parent), gradientColors(_gradientColors) { - setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); - setMinimumHeight(25); + // Keep the font/style-derived height even when the home page has ample space. + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); setStyleSheet(generateButtonStylesheet(gradientColors)); + applyInterfaceDefaultFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, &HomeStyledButton::applyInterfaceDefaultFont); +} + +void HomeStyledButton::applyInterfaceDefaultFont() +{ + // Family comes from the InterfaceDefault theme role; the 34px size and + // bold weight previously carried by the stylesheet stay caller-side. + QFont buttonFont = themeManager->appFont(AppFont::InterfaceDefault); + buttonFont.setPixelSize(34); + buttonFont.setWeight(QFont::Bold); + setFont(buttonFont); } void HomeStyledButton::updateStylesheet(const QPair &colors) @@ -50,9 +65,6 @@ QString HomeStyledButton::generateButtonStylesheet(const QPair & return QString(R"( QPushButton { - font-size: 34px; - font-weight: bold; - font-family: sans-serif, "Segoe UI", "Helvetica Neue"; padding: 30px; color: white; border: 2px solid %1; diff --git a/cockatrice/src/interface/widgets/general/home_styled_button.h b/cockatrice/src/interface/widgets/general/home_styled_button.h index 55ac5550f..5756db5a1 100644 --- a/cockatrice/src/interface/widgets/general/home_styled_button.h +++ b/cockatrice/src/interface/widgets/general/home_styled_button.h @@ -24,6 +24,9 @@ public: public slots: void paintEvent(QPaintEvent *event) override; +private slots: + void applyInterfaceDefaultFont(); + private: QPair gradientColors; }; diff --git a/cockatrice/src/interface/widgets/general/home_widget.cpp b/cockatrice/src/interface/widgets/general/home_widget.cpp index 5905b2c40..db4501efc 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.cpp +++ b/cockatrice/src/interface/widgets/general/home_widget.cpp @@ -27,10 +27,11 @@ #include #include #include -#include +#include #include #include #include +#include #include #include #include @@ -44,9 +45,9 @@ #include #include #include +#include #include #include -#include #include #include #include @@ -60,6 +61,7 @@ #include #include #include +#include #include #include #include @@ -68,20 +70,26 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor) : QWidget(parent), tabSupervisor(_tabSupervisor), background(themePixmap(QStringLiteral("backgrounds/home"))) { - layout = new QGridLayout(this); + setObjectName("homeWidget"); backgroundSourceCard = new CardInfoPictureArtCropWidget(this); gradientColors = determineButtonColor(); - layout->addWidget(createButtons(), 1, 1, Qt::AlignVCenter | Qt::AlignHCenter); + // The launcher keeps its natural size on a large display and stays centred in the + // viewport. On a small window it scrolls, instead of its combined minimum height + // becoming the minimum height of the entire application. + auto *scrollArea = QtUtils::makeScrollArea(this, Qt::ScrollBarAsNeeded, Qt::ScrollBarAsNeeded); + scrollArea->setObjectName("homeActionsScrollArea"); + scrollArea->setFrameShape(QFrame::NoFrame); + scrollArea->setWidgetResizable(false); + scrollArea->setAlignment(Qt::AlignCenter); + // The home background is painted by this widget, so let it show through the viewport. + scrollArea->viewport()->setAutoFillBackground(false); + scrollArea->setWidget(createButtons()); - layout->setRowStretch(0, 1); - layout->setRowStretch(2, 1); - layout->setColumnStretch(0, 1); - layout->setColumnStretch(2, 1); - - setLayout(layout); + auto *layout = new QVBoxLayout(this); + layout->addWidget(scrollArea); cardChangeTimer = new QTimer(this); connect(cardChangeTimer, &QTimer::timeout, this, &HomeWidget::updateRandomCard); @@ -287,22 +295,25 @@ void HomeWidget::updateButtonsToBackgroundColor() QGroupBox *HomeWidget::createButtons() { QGroupBox *box = new QGroupBox(this); - box->setStyleSheet(R"( - QGroupBox { - font-size: 20px; - color: white; /* Title text color */ - background: transparent; - } - - QGroupBox::title { - color: white; - subcontrol-origin: margin; - subcontrol-position: top center; /* or top left / right */ - } -)"); - box->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + box->setObjectName("homeButtonsBox"); + // The box only stacks the logo and the actions: no frame, and no background of its + // own, so the home background art stays visible. It never shows a title. The rule is + // load-bearing: QScrollArea::setWidget() turns autoFillBackground on for its widget. + box->setStyleSheet("QGroupBox { background: transparent; border: none; }"); + // Family comes from the InterfaceDefault theme role; the 20px size stays caller-side. + auto applyBoxFont = [box]() { + QFont boxFont = themeManager->appFont(AppFont::InterfaceDefault); + boxFont.setPixelSize(20); + box->setFont(boxFont); + }; + applyBoxFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, applyBoxFont); QVBoxLayout *boxLayout = new QVBoxLayout; boxLayout->setAlignment(Qt::AlignHCenter); + // The scroll area is not resizable, so the box has to size itself: SetFixedSize keeps + // it at its content size and re-fixes it whenever the labels or fonts change, which is + // what makes the scroll area's scroll bars follow the content. + boxLayout->setSizeConstraint(QLayout::SetFixedSize); logoLabel = new QLabel; logoLabel->setAlignment(Qt::AlignCenter); @@ -311,27 +322,34 @@ QGroupBox *HomeWidget::createButtons() boxLayout->addSpacing(25); connectButton = new HomeStyledButton(QString(), gradientColors); + connectButton->setObjectName("homeConnectButton"); boxLayout->addWidget(connectButton); visualDeckEditorButton = new HomeStyledButton(QString(), gradientColors); + visualDeckEditorButton->setObjectName("homeCreateNewDeckButton"); connect(visualDeckEditorButton, &QPushButton::clicked, tabSupervisor, [this] { tabSupervisor->openDeckInNewTab(LoadedDeck()); }); boxLayout->addWidget(visualDeckEditorButton); visualDeckStorageButton = new HomeStyledButton(QString(), gradientColors); + visualDeckStorageButton->setObjectName("homeBrowseDecksButton"); connect(visualDeckStorageButton, &QPushButton::clicked, tabSupervisor, [this] { tabSupervisor->actTabVisualDeckStorage(true); }); boxLayout->addWidget(visualDeckStorageButton); visualDatabaseDisplayButton = new HomeStyledButton(QString(), gradientColors); + visualDatabaseDisplayButton->setObjectName("homeBrowseCardDatabaseButton"); connect(visualDatabaseDisplayButton, &QPushButton::clicked, tabSupervisor, &TabSupervisor::addVisualDatabaseDisplayTab); boxLayout->addWidget(visualDatabaseDisplayButton); edhrecButton = new HomeStyledButton(QString(), gradientColors); + edhrecButton->setObjectName("homeBrowseEdhrecButton"); connect(edhrecButton, &QPushButton::clicked, tabSupervisor, &TabSupervisor::addEdhrecMainTab); boxLayout->addWidget(edhrecButton); archidektButton = new HomeStyledButton(QString(), gradientColors); + archidektButton->setObjectName("homeBrowseArchidektButton"); connect(archidektButton, &QPushButton::clicked, tabSupervisor, &TabSupervisor::addArchidektTab); boxLayout->addWidget(archidektButton); replayButton = new HomeStyledButton(QString(), gradientColors); + replayButton->setObjectName("homeViewReplaysButton"); connect(replayButton, &QPushButton::clicked, tabSupervisor, [this] { tabSupervisor->actTabReplays(true); }); boxLayout->addWidget(replayButton); if (qobject_cast(tabSupervisor->parentWidget())) { @@ -463,7 +481,9 @@ void HomeWidget::paintEvent(QPaintEvent *event) constexpr int margin = 15; constexpr qreal attributionMargin = 4.0; - QFont attributionFont = painter.font(); + // Family comes from the InterfaceDefault theme role; the 14px sizes and + // bold weights stay caller-side as before. + QFont attributionFont = themeManager->appFont(AppFont::InterfaceDefault); attributionFont.setPointSize(14); attributionFont.setBold(true); painter.setFont(attributionFont); @@ -476,7 +496,7 @@ void HomeWidget::paintEvent(QPaintEvent *event) // Card name bubble above the attribution (when enabled). if (!cardName.isEmpty() && SettingsCache::instance().appearance().getHomeTabDisplayCardName()) { - QFont font = painter.font(); + QFont font = themeManager->appFont(AppFont::InterfaceDefault); font.setPointSize(14); font.setBold(true); painter.setFont(font); diff --git a/cockatrice/src/interface/widgets/general/home_widget.h b/cockatrice/src/interface/widgets/general/home_widget.h index aa4745c82..fec1db950 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.h +++ b/cockatrice/src/interface/widgets/general/home_widget.h @@ -16,7 +16,6 @@ #include #include -class QGridLayout; class QLabel; class CardInfoPictureArtCropWidget; class ExactCard; @@ -46,7 +45,6 @@ public slots: void updateConnectButton(const ClientStatus status); private: - QGridLayout *layout; QTimer *cardChangeTimer; TabSupervisor *tabSupervisor; QPixmap background; diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp index c4298d6b6..e69687c54 100644 --- a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp @@ -1,5 +1,6 @@ #include "first_run_wizard.h" +#include "../interface/theme_manager.h" #include "first_run_wizard_page.h" #include "pages/account_setup_page.h" #include "pages/card_database_setup_page.h" @@ -32,9 +33,8 @@ FirstRunWizard::FirstRunWizard(QWidget *parent) : QDialog(parent) bannerHost = new BannerHost(this); titleLabel = new QLabel(this); - QFont titleFont = QtUtils::boldFont(titleLabel->font()); - titleFont.setPointSizeF(titleFont.pointSizeF() * 1.4); - titleLabel->setFont(titleFont); + applyInterfaceDefaultFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, &FirstRunWizard::applyInterfaceDefaultFont); subtitleLabel = new QLabel(this); subtitleLabel->setWordWrap(true); @@ -154,6 +154,16 @@ void FirstRunWizard::updateChrome() } } +void FirstRunWizard::applyInterfaceDefaultFont() +{ + // Family comes from the InterfaceDefault theme role; the *1.4 size + // multiplier and bold weight stay caller-side as before. + QFont titleFont = themeManager->appFont(AppFont::InterfaceDefault); + titleFont.setPointSizeF(titleFont.pointSizeF() * 1.4); + titleFont.setBold(true); + titleLabel->setFont(titleFont); +} + void FirstRunWizard::goNext() { FirstRunWizardPage *page = pages[currentIndex]; diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h index 812985a9f..a5fa3b4bd 100644 --- a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h @@ -55,6 +55,7 @@ private slots: void goBack(); void skip(); void updateChrome(); + void applyInterfaceDefaultFont(); private: void addPage(FirstRunWizardPage *page); diff --git a/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.cpp index bad88ddbb..13a43bd47 100644 --- a/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.cpp @@ -1,5 +1,6 @@ #include "all_zones_card_amount_widget.h" +#include "../../theme_manager.h" #include "../general/display/shadow_background_label.h" #include "card_amount_widget.h" #include "libcockatrice/card/card_info.h" @@ -67,6 +68,7 @@ AllZonesCardAmountWidget::AllZonesCardAmountWidget(QWidget *parent, buttonBoxSideboard->setVisible(!isToken); connect(cardSizeSlider, &QSlider::valueChanged, this, &AllZonesCardAmountWidget::adjustFontSize); + connect(themeManager, &ThemeManager::fontsChanged, this, &AllZonesCardAmountWidget::applyCardTextFont); QTimer::singleShot(10, this, [this]() { adjustFontSize(this->cardSizeSlider->value()); }); @@ -90,8 +92,9 @@ void AllZonesCardAmountWidget::adjustFontSize(int scalePercentage) int newFontSize = minFontSize + (scalePercentage - basePercentage) * (maxFontSize - minFontSize) / 225; newFontSize = std::clamp(newFontSize, minFontSize, maxFontSize); - // Update the font labels - QFont zoneLabelFont = zoneLabelMainboard->font(); + // Update the font labels. The family comes from the CardText theme + // role; the slider-driven point size stays as before. + QFont zoneLabelFont = themeManager->appFont(AppFont::CardText); zoneLabelFont.setPointSize(newFontSize); zoneLabelMainboard->setFont(zoneLabelFont); zoneLabelSideboard->setFont(zoneLabelFont); @@ -101,6 +104,11 @@ void AllZonesCardAmountWidget::adjustFontSize(int scalePercentage) repaint(); } +void AllZonesCardAmountWidget::applyCardTextFont() +{ + adjustFontSize(cardSizeSlider->value()); +} + void AllZonesCardAmountWidget::setAmounts(int mainboardAmount, int sideboardAmount, int tokensboardAmount) { buttonBoxMainboard->setAmount(mainboardAmount); diff --git a/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.h b/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.h index cefabe293..3213cf16b 100644 --- a/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.h +++ b/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.h @@ -36,6 +36,9 @@ public slots: void adjustFontSize(int scalePercentage); void setAmounts(int mainboardAmount, int sideboardAmount, int tokensboardAmount); +private slots: + void applyCardTextFont(); + private: QVBoxLayout *layout; QSlider *cardSizeSlider; diff --git a/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.cpp index eae49d7ef..7f25ea1f4 100644 --- a/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.cpp @@ -1,5 +1,6 @@ #include "card_amount_widget.h" +#include "../../theme_manager.h" #include "../deck_editor/deck_state_manager.h" #include "../general/display/dynamic_font_size_push_button.h" #include "deck_list_model.h" @@ -77,6 +78,7 @@ CardAmountWidget::CardAmountWidget(QWidget *parent, // Connect slider for dynamic font size adjustment connect(cardSizeSlider, &QSlider::valueChanged, this, &CardAmountWidget::adjustFontSize); + connect(themeManager, &ThemeManager::fontsChanged, this, &CardAmountWidget::applyCardTextFont); } int CardAmountWidget::getAmount() @@ -138,8 +140,9 @@ void CardAmountWidget::adjustFontSize(int scalePercentage) int newFontSize = minFontSize + (scalePercentage - basePercentage) * (maxFontSize - minFontSize) / 225; newFontSize = std::clamp(newFontSize, minFontSize, maxFontSize); - // Update the font for card count label - QFont cardCountFont = cardCountInZone->font(); + // Update the font for card count label. The family comes from the + // CardText theme role; the slider-driven point size stays as before. + QFont cardCountFont = themeManager->appFont(AppFont::CardText); cardCountFont.setPointSize(newFontSize); cardCountInZone->setFont(cardCountFont); @@ -150,6 +153,11 @@ void CardAmountWidget::adjustFontSize(int scalePercentage) repaint(); } +void CardAmountWidget::applyCardTextFont() +{ + adjustFontSize(cardSizeSlider->value()); +} + /** * @brief Updates the card count display in the widget. */ diff --git a/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.h b/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.h index 6f5889fc7..cef872157 100644 --- a/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.h +++ b/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.h @@ -67,6 +67,7 @@ private slots: void removePrintingSideboard(); void removePrintingTokensboard(); void adjustFontSize(int scalePercentage); + void applyCardTextFont(); }; #endif // CARD_AMOUNT_WIDGET_H diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp index b8438134c..e21bf8a4e 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp @@ -21,12 +21,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -54,6 +56,9 @@ PrintingSelector::PrintingSelector(QWidget *parent, AbstractTabDeckEditor *_deck setLayout(layout); widgetLoadingBufferTimer = new QTimer(this); + deckChangeTimer = new QTimer(this); + deckChangeTimer->setSingleShot(true); + connect(deckChangeTimer, &QTimer::timeout, this, &PrintingSelector::updateDisplay); flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); @@ -122,11 +127,36 @@ void PrintingSelector::retranslateUi() void PrintingSelector::printingsInDeckChanged() { if (SettingsCache::instance().cardsDisplay().getBumpSetsWithCardsInDeckToTop()) { - // Delay the update to avoid race conditions - QTimer::singleShot(100, this, &PrintingSelector::updateDisplay); + displayDirty = true; + if (isVisible()) { + // Coalesce deck changes while the model settles. + deckChangeTimer->start(100); + } else { + widgetLoadingBufferTimer->stop(); + } } } +void PrintingSelector::showEvent(QShowEvent *event) +{ + QWidget::showEvent(event); + if (displayDirty) { + updateDisplay(); + } +} + +void PrintingSelector::hideEvent(QHideEvent *event) +{ + // A partially built batch must be rebuilt on reveal, not resumed with + // stale printing data after the selected card or deck changes. + if (widgetLoadingBufferTimer->isActive() || deckChangeTimer->isActive()) { + widgetLoadingBufferTimer->stop(); + deckChangeTimer->stop(); + displayDirty = true; + } + QWidget::hideEvent(event); +} + /** * @return A map of uuid to amounts (main, side, tokens). */ @@ -173,6 +203,8 @@ void PrintingSelector::updateCardAmounts() */ void PrintingSelector::updateDisplay() { + deckChangeTimer->stop(); + displayDirty = false; widgetLoadingBufferTimer->stop(); widgetLoadingBufferTimer->deleteLater(); widgetLoadingBufferTimer = new QTimer(this); @@ -199,20 +231,18 @@ void PrintingSelector::updateDisplay() */ void PrintingSelector::setCard(const CardInfoPtr &newCard) { - if (newCard.isNull()) { - selectedCard = newCard; - updateDisplay(); - return; - } - - // we don't need to redraw the widget if the card is the same - if (!selectedCard.isNull() && selectedCard->getName() == newCard->getName()) { + // Same-name cards may be different database snapshots after a reload. + if (selectedCard == newCard) { return; } + widgetLoadingBufferTimer->stop(); + deckChangeTimer->stop(); selectedCard = newCard; if (isVisible()) { updateDisplay(); + } else { + displayDirty = true; } flowWidget->setMinimumSizeToMaxSizeHint(); flowWidget->scrollArea->verticalScrollBar()->setValue(0); diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector.h b/cockatrice/src/interface/widgets/printing_selector/printing_selector.h index b9cb27ecd..7b4af95e0 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector.h +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector.h @@ -18,6 +18,8 @@ class QCheckBox; class QHBoxLayout; class QString; class QTimer; +class QShowEvent; +class QHideEvent; class QVBoxLayout; class SettingsButtonWidget; template class QMap; @@ -47,6 +49,10 @@ public: void setCard(const CardInfoPtr &newCard); void getAllSetsForCurrentCard(); +protected: + void showEvent(QShowEvent *event) override; + void hideEvent(QHideEvent *event) override; + public slots: void retranslateUi(); void updateDisplay(); @@ -88,6 +94,8 @@ private: DeckStateManager *deckStateManager; CardInfoPtr selectedCard; QTimer *widgetLoadingBufferTimer; + QTimer *deckChangeTimer; + bool displayDirty = false; int currentIndex = 0; }; diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp index 94acdbdce..f7975fb81 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp @@ -2,6 +2,8 @@ #include "../../../client/settings/cache_settings.h" #include "../../card_picture_loader/card_picture_loader.h" +#include "../../theme_icon.h" +#include "../../theme_manager.h" #include "../cards/card_info_picture_widget.h" #include "../tabs/abstract_tab_deck_editor.h" #include "all_zones_card_amount_widget.h" @@ -12,16 +14,19 @@ #include #include +#include #include #include #include #include -#include #include #include #include #include #include +#include +#include +#include #include #include #include @@ -36,7 +41,6 @@ #include #include #include -#include #include #include #include @@ -120,6 +124,12 @@ PrintingSelectorCardOverlayWidget::PrintingSelectorCardOverlayWidget(QWidget *pa mainLayout->addWidget(cardInfoPicture); initializePinBadge(); + auto refreshPinBadge = [this] { + initializePinBadge(); + updatePinBadgeVisibility(); + }; + connect(themeManager, &ThemeManager::paletteChanged, this, refreshPinBadge); + connect(themeManager, &ThemeManager::themeChanged, this, refreshPinBadge); // Parent the preview to this overlay so it is destroyed with it (Qt::ToolTip keeps // it a frameless, non-activating top-level window despite the parent). @@ -401,10 +411,56 @@ void PrintingSelectorCardOverlayWidget::customMenu(QPoint point) } /** - * @brief Initializes the pin badge overlay and loads its icon with DPI-aware rasterization. + * @brief Composes the pin badge: the glyph over a rounded palette backdrop. * - * The icon is rasterized once using the label's device pixel ratio so it stays crisp on HiDPI - * displays. The resulting pixmap is cached on the QLabel and simply shown/hidden when needed. + * The badge floats on card art rather than on a palette surface, so a bare glyph + * colored WindowText is not enough: a thin dark stroke disappears on dark art in a + * light palette, and a thin light stroke disappears on light art in a dark palette. + * A Window-filled rounded rect with a WindowText outline gives the glyph a solid + * backing, and the outline guarantees the badge edge contrasts with the art even when + * the fill matches it. Both roles follow the palette, like the glyph. + * + * @param logicalSize The badge's size in logical pixels. + * @param devicePixelRatio The label's device pixel ratio, applied to the pixmap so it + * stays crisp on HiDPI displays. + * @param palette The palette the glyph's WindowText resolves against. + */ +static QPixmap composePinBadgePixmap(const QSize &logicalSize, qreal devicePixelRatio, const QPalette &palette) +{ + // Inset the glyph so the backdrop reads as a badge around it, not as a border. + const int inset = qRound(logicalSize.width() * 0.16); + const QSize glyphSize = logicalSize - QSize(inset * 2, inset * 2); + const QPixmap glyph = + loadThemedIcon(QStringLiteral("icons/pin"), AppColor::Marked).pixmap(glyphSize, devicePixelRatio); + if (glyph.isNull()) { + // Let the caller fall back to the textual badge instead of drawing an empty panel. + return QPixmap(); + } + + QPixmap badge(logicalSize * devicePixelRatio); + badge.setDevicePixelRatio(devicePixelRatio); + badge.fill(Qt::transparent); + + QPainter painter(&badge); + painter.setRenderHint(QPainter::Antialiasing); + + // Half-pixel inset keeps the 1.5px outline inside the pixmap instead of clipping it. + const QRectF box(QPointF(0.5, 0.5), QSizeF(logicalSize) - QSizeF(1, 1)); + painter.setPen(QPen(palette.color(QPalette::WindowText), 1.5)); + painter.setBrush(palette.color(QPalette::Window)); + painter.drawRoundedRect(box, 8, 8); + painter.drawPixmap(QPointF(inset, inset), glyph); + + return badge; +} + +/** + * @brief Initializes the pin badge overlay and loads a DPI-aware icon pixmap. + * + * The glyph is drawn onto a rounded palette backdrop so the badge stays readable on + * card art, and it is rasterized at the label's device pixel ratio so it stays crisp on + * HiDPI displays. It is re-rendered when the palette or theme changes so the badge + * follows the surrounding UI instead of freezing at its first colors. * If the SVG cannot be read, a textual fallback badge is created instead. */ void PrintingSelectorCardOverlayWidget::initializePinBadge() @@ -420,24 +476,13 @@ void PrintingSelectorCardOverlayWidget::initializePinBadge() } bool pinLoaded = false; - QImageReader pinReader(QStringLiteral("theme:icons/pin")); - - if (pinReader.canRead()) { - // Rasterize a 64×64 logical icon so it has a consistent size regardless of card scaling. - const QSize targetSize(64, 64); - const qreal dpr = pinBadge->devicePixelRatioF(); - const QSize rasterSize(qMax(1, qCeil(targetSize.width() * dpr)), qMax(1, qCeil(targetSize.height() * dpr))); - pinReader.setScaledSize(rasterSize); - const QImage pinImage = pinReader.read(); - if (!pinImage.isNull()) { - // Tag the pixmap with the same DPR so Qt renders it at the correct physical size. - QPixmap pinPix = QPixmap::fromImage(pinImage); - pinPix.setDevicePixelRatio(dpr); - pinBadge->setPixmap(pinPix); - pinBadge->setFixedSize(targetSize); - pinBadge->setStyleSheet(QStringLiteral("background: transparent;")); - pinLoaded = true; - } + const QSize targetSize(64, 64); + const QPixmap pinPix = composePinBadgePixmap(targetSize, pinBadge->devicePixelRatioF(), pinBadge->palette()); + if (!pinPix.isNull()) { + pinBadge->setPixmap(pinPix); + pinBadge->setFixedSize(targetSize); + pinBadge->setStyleSheet(QStringLiteral("background: transparent;")); + pinLoaded = true; } if (!pinLoaded) { diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_placeholder_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector_placeholder_widget.cpp index 7f9ad66c8..225c3e21d 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_placeholder_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_placeholder_widget.cpp @@ -1,5 +1,8 @@ #include "printing_selector_placeholder_widget.h" +#include "../interface/theme_manager.h" + +#include #include #include #include @@ -30,10 +33,12 @@ PrintingSelectorPlaceholderWidget::PrintingSelectorPlaceholderWidget(QWidget *pa textLabel->setStyleSheet(R"( QLabel { color: palette(mid); - font-size: 14px; padding: 10px; } )"); + applyInterfaceDefaultFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, + &PrintingSelectorPlaceholderWidget::applyInterfaceDefaultFont); mainLayout->addWidget(imageLabel); mainLayout->addWidget(textLabel); @@ -44,4 +49,12 @@ PrintingSelectorPlaceholderWidget::PrintingSelectorPlaceholderWidget(QWidget *pa void PrintingSelectorPlaceholderWidget::retranslateUi() { textLabel->setText(tr("Select a card to view its available printings")); +} + +void PrintingSelectorPlaceholderWidget::applyInterfaceDefaultFont() +{ + // Family comes from the InterfaceDefault theme role; the 14px size stays caller-side. + QFont placeholderFont = themeManager->appFont(AppFont::InterfaceDefault); + placeholderFont.setPixelSize(14); + textLabel->setFont(placeholderFont); } \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_placeholder_widget.h b/cockatrice/src/interface/widgets/printing_selector/printing_selector_placeholder_widget.h index 6702d325a..9e6e47bcb 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_placeholder_widget.h +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_placeholder_widget.h @@ -14,6 +14,9 @@ class PrintingSelectorPlaceholderWidget : public QWidget public: explicit PrintingSelectorPlaceholderWidget(QWidget *parent = nullptr); +private slots: + void applyInterfaceDefaultFont(); + private: QVBoxLayout *mainLayout; QLabel *imageLabel; diff --git a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp index ea0d2db5a..c6a6d989f 100644 --- a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp +++ b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp @@ -1,12 +1,12 @@ #include "settings_button_widget.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "settings_popup_widget.h" #include #include +#include #include -#include #include #include #include @@ -21,7 +21,7 @@ SettingsButtonWidget::SettingsButtonWidget(QWidget *parent) : QWidget(parent), button(new QToolButton(this)), popup(new SettingsPopupWidget(nullptr)) { - button->setIcon(themePixmap(QStringLiteral("icons/cogwheel"))); + button->setIcon(loadThemedIcon(QStringLiteral("icons/settings"))); button->setCheckable(true); button->setFixedSize(32, 32); connect(button, &QToolButton::clicked, this, &SettingsButtonWidget::togglePopup); @@ -49,9 +49,9 @@ void SettingsButtonWidget::removeSettingsWidget(QWidget *toRemove) const popup->removeSettingsWidget(toRemove); } -void SettingsButtonWidget::setButtonIcon(QPixmap iconMap) +void SettingsButtonWidget::setButtonIcon(const QIcon &icon) { - button->setIcon(iconMap); + button->setIcon(icon); } void SettingsButtonWidget::setButtonText(const QString &text) diff --git a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.h b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.h index 015300bf8..56d542d37 100644 --- a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.h +++ b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.h @@ -13,7 +13,7 @@ #include class QHBoxLayout; -class QPixmap; +class QIcon; class QToolButton; class SettingsPopupWidget; @@ -28,7 +28,7 @@ public: void addSettingsWidget(QWidget *toAdd) const; void removeSettingsWidget(QWidget *toRemove) const; - void setButtonIcon(QPixmap iconMap); + void setButtonIcon(const QIcon &icon); void setButtonText(const QString &buttonText); void setCompact(bool compact); bool isCompact() const diff --git a/cockatrice/src/interface/widgets/replay/replay_event_index.cpp b/cockatrice/src/interface/widgets/replay/replay_event_index.cpp new file mode 100644 index 000000000..d21671b5a --- /dev/null +++ b/cockatrice/src/interface/widgets/replay/replay_event_index.cpp @@ -0,0 +1,876 @@ +#include "replay_event_index.h" + +#include "libcockatrice/protocol/get_pb_extension.h" +#include "libcockatrice/protocol/pb/card_attributes.pb.h" +#include "libcockatrice/protocol/pb/context_concede.pb.h" +#include "libcockatrice/protocol/pb/context_connection_state_changed.pb.h" +#include "libcockatrice/protocol/pb/context_deck_select.pb.h" +#include "libcockatrice/protocol/pb/context_mulligan.pb.h" +#include "libcockatrice/protocol/pb/context_ready_start.pb.h" +#include "libcockatrice/protocol/pb/context_set_sideboard_lock.pb.h" +#include "libcockatrice/protocol/pb/context_undo_draw.pb.h" +#include "libcockatrice/protocol/pb/event_attach_card.pb.h" +#include "libcockatrice/protocol/pb/event_change_zone_properties.pb.h" +#include "libcockatrice/protocol/pb/event_create_counter.pb.h" +#include "libcockatrice/protocol/pb/event_create_token.pb.h" +#include "libcockatrice/protocol/pb/event_del_counter.pb.h" +#include "libcockatrice/protocol/pb/event_destroy_card.pb.h" +#include "libcockatrice/protocol/pb/event_draw_cards.pb.h" +#include "libcockatrice/protocol/pb/event_dump_zone.pb.h" +#include "libcockatrice/protocol/pb/event_flip_card.pb.h" +#include "libcockatrice/protocol/pb/event_game_closed.pb.h" +#include "libcockatrice/protocol/pb/event_game_host_changed.pb.h" +#include "libcockatrice/protocol/pb/event_game_log_notice.pb.h" +#include "libcockatrice/protocol/pb/event_game_say.pb.h" +#include "libcockatrice/protocol/pb/event_game_state_changed.pb.h" +#include "libcockatrice/protocol/pb/event_join.pb.h" +#include "libcockatrice/protocol/pb/event_kicked.pb.h" +#include "libcockatrice/protocol/pb/event_leave.pb.h" +#include "libcockatrice/protocol/pb/event_move_card.pb.h" +#include "libcockatrice/protocol/pb/event_player_properties_changed.pb.h" +#include "libcockatrice/protocol/pb/event_reveal_cards.pb.h" +#include "libcockatrice/protocol/pb/event_reverse_turn.pb.h" +#include "libcockatrice/protocol/pb/event_roll_die.pb.h" +#include "libcockatrice/protocol/pb/event_set_active_phase.pb.h" +#include "libcockatrice/protocol/pb/event_set_active_player.pb.h" +#include "libcockatrice/protocol/pb/event_set_card_attr.pb.h" +#include "libcockatrice/protocol/pb/event_set_card_counter.pb.h" +#include "libcockatrice/protocol/pb/event_set_counter.pb.h" +#include "libcockatrice/protocol/pb/event_shuffle.pb.h" +#include "libcockatrice/protocol/pb/game_event.pb.h" +#include "libcockatrice/protocol/pb/game_event_container.pb.h" +#include "libcockatrice/protocol/pb/game_event_context.pb.h" +#include "libcockatrice/protocol/pb/game_replay.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_card.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_counter.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_player.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_playerproperties.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_zone.pb.h" +#include "libcockatrice/utility/zone_names.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +struct PendingDetail +{ + QString text; + QString badge; + ReplayEventKind kind = ReplayEventKind::Action; + QString actor; +}; + +QString formatTime(int timeMs) +{ + const int totalSeconds = qMax(0, timeMs) / 1000; + const int hours = totalSeconds / 3600; + const int minutes = (totalSeconds / 60) % 60; + const int seconds = totalSeconds % 60; + + if (hours > 0) { + return QStringLiteral("%1:%2:%3") + .arg(hours) + .arg(minutes, 2, 10, QLatin1Char('0')) + .arg(seconds, 2, 10, QLatin1Char('0')); + } + + return QStringLiteral("%1:%2").arg(minutes, 2, 10, QLatin1Char('0')).arg(seconds, 2, 10, QLatin1Char('0')); +} + +class ReplayEventIndexBuilder +{ + Q_DECLARE_TR_FUNCTIONS(ReplayEventIndex) + +public: + ReplayEventIndexBuilder(const GameReplay *replay, const QList &timeline) : replay(replay), timeline(timeline) + { + } + + QList build() + { + QList entries; + + for (int i = 0; i < replay->event_list_size(); ++i) { + const GameEventContainer &container = replay->event_list(i); + const QList details = describeContainer(container); + if (details.isEmpty()) { + continue; + } + + ReplayEventEntry entry; + entry.eventIndex = i; + entry.timeMs = timeline.value(i, static_cast(container.seconds_elapsed()) * 1000); + entry.timeLabel = formatTime(entry.timeMs); + entry.kind = details.first().kind; + entry.badge = details.first().badge; + if (details.size() > 1) { + entry.badge += QStringLiteral(" +%1").arg(details.size() - 1); + } + + const QString sharedActor = details.first().actor; + const bool mixedActors = std::any_of(details.cbegin(), details.cend(), [&sharedActor](const auto &detail) { + return detail.actor != sharedActor; + }); + + QStringList detailTexts; + for (const PendingDetail &detail : details) { + if (mixedActors && !detail.actor.isEmpty()) { + detailTexts.append(tr("%1: %2").arg(detail.actor, detail.text)); + } else { + detailTexts.append(detail.text); + } + } + entry.actor = mixedActors ? QString() : sharedActor; + entry.detail = detailTexts.join(QStringLiteral(" • ")); + + entries.append(entry); + } + + return entries; + } + +private: + const GameReplay *replay; + const QList &timeline; + + QHash playerNames; + QHash spectators; + QHash cardNames; + QHash counterNames; + + int activePlayerId = -1; + bool gameStarted = false; + bool sawGameState = false; + + static QString cardKey(int playerId, const QString &zone, int cardId) + { + return QStringLiteral("%1/%2/%3").arg(playerId).arg(zone).arg(cardId); + } + + static QString counterKey(int playerId, int counterId) + { + return QStringLiteral("%1/%2").arg(playerId).arg(counterId); + } + + QString playerName(int playerId) const + { + if (playerId < 0) { + return tr("Game"); + } + return playerNames.value(playerId, tr("Player %1").arg(playerId)); + } + + QString actorName(int playerId) const + { + if (playerId < 0) { + return QString(); + } + return playerNames.value(playerId, tr("Player %1").arg(playerId)); + } + + bool isSpectator(int playerId) const + { + return spectators.value(playerId, false); + } + + void rememberPlayer(const ServerInfo_PlayerProperties &properties) + { + const int playerId = properties.player_id(); + if (playerId < 0) { + return; + } + + const QString name = QString::fromStdString(properties.user_info().name()); + if (!name.isEmpty()) { + playerNames.insert(playerId, name); + } + spectators.insert(playerId, properties.spectator()); + } + + void rememberCard(int playerId, const QString &zone, int cardId, const QString &name) + { + if (cardId >= 0 && !name.isEmpty()) { + cardNames.insert(cardKey(playerId, zone, cardId), name); + } + } + + void forgetCard(int playerId, const QString &zone, int cardId) + { + if (cardId >= 0) { + cardNames.remove(cardKey(playerId, zone, cardId)); + } + } + + QString cardName(int playerId, const QString &zone, int cardId) const + { + if (cardId < 0) { + return QString(); + } + return cardNames.value(cardKey(playerId, zone, cardId)); + } + + void rememberCounter(int playerId, const ServerInfo_Counter &counter) + { + const QString name = QString::fromStdString(counter.name()); + if (!name.isEmpty()) { + counterNames.insert(counterKey(playerId, counter.id()), name); + } + } + + QString counterName(int playerId, int counterId) const + { + return counterNames.value(counterKey(playerId, counterId)); + } + + QString zoneName(const QString &zone) const + { + if (zone == ZoneNames::TABLE) { + return tr("battlefield"); + } + if (zone == ZoneNames::GRAVE) { + return tr("graveyard"); + } + if (zone == ZoneNames::EXILE) { + return tr("exile"); + } + if (zone == ZoneNames::HAND) { + return tr("hand"); + } + if (zone == ZoneNames::DECK) { + return tr("library"); + } + if (zone == ZoneNames::SIDEBOARD) { + return tr("sideboard"); + } + if (zone == ZoneNames::STACK) { + return tr("stack"); + } + return zone; + } + + QString zonePhrase(int playerId, const QString &zone, int actorId) const + { + if (zone == ZoneNames::TABLE) { + return tr("the battlefield"); + } + if (zone == ZoneNames::EXILE) { + return tr("exile"); + } + if (zone == ZoneNames::STACK) { + return tr("the stack"); + } + + const QString name = zoneName(zone); + if (playerId < 0 || playerId == actorId) { + return tr("their %1").arg(name); + } + return tr("%1's %2").arg(playerName(playerId), name); + } + + QString formatCardList(const QStringList &names, int total) const + { + // Keep repeats: each entry is a drawn/revealed card, so the list length must agree with the count. + QStringList listedNames; + for (const QString &name : names) { + if (!name.isEmpty()) { + listedNames.append(name); + } + } + + if (listedNames.isEmpty()) { + if (total <= 0) { + return tr("no cards"); + } + return total == 1 ? tr("1 card") : tr("%1 cards").arg(total); + } + + if (total < listedNames.size()) { + total = listedNames.size(); + } + + if (listedNames.size() > 3) { + return tr("%1 and %2 more").arg(listedNames.mid(0, 3).join(QStringLiteral(", "))).arg(total - 3); + } + if (total > listedNames.size()) { + return tr("%1 and %2 more").arg(listedNames.join(QStringLiteral(", "))).arg(total - listedNames.size()); + } + + return listedNames.join(QStringLiteral(", ")); + } + + void updateGameState(const Event_GameStateChanged &event) + { + for (int i = 0; i < event.player_list_size(); ++i) { + const ServerInfo_Player &player = event.player_list(i); + const int playerId = player.properties().player_id(); + rememberPlayer(player.properties()); + + for (int j = 0; j < player.zone_list_size(); ++j) { + const ServerInfo_Zone &zone = player.zone_list(j); + for (int k = 0; k < zone.card_list_size(); ++k) { + const ServerInfo_Card &card = zone.card_list(k); + rememberCard(playerId, QString::fromStdString(zone.name()), card.id(), + QString::fromStdString(card.name())); + } + } + + for (int j = 0; j < player.counter_list_size(); ++j) { + rememberCounter(playerId, player.counter_list(j)); + } + } + + if (event.has_active_player_id()) { + activePlayerId = event.active_player_id(); + } + if (event.has_game_started()) { + gameStarted = event.game_started(); + } + } + + QString describeMoveCard(const Event_MoveCard &event, int actorId, const GameEventContext &context) + { + const int startPlayerId = event.start_player_id() >= 0 ? event.start_player_id() : actorId; + const int targetPlayerId = event.target_player_id() >= 0 ? event.target_player_id() : startPlayerId; + const QString startZone = QString::fromStdString(event.start_zone()); + const QString targetZone = event.has_target_zone() ? QString::fromStdString(event.target_zone()) : startZone; + + QString name; + if (event.has_card_name()) { + name = QString::fromStdString(event.card_name()); + } + if (name.isEmpty()) { + name = cardName(startPlayerId, startZone, event.card_id()); + } + + const bool knownName = !name.isEmpty(); + if (name.isEmpty()) { + name = event.face_down() ? tr("a face-down card") : tr("a card"); + } + + const int newCardId = event.has_new_card_id() ? event.new_card_id() : event.card_id(); + forgetCard(startPlayerId, startZone, event.card_id()); + if (knownName) { + rememberCard(targetPlayerId, targetZone, newCardId, name); + } + + if (startZone == targetZone) { + return tr("moved %1 within %2.").arg(name, zonePhrase(targetPlayerId, targetZone, actorId)); + } + if (context.HasExtension(Context_UndoDraw::ext)) { + return tr("returned %1 to %2.").arg(name, zonePhrase(targetPlayerId, targetZone, actorId)); + } + + return tr("moved %1 from %2 to %3.") + .arg(name, zonePhrase(startPlayerId, startZone, actorId), zonePhrase(targetPlayerId, targetZone, actorId)); + } + + QString describeSetCardAttribute(const Event_SetCardAttr &event, int actorId) + { + const QString zone = QString::fromStdString(event.zone_name()); + QString name; + if (event.has_card_id()) { + name = cardName(actorId, zone, event.card_id()); + } + if (name.isEmpty()) { + name = event.has_card_id() ? tr("a card") : tr("all cards"); + } + + const QString value = QString::fromStdString(event.attr_value()); + switch (event.attribute()) { + case AttrTapped: + return value == QLatin1String("1") ? tr("tapped %1.").arg(name) : tr("untapped %1.").arg(name); + case AttrAttacking: + return value == QLatin1String("1") ? tr("%1 is now attacking.").arg(name) + : tr("%1 is no longer attacking.").arg(name); + case AttrFaceDown: + return value == QLatin1String("1") ? tr("turned %1 face down.").arg(name) + : tr("turned %1 face up.").arg(name); + case AttrColor: + return tr("changed %1's color to %2.").arg(name, value); + case AttrPT: + return tr("set %1's power/toughness to %2.").arg(name, value); + case AttrAnnotation: + return value.isEmpty() ? tr("cleared %1's annotation.").arg(name) + : tr("annotated %1: %2").arg(name, value); + case AttrDoesntUntap: + return value == QLatin1String("1") ? tr("%1 will no longer untap.").arg(name) + : tr("%1 will untap normally again.").arg(name); + } + + return tr("changed %1.").arg(name); + } + + QString badgeForCardAttribute(CardAttribute attribute) const + { + switch (attribute) { + case AttrTapped: + return tr("TAP"); + case AttrAttacking: + return tr("ATTACK"); + case AttrFaceDown: + return tr("FLIP"); + case AttrColor: + return tr("COLOR"); + case AttrPT: + return tr("PT"); + case AttrAnnotation: + return tr("NOTE"); + case AttrDoesntUntap: + return tr("TAP"); + } + return tr("CARD"); + } + + QString describeLeaveReason(Event_Leave::LeaveReason reason) const + { + switch (reason) { + case Event_Leave::USER_KICKED: + return tr("kicked by game host or moderator"); + case Event_Leave::USER_LEFT: + return tr("left the game"); + case Event_Leave::USER_DISCONNECTED: + return tr("disconnected"); + case Event_Leave::OTHER: + default: + return tr("reason unknown"); + } + } + + void addDetail(QList &details, + const QString &text, + const QString &badge, + ReplayEventKind kind, + int actorId, + bool forcedByJudge) const + { + if (text.isEmpty()) { + return; + } + + const QString finalText = forcedByJudge ? tr("%1 (judge)").arg(text) : text; + details.append(PendingDetail{.text = finalText, .badge = badge, .kind = kind, .actor = actorName(actorId)}); + } + + QList describeContainer(const GameEventContainer &container) + { + QList details; + const GameEventContext &context = container.context(); + const bool forcedByJudge = container.has_forced_by_judge(); + const auto contextType = static_cast(getPbExtension(context)); + + for (int i = 0; i < container.event_list_size(); ++i) { + const GameEvent &event = container.event_list(i); + const int eventPlayerId = event.player_id(); + const auto eventType = static_cast(getPbExtension(event)); + + switch (eventType) { + case GameEvent::GAME_STATE_CHANGED: { + const Event_GameStateChanged &state = event.GetExtension(Event_GameStateChanged::ext); + const bool wasStarted = gameStarted; + updateGameState(state); + if (state.game_started() && !wasStarted) { + addDetail(details, tr("Game started."), tr("GAME"), ReplayEventKind::System, -1, forcedByJudge); + } else if (!sawGameState) { + addDetail(details, tr("Game state loaded."), tr("GAME"), ReplayEventKind::System, -1, + forcedByJudge); + } + sawGameState = true; + break; + } + case GameEvent::PLAYER_PROPERTIES_CHANGED: { + const Event_PlayerPropertiesChanged &propertiesEvent = + event.GetExtension(Event_PlayerPropertiesChanged::ext); + const bool hasProperties = propertiesEvent.has_player_properties(); + const ServerInfo_PlayerProperties &properties = propertiesEvent.player_properties(); + // The server only fills in the field that changed, so player_id is absent from these events + // unless they are about a specific player; fall back to the event's own player id. + const bool describesPlayer = hasProperties && properties.has_player_id(); + const int playerId = describesPlayer ? properties.player_id() : eventPlayerId; + if (describesPlayer) { + rememberPlayer(properties); + } + + switch (contextType) { + case GameEventContext::READY_START: + addDetail(details, + properties.ready_start() ? tr("is ready to start.") : tr("is no longer ready."), + tr("READY"), ReplayEventKind::System, playerId, forcedByJudge); + break; + case GameEventContext::CONCEDE: + addDetail(details, tr("conceded the game."), tr("CONCEDE"), ReplayEventKind::System, + playerId, forcedByJudge); + break; + case GameEventContext::UNCONCEDE: + addDetail(details, tr("unconceded the game."), tr("CONCEDE"), ReplayEventKind::System, + playerId, forcedByJudge); + break; + case GameEventContext::DECK_SELECT: { + const Context_DeckSelect &deckSelect = context.GetExtension(Context_DeckSelect::ext); + const int sideboardSize = deckSelect.sideboard_size(); + addDetail(details, + sideboardSize >= 0 + ? tr("selected a deck with %1 sideboard cards.").arg(sideboardSize) + : tr("selected a deck."), + tr("DECK"), ReplayEventKind::System, playerId, forcedByJudge); + break; + } + case GameEventContext::MULLIGAN: { + const Context_Mulligan &mulligan = context.GetExtension(Context_Mulligan::ext); + addDetail(details, + mulligan.number() > 0 ? tr("took a mulligan (%1).").arg(mulligan.number()) + : tr("took a mulligan."), + tr("MULLIGAN"), ReplayEventKind::System, playerId, forcedByJudge); + break; + } + case GameEventContext::SET_SIDEBOARD_LOCK: + addDetail(details, + properties.sideboard_locked() ? tr("locked their sideboard.") + : tr("unlocked their sideboard."), + tr("SIDEBOARD"), ReplayEventKind::System, playerId, forcedByJudge); + break; + case GameEventContext::CONNECTION_STATE_CHANGED: + addDetail(details, + properties.ping_seconds() != -1 ? tr("reconnected to the game.") + : tr("lost connection to the game."), + tr("CONNECTION"), ReplayEventKind::System, playerId, forcedByJudge); + break; + default: + break; + } + break; + } + case GameEvent::JOIN: { + const Event_Join &join = event.GetExtension(Event_Join::ext); + if (join.has_player_properties()) { + rememberPlayer(join.player_properties()); + } + const int playerId = join.player_properties().player_id() >= 0 + ? join.player_properties().player_id() + : eventPlayerId; + addDetail(details, isSpectator(playerId) ? tr("joined as a spectator.") : tr("joined the game."), + tr("JOIN"), ReplayEventKind::System, playerId, forcedByJudge); + break; + } + case GameEvent::LEAVE: { + const Event_Leave &leave = event.GetExtension(Event_Leave::ext); + const QString reason = describeLeaveReason(leave.reason()); + if (eventPlayerId < 0) { + addDetail(details, tr("A player left the game (%1).").arg(reason), tr("LEAVE"), + ReplayEventKind::System, -1, forcedByJudge); + } else { + addDetail(details, + isSpectator(eventPlayerId) ? tr("stopped spectating (%1).").arg(reason) + : tr("left the game (%1).").arg(reason), + tr("LEAVE"), ReplayEventKind::System, eventPlayerId, forcedByJudge); + } + break; + } + case GameEvent::KICKED: + addDetail(details, + eventPlayerId >= 0 ? tr("was kicked from the game.") + : tr("A player was kicked from the game."), + tr("KICK"), ReplayEventKind::System, eventPlayerId, forcedByJudge); + break; + case GameEvent::GAME_CLOSED: + addDetail(details, tr("Game closed."), tr("GAME"), ReplayEventKind::System, -1, forcedByJudge); + break; + case GameEvent::GAME_HOST_CHANGED: + addDetail(details, eventPlayerId >= 0 ? tr("is now the game host.") : tr("The game host changed."), + tr("HOST"), ReplayEventKind::System, eventPlayerId, forcedByJudge); + break; + case GameEvent::GAME_SAY: { + const Event_GameSay &say = event.GetExtension(Event_GameSay::ext); + addDetail(details, QString::fromStdString(say.message()), tr("CHAT"), ReplayEventKind::Chat, + eventPlayerId, forcedByJudge); + break; + } + case GameEvent::SET_ACTIVE_PLAYER: { + const Event_SetActivePlayer &setActivePlayer = event.GetExtension(Event_SetActivePlayer::ext); + activePlayerId = setActivePlayer.active_player_id(); + if (activePlayerId >= 0) { + addDetail(details, tr("begins their turn."), tr("TURN"), ReplayEventKind::Turn, activePlayerId, + forcedByJudge); + } + break; + } + case GameEvent::SET_ACTIVE_PHASE: { + const Event_SetActivePhase &setActivePhase = event.GetExtension(Event_SetActivePhase::ext); + if (setActivePhase.phase() >= 0) { + const QString phaseName = Phases::getPhase(setActivePhase.phase()).getName(); + addDetail(details, tr("entered the %1 phase.").arg(phaseName), tr("PHASE"), + ReplayEventKind::Phase, activePlayerId >= 0 ? activePlayerId : eventPlayerId, + forcedByJudge); + } + break; + } + case GameEvent::REVERSE_TURN: { + const Event_ReverseTurn &reverseTurn = event.GetExtension(Event_ReverseTurn::ext); + addDetail(details, + reverseTurn.reversed() ? tr("reversed the turn order.") : tr("restored the turn order."), + tr("TURN"), ReplayEventKind::Turn, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::MOVE_CARD: { + const Event_MoveCard &moveCard = event.GetExtension(Event_MoveCard::ext); + const QString text = describeMoveCard(moveCard, eventPlayerId, context); + const QString startZone = QString::fromStdString(moveCard.start_zone()); + const QString targetZone = QString::fromStdString(moveCard.target_zone()); + QString badge = tr("MOVE"); + if (context.HasExtension(Context_UndoDraw::ext)) { + badge = tr("UNDO"); + } else if (startZone == ZoneNames::STACK) { + badge = tr("RESOLVE"); + } else if (startZone == ZoneNames::HAND && targetZone == ZoneNames::TABLE) { + badge = tr("PLAY"); + } + addDetail(details, text, badge, ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::DRAW_CARDS: { + const Event_DrawCards &drawCards = event.GetExtension(Event_DrawCards::ext); + QStringList names; + for (int j = 0; j < drawCards.cards_size(); ++j) { + const ServerInfo_Card &card = drawCards.cards(j); + const QString name = QString::fromStdString(card.name()); + names.append(name); + rememberCard(eventPlayerId, ZoneNames::HAND, card.id(), name); + } + if (drawCards.number() > 0) { + addDetail(details, tr("drew %1.").arg(formatCardList(names, drawCards.number())), tr("DRAW"), + ReplayEventKind::Action, eventPlayerId, forcedByJudge); + } + break; + } + case GameEvent::REVEAL_CARDS: { + const Event_RevealCards &revealCards = event.GetExtension(Event_RevealCards::ext); + const QString zone = QString::fromStdString(revealCards.zone_name()); + QStringList names; + for (int j = 0; j < revealCards.cards_size(); ++j) { + const ServerInfo_Card &card = revealCards.cards(j); + const QString name = QString::fromStdString(card.name()); + names.append(name); + rememberCard(eventPlayerId, zone, card.id(), name); + } + const int total = + revealCards.number_of_cards() > 0 ? revealCards.number_of_cards() : revealCards.cards_size(); + QString text = + tr("revealed %1 from %2.") + .arg(formatCardList(names, total), zonePhrase(eventPlayerId, zone, eventPlayerId)); + if (revealCards.other_player_id() >= 0 && revealCards.other_player_id() != eventPlayerId) { + text = tr("revealed %1 from %2 to %3.") + .arg(formatCardList(names, total), zonePhrase(eventPlayerId, zone, eventPlayerId), + playerName(revealCards.other_player_id())); + } + addDetail(details, text, tr("REVEAL"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::SHUFFLE: { + const Event_Shuffle &shuffle = event.GetExtension(Event_Shuffle::ext); + addDetail( + details, + tr("shuffled %1.") + .arg(zonePhrase(eventPlayerId, QString::fromStdString(shuffle.zone_name()), eventPlayerId)), + tr("SHUFFLE"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::ROLL_DIE: { + const Event_RollDie &rollDie = event.GetExtension(Event_RollDie::ext); + QStringList rolls; + for (int j = 0; j < rollDie.values_size(); ++j) { + rolls.append(QString::number(rollDie.values(j))); + } + if (rolls.isEmpty() && rollDie.value() > 0) { + rolls.append(QString::number(rollDie.value())); + } + const QString sides = rollDie.sides() > 0 ? QString::number(rollDie.sides()) : QStringLiteral("?"); + addDetail(details, tr("rolled a d%1: %2.").arg(sides, rolls.join(QStringLiteral(", "))), tr("DICE"), + ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::FLIP_CARD: { + const Event_FlipCard &flipCard = event.GetExtension(Event_FlipCard::ext); + const QString zone = QString::fromStdString(flipCard.zone_name()); + if (flipCard.has_card_name()) { + rememberCard(eventPlayerId, zone, flipCard.card_id(), + QString::fromStdString(flipCard.card_name())); + } + QString name = flipCard.has_card_name() ? QString::fromStdString(flipCard.card_name()) + : cardName(eventPlayerId, zone, flipCard.card_id()); + if (name.isEmpty()) { + name = tr("a card"); + } + addDetail(details, + flipCard.face_down() ? tr("flipped %1 face down.").arg(name) + : tr("flipped %1 face up.").arg(name), + tr("FLIP"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::DESTROY_CARD: { + const Event_DestroyCard &destroyCard = event.GetExtension(Event_DestroyCard::ext); + const QString zone = QString::fromStdString(destroyCard.zone_name()); + const QString name = cardName(eventPlayerId, zone, destroyCard.card_id()); + forgetCard(eventPlayerId, zone, destroyCard.card_id()); + addDetail(details, tr("destroyed %1.").arg(name.isEmpty() ? tr("a card") : name), tr("DESTROY"), + ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::ATTACH_CARD: { + const Event_AttachCard &attachCard = event.GetExtension(Event_AttachCard::ext); + const QString startZone = QString::fromStdString(attachCard.start_zone()); + const QString source = cardName(eventPlayerId, startZone, attachCard.card_id()); + const QString sourceText = source.isEmpty() ? tr("a card") : source; + // An unattach event carries no target at all, so the target fields default to player 0 / no zone + // / card 0; only a present target_player_id means the card was attached to something. + if (!attachCard.has_target_player_id()) { + addDetail(details, tr("unattached %1.").arg(sourceText), tr("ATTACH"), ReplayEventKind::Action, + eventPlayerId, forcedByJudge); + break; + } + const QString targetZone = QString::fromStdString(attachCard.target_zone()); + const int targetPlayerId = + attachCard.target_player_id() >= 0 ? attachCard.target_player_id() : eventPlayerId; + const QString target = cardName(targetPlayerId, targetZone, attachCard.target_card_id()); + addDetail(details, + tr("attached %1 to %2.").arg(sourceText, target.isEmpty() ? tr("a card") : target), + tr("ATTACH"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::CREATE_TOKEN: { + const Event_CreateToken &createToken = event.GetExtension(Event_CreateToken::ext); + const QString name = QString::fromStdString(createToken.card_name()); + rememberCard(eventPlayerId, QString::fromStdString(createToken.zone_name()), createToken.card_id(), + name); + const QString pt = QString::fromStdString(createToken.pt()); + addDetail( + details, + tr("created a token: %1%2.") + .arg(name.isEmpty() ? tr("token") : name, pt.isEmpty() ? QString() : tr(" (%1)").arg(pt)), + tr("TOKEN"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::SET_CARD_ATTR: { + const Event_SetCardAttr &setCardAttr = event.GetExtension(Event_SetCardAttr::ext); + addDetail(details, describeSetCardAttribute(setCardAttr, eventPlayerId), + badgeForCardAttribute(setCardAttr.attribute()), ReplayEventKind::Action, eventPlayerId, + forcedByJudge); + break; + } + case GameEvent::SET_CARD_COUNTER: { + const Event_SetCardCounter &setCardCounter = event.GetExtension(Event_SetCardCounter::ext); + const QString name = cardName(eventPlayerId, QString::fromStdString(setCardCounter.zone_name()), + setCardCounter.card_id()); + addDetail( + details, + tr("set a counter on %1 to %2.") + .arg(name.isEmpty() ? tr("a card") : name, QString::number(setCardCounter.counter_value())), + tr("COUNTER"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::CREATE_COUNTER: { + const Event_CreateCounter &createCounter = event.GetExtension(Event_CreateCounter::ext); + const ServerInfo_Counter &counter = createCounter.counter_info(); + rememberCounter(eventPlayerId, counter); + const QString name = QString::fromStdString(counter.name()); + addDetail(details, + tr("created the %1 counter (%2).") + .arg(name.isEmpty() ? tr("new") : name, QString::number(counter.count())), + tr("COUNTER"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::SET_COUNTER: { + const Event_SetCounter &setCounter = event.GetExtension(Event_SetCounter::ext); + const QString name = counterName(eventPlayerId, setCounter.counter_id()); + addDetail(details, + tr("set %1 to %2.") + .arg(name.isEmpty() ? tr("a counter") : tr("the %1 counter").arg(name), + QString::number(setCounter.value())), + tr("COUNTER"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::DEL_COUNTER: { + const Event_DelCounter &delCounter = event.GetExtension(Event_DelCounter::ext); + const QString name = counterName(eventPlayerId, delCounter.counter_id()); + counterNames.remove(counterKey(eventPlayerId, delCounter.counter_id())); + addDetail(details, + tr("removed %1.").arg(name.isEmpty() ? tr("a counter") : tr("the %1 counter").arg(name)), + tr("COUNTER"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::DUMP_ZONE: { + const Event_DumpZone &dumpZone = event.GetExtension(Event_DumpZone::ext); + const QString zoneOwnerPhrase = zonePhrase( + dumpZone.zone_owner_id(), QString::fromStdString(dumpZone.zone_name()), eventPlayerId); + if (dumpZone.number_cards() < 0) { + addDetail(details, tr("looked at %1.").arg(zoneOwnerPhrase), tr("LOOK"), + ReplayEventKind::Action, eventPlayerId, forcedByJudge); + } else { + addDetail(details, + tr("looked at %1 from %2.") + .arg(formatCardList(QStringList(), dumpZone.number_cards()), zoneOwnerPhrase), + tr("LOOK"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + } + break; + } + case GameEvent::CHANGE_ZONE_PROPERTIES: { + const Event_ChangeZoneProperties &changeZone = event.GetExtension(Event_ChangeZoneProperties::ext); + const QString zone = QString::fromStdString(changeZone.zone_name()); + QStringList changes; + if (changeZone.always_reveal_top_card()) { + changes.append(tr("top card revealed to all")); + } + if (changeZone.always_look_at_top_card()) { + changes.append(tr("top card visible to its owner")); + } + addDetail( + details, + changes.isEmpty() + ? tr("changed the properties of %1.").arg(zonePhrase(eventPlayerId, zone, eventPlayerId)) + : tr("changed %1: %2.") + .arg(zonePhrase(eventPlayerId, zone, eventPlayerId), + changes.join(QStringLiteral(", "))), + tr("ZONE"), ReplayEventKind::Action, eventPlayerId, forcedByJudge); + break; + } + case GameEvent::GAME_LOG_NOTICE: { + const Event_GameLogNotice ¬ice = event.GetExtension(Event_GameLogNotice::ext); + switch (notice.notice_type()) { + case Event_GameLogNotice::UNDO_DRAW_FAILED: + addDetail(details, tr("failed to undo a draw."), tr("NOTICE"), ReplayEventKind::System, + eventPlayerId, forcedByJudge); + break; + default: + addDetail(details, tr("A game notice was logged."), tr("NOTICE"), ReplayEventKind::System, + -1, forcedByJudge); + break; + } + break; + } + case GameEvent::CREATE_ARROW: + case GameEvent::DELETE_ARROW: + // Arrows are transient visual annotations and would make the history very noisy. + break; + default: + addDetail(details, tr("Unrecognized game event."), tr("EVENT"), ReplayEventKind::Action, + eventPlayerId, forcedByJudge); + break; + } + } + + return details; + } +}; +} // namespace + +QList buildReplayEventEntries(const GameReplay *replay, const QList &timeline) +{ + if (!replay) { + return {}; + } + + return ReplayEventIndexBuilder(replay, timeline).build(); +} diff --git a/cockatrice/src/interface/widgets/replay/replay_event_index.h b/cockatrice/src/interface/widgets/replay/replay_event_index.h new file mode 100644 index 000000000..40f72a965 --- /dev/null +++ b/cockatrice/src/interface/widgets/replay/replay_event_index.h @@ -0,0 +1,43 @@ +#ifndef COCKATRICE_REPLAY_EVENT_INDEX_H +#define COCKATRICE_REPLAY_EVENT_INDEX_H + +#include +#include + +class GameReplay; + +enum class ReplayEventKind +{ + Action, + Turn, + Phase, + Chat, + System +}; + +/** + * @brief One user-visible entry in the replay history sidebar. + * + * The event index refers to the corresponding GameEventContainer in the replay. It is used to seek the replay to that + * container. + */ +struct ReplayEventEntry +{ + int eventIndex = -1; + int timeMs = 0; + QString timeLabel; + QString actor; + QString badge; + QString detail; + ReplayEventKind kind = ReplayEventKind::Action; +}; + +/** + * @brief Builds a compact, human-readable index of the events in a replay. + * + * The returned entries copy all the data they need, so the caller does not need to keep the replay object alive after + * this function returns. + */ +QList buildReplayEventEntries(const GameReplay *replay, const QList &timeline); + +#endif // COCKATRICE_REPLAY_EVENT_INDEX_H diff --git a/cockatrice/src/interface/widgets/replay/replay_event_sidebar.cpp b/cockatrice/src/interface/widgets/replay/replay_event_sidebar.cpp new file mode 100644 index 000000000..b5fab2794 --- /dev/null +++ b/cockatrice/src/interface/widgets/replay/replay_event_sidebar.cpp @@ -0,0 +1,284 @@ +#include "replay_event_sidebar.h" + +#include "replay_event_index.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +enum ItemDataRole +{ + EventIndexRole = Qt::UserRole + 1, + TimeLabelRole, + KindRole, + ActorRole, + BadgeRole, + DetailRole +}; + +QColor accentForKind(ReplayEventKind kind, const QPalette &palette) +{ + switch (kind) { + case ReplayEventKind::Turn: + return QColor(0x4c, 0xaf, 0x50); + case ReplayEventKind::Phase: + return QColor(0x42, 0xa5, 0xf5); + case ReplayEventKind::Chat: + return QColor(0xff, 0xb3, 0x00); + case ReplayEventKind::System: + return palette.mid().color(); + case ReplayEventKind::Action: + default: + return palette.highlight().color(); + } +} + +class ReplayEventDelegate : public QStyledItemDelegate +{ +public: + explicit ReplayEventDelegate(QObject *parent) : QStyledItemDelegate(parent) + { + } + + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override + { + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + + const QPalette &palette = option.palette; + const bool selected = option.state.testFlag(QStyle::State_Selected); + const bool hovered = option.state.testFlag(QStyle::State_MouseOver); + const auto kind = static_cast(index.data(KindRole).toInt()); + const QColor accent = accentForKind(kind, palette); + + const QRect card = option.rect.adjusted(4, 2, -4, -2); + QColor cardBackground = selected ? palette.highlight().color() : palette.base().color(); + if (!selected && hovered) { + cardBackground = palette.alternateBase().color(); + } + + painter->setPen(Qt::NoPen); + painter->setBrush(cardBackground); + painter->drawRoundedRect(card, 6, 6); + + QColor borderColor = selected ? palette.highlightedText().color() : palette.mid().color(); + borderColor.setAlpha(selected ? 100 : 130); + painter->setBrush(Qt::NoBrush); + painter->setPen(QPen(borderColor, 1)); + painter->drawRoundedRect(card.adjusted(0, 0, -1, -1), 6, 6); + + QColor accentColor = accent; + if (selected) { + accentColor = palette.highlightedText().color(); + accentColor.setAlpha(210); + } + painter->setPen(Qt::NoPen); + painter->setBrush(accentColor); + painter->drawRoundedRect(QRect(card.left() + 7, card.top() + 7, 3, card.height() - 14), 1.5, 1.5); + + const QColor textColor = selected ? palette.highlightedText().color() : palette.text().color(); + QColor mutedColor = textColor; + mutedColor.setAlpha(170); + + QFont timeFont = option.font; + timeFont.setBold(true); + timeFont.setPointSizeF(qMax(7.0, timeFont.pointSizeF() * 0.9)); + + QFont actorFont = option.font; + actorFont.setBold(true); + + QFont badgeFont = option.font; + badgeFont.setBold(true); + badgeFont.setPointSizeF(qMax(6.5, badgeFont.pointSizeF() * 0.72)); + + QFont detailFont = option.font; + detailFont.setPointSizeF(qMax(7.0, detailFont.pointSizeF() * 0.95)); + + const QFontMetrics timeMetrics(timeFont); + const QFontMetrics actorMetrics(actorFont); + const QFontMetrics badgeMetrics(badgeFont); + const QFontMetrics detailMetrics(detailFont); + + const int contentLeft = card.left() + 15; + const int contentRight = card.right() - 10; + const int topRowTop = card.top() + 6; + const int topRowHeight = qMax(timeMetrics.height(), badgeMetrics.height()) + 2; + + const QString timeText = index.data(TimeLabelRole).toString(); + const QString actorText = index.data(ActorRole).toString(); + const QString badgeText = index.data(BadgeRole).toString(); + const QString detailText = index.data(DetailRole).toString(); + + const int timeWidth = timeMetrics.horizontalAdvance(timeText); + painter->setFont(timeFont); + painter->setPen(mutedColor); + painter->drawText(QRect(contentLeft, topRowTop, timeWidth + 2, topRowHeight), Qt::AlignLeft | Qt::AlignVCenter, + timeText); + + int actorRight = contentRight; + if (!badgeText.isEmpty()) { + const int badgeWidth = badgeMetrics.horizontalAdvance(badgeText) + 10; + const int badgeHeight = badgeMetrics.height() + 2; + const QRect badgeRect(contentRight - badgeWidth, topRowTop + (topRowHeight - badgeHeight) / 2, badgeWidth, + badgeHeight); + + QColor badgeBackground = accentColor; + badgeBackground.setAlpha(selected ? 45 : 32); + painter->setPen(Qt::NoPen); + painter->setBrush(badgeBackground); + painter->drawRoundedRect(badgeRect, 3, 3); + + painter->setFont(badgeFont); + painter->setPen(accentColor); + painter->drawText(badgeRect, Qt::AlignCenter, badgeText); + + actorRight = badgeRect.left() - 8; + } + + const int actorLeft = contentLeft + timeWidth + 10; + if (actorRight > actorLeft) { + const QString elidedActor = + actorMetrics.elidedText(actorText, Qt::ElideRight, qMax(0, actorRight - actorLeft)); + painter->setFont(actorFont); + painter->setPen(textColor); + painter->drawText(QRect(actorLeft, topRowTop, actorRight - actorLeft, topRowHeight), + Qt::AlignLeft | Qt::AlignVCenter, elidedActor); + } + + const int detailTop = topRowTop + topRowHeight + 2; + const int detailHeight = qMax(detailMetrics.height(), card.bottom() - detailTop); + const QString elidedDetail = + detailMetrics.elidedText(detailText, Qt::ElideRight, qMax(0, contentRight - contentLeft)); + painter->setFont(detailFont); + painter->setPen(textColor); + painter->drawText(QRect(contentLeft, detailTop, contentRight - contentLeft, detailHeight), + Qt::AlignLeft | Qt::AlignVCenter, elidedDetail); + + painter->restore(); + } + + QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex & /*index*/) const override + { + const QFontMetrics metrics(option.font); + return {0, qMax(58, metrics.height() * 3 + 10)}; + } +}; +} // namespace + +ReplayEventSidebar::ReplayEventSidebar(QWidget *parent, const GameReplay *replay, const QList &timeline) + : QWidget(parent), eventList(new QListWidget(this)), emptyLabel(new QLabel(this)) +{ + setObjectName("replayEventSidebar"); + + eventList->setObjectName("replayEventList"); + eventList->setItemDelegate(new ReplayEventDelegate(eventList)); + eventList->setFrameShape(QFrame::NoFrame); + eventList->setFocusPolicy(Qt::NoFocus); + eventList->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + eventList->setSelectionMode(QAbstractItemView::SingleSelection); + eventList->setMouseTracking(true); + eventList->setUniformItemSizes(true); + eventList->setVerticalScrollMode(QAbstractItemView::ScrollPerPixel); + + emptyLabel->setAlignment(Qt::AlignCenter); + emptyLabel->setWordWrap(true); + emptyLabel->setMargin(16); + emptyLabel->setForegroundRole(QPalette::PlaceholderText); + + auto *layout = QtUtils::makeLayout(this); + layout->addWidget(eventList); + layout->addWidget(emptyLabel); + + const QList entries = buildReplayEventEntries(replay, timeline); + eventIndices.reserve(entries.size()); + + for (const ReplayEventEntry &entry : entries) { + auto *item = new QListWidgetItem(eventList); + item->setData(EventIndexRole, entry.eventIndex); + item->setData(TimeLabelRole, entry.timeLabel); + item->setData(KindRole, static_cast(entry.kind)); + item->setData(ActorRole, entry.actor); + item->setData(BadgeRole, entry.badge); + item->setData(DetailRole, entry.detail); + const QString tooltip = entry.actor.isEmpty() + ? tr("%1\n%2").arg(entry.timeLabel, entry.detail) + : tr("%1 · %2\n%3").arg(entry.timeLabel, entry.actor, entry.detail); + item->setToolTip(tooltip); + item->setData(Qt::AccessibleTextRole, entry.actor.isEmpty() + ? tr("%1: %2").arg(entry.timeLabel, entry.detail) + : tr("%1, %2: %3").arg(entry.timeLabel, entry.actor, entry.detail)); + eventIndices.append(entry.eventIndex); + } + + const bool hasEvents = !entries.isEmpty(); + eventList->setVisible(hasEvents); + emptyLabel->setVisible(!hasEvents); + emptyLabel->setText(tr("No game events in this replay.")); + + connect(eventList, &QListWidget::itemClicked, this, [this](QListWidgetItem *item) { + if (!item) { + return; + } + + setCurrentRow(eventList->row(item)); + emit eventSelected(item->data(EventIndexRole).toInt()); + }); + + if (hasEvents) { + setCurrentRow(-1); + } +} + +void ReplayEventSidebar::setCurrentEvent(int eventIndex) +{ + if (eventIndices.isEmpty()) { + setCurrentRow(-1); + return; + } + + // The rows hold a subset of the replay's events, in playback order, so the last row at or before eventIndex is the + // one the game state has reached. An eventIndex of -1 (nothing replayed yet) selects no row. + const auto it = std::upper_bound(eventIndices.cbegin(), eventIndices.cend(), eventIndex); + const int row = static_cast(it - eventIndices.cbegin()) - 1; + setCurrentRow(row); +} + +void ReplayEventSidebar::setCurrentRow(int row) +{ + if (row < 0 || row >= eventList->count()) { + row = -1; + } + + if (row >= 0) { + // The view's current index also moves on mouse press and can be deselected by a ctrl-click, + // so the cached row alone cannot tell whether the view is already in the desired state. + const QListWidgetItem *item = eventList->item(row); + if (row == eventList->currentRow() && item && item->isSelected()) { + return; + } + } else if (row == currentRow) { + return; + } + + currentRow = row; + eventList->setCurrentRow(currentRow); + + if (currentRow >= 0) { + QListWidgetItem *item = eventList->item(currentRow); + if (!eventList->viewport()->rect().contains(eventList->visualItemRect(item))) { + eventList->scrollToItem(item, QAbstractItemView::PositionAtCenter); + } + } +} diff --git a/cockatrice/src/interface/widgets/replay/replay_event_sidebar.h b/cockatrice/src/interface/widgets/replay/replay_event_sidebar.h new file mode 100644 index 000000000..ee163356e --- /dev/null +++ b/cockatrice/src/interface/widgets/replay/replay_event_sidebar.h @@ -0,0 +1,50 @@ +#ifndef COCKATRICE_REPLAY_EVENT_SIDEBAR_H +#define COCKATRICE_REPLAY_EVENT_SIDEBAR_H + +#include +#include +#include + +class GameReplay; +class QLabel; +class QListWidget; + +/** + * @brief A scrollable list of replay events that can be used to seek the replay. + * + * The sidebar copies the event data it needs at construction time. It does not keep a reference to the replay or to + * the replay manager. + */ +class ReplayEventSidebar : public QWidget +{ + Q_OBJECT + +public: + /** + * @param parent The parent widget + * @param replay The replay to build the event list from + * @param timeline Event index to time in milliseconds, as used by ReplayManager + */ + ReplayEventSidebar(QWidget *parent, const GameReplay *replay, const QList &timeline); + +public slots: + /** + * @brief Highlights the row of the event the game state has reached. + * + * @param eventIndex Index into the replay's event list, or -1 when nothing has been replayed yet + */ + void setCurrentEvent(int eventIndex); + +signals: + void eventSelected(int eventIndex); + +private: + void setCurrentRow(int row); + + QListWidget *eventList; + QLabel *emptyLabel; + QList eventIndices; ///< Replay event index of each row, ascending + int currentRow = -1; +}; + +#endif // COCKATRICE_REPLAY_EVENT_SIDEBAR_H diff --git a/cockatrice/src/interface/widgets/replay/replay_manager.cpp b/cockatrice/src/interface/widgets/replay/replay_manager.cpp index 8ba0cb653..b01d7f9d6 100644 --- a/cockatrice/src/interface/widgets/replay/replay_manager.cpp +++ b/cockatrice/src/interface/widgets/replay/replay_manager.cpp @@ -57,7 +57,7 @@ ReplayManager::ReplayManager(QObject *parent, GameReplay *replay) rewindBufferingTimer = new QTimer(this); rewindBufferingTimer->setSingleShot(true); - connect(rewindBufferingTimer, &QTimer::timeout, this, &ReplayManager::processRewind); + connect(rewindBufferingTimer, &QTimer::timeout, this, [this] { processRewind(); }); } ReplayManager::~ReplayManager() @@ -65,7 +65,7 @@ ReplayManager::~ReplayManager() delete replay; } -void ReplayManager::skipToTime(int newTime, bool doRewindBuffering) +void ReplayManager::skipToTime(int newTime, bool doRewindBuffering, bool includeEventsAtTime) { // check boundary conditions if (newTime < 0) { @@ -75,15 +75,17 @@ void ReplayManager::skipToTime(int newTime, bool doRewindBuffering) newTime = maxTime; } - newTime -= newTime % TIMER_INTERVAL_MS; // Time should always be a multiple of the interval + if (!includeEventsAtTime) { + newTime -= newTime % TIMER_INTERVAL_MS; // Time should always be a multiple of the interval + } const bool isBackwardsSkip = newTime < currentProcessedTime; currentVisualTime = newTime; if (isBackwardsSkip) { - handleBackwardsSkip(doRewindBuffering); + handleBackwardsSkip(doRewindBuffering, includeEventsAtTime); } else { - processNewEvents(FORWARD_SKIP); + processNewEvents(FORWARD_SKIP, includeEventsAtTime); } timeChanged(currentVisualTime); @@ -95,7 +97,7 @@ void ReplayManager::skipToTime(int newTime, bool doRewindBuffering) * @param doRewindBuffering When true, if multiple backward skips are made in quick succession, only a single rewind * is processed at the end. When false, the backwards skip will always cause an immediate rewind. */ -void ReplayManager::handleBackwardsSkip(bool doRewindBuffering) +void ReplayManager::handleBackwardsSkip(bool doRewindBuffering, bool includeEventsAtTime) { if (doRewindBuffering) { // We use a one-shot timer to implement the rewind buffering. @@ -105,11 +107,11 @@ void ReplayManager::handleBackwardsSkip(bool doRewindBuffering) rewindBufferingTimer->start(SettingsCache::instance().userInterface().getRewindBufferingMs()); } else { // otherwise, process the rewind immediately - processRewind(); + processRewind(includeEventsAtTime); } } -void ReplayManager::processRewind() +void ReplayManager::processRewind(bool includeEventsAtTime) { // stop any queued-up rewinds rewindBufferingTimer->stop(); @@ -117,7 +119,7 @@ void ReplayManager::processRewind() // process the rewind currentEvent = 0; emit rewound(); - processNewEvents(BACKWARD_SKIP); + processNewEvents(BACKWARD_SKIP, includeEventsAtTime); } void ReplayManager::replayTimerTimeout() @@ -134,11 +136,13 @@ void ReplayManager::replayTimerTimeout() } /** @brief Processes all unprocessed events up to the current time. */ -void ReplayManager::processNewEvents(PlaybackMode playbackMode) +void ReplayManager::processNewEvents(PlaybackMode playbackMode, bool includeEventsAtTime) { currentProcessedTime = currentVisualTime; - while (currentEvent < replayTimeline.size() && replayTimeline[currentEvent] < currentProcessedTime) { + while (currentEvent < replayTimeline.size() && + (replayTimeline[currentEvent] < currentProcessedTime || + (includeEventsAtTime && replayTimeline[currentEvent] == currentProcessedTime))) { EventProcessingOptions options; // backwards skip => always skip reveal windows @@ -236,6 +240,24 @@ void ReplayManager::setTime(int time) skipToTime(time, false); } +void ReplayManager::setTimeToEvent(int eventIndex) +{ + if (eventIndex < 0 || eventIndex >= replayTimeline.size()) { + return; + } + + // Flush a pending buffered rewind before seeking, otherwise its timer fires after this seek and rebuilds the + // replay state from an outdated currentVisualTime, dropping the event we seek to. Stopping the timer instead + // of processing it is not enough: playback ticks advance currentProcessedTime without rewinding currentEvent, + // so the board could still be ahead of the target. + if (rewindBufferingTimer->isActive()) { + processRewind(); + } + + // Unlike a timeline seek, jumping to an event should apply that event immediately. + skipToTime(replayTimeline.at(eventIndex), false, true); +} + void ReplayManager::skipByAmount(int amount) { skipToTime(currentVisualTime + amount, amount < 0); diff --git a/cockatrice/src/interface/widgets/replay/replay_manager.h b/cockatrice/src/interface/widgets/replay/replay_manager.h index 3849b86ae..e3745778e 100644 --- a/cockatrice/src/interface/widgets/replay/replay_manager.h +++ b/cockatrice/src/interface/widgets/replay/replay_manager.h @@ -40,10 +40,10 @@ class ReplayManager : public QObject ///< to rewind buffering int currentEvent = 0; ///< current event's index - void skipToTime(int newTime, bool doRewindBuffering); - void handleBackwardsSkip(bool doRewindBuffering); - void processRewind(); - void processNewEvents(PlaybackMode playbackMode); + void skipToTime(int newTime, bool doRewindBuffering, bool includeEventsAtTime = false); + void handleBackwardsSkip(bool doRewindBuffering, bool includeEventsAtTime = false); + void processRewind(bool includeEventsAtTime = false); + void processNewEvents(PlaybackMode playbackMode, bool includeEventsAtTime = false); void handleSkipEmptySection(); private slots: @@ -66,6 +66,16 @@ public: return replayTimeline; } + /** + * @brief Index of the last event the game state has processed. + * + * @return The event index, or -1 while no event has been replayed yet + */ + [[nodiscard]] int getLastProcessedEventIndex() const + { + return currentEvent - 1; + } + void setTimeScaleFactor(qreal _timeScaleFactor); void setSkipEmptySections(bool value); @@ -73,6 +83,7 @@ public slots: void startReplay(); void stopReplay(); void setTime(int time); + void setTimeToEvent(int eventIndex); void skipByAmount(int amount); // use a negative amount to skip backwards signals: diff --git a/cockatrice/src/interface/widgets/replay/replay_widget.cpp b/cockatrice/src/interface/widgets/replay/replay_widget.cpp index 07b4e72ce..e911bc0f9 100644 --- a/cockatrice/src/interface/widgets/replay/replay_widget.cpp +++ b/cockatrice/src/interface/widgets/replay/replay_widget.cpp @@ -19,6 +19,11 @@ class GameReplay; +int ReplayWidget::getLastProcessedEventIndex() const +{ + return replayManager->getLastProcessedEventIndex(); +} + ReplayWidget::ReplayWidget(QWidget *parent, GameReplay *replay) : QWidget(parent), replayPlayButton(nullptr), replayFastForwardButton(nullptr), aReplaySkipForward(nullptr), aReplaySkipBackward(nullptr), aReplaySkipForwardBig(nullptr), aReplaySkipBackwardBig(nullptr) @@ -28,6 +33,7 @@ ReplayWidget::ReplayWidget(QWidget *parent, GameReplay *replay) connect(replayManager, &ReplayManager::eventReplayed, this, &ReplayWidget::eventReplayed); connect(replayManager, &ReplayManager::replayFinished, this, &ReplayWidget::replayFinished); connect(replayManager, &ReplayManager::rewound, this, &ReplayWidget::rewound); + connect(replayManager, &ReplayManager::timeChanged, this, &ReplayWidget::timeChanged); // timeline widget timelineWidget = new ReplayTimelineWidget; @@ -96,6 +102,16 @@ ReplayWidget::ReplayWidget(QWidget *parent, GameReplay *replay) refreshShortcuts(); } +const QList &ReplayWidget::getReplayTimeline() const +{ + return replayManager->getReplayTimeline(); +} + +void ReplayWidget::seekToEvent(int eventIndex) +{ + replayManager->setTimeToEvent(eventIndex); +} + void ReplayWidget::replayFinished() { replayPlayButton->setChecked(false); diff --git a/cockatrice/src/interface/widgets/replay/replay_widget.h b/cockatrice/src/interface/widgets/replay/replay_widget.h index 0e78442ce..cbb676288 100644 --- a/cockatrice/src/interface/widgets/replay/replay_widget.h +++ b/cockatrice/src/interface/widgets/replay/replay_widget.h @@ -8,6 +8,7 @@ #ifndef REPLAY_WIDGET_H #define REPLAY_WIDGET_H +#include #include #include #include @@ -35,8 +36,19 @@ public: */ ReplayWidget(QWidget *parent, GameReplay *replay); + [[nodiscard]] const QList &getReplayTimeline() const; + + /** + * @brief Index of the last replay event the game state has processed, or -1 before the first event. + */ + [[nodiscard]] int getLastProcessedEventIndex() const; + +public slots: + void seekToEvent(int eventIndex); + signals: void rewound(); + void timeChanged(int time); void eventReplayed(const GameEventContainer &cont, EventProcessingOptions options); private: diff --git a/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp b/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp index 037a4a1e5..c57f65352 100644 --- a/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp +++ b/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp @@ -4,6 +4,7 @@ #include "../../client/sound_engine.h" #include "../../interface/card_localization.h" #include "../../interface/pixel_map_generator.h" +#include "../../interface/theme_manager.h" #include "../../tabs/tab_supervisor.h" #include "../user/user_context_menu.h" #include "../user/user_list_manager.h" @@ -63,6 +64,9 @@ ChatView::ChatView(TabSupervisor *_tabSupervisor, AbstractGame *_game, bool _sho connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, &ChatView::adjustColorsToPalette); + applyChatFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, &ChatView::applyChatFont); + userContextMenu = new UserContextMenu(tabSupervisor, this, game); connect(userContextMenu, SIGNAL(openMessageDialog(QString, bool)), this, SIGNAL(openMessageDialog(QString, bool))); @@ -108,6 +112,16 @@ void ChatView::adjustColorsToPalette() QTimer::singleShot(0, this, &ChatView::refreshBlockColors); } +void ChatView::applyChatFont() +{ + // Family comes from the ChatLog theme role; the message char formats carry + // no family of their own, so they (and new content) follow the widget and + // document default fonts. Weights/italics stay caller-side as before. + const QFont chatFont = themeManager->appFont(AppFont::ChatLog); + setFont(chatFont); + document()->setDefaultFont(chatFont); +} + void ChatView::refreshBlockColors() { QTextDocument *doc = document(); diff --git a/cockatrice/src/interface/widgets/server/chat_view/chat_view.h b/cockatrice/src/interface/widgets/server/chat_view/chat_view.h index 3fa7224e5..a2e2f33dd 100644 --- a/cockatrice/src/interface/widgets/server/chat_view/chat_view.h +++ b/cockatrice/src/interface/widgets/server/chat_view/chat_view.h @@ -111,6 +111,7 @@ private slots: void openLink(const QUrl &link); void actMessageClicked(); void adjustColorsToPalette(); + void applyChatFont(); void refreshBlockColors(); void onScrollBarRangeChanged(); void onScrollBarValueChanged(int value); diff --git a/cockatrice/src/interface/widgets/server/game_selector.cpp b/cockatrice/src/interface/widgets/server/game_selector.cpp index 2702a527d..775e58297 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector.cpp @@ -1,9 +1,11 @@ #include "game_selector.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../../client/settings/shortcuts_settings.h" +#include "../../theme_icon.h" #include "../interface/widgets/dialogs/dlg_create_game.h" #include "../interface/widgets/dialogs/dlg_filter_games.h" +#include "../interface/widgets/game_hosting/game_creation_options.h" #include "../interface/widgets/tabs/tab_room.h" #include "../interface/widgets/tabs/tab_supervisor.h" #include "../interface/widgets/utility/get_text_with_max.h" @@ -15,13 +17,18 @@ #include "libcockatrice/protocol/pb/serverinfo_user.pb.h" #include "user/user_list_manager.h" +#include +#include #include +#include #include #include +#include #include #include #include #include +#include #include #include #include @@ -31,15 +38,19 @@ #include #include #include +#include +#include #include #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -51,6 +62,33 @@ class QPoint; class QWidget; template class QList; +namespace +{ + +/** + * @brief Returns whether the application paints with the native macOS style. + * + * QMacStyle ignores autoRaise and flat, and draws a QToolButton menu as a second square + * bezel, so the split host control cannot be composed there. A global style sheet wraps the + * real application style in a QStyleSheetStyle that takes it as a child, so that layer is + * unwrapped before the style is identified. + */ +bool usesMacStyle() +{ + QStyle *style = QApplication::style(); + if (style == nullptr) { + return false; + } + if (style->inherits("QStyleSheetStyle")) { + if (QStyle *baseStyle = style->findChild(QString(), Qt::FindDirectChildrenOnly)) { + style = baseStyle; + } + } + return style->inherits("QMacStyle"); +} + +} // namespace + GameSelector::GameSelector(AbstractClient *_client, TabSupervisor *_tabSupervisor, TabRoom *_room, @@ -130,20 +168,62 @@ GameSelector::GameSelector(AbstractClient *_client, } filterButton = new QPushButton; - filterButton->setIcon(themePixmap(QStringLiteral("icons/search"))); + filterButton->setIcon(loadThemedIcon(QStringLiteral("icons/search"))); connect(filterButton, &QPushButton::clicked, this, &GameSelector::actSetFilter); clearFilterButton = new QPushButton; - clearFilterButton->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); + clearFilterButton->setIcon(loadThemedIcon(QStringLiteral("icons/clearsearch"))); bool filtersSetToDefault = showFilters && gameListProxyModel->areFilterParametersSetToDefaults(); clearFilterButton->setEnabled(!filtersSetToDefault); connect(clearFilterButton, &QPushButton::clicked, this, &GameSelector::actClearFilter); connect(gameListProxyModel, &GamesProxyModel::filtersChanged, this, &GameSelector::checkClearFilterButtonState); if (room) { - createButton = new QPushButton; - connect(createButton, &QPushButton::clicked, this, &GameSelector::actCreate); - } else { - createButton = nullptr; + hostGameAction = new QAction(this); + hostGameAction->setShortcutContext(Qt::WidgetWithChildrenShortcut); + connect(hostGameAction, &QAction::triggered, this, &GameSelector::actQuickHost); + + hostGameMenu = new QMenu(this); + hostGameMenu->setObjectName("hostGameMenu"); + gameSettingsAction = hostGameMenu->addAction(QString()); + gameSettingsAction->setObjectName("gameSettingsAction"); + gameSettingsAction->setShortcutContext(Qt::WidgetWithChildrenShortcut); + connect(gameSettingsAction, &QAction::triggered, this, &GameSelector::actCreate); + + // A split QToolButton in its native style: the primary half quick-hosts, and the + // arrow opens the menu with the full form. This is the control on every style that + // can draw one; QMacStyle draws the menu half as a second square bezel instead. + hostGameSplitButton = new QToolButton(this); + hostGameSplitButton->setObjectName("hostGameSplitButton"); + hostGameSplitButton->setDefaultAction(hostGameAction); + hostGameSplitButton->setPopupMode(QToolButton::MenuButtonPopup); + hostGameSplitButton->setToolButtonStyle(Qt::ToolButtonTextOnly); + hostGameSplitButton->setMenu(hostGameMenu); + + // The native macOS style has no split rendering, so there the primary stays a plain + // QPushButton next to a plain settings button. A QPushButton does not take its text, + // tooltip or enabled state from an action, so those are mirrored by hand below and + // in updateHostControlsEnabled(). + hostGameButton = new QPushButton(this); + hostGameButton->setObjectName("hostGameButton"); + connect(hostGameButton, &QPushButton::clicked, hostGameAction, &QAction::trigger); + + hostGameSettingsButton = new QPushButton(this); + hostGameSettingsButton->setObjectName("hostGameSettingsButton"); + connect(hostGameSettingsButton, &QPushButton::clicked, gameSettingsAction, &QAction::trigger); + + applyHostGameStyle(); + + // The shortcuts belong to the whole room tab, not just the game list, so they also + // fire while the chat or the user list has focus. A QToolButton menu is mouse-only, + // so the settings action needs its own shortcut for keyboard parity. + if (auto *roomTab = qobject_cast(parentWidget())) { + roomTab->addAction(hostGameAction); + roomTab->addAction(gameSettingsAction); + } else { + addAction(hostGameAction); + addAction(gameSettingsAction); + } + refreshShortcuts(); } joinButton = new QPushButton; joinAsJudgeButton = new QPushButton; @@ -157,7 +237,10 @@ GameSelector::GameSelector(AbstractClient *_client, } buttonLayout->addStretch(); if (room) { - buttonLayout->addWidget(createButton); + // Only the form that matches the active style is visible; hidden widgets take no room. + buttonLayout->addWidget(hostGameSplitButton); + buttonLayout->addWidget(hostGameButton); + buttonLayout->addWidget(hostGameSettingsButton); } buttonLayout->addWidget(joinButton); if (tabSupervisor->getUserInfo()->user_level() & ServerInfo_User::IsJudge) { @@ -173,10 +256,16 @@ GameSelector::GameSelector(AbstractClient *_client, } buttonLayout->setAlignment(Qt::AlignTop); + hostErrorLabel = new QLabel(this); + hostErrorLabel->setObjectName("hostGameErrorLabel"); + hostErrorLabel->setWordWrap(true); + hostErrorLabel->setVisible(false); + QVBoxLayout *mainLayout = new QVBoxLayout; if (showFilters && restoresettings) { mainLayout->addWidget(quickFilterToolBar); } + mainLayout->addWidget(hostErrorLabel); mainLayout->addWidget(gameListView); mainLayout->addLayout(buttonLayout); @@ -266,9 +355,131 @@ void GameSelector::actCreate() return; } - DlgCreateGame dlg(room, room->getGameTypes(), this); - dlg.exec(); - updateTitle(); + clearHostError(); + + // Heap-allocate the dialog: if this selector (or its room tab) is destroyed while the + // dialog is open, e.g. on server disconnect, Qt deletes its children. A stack-allocated + // child would then be deleted by Qt and crash. + auto *dlg = new DlgCreateGame(room, room->getGameTypes(), this); + dlg->setAttribute(Qt::WA_DeleteOnClose); + connect(dlg, &QDialog::finished, this, &GameSelector::updateTitle); + dlg->exec(); +} + +void GameSelector::actQuickHost() +{ + if (room == nullptr) { + qWarning() << "Attempted to create a game, but the room was null"; + return; + } + + clearHostError(); + + auto &gameSettings = SettingsCache::instance().game(); + const bool registered = tabSupervisor->getUserInfo()->user_level() & ServerInfo_User::IsRegistered; + const GameCreationOptions options = + gameSettings.getRememberGameSettings() + ? GameCreationOptions::fromSettings(gameSettings, room->getGameTypes(), registered) + : GameCreationOptions::defaultsFor(registered); + + PendingCommand *pend = room->prepareRoomCommand(options.toCreateGameCommand()); + connect(pend, &PendingCommand::finished, this, &GameSelector::checkQuickHostResponse); + room->sendRoomCommand(pend); + + setHostCommandPending(true); +} + +void GameSelector::checkQuickHostResponse(const Response &response) +{ + setHostCommandPending(false); + + switch (response.response_code()) { + case Response::RespOk: + clearHostError(); + break; + case Response::RespContextError: + showHostError(tr("You already host the maximum number of games.")); + break; + case Response::RespInternalError: + showHostError(tr("The server could not create the game.")); + break; + default: + showHostError(tr("Could not create the game.")); + break; + } +} + +void GameSelector::showHostError(const QString &message) +{ + hostErrorLabel->setText(message); + hostErrorLabel->setAccessibleName(message); + hostErrorLabel->setVisible(true); + + // The label appears above the game list without taking focus, so announce it: a screen + // reader would otherwise never reach the message after the button press. + QAccessibleEvent alertEvent(hostErrorLabel, QAccessible::Alert); + QAccessible::updateAccessibility(&alertEvent); +} + +void GameSelector::clearHostError() +{ + hostErrorLabel->clear(); + hostErrorLabel->setAccessibleName(QString()); + hostErrorLabel->setVisible(false); +} + +void GameSelector::applyHostGameStyle() +{ + if (hostGameSplitButton == nullptr || hostGameButton == nullptr || hostGameSettingsButton == nullptr) { + return; + } + + // Every style that can draw a split button gets one. The native macOS style cannot + // (QMacStyle draws the menu half as a second square bezel), so it gets the plain + // primary button and a sibling settings button instead. + const bool macStyle = usesMacStyle(); + hostGameSplitButton->setVisible(!macStyle); + hostGameButton->setVisible(macStyle); + hostGameSettingsButton->setVisible(macStyle); + + // The appearance settings can switch the style while the room is open. + updateHostGameToolTip(); +} + +void GameSelector::changeEvent(QEvent *event) +{ + if (event->type() == QEvent::StyleChange) { + applyHostGameStyle(); + } + QGroupBox::changeEvent(event); +} + +void GameSelector::setHostCommandPending(bool pending) +{ + hostCommandPending = pending; + // A join may still be in flight: its response enables these controls again, so keep them + // disabled until that response arrives instead of re-enabling on the host response alone. + updateHostControlsEnabled(); +} + +void GameSelector::updateHostControlsEnabled() +{ + const bool hostControlsEnabled = !hostCommandPending && !joinCommandPending; + if (hostGameAction) { + hostGameAction->setEnabled(hostControlsEnabled); + } + if (hostGameButton) { + hostGameButton->setEnabled(hostControlsEnabled); + } + if (hostGameSplitButton) { + hostGameSplitButton->setEnabled(hostControlsEnabled); + } + if (hostGameSettingsButton) { + hostGameSettingsButton->setEnabled(hostControlsEnabled); + } + if (gameSettingsAction) { + gameSettingsAction->setEnabled(hostControlsEnabled); + } } void GameSelector::checkResponse(const Response &response) @@ -285,6 +496,9 @@ void GameSelector::checkResponse(const Response &response) // Ideally we should have a way to figure out if the current game is the // same as the one we are getting a response for, but it's probably not // worth the trouble. + // The join response is the one that re-enables the buttons; clear the join flag first so + // the host controls become usable again unless a quick host is still in flight. + joinCommandPending = false; enableButtons(); switch (response.response_code()) { @@ -362,8 +576,9 @@ void GameSelector::customContextMenu(const QPoint &point) const ServerInfo_Game &gameInfo = gameListModel->getGame(index.data(Qt::UserRole).toInt()); const QMap &gameTypes = gameListModel->getGameTypes().value(gameInfo.room_id()); - DlgCreateGame dlg(gameInfo, gameTypes, this); - dlg.exec(); + auto *dlg = new DlgCreateGame(gameInfo, gameTypes, this); + dlg->setAttribute(Qt::WA_DeleteOnClose); + dlg->exec(); }); QAction copyLink(tr("Cop&y game link")); @@ -481,9 +696,8 @@ bool GameSelector::joinGameById(const int gameId, const bool asSpectator) void GameSelector::disableButtons() { - if (createButton) { - createButton->setEnabled(false); - } + joinCommandPending = true; + updateHostControlsEnabled(); joinButton->setEnabled(false); spectateButton->setEnabled(false); @@ -491,9 +705,7 @@ void GameSelector::disableButtons() void GameSelector::enableButtons() { - if (createButton) { - createButton->setEnabled(true); - } + updateHostControlsEnabled(); // Enable buttons for the currently selected game enableButtonsForIndex(gameListView->currentIndex()); @@ -516,8 +728,22 @@ void GameSelector::retranslateUi() { filterButton->setText(tr("&Filter games")); clearFilterButton->setText(tr("C&lear filter")); - if (createButton) { - createButton->setText(tr("C&reate")); + if (hostGameAction) { + hostGameAction->setText(tr("Host game")); + if (hostGameButton) { + // A QPushButton does not draw text from an action; on macOS this button is the + // primary action, so it carries the same label. + hostGameButton->setText(tr("Host game")); + } + updateHostGameToolTip(); + } + if (gameSettingsAction) { + gameSettingsAction->setText(tr("Game settings…")); + if (hostGameSettingsButton) { + // A QPushButton does not draw text from an action; on macOS this button is the + // secondary action, so it carries the same label. + hostGameSettingsButton->setText(tr("Game settings…")); + } } joinButton->setText(tr("&Join")); joinAsJudgeButton->setText(tr("Join as judge")); @@ -527,6 +753,39 @@ void GameSelector::retranslateUi() updateTitle(); } +void GameSelector::refreshShortcuts() +{ + if (hostGameAction) { + hostGameAction->setShortcuts(SettingsCache::instance().shortcuts().getShortcut("GameSelector/aQuickHostGame")); + updateHostGameToolTip(); + } + if (gameSettingsAction) { + gameSettingsAction->setShortcuts(SettingsCache::instance().shortcuts().getShortcut("GameSelector/aReviewGame")); + } +} + +void GameSelector::updateHostGameToolTip() +{ + const QString shortcut = SettingsCache::instance().shortcuts().getShortcutString("GameSelector/aQuickHostGame"); + const QString quickHostTooltip = shortcut.isEmpty() + ? tr("Host a game with your last-used settings") + : tr("Host a game with your last-used settings (%1)").arg(shortcut); + // The tooltip lives on the action for the shortcut and on the button for the mouse, + // since a QPushButton does not inherit an action's tooltip. + if (hostGameAction) { + hostGameAction->setToolTip(quickHostTooltip); + } + if (hostGameButton) { + hostGameButton->setToolTip(quickHostTooltip); + } + if (hostGameSplitButton) { + hostGameSplitButton->setToolTip(quickHostTooltip); + } + if (hostGameSettingsButton) { + hostGameSettingsButton->setToolTip(tr("Open the game settings before hosting")); + } +} + void GameSelector::processGameInfo(const ServerInfo_Game &info) { gameListModel->updateGameList(info); diff --git a/cockatrice/src/interface/widgets/server/game_selector.h b/cockatrice/src/interface/widgets/server/game_selector.h index e3407abea..bf51239f1 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.h +++ b/cockatrice/src/interface/widgets/server/game_selector.h @@ -14,7 +14,11 @@ class QTreeView; class GamesModel; class GamesProxyModel; +class QLabel; +class QMenu; class QPushButton; +class QToolButton; +class QAction; class AbstractClient; class TabSupervisor; class TabRoom; @@ -64,6 +68,17 @@ private slots: */ void actCreate(); + /** + * @brief Creates a game immediately from the remembered settings, without a dialog. + */ + void actQuickHost(); + + /** + * @brief Reports a failed quick-host request inline instead of as a modal box. + * @param response The server response to the create command. + */ + void checkQuickHostResponse(const Response &response); + /** * @brief Joins the currently selected game as a player. */ @@ -127,6 +142,15 @@ signals: */ void gameJoined(int gameId); +protected: + /** + * @brief Rebuilds the host control when the application style changes. + * + * The appearance settings can switch between the native macOS style and a + * cross-platform style while the room is open, and the host control follows. + */ + void changeEvent(QEvent *event) override; + private: AbstractClient *client; /**< The network client used to communicate with the server. */ TabSupervisor *tabSupervisor; /**< Reference to TabSupervisor for managing tabs and rooms. */ @@ -138,13 +162,19 @@ private: GameSelectorQuickFilterToolBar *quickFilterToolBar; - QPushButton *filterButton; /**< Button to open the filter dialog. */ - QPushButton *clearFilterButton; /**< Button to clear active filters. */ - QPushButton *createButton; /**< Button to create a new game (only if room is set). */ - QPushButton *joinButton; /**< Button to join the selected game. */ - QPushButton *joinAsJudgeButton; /**< Button to join the selected game as a judge. */ - QPushButton *spectateButton; /**< Button to spectate the selected game. */ - QPushButton *joinAsJudgeSpectatorButton; /**< Button to join the selected game as a spectating judge. */ + QPushButton *filterButton; /**< Button to open the filter dialog. */ + QPushButton *clearFilterButton; /**< Button to clear active filters. */ + QToolButton *hostGameSplitButton = nullptr; /**< Split host button, on every style that can draw one. */ + QPushButton *hostGameButton = nullptr; /**< Primary quick-host button, on the native macOS style only. */ + QPushButton *hostGameSettingsButton = nullptr; /**< Opens the host form; a sibling button on macOS. */ + QMenu *hostGameMenu = nullptr; /**< Menu behind the split arrow (non-macOS styles only). */ + QAction *hostGameAction = nullptr; /**< Quick-host action, carrying the keyboard shortcut. */ + QAction *gameSettingsAction = nullptr; /**< Opens the host form with the current settings. */ + QLabel *hostErrorLabel; /**< Inline failure message for a quick-host request. */ + QPushButton *joinButton; /**< Button to join the selected game. */ + QPushButton *joinAsJudgeButton; /**< Button to join the selected game as a judge. */ + QPushButton *spectateButton; /**< Button to spectate the selected game. */ + QPushButton *joinAsJudgeSpectatorButton; /**< Button to join the selected game as a spectating judge. */ const bool showFilters; /**< Determines whether filter buttons are displayed. */ GameTypeMap gameTypeMap; /**< Mapping of game types for the current room. */ @@ -152,6 +182,28 @@ private: QTimer filterSaveDebounceTimer; /**< Delays persisting quick-filter edits so typing does not rewrite the settings file on every keystroke. */ + bool hostCommandPending = false; /**< True while a quick-host command is in flight. */ + bool joinCommandPending = false; /**< True while a join/spectate command is in flight. */ + + /** @brief Shows a quick-host failure above the game list. */ + void showHostError(const QString &message); + /** @brief Hides the quick-host failure message. */ + void clearHostError(); + /** + * @brief Rebuilds the host control for the active style. + * + * The primary button always quick-hosts. Every style that can draw a split control + * gets one, whose arrow opens the menu; the native macOS style has no split control, + * so it gets a plain sibling button for the secondary action instead. + */ + void applyHostGameStyle(); + /** @brief Marks a quick-host command as in flight or finished, then updates the host controls. */ + void setHostCommandPending(bool pending); + /** @brief Applies the in-flight state of both host and join commands to the host controls. */ + void updateHostControlsEnabled(); + /** @brief Keeps the host control tooltips in sync with the current shortcut and style. */ + void updateHostGameToolTip(); + /** * @brief Updates the widget title to reflect the current number of displayed games. * @@ -222,6 +274,11 @@ public: */ void retranslateUi(); + /** + * @brief Re-reads the quick-host and review shortcuts after the user rebinds them. + */ + void refreshShortcuts(); + /** * @brief Updates or adds a game entry in the list. * @param info The ServerInfo_Game object containing information about the game to update. diff --git a/cockatrice/src/interface/widgets/server/games_model.cpp b/cockatrice/src/interface/widgets/server/games_model.cpp index 315d51d9e..d33526bd1 100644 --- a/cockatrice/src/interface/widgets/server/games_model.cpp +++ b/cockatrice/src/interface/widgets/server/games_model.cpp @@ -1,6 +1,7 @@ #include "games_model.h" #include "../../../client/settings/cache_settings.h" +#include "../../theme_icon.h" #include "../interface/pixel_map_generator.h" #include "libcockatrice/protocol/pb/serverinfo_user.pb.h" #include "user/user_list_proxy.h" @@ -85,7 +86,8 @@ const QString GamesModel::getGameCreatedString(const int secs) } GamesModel::GamesModel(const QMap &_rooms, const QMap &_gameTypes, QObject *parent) - : QAbstractTableModel(parent), rooms(_rooms), gameTypes(_gameTypes) + : QAbstractTableModel(parent), rooms(_rooms), gameTypes(_gameTypes), + lockIcon(loadThemedIcon(QStringLiteral("icons/lock"))) { } @@ -185,10 +187,15 @@ QVariant GamesModel::data(const QModelIndex &index, int role) const if (gameentry.share_decklists_on_load()) { result.append(tr("open decklists")); } + if (gameentry.shuffle_players()) { + result.append(tr("random turn order")); + } return result.join(", "); } case Qt::DecorationRole: { - return gameentry.with_password() ? QIcon(LockPixmapGenerator::generatePixmap(13)) : QVariant(); + // One engine per model: it is palette- and theme-aware and + // invalidates itself, so a repaint must not build a new one. + return gameentry.with_password() ? lockIcon : QVariant(); case Qt::TextAlignmentRole: return Qt::AlignLeft; default: diff --git a/cockatrice/src/interface/widgets/server/games_model.h b/cockatrice/src/interface/widgets/server/games_model.h index 9bf54b83a..62862417d 100644 --- a/cockatrice/src/interface/widgets/server/games_model.h +++ b/cockatrice/src/interface/widgets/server/games_model.h @@ -5,6 +5,7 @@ #include "game_type_map.h" #include +#include #include #include #include @@ -32,6 +33,7 @@ private: QList gameList; /**< List of games currently displayed. */ QMap rooms; /**< Map of room IDs to room names. */ QMap gameTypes; /**< Map of room IDs to available game types. */ + QIcon lockIcon; /**< Themed lock for password-protected games. */ static const int NUM_COLS = 8; /**< Number of columns in the table. */ diff --git a/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp b/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp index c131c3cbf..c1db6138d 100644 --- a/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp +++ b/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.cpp @@ -1,6 +1,6 @@ #include "remote_replay_list_tree_widget.h" -#include "../../../pixel_map_generator.h" +#include "../../../theme_icon.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/serverinfo_replay_match.pb.h" @@ -50,7 +50,7 @@ RemoteReplayList_TreeModel::RemoteReplayList_TreeModel(AbstractClient *_client, QFileIconProvider fip; dirIcon = fip.icon(QFileIconProvider::Folder); fileIcon = fip.icon(QFileIconProvider::File); - lockIcon = themePixmap(QStringLiteral("icons/lock")); + lockIcon = loadThemedIcon(QStringLiteral("icons/lock")); } RemoteReplayList_TreeModel::~RemoteReplayList_TreeModel() diff --git a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp index d84bd5804..6e8fd771a 100644 --- a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp @@ -12,7 +12,6 @@ #include #include #include -#include #include #include #include @@ -66,27 +65,6 @@ void UserCardArtProvider::requestCardArt(const QString &userName, const QString processQueue(); } -QPixmap UserCardArtProvider::cropCardArt(const QPixmap &fullRes, const ExactCard &card) -{ - QPixmap source = fullRes; - - // Sideways-layout cards (plane, siege/battle, split) store their landscape - // artwork rotated 90° inside a portrait frame. Rotate it upright first so - // the crop below lands on the horizontal art, mirroring the way - // CardInfoPictureWidget displays these cards. - const bool landscape = card.getInfo().getUiAttributes().landscapeOrientation; - source = CardArtUtils::rotateSidewaysLayoutArt(source, card); - - const QSize sz = source.size(); - const int marginX = sz.width() * 0.07; - const int topMargin = landscape ? sz.height() * 0.05 : sz.height() * 0.11; - const int bottomMargin = landscape ? sz.height() * 0.42 : sz.height() * 0.45; - - const QRect artRect(marginX, topMargin, sz.width() - 2 * marginX, sz.height() - topMargin - bottomMargin); - - return source.copy(artRect.intersected(source.rect())); -} - void UserCardArtProvider::insertIntoCache(const QString &key, const QPixmap &pixmap) { if (pixmap.isNull()) { @@ -134,7 +112,7 @@ void UserCardArtProvider::processQueue() // Synchronous hit (already loaded/on disk) if (!fullRes.isNull()) { - insertIntoCache(key, cropCardArt(fullRes, card)); + insertIntoCache(key, CardArtUtils::cropCardArt(fullRes, card)); pending.remove(key); emit cardArtUpdated(userName); @@ -158,7 +136,7 @@ void UserCardArtProvider::processQueue() CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040)); if (!fullRes.isNull()) { - self->insertIntoCache(key, self->cropCardArt(fullRes, card)); + self->insertIntoCache(key, CardArtUtils::cropCardArt(fullRes, card)); } self->pending.remove(key); @@ -172,4 +150,4 @@ void UserCardArtProvider::processQueue() // Stop here. We'll continue when the async load finishes. return; } -} \ No newline at end of file +} diff --git a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h index ce9225b00..a7844ca88 100644 --- a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h +++ b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h @@ -10,8 +10,6 @@ #include #include -class ExactCard; - class UserCardArtProvider : public QObject { Q_OBJECT @@ -21,7 +19,6 @@ public: void requestCardArt(const QString &userName, const QString &cardName, const QString &providerId); const QMap &cache() const; - static QPixmap cropCardArt(const QPixmap &fullRes, const ExactCard &card); signals: void cardArtUpdated(const QString &userName); diff --git a/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp index f8b57cfc2..67fb57e78 100644 --- a/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp @@ -2,6 +2,7 @@ #include "../../../card_picture_loader/card_picture_loader.h" #include "../../cards/art_crop_attribution.h" +#include "../../cards/card_art_utils.h" #include "../../utility/completer_utils.h" #include "card_database_display_model.h" #include "card_database_model.h" @@ -10,7 +11,6 @@ #include "libcockatrice/card/printing/exact_card.h" #include "libcockatrice/card/printing/printing_info.h" #include "libcockatrice/card/set/card_set.h" -#include "user_card_art_provider.h" #include "user_list_painter.h" #include @@ -580,7 +580,7 @@ void UserCardArtSettingsDialog::reloadPreview() return; } - currentPixmap = UserCardArtProvider::cropCardArt(fullRes, card); + currentPixmap = CardArtUtils::cropCardArt(fullRes, card); preview->setPixmap(currentPixmap); preview->setParams(currentParams); diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp index 447e63673..f61293013 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp @@ -83,6 +83,7 @@ UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent, aDemoteFromDeveloper = new QAction(QString(), this); aGetAdminNotes = new QAction(QString(), this); aInvestigateUser = new QAction(QString(), this); + aInvestigateAlts = new QAction(QString(), this); retranslateUi(); } @@ -111,6 +112,7 @@ void UserContextMenu::retranslateUi() aDemoteFromDeveloper->setText(tr("Demote user from de&veloper")); aGetAdminNotes->setText(tr("View admin notes")); aInvestigateUser->setText(tr("Investigate user")); + aInvestigateAlts->setText(tr("Investigate &alts")); } void UserContextMenu::gamesOfUserReceived(const Response &resp, const CommandContainer &commandContainer) @@ -454,6 +456,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos, menu->addSeparator(); menu->addAction(aGetAdminNotes); menu->addAction(aInvestigateUser); + menu->addAction(aInvestigateAlts); menu->addSeparator(); if (userLevel.testFlag(ServerInfo_User::IsModerator) && @@ -499,6 +502,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos, aBanHistory->setEnabled(anotherUser); aGetAdminNotes->setEnabled(anotherUser); aInvestigateUser->setEnabled(anotherUser); + aInvestigateAlts->setEnabled(anotherUser); aPromoteToMod->setEnabled(anotherUser); aDemoteFromMod->setEnabled(anotherUser); aPromoteToJudge->setEnabled(anotherUser); @@ -553,6 +557,8 @@ void UserContextMenu::showContextMenu(const QPoint &pos, execAdminNotes(userName); } else if (actionClicked == aInvestigateUser) { execInvestigateUser(userName); + } else if (actionClicked == aInvestigateAlts) { + execInvestigateAlts(userName); } else if (actionClicked == aCopyToClipBoard) { QClipboard *clipboard = QGuiApplication::clipboard(); clipboard->setText(deckHash); @@ -745,6 +751,11 @@ void UserContextMenu::execInvestigateUser(const QString &userName) tabSupervisor->openTabModeration(userName); } +void UserContextMenu::execInvestigateAlts(const QString &userName) +{ + tabSupervisor->openTabAlts(userName); +} + void UserContextMenu::execAdjustMod(const QString &userName, bool shouldBeMod) { Command_AdjustMod cmd; diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.h b/cockatrice/src/interface/widgets/server/user/user_context_menu.h index 2536819a4..86be5eff2 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.h +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.h @@ -54,6 +54,7 @@ private: QAction *aGetAdminNotes; std::function()> gameInviteLinkProvider; QAction *aInvestigateUser; + QAction *aInvestigateAlts; signals: void openMessageDialog(const QString &userName, bool focus); private slots: @@ -127,6 +128,7 @@ public: void execWarnHistory(const QString &userName); void execAdminNotes(const QString &userName); void execInvestigateUser(const QString &userName); + void execInvestigateAlts(const QString &userName); void execAdjustMod(const QString &userName, bool shouldBeMod); void execAdjustJudge(const QString &userName, bool shouldBeJudge); void execAdjustDeveloper(const QString &userName, bool shouldBeDeveloper); diff --git a/cockatrice/src/interface/widgets/server/user/user_games_model.cpp b/cockatrice/src/interface/widgets/server/user/user_games_model.cpp new file mode 100644 index 000000000..b7c728c45 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_games_model.cpp @@ -0,0 +1,149 @@ +#include "user_games_model.h" + +#include +#include +#include +#include +#include +#include +#include + +UserGamesModel::UserGamesModel(AbstractClient *_client, int requestTimeoutMs, QObject *parent) + : QAbstractListModel(parent), client(_client) +{ + timeout.setSingleShot(true); + timeout.setInterval(requestTimeoutMs); + connect(&timeout, &QTimer::timeout, this, [this] { + invalidateRequest(); + setState(State::Error); + }); + connected = client->getStatus() == StatusLoggedIn; + connect(client, &AbstractClient::statusChanged, this, [this](ClientStatus status) { + // AbstractClient::setStatus emits this signal while holding the (non-recursive) + // status mutex, which getStatus() would try to lock again. Track the emitted + // value here, and read this cache from canRefresh() for the same reason. + connected = status == StatusLoggedIn; + if (!connected) { + invalidateRequest(); + replaceGames({}); + setState(online ? State::Disconnected : State::Offline); + } else if (online) { + refresh(); + } + }); + connect(client, &QObject::destroyed, this, [this] { + client = nullptr; + connected = false; + invalidateRequest(); + replaceGames({}); + setState(online ? State::Disconnected : State::Offline); + }); +} + +int UserGamesModel::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : games.size(); +} + +QVariant UserGamesModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.model() != this || index.column() != 0 || index.row() < 0 || + index.row() >= games.size() || role != GameRole) { + return {}; + } + return QVariant::fromValue(games.at(index.row())); +} + +void UserGamesModel::setUser(const QString &name, bool isOnline) +{ + userName = name; + online = isOnline; + refresh(); +} + +UserGamesModel::State UserGamesModel::getState() const +{ + return state; +} + +bool UserGamesModel::canRefresh() const +{ + return online && !userName.isEmpty() && client && connected && state != State::Loading; +} + +void UserGamesModel::invalidateRequest() +{ + ++requestGeneration; + // The client owns pending commands. Detach our interest without deleting + // an object that may already have moved to the client's thread. + disconnect(responseConnection); + responseConnection = {}; + timeout.stop(); +} + +void UserGamesModel::replaceGames(QList replacement) +{ + beginResetModel(); + games = std::move(replacement); + endResetModel(); +} + +void UserGamesModel::setState(State newState) +{ + state = newState; + emit stateChanged(); +} + +void UserGamesModel::refresh() +{ + invalidateRequest(); + const quint64 request = requestGeneration; + replaceGames({}); + if (!online || userName.isEmpty()) { + setState(State::Offline); + return; + } + if (!client || !connected) { + setState(State::Disconnected); + return; + } + + setState(State::Loading); + // State listeners may select another user or disconnect the client. + if (request != requestGeneration) { + return; + } + + Command_GetGamesOfUser command; + command.set_user_name(userName.toStdString()); + PendingCommand *pending = client->prepareSessionCommand(command); + responseConnection = connect(pending, &PendingCommand::finished, this, + [this, request](const Response &response) { finishRequest(response, request); }); + timeout.start(); + client->sendCommand(pending); +} + +void UserGamesModel::finishRequest(const Response &response, quint64 request) +{ + if (request != requestGeneration) { + return; + } + invalidateRequest(); + const quint64 completion = requestGeneration; + if (response.response_code() != Response::RespOk || !response.HasExtension(Response_GetGamesOfUser::ext)) { + setState(State::Error); + return; + } + + const auto &result = response.GetExtension(Response_GetGamesOfUser::ext); + QList replacement; + replacement.reserve(result.game_list_size()); + for (const auto &game : result.game_list()) { + replacement.append(game); + } + replaceGames(std::move(replacement)); + if (completion != requestGeneration) { + return; + } + setState(games.isEmpty() ? State::Empty : State::Ready); +} diff --git a/cockatrice/src/interface/widgets/server/user/user_games_model.h b/cockatrice/src/interface/widgets/server/user/user_games_model.h new file mode 100644 index 000000000..b8ad71a67 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_games_model.h @@ -0,0 +1,63 @@ +#ifndef COCKATRICE_USER_GAMES_MODEL_H +#define COCKATRICE_USER_GAMES_MODEL_H + +#include +#include +#include +#include +#include +#include + +class AbstractClient; +class Response; + +/** Current user's games and the lifetime of the request that supplies them. */ +class UserGamesModel : public QAbstractListModel +{ + Q_OBJECT + +public: + enum class State + { + Offline, + Disconnected, + Loading, + Ready, + Empty, + Error + }; + static constexpr int DefaultRequestTimeoutMs = 15000; + static constexpr int GameRole = Qt::UserRole + 10; + + explicit UserGamesModel(AbstractClient *client, + int requestTimeoutMs = DefaultRequestTimeoutMs, + QObject *parent = nullptr); + int rowCount(const QModelIndex &parent = {}) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + void setUser(const QString &name, bool isOnline); + void refresh(); + State getState() const; + bool canRefresh() const; + +signals: + void stateChanged(); + +private: + void invalidateRequest(); + void replaceGames(QList replacement); + void finishRequest(const Response &response, quint64 request); + void setState(State newState); + + QPointer client; + QString userName; + bool online = false; + bool connected = false; + State state = State::Offline; + QList games; + quint64 requestGeneration = 0; + QMetaObject::Connection responseConnection; + QTimer timeout; +}; + +#endif // COCKATRICE_USER_GAMES_MODEL_H diff --git a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp index fa7698037..997001c50 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp @@ -1,6 +1,7 @@ #include "user_info_box.h" #include "../../interface/pixel_map_generator.h" +#include "../../interface/theme_manager.h" #include "../../interface/widgets/dialogs/dlg_edit_avatar.h" #include "../../interface/widgets/dialogs/dlg_edit_password.h" #include "../../interface/widgets/dialogs/dlg_edit_user.h" @@ -39,10 +40,8 @@ UserInfoBox::UserInfoBox(AbstractClient *_client, bool _editable, QWidget *parent, Qt::WindowFlags flags) : QWidget(parent, flags), client(_client), editable(_editable) { - QFont nameFont = nameLabel.font(); - nameFont.setBold(true); - nameFont.setPointSizeF(nameFont.pointSizeF() * 1.5); - nameLabel.setFont(nameFont); + applyInterfaceDefaultFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, &UserInfoBox::applyInterfaceDefaultFont); auto *userIconAndNameLayout = new QHBoxLayout; userIconAndNameLayout->addWidget(&userLevelIcon, 0, Qt::AlignCenter); @@ -91,6 +90,16 @@ UserInfoBox::UserInfoBox(AbstractClient *_client, bool _editable, QWidget *paren retranslateUi(); } +void UserInfoBox::applyInterfaceDefaultFont() +{ + // Family comes from the InterfaceDefault theme role; the *1.5 size + // multiplier and bold weight stay caller-side as before. + QFont nameFont = themeManager->appFont(AppFont::InterfaceDefault); + nameFont.setBold(true); + nameFont.setPointSizeF(nameFont.pointSizeF() * 1.5); + nameLabel.setFont(nameFont); +} + void UserInfoBox::retranslateUi() { realNameLabel1.setText(tr("Real Name:")); diff --git a/cockatrice/src/interface/widgets/server/user/user_info_box.h b/cockatrice/src/interface/widgets/server/user/user_info_box.h index 6bc571a37..98919b027 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_box.h +++ b/cockatrice/src/interface/widgets/server/user/user_info_box.h @@ -48,6 +48,7 @@ private slots: void processEditResponse(const Response &r); void processPasswordResponse(const Response &r); void processAvatarResponse(const Response &r); + void applyInterfaceDefaultFont(); void actEdit(); void actEditInternal(const Response &r); diff --git a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp index efb3b2e31..4b4887bc4 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp @@ -6,10 +6,9 @@ #include "../../interface/widgets/tabs/tab_supervisor.h" #include "libcockatrice/card/database/card_database_querier.h" #include "libcockatrice/card/printing/exact_card.h" -#include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/serverinfo_game.pb.h" #include "libcockatrice/protocol/pb/serverinfo_user.pb.h" -#include "libcockatrice/protocol/pb/session_commands.pb.h" +#include "user_games_model.h" #include "user_level.h" #include "user_list_painter.h" @@ -34,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -41,8 +41,6 @@ #include #include #include -#include -#include #include #include #include @@ -52,8 +50,6 @@ #include #include #include -#include -#include #include #include #include @@ -142,7 +138,8 @@ public: p->drawEllipse(QRectF(rect.left() + 9, rect.top() + (rect.height() - 8) / 2.0, 8, 8)); // Game title (bold, elided) - QFont tf = option.font; + // Family comes from the InterfaceDefault theme role; bold stays caller-side. + QFont tf = themeManager->appFont(AppFont::InterfaceDefault); tf.setBold(true); p->setFont(tf); p->setPen(pal.color(QPalette::Text)); @@ -154,7 +151,7 @@ public: // Player count const bool full = game.player_count() >= game.max_players(); - p->setFont(option.font); + p->setFont(themeManager->appFont(AppFont::InterfaceDefault)); p->setPen(full ? QColor(249, 115, 22) : pal.color(QPalette::Disabled, QPalette::Text)); p->drawText(QRect(rect.right() - countW - 4, rect.top(), countW, rect.height()), Qt::AlignVCenter | Qt::AlignRight, @@ -323,7 +320,8 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *) const int tx = ax + AvatarSize + AvatarToTextGap; const int tw = rect.width() - tx - 8; - QFont nf = font(); + // Family comes from the InterfaceDefault theme role; the *1.12 size and bold stay caller-side. + QFont nf = themeManager->appFont(AppFont::InterfaceDefault); nf.setBold(true); nf.setPointSizeF(nf.pointSizeF() * 1.12); p.setFont(nf); @@ -352,7 +350,7 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *) } if (!badge.text.isEmpty()) { - QFont bf = font(); + QFont bf = themeManager->appFont(AppFont::InterfaceDefault); bf.setPointSizeF(bf.pointSizeF() * 0.70); bf.setBold(true); p.setFont(bf); @@ -369,7 +367,8 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *) // The painter font at this point depends on whether a badge was drawn // (badge font vs username font), so pin an explicit font for the pill. - p.setFont(font()); + // Family follows the InterfaceDefault theme role like the rest of the popup chrome. + p.setFont(themeManager->appFont(AppFont::InterfaceDefault)); // Only show the attribution when there is actually art on screen: on a // cache miss cardArt is null and the pill would float over the plain @@ -398,6 +397,7 @@ UserInfoPopup::UserInfoPopup(TabSupervisor *_ts, // Restyle the popup chrome when the theme or its color scheme changes. if (themeManager) { connect(themeManager, &ThemeManager::themeChanged, this, &UserInfoPopup::applyTheme); + connect(themeManager, &ThemeManager::fontsChanged, this, &UserInfoPopup::applyTheme); } } @@ -427,6 +427,7 @@ void UserInfoPopup::buildUi() refreshBtn = new QPushButton(QStringLiteral("↻"), this); refreshBtn->setFixedSize(20, 20); refreshBtn->setFlat(true); + refreshBtn->setToolTip(tr("Refresh games")); connect(refreshBtn, &QPushButton::clicked, this, &UserInfoPopup::refreshGames); gh->addWidget(refreshBtn); root->addLayout(gh); @@ -437,7 +438,8 @@ void UserInfoPopup::buildUi() root->addWidget(gamesStatus); // Games list - gamesModel = new QStandardItemModel(this); + gamesModel = new UserGamesModel(client, UserGamesModel::DefaultRequestTimeoutMs, this); + connect(gamesModel, &UserGamesModel::stateChanged, this, &UserInfoPopup::updateGamesDisplay); gamesView = new QListView(this); gamesView->setModel(gamesModel); gamesView->setItemDelegate(new PopupGameDelegate(gamesView)); @@ -475,15 +477,24 @@ void UserInfoPopup::applyTheme() separator->setStyleSheet(QStringLiteral("color:%1; margin: 0 8px;").arg(colorStr(theme.border))); - gamesLabel->setStyleSheet(QStringLiteral("color:%1; font-size:11px; font-weight:bold; background:transparent;") - .arg(colorStr(theme.subText))); + gamesLabel->setStyleSheet(QStringLiteral("color:%1; background:transparent;").arg(colorStr(theme.subText))); + QFont gamesLabelFont = themeManager->appFont(AppFont::InterfaceDefault); + gamesLabelFont.setPixelSize(11); + gamesLabelFont.setWeight(QFont::Bold); + gamesLabel->setFont(gamesLabelFont); - refreshBtn->setStyleSheet(QStringLiteral("QPushButton{color:%1;border:none;font-size:14px;background:transparent;}" + refreshBtn->setStyleSheet(QStringLiteral("QPushButton{color:%1;border:none;background:transparent;}" "QPushButton:hover{color:%2;}") .arg(colorStr(theme.subText), colorStr(theme.text))); + QFont refreshFont = themeManager->appFont(AppFont::InterfaceDefault); + refreshFont.setPixelSize(14); + refreshBtn->setFont(refreshFont); - gamesStatus->setStyleSheet(QStringLiteral("color:%1; font-size:11px; padding:10px; background:transparent;") - .arg(colorStr(theme.statusText))); + gamesStatus->setStyleSheet( + QStringLiteral("color:%1; padding:10px; background:transparent;").arg(colorStr(theme.statusText))); + QFont statusFont = themeManager->appFont(AppFont::InterfaceDefault); + statusFont.setPixelSize(11); + gamesStatus->setFont(statusFont); gamesView->setStyleSheet(QStringLiteral("QListView{background:%1;border:none;}" "QListView::item:selected{background:%2;}") @@ -491,9 +502,12 @@ void UserInfoPopup::applyTheme() closeBtn->setStyleSheet( QStringLiteral("QPushButton{background:%1;color:%2;" - "border:none;border-radius:11px;font-size:10px;}" + "border:none;border-radius:11px;}" "QPushButton:hover{color:%3;background:%4;}") .arg(colorStr(theme.closeBg), colorStr(theme.subText), colorStr(theme.text), colorStr(theme.closeHover))); + QFont closeFont = themeManager->appFont(AppFont::InterfaceDefault); + closeFont.setPixelSize(10); + closeBtn->setFont(closeFont); header->update(); } @@ -508,13 +522,17 @@ static QPushButton *makeBtn(const QString &label, const QString &tip, QWidget *p b->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); b->setStyleSheet(QStringLiteral("QPushButton{" " background:%1;color:%2;border:1px solid %3;" - " border-radius:4px;font-size:11px;padding:0 4px;" + " border-radius:4px;padding:0 4px;" "}" "QPushButton:hover{background:%4;color:%5;}" "QPushButton:pressed{background:%6;}" "QPushButton:disabled{color:%7;border-color:%3;}") .arg(colorStr(t.buttonBg), colorStr(t.text), colorStr(t.buttonBorder), colorStr(t.buttonHover), colorStr(t.text), colorStr(t.buttonPressed), colorStr(t.buttonDisabled))); + // Family comes from the InterfaceDefault theme role; the 11px size stays caller-side. + QFont btnFont = themeManager->appFont(AppFont::InterfaceDefault); + btnFont.setPixelSize(11); + b->setFont(btnFont); return b; } @@ -645,13 +663,21 @@ void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool o void UserInfoPopup::updateActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored) { - rebuildActionButtons(userInfo, online, isBuddy, isIgnored); - adjustSize(); + currentUserInfo = userInfo; + currentBuddy = isBuddy; + currentIgnored = isIgnored; + if (currentOnline != online) { + setOnline(online); + } else { + refreshHeader(); + rebuildActionButtons(userInfo, online, isBuddy, isIgnored); + adjustSize(); + } } void UserInfoPopup::onGamesContextMenu(const QPoint &pos) { - const QModelIndex idx = gamesView->indexAt(pos); + const QPersistentModelIndex idx = gamesView->indexAt(pos); if (!idx.isValid()) { return; } @@ -679,7 +705,7 @@ void UserInfoPopup::onGamesContextMenu(const QPoint &pos) } const QAction *chosen = menu.exec(gamesView->viewport()->mapToGlobal(pos)); - if (!chosen) { + if (!chosen || !idx.isValid()) { return; } @@ -716,6 +742,8 @@ void UserInfoPopup::showForUser(const QString &userName, currentUser = userName; currentUserInfo = userInfo; currentOnline = online; + currentBuddy = isBuddy; + currentIgnored = isIgnored; // Header refreshHeader(); @@ -723,88 +751,71 @@ void UserInfoPopup::showForUser(const QString &userName, // Actions rebuildActionButtons(userInfo, online, isBuddy, isIgnored); - // Games list reset - gamesModel->clear(); - gamesView->hide(); - gamesStatus->setText(tr("Loading games…")); - gamesStatus->show(); - // Close button, top right corner, above everything closeBtn->move(PopupWidth - closeBtn->width() - 6, 6); closeBtn->raise(); + gamesModel->setUser(currentUser, currentOnline); adjustSize(); - fetchGames(); } // ── Games fetch ─────────────────────────────────────────────────────────────── void UserInfoPopup::fetchGames() { - if (!client || currentUser.isEmpty()) { - return; - } - - Command_GetGamesOfUser cmd; - cmd.set_user_name(currentUser.toStdString()); - - const QString snapshot = currentUser; - PendingCommand *pend = client->prepareSessionCommand(cmd); - connect(pend, &PendingCommand::finished, this, - [this, snapshot](const Response &r) { onGamesReceived(r, snapshot); }); - client->sendCommand(pend); + gamesModel->refresh(); } -void UserInfoPopup::onGamesReceived(const Response &r, const QString &forUser) +void UserInfoPopup::setOnline(bool online) { - if (forUser != currentUser) { - return; // stale response, different user showing now - } - - gamesModel->clear(); - - if (r.response_code() != Response::RespOk) { - gamesStatus->setText(tr("Could not load games.")); - gamesStatus->show(); - gamesView->hide(); + if (currentOnline == online) { return; } + currentOnline = online; + refreshHeader(); + rebuildActionButtons(currentUserInfo, online, currentBuddy, currentIgnored); + gamesModel->setUser(currentUser, online); +} - const auto &resp = r.GetExtension(Response_GetGamesOfUser::ext); - if (resp.game_list_size() == 0) { - gamesStatus->setText(tr("No active games.")); - gamesStatus->show(); - gamesView->hide(); - return; +void UserInfoPopup::updateGamesDisplay() +{ + refreshBtn->setEnabled(gamesModel->canRefresh()); + const auto state = gamesModel->getState(); + const bool ready = state == UserGamesModel::State::Ready; + gamesView->setVisible(ready); + gamesStatus->setVisible(!ready); + + switch (state) { + case UserGamesModel::State::Offline: + gamesStatus->setText(tr("User is offline.")); + break; + case UserGamesModel::State::Disconnected: + gamesStatus->setText(tr("Not connected to the server.")); + break; + case UserGamesModel::State::Loading: + gamesStatus->setText(tr("Loading games…")); + break; + case UserGamesModel::State::Empty: + gamesStatus->setText(tr("No active games.")); + break; + case UserGamesModel::State::Error: + gamesStatus->setText(tr("Could not load games.")); + break; + case UserGamesModel::State::Ready: { + // Fit exactly to the number of visible rows, scroll when more than 5. + constexpr int rowH = 38; // must match PopupGameDelegate::sizeHint + constexpr int maxRows = 5; + const int count = gamesModel->rowCount(); + gamesView->setFixedHeight(qMin(count, maxRows) * rowH + 2); + gamesView->setVerticalScrollBarPolicy(count > maxRows ? Qt::ScrollBarAlwaysOn : Qt::ScrollBarAlwaysOff); + break; + } } - - for (int i = 0; i < resp.game_list_size(); ++i) { - auto *item = new QStandardItem; - item->setData(QVariant::fromValue(resp.game_list(i)), PopupRoles::GameData); - item->setEditable(false); - gamesModel->appendRow(item); - } - - gamesStatus->hide(); - gamesView->show(); - - // Fit exactly to the number of visible rows, scroll when more than 5 - constexpr int rowH = 38; // must match PopupGameDelegate::sizeHint - constexpr int maxRows = 5; - const int count = gamesModel->rowCount(); - const int visible = qMin(count, maxRows); - gamesView->setFixedHeight(visible * rowH + 2); - gamesView->setVerticalScrollBarPolicy(count > maxRows ? Qt::ScrollBarAlwaysOn : Qt::ScrollBarAlwaysOff); - adjustSize(); } void UserInfoPopup::refreshGames() { - gamesModel->clear(); - gamesView->hide(); - gamesStatus->setText(tr("Loading games…")); - gamesStatus->show(); fetchGames(); } diff --git a/cockatrice/src/interface/widgets/server/user/user_info_popup.h b/cockatrice/src/interface/widgets/server/user/user_info_popup.h index f2a91d025..1af4ad0e1 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_popup.h +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.h @@ -1,6 +1,7 @@ #ifndef COCKATRICE_USER_INFO_POPUP_H #define COCKATRICE_USER_INFO_POPUP_H +#include "user_games_model.h" #include "user_list_painter.h" #include @@ -21,15 +22,13 @@ class QPushButton; class TabSupervisor; class QListView; class QPoint; -class QStandardItemModel; -class Response; template class QMap; // ── Roles ───────────────────────────────────────────────────────────────────── namespace PopupRoles { -constexpr int GameData = Qt::UserRole + 10; +constexpr int GameData = UserGamesModel::GameRole; } // Popup theme @@ -143,6 +142,7 @@ public: void showForUser(const QString &userName, const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored); void fetchGames(); + void setOnline(bool online); [[nodiscard]] QString getCurrentUser() const { @@ -199,7 +199,7 @@ protected: private slots: void refreshGames(); - void onGamesReceived(const Response &r, const QString &forUser); + void updateGamesDisplay(); void onGamesContextMenu(const QPoint &pos); private: @@ -218,6 +218,8 @@ private: QString currentUser; ServerInfo_User currentUserInfo; bool currentOnline = false; + bool currentBuddy = false; + bool currentIgnored = false; std::function gameInviteAvailable; UserInfoHeaderWidget *header; @@ -225,7 +227,7 @@ private: QLabel *gamesLabel; QFrame *separator; QListView *gamesView; - QStandardItemModel *gamesModel; + UserGamesModel *gamesModel; QLabel *gamesStatus; QPushButton *closeBtn; QPushButton *refreshBtn; diff --git a/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp b/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp index 08eaaaff7..acb91351b 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp @@ -1,6 +1,7 @@ #include "user_list_painter.h" #include "../../interface/pixel_map_generator.h" +#include "../../interface/theme_manager.h" #include "libcockatrice/protocol/pb/serverinfo_user.pb.h" #include @@ -292,7 +293,9 @@ void UserListPainter::drawUserName(QPainter *painter, bool online, const Style &style) { - QFont nameFont = option.font; + Q_UNUSED(option); + // Family comes from the InterfaceDefault theme role; bold stays caller-side. + QFont nameFont = themeManager->appFont(AppFont::InterfaceDefault); nameFont.setBold(true); painter->setFont(nameFont); @@ -351,7 +354,9 @@ void UserListPainter::drawBadges(QPainter *painter, return; } - QFont badgeFont = option.font; + Q_UNUSED(option); + // Family comes from the InterfaceDefault theme role; the *0.68 size and bold stay caller-side. + QFont badgeFont = themeManager->appFont(AppFont::InterfaceDefault); badgeFont.setPointSizeF(badgeFont.pointSizeF() * 0.68); badgeFont.setBold(true); painter->setFont(badgeFont); diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp index a20b39b82..b7f4614c9 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -1,5 +1,6 @@ #include "user_list_widget.h" +#include "../../../../client/crash/crash_reporter.h" #include "../../../../client/settings/cache_settings.h" #include "../../interface/pixel_map_generator.h" #include "../../interface/theme_manager.h" @@ -519,6 +520,19 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, userTree->viewport()->update(); userTree->update(); }); + // Divider headers store their font on the item, so re-apply the role + // family here; the painter reads the role live and needs only the + // repaint below. + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { + QFont dividerFont = themeManager->appFont(AppFont::InterfaceDefault); + dividerFont.setBold(true); + for (auto it = sectionItems.constBegin(); it != sectionItems.constEnd(); ++it) { + it.value()->setFont(0, dividerFont); + it.value()->setSizeHint(0, QSize(0, QFontMetrics(dividerFont).height() + 16)); + } + userTree->viewport()->update(); + userTree->update(); + }); } QVBoxLayout *vbox = new QVBoxLayout; @@ -617,7 +631,14 @@ void UserListWidget::bind(UserListManager *mgr) connect(manager, &UserListManager::addedToIgnoreList, this, refreshCurrentPopup); connect(manager, &UserListManager::removedFromIgnoreList, this, refreshIfPopupOpen); connect(manager, &UserListManager::userJoinedOnline, this, refreshCurrentPopup); - connect(manager, &UserListManager::userLeftOnline, this, refreshIfPopupOpen); + connect(manager, &UserListManager::userLeftOnline, this, [this, refreshIfPopupOpen](const QString &name) { + // The online row may already have been removed; the pinned popup must + // still invalidate its games request and display the offline state. + if (userInfoPopup && userInfoPopup->getCurrentUser() == name) { + userInfoPopup->setOnline(false); + } + refreshIfPopupOpen(name); + }); rebuild(); } @@ -1201,6 +1222,7 @@ void UserListWidget::processUserInfo(Section section, const ServerInfo_User &use bool UserListWidget::deleteUser(const QString &userName) { + CrashReporter::addBreadcrumb("users", "user list row removed"); if (sectioned) { // The user may own several rows (one per section). Drop them all. bool removed = false; @@ -1492,7 +1514,7 @@ QTreeWidgetItem *UserListWidget::createSectionItem(Section section) // They act as collapsible section headers once they have focus. divider->setFlags(Qt::ItemIsEnabled | Qt::ItemIsSelectable); - QFont font = userTree->font(); + QFont font = themeManager->appFont(AppFont::InterfaceDefault); font.setBold(true); divider->setFont(0, font); // A little taller than a plain text row so the header reads as a section diff --git a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp index de5f67b32..21c0a79e1 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp @@ -146,6 +146,7 @@ AppearanceSettingsPage::AppearanceSettingsPage() }); homeTabBackgroundShuffleFrequencySpinBox.setRange(0, 3600); + //: Unit following a number of seconds. Keep the leading space unless your language writes number and unit flush. homeTabBackgroundShuffleFrequencySpinBox.setSuffix(tr(" seconds")); homeTabBackgroundShuffleFrequencySpinBox.setValue(settings.appearance().getHomeTabBackgroundShuffleFrequency()); connect(&homeTabBackgroundShuffleFrequencySpinBox, qOverload(&QSpinBox::valueChanged), &settings.appearance(), @@ -675,7 +676,7 @@ void AppearanceSettingsPage::retranslateUi() } handGroupBox->setTitle(tr("Hand layout")); - horizontalHandCheckBox.setText(tr("Display hand horizontally (wastes space)")); + horizontalHandCheckBox.setText(tr("Display hand horizontally (requires more space)")); leftJustifiedHandCheckBox.setText(tr("Enable left justification")); tableGroupBox->setTitle(tr("Table grid layout")); diff --git a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp index 31178ba4c..6e104d617 100644 --- a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp @@ -1,13 +1,14 @@ #include "deck_editor_settings_page.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../dialogs/path_safety_warning.h" #include "update/card_spoiler/spoiler_background_updater.h" #include #include #include +#include #include #include #include @@ -61,7 +62,7 @@ DeckEditorSettingsPage::DeckEditorSettingsPage() connect(mpSpoilerPathButton, &QPushButton::clicked, this, &DeckEditorSettingsPage::spoilerPathButtonClicked); updateNowButton = new QPushButton; - updateNowButton->setFixedWidth(150); + updateNowButton->setMinimumWidth(150); connect(updateNowButton, &QPushButton::clicked, this, &DeckEditorSettingsPage::updateSpoilers); // Update the GUI depending on if the box is ticked or not @@ -79,19 +80,19 @@ DeckEditorSettingsPage::DeckEditorSettingsPage() } aAdd = new QAction(this); - aAdd->setIcon(themePixmap(QStringLiteral("icons/increment"))); + aAdd->setIcon(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive, IconStyle::Sticker)); connect(aAdd, &QAction::triggered, this, &DeckEditorSettingsPage::actAddURL); aEdit = new QAction(this); - aEdit->setIcon(themePixmap(QStringLiteral("icons/pencil"))); + aEdit->setIcon(loadThemedIcon(QStringLiteral("icons/pencil"))); connect(aEdit, &QAction::triggered, this, &DeckEditorSettingsPage::actEditURL); aRemove = new QAction(this); - aRemove->setIcon(themePixmap(QStringLiteral("icons/decrement"))); + aRemove->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative, IconStyle::Sticker)); connect(aRemove, &QAction::triggered, this, &DeckEditorSettingsPage::actRemoveURL); aRateLimit = new QAction(this); - aRateLimit->setIcon(themePixmap(QStringLiteral("icons/cogwheel"))); + aRateLimit->setIcon(loadThemedIcon(QStringLiteral("icons/settings"))); connect(aRateLimit, &QAction::triggered, this, &DeckEditorSettingsPage::actAdjustRateLimit); auto *urlToolBar = new QToolBar; @@ -120,12 +121,19 @@ DeckEditorSettingsPage::DeckEditorSettingsPage() lpSpoilerGrid->addWidget(&lastUpdatedLabel, 2, 0); lpSpoilerGrid->addWidget(updateNowButton, 2, 1); lpSpoilerGrid->addWidget(&infoOnSpoilersLabel, 3, 0, 1, 3, Qt::AlignTop); + infoOnSpoilersLabel.setWordWrap(true); // On a change to the checkbox, hide/un-hide the other fields connect(&mcDownloadSpoilersCheckBox, &QCheckBox::toggled, &SettingsCache::instance().downloads(), &DownloadSettings::setDownloadSpoilerStatus); connect(&mcDownloadSpoilersCheckBox, &QCheckBox::toggled, this, &DeckEditorSettingsPage::setSpoilersEnabled); + // The updater is application-owned and outlives this page, so a Settings page + // reopened mid-update must observe the operation that is still running. + if (auto *runningUpdater = SpoilerBackgroundUpdater::current()) { + observeSpoilerUpdater(runningUpdater); + } + mpGeneralGroupBox = new QGroupBox; mpGeneralGroupBox->setLayout(lpGeneralGrid); @@ -328,20 +336,60 @@ void DeckEditorSettingsPage::urlListChanged(const QModelIndex &, int, int, const void DeckEditorSettingsPage::updateSpoilers() { - // Disable the button so the user can only press it once at a time - updateNowButton->setDisabled(true); - updateNowButton->setText(tr("Updating...")); + if (spoilerUpdater) { + return; + } + // Application ownership lets either operation finish after settings closes. + // When disabled, the same button retries file removal and the local reload. + SpoilerBackgroundUpdater *sbu; + if (SettingsCache::instance().downloads().getDownloadSpoilersStatus()) { + sbu = new SpoilerBackgroundUpdater(QCoreApplication::instance(), true); + // Only an updater this page creates is retired by deleteLater: observation + // may attach to the stack-allocated launch updater, which must not be deleted. + connect(sbu, &SpoilerBackgroundUpdater::spoilerCheckerDone, sbu, &QObject::deleteLater); + } else { + // deleteSpoilerFile() wires its own deleteLater. + sbu = SpoilerBackgroundUpdater::deleteSpoilerFile(); + } + observeSpoilerUpdater(sbu); +} - // Create a new SBU that will act as if the client was just reloaded - auto *sbu = new SpoilerBackgroundUpdater(); - connect(sbu, &SpoilerBackgroundUpdater::spoilerCheckerDone, this, &DeckEditorSettingsPage::unlockSettings); - connect(sbu, &SpoilerBackgroundUpdater::spoilersUpdatedSuccessfully, this, &DeckEditorSettingsPage::unlockSettings); +void DeckEditorSettingsPage::observeSpoilerUpdater(SpoilerBackgroundUpdater *sbu) +{ + spoilerUpdater = sbu; + unlockSettings(); + connect(sbu, &SpoilerBackgroundUpdater::spoilerCheckerDone, this, [this, sbu] { + if (spoilerUpdater == sbu) { + spoilerUpdater.clear(); + unlockSettings(); + } + }); + connect(sbu, &SpoilerBackgroundUpdater::spoilerUpdateFailed, this, [this, sbu](const QString &message) { + if (spoilerUpdater != sbu) { + return; + } + auto *notice = new QMessageBox(QMessageBox::Warning, tr("Spoiler update"), message, QMessageBox::Ok, this); + notice->setTextFormat(Qt::PlainText); + notice->setAttribute(Qt::WA_DeleteOnClose); + notice->open(); + }); } void DeckEditorSettingsPage::unlockSettings() { - updateNowButton->setDisabled(false); - updateNowButton->setText(tr("Update Spoilers")); + updateNowButton->setEnabled(!spoilerUpdater); + updateNowButton->setText(spoilerUpdater ? tr("Updating...") + : SettingsCache::instance().downloads().getDownloadSpoilersStatus() + ? tr("Update Spoilers") + : tr("Reload Card Database")); + lastUpdatedLabel.setText(tr("Last Change") + ": " + getLastUpdateTime()); + infoOnSpoilersLabel.setText( + SettingsCache::instance().downloads().getDownloadSpoilersStatus() + ? tr("Spoilers download automatically on launch") + "\n" + + tr("Press the button to manually update without relaunching") + "\n\n" + + tr("Updates continue if you close settings") + : tr("Spoilers are off. Reload Card Database removes any downloaded spoiler file and reloads your " + "card data.")); } QString DeckEditorSettingsPage::getLastUpdateTime() @@ -379,12 +427,17 @@ void DeckEditorSettingsPage::setSpoilersEnabled(bool anInput) mpSpoilerSavePathLineEdit->setEnabled(anInput); mpSpoilerPathButton->setEnabled(anInput); lastUpdatedLabel.setEnabled(anInput); - updateNowButton->setEnabled(anInput); - infoOnSpoilersLabel.setEnabled(anInput); + // The hint explains the button in both states, especially the disabled-state + // Reload Card Database action, so keep it readable while spoilers are off. + infoOnSpoilersLabel.setEnabled(true); - if (!anInput) { - SpoilerBackgroundUpdater::deleteSpoilerFile(); + // Only react to the user disabling the setting. The constructor calls this to + // initialize the controls, and a leftover spoiler file is recovered through the + // Reload Card Database button rather than an unrequested reload on page open. + if (!anInput && sender() == &mcDownloadSpoilersCheckBox) { + observeSpoilerUpdater(SpoilerBackgroundUpdater::deleteSpoilerFile()); } + unlockSettings(); } void DeckEditorSettingsPage::retranslateUi() @@ -394,13 +447,10 @@ void DeckEditorSettingsPage::retranslateUi() mcDownloadSpoilersCheckBox.setText(tr("Download Spoilers Automatically")); mcSpoilerSaveLabel.setText(tr("Spoiler Location:")); lastUpdatedLabel.setText(tr("Last Change") + ": " + getLastUpdateTime()); - infoOnSpoilersLabel.setText(tr("Spoilers download automatically on launch") + "\n" + - tr("Press the button to manually update without relaunching") + "\n\n" + - tr("Do not close settings until manual update is complete")); picDownloadCheckBox.setText(tr("Download card pictures on the fly")); urlLinkLabel.setText(QString("%2").arg(WIKI_CUSTOM_PIC_URL).arg(tr("How to add a custom URL"))); resetDownloadURLs.setText(tr("Reset Download URLs")); - updateNowButton->setText(tr("Update Spoilers")); + unlockSettings(); aAdd->setText(tr("Add New URL")); aEdit->setText(tr("Edit URL")); aRemove->setText(tr("Remove URL")); diff --git a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.h b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.h index 47f840191..8e33e697d 100644 --- a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.h @@ -5,6 +5,7 @@ #include #include +#include #include #include #include @@ -15,6 +16,7 @@ class QLineEdit; class QListWidget; class QListWidgetItem; class QModelIndex; +class SpoilerBackgroundUpdater; class DeckEditorSettingsPage : public AbstractSettingsPage { @@ -54,6 +56,7 @@ private: QLabel infoOnSpoilersLabel; QPushButton *mpSpoilerPathButton; QPushButton *updateNowButton; + QPointer spoilerUpdater; /** @brief Adds a list item for the given URL, storing the raw URL alongside its displayed label. */ QListWidgetItem *addUrlItem(const QString &url); @@ -66,6 +69,8 @@ private: /** @brief Refreshes the displayed label of every URL item after limits or settings change. */ void refreshUrlItems(); + + void observeSpoilerUpdater(SpoilerBackgroundUpdater *updater); }; #endif // COCKATRICE_DECK_EDITOR_SETTINGS_PAGE_H diff --git a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp index 6128a7830..ad6651ffa 100644 --- a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp @@ -1,7 +1,7 @@ #include "messages_settings_page.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/widgets/utility/get_text_with_max.h" #include "libcockatrice/utility/macros.h" @@ -88,18 +88,21 @@ MessagesSettingsPage::MessagesSettingsPage() auto *chatGrid = new QGridLayout; chatGrid->addWidget(&chatMentionCheckBox, 0, 0); - chatGrid->addWidget(&invertMentionForeground, 0, 1); + chatGrid->addWidget(&mentionColorStringLabel, 0, 1, Qt::AlignRight); chatGrid->addWidget(mentionColor, 0, 2); - chatGrid->addWidget(&chatMentionCompleterCheckbox, 1, 0); - chatGrid->addWidget(&ignoreUnregUsersMainChat, 2, 0); - chatGrid->addWidget(&hexLabel, 1, 2); - chatGrid->addWidget(&ignoreUnregUserMessages, 3, 0); - chatGrid->addWidget(&ignoreNonBuddyUserMessages, 4, 0); - chatGrid->addWidget(&messagePopups, 5, 0); - chatGrid->addWidget(&mentionPopups, 6, 0); - chatGrid->addWidget(&roomHistory, 7, 0); - chatGrid->addWidget(&ignoreAllPrivateMessagesCheckBox, 8, 0); - chatGrid->addWidget(&useGameTimeCheckBox, 9, 0); + chatGrid->addWidget(&invertMentionForeground, 0, 3); + chatGrid->addWidget(&chatMentionCompleterCheckbox, 1, 0, 1, -1); + chatGrid->addWidget(&ignoreUnregUsersMainChat, 2, 0, 1, -1); + chatGrid->addWidget(&ignoreUnregUserMessages, 3, 0, 1, -1); + chatGrid->addWidget(&ignoreNonBuddyUserMessages, 4, 0, 1, -1); + chatGrid->addWidget(&messagePopups, 5, 0, 1, -1); + chatGrid->addWidget(&mentionPopups, 6, 0, 1, -1); + chatGrid->addWidget(&roomHistory, 7, 0, 1, -1); + chatGrid->addWidget(&ignoreAllPrivateMessagesCheckBox, 8, 0, 1, -1); + chatGrid->addWidget(&useGameTimeCheckBox, 9, 0, 1, -1); + // Expand the first column more than the following ones + chatGrid->setColumnStretch(0, 3); + chatGrid->setColumnStretch(1, 1); chatGroupBox = new QGroupBox; chatGroupBox->setLayout(chatGrid); @@ -109,11 +112,14 @@ MessagesSettingsPage::MessagesSettingsPage() connect(highlightColor, &QLineEdit::textChanged, this, &MessagesSettingsPage::updateHighlightColor); auto *highlightNotice = new QGridLayout; - highlightNotice->addWidget(highlightColor, 0, 2); - highlightNotice->addWidget(&invertHighlightForeground, 0, 1); - highlightNotice->addWidget(&hexHighlightLabel, 1, 2); highlightNotice->addWidget(customAlertString, 0, 0); - highlightNotice->addWidget(&customAlertStringLabel, 1, 0); + highlightNotice->addWidget(&highlightColorStringLabel, 0, 1, Qt::AlignRight); + highlightNotice->addWidget(highlightColor, 0, 2); + highlightNotice->addWidget(&invertHighlightForeground, 0, 3); + highlightNotice->addWidget(&customAlertStringLabel, 1, 0, 1, -1); + // Expand the first column more than the following ones + highlightNotice->setColumnStretch(0, 3); + highlightNotice->setColumnStretch(1, 1); customAlertStringLabel.setBuddy(customAlertString); highlightGroupBox = new QGroupBox; highlightGroupBox->setLayout(highlightNotice); @@ -131,10 +137,8 @@ MessagesSettingsPage::MessagesSettingsPage() auto *channelGrid = new QGridLayout; channelGrid->addWidget(&channelUnreadColorLabel, 0, 0); channelGrid->addWidget(channelUnreadColor, 0, 1); - channelGrid->addWidget(&hexChannelUnreadLabel, 0, 2); channelGrid->addWidget(&channelMentionColorLabel, 1, 0); channelGrid->addWidget(channelMentionColor, 1, 1); - channelGrid->addWidget(&hexChannelMentionLabel, 1, 2); channelGroupBox = new QGroupBox; channelGroupBox->setLayout(channelGrid); @@ -146,15 +150,15 @@ MessagesSettingsPage::MessagesSettingsPage() } aAdd = new QAction(this); - aAdd->setIcon(themePixmap(QStringLiteral("icons/increment"))); + aAdd->setIcon(loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive, IconStyle::Sticker)); connect(aAdd, &QAction::triggered, this, &MessagesSettingsPage::actAdd); aEdit = new QAction(this); - aEdit->setIcon(themePixmap(QStringLiteral("icons/pencil"))); + aEdit->setIcon(loadThemedIcon(QStringLiteral("icons/pencil"))); connect(aEdit, &QAction::triggered, this, &MessagesSettingsPage::actEdit); aRemove = new QAction(this); - aRemove->setIcon(themePixmap(QStringLiteral("icons/decrement"))); + aRemove->setIcon(loadThemedIcon(QStringLiteral("icons/decrement"), AppColor::Negative, IconStyle::Sticker)); connect(aRemove, &QAction::triggered, this, &MessagesSettingsPage::actRemove); auto *messageToolBar = new QToolBar; @@ -331,6 +335,8 @@ void MessagesSettingsPage::retranslateUi() channelGroupBox->setTitle(tr("Room channel tabs")); channelUnreadColorLabel.setText(tr("Unread messages color")); channelMentionColorLabel.setText(tr("Mentioned messages color")); + channelUnreadColor->setPlaceholderText(tr("Enter HEX code")); + channelMentionColor->setPlaceholderText(tr("Enter HEX code")); chatMentionCheckBox.setText(tr("Enable chat mentions")); chatMentionCompleterCheckbox.setText(tr("Enable mention completer")); messageGroupBox->setTitle(tr("In-game message macros")); @@ -341,15 +347,15 @@ void MessagesSettingsPage::retranslateUi() ignoreNonBuddyUserMessages.setText(tr("Ignore private messages sent by non-buddy users")); invertMentionForeground.setText(tr("Invert text color")); invertHighlightForeground.setText(tr("Invert text color")); + mentionColorStringLabel.setText(tr("Color")); + highlightColorStringLabel.setText(tr("Color")); + mentionColor->setPlaceholderText(tr("Enter HEX code")); + highlightColor->setPlaceholderText(tr("Enter HEX code")); messagePopups.setText(tr("Enable desktop notifications for private messages")); mentionPopups.setText(tr("Enable desktop notification for mentions")); roomHistory.setText(tr("Enable room message history on join")); ignoreAllPrivateMessagesCheckBox.setText(tr("Ignore all private messages")); useGameTimeCheckBox.setText(tr("Use game time instead of local time in game logs")); - hexLabel.setText(tr("(Color is hexadecimal)")); - hexHighlightLabel.setText(tr("(Color is hexadecimal)")); - hexChannelUnreadLabel.setText(tr("(Color is hexadecimal)")); - hexChannelMentionLabel.setText(tr("(Color is hexadecimal)")); customAlertStringLabel.setText(tr("Separate words with a space, alphanumeric characters only")); customAlertString->setPlaceholderText(tr("Word1 Word2 Word3")); aAdd->setText(tr("Add New Message")); diff --git a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h index 112ff4e29..06fabcef2 100644 --- a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h @@ -58,13 +58,11 @@ private: QLineEdit *channelUnreadColor; QLineEdit *channelMentionColor; QLineEdit *customAlertString; - QLabel hexLabel; - QLabel hexHighlightLabel; QLabel channelUnreadColorLabel; QLabel channelMentionColorLabel; - QLabel hexChannelUnreadLabel; - QLabel hexChannelMentionLabel; QLabel customAlertStringLabel; + QLabel mentionColorStringLabel; + QLabel highlightColorStringLabel; QLabel explainMessagesLabel; void storeSettings(); diff --git a/cockatrice/src/interface/widgets/settings_page/settings_search_delegate.cpp b/cockatrice/src/interface/widgets/settings_page/settings_search_delegate.cpp index bf35b7557..67354c9d7 100644 --- a/cockatrice/src/interface/widgets/settings_page/settings_search_delegate.cpp +++ b/cockatrice/src/interface/widgets/settings_page/settings_search_delegate.cpp @@ -5,6 +5,7 @@ */ #include "settings_search_delegate.h" +#include "../interface/theme_manager.h" #include "settings_search_model.h" #include @@ -88,7 +89,8 @@ void SettingsSearchDelegate::paint(QPainter *painter, QRect textRect = contentRect.adjusted(iconOffset, 0, 0, 0); // Breadcrumb: "Page > Group" - QFont breadcrumbFont = option.font; + // Family comes from the InterfaceDefault theme role; the -1 size and bold stay caller-side. + QFont breadcrumbFont = themeManager->appFont(AppFont::InterfaceDefault); breadcrumbFont.setPointSize(breadcrumbFont.pointSize() - 1); breadcrumbFont.setBold(true); @@ -113,7 +115,7 @@ void SettingsSearchDelegate::paint(QPainter *painter, yPos += 20; // Setting label - QFont labelFont = option.font; + QFont labelFont = themeManager->appFont(AppFont::InterfaceDefault); labelFont.setPointSize(labelFont.pointSize() + 1); labelFont.setBold(isSelected); diff --git a/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp index a77c0c2c3..83c2ec585 100644 --- a/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp @@ -3,7 +3,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcut_treeview.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/widgets/utility/custom_line_edit.h" #include "../interface/widgets/utility/sequence_edit.h" @@ -58,8 +58,8 @@ ShortcutSettingsPage::ShortcutSettingsPage() btnResetAll = new QPushButton(this); btnClearAll = new QPushButton(this); - btnResetAll->setIcon(themePixmap(QStringLiteral("icons/update"))); - btnClearAll->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); + btnResetAll->setIcon(loadThemedIcon(QStringLiteral("icons/reload"))); + btnClearAll->setIcon(loadThemedIcon(QStringLiteral("icons/clearsearch"))); // layout auto *_editLayout = new QGridLayout; diff --git a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp index 44ce01aba..b29c4d90c 100644 --- a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp @@ -28,6 +28,14 @@ enum visualDeckStoragePromptForConversionIndex visualDeckStoragePromptForConversionIndexAlways }; +// A corrupt or out-of-range stored trigger would otherwise select index -1 and leave +// the row half-hidden; clamp it back into the known trigger range. +static int clampHoverTrigger(int trigger) +{ + return qBound(static_cast(CardsDisplaySettings::HoverPanelTriggerHover), trigger, + static_cast(CardsDisplaySettings::HoverPanelTriggerOff)); +} + UserInterfaceSettingsPage::UserInterfaceSettingsPage() { // general settings @@ -154,6 +162,68 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() animationGroupBox = new QGroupBox; animationGroupBox->setLayout(animationGrid); + // hover panel settings + hoverZoomTriggerSelector.addItem(QString()); + hoverZoomTriggerSelector.addItem(QString()); + hoverZoomTriggerSelector.addItem(QString()); + hoverZoomTriggerSelector.addItem(QString()); + hoverZoomTriggerSelector.addItem(QString()); + hoverZoomTriggerSelector.setCurrentIndex( + clampHoverTrigger(SettingsCache::instance().cardsDisplay().getHoverZoomTrigger())); + connect(&hoverZoomTriggerSelector, &QComboBox::currentIndexChanged, &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setHoverZoomTrigger); + + hoverPanelTriggerSelector.addItem(QString()); + hoverPanelTriggerSelector.addItem(QString()); + hoverPanelTriggerSelector.addItem(QString()); + hoverPanelTriggerSelector.addItem(QString()); + hoverPanelTriggerSelector.addItem(QString()); + hoverPanelTriggerSelector.setCurrentIndex( + clampHoverTrigger(SettingsCache::instance().cardsDisplay().getHoverPanelTrigger())); + connect(&hoverPanelTriggerSelector, &QComboBox::currentIndexChanged, &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setHoverPanelTrigger); + + hoverPanelDoubleConfirmCheckBox.setChecked(SettingsCache::instance().cardsDisplay().getHoverPanelDoubleConfirm()); + connect(&hoverPanelDoubleConfirmCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setHoverPanelDoubleConfirm); + + hoverZoomDelayBox.setRange(0, 5000); + hoverZoomDelayBox.setSingleStep(50); + hoverZoomDelayBox.setSuffix(" ms"); + hoverZoomDelayBox.setValue(SettingsCache::instance().cardsDisplay().getHoverZoomDelayMs()); + connect(&hoverZoomDelayBox, qOverload(&QSpinBox::valueChanged), &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setHoverZoomDelayMs); + + hoverPanelDelayBox.setRange(0, 5000); + hoverPanelDelayBox.setSingleStep(50); + hoverPanelDelayBox.setSuffix(" ms"); + hoverPanelDelayBox.setValue(SettingsCache::instance().cardsDisplay().getHoverPanelDelayMs()); + connect(&hoverPanelDelayBox, qOverload(&QSpinBox::valueChanged), &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setHoverPanelDelayMs); + + auto *hoverPanelGrid = new QGridLayout; + hoverPanelGrid->addWidget(&hoverZoomTriggerLabel, 0, 0); + hoverPanelGrid->addWidget(&hoverZoomTriggerSelector, 0, 1); + hoverPanelGrid->addWidget(&hoverPanelTriggerLabel, 1, 0); + hoverPanelGrid->addWidget(&hoverPanelTriggerSelector, 1, 1); + hoverPanelGrid->addWidget(&hoverPanelDoubleConfirmCheckBox, 2, 0, 1, 2); + hoverPanelGrid->addWidget(&hoverZoomDelayLabel, 3, 0); + hoverPanelGrid->addWidget(&hoverZoomDelayBox, 3, 1); + hoverPanelGrid->addWidget(&hoverPanelDelayLabel, 4, 0); + hoverPanelGrid->addWidget(&hoverPanelDelayBox, 4, 1); + hoverWheelNoteLabel.setWordWrap(true); + hoverPanelGrid->addWidget(&hoverWheelNoteLabel, 5, 0, 1, 2); + connect(&hoverZoomTriggerSelector, &QComboBox::currentIndexChanged, this, + &UserInterfaceSettingsPage::updateHoverPanelUiState); + connect(&hoverPanelTriggerSelector, &QComboBox::currentIndexChanged, this, + &UserInterfaceSettingsPage::updateHoverPanelUiState); + connect(&hoverPanelDoubleConfirmCheckBox, &QCheckBox::QT_STATE_CHANGED, this, + &UserInterfaceSettingsPage::updateHoverPanelUiState); + updateHoverPanelUiState(); + + hoverPanelGroupBox = new QGroupBox; + hoverPanelGroupBox->setLayout(hoverPanelGrid); + // deck editor settings openDeckInNewTabCheckBox.setChecked(SettingsCache::instance().deckEditor().getOpenDeckInNewTab()); connect(&openDeckInNewTabCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().deckEditor(), @@ -293,6 +363,7 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() mainLayout->addWidget(generalGroupBox); mainLayout->addWidget(notificationsGroupBox); mainLayout->addWidget(animationGroupBox); + mainLayout->addWidget(hoverPanelGroupBox); mainLayout->addWidget(deckEditorGroupBox); mainLayout->addWidget(replayGroupBox); mainLayout->addStretch(); @@ -351,6 +422,33 @@ void UserInterfaceSettingsPage::updateCommanderSpellbookUiState() } } +void UserInterfaceSettingsPage::updateHoverPanelUiState() +{ + // Phase timers only apply to the hover trigger; double-confirm only applies when + // both phases share one trigger (repeating it). + const auto hoverTrigger = CardsDisplaySettings::HoverPanelTriggerHover; + const auto wheelTrigger = CardsDisplaySettings::HoverPanelTriggerWheel; + const auto offTrigger = CardsDisplaySettings::HoverPanelTriggerOff; + const bool zoomOnHover = hoverZoomTriggerSelector.currentIndex() == hoverTrigger; + const bool panelOnHover = hoverPanelTriggerSelector.currentIndex() == hoverTrigger; + const bool anyOff = + hoverZoomTriggerSelector.currentIndex() == offTrigger || hoverPanelTriggerSelector.currentIndex() == offTrigger; + const bool sharedTrigger = + !anyOff && hoverZoomTriggerSelector.currentIndex() == hoverPanelTriggerSelector.currentIndex(); + hoverZoomDelayLabel.setVisible(zoomOnHover); + hoverZoomDelayBox.setVisible(zoomOnHover); + hoverPanelDelayLabel.setVisible(panelOnHover); + hoverPanelDelayBox.setVisible(panelOnHover); + hoverPanelDoubleConfirmCheckBox.setEnabled(sharedTrigger); + const bool panelAuto = panelOnHover && !(sharedTrigger && hoverPanelDoubleConfirmCheckBox.isChecked()); + hoverPanelDelayLabel.setEnabled(panelAuto); + hoverPanelDelayBox.setEnabled(panelAuto); + // The wheel trigger consumes wheel events over cards, so the board cannot scroll there. + const bool wheelBound = hoverZoomTriggerSelector.currentIndex() == wheelTrigger || + hoverPanelTriggerSelector.currentIndex() == wheelTrigger; + hoverWheelNoteLabel.setVisible(wheelBound); +} + void UserInterfaceSettingsPage::retranslateUi() { generalGroupBox->setTitle(tr("General interface settings")); @@ -380,6 +478,22 @@ void UserInterfaceSettingsPage::retranslateUi() lifeCounterAnimationsCheckBox.setText(tr("Life counter flash")); battlefieldFlashCheckBox.setText(tr("Battlefield flash on damage")); + hoverPanelGroupBox->setTitle(tr("Hover panel settings")); + hoverPanelTriggerLabel.setText(tr("Show the card panel on:")); + hoverZoomTriggerLabel.setText(tr("Zoom the card on:")); + for (auto *selector : {&hoverPanelTriggerSelector, &hoverZoomTriggerSelector}) { + selector->setItemText(CardsDisplaySettings::HoverPanelTriggerHover, tr("Hover")); + selector->setItemText(CardsDisplaySettings::HoverPanelTriggerRightClick, tr("Right-click")); + selector->setItemText(CardsDisplaySettings::HoverPanelTriggerWheel, tr("Mouse wheel")); + selector->setItemText(CardsDisplaySettings::HoverPanelTriggerShortcut, tr("Keyboard shortcut")); + selector->setItemText(CardsDisplaySettings::HoverPanelTriggerOff, tr("Off")); + } + hoverPanelDoubleConfirmCheckBox.setText(tr("Zoom first; repeat the action to show the panel")); + hoverZoomDelayLabel.setText(tr("Time before the zoom kicks in:")); + hoverPanelDelayLabel.setText(tr("Time before the tab panel shows up:")); + hoverWheelNoteLabel.setText( + tr("Note: the mouse wheel action replaces board scrolling over cards; scroll up to zoom back out.")); + deckEditorGroupBox->setTitle(tr("Deck editor/storage settings")); openDeckInNewTabCheckBox.setText(tr("Open deck in new tab by default")); visualDeckEditorShowCardCountsCheckBox.setText(tr("Show card counts in Visual Deck Editor")); diff --git a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h index 297d80c35..6b60c58bf 100644 --- a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h @@ -22,6 +22,7 @@ private slots: void enableAllAnimations(); void disableAllAnimations(); void updateCommanderSpellbookUiState(); + void updateHoverPanelUiState(); private: QCheckBox doubleClickToPlayCheckBox; @@ -47,6 +48,17 @@ private: QCheckBox lifeCounterAnimationsCheckBox; QCheckBox battlefieldFlashCheckBox; + QLabel hoverPanelTriggerLabel; + QComboBox hoverPanelTriggerSelector; + QLabel hoverZoomTriggerLabel; + QComboBox hoverZoomTriggerSelector; + QCheckBox hoverPanelDoubleConfirmCheckBox; + QLabel hoverZoomDelayLabel; + QSpinBox hoverZoomDelayBox; + QLabel hoverPanelDelayLabel; + QSpinBox hoverPanelDelayBox; + QLabel hoverWheelNoteLabel; + QCheckBox openDeckInNewTabCheckBox; QLabel visualDeckStoragePromptForConversionLabel; QComboBox visualDeckStoragePromptForConversionSelector; @@ -69,6 +81,7 @@ private: QGroupBox *generalGroupBox; QGroupBox *notificationsGroupBox; QGroupBox *animationGroupBox; + QGroupBox *hoverPanelGroupBox; QGroupBox *deckEditorGroupBox; QGroupBox *replayGroupBox; diff --git a/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp new file mode 100644 index 000000000..9fb6a7ffe --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.cpp @@ -0,0 +1,109 @@ +#include "alt_table_delegate.h" + +#include "../utility/pill_painter.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr int ICON_TEXT_GAP = 4; +} // namespace + +AltTableDelegate::AltTableDelegate(int viaColumn, + int warnsColumn, + int bansColumn, + int sharedColumn, + int activeColumn, + QObject *parent) + : QStyledItemDelegate(parent), viaColumn(viaColumn), warnsColumn(warnsColumn), bansColumn(bansColumn), + sharedColumn(sharedColumn), activeColumn(activeColumn) +{ +} + +void AltTableDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + const bool selected = (option.state & QStyle::State_Selected) != 0; + + QStyleOptionViewItem opt(option); + initStyleOption(&opt, index); + painter->setFont(opt.font); + QStyle *style = opt.widget != nullptr ? opt.widget->style() : QApplication::style(); + const QString text = index.data(Qt::DisplayRole).toString(); + const QPalette::ColorRole emphasisRole = selected ? QPalette::HighlightedText : QPalette::WindowText; + + if (index.column() == viaColumn) { + bool sharedOk = false; + const unsigned shared = index.sibling(index.row(), sharedColumn).data(Qt::DisplayRole).toUInt(&sharedOk); + const bool ipMatch = text.startsWith(QStringLiteral("ip:")); + // shared_count for an IP match already includes the investigated account, + // so anything above 2 means the address is shared beyond that pair. + const bool dimmed = sharedOk && ipMatch && shared > 2; + const QPalette::ColorRole textRole = (dimmed && !selected) ? QPalette::PlaceholderText : emphasisRole; + + QPalette::ColorRole tintRole = QPalette::NoRole; + if (text == QStringLiteral("email")) { + tintRole = QPalette::Link; + } else if (text.startsWith(QStringLiteral("clientid:"))) { + tintRole = QPalette::Highlight; + } else if (ipMatch) { + tintRole = QPalette::PlaceholderText; + } + if (tintRole != QPalette::NoRole) { + if (!pill_painter::paint(painter, opt, tintRole, textRole, text)) { + QStyledItemDelegate::paint(painter, opt, index); + } + return; + } + QStyledItemDelegate::paint(painter, opt, index); + return; + } + + if (index.column() == warnsColumn || index.column() == bansColumn) { + bool countOk = false; + const int count = text.toInt(&countOk); + if (countOk && count > 0 && !selected) { + const QIcon icon = style->standardIcon(index.column() == bansColumn ? QStyle::SP_MessageBoxCritical + : QStyle::SP_MessageBoxWarning); + const int iconSize = qMax(0, opt.rect.height() - 2 * ICON_TEXT_GAP); + QRect textRect = opt.rect; + if (!icon.isNull() && iconSize > 0) { + const QRect iconRect(opt.rect.left() + ICON_TEXT_GAP, opt.rect.top() + ICON_TEXT_GAP, iconSize, + iconSize); + icon.paint(painter, iconRect); + textRect.setLeft(iconRect.right() + ICON_TEXT_GAP); + } + const QString elided = opt.fontMetrics.elidedText(text, opt.textElideMode, textRect.width()); + style->drawItemText(painter, textRect, Qt::AlignLeft | Qt::AlignVCenter, opt.palette, true, elided, + emphasisRole); + return; + } + } + + if (index.column() == activeColumn) { + const bool active = index.data(Qt::UserRole).toBool(); + const QPalette::ColorRole tintRole = active ? QPalette::Highlight : QPalette::PlaceholderText; + if (!pill_painter::paint(painter, opt, tintRole, emphasisRole, text)) { + QStyledItemDelegate::paint(painter, opt, index); + } + return; + } + + QStyledItemDelegate::paint(painter, opt, index); +} + +QSize AltTableDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + const QSize hint = QStyledItemDelegate::sizeHint(option, index); + if (index.column() == viaColumn || index.column() == activeColumn) { + return hint + QSize(pill_painter::PAD_X + 2 * ICON_TEXT_GAP, pill_painter::PAD_Y); + } + if (index.column() == warnsColumn || index.column() == bansColumn) { + return hint + QSize(2 * ICON_TEXT_GAP, 0); + } + return hint; +} diff --git a/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h new file mode 100644 index 000000000..6cd5c7fad --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/alt_table_delegate.h @@ -0,0 +1,37 @@ +#ifndef ALT_TABLE_DELEGATE_H +#define ALT_TABLE_DELEGATE_H + +#include +#include + +/** + * Badge + icon painting for the User Alts table. The match-evidence column + * renders as a palette-tinted pill per match type (email / client id / IP), and + * an IP shared by more than one other account is dimmed. Warn/ban counts gain + * the style's warning/critical icons when nonzero. The active column renders as + * a palette-tinted pill (Highlight when active, PlaceholderText when inactive, + * from Qt::UserRole). Selected rows and unknown content fall back to default + * painting. + */ +class AltTableDelegate : public QStyledItemDelegate +{ + Q_OBJECT +public: + explicit AltTableDelegate(int viaColumn, + int warnsColumn, + int bansColumn, + int sharedColumn, + int activeColumn, + QObject *parent = nullptr); + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; + [[nodiscard]] QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; + +private: + int viaColumn; + int warnsColumn; + int bansColumn; + int sharedColumn; + int activeColumn; +}; + +#endif // ALT_TABLE_DELEGATE_H diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.cpp index 97383534f..3720aeb55 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.cpp @@ -1,5 +1,6 @@ #include "archidekt_deck_preview_image_display_widget.h" +#include "../../../../../theme_manager.h" #include "../../../../general/display/shadow_background_label.h" #include @@ -17,13 +18,9 @@ ArchidektDeckPreviewImageDisplayWidget::ArchidektDeckPreviewImageDisplayWidget(Q bottomLeftLabel = new ShadowBackgroundLabel(this, ""); bottomRightLabel = new ShadowBackgroundLabel(this, ""); - QFont f; - f.setBold(true); - f.setPointSize(16); - topLeftLabel->setFont(f); - topRightLabel->setFont(f); - bottomLeftLabel->setFont(f); - bottomRightLabel->setFont(f); + applyCardTextFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, + &ArchidektDeckPreviewImageDisplayWidget::applyCardTextFont); // Raise so labels appear above image topLeftLabel->raise(); @@ -32,6 +29,18 @@ ArchidektDeckPreviewImageDisplayWidget::ArchidektDeckPreviewImageDisplayWidget(Q bottomRightLabel->raise(); } +void ArchidektDeckPreviewImageDisplayWidget::applyCardTextFont() +{ + // Family comes from the CardText theme role; size and weight stay caller-side as before. + QFont f = themeManager->appFont(AppFont::CardText); + f.setBold(true); + f.setPointSize(16); + topLeftLabel->setFont(f); + topRightLabel->setFont(f); + bottomLeftLabel->setFont(f); + bottomRightLabel->setFont(f); +} + void ArchidektDeckPreviewImageDisplayWidget::setAspectRatio(float ratio) { aspectRatio = ratio; diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.h b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.h index 991f27b6a..41585e851 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.h @@ -60,6 +60,9 @@ protected: */ void resizeEvent(QResizeEvent *event) override; +private slots: + void applyCardTextFont(); + private: float aspectRatio = 1.0f; ///< Aspect ratio to maintain for the preview image }; diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp index 7f7a2d3fd..ae3a9baef 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp @@ -1,7 +1,7 @@ #include "tab_archidekt.h" #include "../../../../../client/settings/cache_settings.h" -#include "../../../../pixel_map_generator.h" +#include "../../../../theme_icon.h" #include "../../../cards/additional_info/mana_symbol_widget.h" #include "../../../cards/card_size_widget.h" #include "../../../quick_settings/settings_button_widget.h" @@ -25,6 +25,7 @@ #include #include #include +#include #include #include #include @@ -60,8 +61,6 @@ TabArchidekt::TabArchidekt(TabSupervisor *_tabSupervisor) { // Initialize network networkManager = NetworkManagerFactory::create(this); - networkManager->setRedirectPolicy(QNetworkRequest::ManualRedirectPolicy); - connect(networkManager, &QNetworkAccessManager::finished, this, &TabArchidekt::processApiJson); // Initialize debounce timer searchDebounceTimer = new QTimer(this); @@ -77,6 +76,48 @@ TabArchidekt::TabArchidekt(TabSupervisor *_tabSupervisor) getTopDecks(); } +TabArchidekt::~TabArchidekt() +{ + cancelRequest(); +} + +void TabArchidekt::cancelRequest() +{ + const QPointer previousReply = activeReply; + activeReply = nullptr; + isLoadingMore = false; + if (previousReply) { + disconnect(previousReply, nullptr, this, nullptr); + previousReply->abort(); + previousReply->deleteLater(); + } +} + +void TabArchidekt::startRequest(QUrl url, RequestKind kind, int page) +{ + cancelRequest(); + requestUrl = url; + requestKind = kind; + requestedPage = page; + isLoadingMore = true; + retryButton->hide(); + requestStatusLabel->setText(kind == RequestKind::Deck ? tr("Loading deck…") : tr("Loading decks…")); + requestStatusLabel->show(); + + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); + auto *reply = networkManager->get(request); + activeReply = reply; + connect(reply, &QNetworkReply::finished, this, [this, reply]() { processApiJson(reply); }); +} + +void TabArchidekt::showRequestError(const QString &error) +{ + requestStatusLabel->setText(tr("Unable to load decks: %1").arg(error)); + requestStatusLabel->show(); + retryButton->show(); +} + void TabArchidekt::initializeUi() { // Main container @@ -194,6 +235,18 @@ void TabArchidekt::initializeUi() mainLayout->addWidget(primaryToolbar); mainLayout->addWidget(secondaryToolbar); + + auto *requestStatusRow = new QHBoxLayout; + requestStatusLabel = new QLabel(container); + requestStatusLabel->setTextFormat(Qt::PlainText); + requestStatusLabel->setWordWrap(true); + retryButton = new QPushButton(tr("Retry"), container); + retryButton->hide(); + connect(retryButton, &QPushButton::clicked, this, + [this]() { startRequest(requestUrl, requestKind, requestedPage); }); + requestStatusRow->addWidget(requestStatusLabel, 1); + requestStatusRow->addWidget(retryButton); + mainLayout->addLayout(requestStatusRow); mainLayout->addWidget(scrollArea); setCentralWidget(container); @@ -229,7 +282,7 @@ void TabArchidekt::setupFilterWidgets() // Format filter (collapsible) formatButton = new SettingsButtonWidget(secondaryToolbar); formatButton->setButtonText(tr("Formats")); - formatButton->setButtonIcon(themePixmap(QStringLiteral("icons/scale_balanced"))); + formatButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scales"))); QWidget *formatContainer = new QWidget(secondaryToolbar); QGridLayout *formatLayout = new QGridLayout(formatContainer); @@ -404,6 +457,7 @@ void TabArchidekt::retranslateUi() minDeckSizeLogicCombo->setItemText(0, tr("Exact")); searchButton->setText(tr("Search")); + retryButton->setText(tr("Retry")); settingsButton->setToolTip(tr("Display Settings")); } @@ -517,82 +571,95 @@ QString TabArchidekt::buildSearchUrl() void TabArchidekt::doSearch() { - // Reset to first page on new search - currentPage = 1; - // We're searching, so we'll be in list mode - isListMode = true; // Don't debounce - only called by explicit user actions now doSearchImmediate(); } void TabArchidekt::doSearchImmediate() { - QString url = buildSearchUrl(); - QNetworkRequest req{QUrl(url)}; - req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); - networkManager->get(req); + searchDebounceTimer->stop(); + currentPage = 1; + startRequest(QUrl(buildSearchUrl()), RequestKind::Search); } void TabArchidekt::loadNextPage() { - if (isLoadingMore) { + if (activeReply || !isListMode || !listingsWidget || nextPageUrl.isEmpty()) { return; } - isLoadingMore = true; - currentPage++; - - QString url = buildSearchUrl(); - QNetworkRequest req{QUrl(url)}; - req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); - networkManager->get(req); + // Continue the accepted search, even if the user has edited filters without submitting them. + // Advance currentPage only after this response has been accepted, so failures can retry it. + startRequest(nextPageUrl, RequestKind::NextPage, currentPage + 1); } void TabArchidekt::actNavigatePage(QString url) { - QNetworkRequest request{QUrl(url)}; - request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); - networkManager->get(request); + searchDebounceTimer->stop(); + startRequest(QUrl(url), RequestKind::Deck); } void TabArchidekt::getTopDecks() { - currentPage = 1; - QNetworkRequest request{QUrl(buildSearchUrl())}; - request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); - networkManager->get(request); + doSearchImmediate(); } void TabArchidekt::processApiJson(QNetworkReply *reply) { - if (reply->error() != QNetworkReply::NoError) { - isLoadingMore = false; + if (reply != activeReply) { reply->deleteLater(); return; } + activeReply = nullptr; + isLoadingMore = false; + reply->deleteLater(); + + if (reply->error() != QNetworkReply::NoError) { + showRequestError(reply->errorString()); + return; + } QByteArray responseData = reply->readAll(); - QJsonDocument jsonDoc = QJsonDocument::fromJson(responseData); + QJsonParseError parseError; + QJsonDocument jsonDoc = QJsonDocument::fromJson(responseData, &parseError); - if (!jsonDoc.isObject()) { - isLoadingMore = false; - reply->deleteLater(); + if (parseError.error != QJsonParseError::NoError || !jsonDoc.isObject()) { + showRequestError(tr("Invalid response from Archidekt.")); return; } QJsonObject jsonObj = jsonDoc.object(); - QString responseUrl = reply->url().toString(); - - if (responseUrl.startsWith("https://archidekt.com/api/decks/v3/")) { + if (requestKind != RequestKind::Deck) { + if (!jsonObj.value("results").isArray() || + (!jsonObj.value("next").isNull() && !jsonObj.value("next").isString())) { + showRequestError(tr("Invalid deck listing from Archidekt.")); + return; + } + for (const auto &value : jsonObj.value("results").toArray()) { + if (!value.isObject() || value.toObject().value("id").toInt() <= 0) { + showRequestError(tr("Invalid deck listing from Archidekt.")); + return; + } + } + currentPage = requestedPage; + const QString next = jsonObj.value("next").toString(); + nextPageUrl = next.isEmpty() ? QUrl() : reply->url().resolved(QUrl(next)); + // An empty or self-repeating page cannot make forward progress. + if (jsonObj.value("results").toArray().isEmpty() || nextPageUrl == reply->url()) { + nextPageUrl.clear(); + } + requestStatusLabel->setText( + currentPage == 1 && jsonObj.value("results").toArray().isEmpty() ? tr("No decks found.") : QString()); + requestStatusLabel->setVisible(!requestStatusLabel->text().isEmpty()); processTopDecksResponse(jsonObj); - } else if (responseUrl.startsWith("https://archidekt.com/api/decks/")) { - processDeckResponse(jsonObj); } else { - prettyPrintJson(jsonObj, 4); + if (jsonObj.value("id").toInt() <= 0 || !jsonObj.value("cards").isArray()) { + showRequestError(tr("Invalid deck from Archidekt.")); + return; + } + requestStatusLabel->hide(); + processDeckResponse(jsonObj); } - - isLoadingMore = false; - reply->deleteLater(); } void TabArchidekt::processTopDecksResponse(QJsonObject reply) @@ -600,8 +667,12 @@ void TabArchidekt::processTopDecksResponse(QJsonObject reply) ArchidektDeckListingApiResponse deckData; deckData.fromJson(reply); + // An accepted listing restores list mode; a failed deck navigation leaves the listing usable. + isListMode = true; + // New search → clear everything - if (currentPage == 1) { + if (requestKind == RequestKind::Search) { + listingsWidget = nullptr; QtUtils::clearLayoutRec(resultsLayout, QtUtils::DeletePolicy::Immediate); listingsWidget = new ArchidektApiResponseDeckListingsDisplayWidget(resultsContainer, deckData, cardSizeSlider); @@ -626,8 +697,10 @@ void TabArchidekt::processDeckResponse(QJsonObject reply) // We're in single deck mode - disable infinite scroll isListMode = false; + nextPageUrl.clear(); // Clear existing results for single deck view + listingsWidget = nullptr; QtUtils::clearLayoutRec(resultsLayout, QtUtils::DeletePolicy::Immediate); auto display = new ArchidektApiResponseDeckDisplayWidget(resultsContainer, deckData, cardSizeSlider); diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.h b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.h index c74380b63..81c9502df 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.h +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.h @@ -8,8 +8,10 @@ #include #include #include +#include #include #include +#include #include #include @@ -55,6 +57,7 @@ public: * connects signals and slots, and triggers an initial fetch of top decks. */ explicit TabArchidekt(TabSupervisor *_tabSupervisor); + ~TabArchidekt() override; /** * @brief Update all UI text to reflect the current language or translation @@ -190,6 +193,17 @@ protected: bool eventFilter(QObject *obj, QEvent *event) override; private: + enum class RequestKind + { + Search, + NextPage, + Deck + }; + + void startRequest(QUrl url, RequestKind kind, int page = 1); + void cancelRequest(); + void showRequestError(const QString &error); + /** * @brief Initialize the main UI layout and toolbars * @@ -236,7 +250,14 @@ private: int currentPage; ///< Current page number for infinite scroll bool isLoadingMore; ///< Flag to prevent multiple simultaneous page loads bool isListMode; - ArchidektApiResponseDeckListingsDisplayWidget *listingsWidget = nullptr; + QPointer activeReply; + RequestKind requestKind = RequestKind::Search; + QUrl requestUrl; + QUrl nextPageUrl; + int requestedPage = 1; + QPointer listingsWidget; + QLabel *requestStatusLabel; + QPushButton *retryButton; // --------------------------------------------------------------------- // Layout Containers diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.cpp index 8e0448faa..7b60ed23c 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.cpp @@ -26,6 +26,11 @@ quint64 CommanderBracketService::estimateBracket(const DeckList &deck, QObject * return CommanderSpellbookApiAccessor::instance().estimateBracket(deck, requester); } +void CommanderBracketService::cancelEstimate(quint64 requestId) +{ + CommanderSpellbookApiAccessor::instance().cancelEstimate(requestId); +} + void CommanderBracketService::onEstimateBracketFinished(CommanderSpellbookApiAccessor::RequestId id, QObject *requester, const EstimateBracketResult &result) diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.h index 22962cc95..00debce86 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.h +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.h @@ -30,6 +30,7 @@ public: static CommanderBracketService &instance(); quint64 estimateBracket(const DeckList &deck, QObject *requester); + void cancelEstimate(quint64 requestId); signals: void estimateFinished(quint64 requestId, QObject *requester, const CommanderBracketEstimate &estimate); diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp index 5279165af..a0e3f2491 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp @@ -1,7 +1,7 @@ #include "commander_bracket_widget.h" #include "../../../../../client/settings/cache_settings.h" -#include "../../../../pixel_map_generator.h" +#include "../../../../theme_icon.h" #include "commander_bracket_service.h" #include "libcockatrice/deck_list/deck_list.h" @@ -28,9 +28,18 @@ template class QSharedPointer; CommanderBracketWidget::CommanderBracketWidget(QWidget *parent) : QWidget(parent) { + autoEstimateTimer = new QTimer(this); + autoEstimateTimer->setSingleShot(true); + connect(autoEstimateTimer, &QTimer::timeout, this, [this]() { + if (SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationEnabled() == + commanderSpellbookIntegrationEnabledIndexAutomatic) { + requestBracketEstimate(); + } + }); bracketLabel = new QLabel(tr("Bracket:"), this); bracketValueLabel = new QLabel(this); + bracketValueLabel->setTextFormat(Qt::PlainText); bracketValueLabel->setText("-"); bracketValueLabel->setObjectName("bracketValueLabel"); @@ -40,7 +49,7 @@ CommanderBracketWidget::CommanderBracketWidget(QWidget *parent) : QWidget(parent bracketInfoButton->setEnabled(false); bracketRefreshButton = new QToolButton(this); - bracketRefreshButton->setIcon(themePixmap(QStringLiteral("icons/reload"))); + bracketRefreshButton->setIcon(loadThemedIcon(QStringLiteral("icons/reload"))); bracketRefreshButton->setAutoRaise(true); connect(bracketRefreshButton, &QToolButton::clicked, this, &CommanderBracketWidget::requestBracketEstimate); @@ -65,18 +74,38 @@ CommanderBracketWidget::CommanderBracketWidget(QWidget *parent) : QWidget(parent this, &CommanderBracketWidget::maybeAutoEstimateBracket); connect(&SettingsCache::instance().deckEditor(), &DeckEditorSettings::commanderSpellbookIntegrationUseOfficialBracketNamesChanged, this, - &CommanderBracketWidget::maybeAutoEstimateBracket); + &CommanderBracketWidget::displayEstimate); setVisible(false); } +CommanderBracketWidget::~CommanderBracketWidget() +{ + cancelEstimate(); +} + +void CommanderBracketWidget::cancelEstimate() +{ + autoEstimateTimer->stop(); + const quint64 canceledRequest = requestId; + requestId = 0; + CommanderBracketService::instance().cancelEstimate(canceledRequest); + bracketRefreshButton->setEnabled(true); + if (canceledRequest) { + bracketValueLabel->setText("-"); + bracketValueLabel->setToolTip({}); + } +} + void CommanderBracketWidget::setDeck(const QSharedPointer &_deck) { + cancelEstimate(); deck = _deck; - requestId = 0; // invalidate any in-flight estimate for the previous deck + hasEstimate = false; // Reset the displayed bracket bracketValueLabel->setText("-"); + bracketValueLabel->setToolTip({}); bracketInfoButton->setToolTip({}); bracketInfoButton->setEnabled(false); bracketRefreshButton->setEnabled(true); @@ -144,7 +173,7 @@ bool CommanderBracketWidget::promptCommanderSpellbookIntegration() dialog.exec(); // Persist naming choice (if not disabled) - if (clickedButton != disableBtn) { + if (clickedButton == enableBtn || clickedButton == automaticBtn) { bool useOfficial = namingCombo->currentIndex() == 1; SettingsCache::instance().deckEditor().setCommanderSpellbookIntegrationUseOfficialBracketNames(useOfficial); } @@ -176,9 +205,20 @@ void CommanderBracketWidget::updateBracketVisibility(bool visible) void CommanderBracketWidget::requestBracketEstimate() { + const int mode = SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationEnabled(); + if (!deck || deck->getGameFormat().compare("commander", Qt::CaseInsensitive) != 0 || requestId || + (mode != commanderSpellbookIntegrationEnabledIndexEnabled && + mode != commanderSpellbookIntegrationEnabledIndexAutomatic)) { + return; + } + + autoEstimateTimer->stop(); + hasEstimate = false; bracketRefreshButton->setEnabled(false); bracketInfoButton->setEnabled(false); + bracketInfoButton->setToolTip({}); bracketValueLabel->setText(tr("Calculating…")); + bracketValueLabel->setToolTip({}); requestId = CommanderBracketService::instance().estimateBracket(*deck, this); } @@ -191,22 +231,35 @@ void CommanderBracketWidget::onEstimateBracketFinished(quint64 id, return; } + requestId = 0; + hasEstimate = true; + lastEstimate = result; + displayEstimate(); + bracketRefreshButton->setEnabled(true); +} + +void CommanderBracketWidget::displayEstimate() +{ + if (!hasEstimate) { + bracketInfoButton->setToolTip(tr("Why this bracket?")); + return; + } + BracketExplainer explainer; - lastBracketExplanation = explainer.explain(result.rawResult); + lastBracketExplanation = explainer.explain(lastEstimate.rawResult); // Display bracket bracketValueLabel->setText( SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationUseOfficialBracketNames() - ? result.officialName - : result.displayName); - bracketRefreshButton->setEnabled(true); + ? lastEstimate.officialName + : lastEstimate.displayName); // Build tooltip QString tooltip; for (const auto §ion : lastBracketExplanation.sections) { - tooltip += "" + section.title + "
    "; + tooltip += "" + section.title.toHtmlEscaped() + "
    "; for (const auto &line : section.bulletPoints) { - tooltip += "• " + line + "
    "; + tooltip += "• " + line.toHtmlEscaped() + "
    "; } tooltip += "
    "; } @@ -215,13 +268,17 @@ void CommanderBracketWidget::onEstimateBracketFinished(quint64 id, bracketInfoButton->setEnabled(!tooltip.isEmpty()); } -void CommanderBracketWidget::onEstimateBracketError(quint64 id, QObject *requester, const QString & /*error*/) +void CommanderBracketWidget::onEstimateBracketError(quint64 id, QObject *requester, const QString &error) { if (requester != this || id != requestId) { return; } + requestId = 0; + hasEstimate = false; bracketValueLabel->setText("-"); + bracketValueLabel->setToolTip( + QStringLiteral("%1").arg(tr("Unable to estimate bracket: %1").arg(error).toHtmlEscaped())); bracketRefreshButton->setEnabled(true); bracketInfoButton->setToolTip({}); bracketInfoButton->setEnabled(false); @@ -229,6 +286,15 @@ void CommanderBracketWidget::onEstimateBracketError(quint64 id, QObject *request void CommanderBracketWidget::maybeAutoEstimateBracket() { + if (prompting) { + return; + } + autoEstimateTimer->stop(); + if (!deck) { + cancelEstimate(); + updateBracketVisibility(false); + return; + } const QString formatKey = deck->getGameFormat(); const bool isCommander = (formatKey.compare("commander", Qt::CaseInsensitive) == 0); @@ -236,17 +302,20 @@ void CommanderBracketWidget::maybeAutoEstimateBracket() int mode = SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationEnabled(); if (!isCommander || mode == commanderSpellbookIntegrationEnabledIndexDisabled) { + cancelEstimate(); updateBracketVisibility(false); return; } if (mode == commanderSpellbookIntegrationEnabledIndexUnprompted) { - if (prompting) { - return; - } + const auto promptedDeck = deck; prompting = true; const bool accepted = promptCommanderSpellbookIntegration(); prompting = false; + if (deck != promptedDeck || !deck || deck->getGameFormat().compare("commander", Qt::CaseInsensitive) != 0) { + maybeAutoEstimateBracket(); + return; + } if (!accepted) { updateBracketVisibility(false); return; @@ -261,17 +330,17 @@ void CommanderBracketWidget::maybeAutoEstimateBracket() } // Avoid firing if we already have a result or a request in flight - if (!bracketRefreshButton->isEnabled()) { + if (requestId || hasEstimate) { return; } // Defer to avoid races during init / model rebuild - QTimer::singleShot(0, this, &CommanderBracketWidget::requestBracketEstimate); + autoEstimateTimer->start(0); } void CommanderBracketWidget::retranslateUi() { bracketLabel->setText(tr("Bracket:")); - bracketInfoButton->setToolTip(tr("Why this bracket?")); + displayEstimate(); bracketRefreshButton->setToolTip(tr("Recalculate bracket")); } diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.h index f5b2b7958..0ef1eedee 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.h @@ -1,6 +1,7 @@ #ifndef COCKATRICE_COMMANDER_BRACKET_WIDGET_H #define COCKATRICE_COMMANDER_BRACKET_WIDGET_H +#include "commander_bracket_service.h" #include "commander_spellbook_bracket_explainer.h" #include @@ -11,6 +12,7 @@ class QLabel; class QToolButton; +class QTimer; struct CommanderBracketEstimate; class DeckList; class QObject; @@ -22,6 +24,7 @@ class CommanderBracketWidget : public QWidget public: explicit CommanderBracketWidget(QWidget *parent = nullptr); + ~CommanderBracketWidget() override; void setDeck(const QSharedPointer &_deck); void retranslateUi(); @@ -35,10 +38,15 @@ private slots: private: bool promptCommanderSpellbookIntegration(); void updateBracketVisibility(bool visible); + void cancelEstimate(); + void displayEstimate(); QSharedPointer deck; bool prompting = false; quint64 requestId = 0; + QTimer *autoEstimateTimer; + bool hasEstimate = false; + CommanderBracketEstimate lastEstimate; QLabel *bracketLabel; QLabel *bracketValueLabel; diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.cpp index bd8d9379d..8456e2e21 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.cpp @@ -9,9 +9,10 @@ #include #include #include +#include #include #include -#include +#include #include class DeckList; @@ -24,13 +25,18 @@ CommanderSpellbookApiAccessor &CommanderSpellbookApiAccessor::instance() return instance; } -CommanderSpellbookApiAccessor::CommanderSpellbookApiAccessor(QObject *parent) : QObject(parent) +CommanderSpellbookApiAccessor::CommanderSpellbookApiAccessor(QObject *parent) + : QObject(parent), network(NetworkManagerFactory::create(this)) { } CommanderSpellbookApiAccessor::RequestId CommanderSpellbookApiAccessor::estimateBracket(const DeckList &deck, QObject *requester) { + if (!requester) { + return 0; + } + CommanderSpellbookDeckRequest deckRequest = CommanderSpellbookDeckRequest::fromDeckList(deck); QJsonDocument doc(deckRequest.toJson()); @@ -40,30 +46,39 @@ CommanderSpellbookApiAccessor::RequestId CommanderSpellbookApiAccessor::estimate req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); - QNetworkReply *reply = network.post(req, body); + QNetworkReply *reply = network->post(req, body); const RequestId id = nextRequestId++; - reply->setProperty("requestId", QVariant::fromValue(id)); - reply->setProperty("requester", QVariant::fromValue(requester)); - - connect(reply, &QNetworkReply::finished, this, [this, reply]() { onEstimateReplyFinished(reply); }); + pendingRequests.insert(id, reply); + const QPointer guardedRequester(requester); + connect(requester, &QObject::destroyed, reply, [this, id]() { cancelEstimate(id); }); + connect(reply, &QNetworkReply::finished, this, [this, reply, id, guardedRequester]() { + if (pendingRequests.take(id) != reply) { + return; + } + reply->deleteLater(); + if (guardedRequester) { + onEstimateReplyFinished(reply, id, guardedRequester); + } + }); return id; } -void CommanderSpellbookApiAccessor::onEstimateReplyFinished(QNetworkReply *reply) +void CommanderSpellbookApiAccessor::cancelEstimate(RequestId id) { - reply->deleteLater(); - - const RequestId id = reply->property("requestId").toULongLong(); - QObject *requester = reply->property("requester").value(); - - if (!requester) { - // Requester died — silently drop + const QPointer reply = pendingRequests.take(id); + if (!reply) { return; } + disconnect(reply, nullptr, this, nullptr); + reply->abort(); + reply->deleteLater(); +} +void CommanderSpellbookApiAccessor::onEstimateReplyFinished(QNetworkReply *reply, RequestId id, QObject *requester) +{ if (reply->error() != QNetworkReply::NoError) { emit estimateBracketError(id, requester, reply->errorString()); return; @@ -78,6 +93,10 @@ void CommanderSpellbookApiAccessor::onEstimateReplyFinished(QNetworkReply *reply } EstimateBracketResult result = EstimateBracketResult::fromJson(doc.object()); + if (result.bracketTag.trimmed().isEmpty()) { + emit estimateBracketError(id, requester, QStringLiteral("Missing bracket in response")); + return; + } emit estimateBracketFinished(id, requester, result); } diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.h index 98e7e3710..a1b2936bb 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.h +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.h @@ -1,14 +1,16 @@ #ifndef COCKATRICE_COMMANDER_SPELLBOOK_API_ACCESSOR_H #define COCKATRICE_COMMANDER_SPELLBOOK_API_ACCESSOR_H -#include +#include #include +#include #include #include #include class DeckList; class QNetworkReply; +class QNetworkAccessManager; struct EstimateBracketResult; class CommanderSpellbookApiAccessor final : public QObject @@ -21,6 +23,7 @@ public: using RequestId = quint64; RequestId estimateBracket(const DeckList &deck, QObject *requester); + void cancelEstimate(RequestId id); signals: void estimateBracketFinished(RequestId id, QObject *requester, const EstimateBracketResult &result); @@ -31,9 +34,10 @@ private: explicit CommanderSpellbookApiAccessor(QObject *parent = nullptr); Q_DISABLE_COPY_MOVE(CommanderSpellbookApiAccessor) - void onEstimateReplyFinished(QNetworkReply *reply); + void onEstimateReplyFinished(QNetworkReply *reply, RequestId id, QObject *requester); - QNetworkAccessManager network; + QNetworkAccessManager *network; + QHash> pendingRequests; RequestId nextRequestId = 1; }; diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp index 407f53e99..be490ce37 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp @@ -3,11 +3,14 @@ #include "../../../../../client/settings/cache_settings.h" #include +#include #include +#include #include #include #include #include +#include #include #include #include @@ -22,6 +25,14 @@ HandleCommanderBrackets::HandleCommanderBrackets(QObject *parent) { } +HandleCommanderBrackets::~HandleCommanderBrackets() +{ + if (reply) { + disconnect(reply, nullptr, this, nullptr); + reply->abort(); + } +} + void HandleCommanderBrackets::downloadBracketDefinitions() { if (reply) { @@ -35,31 +46,49 @@ void HandleCommanderBrackets::downloadBracketDefinitions() void HandleCommanderBrackets::actFinishParsingDownloadedData() { - reply = qobject_cast(sender()); + auto *finishedReply = qobject_cast(sender()); + if (!finishedReply || finishedReply != reply) { + return; + } - if (reply->error() != QNetworkReply::NoError) { - emit sigBracketDefinitionsDownloadFailed(reply->error()); + // Clear the active request before notifying listeners, which may immediately retry. + reply = nullptr; + finishedReply->deleteLater(); - reply->deleteLater(); + if (finishedReply->error() != QNetworkReply::NoError) { + emit sigBracketDefinitionsDownloadFailed(finishedReply->error()); return; } QJsonParseError parseError; - auto document = QJsonDocument::fromJson(reply->readAll(), &parseError); + auto document = QJsonDocument::fromJson(finishedReply->readAll(), &parseError); - if (parseError.error != QJsonParseError::NoError) { + if (parseError.error != QJsonParseError::NoError || !document.isObject() || + !document.object().value("brackets").isArray() || document.object().value("brackets").toArray().isEmpty()) { emit sigBracketDefinitionsDownloadFailed(QNetworkReply::UnknownContentError); - - reply->deleteLater(); return; } + // Validate the complete replacement before touching the last usable definitions. + // Tags are QSettings group names, so path separators must not create nested groups. + QSet tags; + for (const auto &value : document.object().value("brackets").toArray()) { + const auto definition = value.toObject(); + const auto tag = definition.value("tag").toString(); + if (!value.isObject() || tag.trimmed().isEmpty() || tag.contains('/') || tag.contains('\\') || + tags.contains(tag) || definition.value("officialName").toString().trimmed().isEmpty() || + definition.value("displayName").toString().trimmed().isEmpty() || + !definition.value("explanation").isString()) { + emit sigBracketDefinitionsDownloadFailed(QNetworkReply::UnknownContentError); + return; + } + tags.insert(tag); + } + updateBracketDefinitions(document.toVariant().toMap()); emit sigBracketDefinitionsDownloaded(); - - reply->deleteLater(); } void HandleCommanderBrackets::updateBracketDefinitions(const QVariantMap &jsonMap) diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.h index 39bddba05..99b15832c 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.h +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.h @@ -3,6 +3,7 @@ #include #include +#include #include #include @@ -14,6 +15,7 @@ class HandleCommanderBrackets : public QObject public: explicit HandleCommanderBrackets(QObject *parent = nullptr); + ~HandleCommanderBrackets() override; void downloadBracketDefinitions(); @@ -28,7 +30,7 @@ private: void updateBracketDefinitions(const QVariantMap &jsonMap); QNetworkAccessManager *nam; - QNetworkReply *reply; + QPointer reply; }; #endif // COCKATRICE_HANDLE_COMMANDER_BRACKETS_H diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp index b2ddace01..2b76de973 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp @@ -1,5 +1,6 @@ #include "edhrec_api_response_card_details_display_widget.h" +#include "../../../../../../theme_manager.h" #include "../../../../../cards/card_info_picture_widget.h" #include "../../../../../cards/card_size_widget.h" #include "../../../../../general/display/background_plate_widget.h" @@ -9,6 +10,7 @@ #include "edhrec_api_response_card_synergy_display_widget.h" #include "libcockatrice/card/database/card_database_querier.h" +#include #include #include #include @@ -32,7 +34,9 @@ EdhrecApiResponseCardDetailsDisplayWidget::EdhrecApiResponseCardDetailsDisplayWi nameLabel = new QLabel(this); nameLabel->setText(toDisplay.name); nameLabel->setAlignment(Qt::AlignHCenter); - nameLabel->setStyleSheet("font-size: 20px; font-weight: bold"); + applyCardTextFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, + &EdhrecApiResponseCardDetailsDisplayWidget::applyCardTextFont); inclusionDisplayWidget = new EdhrecApiResponseCardInclusionDisplayWidget(this, toDisplay); @@ -86,3 +90,14 @@ void EdhrecApiResponseCardDetailsDisplayWidget::actRequestPageNavigation() { emit requestUrl(toDisplay.url); } + +void EdhrecApiResponseCardDetailsDisplayWidget::applyCardTextFont() +{ + // Family comes from the CardText theme role; the 20px size and bold + // weight previously carried by the stylesheet stay caller-side. Pixel + // size, not point size: the stylesheet said 20px, and 20pt is ~27px. + QFont cardFont = themeManager->appFont(AppFont::CardText); + cardFont.setPixelSize(20); + cardFont.setWeight(QFont::Bold); + nameLabel->setFont(cardFont); +} diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.h index d738f4685..8a3c8fba4 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.h @@ -30,6 +30,9 @@ public slots: signals: void requestUrl(QString url); +private slots: + void applyCardTextFont(); + private: EdhrecApiResponseCardDetails toDisplay; QVBoxLayout *layout; diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.cpp index 2ff22f2b3..df0319f88 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.cpp @@ -1,5 +1,6 @@ #include "edhrec_api_response_commander_details_display_widget.h" +#include "../../../../../../theme_manager.h" #include "../../../../../cards/card_info_picture_widget.h" #include "../../../../../cards/card_size_widget.h" #include "../../api_response/cards/edhrec_commander_api_response_commander_details.h" @@ -8,6 +9,7 @@ #include "edhrec_commander_api_response_navigation_widget.h" #include "libcockatrice/card/database/card_database_querier.h" +#include #include #include #include @@ -48,15 +50,16 @@ EdhrecCommanderResponseCommanderDetailsDisplayWidget::EdhrecCommanderResponseCom commanderName = new QLabel(this); commanderName->setText(commanderDetails.getName()); commanderName->setAlignment(Qt::AlignCenter); - commanderName->setStyleSheet("font-size: 28px; font-weight: bold"); label = new QLabel(this); label->setAlignment(Qt::AlignCenter); - label->setStyleSheet("font-size: 16px"); salt = new QLabel(this); salt->setAlignment(Qt::AlignCenter); - salt->setStyleSheet("font-size: 16px"); + + applyCardTextFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, + &EdhrecCommanderResponseCommanderDetailsDisplayWidget::applyCardTextFont); cardPricesDisplayWidget = new EdhrecApiResponseCardPricesDisplayWidget(this, commanderDetails.getPrices()); @@ -83,3 +86,19 @@ void EdhrecCommanderResponseCommanderDetailsDisplayWidget::retranslateUi() label->setText(commanderDetails.getLabel()); salt->setText(tr("Salt: ") + QString::number(commanderDetails.getSalt())); } + +void EdhrecCommanderResponseCommanderDetailsDisplayWidget::applyCardTextFont() +{ + // Family comes from the CardText theme role; the sizes and the bold + // weight previously carried by the stylesheets stay caller-side. Pixel + // sizes, not point sizes: the stylesheets said 28px and 16px. + QFont nameFont = themeManager->appFont(AppFont::CardText); + nameFont.setPixelSize(28); + nameFont.setWeight(QFont::Bold); + commanderName->setFont(nameFont); + + QFont detailsFont = themeManager->appFont(AppFont::CardText); + detailsFont.setPixelSize(16); + label->setFont(detailsFont); + salt->setFont(detailsFont); +} diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.h index 197263512..99e6b3d43 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.h @@ -30,6 +30,9 @@ public: QString baseUrl); void retranslateUi(); +private slots: + void applyCardTextFont(); + private: EdhrecCommanderApiResponseCommanderDetails commanderDetails; QHBoxLayout *layout; diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.cpp index b26a7e574..749476619 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.cpp @@ -1,6 +1,9 @@ #include "edhrec_commander_api_response_bracket_navigation_widget.h" +#include "../../interface/theme_manager.h" + #include +#include #include #include #include @@ -25,7 +28,7 @@ EdhrecCommanderApiResponseBracketNavigationWidget::EdhrecCommanderApiResponseBra QString label = option.isEmpty() ? "All" : option.at(0).toUpper() + option.mid(1); QPushButton *optionButton = new QPushButton(label, this); optionButton->setMinimumHeight(84); - optionButton->setStyleSheet("font-size: 24px"); + optionButton->setStyleSheet(QString()); gameChangerButtons[option] = optionButton; layout->addWidget(optionButton, 2, i); connect(optionButton, &QPushButton::clicked, this, [=, this]() { @@ -35,6 +38,10 @@ EdhrecCommanderApiResponseBracketNavigationWidget::EdhrecCommanderApiResponseBra }); } + applyInterfaceDefaultFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, + &EdhrecCommanderApiResponseBracketNavigationWidget::applyInterfaceDefaultFont); + updateOptionButtonSelection(gameChangerButtons, ""); retranslateUi(); @@ -94,8 +101,18 @@ void EdhrecCommanderApiResponseBracketNavigationWidget::updateOptionButtonSelect const QString &selectedKey) { for (auto it = buttons.begin(); it != buttons.end(); ++it) { - it.value()->setStyleSheet(it.key() == selectedKey - ? "background-color: lightblue; font-weight: bold; font-size: 24px;" - : "font-size: 24px"); + const bool selected = (it.key() == selectedKey); + it.value()->setStyleSheet(selected ? QStringLiteral("background-color: lightblue;") : QString()); + QFont buttonFont = themeManager->appFont(AppFont::InterfaceDefault); + buttonFont.setPixelSize(24); + buttonFont.setWeight(selected ? QFont::Bold : QFont::Normal); + it.value()->setFont(buttonFont); } +} + +void EdhrecCommanderApiResponseBracketNavigationWidget::applyInterfaceDefaultFont() +{ + // Family comes from the InterfaceDefault theme role; the 24px size stays + // caller-side and the selected button keeps its bold emphasis. + updateOptionButtonSelection(gameChangerButtons, selectedGameChanger); } \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.h index a741b6696..aa4e61ff1 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.h @@ -37,6 +37,9 @@ private: QMap gameChangerButtons; void updateOptionButtonSelection(QMap &buttons, const QString &selectedKey); + +private slots: + void applyInterfaceDefaultFont(); }; #endif // COCKATRICE_EDHREC_COMMANDER_API_RESPONSE_BRACKET_NAVIGATION_WIDGET_H diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.cpp index 48704d010..27cd1c51f 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.cpp @@ -1,6 +1,9 @@ #include "edhrec_commander_api_response_budget_navigation_widget.h" +#include "../../interface/theme_manager.h" + #include +#include #include #include #include @@ -25,7 +28,7 @@ EdhrecCommanderApiResponseBudgetNavigationWidget::EdhrecCommanderApiResponseBudg QString label = option.isEmpty() ? "Any" : option.at(0).toUpper() + option.mid(1); QPushButton *btn = new QPushButton(label, this); btn->setMinimumHeight(84); - btn->setStyleSheet("font-size: 24px"); + btn->setStyleSheet(QString()); budgetButtons[option] = btn; layout->addWidget(btn, 4, i); connect(btn, &QPushButton::clicked, this, [=, this]() { @@ -35,6 +38,10 @@ EdhrecCommanderApiResponseBudgetNavigationWidget::EdhrecCommanderApiResponseBudg }); } + applyInterfaceDefaultFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, + &EdhrecCommanderApiResponseBudgetNavigationWidget::applyInterfaceDefaultFont); + updateOptionButtonSelection(budgetButtons, ""); retranslateUi(); @@ -96,8 +103,18 @@ void EdhrecCommanderApiResponseBudgetNavigationWidget::updateOptionButtonSelecti const QString &selectedKey) { for (auto it = buttons.begin(); it != buttons.end(); ++it) { - it.value()->setStyleSheet(it.key() == selectedKey - ? "background-color: lightblue; font-weight: bold; font-size: 24px;" - : "font-size: 24px"); + const bool selected = (it.key() == selectedKey); + it.value()->setStyleSheet(selected ? QStringLiteral("background-color: lightblue;") : QString()); + QFont buttonFont = themeManager->appFont(AppFont::InterfaceDefault); + buttonFont.setPixelSize(24); + buttonFont.setWeight(selected ? QFont::Bold : QFont::Normal); + it.value()->setFont(buttonFont); } +} + +void EdhrecCommanderApiResponseBudgetNavigationWidget::applyInterfaceDefaultFont() +{ + // Family comes from the InterfaceDefault theme role; the 24px size stays + // caller-side and the selected button keeps its bold emphasis. + updateOptionButtonSelection(budgetButtons, selectedBudget); } \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.h index 8013d1d6b..d727225a5 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.h @@ -37,6 +37,9 @@ private: QMap budgetButtons; void updateOptionButtonSelection(QMap &buttons, const QString &selectedKey); + +private slots: + void applyInterfaceDefaultFont(); }; #endif // COCKATRICE_EDHREC_COMMANDER_API_RESPONSE_BUDGET_NAVIGATION_WIDGET_H diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.cpp index 220dcfbda..57070a5e1 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.cpp @@ -1,9 +1,11 @@ #include "edhrec_commander_api_response_navigation_widget.h" +#include "../../interface/theme_manager.h" #include "../../tab_edhrec_main.h" #include "edhrec_commander_api_response_bracket_navigation_widget.h" #include "edhrec_commander_api_response_budget_navigation_widget.h" +#include #include #include #include @@ -30,10 +32,14 @@ EdhrecCommanderApiResponseNavigationWidget::EdhrecCommanderApiResponseNavigation comboPushButton = new QPushButton(this); comboPushButton->setMinimumHeight(84); - comboPushButton->setStyleSheet("font-size: 24px"); + comboPushButton->setStyleSheet(QString()); averageDeckPushButton = new QPushButton(this); averageDeckPushButton->setMinimumHeight(84); - averageDeckPushButton->setStyleSheet("font-size: 24px"); + averageDeckPushButton->setStyleSheet(QString()); + + applyInterfaceDefaultFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, + &EdhrecCommanderApiResponseNavigationWidget::applyInterfaceDefaultFont); layout->addWidget(comboPushButton, 0, 0, 1, 1); layout->addWidget(averageDeckPushButton, 0, 1, 1, 1); @@ -61,6 +67,15 @@ void EdhrecCommanderApiResponseNavigationWidget::retranslateUi() averageDeckPushButton->setText(tr("Average Deck")); } +void EdhrecCommanderApiResponseNavigationWidget::applyInterfaceDefaultFont() +{ + // Family comes from the InterfaceDefault theme role; the 24px size stays caller-side. + QFont navFont = themeManager->appFont(AppFont::InterfaceDefault); + navFont.setPixelSize(24); + comboPushButton->setFont(navFont); + averageDeckPushButton->setFont(navFont); +} + QString EdhrecCommanderApiResponseNavigationWidget::addNavigationOptionsToUrl(QString baseUrl) { if (!bracketNavigationWidget->getSelectedGameChanger().isEmpty()) { diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.h index 737558625..456228554 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.h @@ -35,6 +35,9 @@ public slots: signals: void requestUrl(QString url); +private slots: + void applyInterfaceDefaultFont(); + private: QGridLayout *layout; diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.h index 5d0245ed4..ce0861ef7 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.h @@ -38,7 +38,8 @@ public: [[nodiscard]] QString getTabText() const override { auto cardName = cardToQuery.isNull() ? QString() : cardToQuery->getName(); - return tr("EDHRec: ") + cardName; + //: EDHRec is a website name, keep it as written. %1 is the card being looked up. + return tr("EDHRec: %1").arg(cardName); } CardSizeWidget *getCardSizeSlider() const diff --git a/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp b/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp new file mode 100644 index 000000000..361e89605 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/report_status_delegate.cpp @@ -0,0 +1,54 @@ +#include "report_status_delegate.h" + +#include "../utility/pill_painter.h" + +#include +#include +#include +#include +#include + +ReportStatusDelegate::ReportStatusDelegate(QObject *parent) : QStyledItemDelegate(parent) +{ +} + +void ReportStatusDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + const bool selected = (option.state & QStyle::State_Selected) != 0; + QString status = index.data(Qt::UserRole).toString(); + if (status.isEmpty()) { + status = index.data(Qt::DisplayRole).toString(); + } + + QPalette::ColorRole tintRole = QPalette::NoRole; + if (status == QStringLiteral("open")) { + tintRole = QPalette::Highlight; + } else if (status == QStringLiteral("assigned")) { + tintRole = QPalette::Link; + } else if (status == QStringLiteral("resolved") || status == QStringLiteral("dismissed")) { + tintRole = QPalette::PlaceholderText; + } + + QStyleOptionViewItem opt(option); + initStyleOption(&opt, index); + painter->setFont(opt.font); + + if (tintRole == QPalette::NoRole) { + const QPalette::ColorRole textRole = selected ? QPalette::HighlightedText : QPalette::PlaceholderText; + QStyle *fallbackStyle = opt.widget != nullptr ? opt.widget->style() : QApplication::style(); + fallbackStyle->drawItemText(painter, opt.rect, Qt::AlignLeft | Qt::AlignVCenter, opt.palette, true, opt.text, + textRole); + return; + } + + const QPalette::ColorRole textRole = selected ? QPalette::HighlightedText : QPalette::WindowText; + if (!pill_painter::paint(painter, opt, tintRole, textRole, opt.text.isEmpty() ? status : opt.text)) { + QStyledItemDelegate::paint(painter, opt, index); + } +} + +QSize ReportStatusDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + const QSize hint = QStyledItemDelegate::sizeHint(option, index); + return hint + QSize(pill_painter::PAD_X, pill_painter::PAD_Y); +} diff --git a/cockatrice/src/interface/widgets/tabs/report_status_delegate.h b/cockatrice/src/interface/widgets/tabs/report_status_delegate.h new file mode 100644 index 000000000..f01f6d3e3 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/report_status_delegate.h @@ -0,0 +1,22 @@ +#ifndef REPORT_STATUS_DELEGATE_H +#define REPORT_STATUS_DELEGATE_H + +#include +#include + +/** + * Status pill for the report queue table. Paints the status text on a + * translucent badge tinted with a palette role per status (open, assigned, + * resolved, dismissed), so the colors always follow the active theme. + * Falls back to default painting for selected rows and unknown statuses. + */ +class ReportStatusDelegate : public QStyledItemDelegate +{ + Q_OBJECT +public: + explicit ReportStatusDelegate(QObject *parent = nullptr); + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; + [[nodiscard]] QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; +}; + +#endif // REPORT_STATUS_DELEGATE_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp new file mode 100644 index 000000000..d2cec3855 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -0,0 +1,699 @@ +#include "tab_alts.h" + +#include "../utility/epoch_format.h" +#include "../utility/table_activation.h" +#include "abstract_client.h" +#include "alt_table_delegate.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/response_user_alts.pb.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr int COL_ALTS_USER = 0; +constexpr int COL_ALTS_VIA = 1; +constexpr int COL_ALTS_DEPTH = 2; +constexpr int COL_ALTS_SHARED = 3; +constexpr int COL_ALTS_IPS = 4; +constexpr int COL_ALTS_EMAIL = 5; +constexpr int COL_ALTS_CLIENTID = 6; +constexpr int COL_ALTS_REGISTERED = 7; +constexpr int COL_ALTS_LAST_LOGIN = 8; +constexpr int COL_ALTS_WARNS = 9; +constexpr int COL_ALTS_BANS = 10; +constexpr int COL_ALTS_ACTIVE = 11; +constexpr int COL_ALTS_COUNT = 12; + +constexpr int COL_WARNS_TIME = 0; +constexpr int COL_WARNS_MODERATOR = 1; +constexpr int COL_WARNS_REASON = 2; +constexpr int COL_WARNS_COUNT = 3; + +constexpr int COL_BANS_TIME = 0; +constexpr int COL_BANS_MODERATOR = 1; +constexpr int COL_BANS_LENGTH = 2; +constexpr int COL_BANS_REASON = 3; +constexpr int COL_BANS_COUNT = 4; + +constexpr int MAX_ALT_DEPTH = 3; +// Starting default only (adjustable 1-100000): shared VPN exits routinely +// cover dozens of users, so 10 hides the obvious VPN noise but keeps +// small shared networks visible. +constexpr int DEFAULT_VPN_THRESHOLD = 10; + +void configureAltTable(QTableWidget *table) +{ + table->setSelectionBehavior(QAbstractItemView::SelectRows); + table->setSelectionMode(QAbstractItemView::SingleSelection); + table->setEditTriggers(QAbstractItemView::NoEditTriggers); + table->verticalHeader()->setVisible(false); + table->setAlternatingRowColors(true); + table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); + table->horizontalHeader()->setStretchLastSection(true); +} +} // namespace + +TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser) + : Tab(_tabSupervisor), client(_client) +{ + auto *centralWidget = new QWidget(this); + setCentralWidget(centralWidget); + + searchEdit = new QLineEdit; + searchEdit->setClearButtonEnabled(true); + connect(searchEdit, &QLineEdit::returnPressed, this, &TabAlts::investigateUser); + + depthSpin = new QSpinBox; + depthSpin->setMinimum(1); + depthSpin->setMaximum(MAX_ALT_DEPTH); + depthSpin->setValue(1); + depthSpin->setKeyboardTracking(false); + connect(depthSpin, &QSpinBox::valueChanged, this, &TabAlts::onSearchFilterChanged); + + investigateButton = new QPushButton; + connect(investigateButton, &QPushButton::clicked, this, &TabAlts::investigateUser); + + backButton = new QPushButton; + backButton->setEnabled(false); + connect(backButton, &QPushButton::clicked, this, &TabAlts::goBack); + + statusLabel = new QLabel; + trailLabel = new QLabel; + trailLabel->setTextFormat(Qt::RichText); + trailLabel->setTextInteractionFlags(Qt::TextBrowserInteraction); + trailLabel->setOpenExternalLinks(false); + // The trail grows with every hop, so let it wrap instead of forcing the tab + // wider than the window. + trailLabel->setWordWrap(true); + connect(trailLabel, &QLabel::linkActivated, this, [this](const QString &userName) { + if (!userName.isEmpty() && userName != currentUser) { + goToTrailEntry(userName); + } + }); + + auto *topBar = new QHBoxLayout; + topBar->addWidget(backButton); + topBar->addWidget(searchEdit); + topBar->addWidget(depthSpin); + topBar->addWidget(investigateButton); + + auto makeChip = [this](const QString &text) { + auto *chip = new QPushButton(text); + chip->setCheckable(true); + chip->setChecked(true); + connect(chip, &QPushButton::toggled, this, &TabAlts::onSearchFilterChanged); + return chip; + }; + emailChip = makeChip(QString()); + clientIdChip = makeChip(QString()); + ipChip = makeChip(QString()); + vpnLabel = new QLabel; + vpnSpin = new QSpinBox; + vpnSpin->setMinimum(1); + vpnSpin->setMaximum(100000); + vpnSpin->setValue(DEFAULT_VPN_THRESHOLD); + connect(vpnSpin, &QSpinBox::valueChanged, this, &TabAlts::onDisplayFilterChanged); + windowLabel = new QLabel; + monthsSpin = new QSpinBox; + monthsSpin->setMinimum(1); + monthsSpin->setMaximum(24); + monthsSpin->setValue(6); + monthsSpin->setKeyboardTracking(false); + connect(monthsSpin, &QSpinBox::valueChanged, this, &TabAlts::onSearchFilterChanged); + + auto *filterBar = new QHBoxLayout; + filterBar->addWidget(emailChip); + filterBar->addWidget(clientIdChip); + filterBar->addWidget(ipChip); + filterBar->addWidget(vpnLabel); + filterBar->addWidget(vpnSpin); + filterBar->addWidget(windowLabel); + filterBar->addWidget(monthsSpin); + filterBar->addStretch(); + + altsTable = new QTableWidget(0, COL_ALTS_COUNT); + configureAltTable(altsTable); + altsDelegate = + new AltTableDelegate(COL_ALTS_VIA, COL_ALTS_WARNS, COL_ALTS_BANS, COL_ALTS_SHARED, COL_ALTS_ACTIVE, altsTable); + altsTable->setItemDelegateForColumn(COL_ALTS_VIA, altsDelegate); + altsTable->setItemDelegateForColumn(COL_ALTS_WARNS, altsDelegate); + altsTable->setItemDelegateForColumn(COL_ALTS_BANS, altsDelegate); + altsTable->setItemDelegateForColumn(COL_ALTS_ACTIVE, altsDelegate); + connectTableActivation(altsTable, this, [this](const QModelIndex &index) { + if (!index.isValid()) { + return; + } + auto *userItem = altsTable->item(index.row(), COL_ALTS_USER); + if (userItem != nullptr && !userItem->text().isEmpty() && userItem->text() != currentUser) { + investigate(userItem->text()); + } + }); + connect(altsTable, &QTableWidget::itemSelectionChanged, this, &TabAlts::onAltSelected); + + detailNameValue = new QLabel; + detailCountsValue = new QLabel; + detailNotesEdit = new QTextEdit; + detailNotesEdit->setReadOnly(true); + // Compact cap so the warn/ban tables stay visible without scrolling the tab. + detailNotesEdit->setMaximumHeight(60); + warnsTable = new QTableWidget(0, COL_WARNS_COUNT); + configureAltTable(warnsTable); + bansTable = new QTableWidget(0, COL_BANS_COUNT); + configureAltTable(bansTable); + + auto *detailGrid = new QGridLayout; + detailNameLabel = new QLabel; + detailCountsLabel = new QLabel; + detailNotesLabel = new QLabel; + detailGrid->addWidget(detailNameLabel, 0, 0); + detailGrid->addWidget(detailNameValue, 0, 1); + detailGrid->addWidget(detailCountsLabel, 0, 2); + detailGrid->addWidget(detailCountsValue, 0, 3); + detailGrid->addWidget(detailNotesLabel, 1, 0); + detailGrid->addWidget(detailNotesEdit, 1, 1, 1, 3); + + detailsGroup = new QGroupBox; + auto *detailsLayout = new QVBoxLayout(detailsGroup); + detailsLayout->addLayout(detailGrid); + detailsLayout->addWidget(warnsTable, 1); + detailsLayout->addWidget(bansTable, 1); + investigateSelectedButton = new QPushButton; + investigateSelectedButton->setEnabled(false); + connect(investigateSelectedButton, &QPushButton::clicked, this, &TabAlts::investigateSelected); + detailsLayout->addWidget(investigateSelectedButton); + + auto *contentSplitter = new QSplitter(Qt::Horizontal); + contentSplitter->addWidget(altsTable); + contentSplitter->addWidget(detailsGroup); + contentSplitter->setStretchFactor(0, 3); + contentSplitter->setStretchFactor(1, 2); + detailsGroup->setMinimumWidth(260); + + auto *mainLayout = new QVBoxLayout(centralWidget); + mainLayout->addLayout(topBar); + mainLayout->addLayout(filterBar); + mainLayout->addWidget(statusLabel); + mainLayout->addWidget(trailLabel); + mainLayout->addWidget(contentSplitter, 1); + + retranslateUi(); + clearDetails(); + investigate(initialUser); +} + +void TabAlts::retranslateUi() +{ + searchEdit->setPlaceholderText(tr("User name")); + depthSpin->setPrefix(tr("Depth: ")); + depthSpin->setToolTip(tr("Search depth: 1 finds direct matches, higher values follow alts of alts.")); + investigateButton->setText(tr("Investigate")); + backButton->setText(tr("Back")); + backButton->setToolTip(tr("Back to the previously investigated user")); + if (currentUser.isEmpty()) { + statusLabel->setText(tr("Enter a user name to investigate.")); + } + + emailChip->setText(tr("Email")); + emailChip->setToolTip(tr("Show or hide email matches")); + clientIdChip->setText(tr("Client ID")); + clientIdChip->setToolTip(tr("Show or hide client ID matches")); + ipChip->setText(tr("IP")); + ipChip->setToolTip(tr("Show or hide IP address matches")); + vpnLabel->setText(tr("Hide IPs shared by more than")); + vpnSpin->setSuffix(tr(" users")); + windowLabel->setText(tr("Session window")); + monthsSpin->setSuffix(tr(" months")); + monthsSpin->setToolTip(tr("How far back session IPs and client IDs are considered.")); + + altsTable->setHorizontalHeaderLabels({tr("User"), tr("Via"), tr("Depth"), tr("Shared"), tr("IPs"), tr("eMail"), + tr("Client ID"), tr("Registered"), tr("Last login"), tr("Warns"), tr("Bans"), + tr("Active")}); + altsTable->setToolTip(tr("Double-click or press Enter on a row to investigate that account")); + warnsTable->setHorizontalHeaderLabels({tr("Warning time"), tr("Moderator"), tr("Reason")}); + bansTable->setHorizontalHeaderLabels({tr("Ban time"), tr("Moderator"), tr("Length"), tr("Reason")}); + + detailsGroup->setTitle(tr("Selected user")); + detailNameLabel->setText(tr("Name:")); + detailCountsLabel->setText(tr("Counts:")); + detailNotesLabel->setText(tr("Admin notes:")); + investigateSelectedButton->setText(tr("Investigate this user")); + investigateSelectedButton->setToolTip(tr("Open the selected account as a new investigation")); + + if (!lastAlts.isEmpty()) { + renderTable(); + } +} + +QString TabAlts::formatEpoch(quint64 ts) const +{ + return formatEpochTimestamp(ts, tr("Unknown")); +} + +void TabAlts::investigate(const QString &userName) +{ + if (userName.isEmpty() || userName == currentUser) { + return; + } + // Alt matches are symmetric, so revisiting an earlier account is a normal + // double-click. Truncate there instead of pushing it again, so the trail + // stays a single path. + if (backStack.contains(userName)) { + goToTrailEntry(userName); + return; + } + if (!currentUser.isEmpty()) { + backStack << currentUser; + backButton->setEnabled(true); + } + loadUser(userName); +} + +void TabAlts::goToTrailEntry(const QString &userName) +{ + const int index = backStack.indexOf(userName); + if (index < 0) { + investigate(userName); + return; + } + // Clicking an earlier crumb truncates the history there instead of pushing + // another entry, so the trail keeps a single path and Back still works. + backStack.erase(backStack.begin() + index, backStack.end()); + backButton->setEnabled(!backStack.isEmpty()); + loadUser(userName); +} + +void TabAlts::goBack() +{ + if (backStack.isEmpty()) { + return; + } + const QString previousUser = backStack.takeLast(); + backButton->setEnabled(!backStack.isEmpty()); + loadUser(previousUser); +} + +void TabAlts::loadUser(const QString &userName) +{ + currentUser = userName; + searchEdit->setText(userName); + updateTrail(); + investigateUser(); +} + +void TabAlts::updateTrail() +{ + QStringList trail = backStack; + trail << currentUser; + trail.removeAll(QString()); + QStringList links; + for (const QString &name : trail) { + const QString safeName = name.toHtmlEscaped(); + if (name == currentUser) { + links << QStringLiteral("") + safeName + QStringLiteral(""); + } else { + links << QStringLiteral("") + safeName + + QStringLiteral(""); + } + } + trailLabel->setText(links.join(QStringLiteral(" › "))); + trailLabel->setVisible(!links.isEmpty()); +} + +void TabAlts::investigateUser() +{ + const QString userName = searchEdit->text().simplified(); + if (userName.isEmpty()) { + return; + } + if (userName != currentUser && !currentUser.isEmpty()) { + backStack << currentUser; + backButton->setEnabled(true); + } + currentUser = userName; + updateTrail(); + reloadCurrentUser(); +} + +void TabAlts::reloadCurrentUser() +{ + lastAlts.clear(); + selectedUser.clear(); + clearDetails(); + altsTable->setRowCount(0); + statusLabel->setText(tr("Loading alts for %1...").arg(currentUser)); + requestAlts(currentUser, depthSpin->value()); +} + +void TabAlts::requestAlts(const QString &userName, int depth) +{ + Command_GetUserAlts cmd; + cmd.set_user_name(userName.toStdString()); + cmd.set_depth(static_cast(depth)); + cmd.set_include_email(emailChip->isChecked()); + cmd.set_include_clientid(clientIdChip->isChecked()); + cmd.set_include_ip(ipChip->isChecked()); + cmd.set_session_months(static_cast(monthsSpin->value())); + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, [this, userName](const Response &response) { + if (userName == currentUser) { + altsResponse(response); + } + }); + client->sendCommand(pend); +} + +void TabAlts::altsResponse(const Response &response) +{ + lastAlts.clear(); + if (response.response_code() != Response::RespOk) { + statusLabel->setText(tr("Could not load alts for %1.").arg(currentUser)); + return; + } + + const Response_UserAlts &resp = response.GetExtension(Response_UserAlts::ext); + // The server answers RespOk with no rows when the user does not exist. + if (resp.alts_size() == 0) { + statusLabel->setText(tr("User %1 not found.").arg(currentUser)); + return; + } + + for (int i = 0; i < resp.alts_size(); ++i) { + lastAlts << resp.alts(i); + } + renderTable(); +} + +bool TabAlts::rowVisible(const ServerInfo_UserAlt &alt) const +{ + const QString name = QString::fromStdString(alt.user_name()); + if (name == currentUser) { + return true; + } + if (!alt.has_match_reason() || alt.match_reason().empty()) { + return true; + } + const QString reason = QString::fromStdString(alt.match_reason()); + if (reason == QStringLiteral("email")) { + return emailChip->isChecked(); + } + if (reason.startsWith(QStringLiteral("clientid:"))) { + return clientIdChip->isChecked(); + } + if (reason.startsWith(QStringLiteral("ip:"))) { + return ipChip->isChecked() && + (!alt.has_shared_count() || alt.shared_count() <= static_cast(vpnSpin->value())); + } + return true; +} + +void TabAlts::renderTable() +{ + // Blocked so the programmatic refill neither wipes the details panel + // (via the selection-cleared path) nor refetches them on restore. + const QSignalBlocker blocker(altsTable); + altsTable->setRowCount(0); + + int emailCount = 0; + int clientIdCount = 0; + int ipCount = 0; + for (const ServerInfo_UserAlt &alt : lastAlts) { + const QString reason = QString::fromStdString(alt.match_reason()); + if (reason == QStringLiteral("email")) { + ++emailCount; + } else if (reason.startsWith(QStringLiteral("clientid:"))) { + ++clientIdCount; + } else if (reason.startsWith(QStringLiteral("ip:"))) { + ++ipCount; + } + } + // The server omits unchecked match types, so keep the last count while a + // chip is off instead of relabelling it "(0)". + if (emailChip->isChecked()) { + emailChip->setText(tr("Email (%1)").arg(emailCount)); + } + if (clientIdChip->isChecked()) { + clientIdChip->setText(tr("Client ID (%1)").arg(clientIdCount)); + } + if (ipChip->isChecked()) { + ipChip->setText(tr("IP (%1)").arg(ipCount)); + } + + QFont userFont = altsTable->font(); + userFont.setBold(true); + const QColor dimColor = palette().color(QPalette::Disabled, QPalette::Text); + + int hidden = 0; + int row = 0; + for (const ServerInfo_UserAlt &alt : lastAlts) { + if (!rowVisible(alt)) { + ++hidden; + continue; + } + altsTable->insertRow(row); + auto *userItem = new QTableWidgetItem(QString::fromStdString(alt.user_name())); + userItem->setFont(userFont); + altsTable->setItem(row, COL_ALTS_USER, userItem); + altsTable->setItem(row, COL_ALTS_VIA, + new QTableWidgetItem(alt.has_match_reason() && !alt.match_reason().empty() + ? QString::fromStdString(alt.match_reason()) + : QStringLiteral("-"))); + altsTable->setItem(row, COL_ALTS_DEPTH, + new QTableWidgetItem(alt.has_depth() ? QString::number(alt.depth()) : QStringLiteral("-"))); + altsTable->setItem( + row, COL_ALTS_SHARED, + new QTableWidgetItem(alt.has_shared_count() ? QString::number(alt.shared_count()) : QStringLiteral("-"))); + altsTable->setItem( + row, COL_ALTS_IPS, + new QTableWidgetItem(alt.has_ip_count() ? QString::number(alt.ip_count()) : QStringLiteral("-"))); + altsTable->setItem(row, COL_ALTS_EMAIL, new QTableWidgetItem(QString::fromStdString(alt.email()))); + altsTable->setItem(row, COL_ALTS_CLIENTID, new QTableWidgetItem(QString::fromStdString(alt.clientid()))); + altsTable->setItem(row, COL_ALTS_REGISTERED, new QTableWidgetItem(formatEpoch(alt.registration_time()))); + altsTable->setItem(row, COL_ALTS_LAST_LOGIN, new QTableWidgetItem(formatEpoch(alt.last_login()))); + altsTable->setItem(row, COL_ALTS_WARNS, new QTableWidgetItem(QString::number(alt.warn_count()))); + altsTable->setItem(row, COL_ALTS_BANS, new QTableWidgetItem(QString::number(alt.ban_count()))); + auto *activeItem = new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no")); + activeItem->setData(Qt::UserRole, alt.is_active()); + altsTable->setItem(row, COL_ALTS_ACTIVE, activeItem); + // shared_count already includes the investigated account, so anything + // above 2 means the address is shared beyond that pair. + if (QString::fromStdString(alt.match_reason()).startsWith(QStringLiteral("ip:")) && alt.has_shared_count() && + alt.shared_count() > 2) { + for (int col = 0; col < COL_ALTS_COUNT; ++col) { + if (col == COL_ALTS_VIA) { + continue; + } + if (auto *item = altsTable->item(row, col)) { + item->setForeground(dimColor); + } + } + } + ++row; + } + // The server appends the frontier user as a depth-0 seed row; it is the + // investigated user, not one of their related accounts. + int relatedCount = 0; + for (const ServerInfo_UserAlt &alt : lastAlts) { + if (!alt.has_depth() || alt.depth() > 0) { + ++relatedCount; + } + } + if (hidden == 0) { + statusLabel->setText(tr("%1 account(s) related to %2.").arg(relatedCount).arg(currentUser)); + } else { + statusLabel->setText(tr("%1 shown, %2 hidden by filters (%3 total) related to %4.") + .arg(row) + .arg(hidden) + .arg(relatedCount) + .arg(currentUser)); + } + + if (!selectedUser.isEmpty()) { + bool restored = false; + for (int restoreRow = 0; restoreRow < altsTable->rowCount(); ++restoreRow) { + auto *userItem = altsTable->item(restoreRow, COL_ALTS_USER); + if (userItem != nullptr && userItem->text() == selectedUser) { + altsTable->setCurrentItem(userItem, QItemSelectionModel::Select | QItemSelectionModel::Rows); + restored = true; + break; + } + } + if (!restored) { + selectedUser.clear(); + clearDetails(); + } + } +} + +void TabAlts::onSearchFilterChanged() +{ + if (currentUser.isEmpty()) { + return; + } + // A filter change reloads the user being investigated; it must not switch + // to a name that was typed into the search box but never submitted. + reloadCurrentUser(); +} + +void TabAlts::onDisplayFilterChanged() +{ + if (lastAlts.isEmpty()) { + return; + } + renderTable(); +} + +void TabAlts::investigateSelected() +{ + if (!selectedUser.isEmpty() && selectedUser != currentUser) { + investigate(selectedUser); + } +} + +void TabAlts::onAltSelected() +{ + const auto selected = altsTable->selectedItems(); + if (selected.isEmpty()) { + selectedUser.clear(); + clearDetails(); + return; + } + auto *userItem = altsTable->item(selected.first()->row(), COL_ALTS_USER); + if (userItem == nullptr || userItem->text().isEmpty() || userItem->text() == selectedUser) { + return; + } + selectedUser = userItem->text(); + requestDetails(selectedUser); +} + +void TabAlts::requestDetails(const QString &userName) +{ + detailNameValue->setText(userName); + detailCountsValue->setText(tr("Loading...")); + detailNotesEdit->clear(); + warnsTable->setRowCount(0); + bansTable->setRowCount(0); + investigateSelectedButton->setEnabled(userName != currentUser); + + Command_ReportUserInfo infoCmd; + infoCmd.set_user_name(userName.toStdString()); + PendingCommand *infoPend = client->prepareModeratorCommand(infoCmd); + connect(infoPend, &PendingCommand::finished, this, [this, userName](const Response &response) { + if (userName == selectedUser) { + userDetailsResponse(response); + } + }); + client->sendCommand(infoPend); + + Command_GetWarnHistory warnCmd; + warnCmd.set_user_name(userName.toStdString()); + PendingCommand *warnPend = client->prepareModeratorCommand(warnCmd); + connect(warnPend, &PendingCommand::finished, this, [this, userName](const Response &response) { + if (userName == selectedUser) { + warnHistoryResponse(response); + } + }); + client->sendCommand(warnPend); + + Command_GetBanHistory banCmd; + banCmd.set_user_name(userName.toStdString()); + PendingCommand *banPend = client->prepareModeratorCommand(banCmd); + connect(banPend, &PendingCommand::finished, this, [this, userName](const Response &response) { + if (userName == selectedUser) { + banHistoryResponse(response); + } + }); + client->sendCommand(banPend); +} + +void TabAlts::clearDetails() +{ + detailNameValue->clear(); + detailCountsValue->setText(tr("Select a row to inspect its history.")); + detailNotesEdit->clear(); + warnsTable->setRowCount(0); + bansTable->setRowCount(0); + investigateSelectedButton->setEnabled(false); +} + +void TabAlts::userDetailsResponse(const Response &response) +{ + if (response.response_code() != Response::RespOk) { + detailCountsValue->setText(tr("Error loading user info.")); + return; + } + + const Response_ReportUserInfo &resp = response.GetExtension(Response_ReportUserInfo::ext); + detailCountsValue->setText(tr("Reports: %1 Bans: %2 Warnings: %3") + .arg(resp.total_reports()) + .arg(resp.total_bans()) + .arg(resp.total_warns())); + + if (resp.has_admin_notes() && !resp.admin_notes().empty()) { + detailNotesEdit->setPlainText(QString::fromStdString(resp.admin_notes())); + } else { + detailNotesEdit->setPlainText(tr("(none)")); + } +} + +void TabAlts::warnHistoryResponse(const Response &response) +{ + warnsTable->setRowCount(0); + if (response.response_code() != Response::RespOk) { + return; + } + + const Response_WarnHistory &resp = response.GetExtension(Response_WarnHistory::ext); + warnsTable->setRowCount(resp.warn_list_size()); + for (int i = 0; i < resp.warn_list_size(); ++i) { + const ServerInfo_Warning &warn = resp.warn_list(i); + warnsTable->setItem(i, COL_WARNS_TIME, new QTableWidgetItem(QString::fromStdString(warn.time_of()))); + warnsTable->setItem(i, COL_WARNS_MODERATOR, new QTableWidgetItem(QString::fromStdString(warn.admin_name()))); + warnsTable->setItem(i, COL_WARNS_REASON, new QTableWidgetItem(QString::fromStdString(warn.reason()))); + } +} + +void TabAlts::banHistoryResponse(const Response &response) +{ + bansTable->setRowCount(0); + if (response.response_code() != Response::RespOk) { + return; + } + + const Response_BanHistory &resp = response.GetExtension(Response_BanHistory::ext); + bansTable->setRowCount(resp.ban_list_size()); + for (int i = 0; i < resp.ban_list_size(); ++i) { + const ServerInfo_Ban &ban = resp.ban_list(i); + bansTable->setItem(i, COL_BANS_TIME, new QTableWidgetItem(QString::fromStdString(ban.ban_time()))); + bansTable->setItem(i, COL_BANS_MODERATOR, new QTableWidgetItem(QString::fromStdString(ban.admin_name()))); + bansTable->setItem(i, COL_BANS_LENGTH, new QTableWidgetItem(QString::fromStdString(ban.ban_length()))); + bansTable->setItem(i, COL_BANS_REASON, new QTableWidgetItem(QString::fromStdString(ban.ban_reason()))); + } +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.h b/cockatrice/src/interface/widgets/tabs/tab_alts.h new file mode 100644 index 000000000..7f6dd4f42 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.h @@ -0,0 +1,102 @@ +#ifndef TAB_ALTS_H +#define TAB_ALTS_H + +#include "tab.h" + +#include +#include +#include +#include +#include + +class AbstractClient; +class AltTableDelegate; +class QGroupBox; +class QLabel; +class QLineEdit; +class QPushButton; +class QSpinBox; +class QTableWidget; +class QTextEdit; +class Response; +class TabSupervisor; + +/** + * Staff alt-graph investigation tool. Shows accounts related to the + * investigated user (shared email, client id, or recent IP address), + * including transitive alts-of-alts, with per-row match evidence. + * Selecting a row loads that account's warnings, bans, and admin notes. + */ +class TabAlts : public Tab +{ + Q_OBJECT +public: + explicit TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser = {}); + void retranslateUi() override; + [[nodiscard]] QString getTabText() const override + { + return tr("User Alts"); + } + void investigate(const QString &userName); + +private slots: + void investigateUser(); + void goBack(); + void investigateSelected(); + void altsResponse(const Response &response); + void onSearchFilterChanged(); + void onDisplayFilterChanged(); + void onAltSelected(); + void userDetailsResponse(const Response &response); + void warnHistoryResponse(const Response &response); + void banHistoryResponse(const Response &response); + +private: + void loadUser(const QString &userName); + void goToTrailEntry(const QString &userName); + void updateTrail(); + void reloadCurrentUser(); + void requestAlts(const QString &userName, int depth); + void requestDetails(const QString &userName); + void clearDetails(); + void renderTable(); + [[nodiscard]] bool rowVisible(const ServerInfo_UserAlt &alt) const; + [[nodiscard]] QString formatEpoch(quint64 ts) const; + + AbstractClient *client; + QString currentUser; + QString selectedUser; + QList backStack; + QList lastAlts; + + QLineEdit *searchEdit; + QSpinBox *depthSpin; + QPushButton *investigateButton; + QPushButton *backButton; + QPushButton *investigateSelectedButton; + QLabel *trailLabel; + QLabel *statusLabel; + QTableWidget *altsTable; + AltTableDelegate *altsDelegate; + + QPushButton *emailChip; + QPushButton *clientIdChip; + QPushButton *ipChip; + QLabel *vpnLabel; + QSpinBox *vpnSpin; + QLabel *windowLabel; + QSpinBox *monthsSpin; + + QLabel *detailNameValue; + QLabel *detailCountsValue; + QTextEdit *detailNotesEdit; + QTableWidget *warnsTable; + QTableWidget *bansTable; + + QGroupBox *detailsGroup; + QLabel *detailNameLabel; + QLabel *detailCountsLabel; + QLabel *detailNotesLabel; +}; + +#endif // TAB_ALTS_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_editor.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_editor.cpp index 7ca179cea..e88b2aad6 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_editor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_editor.cpp @@ -131,10 +131,16 @@ void TabDeckEditor::loadLayout() LayoutsSettings &layouts = SettingsCache::instance().layouts(); auto layoutState = layouts.getDeckEditorLayoutState(); - if (layoutState.isNull()) { - restartLayout(); - } else { - restoreState(layoutState); + // Start with every current dock placed. Saved layouts from older versions + // cannot describe docks introduced later; restoreState leaves those docks + // in their existing positions while restoring the saved docks, including + // their visibility and floating state. + restartLayout(); + if (!layoutState.isEmpty()) { + if (!restoreState(layoutState)) { + // A corrupt or incompatible state must not leave a partial layout. + restartLayout(); + } restoreGeometry(layouts.getDeckEditorGeometry()); } } diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index 3dfdc956b..32abde046 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -2,7 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../deck_loader/deck_loader.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../cards/additional_info/deck_color_identity.h" #include "../deck_share/deck_share_utils.h" #include "../deck_share/share_bar_widget.h" @@ -187,19 +187,20 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, // Left side actions aOpenLocalDeck = new QAction(this); - aOpenLocalDeck->setIcon(themePixmap(QStringLiteral("icons/pencil"))); + aOpenLocalDeck->setIcon(loadThemedIcon(QStringLiteral("icons/pencil"))); connect(aOpenLocalDeck, &QAction::triggered, this, &TabDeckStorage::actOpenLocalDeck); aRenameLocal = new QAction(this); - aRenameLocal->setIcon(themePixmap(QStringLiteral("icons/rename"))); + aRenameLocal->setIcon(loadThemedIcon(QStringLiteral("icons/rename"))); connect(aRenameLocal, &QAction::triggered, this, &TabDeckStorage::actRenameLocal); aUpload = new QAction(this); - aUpload->setIcon(themePixmap(QStringLiteral("icons/arrow_right_green"))); + aUpload->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_right"))); connect(aUpload, &QAction::triggered, this, &TabDeckStorage::actUpload); aNewLocalFolder = new QAction(this); aNewLocalFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder)); connect(aNewLocalFolder, &QAction::triggered, this, &TabDeckStorage::actNewLocalFolder); aDeleteLocalDeck = new QAction(this); - aDeleteLocalDeck->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + aDeleteLocalDeck->setIcon( + loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker)); connect(aDeleteLocalDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteLocalDeck); aOpenDecksFolder = new QAction(this); @@ -208,24 +209,25 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, // Right side actions aOpenRemoteDeck = new QAction(this); - aOpenRemoteDeck->setIcon(themePixmap(QStringLiteral("icons/pencil"))); + aOpenRemoteDeck->setIcon(loadThemedIcon(QStringLiteral("icons/pencil"))); connect(aOpenRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actOpenRemoteDeck); aDownload = new QAction(this); - aDownload->setIcon(themePixmap(QStringLiteral("icons/arrow_left_green"))); + aDownload->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_left"))); connect(aDownload, &QAction::triggered, this, &TabDeckStorage::actDownload); aNewFolder = new QAction(this); aNewFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder)); connect(aNewFolder, &QAction::triggered, this, &TabDeckStorage::actNewFolder); aDeleteRemoteDeck = new QAction(this); - aDeleteRemoteDeck->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + aDeleteRemoteDeck->setIcon( + loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker)); connect(aDeleteRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteRemoteDeck); aShareDecks = new QAction(this); - aShareDecks->setIcon(themePixmap(QStringLiteral("icons/share"))); + aShareDecks->setIcon(loadThemedIcon(QStringLiteral("icons/share"), AppColor::Informational)); connect(aShareDecks, &QAction::triggered, this, &TabDeckStorage::actShareDecks); aPublishDeck = new QAction(this); - aPublishDeck->setIcon(QPixmap("theme:icons/lock")); + aPublishDeck->setIcon(loadThemedIcon(QStringLiteral("icons/lock"))); connect(aPublishDeck, &QAction::triggered, this, &TabDeckStorage::actPublishDeck); // Add actions to toolbars diff --git a/cockatrice/src/interface/widgets/tabs/tab_developer.cpp b/cockatrice/src/interface/widgets/tabs/tab_developer.cpp index d641a1ea5..024cd46f0 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_developer.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_developer.cpp @@ -6,6 +6,7 @@ #include "tab_developer.h" +#include "../interface/theme_manager.h" #include "libcockatrice/protocol/pb/response.pb.h" #include @@ -100,6 +101,20 @@ TabDeveloper::TabDeveloper(TabSupervisor *_tabSupervisor, AbstractClient *_clien central->setLayout(mainLayout); setCentralWidget(central); + // Separator rows store their font on the item; re-apply the role family on + // font changes (value cells keep the default font and need only a repaint). + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { + QFont separatorFont = themeManager->appFont(AppFont::InterfaceDefault); + separatorFont.setBold(true); + for (int row = 0; row < statsTable->rowCount(); ++row) { + auto *labelItem = statsTable->item(row, 0); + if (labelItem && !(labelItem->flags() & Qt::ItemIsSelectable)) { + labelItem->setFont(separatorFont); + } + } + statsTable->viewport()->update(); + }); + retranslateUi(); } @@ -155,7 +170,8 @@ void TabDeveloper::appendSeparatorRow(const QString §ionTitle) const int row = statsTable->rowCount(); statsTable->insertRow(row); auto *labelItem = new QTableWidgetItem(sectionTitle); - auto font = labelItem->font(); + // Family comes from the InterfaceDefault theme role; bold stays caller-side. + QFont font = themeManager->appFont(AppFont::InterfaceDefault); font.setBold(true); labelItem->setFont(font); labelItem->setFlags(labelItem->flags() & ~Qt::ItemIsSelectable); diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index 9e724727f..5dba1fc51 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -20,12 +20,14 @@ #include "../interface/widgets/cards/card_info_frame_widget.h" #include "../interface/widgets/dialogs/dlg_create_game.h" #include "../interface/widgets/dialogs/dlg_invite_to_game.h" +#include "../interface/widgets/dialogs/dlg_turn_order.h" #include "../interface/widgets/server/game_link.h" #include "../interface/widgets/server/user/user_list_manager.h" #include "../interface/widgets/utility/completer_utils.h" #include "../interface/widgets/utility/line_edit_completer.h" #include "../main.h" #include "../menus/tearoff_menu.h" +#include "../replay/replay_event_sidebar.h" #include "../replay/replay_widget.h" #include "../utility/visibility_change_listener.h" #include "card/card_completer_proxy_model.h" @@ -97,8 +99,9 @@ class Message; } // namespace google TabGame::TabGame(TabSupervisor *_tabSupervisor, GameReplay *_replay) - : Tab(_tabSupervisor), sayLabel(nullptr), sayEdit(nullptr) + : Tab(_tabSupervisor), sayLabel(nullptr), sayEdit(nullptr), replayDock(nullptr), historyDock(nullptr) { + setObjectName("TabGame"); // THIS CTOR IS USED ON REPLAY game = new Replay(this, _replay, tabSupervisor->getIsLocalGame(), &SettingsCache::instance().userInterface(), new SettingsCardPreferenceProvider(this)); @@ -113,9 +116,11 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor, GameReplay *_replay) addDockWidget(Qt::RightDockWidgetArea, cardInfoDock); addDockWidget(Qt::RightDockWidgetArea, playerListDock); addDockWidget(Qt::RightDockWidgetArea, messageLayoutDock); + addDockWidget(Qt::LeftDockWidgetArea, historyDock); addDockWidget(Qt::BottomDockWidgetArea, replayDock); mainWidget = new QStackedWidget(this); + mainWidget->setObjectName("mainWidget"); mainWidget->addWidget(deckViewContainerWidget); mainWidget->addWidget(gamePlayAreaWidget); setCentralWidget(mainWidget); @@ -144,6 +149,7 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor, const QMap &_roomGameTypes) : Tab(_tabSupervisor), userListProxy(_tabSupervisor->getUserListManager()) { + setObjectName("TabGame"); // THIS CTOR IS USED ON GAMES game = new Game(this, tabSupervisor->getIsLocalGame(), _clients, event, _roomGameTypes, &SettingsCache::instance().userInterface(), new SettingsCardPreferenceProvider(this)); @@ -154,12 +160,14 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor, createPlayAreaWidget(); createDeckViewContainerWidget(); replayDock = nullptr; + historyDock = nullptr; addDockWidget(Qt::RightDockWidgetArea, cardInfoDock); addDockWidget(Qt::RightDockWidgetArea, playerListDock); addDockWidget(Qt::RightDockWidgetArea, messageLayoutDock); mainWidget = new QStackedWidget(this); + mainWidget->setObjectName("mainWidget"); mainWidget->addWidget(deckViewContainerWidget); mainWidget->addWidget(gamePlayAreaWidget); mainWidget->setContentsMargins(0, 0, 0, 0); @@ -191,6 +199,7 @@ void TabGame::connectToGameState() { connect(game->getGameState(), &GameState::gameStarted, this, &TabGame::startGame); connect(game->getGameState(), &GameState::gameStopped, this, &TabGame::stopGame); + connect(game->getGameState(), &GameState::hostChanged, this, &TabGame::updateTurnOrderAction); connect(game->getGameState(), &GameState::activePhaseChanged, this, &TabGame::setActivePhase); connect(game->getGameState(), &GameState::activePlayerChanged, this, &TabGame::setActivePlayer); } @@ -259,6 +268,8 @@ void TabGame::connectMessageLogToGameEventHandler() connect(game->getGameEventHandler(), &GameEventHandler::logTurnReversed, messageLog, &MessageLogWidget::logReverseTurn); + connect(game->getGameEventHandler(), &GameEventHandler::logTurnOrderChanged, messageLog, + &MessageLogWidget::logTurnOrderChanged); connect(game->getGameEventHandler(), &GameEventHandler::logConcede, messageLog, &MessageLogWidget::logConcede); connect(game->getGameEventHandler(), &GameEventHandler::logUnconcede, messageLog, &MessageLogWidget::logUnconcede); @@ -319,6 +330,9 @@ void TabGame::emitUserEvent() TabGame::~TabGame() { + if (aCardMenu && aCardMenu->menu() && aCardMenu->menu() != emptyCardMenu) { + delete aCardMenu->menu(); + } for (auto &player : game->getPlayerManager()->getPlayers()) { player->clear(); } @@ -345,6 +359,9 @@ void TabGame::retranslateUi() } cardInfoDock->setWindowTitle(tr("Card Info") + (cardInfoDock->isWindow() ? tabText : QString())); messageLayoutDock->setWindowTitle(tr("Messages") + (messageLayoutDock->isWindow() ? tabText : QString())); + if (historyDock) { + historyDock->setWindowTitle(tr("Game History") + (historyDock->isWindow() ? tabText : QString())); + } if (replayDock) { replayDock->setWindowTitle(tr("Replay Timeline") + (replayDock->isWindow() ? tabText : QString())); } @@ -369,6 +386,9 @@ void TabGame::retranslateUi() if (aReverseTurn) { aReverseTurn->setText(tr("Reverse turn order")); } + if (aTurnOrder) { + aTurnOrder->setText(tr("&Set turn order...")); + } if (aRemoveLocalArrows) { aRemoveLocalArrows->setText(tr("&Remove all local arrows")); } @@ -418,6 +438,9 @@ void TabGame::retranslateUi() dockToActions[messageLayoutDock].menu->setTitle(tr("Messages")); dockToActions[playerListDock].menu->setTitle(tr("Player List")); + if (historyDock) { + dockToActions[historyDock].menu->setTitle(tr("Game History")); + } if (replayDock) { dockToActions[replayDock].menu->setTitle(tr("Replay Timeline")); } @@ -550,8 +573,10 @@ void TabGame::adminLockChanged(bool lock) void TabGame::actGameInfo() { - DlgCreateGame dlg(game->getGameMetaInfo()->proto(), game->getGameMetaInfo()->getRoomGameTypes(), this); - dlg.exec(); + // Heap-allocated so that destroying this tab while the dialog is open cannot delete a stack object. + auto *dlg = new DlgCreateGame(game->getGameMetaInfo()->proto(), game->getGameMetaInfo()->getRoomGameTypes(), this); + dlg->setAttribute(Qt::WA_DeleteOnClose); + dlg->exec(); } void TabGame::actCopyGameLink() @@ -625,6 +650,73 @@ void TabGame::actConcede() } } +void TabGame::actTurnOrder() +{ + if (!canSetTurnOrder()) { + return; + } + + QStringList playerNames; + const QList seatOrder = game->getGameState()->getSeatOrder(); + const auto &players = game->getPlayerManager()->getPlayers(); + if (!seatOrder.isEmpty()) { + for (int playerId : seatOrder) { + if (PlayerLogic *player = players.value(playerId, nullptr)) { + playerNames.append(player->getPlayerInfo()->getName()); + } + } + // Include any players missing from the tracked order rather than dropping them. + for (auto it = players.constBegin(); it != players.constEnd(); ++it) { + const QString name = it.value()->getPlayerInfo()->getName(); + if (!playerNames.contains(name)) { + playerNames.append(name); + } + } + } else { + for (PlayerLogic *player : players) { + playerNames.append(player->getPlayerInfo()->getName()); + } + } + + DlgTurnOrder dlg(playerNames, this); + if (dlg.exec() != QDialog::Accepted) { + return; + } + + game->getGameEventHandler()->handleSetPlayerOrder(dlg.order(), dlg.isRandomizeRequested()); +} + +/** + * Whether the local user may change the seating order: only while the game has + * not started, and only as the host or a moderator - the server allows both + * (Server_AbstractParticipant::cmdSetPlayerOrder). + */ +bool TabGame::canSetTurnOrder() const +{ + if (game->getGameMetaInfo()->started()) { + return false; + } + if (game->isHost()) { + return true; + } + const ServerInfo_User *userInfo = tabSupervisor->getUserInfo(); + return userInfo != nullptr && (userInfo->user_level() & ServerInfo_User::IsModerator); +} + +/** + * Shows the turn order action only when it can actually change anything: the + * player must be allowed to set the order and the game must not have started. + * Called whenever either condition may have changed (game start/stop, host + * change). + */ +void TabGame::updateTurnOrderAction() +{ + if (aTurnOrder == nullptr) { + return; + } + aTurnOrder->setVisible(canSetTurnOrder()); +} + /** * Confirms the leave game and sends the leave game command, if applicable. * @@ -836,6 +928,15 @@ void TabGame::processPlayerLeave(PlayerLogic *leavingPlayer) } } + // A shuffled local player has their seat changed, so the deck view container + // keyed by their previous id must be cleaned up alongside the leaving player. + int leavingPlayerId = leavingPlayer->getPlayerInfo()->getId(); + if (deckViewContainers.contains(leavingPlayerId)) { + TabbedDeckViewContainer *deckView = deckViewContainers.take(leavingPlayerId); + deckViewContainerLayout->removeWidget(deckView); + deckView->deleteLater(); + } + scene->removePlayer(leavingPlayer); } @@ -944,6 +1045,7 @@ void TabGame::startGame(bool _resuming) aConcede->setText(tr("&Concede")); aConcede->setEnabled(true); } + updateTurnOrderAction(); } void TabGame::stopGame() @@ -966,6 +1068,7 @@ void TabGame::stopGame() aConcede->setText(tr("&Concede")); aConcede->setEnabled(false); } + updateTurnOrderAction(); } void TabGame::closeGame() @@ -1058,10 +1161,16 @@ QString TabGame::getTabText() const /** * @param menu The menu to set. Pass in nullptr to set the menu to empty. + * + * The previous card menu is deleted here: QAction::setMenu() does not take ownership, so whoever installs the menu + * (this widget) has to dispose of the one it replaces. The empty placeholder menu is reused instead of deleted. */ void TabGame::setCardMenu(CardMenu *menu) { - if (!aCardMenu) { + if (!aCardMenu || !emptyCardMenu) { + if (menu) { + menu->deleteLater(); + } return; } @@ -1071,10 +1180,11 @@ void TabGame::setCardMenu(CardMenu *menu) gameView->viewport()->update(); } } - if (menu) { - aCardMenu->setMenu(menu); - } else { - aCardMenu->setMenu(new QMenu); + + QMenu *previousMenu = aCardMenu->menu(); + aCardMenu->setMenu(menu ? menu : emptyCardMenu); + if (previousMenu && previousMenu != emptyCardMenu && previousMenu != menu) { + previousMenu->deleteLater(); } } @@ -1090,6 +1200,9 @@ void TabGame::createMenuItems() connect(aNextTurn, &QAction::triggered, game->getGameEventHandler(), &GameEventHandler::handleNextTurn); aReverseTurn = new QAction(this); connect(aReverseTurn, &QAction::triggered, game->getGameEventHandler(), &GameEventHandler::handleReverseTurn); + aTurnOrder = new QAction(this); + connect(aTurnOrder, &QAction::triggered, this, &TabGame::actTurnOrder); + updateTurnOrderAction(); aRemoveLocalArrows = new QAction(this); connect(aRemoveLocalArrows, &QAction::triggered, this, &TabGame::actRemoveLocalArrows); aRotateViewCW = new QAction(this); @@ -1118,6 +1231,7 @@ void TabGame::createMenuItems() connect(aFocusChat, &QAction::triggered, sayEdit, qOverload<>(&LineEditCompleter::setFocus)); phasesMenu = new TearOffMenu(this); + phasesMenu->setObjectName("phasesMenu"); for (int i = 0; i < phasesToolbar->phaseCount(); ++i) { auto *temp = new QAction(QString(), this); @@ -1128,13 +1242,18 @@ void TabGame::createMenuItems() phasesMenu->addSeparator(); phasesMenu->addAction(aNextPhase); + aNextPhase->setObjectName("aNextPhase"); phasesMenu->addAction(aNextPhaseAction); + aNextPhaseAction->setObjectName("aNextPhaseAction"); gameMenu = new QMenu(this); + gameMenu->setObjectName("gameMenu"); playersSeparator = gameMenu->addSeparator(); gameMenu->addMenu(phasesMenu); gameMenu->addAction(aNextTurn); + aNextTurn->setObjectName("aNextTurn"); gameMenu->addAction(aReverseTurn); + gameMenu->addAction(aTurnOrder); gameMenu->addSeparator(); gameMenu->addAction(aRemoveLocalArrows); gameMenu->addAction(aRotateViewCW); @@ -1149,7 +1268,8 @@ void TabGame::createMenuItems() gameMenu->addSeparator(); - aCardMenu = gameMenu->addMenu(new QMenu(this)); + emptyCardMenu = new QMenu(this); + aCardMenu = gameMenu->addMenu(emptyCardMenu); // Sync the new action with the same state the dock button already shows. updateInviteButtonState(); @@ -1163,6 +1283,7 @@ void TabGame::createReplayMenuItems() aNextPhaseAction = nullptr; aNextTurn = nullptr; aReverseTurn = nullptr; + aTurnOrder = nullptr; aRemoveLocalArrows = nullptr; aRotateViewCW = nullptr; aRotateViewCCW = nullptr; @@ -1180,6 +1301,7 @@ void TabGame::createReplayMenuItems() gameMenu->addAction(aLeaveGame); aCardMenu = nullptr; + emptyCardMenu = nullptr; addTabMenu(gameMenu); } @@ -1192,6 +1314,10 @@ void TabGame::createViewMenuItems() registerDockWidget(viewMenu, messageLayoutDock, {250, 200}); registerDockWidget(viewMenu, playerListDock, {250, 50}); + if (historyDock) { + registerDockWidget(viewMenu, historyDock, {280, 400}); + } + if (replayDock) { registerDockWidget(viewMenu, replayDock, {900, 100}); } @@ -1261,6 +1387,12 @@ void TabGame::actResetLayout() addDockWidget(Qt::RightDockWidgetArea, playerListDock); addDockWidget(Qt::RightDockWidgetArea, messageLayoutDock); + if (historyDock) { + historyDock->setVisible(true); + historyDock->setFloating(false); + addDockWidget(Qt::LeftDockWidgetArea, historyDock); + } + if (replayDock) { replayDock->setVisible(true); replayDock->setFloating(false); @@ -1269,6 +1401,9 @@ void TabGame::actResetLayout() cardInfoDock->resize(250, 360); messageLayoutDock->resize(250, 200); playerListDock->resize(250, 50); + if (historyDock) { + historyDock->resize(280, 400); + } replayDock->resize(900, 100); } else { cardInfoDock->resize(250, 360); @@ -1293,6 +1428,7 @@ void TabGame::createPlayAreaWidget(bool bReplay) scene = new GameScene(phasesToolbar, this); connect(game->getPlayerManager(), &PlayerManager::playerConceded, scene, &GameScene::rearrange); connect(game->getPlayerManager(), &PlayerManager::playerCountChanged, scene, &GameScene::rearrange); + connect(game->getGameState(), &GameState::seatOrderChanged, scene, &GameScene::rearrange); connect(scene, &GameScene::arrowDeletionRequested, game->getGameEventHandler(), &GameEventHandler::handleArrowDeletion); connect(game->getGameEventHandler(), &GameEventHandler::arrowDeleted, scene, &GameScene::deleteArrow); @@ -1322,6 +1458,22 @@ void TabGame::createReplayDock(GameReplay *replay) [this](const auto &event, auto options) { game->getGameEventHandler()->processGameEventContainer(event, nullptr, options); }); + + auto *historySidebar = new ReplayEventSidebar(nullptr, replay, replayWidget->getReplayTimeline()); + historyDock = new QDockWidget(this); + historyDock->setObjectName("historyDock"); + historyDock->setFeatures(QDockWidget::DockWidgetClosable | QDockWidget::DockWidgetFloatable | + QDockWidget::DockWidgetMovable); + historyDock->setWidget(historySidebar); + historyDock->setFloating(false); + + connect(historySidebar, &ReplayEventSidebar::eventSelected, replayWidget, &ReplayWidget::seekToEvent); + // The sidebar highlights events rather than times, so ask the manager which event the game state has reached. + connect(replayWidget, &ReplayWidget::timeChanged, historySidebar, + [this, historySidebar] { historySidebar->setCurrentEvent(replayWidget->getLastProcessedEventIndex()); }); + + // No event has been replayed yet when the sidebar is connected, so apply the initial state explicitly. + historySidebar->setCurrentEvent(replayWidget->getLastProcessedEventIndex()); } void TabGame::createDeckViewContainerWidget(bool bReplay) diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.h b/cockatrice/src/interface/widgets/tabs/tab_game.h index 7bc52202f..5c2af82b4 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.h +++ b/cockatrice/src/interface/widgets/tabs/tab_game.h @@ -50,6 +50,7 @@ class AbstractClient; class GameView; class GameScene; class ReplayWidget; +class ReplayEventSidebar; class CardInfoFrameWidget; class QLabel; class QPushButton; @@ -89,16 +90,19 @@ private: QMap deckViewContainers; QVBoxLayout *deckViewContainerLayout; QWidget *gamePlayAreaWidget, *deckViewContainerWidget; - QDockWidget *cardInfoDock, *messageLayoutDock, *playerListDock, *replayDock; + QDockWidget *cardInfoDock, *messageLayoutDock, *playerListDock, *replayDock, *historyDock; QAction *playersSeparator; QMenu *gameMenu, *viewMenu; TearOffMenu *phasesMenu; QAction *aGameInfo, *aConcede, *aCopyGameLink, *aLeaveGame, *aNextPhase, *aNextPhaseAction, *aNextTurn, - *aReverseTurn, *aRemoveLocalArrows, *aRotateViewCW, *aRotateViewCCW, *aResetLayout, *aResetReplayLayout; + *aReverseTurn, *aTurnOrder, *aRemoveLocalArrows, *aRotateViewCW, *aRotateViewCCW, *aResetLayout, + *aResetReplayLayout; QAction *aFocusChat; QAction *aInviteToGame = nullptr; QList phaseActions; QAction *aCardMenu; + /** @brief Placeholder submenu shown when no card is selected; reused instead of reallocated. */ + QMenu *emptyCardMenu = nullptr; /** * @brief The actions associated with managing a QDockWidget @@ -129,6 +133,7 @@ private: PlayerLogic *setActivePlayer(int id); void setActivePhase(int phase); + bool canSetTurnOrder() const; void createMenuItems(); void createReplayMenuItems(); void createViewMenuItems(); @@ -162,6 +167,8 @@ private slots: void actInviteToGame(); void actConcede(); void actCopyGameLink(); + void actTurnOrder(); + void updateTurnOrderAction(); void actRemoveLocalArrows(); void actRotateViewCW(); void actRotateViewCCW(); diff --git a/cockatrice/src/interface/widgets/tabs/tab_home.cpp b/cockatrice/src/interface/widgets/tabs/tab_home.cpp index af9994fdd..3ad16aa91 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_home.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_home.cpp @@ -6,6 +6,7 @@ class TabSupervisor; TabHome::TabHome(TabSupervisor *_tabSupervisor, AbstractClient *_client) : Tab(_tabSupervisor), client(_client) { + setObjectName("TabHome"); homeWidget = new HomeWidget(this, tabSupervisor); setCentralWidget(homeWidget); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp index be1adeef8..8568b0d2e 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp @@ -1,11 +1,14 @@ #include "tab_moderation.h" +#include "../utility/epoch_format.h" +#include "../utility/table_activation.h" #include "abstract_client.h" #include "libcockatrice/protocol/pb/admin_commands.pb.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/serverinfo_moderator_login.pb.h" #include "libcockatrice/protocol/pb/serverinfo_user_alt.pb.h" #include "libcockatrice/protocol/pb/serverinfo_user_session.pb.h" +#include "tab_supervisor.h" #include #include @@ -21,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -99,38 +103,67 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli topBar->addWidget(removeBannerButton); userInfoGroup = new QGroupBox; - userInfoNameLabel = new QLabel; userInfoNameValue = new QLabel; - userInfoRegisteredLabel = new QLabel; - userInfoRegisteredValue = new QLabel; - userInfoLastLoginLabel = new QLabel; - userInfoLastLoginValue = new QLabel; - userInfoStatusLabel = new QLabel; + QFont displayFont = userInfoNameValue->font(); + displayFont.setPointSize(displayFont.pointSize() + 6); + displayFont.setBold(true); + userInfoNameValue->setFont(displayFont); userInfoStatusValue = new QLabel; - userInfoCountsLabel = new QLabel; - userInfoCountsValue = new QLabel; + QFont statusFont = userInfoStatusValue->font(); + statusFont.setBold(true); + userInfoStatusValue->setFont(statusFont); + sublineLabel = new QLabel; + + auto makeCountCard = [](const QString &title, QLabel *&value) { + auto *card = new QGroupBox(title); + value = new QLabel(QStringLiteral("–"), card); + QFont cardFont = value->font(); + cardFont.setPointSize(cardFont.pointSize() + 6); + cardFont.setBold(true); + value->setFont(cardFont); + value->setAlignment(Qt::AlignCenter); + auto *cardLayout = new QVBoxLayout(card); + cardLayout->addWidget(value); + return card; + }; + cardReports = makeCountCard(QString(), cardReportsValue); + cardWarns = makeCountCard(QString(), cardWarnsValue); + cardBans = makeCountCard(QString(), cardBansValue); + + altsCard = new QGroupBox; + altsCountValue = new QLabel(QStringLiteral("–"), altsCard); + QFont altsFont = altsCountValue->font(); + altsFont.setPointSize(altsFont.pointSize() + 6); + altsFont.setBold(true); + altsCountValue->setFont(altsFont); + altsCountValue->setAlignment(Qt::AlignCenter); + openAltsButton = new QPushButton; + connect(openAltsButton, &QPushButton::clicked, this, &TabModeration::openAltsGraph); + auto *altsCardLayout = new QVBoxLayout(altsCard); + altsCardLayout->addWidget(altsCountValue); + altsCardLayout->addWidget(openAltsButton); + + auto *cardsRow = new QHBoxLayout; + cardsRow->addWidget(cardReports); + cardsRow->addWidget(cardWarns); + cardsRow->addWidget(cardBans); + cardsRow->addWidget(altsCard); + userInfoNotesLabel = new QLabel; userInfoNotesEdit = new QTextEdit; userInfoNotesEdit->setReadOnly(true); - auto *infoGrid = new QGridLayout; - infoGrid->addWidget(userInfoNameLabel, 0, 0); - infoGrid->addWidget(userInfoNameValue, 0, 1); - infoGrid->addWidget(userInfoRegisteredLabel, 0, 2); - infoGrid->addWidget(userInfoRegisteredValue, 0, 3); - infoGrid->addWidget(userInfoLastLoginLabel, 1, 0); - infoGrid->addWidget(userInfoLastLoginValue, 1, 1); - infoGrid->addWidget(userInfoStatusLabel, 1, 2); - infoGrid->addWidget(userInfoStatusValue, 1, 3); - infoGrid->addWidget(userInfoCountsLabel, 2, 0); - infoGrid->addWidget(userInfoCountsValue, 2, 1, 1, 3); - infoGrid->addWidget(userInfoNotesLabel, 3, 0, Qt::AlignTop); - infoGrid->addWidget(userInfoNotesEdit, 3, 1, 1, 3); - infoGrid->setColumnStretch(1, 1); - infoGrid->setColumnStretch(3, 1); + auto *identityRow = new QHBoxLayout; + identityRow->addWidget(userInfoNameValue); + identityRow->addStretch(); + identityRow->addWidget(userInfoStatusValue); auto *infoLayout = new QVBoxLayout(userInfoGroup); - infoLayout->addLayout(infoGrid); + infoLayout->addLayout(identityRow); + infoLayout->addWidget(sublineLabel); + infoLayout->addLayout(cardsRow); + infoLayout->addWidget(userInfoNotesLabel); + infoLayout->addWidget(userInfoNotesEdit); auto configureTable = [](QTableWidget *table) { table->setSelectionBehavior(QAbstractItemView::SelectRows); @@ -142,19 +175,27 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli table->horizontalHeader()->setStretchLastSection(true); }; - altsGroup = new QGroupBox; altsTable = new QTableWidget(0, COL_ALTS_COUNT); configureTable(altsTable); - auto *altsLayout = new QVBoxLayout(altsGroup); + connectTableActivation(altsTable, this, [this](const QModelIndex &index) { + if (!index.isValid()) { + return; + } + auto *userItem = altsTable->item(index.row(), COL_ALTS_USER); + if (userItem != nullptr && !userItem->text().isEmpty()) { + getTabSupervisor()->openTabAlts(userItem->text()); + } + }); + auto *altsPage = new QWidget; + auto *altsLayout = new QVBoxLayout(altsPage); altsLayout->addWidget(altsTable); - sessionsGroup = new QGroupBox; sessionsTable = new QTableWidget(0, COL_SESSIONS_COUNT); configureTable(sessionsTable); - auto *sessionsLayout = new QVBoxLayout(sessionsGroup); + auto *sessionsPage = new QWidget; + auto *sessionsLayout = new QVBoxLayout(sessionsPage); sessionsLayout->addWidget(sessionsTable); - staffGroup = new QGroupBox; staffTable = new QTableWidget(0, COL_STAFF_COUNT); configureTable(staffTable); refreshStaffButton = new QPushButton; @@ -162,19 +203,21 @@ TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_cli auto *staffHeader = new QHBoxLayout; staffHeader->addStretch(); staffHeader->addWidget(refreshStaffButton); - auto *staffLayout = new QVBoxLayout(staffGroup); + auto *staffPage = new QWidget; + auto *staffLayout = new QVBoxLayout(staffPage); staffLayout->addWidget(staffTable); staffLayout->addLayout(staffHeader); + sectionTabs = new QTabWidget; + sectionTabs->addTab(altsPage, QString()); + sectionTabs->addTab(sessionsPage, QString()); + sectionTabs->addTab(staffPage, QString()); + auto *splitter = new QSplitter(Qt::Vertical); splitter->addWidget(userInfoGroup); - splitter->addWidget(altsGroup); - splitter->addWidget(sessionsGroup); - splitter->addWidget(staffGroup); + splitter->addWidget(sectionTabs); splitter->setStretchFactor(0, 1); splitter->setStretchFactor(1, 2); - splitter->setStretchFactor(2, 2); - splitter->setStretchFactor(3, 1); auto *mainLayout = new QVBoxLayout(centralWidget); mainLayout->addLayout(topBar); @@ -196,45 +239,56 @@ void TabModeration::retranslateUi() refreshStaffButton->setText(tr("Refresh")); userInfoGroup->setTitle(tr("User Info")); - userInfoNameLabel->setText(tr("Name:")); - userInfoRegisteredLabel->setText(tr("Registered:")); - userInfoLastLoginLabel->setText(tr("Last login:")); - userInfoStatusLabel->setText(tr("Status:")); - userInfoCountsLabel->setText(tr("Counts:")); userInfoNotesLabel->setText(tr("Admin notes:")); + cardReports->setTitle(tr("Reports")); + cardWarns->setTitle(tr("Warnings")); + cardBans->setTitle(tr("Bans")); + altsCard->setTitle(tr("Alts")); + openAltsButton->setText(tr("Open graph")); + openAltsButton->setToolTip(tr("Open this user in the User Alts tab")); - altsGroup->setTitle(tr("Alts")); - sessionsGroup->setTitle(tr("Sessions")); - staffGroup->setTitle(tr("Staff Last Logins")); + sectionTabs->setTabText(0, tr("Alts")); + sectionTabs->setTabText(1, tr("Sessions")); + sectionTabs->setTabText(2, tr("Staff Last Logins")); + + if (cachedUserInfoValid) { + applyUserInfo(); + } + if (cachedAltsCount >= 0) { + applyAltsCount(); + } altsTable->setHorizontalHeaderLabels({tr("User"), tr("eMail"), tr("Client ID"), tr("Registered"), tr("Last login"), tr("Warns"), tr("Bans"), tr("Active")}); + altsTable->setToolTip(tr("Double-click or press Enter on a row to open it in the User Alts tab")); sessionsTable->setHorizontalHeaderLabels({tr("IP"), tr("Client ID"), tr("Start"), tr("End"), tr("Type")}); staffTable->setHorizontalHeaderLabels({tr("User"), tr("Level"), tr("Last login")}); } QString TabModeration::formatEpoch(quint64 ts) const { - if (ts == 0) { - return tr("Unknown"); - } - return QDateTime::fromSecsSinceEpoch(ts).toLocalTime().toString("yyyy-MM-dd HH:mm"); + return formatEpochTimestamp(ts, tr("Unknown")); } void TabModeration::clearUserData() { currentUser.clear(); + cachedUserInfoValid = false; + cachedAltsCount = -1; userInfoNameValue->clear(); - userInfoRegisteredValue->clear(); - userInfoLastLoginValue->clear(); userInfoStatusValue->clear(); - userInfoCountsValue->clear(); + sublineLabel->clear(); + cardReportsValue->setText(QStringLiteral("–")); + cardWarnsValue->setText(QStringLiteral("–")); + cardBansValue->setText(QStringLiteral("–")); + altsCountValue->setText(QStringLiteral("–")); userInfoNotesEdit->clear(); altsTable->setRowCount(0); sessionsTable->setRowCount(0); resetPasswordButton->setEnabled(false); removeAvatarButton->setEnabled(false); removeBannerButton->setEnabled(false); + openAltsButton->setEnabled(false); } void TabModeration::investigate(const QString &userName) @@ -253,9 +307,13 @@ void TabModeration::investigateUser() return; } currentUser = userName; + cachedUserInfoValid = false; + cachedAltsCount = -1; resetPasswordButton->setEnabled(true); removeAvatarButton->setEnabled(true); removeBannerButton->setEnabled(true); + openAltsButton->setEnabled(true); + altsCountValue->setText(QStringLiteral("…")); altsTable->setRowCount(0); sessionsTable->setRowCount(0); requestUserInfo(userName); @@ -263,13 +321,22 @@ void TabModeration::investigateUser() requestAlts(userName); } +void TabModeration::openAltsGraph() +{ + if (currentUser.isEmpty()) { + return; + } + getTabSupervisor()->openTabAlts(currentUser); +} + void TabModeration::requestUserInfo(const QString &userName) { userInfoNameValue->setText(userName); - userInfoRegisteredValue->setText(tr("Loading...")); - userInfoLastLoginValue->setText(tr("Loading...")); + sublineLabel->setText(tr("Loading...")); userInfoStatusValue->clear(); - userInfoCountsValue->clear(); + cardReportsValue->setText(QStringLiteral("…")); + cardWarnsValue->setText(QStringLiteral("…")); + cardBansValue->setText(QStringLiteral("…")); userInfoNotesEdit->clear(); Command_ReportUserInfo cmd; @@ -282,9 +349,12 @@ void TabModeration::requestUserInfo(const QString &userName) void TabModeration::userInfoResponse(const Response &response) { if (response.response_code() != Response::RespOk) { - userInfoRegisteredValue->clear(); - userInfoLastLoginValue->clear(); + cachedUserInfoValid = false; + sublineLabel->clear(); userInfoStatusValue->setText(tr("Error loading user info.")); + cardReportsValue->setText(QStringLiteral("–")); + cardWarnsValue->setText(QStringLiteral("–")); + cardBansValue->setText(QStringLiteral("–")); return; } @@ -293,20 +363,28 @@ void TabModeration::userInfoResponse(const Response &response) return; } - userInfoRegisteredValue->setText(formatEpoch(resp.registration_time())); - userInfoLastLoginValue->setText(formatEpoch(resp.last_login())); + cachedUserInfo.CopyFrom(resp); + cachedUserInfoValid = true; + applyUserInfo(); +} + +void TabModeration::applyUserInfo() +{ + const Response_ReportUserInfo &resp = cachedUserInfo; + sublineLabel->setText(tr("Registered %1 · Last login %2") + .arg(formatEpoch(resp.registration_time())) + .arg(formatEpoch(resp.last_login()))); QStringList statusParts; statusParts << (resp.is_active() ? tr("active") : tr("inactive")); if (resp.has_is_admin() && resp.is_admin()) { statusParts << tr("admin"); } - userInfoStatusValue->setText(statusParts.join(", ")); + userInfoStatusValue->setText(statusParts.join(QStringLiteral(", "))); - userInfoCountsValue->setText(tr("Reports: %1 Bans: %2 Warnings: %3") - .arg(resp.total_reports()) - .arg(resp.total_bans()) - .arg(resp.total_warns())); + cardReportsValue->setText(QString::number(resp.total_reports())); + cardWarnsValue->setText(QString::number(resp.total_warns())); + cardBansValue->setText(QString::number(resp.total_bans())); if (resp.has_admin_notes() && !resp.admin_notes().empty()) { userInfoNotesEdit->setPlainText(QString::fromStdString(resp.admin_notes())); @@ -315,12 +393,21 @@ void TabModeration::userInfoResponse(const Response &response) } } +void TabModeration::applyAltsCount() +{ + altsCountValue->setText(tr("%1 related").arg(cachedAltsCount)); +} + void TabModeration::requestSessions(const QString &userName) { Command_GetUserSessions cmd; cmd.set_user_name(userName.toStdString()); PendingCommand *pend = client->prepareModeratorCommand(cmd); - connect(pend, &PendingCommand::finished, this, &TabModeration::sessionsResponse); + connect(pend, &PendingCommand::finished, this, [this, userName](const Response &response) { + if (userName == currentUser) { + sessionsResponse(response); + } + }); client->sendCommand(pend); } @@ -352,7 +439,11 @@ void TabModeration::requestAlts(const QString &userName) Command_GetUserAlts cmd; cmd.set_user_name(userName.toStdString()); PendingCommand *pend = client->prepareModeratorCommand(cmd); - connect(pend, &PendingCommand::finished, this, &TabModeration::altsResponse); + connect(pend, &PendingCommand::finished, this, [this, userName](const Response &response) { + if (userName == currentUser) { + altsResponse(response); + } + }); client->sendCommand(pend); } @@ -360,10 +451,21 @@ void TabModeration::altsResponse(const Response &response) { altsTable->setRowCount(0); if (response.response_code() != Response::RespOk) { + cachedAltsCount = -1; + altsCountValue->setText(QStringLiteral("–")); return; } const Response_UserAlts &resp = response.GetExtension(Response_UserAlts::ext); + // The server always includes the frontier user as a depth-0 seed row. + int relatedCount = 0; + for (int i = 0; i < resp.alts_size(); ++i) { + if (!resp.alts(i).has_depth() || resp.alts(i).depth() > 0) { + ++relatedCount; + } + } + cachedAltsCount = relatedCount; + applyAltsCount(); altsTable->setRowCount(resp.alts_size()); for (int i = 0; i < resp.alts_size(); ++i) { const ServerInfo_UserAlt &alt = resp.alts(i); diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.h b/cockatrice/src/interface/widgets/tabs/tab_moderation.h index ce23b5a7a..9ba123493 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_moderation.h +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.h @@ -5,6 +5,7 @@ #include #include +#include #include class AbstractClient; @@ -13,6 +14,7 @@ class QLabel; class QLineEdit; class QPushButton; class QTableWidget; +class QTabWidget; class QTextEdit; class Response; class TabSupervisor; @@ -36,6 +38,7 @@ public: private slots: void investigateUser(); + void openAltsGraph(); void userInfoResponse(const Response &response); void sessionsResponse(const Response &response); void altsResponse(const Response &response); @@ -53,10 +56,15 @@ private: void requestAlts(const QString &userName); void requestModeratorLogins(); void clearUserData(); + void applyUserInfo(); + void applyAltsCount(); [[nodiscard]] QString formatEpoch(quint64 ts) const; AbstractClient *client; QString currentUser; + Response_ReportUserInfo cachedUserInfo; + bool cachedUserInfoValid = false; + int cachedAltsCount = -1; QLineEdit *searchEdit; QPushButton *investigateButton; @@ -65,25 +73,25 @@ private: QPushButton *removeBannerButton; QGroupBox *userInfoGroup; - QLabel *userInfoNameLabel; QLabel *userInfoNameValue; - QLabel *userInfoRegisteredLabel; - QLabel *userInfoRegisteredValue; - QLabel *userInfoLastLoginLabel; - QLabel *userInfoLastLoginValue; - QLabel *userInfoStatusLabel; QLabel *userInfoStatusValue; - QLabel *userInfoCountsLabel; - QLabel *userInfoCountsValue; + QLabel *sublineLabel; + QGroupBox *cardReports; + QGroupBox *cardWarns; + QGroupBox *cardBans; + QGroupBox *altsCard; + QLabel *cardReportsValue; + QLabel *cardWarnsValue; + QLabel *cardBansValue; + QLabel *altsCountValue; QLabel *userInfoNotesLabel; QTextEdit *userInfoNotesEdit; - QGroupBox *altsGroup; + QTabWidget *sectionTabs; QTableWidget *altsTable; - QGroupBox *sessionsGroup; QTableWidget *sessionsTable; - QGroupBox *staffGroup; QTableWidget *staffTable; + QPushButton *openAltsButton; QPushButton *refreshStaffButton; }; diff --git a/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp index ce37ce440..dfde08f8e 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp @@ -2,8 +2,10 @@ #include "../../../client/settings/cache_settings.h" #include "../../deck_loader/deck_loader.h" +#include "../../theme_icon.h" #include "../cards/card_size_widget.h" #include "../general/layout_containers/flow_widget.h" +#include "../interface/theme_manager.h" #include "../visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h" #include "../visual_deck_storage/deck_preview/public_deck_preview_widget.h" #include "../visual_deck_storage/remote_public_decks_model.h" @@ -45,15 +47,25 @@ TabPublicDecks::TabPublicDecks(TabSupervisor *_tabSupervisor, AbstractClient *_c cardSize = SettingsCache::instance().cardsDisplay().getVisualDeckStorageCardSize(); titleLabel = new QLabel(tr("Public decks of %1").arg(userName.toHtmlEscaped()), this); - titleLabel->setFont(QtUtils::boldFont(titleLabel->font())); + // Family comes from the InterfaceDefault theme role; bold stays caller-side. + auto applyTitleFont = [this]() { + titleLabel->setFont(QtUtils::boldFont(themeManager->appFont(AppFont::InterfaceDefault))); + }; + applyTitleFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, [this, applyTitleFont] { + applyTitleFont(); + // Preview cells paint with the live role font; repaint the grid so the + // new family shows without waiting for a scroll or hover. + flowWidget->update(); + }); auto *headerLayout = new QHBoxLayout; headerLayout->addWidget(titleLabel); headerLayout->addStretch(1); // Filter/toolbar row, matching the Visual Deck Storage: color identity filter - // first, the search bar stretching in the middle, and the quick settings - // cogwheel at the end. The card size slider lives inside the cogwheel popup. + // first, the search bar stretching in the middle, and the quick-settings button + // at the end. The card size slider lives inside its popup. emptyLabel = new QLabel(tr("This user has not published any decks."), this); emptyLabel->setAlignment(Qt::AlignCenter); emptyLabel->setVisible(false); @@ -68,7 +80,7 @@ TabPublicDecks::TabPublicDecks(TabSupervisor *_tabSupervisor, AbstractClient *_c colorIdentityFilter = new DeckPreviewColorIdentityFilterWidget(this); searchWidget = new VisualDeckStorageSearchWidget(this); refreshButton = new QToolButton(this); - refreshButton->setIcon(QPixmap("theme:icons/reload")); + refreshButton->setIcon(loadThemedIcon(QStringLiteral("icons/reload"))); refreshButton->setFixedSize(32, 32); quickSettingsWidget = new VisualDeckStorageQuickSettingsWidget(this); quickSettingsWidget->setPublicDecksMode(true); diff --git a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp index d37bff3d8..e33f1754e 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp @@ -1,7 +1,7 @@ #include "tab_replays.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/widgets/server/remote/remote_replay_list_tree_widget.h" #include "libcockatrice/protocol/pb/commands.pb.h" #include "libcockatrice/protocol/pb/serverinfo_replay.pb.h" @@ -145,17 +145,18 @@ QGroupBox *TabReplays::createLeftLayout() // Left side actions aOpenLocalReplay = new QAction(this); - aOpenLocalReplay->setIcon(themePixmap(QStringLiteral("icons/view"))); + aOpenLocalReplay->setIcon(loadThemedIcon(QStringLiteral("icons/view"))); connect(aOpenLocalReplay, &QAction::triggered, this, &TabReplays::actOpenLocalReplay); connect(localDirView, &QTreeView::doubleClicked, this, &TabReplays::actOpenLocalReplay); aRenameLocal = new QAction(this); - aRenameLocal->setIcon(themePixmap(QStringLiteral("icons/rename"))); + aRenameLocal->setIcon(loadThemedIcon(QStringLiteral("icons/rename"))); connect(aRenameLocal, &QAction::triggered, this, &TabReplays::actRenameLocal); aNewLocalFolder = new QAction(this); aNewLocalFolder->setIcon(qApp->style()->standardIcon(QStyle::SP_FileDialogNewFolder)); connect(aNewLocalFolder, &QAction::triggered, this, &TabReplays::actNewLocalFolder); aDeleteLocalReplay = new QAction(this); - aDeleteLocalReplay->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + aDeleteLocalReplay->setIcon( + loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker)); connect(aDeleteLocalReplay, &QAction::triggered, this, &TabReplays::actDeleteLocalReplay); aOpenReplaysFolder = new QAction(this); @@ -207,24 +208,25 @@ QGroupBox *TabReplays::createRightLayout() // Right side actions aOpenRemoteReplay = new QAction(this); - aOpenRemoteReplay->setIcon(themePixmap(QStringLiteral("icons/view"))); + aOpenRemoteReplay->setIcon(loadThemedIcon(QStringLiteral("icons/view"))); connect(aOpenRemoteReplay, &QAction::triggered, this, &TabReplays::actOpenRemoteReplay); connect(serverDirView, &QTreeView::doubleClicked, this, &TabReplays::actOpenRemoteReplay); aDownload = new QAction(this); - aDownload->setIcon(themePixmap(QStringLiteral("icons/arrow_left_green"))); + aDownload->setIcon(loadThemedIcon(QStringLiteral("icons/arrow_left"))); connect(aDownload, &QAction::triggered, this, &TabReplays::actDownload); aKeep = new QAction(this); - aKeep->setIcon(themePixmap(QStringLiteral("icons/lock"))); + aKeep->setIcon(loadThemedIcon(QStringLiteral("icons/lock"))); connect(aKeep, &QAction::triggered, this, &TabReplays::actKeepRemoteReplay); aDeleteRemoteReplay = new QAction(this); - aDeleteRemoteReplay->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + aDeleteRemoteReplay->setIcon( + loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker)); connect(aDeleteRemoteReplay, &QAction::triggered, this, &TabReplays::actDeleteRemoteReplay); aGetReplayCode = new QAction(this); - aGetReplayCode->setIcon(themePixmap(QStringLiteral("icons/share"))); + aGetReplayCode->setIcon(loadThemedIcon(QStringLiteral("icons/share"), AppColor::Informational)); connect(aGetReplayCode, &QAction::triggered, this, &TabReplays::actGetReplayCode); aSubmitReplayCode = new QAction(this); - aSubmitReplayCode->setIcon(themePixmap(QStringLiteral("icons/search"))); + aSubmitReplayCode->setIcon(loadThemedIcon(QStringLiteral("icons/search"))); connect(aSubmitReplayCode, &QAction::triggered, this, &TabReplays::actSubmitReplayCode); // Add actions to toolbars diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.cpp b/cockatrice/src/interface/widgets/tabs/tab_report.cpp index 988c3e7d9..d934a340b 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_report.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_report.cpp @@ -1,9 +1,11 @@ #include "tab_report.h" +#include "../interface/theme_manager.h" #include "../server/user/user_context_menu.h" #include "../utility/report_utils.h" #include "abstract_client.h" #include "libcockatrice/protocol/pb/response.pb.h" +#include "report_status_delegate.h" #include "tab_supervisor.h" #include @@ -12,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -22,7 +25,7 @@ #include #include #include -#include +#include #include #include #include @@ -124,25 +127,43 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T connect(table, &QTableWidget::itemSelectionChanged, this, &TabReport::onSelectionChanged); table->setContextMenuPolicy(Qt::CustomContextMenu); connect(table, &QTableWidget::customContextMenuRequested, this, &TabReport::showTableContextMenu); + table->setItemDelegateForColumn(COL_STATUS, new ReportStatusDelegate(table)); userContextMenu = new UserContextMenu(tabSupervisor, this); connect(userContextMenu, &UserContextMenu::openMessageDialog, this, &TabReport::openMessageDialog); - descGroup = new QGroupBox; + reportHeaderName = new QLabel; + QFont displayFont = reportHeaderName->font(); + displayFont.setPointSize(displayFont.pointSize() + 6); + displayFont.setBold(true); + reportHeaderName->setFont(displayFont); + reportHeaderStatus = new QLabel; + QFont statusFont = reportHeaderStatus->font(); + statusFont.setBold(true); + reportHeaderStatus->setFont(statusFont); + reportHeaderSubline = new QLabel; + + auto *reportIdentityRow = new QHBoxLayout; + reportIdentityRow->addWidget(reportHeaderName); + reportIdentityRow->addStretch(); + reportIdentityRow->addWidget(reportHeaderStatus); + + auto *descPage = new QWidget; descriptionEdit = new QTextEdit; descriptionEdit->setReadOnly(true); - auto *descLayout = new QVBoxLayout(descGroup); + auto *descLayout = new QVBoxLayout(descPage); descLayout->setContentsMargins(4, 4, 4, 4); descLayout->addWidget(descriptionEdit); - chatGroup = new QGroupBox; + auto *chatPage = new QWidget; chatLogEdit = new QTextEdit; chatLogEdit->setReadOnly(true); - chatLogEdit->setFont(QtUtils::monospaceFont()); - auto *chatLayout = new QVBoxLayout(chatGroup); + applyChatLogFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, &TabReport::applyChatLogFont); + auto *chatLayout = new QVBoxLayout(chatPage); chatLayout->setContentsMargins(4, 4, 4, 4); chatLayout->addWidget(chatLogEdit); - commentsGroup = new QGroupBox; + auto *commentsPage = new QWidget; commentsEdit = new QTextEdit; commentsEdit->setReadOnly(true); auto *commentInputRow = new QHBoxLayout; @@ -153,12 +174,12 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T connect(commentInput, &QLineEdit::returnPressed, this, &TabReport::addComment); commentInputRow->addWidget(commentInput); commentInputRow->addWidget(commentButton); - auto *commentsLayout = new QVBoxLayout(commentsGroup); + auto *commentsLayout = new QVBoxLayout(commentsPage); commentsLayout->setContentsMargins(4, 4, 4, 4); commentsLayout->addWidget(commentsEdit); commentsLayout->addLayout(commentInputRow); - userContextGroup = new QGroupBox; + auto *userPage = new QWidget; userContextName = new QLabel; userContextAccountAge = new QLabel; userContextReports = new QLabel; @@ -177,7 +198,7 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T userContextWarnsLabel = new QLabel; userContextNotesLabel = new QLabel; userContextRecentReportsLabel = new QLabel; - auto *ucLayout = new QGridLayout(userContextGroup); + auto *ucLayout = new QGridLayout(userPage); ucLayout->setContentsMargins(8, 8, 8, 8); ucLayout->addWidget(userContextUserLabel, 0, 0); ucLayout->addWidget(userContextName, 0, 1, 1, 3); @@ -193,7 +214,6 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T ucLayout->addWidget(userContextNotes, 3, 1, 1, 3); ucLayout->addWidget(userContextRecentReportsLabel, 4, 0, 1, 4); ucLayout->addWidget(userContextRecentReports, 5, 0, 1, 4); - userContextGroup->setVisible(false); statsGroup = new QGroupBox; statsGroup->setCheckable(true); @@ -220,13 +240,11 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T } }); - detailSplitter = new QSplitter(Qt::Vertical); - detailSplitter->addWidget(descGroup); - detailSplitter->addWidget(chatGroup); - detailSplitter->addWidget(commentsGroup); - detailSplitter->setStretchFactor(0, 1); - detailSplitter->setStretchFactor(1, 1); - detailSplitter->setStretchFactor(2, 2); + detailTabs = new QTabWidget; + detailTabs->addTab(descPage, QString()); + detailTabs->addTab(chatPage, QString()); + detailTabs->addTab(commentsPage, QString()); + detailTabs->addTab(userPage, QString()); assignButton = new QPushButton; resolveButton = new QPushButton; @@ -258,17 +276,24 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T layout->addLayout(topBar); layout->addWidget(statsLabel); layout->addWidget(table, 2); - layout->addWidget(detailSplitter, 1); - layout->addWidget(userContextGroup); + layout->addLayout(reportIdentityRow); + layout->addWidget(reportHeaderSubline); + layout->addWidget(detailTabs, 2); layout->addWidget(statsGroup); layout->addLayout(actionBar); retranslateUi(); + clearReportHeader(); setActionsEnabled(false); refreshList(); } +void TabReport::applyChatLogFont() +{ + chatLogEdit->setFont(report_utils::reportLogFont()); +} + void TabReport::retranslateUi() { userContextMenu->retranslateUi(); @@ -285,18 +310,18 @@ void TabReport::retranslateUi() table->setHorizontalHeaderLabels({tr("#"), tr("Time"), tr("Reporter"), tr("Reported User"), tr("Category"), tr("Game ID"), tr("Status"), tr("Assigned To"), tr("Replay"), tr("Room")}); - descGroup->setTitle(tr("Description")); - chatGroup->setTitle(tr("Chat Log Context")); - chatGroup->setToolTip( - tr("Chat log attached by the reporter. It is captured from their client and may be incomplete or edited.")); - commentsGroup->setTitle(tr("Comments / Thread")); + detailTabs->setTabText(0, tr("Description")); + detailTabs->setTabText(1, tr("Chat Log Context")); + detailTabs->setTabToolTip(1, tr("Chat log attached by the reporter. It is captured from their client and may be " + "incomplete or edited.")); + detailTabs->setTabText(2, tr("Comments / Thread")); + detailTabs->setTabText(3, tr("Reported User")); descriptionEdit->setPlaceholderText(tr("No description.")); chatLogEdit->setPlaceholderText(tr("No chat log attached.")); commentsEdit->setPlaceholderText(tr("No comments yet.")); commentInput->setPlaceholderText(tr("Type a reply...")); commentButton->setText(tr("Send")); - userContextGroup->setTitle(tr("Reported User Context")); userContextUserLabel->setText(tr("User:")); userContextAgeLabel->setText(tr("Account Age:")); userContextReportsLabel->setText(tr("Reports:")); @@ -304,10 +329,18 @@ void TabReport::retranslateUi() userContextWarnsLabel->setText(tr("Warns:")); userContextNotesLabel->setText(tr("Admin Notes:")); userContextRecentReportsLabel->setText(tr("Recent Reports Against User:")); - userContextAccountAge->setText(QString()); - userContextReports->setText(QString()); - userContextBans->setText(QString()); - userContextWarns->setText(QString()); + + if (headerReportId >= 0) { + for (const ServerInfo_Report &r : filteredReports) { + if (r.report_id() == headerReportId) { + updateReportHeader(r); + const QString status = QString::fromStdString(r.status()); + const bool canComment = (status == "open" || status == "assigned"); + commentInput->setPlaceholderText(canComment ? tr("Type a reply...") : tr("This report is closed.")); + break; + } + } + } statsGroup->setTitle(tr("Report Statistics")); @@ -400,7 +433,8 @@ void TabReport::reportListResponse(const Response &response) chatLogEdit->clear(); commentsEdit->clear(); setActionsEnabled(false); - userContextGroup->setVisible(false); + clearReportHeader(); + clearUserContext(); } } @@ -437,11 +471,14 @@ void TabReport::applyFilters() table->setSortingEnabled(false); table->setRowCount(filteredReports.size()); + QFont reportedFont = table->font(); + reportedFont.setBold(true); + for (int row = 0; row < filteredReports.size(); ++row) { const ServerInfo_Report &r = filteredReports[row]; report_utils::fillReportTableRow(table, row, r, COL_ID, COL_TIME, COL_REPORTED, COL_CATEGORY, COL_GAMEID, - COL_STATUS, COL_ASSIGNED); + COL_STATUS, COL_ASSIGNED, false); table->setItem(row, COL_REPORTER, new QTableWidgetItem(QString::fromStdString(r.reporter_name()))); @@ -450,12 +487,30 @@ void TabReport::applyFilters() table->setItem(row, COL_ROOM, new QTableWidgetItem(r.has_room_id() && r.room_id() > 0 ? QString::number(r.room_id()) : "")); + + if (auto *reportedItem = table->item(row, COL_REPORTED)) { + reportedItem->setFont(reportedFont); + } } table->setSortingEnabled(true); table->resizeColumnsToContents(); table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch); + if (headerReportId >= 0) { + bool stillVisible = false; + for (const ServerInfo_Report &r : filteredReports) { + if (r.report_id() == headerReportId) { + stillVisible = true; + break; + } + } + if (!stillVisible) { + clearReportHeader(); + clearUserContext(); + } + } + statusLabel->setText(tr("%1 report(s)").arg(filteredReports.size())); } @@ -482,6 +537,31 @@ void TabReport::updateStats() .arg(dismissed)); } +void TabReport::updateReportHeader(const ServerInfo_Report &report) +{ + reportHeaderName->setText(QString::fromStdString(report.reported_user_name())); + reportHeaderStatus->setText(report_utils::formatReportStatus(QString::fromStdString(report.status()))); + + QStringList parts; + parts << QString::fromStdString(report.category()); + parts << QDateTime::fromSecsSinceEpoch(report.report_time()).toLocalTime().toString("yyyy-MM-dd HH:mm"); + if (report.has_room_id() && report.room_id() > 0) { + parts << tr("Room %1").arg(report.room_id()); + } + if (report.game_id() > 0) { + parts << tr("Game %1").arg(report.game_id()); + } + reportHeaderSubline->setText(parts.join(QStringLiteral(" · "))); +} + +void TabReport::clearReportHeader() +{ + headerReportId = -1; + reportHeaderName->setText(tr("Select a report to inspect.")); + reportHeaderStatus->clear(); + reportHeaderSubline->clear(); +} + void TabReport::onSelectionChanged() { const int row = table->currentRow(); @@ -492,7 +572,8 @@ void TabReport::onSelectionChanged() commentInput->clear(); commentButton->setEnabled(false); setActionsEnabled(false); - userContextGroup->setVisible(false); + clearReportHeader(); + clearUserContext(); return; } @@ -501,6 +582,18 @@ void TabReport::onSelectionChanged() updateActionStates(); } +void TabReport::clearUserContext() +{ + lastRequestedUser.clear(); + userContextName->setText(tr("—")); + userContextAccountAge->clear(); + userContextReports->clear(); + userContextBans->clear(); + userContextWarns->clear(); + userContextNotes->setPlainText(tr("Select a report to inspect.")); + userContextRecentReports->clear(); +} + void TabReport::showTableContextMenu(const QPoint &pos) { QTableWidgetItem *clickedItem = table->itemAt(pos); @@ -538,12 +631,17 @@ void TabReport::loadReportDetails(int reportId) for (const ServerInfo_Report &r : filteredReports) { if (r.report_id() == reportId) { descriptionEdit->setPlainText(QString::fromStdString(r.description())); + updateReportHeader(r); + if (headerReportId != reportId) { + headerReportId = reportId; + detailTabs->setCurrentIndex(0); + } QString reportedUser = QString::fromStdString(r.reported_user_name()); if (!reportedUser.isEmpty()) { requestUserInfo(reportedUser); } else { - userContextGroup->setVisible(false); + clearUserContext(); } QString status = QString::fromStdString(r.status()); @@ -863,12 +961,11 @@ void TabReport::addCommentResponse(const Response &response) void TabReport::requestUserInfo(const QString &userName) { if (userName.isEmpty()) { - userContextGroup->setVisible(false); + clearUserContext(); return; } lastRequestedUser = userName; - userContextGroup->setVisible(true); userContextName->setText(userName); userContextAccountAge->setText(tr("Loading...")); userContextReports->clear(); diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.h b/cockatrice/src/interface/widgets/tabs/tab_report.h index 32ebe1a3f..aea42648e 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_report.h +++ b/cockatrice/src/interface/widgets/tabs/tab_report.h @@ -16,8 +16,8 @@ class QGroupBox; class QLabel; class QLineEdit; class QPushButton; -class QSplitter; class QTableWidget; +class QTabWidget; class QTextEdit; class QTimer; class GameReplay; @@ -62,6 +62,7 @@ private slots: void statsResponse(const Response &response); void reopenReport(); void reopenResponse(const Response &response); + void applyChatLogFont(); private: int selectedReportId() const; @@ -71,6 +72,9 @@ private: void applyFilters(); void updateStats(); void loadReportDetails(int reportId); + void updateReportHeader(const ServerInfo_Report &report); + void clearReportHeader(); + void clearUserContext(); AbstractClient *client; @@ -80,10 +84,10 @@ private: QPushButton *refreshButton; QLabel *statsLabel; QTableWidget *table; - QSplitter *detailSplitter; - QGroupBox *descGroup; - QGroupBox *chatGroup; - QGroupBox *commentsGroup; + QLabel *reportHeaderName; + QLabel *reportHeaderStatus; + QLabel *reportHeaderSubline; + QTabWidget *detailTabs; QTextEdit *descriptionEdit; QTextEdit *chatLogEdit; QTextEdit *commentsEdit; @@ -99,7 +103,6 @@ private: QTimer *refreshTimer; UserContextMenu *userContextMenu; - QGroupBox *userContextGroup; QLabel *userContextName; QLabel *userContextAccountAge; QLabel *userContextReports; @@ -124,6 +127,7 @@ private: QList allReports; QList filteredReports; + int headerReportId = -1; int detailsRequestedReportId = -1; int detailsRequestSeq = 0; int selectedReportIdBeforeRefresh = -1; diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.cpp b/cockatrice/src/interface/widgets/tabs/tab_room.cpp index f63b77445..1973a0747 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_room.cpp @@ -2,7 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/widgets/dialogs/dlg_settings.h" #include "../interface/widgets/server/chat_view/chat_view.h" #include "../interface/widgets/server/game_link.h" @@ -113,7 +113,7 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, QMap tempMap; tempMap.insert(info.room_id(), gameTypes); - gameSelector = new GameSelector(client, tabSupervisor, this, QMap(), tempMap, true, true); + gameSelector = new GameSelector(client, tabSupervisor, this, QMap(), tempMap, true, true, this); userListPanel = new UserListPanelWidget(tabSupervisor, client, this); userListPanel->bind(tabSupervisor->getUserListManager()); @@ -198,7 +198,7 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, connect(aOpenChatSettings, &QAction::triggered, this, &TabRoom::actOpenChatSettings); auto *chatSettingsButton = new QToolButton; - chatSettingsButton->setIcon(themePixmap(QStringLiteral("icons/settings"))); + chatSettingsButton->setIcon(loadThemedIcon(QStringLiteral("icons/settings"))); chatSettingsButton->setMenu(chatSettingsMenu); chatSettingsButton->setPopupMode(QToolButton::InstantPopup); @@ -720,6 +720,7 @@ void TabRoom::processRemoveMessagesEvent(const Event_RemoveMessages &event) void TabRoom::refreshShortcuts() { aClearChat->setShortcuts(SettingsCache::instance().shortcuts().getShortcut("tab_room/aClearChat")); + gameSelector->refreshShortcuts(); } void TabRoom::addMentionTag(QString mentionTag) diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 3316232a4..c5cb7fda6 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -1,5 +1,6 @@ #include "tab_supervisor.h" +#include "../../../client/crash/crash_reporter.h" #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" #include "../../intents/contexts/context_connect_to_server.h" @@ -7,6 +8,7 @@ #include "../../intents/contexts/context_join_room.h" #include "../../intents/intent.h" #include "../../intents/intent_join_server_game.h" +#include "../../theme_icon.h" #include "../interface/pixel_map_generator.h" #include "../interface/widgets/server/game_link.h" #include "../interface/widgets/server/user/user_list_manager.h" @@ -21,6 +23,7 @@ #include "tab.h" #include "tab_account.h" #include "tab_admin.h" +#include "tab_alts.h" #include "tab_card_art_rules.h" #include "tab_deck_editor.h" #include "tab_deck_storage.h" @@ -54,6 +57,7 @@ #include #include #include +#include #include #include #include @@ -155,8 +159,17 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget * : QTabWidget(parent), userInfo(nullptr), client(_client), tabsMenu(tabsMenu), tabHome(nullptr), tabVisualDeckStorage(nullptr), tabServer(nullptr), tabAccount(nullptr), tabDeckStorage(nullptr), tabReplays(nullptr), tabAdmin(nullptr), tabCardArtRules(nullptr), tabLog(nullptr), tabReport(nullptr), - tabModeration(nullptr), tabDeveloper(nullptr), isLocalGame(false) + tabModeration(nullptr), tabAlts(nullptr), tabDeveloper(nullptr), isLocalGame(false) { + setObjectName("tabSupervisor"); + + // QTabWidget keeps tab pages in an internal stacked widget. Give it a stable + // name so the GUI tests can address the pages by a readable path + // (tabSupervisor/stackedWidget/). + if (auto *stackedWidget = findChild()) { + stackedWidget->setObjectName("stackedWidget"); + } + setElideMode(Qt::ElideRight); setMovable(true); setIconSize(QSize(15, 15)); @@ -462,6 +475,9 @@ void TabSupervisor::addCloseButtonToTab(Tab *tab, int tabIndex, QAction *manager auto closeSide = static_cast( tabBar()->style()->styleHint(QStyle::SH_TabBar_CloseButtonPosition, nullptr, tabBar())); auto *closeButton = new CloseButton(tab); + // No object name: the tab bar index shifts when tabs move or close, so an + // index-derived name would go stale (or collide). Callers find the button + // through the tab widget instead. if (manager) { // If managed, all close requests should go through the menu action connect(closeButton, &CloseButton::clicked, this, [manager] { checkAndTrigger(manager, false); }); @@ -561,6 +577,7 @@ void TabSupervisor::startLocal(const QList &_clients) tabLog = nullptr; tabReport = nullptr; tabModeration = nullptr; + tabAlts = nullptr; tabDeveloper = nullptr; isLocalGame = true; userInfo = new ServerInfo_User; @@ -577,6 +594,7 @@ void TabSupervisor::startLocal(const QList &_clients) */ void TabSupervisor::stop() { + CrashReporter::addBreadcrumb("tabs", "tab supervisor stopping"); if ((!client) && localClients.isEmpty()) { return; } @@ -609,6 +627,9 @@ void TabSupervisor::stop() if (tabModeration) { tabModeration->close(); } + if (tabAlts) { + tabAlts->close(); + } if (tabCardArtRules) { tabCardArtRules->close(); } @@ -917,6 +938,21 @@ void TabSupervisor::openTabModeration(const QString &userName) aTabModeration->setChecked(true); } +void TabSupervisor::openTabAlts(const QString &userName) +{ + if (tabAlts) { + setCurrentWidget(tabAlts); + if (!userName.isEmpty()) { + tabAlts->investigate(userName); + } + return; + } + tabAlts = new TabAlts(this, client, userName); + myAddTab(tabAlts); + setCurrentWidget(tabAlts); + connect(tabAlts, &QObject::destroyed, this, [this] { tabAlts = nullptr; }); +} + void TabSupervisor::actTabDeveloper(bool checked) { if (checked && !tabDeveloper) { @@ -969,6 +1005,7 @@ void TabSupervisor::updateLatencyTooltip(const LatencyTracker::Stats &stats) void TabSupervisor::gameJoined(const Event_GameJoined &event) { + CrashReporter::addBreadcrumb("tabs", "game joined"); QMap roomGameTypes; TabRoom *room = roomTabs.value(event.game_info().room_id()); if (room) { @@ -1021,6 +1058,7 @@ void TabSupervisor::gameLeft(TabGame *tab) void TabSupervisor::addRoomTab(const ServerInfo_Room &info, bool setCurrent) { + CrashReporter::addBreadcrumb("tabs", "room tab added"); auto *tab = new TabRoom(this, client, userInfo, info); connect(tab, &TabRoom::maximizeClient, this, &TabSupervisor::maximizeMainWindow); connect(tab, &TabRoom::roomClosing, this, &TabSupervisor::roomLeft); @@ -1347,7 +1385,7 @@ void TabSupervisor::tabUserEvent(bool globalEvent) auto *tab = static_cast(sender()); if (tab != currentWidget()) { tab->setContentsChanged(true); - setTabIcon(indexOf(tab), themePixmap(QStringLiteral("icons/tab_changed"))); + setTabIcon(indexOf(tab), loadThemedIcon(QStringLiteral("icons/tab_changed"))); } if (globalEvent && SettingsCache::instance().userInterface().getNotificationsEnabled()) { QApplication::alert(this); @@ -1438,6 +1476,7 @@ void TabSupervisor::actShowPopup(const QString &message) void TabSupervisor::processUserLeft(const QString &userName) { + CrashReporter::addBreadcrumb("users", "user left"); TabMessage *tab = messageTabs.value(userName); if (tab) { tab->processUserLeft(); diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h index 9c7dc48d1..d529c5022 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h @@ -50,6 +50,7 @@ class TabAdmin; class TabMessage; class TabReport; class TabModeration; +class TabAlts; class TabAccount; class TabDeckEditor; class TabDeveloper; @@ -116,6 +117,7 @@ private: TabLog *tabLog; TabReport *tabReport; TabModeration *tabModeration; + TabAlts *tabAlts; TabDeveloper *tabDeveloper; QMap roomTabs; QMap gameTabs; @@ -209,6 +211,7 @@ public slots: /** Joins or spectates a game listed on a user's profile hover card. */ void joinGameFromUserCard(int gameId, int roomId, bool asSpectator); void openTabModeration(const QString &userName = {}); + void openTabAlts(const QString &userName = {}); void switchToFirstAvailableNetworkTab(); void maximizeMainWindow(); void actTabVisualDeckStorage(bool checked); diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp index bf4e15aa6..84bf026f7 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp @@ -20,7 +20,6 @@ #include "tab_deck_editor_visual_tab_widget.h" #include -#include #include #include #include @@ -261,17 +260,25 @@ void TabDeckEditorVisual::processMainboardCardClick(const QMouseEvent *event, } } +/** @brief Get a zone name corresponding to keyboard modifiers. */ +static QString zoneForClickModifiers(const Qt::KeyboardModifiers &modifiers) +{ + if (modifiers.testFlag(Qt::ControlModifier)) { + return DECK_ZONE_SIDE; + } else if (modifiers.testFlag(Qt::ShiftModifier)) { + return DECK_ZONE_MAYBEBOARD; + } else { + return DECK_ZONE_MAIN; + } +} + /** @brief Handles clicks on cards in the database display. */ void TabDeckEditorVisual::processDatabaseCardClick(const QMouseEvent *event, const ExactCard &card) { if (event->button() == Qt::LeftButton) { - if (QApplication::keyboardModifiers() & Qt::ControlModifier) { - addCard(card, DECK_ZONE_SIDE); - } else { - addCard(card, DECK_ZONE_MAIN); - } + addCard(card, zoneForClickModifiers(event->modifiers())); } else if (event->button() == Qt::RightButton) { - decrementCard(card, DECK_ZONE_MAIN); + decrementCard(card, zoneForClickModifiers(event->modifiers())); } else if (event->button() == Qt::MiddleButton) { deckDockWidget->actRemoveCard(); } diff --git a/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp b/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp index a629df43c..0090e9d65 100644 --- a/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp +++ b/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp @@ -1,5 +1,6 @@ #include "card_completer_delegate.h" +#include "../../interface/theme_manager.h" #include "../../pixel_map_generator.h" #include "../cards/additional_info/mana_cost_widget.h" #include "libcockatrice/card/database/card_database_querier.h" @@ -182,7 +183,7 @@ void CardCompleterDelegate::drawManaSymbol(QPainter *p, QPoint centre, const QSt p->setBrush(Qt::NoBrush); p->drawEllipse(pip.adjusted(1, 1, -1, -1)); - QFont f = QtUtils::boldFont(p->font()); + QFont f = QtUtils::boldFont(themeManager->appFont(AppFont::CardText)); f.setPixelSize(qMax(radius - 1, 7)); p->setFont(f); @@ -429,7 +430,7 @@ void CardCompleterDelegate::paint(QPainter *painter, const QStyleOptionViewItem 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 = themeManager->appFont(AppFont::CardText); f.setPixelSize(13); f.setBold(true); @@ -451,7 +452,7 @@ void CardCompleterDelegate::paint(QPainter *painter, const QStyleOptionViewItem if (showInfoBand) { const QRect infoRect(textLeft, rect.top() + 22, textWidth, rect.height() - 24); - QFont f = option.font; + QFont f = themeManager->appFont(AppFont::CardText); f.setPixelSize(10); painter->setFont(f); diff --git a/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp b/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp index 8f1941412..1a17bb8be 100644 --- a/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp +++ b/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp @@ -1,5 +1,6 @@ #include "card_completer_styler.h" +#include "../../interface/theme_manager.h" #include "../cards/card_info_picture_enlarged_widget.h" #include "card_completer_delegate.h" #include "libcockatrice/card/database/card_database_querier.h" @@ -73,6 +74,10 @@ CardCompleterStyler::CardCompleterStyler(QCompleter *completer, QObject *parent) popup->viewport()->setMouseTracking(true); + // The delegate reads the CardText role at paint time, so a font change + // only needs a repaint of the popup. + connect(themeManager, &ThemeManager::fontsChanged, popup, [popup] { popup->viewport()->update(); }); + popup->installEventFilter(this); popup->viewport()->installEventFilter(this); diff --git a/cockatrice/src/interface/widgets/utility/epoch_format.h b/cockatrice/src/interface/widgets/utility/epoch_format.h new file mode 100644 index 000000000..977053997 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/epoch_format.h @@ -0,0 +1,27 @@ +/** + * @file epoch_format.h + * @brief Shared timestamp formatting for the moderation tables. + */ + +#ifndef EPOCH_FORMAT_H +#define EPOCH_FORMAT_H + +#include +#include + +/** + * @brief Format a unix timestamp the way the moderation tabs show dates. + * + * @param ts Seconds since the epoch; 0 means "unknown". + * @param unknownText Label returned for 0, translated by the caller so the + * translation context stays with the tab. + */ +inline QString formatEpochTimestamp(quint64 ts, const QString &unknownText) +{ + if (ts == 0) { + return unknownText; + } + return QDateTime::fromSecsSinceEpoch(ts).toLocalTime().toString(QStringLiteral("yyyy-MM-dd HH:mm")); +} + +#endif // EPOCH_FORMAT_H diff --git a/cockatrice/src/interface/widgets/utility/pill_painter.cpp b/cockatrice/src/interface/widgets/utility/pill_painter.cpp new file mode 100644 index 000000000..6f317b951 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/pill_painter.cpp @@ -0,0 +1,41 @@ +#include "pill_painter.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr qreal PILL_ALPHA = 0.30; +} // namespace + +bool pill_painter::paint(QPainter *painter, + const QStyleOptionViewItem &option, + QPalette::ColorRole tintRole, + QPalette::ColorRole textRole, + const QString &text) +{ + const QRect pillRect = option.rect.adjusted(PAD_X / 2, PAD_Y / 2, -PAD_X / 2, -PAD_Y / 2); + if (pillRect.width() <= 0 || pillRect.height() <= 0) { + return false; + } + + if ((option.state & QStyle::State_Selected) == 0) { + painter->save(); + painter->setRenderHint(QPainter::Antialiasing, true); + QColor pill = option.palette.color(tintRole); + pill.setAlphaF(PILL_ALPHA); + painter->setPen(Qt::NoPen); + painter->setBrush(pill); + painter->drawRoundedRect(pillRect, pillRect.height() / 2.0, pillRect.height() / 2.0); + painter->restore(); + } + + QStyle *style = option.widget != nullptr ? option.widget->style() : QApplication::style(); + const QString elided = option.fontMetrics.elidedText(text, option.textElideMode, pillRect.width()); + style->drawItemText(painter, pillRect, Qt::AlignCenter, option.palette, true, elided, textRole); + return true; +} diff --git a/cockatrice/src/interface/widgets/utility/pill_painter.h b/cockatrice/src/interface/widgets/utility/pill_painter.h new file mode 100644 index 000000000..72e3fe0b4 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/pill_painter.h @@ -0,0 +1,29 @@ +#ifndef PILL_PAINTER_H +#define PILL_PAINTER_H + +#include +#include + +class QPainter; +class QStyleOptionViewItem; + +namespace pill_painter +{ +constexpr int PAD_X = 10; +constexpr int PAD_Y = 4; + +/** + * Draws a translucent, palette-tinted rounded pill inside the option's rect and + * paints the text elided and centred on it, so badges follow the active theme. + * Returns false when the cell is too small to hold a pill, leaving the caller to + * fall back to default painting. + */ +bool paint(QPainter *painter, + const QStyleOptionViewItem &option, + QPalette::ColorRole tintRole, + QPalette::ColorRole textRole, + const QString &text); + +} // namespace pill_painter + +#endif // PILL_PAINTER_H diff --git a/cockatrice/src/interface/widgets/utility/report_utils.cpp b/cockatrice/src/interface/widgets/utility/report_utils.cpp index d7fc083c6..bdbc24f32 100644 --- a/cockatrice/src/interface/widgets/utility/report_utils.cpp +++ b/cockatrice/src/interface/widgets/utility/report_utils.cpp @@ -1,5 +1,6 @@ #include "report_utils.h" +#include "../../theme_manager.h" #include "libcockatrice/protocol/pb/serverinfo_report.pb.h" #include @@ -7,6 +8,7 @@ #include #include #include +#include #include #include #include @@ -19,6 +21,20 @@ namespace report_utils { +QFont reportLogFont() +{ + if (themeManager->hasAppFont(AppFont::ChatLog)) { + return themeManager->appFont(AppFont::ChatLog); + } + QFont monoFont(QStringLiteral("monospace")); + monoFont.setStyleHint(QFont::Monospace); + const int systemPointSize = QFontDatabase::systemFont(QFontDatabase::GeneralFont).pointSize(); + if (systemPointSize > 0) { + monoFont.setPointSize(systemPointSize); + } + return monoFont; +} + namespace { QColor reportStatusColor(const QString &status) @@ -64,6 +80,23 @@ QString formatReportTime(qint64 secondsSinceEpoch) return dt.toString("yyyy-MM-dd hh:mm"); } +QString formatReportStatus(const QString &status) +{ + if (status == QStringLiteral("open")) { + return QObject::tr("Open"); + } + if (status == QStringLiteral("assigned")) { + return QObject::tr("Assigned"); + } + if (status == QStringLiteral("resolved")) { + return QObject::tr("Resolved"); + } + if (status == QStringLiteral("dismissed")) { + return QObject::tr("Dismissed"); + } + return status; +} + void fillReportTableRow(QTableWidget *table, int row, const ServerInfo_Report &report, @@ -73,7 +106,8 @@ void fillReportTableRow(QTableWidget *table, int categoryColumn, int gameIdColumn, int statusColumn, - int assignedColumn) + int assignedColumn, + bool colorizeStatus) { auto *idItem = new QTableWidgetItem(QString::number(report.report_id())); idItem->setData(Qt::UserRole, report.report_id()); @@ -86,8 +120,12 @@ void fillReportTableRow(QTableWidget *table, table->setItem(row, gameIdColumn, new QTableWidgetItem(report.game_id() > 0 ? QString::number(report.game_id()) : QString())); - auto *statusItem = new QTableWidgetItem(QString::fromStdString(report.status())); - setReportStatusItemColor(statusItem, QString::fromStdString(report.status())); + const QString rawStatus = QString::fromStdString(report.status()); + auto *statusItem = new QTableWidgetItem(formatReportStatus(rawStatus)); + statusItem->setData(Qt::UserRole, rawStatus); + if (colorizeStatus) { + setReportStatusItemColor(statusItem, rawStatus); + } table->setItem(row, statusColumn, statusItem); table->setItem(row, assignedColumn, new QTableWidgetItem(QString::fromStdString(report.assigned_mod_name()))); diff --git a/cockatrice/src/interface/widgets/utility/report_utils.h b/cockatrice/src/interface/widgets/utility/report_utils.h index 679989486..f81e01369 100644 --- a/cockatrice/src/interface/widgets/utility/report_utils.h +++ b/cockatrice/src/interface/widgets/utility/report_utils.h @@ -1,6 +1,7 @@ #ifndef REPORT_UTILS_H #define REPORT_UTILS_H +#include #include #include @@ -11,8 +12,15 @@ class ServerInfo_Report; namespace report_utils { +/** @brief Font for the report chat-log views: the theme's ChatLog role when it + * sets one, otherwise the monospace font these views used before the + * role existed. Shared by the three report surfaces so they cannot + * drift apart. */ +QFont reportLogFont(); + QString formatReportCategory(const QString &category); QString formatReportTime(qint64 secondsSinceEpoch); +QString formatReportStatus(const QString &status); void fillReportTableRow(QTableWidget *table, int row, @@ -23,7 +31,8 @@ void fillReportTableRow(QTableWidget *table, int categoryColumn, int gameIdColumn, int statusColumn, - int assignedColumn); + int assignedColumn, + bool colorizeStatus = true); void renderReportDetails(QTextEdit *chatLogEdit, QTextEdit *commentsEdit, diff --git a/cockatrice/src/interface/widgets/utility/sequence_edit.cpp b/cockatrice/src/interface/widgets/utility/sequence_edit.cpp index 44e6581e9..b09e6da1e 100644 --- a/cockatrice/src/interface/widgets/utility/sequence_edit.cpp +++ b/cockatrice/src/interface/widgets/utility/sequence_edit.cpp @@ -2,7 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include #include @@ -24,8 +24,8 @@ SequenceEdit::SequenceEdit(const QString &_shortcutName, QWidget *parent) : QWid defaultButton = new QPushButton("", this); lineEdit->setMinimumWidth(70); - clearButton->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); - defaultButton->setIcon(themePixmap(QStringLiteral("icons/update"))); + clearButton->setIcon(loadThemedIcon(QStringLiteral("icons/clearsearch"))); + defaultButton->setIcon(loadThemedIcon(QStringLiteral("icons/reload"))); auto *layout = QtUtils::makeLayout(this, 1); layout->addWidget(lineEdit); diff --git a/cockatrice/src/interface/widgets/utility/table_activation.cpp b/cockatrice/src/interface/widgets/utility/table_activation.cpp new file mode 100644 index 000000000..5633b9f58 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/table_activation.cpp @@ -0,0 +1,28 @@ +#include "table_activation.h" + +#include +#include +#include + +namespace +{ +void addActivationShortcut(QTableWidget *table, + QObject *context, + int key, + const std::function &handler) +{ + auto *shortcut = new QShortcut(QKeySequence(key), table); + // The table's viewport takes focus, so match children as well. + shortcut->setContext(Qt::WidgetWithChildrenShortcut); + QObject::connect(shortcut, &QShortcut::activated, context, [table, handler] { handler(table->currentIndex()); }); +} +} // namespace + +void connectTableActivation(QTableWidget *table, + QObject *context, + const std::function &handler) +{ + QObject::connect(table, &QTableWidget::doubleClicked, context, handler); + addActivationShortcut(table, context, Qt::Key_Return, handler); + addActivationShortcut(table, context, Qt::Key_Enter, handler); +} diff --git a/cockatrice/src/interface/widgets/utility/table_activation.h b/cockatrice/src/interface/widgets/utility/table_activation.h new file mode 100644 index 000000000..f5038bdb0 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/table_activation.h @@ -0,0 +1,32 @@ +/** + * @file table_activation.h + * @brief Row activation for tables that must not navigate on a single click. + */ + +#ifndef TABLE_ACTIVATION_H +#define TABLE_ACTIVATION_H + +#include + +class QModelIndex; +class QObject; +class QTableWidget; + +/** + * @brief Call @p handler on double-click or Enter, never on a single click. + * + * QAbstractItemView::activated fires on a single click on platforms where + * SH_ItemView_ActivateItemOnSingleClick is set, so navigation actions must not + * use it. This connects doubleClicked and installs Return/Enter shortcuts that + * forward the current index. + * + * @param table The table whose rows activate. + * @param context Object that owns the connections; the handler is disconnected + * with it. + * @param handler Receives the activated row's index, which may be invalid. + */ +void connectTableActivation(QTableWidget *table, + QObject *context, + const std::function &handler); + +#endif // TABLE_ACTIVATION_H diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp index 464d62e4a..46330ac46 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp @@ -15,9 +15,23 @@ #include #include +namespace +{ + +/** @brief Remove the filters that apply to the given color and attribute from the given model. */ +static void removeFilters(FilterTreeModel *filterModel, QChar color, CardFilter::Attr attr) +{ + for (const CardFilter *filter : filterModel->getFiltersOfType(attr)) { + if (filter->term() == color) { + filterModel->removeFilter(filter); + } + } +} +} // namespace + /** - * This widget provides a graphical control element for the CardFilter::Attr::AttrColor filters applied to the filter - * model. + * This widget provides a graphical control element for the CardFilter::Attr::AttrColor and + * CardFilter::Attr::AttrColorIdentity filters applied to the filter model. * @param parent The Qt Widget that this widget will parent to * @param _filterModel The filter model that this widget will manipulate */ @@ -59,11 +73,25 @@ void VisualDatabaseDisplayColorFilterWidget::retranslateUi() modeComboBox->blockSignals(true); modeComboBox->clear(); - modeComboBox->addItem(tr("Exact match"), QVariant::fromValue(FilterMode::ExactMatch)); modeComboBox->addItem(tr("Includes"), QVariant::fromValue(FilterMode::Includes)); + modeComboBox->addItem(tr("Excludes"), QVariant::fromValue(FilterMode::Excludes)); modeComboBox->addItem(tr("Include / Exclude"), QVariant::fromValue(FilterMode::IncludeExclude)); + modeComboBox->addItem(tr("Exact match"), QVariant::fromValue(FilterMode::ExactMatch)); + modeComboBox->addItem(tr("Color identity"), QVariant::fromValue(FilterMode::ColorIdentity)); + + modeComboBox->setItemData(0, tr("Cards containing any of the selected colors"), Qt::ToolTipRole); + modeComboBox->setItemData(1, tr("Cards containing none of the selected colors"), Qt::ToolTipRole); + modeComboBox->setItemData(2, tr("Cards containing any of the selected colors and none of the unselected colors"), + Qt::ToolTipRole); + modeComboBox->setItemData(3, tr("Cards being exactly of the selected colors"), Qt::ToolTipRole); + modeComboBox->setItemData(4, tr("Cards whose color identity fits within the selected colors"), Qt::ToolTipRole); + + if (currentMode == FilterMode::Custom) { + addCustomFilterModeItem(); + } modeComboBox->setToolTip(tr("How selected and unselected colors are combined in the filter")); + modeComboBox->setAccessibleName(tr("Color filter mode")); // Restore current mode const int index = modeComboBox->findData(QVariant::fromValue(currentMode)); @@ -74,6 +102,28 @@ void VisualDatabaseDisplayColorFilterWidget::retranslateUi() modeComboBox->blockSignals(false); } +void VisualDatabaseDisplayColorFilterWidget::setFilterMode(FilterMode filterMode) +{ + int index = modeComboBox->findData(QVariant::fromValue(filterMode)); + + if (index < 0 && filterMode == FilterMode::Custom) { + index = modeComboBox->count(); + addCustomFilterModeItem(); + } + + if (index >= 0) { + modeComboBox->setCurrentIndex(index); + } +} + +void VisualDatabaseDisplayColorFilterWidget::addCustomFilterModeItem() +{ + int customIndex = modeComboBox->count(); + modeComboBox->addItem(tr("Custom"), QVariant::fromValue(FilterMode::Custom)); + modeComboBox->setItemData(customIndex, tr("Cards matching an unknown combination of color filters"), + Qt::ToolTipRole); +} + void VisualDatabaseDisplayColorFilterWidget::handleColorToggled(QChar color, bool active) { if (active) { @@ -85,74 +135,24 @@ void VisualDatabaseDisplayColorFilterWidget::handleColorToggled(QChar color, boo void VisualDatabaseDisplayColorFilterWidget::addFilter(QChar color) { - QString colorString = color; - QString typeStr; + FilterModeInfo info = getFilterModeInfo(currentMode); - // Remove previous filters + removeFilters(filterModel, color, info.attr); - QList allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor); - QList matchingFilters; - - for (const CardFilter *filter : allColorFilters) { - if (filter->term() == color) { - matchingFilters.append(filter); - } - } - - for (const CardFilter *filter : matchingFilters) { - filterModel->removeFilter(filter); - } - - // Add actual filter - - switch (currentMode) { - case FilterMode::ExactMatch: - filterModel->addFilter(new CardFilter(colorString, CardFilter::Type::TypeAnd, CardFilter::Attr::AttrColor)); - break; - - case FilterMode::Includes: - filterModel->addFilter(new CardFilter(colorString, CardFilter::Type::TypeOr, CardFilter::Attr::AttrColor)); - break; - - case FilterMode::IncludeExclude: - filterModel->addFilter(new CardFilter(colorString, CardFilter::Type::TypeOr, CardFilter::Attr::AttrColor)); - break; + if (info.includeType != CardFilter::Type::TypeEnd) { + filterModel->addFilter(new CardFilter(color, info.includeType, info.attr)); } } void VisualDatabaseDisplayColorFilterWidget::removeFilter(QChar color) { - QString colorString = color; + FilterModeInfo info = getFilterModeInfo(currentMode); - // Remove inclusion filters - QList allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor); - QList matchingFilters; + removeFilters(filterModel, color, info.attr); - for (const CardFilter *filter : allColorFilters) { - if (filter->term() == color) { - matchingFilters.append(filter); - } - } - - for (const CardFilter *filter : matchingFilters) { - filterModel->removeFilter(filter); - } - - // Add exclusion filters if the mode demands it - switch (currentMode) { - case FilterMode::ExactMatch: - filterModel->addFilter( - new CardFilter(colorString, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColor)); - break; - - case FilterMode::IncludeExclude: - filterModel->addFilter( - new CardFilter(colorString, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColor)); - break; - - case FilterMode::Includes: - // No exclusion in Includes mode - break; + // Add exclusion filters if the mode demands it. + if (info.excludeType != CardFilter::Type::TypeEnd) { + filterModel->addFilter(new CardFilter(color, info.excludeType, info.attr)); } } @@ -169,6 +169,7 @@ void VisualDatabaseDisplayColorFilterWidget::updateFilterMode() filterModel->filterTree()->blockSignals(true); filterModel->clearFiltersOfType(CardFilter::Attr::AttrColor); + filterModel->clearFiltersOfType(CardFilter::Attr::AttrColorIdentity); const QList manaSymbolWidgets = findChildren(); @@ -211,52 +212,53 @@ QList VisualDatabaseDisplayColorFilterWidget::getActiveColors() void VisualDatabaseDisplayColorFilterWidget::syncWithFilterModel() { - QList allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor); + QList filters; + filters.append(filterModel->getFiltersOfType(CardFilter::Attr::AttrColor)); + filters.append(filterModel->getFiltersOfType(CardFilter::Attr::AttrColorIdentity)); - QList manaSymbolWidgets = findChildren(); + // Re-derive the mode from the filters in the model, so that a loaded filter set selects the mode that + // produced it (or Custom when none could have). + currentMode = deriveFilterMode(filters, currentMode); - for (ManaSymbolWidget *manaSymbolWidget : manaSymbolWidgets) { + // Update the combobox. Add a Custom item only if we have a custom filter mode. + int customIndex = modeComboBox->findData(QVariant::fromValue(FilterMode::Custom)); + modeComboBox->blockSignals(true); + if (currentMode == FilterMode::Custom) { + if (customIndex < 0) { + customIndex = modeComboBox->count(); + addCustomFilterModeItem(); + } + modeComboBox->setCurrentIndex(customIndex); + } else { + if (customIndex >= 0) { + modeComboBox->removeItem(customIndex); + } + int index = modeComboBox->findData(QVariant::fromValue(currentMode)); + if (index >= 0) { + modeComboBox->setCurrentIndex(index); + } + } + modeComboBox->blockSignals(false); + + // Update the mana symbol widgets by looking for the current mode's include or exclude filters. + const FilterModeInfo modeInfo = getFilterModeInfo(currentMode); + + const bool useExclude = + modeInfo.includeType == CardFilter::Type::TypeEnd && modeInfo.excludeType != CardFilter::Type::TypeEnd; + const CardFilter::Type filterType = useExclude ? modeInfo.excludeType : modeInfo.includeType; + + filters = filterModel->getFiltersOfType(modeInfo.attr); + + for (ManaSymbolWidget *manaSymbolWidget : findChildren()) { bool found = false; - for (const CardFilter *filter : allColorFilters) { - if (manaSymbolWidget->getSymbolChar() == filter->term()) { - switch (currentMode) { - case FilterMode::ExactMatch: - switch (filter->type()) { - case CardFilter::Type::TypeAnd: - setManaSymbolActive(filter->term().at(0), true); - break; - default: - setManaSymbolActive(filter->term().at(0), false); - break; - } - break; - case FilterMode::Includes: - switch (filter->type()) { - case CardFilter::Type::TypeOr: - setManaSymbolActive(filter->term().at(0), true); - break; - default: - setManaSymbolActive(filter->term().at(0), false); - break; - } - break; - case FilterMode::IncludeExclude: - switch (filter->type()) { - case CardFilter::Type::TypeOr: - setManaSymbolActive(filter->term().at(0), true); - break; - default: - setManaSymbolActive(filter->term().at(0), false); - break; - } - break; - } + for (const CardFilter *filter : filters) { + if (filter->type() == filterType && manaSymbolWidget->getSymbolChar() == filter->term()) { found = true; + break; } } - if (!found) { - setManaSymbolActive(manaSymbolWidget->getSymbolChar(), false); - } + manaSymbolWidget->setColorActive(useExclude ? !found : found); + manaSymbolWidget->setDisabled(currentMode == FilterMode::Custom); } } diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.h index 184f1f96c..561c92d09 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.h @@ -10,8 +10,8 @@ #include #include #include -#include #include +#include #include #include @@ -38,15 +38,6 @@ private: int circleDiameter; }; -enum class FilterMode -{ - ExactMatch, // Only selected colors are included, all others are excluded. - Includes, // Include selected colors (OR condition). - IncludeExclude // Include selected colors (OR) and exclude unselected colors (AND NOT). -}; - -Q_DECLARE_METATYPE(FilterMode) - class VisualDatabaseDisplayColorFilterWidget : public QWidget { Q_OBJECT @@ -54,11 +45,13 @@ class VisualDatabaseDisplayColorFilterWidget : public QWidget public: explicit VisualDatabaseDisplayColorFilterWidget(QWidget *parent, FilterTreeModel *filterModel); void retranslateUi(); + void setFilterMode(FilterMode filterMode); signals: void filterModeChanged(FilterMode filterMode); private slots: + void addCustomFilterModeItem(); void handleColorToggled(QChar color, bool active); void addFilter(QChar color); void removeFilter(QChar color); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp index 5063cce14..f4c4f7c4e 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp @@ -1,6 +1,6 @@ #include "visual_database_display_filter_toolbar_widget.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../deck_editor/card_database_view.h" #include "../quick_settings/settings_button_widget.h" #include "card_database_display_model.h" @@ -10,6 +10,7 @@ #include "visual_database_display_name_filter_widget.h" #include "visual_database_display_set_filter_widget.h" #include "visual_database_display_sub_type_filter_widget.h" +#include "visual_database_display_tag_filter_widget.h" #include "visual_database_display_widget.h" #include @@ -45,13 +46,15 @@ VisualDatabaseDisplayFilterToolbarWidget::VisualDatabaseDisplayFilterToolbarWidg sortGroupBox = new QGroupBox(this); filterGroupBox = new QGroupBox(this); - auto scalePixmap = [](const QString &fileName) { return QIcon(QPixmap(fileName)).pixmap({20, 20}); }; + auto scalePixmap = [](const QString &prefix, const IconTint &tint = {}) { + return loadThemedIcon(prefix, tint).pixmap({20, 20}); + }; sortByLabel = new QLabel(this); - sortByLabel->setPixmap(scalePixmap("theme:icons/sort_arrow_down")); + sortByLabel->setPixmap(scalePixmap("icons/sort_arrow_down", AppColor::Informational)); filterByLabel = new QLabel(this); - filterByLabel->setPixmap(scalePixmap("theme:icons/filter")); + filterByLabel->setPixmap(scalePixmap("icons/filter", AppColor::Informational)); sortColumnCombo = new QComboBox(this); sortColumnCombo->setSizeAdjustPolicy(QComboBox::SizeAdjustPolicy::AdjustToContents); @@ -89,22 +92,26 @@ VisualDatabaseDisplayFilterToolbarWidget::VisualDatabaseDisplayFilterToolbarWidg }); quickFilterSaveLoadWidget = new SettingsButtonWidget(this); - quickFilterSaveLoadWidget->setButtonIcon(themePixmap(QStringLiteral("icons/floppy_disk"))); + quickFilterSaveLoadWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/floppy_disk"), IconTint::primary())); quickFilterNameWidget = new SettingsButtonWidget(this); - quickFilterNameWidget->setButtonIcon(themePixmap(QStringLiteral("icons/pen_to_square"))); + quickFilterNameWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/pen_to_square"), IconTint::primary())); quickFilterMainTypeWidget = new SettingsButtonWidget(this); - quickFilterMainTypeWidget->setButtonIcon(themePixmap(QStringLiteral("icons/circle_half_stroke"))); + quickFilterMainTypeWidget->setButtonIcon( + loadThemedIcon(QStringLiteral("icons/circle_half_stroke"), IconTint::primary())); quickFilterSubTypeWidget = new SettingsButtonWidget(this); - quickFilterSubTypeWidget->setButtonIcon(themePixmap(QStringLiteral("icons/dragon"))); + quickFilterSubTypeWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/dragon"), IconTint::primary())); quickFilterSetWidget = new SettingsButtonWidget(this); - quickFilterSetWidget->setButtonIcon(themePixmap(QStringLiteral("icons/scroll"))); + quickFilterSetWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scroll"), IconTint::primary())); quickFilterFormatLegalityWidget = new SettingsButtonWidget(this); - quickFilterFormatLegalityWidget->setButtonIcon(themePixmap(QStringLiteral("icons/scale_balanced"))); + quickFilterFormatLegalityWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scales"), IconTint::primary())); + + quickFilterTagWidget = new SettingsButtonWidget(this); + quickFilterTagWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/tags"))); retranslateUi(); } @@ -126,6 +133,7 @@ void VisualDatabaseDisplayFilterToolbarWidget::initialize() formatLegalityWidget = new VisualDatabaseDisplayFormatLegalityFilterWidget(this, filterModel); subTypeFilterWidget = new VisualDatabaseDisplaySubTypeFilterWidget(this, filterModel); setFilterWidget = new VisualDatabaseDisplaySetFilterWidget(this, filterModel); + tagFilterWidget = new VisualDatabaseDisplayTagFilterWidget(this, filterModel); quickFilterSaveLoadWidget->addSettingsWidget(saveLoadWidget); quickFilterNameWidget->addSettingsWidget(nameFilterWidget); @@ -133,6 +141,7 @@ void VisualDatabaseDisplayFilterToolbarWidget::initialize() quickFilterSubTypeWidget->addSettingsWidget(subTypeFilterWidget); quickFilterSetWidget->addSettingsWidget(setFilterWidget); quickFilterFormatLegalityWidget->addSettingsWidget(formatLegalityWidget); + quickFilterTagWidget->addSettingsWidget(tagFilterWidget); // fill groupbox layouts sortLayout->addWidget(sortByLabel); @@ -145,6 +154,7 @@ void VisualDatabaseDisplayFilterToolbarWidget::initialize() filterLayout->addWidget(quickFilterSubTypeWidget); filterLayout->addWidget(quickFilterSetWidget); filterLayout->addWidget(quickFilterFormatLegalityWidget); + filterLayout->addWidget(quickFilterTagWidget); // put everything into main layout addWidget(sortGroupBox); @@ -172,6 +182,7 @@ void VisualDatabaseDisplayFilterToolbarWidget::retranslateUi() quickFilterSubTypeWidget->setToolTip(tr("Filter by card sub-type")); quickFilterSetWidget->setToolTip(tr("Filter by set")); quickFilterFormatLegalityWidget->setToolTip(tr("Filter by format legality")); + quickFilterTagWidget->setToolTip(tr("Filter by card tag")); quickFilterSaveLoadWidget->setButtonText(tr("Save/Load")); quickFilterNameWidget->setButtonText(tr("Name")); @@ -179,6 +190,7 @@ void VisualDatabaseDisplayFilterToolbarWidget::retranslateUi() quickFilterSubTypeWidget->setButtonText(tr("Sub Type")); quickFilterSetWidget->setButtonText(tr("Sets")); quickFilterFormatLegalityWidget->setButtonText(tr("Formats")); + quickFilterTagWidget->setButtonText(tr("Tags")); } void VisualDatabaseDisplayFilterToolbarWidget::resizeEvent(QResizeEvent *event) @@ -192,8 +204,8 @@ void VisualDatabaseDisplayFilterToolbarWidget::updateCompactMode(int availableWi const bool compact = availableWidth < fullWidthHint; const QList filterButtons = { - quickFilterSaveLoadWidget, quickFilterNameWidget, quickFilterMainTypeWidget, - quickFilterSubTypeWidget, quickFilterSetWidget, quickFilterFormatLegalityWidget, + quickFilterSaveLoadWidget, quickFilterNameWidget, quickFilterMainTypeWidget, quickFilterSubTypeWidget, + quickFilterSetWidget, quickFilterFormatLegalityWidget, quickFilterTagWidget, }; for (auto *btn : filterButtons) { diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.h index f97310c1b..9f36f3cd1 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.h @@ -17,6 +17,7 @@ class VisualDatabaseDisplayMainTypeFilterWidget; class VisualDatabaseDisplayNameFilterWidget; class VisualDatabaseDisplaySetFilterWidget; class VisualDatabaseDisplaySubTypeFilterWidget; +class VisualDatabaseDisplayTagFilterWidget; class VisualDatabaseDisplayFilterToolbarWidget : public FlowWidget { @@ -54,6 +55,8 @@ private: VisualDatabaseDisplaySetFilterWidget *setFilterWidget; SettingsButtonWidget *quickFilterFormatLegalityWidget; VisualDatabaseDisplayFormatLegalityFilterWidget *formatLegalityWidget; + SettingsButtonWidget *quickFilterTagWidget; + VisualDatabaseDisplayTagFilterWidget *tagFilterWidget; int fullWidthHint = 0; void updateCompactMode(int availableWidth); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.cpp new file mode 100644 index 000000000..ab7bb27a4 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.cpp @@ -0,0 +1,198 @@ +#include "visual_database_display_tag_filter_widget.h" + +#include "../../../filters/filter_tree_model.h" +#include "visual_database_display_filter_button.h" + +#include +#include +#include +#include +#include +#include +#include + +VisualDatabaseDisplayTagFilterWidget::VisualDatabaseDisplayTagFilterWidget(QWidget *parent, + FilterTreeModel *_filterModel) + : QWidget(parent), filterModel(_filterModel) +{ + allTagsWithCount = CardDatabaseManager::query()->getAllTagsWithCount(); + + setMinimumWidth(300); + setMaximumHeight(300); + + layout = new QVBoxLayout(this); + setLayout(layout); + layout->setContentsMargins(0, 1, 0, 1); + layout->setSpacing(1); + layout->setAlignment(Qt::AlignTop); + + // Create search box + searchBox = new QLineEdit(this); + layout->addWidget(searchBox); + connect(searchBox, &QLineEdit::textChanged, this, + &VisualDatabaseDisplayTagFilterWidget::updateTagButtonsVisibility); + + flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); + layout->addWidget(flowWidget); + + // Create a container for the threshold control + auto *thresholdLayout = new QHBoxLayout(); + thresholdLayout->setContentsMargins(0, 0, 0, 0); + + thresholdLabel = new QLabel(this); + thresholdLayout->addWidget(thresholdLabel); + + // Create the spinbox + spinBox = new QSpinBox(this); + spinBox->setMinimum(1); + spinBox->setMaximum(getMaxTagCount()); + spinBox->setValue(150); + thresholdLayout->addWidget(spinBox); + thresholdLayout->addStretch(); + + layout->addLayout(thresholdLayout); + + connect(spinBox, qOverload(&QSpinBox::valueChanged), this, + &VisualDatabaseDisplayTagFilterWidget::updateTagButtonsVisibility); + + // Create the toggle button for Exact Match/Includes mode + toggleButton = new QPushButton(this); + layout->addWidget(toggleButton); + connect(toggleButton, &QPushButton::clicked, this, &VisualDatabaseDisplayTagFilterWidget::updateFilterMode); + connect(filterModel, &FilterTreeModel::layoutChanged, this, + [this]() { QTimer::singleShot(100, this, &VisualDatabaseDisplayTagFilterWidget::syncWithFilterModel); }); + + createTagButtons(); // Populate buttons initially + updateFilterMode(); // Initialize toggle button text + + retranslateUi(); +} + +void VisualDatabaseDisplayTagFilterWidget::retranslateUi() +{ + searchBox->setPlaceholderText(tr("Search tags...")); + thresholdLabel->setText(tr("Show tags with at least:")); + spinBox->setSuffix(tr(" cards")); + spinBox->setToolTip(tr("Do not display tags with less than this amount of cards in the database")); + toggleButton->setToolTip(tr("Filter mode (require all selected tags, or any of them)")); +} + +void VisualDatabaseDisplayTagFilterWidget::createTagButtons() +{ + // Iterate through tags and create buttons + for (auto it = allTagsWithCount.begin(); it != allTagsWithCount.end(); ++it) { + auto *button = new QPushButton(it.key(), flowWidget); + button->setCheckable(true); + button->setStyleSheet(visualDatabaseDisplayFilterButtonStyle); + + flowWidget->addWidget(button); + tagButtons[it.key()] = button; + + // Connect toggle signal for each button + connect(button, &QPushButton::toggled, this, + [this, tag = it.key()](bool checked) { handleTagToggled(tag, checked); }); + } + updateTagButtonsVisibility(); // Ensure visibility is updated initially +} + +void VisualDatabaseDisplayTagFilterWidget::updateTagButtonsVisibility() +{ + const int threshold = spinBox->value(); + const QString filterText = searchBox->text().trimmed().toLower(); + + // Iterate through buttons and hide/disable those below the threshold. An + // active search overrides the threshold so rare tags stay reachable. + for (auto it = tagButtons.begin(); it != tagButtons.end(); ++it) { + const bool isActive = activeTags.value(it.key(), false); + const bool visible = isActive || (filterText.isEmpty() ? allTagsWithCount[it.key()] >= threshold + : it.key().toLower().contains(filterText)); + + it.value()->setVisible(visible); + it.value()->setEnabled(visible); + } +} + +int VisualDatabaseDisplayTagFilterWidget::getMaxTagCount() const +{ + int maxCount = 1; + for (auto it = allTagsWithCount.begin(); it != allTagsWithCount.end(); ++it) { + maxCount = qMax(maxCount, it.value()); + } + return maxCount; +} + +void VisualDatabaseDisplayTagFilterWidget::handleTagToggled(const QString &tag, bool active) +{ + activeTags[tag] = active; + + if (tagButtons.contains(tag)) { + tagButtons[tag]->setChecked(active); + } + + updateTagFilter(); +} + +void VisualDatabaseDisplayTagFilterWidget::updateTagFilter() +{ + filterModel->blockSignals(true); + filterModel->filterTree()->blockSignals(true); + filterModel->clearFiltersOfType(CardFilter::Attr::AttrTag); + + for (const auto &activeTag : activeTags.keys()) { + if (activeTags[activeTag]) { + QString tag = activeTag; + filterModel->addFilter(new CardFilter( + tag, exactMatchMode ? CardFilter::Type::TypeAnd : CardFilter::Type::TypeOr, CardFilter::Attr::AttrTag)); + } + } + + filterModel->blockSignals(false); + filterModel->filterTree()->blockSignals(false); + + emit filterModel->filterTree()->changed(); + emit filterModel->layoutChanged(); +} + +void VisualDatabaseDisplayTagFilterWidget::updateFilterMode() +{ + exactMatchMode = !exactMatchMode; + toggleButton->setText(exactMatchMode ? tr("Mode: Exact Match") : tr("Mode: Includes")); + updateTagFilter(); +} + +void VisualDatabaseDisplayTagFilterWidget::syncWithFilterModel() +{ + // Temporarily block signals for each button to prevent toggling while updating button states + for (auto it = tagButtons.begin(); it != tagButtons.end(); ++it) { + it.value()->blockSignals(true); + } + + // Uncheck all buttons + for (auto it = tagButtons.begin(); it != tagButtons.end(); ++it) { + it.value()->setChecked(false); + } + + // Get active filters for tags + QSet activeTypes; + for (const auto &filter : filterModel->getFiltersOfType(CardFilter::AttrTag)) { + if (filter->type() == CardFilter::Type::TypeAnd || filter->type() == CardFilter::Type::TypeOr) { + activeTypes.insert(filter->term()); + } + } + + // Check the buttons for active tags + for (const auto &tag : activeTypes) { + activeTags[tag] = true; + if (tagButtons.contains(tag)) { + tagButtons[tag]->setChecked(true); + } + } + + // Re-enable signal emissions for each button + for (auto it = tagButtons.begin(); it != tagButtons.end(); ++it) { + it.value()->blockSignals(false); + } + + // Update the visibility of buttons + updateTagButtonsVisibility(); +} diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.h new file mode 100644 index 000000000..092a10587 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_tag_filter_widget.h @@ -0,0 +1,47 @@ +#ifndef COCKATRICE_VISUAL_DATABASE_DISPLAY_TAG_FILTER_WIDGET_H +#define COCKATRICE_VISUAL_DATABASE_DISPLAY_TAG_FILTER_WIDGET_H + +#include "../../../filters/filter_tree_model.h" +#include "../general/layout_containers/flow_widget.h" + +#include +#include +#include +#include +#include +#include +#include + +class VisualDatabaseDisplayTagFilterWidget : public QWidget +{ + Q_OBJECT +public: + explicit VisualDatabaseDisplayTagFilterWidget(QWidget *parent, FilterTreeModel *filterModel); + void retranslateUi(); + void createTagButtons(); + void updateTagButtonsVisibility(); + [[nodiscard]] int getMaxTagCount() const; + + void handleTagToggled(const QString &tag, bool active); + void updateTagFilter(); + void updateFilterMode(); + void syncWithFilterModel(); + +private: + FilterTreeModel *filterModel; + QMap allTagsWithCount; + + QVBoxLayout *layout; + QLineEdit *searchBox; + FlowWidget *flowWidget; + QLabel *thresholdLabel; + QSpinBox *spinBox; + QPushButton *toggleButton; // Mode switch button + + QMap activeTags; // Track active filters + QMap tagButtons; // Store toggle buttons + + bool exactMatchMode = true; // Toggle between "Exact Match" (all) and "Includes" (any) +}; + +#endif // COCKATRICE_VISUAL_DATABASE_DISPLAY_TAG_FILTER_WIDGET_H diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp index 6037418ad..61cdfefe4 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp @@ -4,7 +4,9 @@ #include "../../../filters/filter_tree_model.h" #include "../../../filters/syntax_help.h" #include "../../key_signals.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" +#include "../cards/card_info_picture_widget.h" +#include "../cards/card_info_picture_with_text_overlay_widget.h" #include "../cards/card_size_widget.h" #include "../deck_editor/card_database_view.h" #include "../general/layout_containers/flow_widget.h" @@ -79,8 +81,10 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, searchEdit->setObjectName("searchEdit"); searchEdit->setPlaceholderText(tr("Search by card name (or search expressions)")); searchEdit->setClearButtonEnabled(true); - searchEdit->addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition); - auto help = searchEdit->addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); + searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/search"), IconTint::quiet()), + QLineEdit::LeadingPosition); + auto help = searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/info"), AppColor::Informational), + QLineEdit::TrailingPosition); connect(help, &QAction::triggered, this, [this] { createSearchSyntaxHelpWindow(searchEdit); }); setFocusProxy(searchEdit); @@ -154,10 +158,11 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, clearFilterWidget = new QToolButton(); clearFilterWidget->setFixedSize(32, 32); - clearFilterWidget->setIcon(themePixmap(QStringLiteral("icons/delete"))); + clearFilterWidget->setIcon(loadThemedIcon(QStringLiteral("icons/delete"), AppColor::Negative, IconStyle::Sticker)); connect(clearFilterWidget, &QToolButton::clicked, this, [this] { filterModel->blockSignals(true); filterModel->filterTree()->blockSignals(true); + colorFilterWidget->setFilterMode(FilterMode::Includes); filterModel->clear(); filterModel->blockSignals(false); filterModel->filterTree()->blockSignals(false); diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp index 63ef07835..e2c853d6a 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp @@ -1,6 +1,6 @@ #include "visual_deck_display_options_widget.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../deck_editor/deck_editor_deck_dock_widget.h" #include "../quick_settings/settings_button_widget.h" #include "../tabs/visual_deck_editor/tab_deck_editor_visual.h" @@ -65,7 +65,7 @@ VisualDeckDisplayOptionsWidget::VisualDeckDisplayOptionsWidget(QWidget *parent) sortByLabel = new QLabel(this); sortCriteriaButton = new SettingsButtonWidget(this); - sortCriteriaButton->setButtonIcon(themePixmap(QStringLiteral("icons/sort_arrow_down"))); + sortCriteriaButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/sort_arrow_down"))); sortLabel = new QLabel(sortCriteriaButton); sortLabel->setWordWrap(true); @@ -119,11 +119,11 @@ void VisualDeckDisplayOptionsWidget::updateDisplayTypeButton() switch (currentDisplayType) { case DisplayType::Flat: displayTypeButton->setButtonText(tr("Toggle Layout: Flat")); - displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scroll"))); + displayTypeButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scroll"), IconTint::primary())); break; case DisplayType::Overlap: displayTypeButton->setButtonText(tr("Toggle Layout: Overlap")); - displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scales"))); + displayTypeButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scales"), IconTint::primary())); break; } } diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_placeholder_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_placeholder_widget.cpp index 76c3d0908..d3c087bc6 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_placeholder_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_placeholder_widget.cpp @@ -1,5 +1,8 @@ #include "visual_deck_editor_placeholder_widget.h" +#include "../interface/theme_manager.h" + +#include #include #include #include @@ -28,10 +31,12 @@ VisualDeckEditorPlaceholderWidget::VisualDeckEditorPlaceholderWidget(QWidget *pa textLabel->setStyleSheet(R"( QLabel { color: palette(mid); - font-size: 14px; padding: 10px; } )"); + applyInterfaceDefaultFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, + &VisualDeckEditorPlaceholderWidget::applyInterfaceDefaultFont); mainLayout->addWidget(imageLabel); mainLayout->addWidget(textLabel); @@ -42,4 +47,12 @@ VisualDeckEditorPlaceholderWidget::VisualDeckEditorPlaceholderWidget(QWidget *pa void VisualDeckEditorPlaceholderWidget::retranslateUi() { textLabel->setText(tr("Add cards using the search bar or database tab to have them appear here")); +} + +void VisualDeckEditorPlaceholderWidget::applyInterfaceDefaultFont() +{ + // Family comes from the InterfaceDefault theme role; the 14px size stays caller-side. + QFont placeholderFont = themeManager->appFont(AppFont::InterfaceDefault); + placeholderFont.setPixelSize(14); + textLabel->setFont(placeholderFont); } \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_placeholder_widget.h b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_placeholder_widget.h index 84a3142d8..794be671e 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_placeholder_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_placeholder_widget.h @@ -15,6 +15,9 @@ public: explicit VisualDeckEditorPlaceholderWidget(QWidget *parent = nullptr); void retranslateUi(); +private slots: + void applyInterfaceDefaultFont(); + private: QVBoxLayout *mainLayout; QLabel *imageLabel; diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp index 506ffa2b4..0e0b5e4b4 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp @@ -1,7 +1,7 @@ #include "visual_deck_editor_widget.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../cards/card_size_widget.h" #include "../cards/deck_card_zone_display_widget.h" #include "../general/layout_containers/flow_widget.h" @@ -144,7 +144,7 @@ void VisualDeckEditorWidget::initializeSearchBarAndCompleter() // Search button functionality searchPushButton = new CompactPushButton(searchContainer); - searchPushButton->setButtonIcon(themePixmap(QStringLiteral("icons/search"))); + searchPushButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/search"))); connect(searchPushButton, &QPushButton::clicked, this, [this] { addCardFromSearch(); }); searchLayout->addWidget(searchPushButton); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_addition_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_addition_widget.cpp index 82ed6191c..30e99f401 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_addition_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_addition_widget.cpp @@ -1,5 +1,7 @@ #include "deck_preview_tag_addition_widget.h" +#include "../../interface/theme_manager.h" + #include #include #include @@ -26,6 +28,10 @@ DeckPreviewTagAdditionWidget::DeckPreviewTagAdditionWidget(QWidget *_parent, QSt // Adjust widget size setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); + + // The tag text reads the InterfaceDefault role at paint time, so a font + // change only needs a repaint. + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { update(); }); } QSize DeckPreviewTagAdditionWidget::sizeHint() const @@ -65,8 +71,9 @@ void DeckPreviewTagAdditionWidget::paintEvent(QPaintEvent *event) painter.setPen(borderPen); painter.drawRect(rect().adjusted(0, 0, -1, -1)); // Adjust for pen width - // Calculate font size based on widget height - QFont font = painter.font(); + // Calculate font size based on widget height. Family comes from the + // InterfaceDefault theme role; the height-derived size stays as before. + QFont font = themeManager->appFont(AppFont::InterfaceDefault); int fontSize = std::max(10, height() / 2); // Ensure a minimum font size of 10 font.setPointSize(fontSize); painter.setFont(font); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp index e62570241..34809da2e 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp @@ -1,5 +1,7 @@ #include "deck_preview_tag_display_widget.h" +#include "../../interface/theme_manager.h" + #include #include #include @@ -41,6 +43,10 @@ DeckPreviewTagDisplayWidget::DeckPreviewTagDisplayWidget(QWidget *parent, const // Adjust widget size setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); + // The tag text reads the InterfaceDefault role at paint time, so a font + // change only needs a repaint. + connect(themeManager, &ThemeManager::fontsChanged, this, [this] { update(); }); + // Connect close button to the remove signal // connect(closeButton, &QPushButton::clicked, this, &DeckPreviewTagDisplayWidget::tagClosed); } @@ -117,8 +123,9 @@ void DeckPreviewTagDisplayWidget::paintEvent(QPaintEvent *event) painter.setPen(borderPen); painter.drawRect(rect().adjusted(0, 0, -1, -1)); - // Calculate font size based on widget height - QFont font = painter.font(); + // Calculate font size based on widget height. Family comes from the + // InterfaceDefault theme role; the height-derived size stays as before. + QFont font = themeManager->appFont(AppFont::InterfaceDefault); int fontSize = std::max(10, height() / 2); // Ensure a minimum font size of 10 font.setPointSize(fontSize); painter.setFont(font); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp index 3611a8c1f..f9409f5f2 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp @@ -10,7 +10,7 @@ #include #include #include -#include +#include #include #include #include @@ -244,15 +244,15 @@ void VisualDeckStorageModel::startScan() // The scan (directory walk + one stat per file) runs on a worker thread so that // constructing the widget never stalls the UI thread on a large deck folder. - auto *watcher = new QFutureWatcher(this); - connect(watcher, &QFutureWatcher::finished, this, [this, watcher, generation] { - watcher->deleteLater(); - + // The continuation is bound to this model as its context object: it runs on the UI thread, + // and Qt drops it if the model is destroyed first, so no result is delivered to a dead model. + QFuture scan = + QtConcurrent::run([currentDeckPath]() -> DeckScanResult { return scanDeckDirectory(currentDeckPath); }); + scan.then(this, [this, generation](const DeckScanResult &result) { if (generation != scanGeneration) { return; // A newer scan started while this one was running; drop the stale result. } - const DeckScanResult result = watcher->result(); folderPaths = result.folderPaths; if (result.decks.isEmpty()) { @@ -268,9 +268,6 @@ void VisualDeckStorageModel::startScan() beginLoad(row); } }); - - watcher->setFuture( - QtConcurrent::run([currentDeckPath]() -> DeckScanResult { return scanDeckDirectory(currentDeckPath); })); } void VisualDeckStorageModel::beginLoad(int row) @@ -286,32 +283,7 @@ void VisualDeckStorageModel::beginLoad(int row) const DeckFileFormat::Format fmt = DeckFileFormat::getFormatFromName(filePath); const int generation = scanGeneration; - auto *watcher = new QFutureWatcher>(this); - connect(watcher, &QFutureWatcher>::finished, this, - [this, watcher, filePath, generation] { - watcher->deleteLater(); - - if (generation != scanGeneration) { - return; // The deck list was re-scanned while this load was running; drop the stale result. - } - - // Queue the result and apply a bounded number per event loop turn so that - // finishing hundreds of loads at once cannot stall the UI thread. - const std::optional result = watcher->result(); - PendingDeckLoad pending; - pending.filePath = filePath; - pending.generation = generation; - if (result) { - pending.ok = true; - pending.deck = std::move(result->deck); - pending.lastModified = result->lastModified; - pending.colorIdentity = std::move(result->colorIdentity); - } - pendingLoads.append(std::move(pending)); - schedulePendingLoadDrain(); - }); - - watcher->setFuture(QtConcurrent::run([filePath, fmt]() -> std::optional { + auto load = QtConcurrent::run([filePath, fmt]() -> std::optional { std::optional deck = DeckLoader::loadFromFile(filePath, fmt, false); if (!deck) { return std::nullopt; @@ -320,7 +292,28 @@ void VisualDeckStorageModel::beginLoad(int row) // keep the completion handler on the UI thread cheap. const QString colorIdentity = getDeckColorIdentity(deck->deckList, CardDatabaseManager::query()); return DeckLoadResult{std::move(*deck), QFileInfo(filePath).lastModified(), colorIdentity}; - })); + }); + + // Bound to this model as context: runs on the UI thread and is dropped if the model is destroyed first. + load.then(this, [this, filePath, generation](std::optional result) { + if (generation != scanGeneration) { + return; // The deck list was re-scanned while this load was running; drop the stale result. + } + + // Queue the result and apply a bounded number per event loop turn so that + // finishing hundreds of loads at once cannot stall the UI thread. + PendingDeckLoad pending; + pending.filePath = filePath; + pending.generation = generation; + if (result) { + pending.ok = true; + pending.deck = std::move(result->deck); + pending.lastModified = result->lastModified; + pending.colorIdentity = std::move(result->colorIdentity); + } + pendingLoads.append(std::move(pending)); + schedulePendingLoadDrain(); + }); } void VisualDeckStorageModel::schedulePendingLoadDrain() diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp index f2669f55d..052f6231a 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp @@ -1,7 +1,7 @@ #include "visual_deck_storage_search_widget.h" #include "../../../filters/syntax_help.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include #include @@ -27,9 +27,10 @@ VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(QWidget *parent) : searchBar = new QLineEdit(this); searchBar->setPlaceholderText(tr("Search by filename (or search expression)")); searchBar->setClearButtonEnabled(true); - searchBar->addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition); + searchBar->addAction(loadThemedIcon(QStringLiteral("icons/search"), IconTint::quiet()), QLineEdit::LeadingPosition); - auto help = searchBar->addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); + auto help = searchBar->addAction(loadThemedIcon(QStringLiteral("icons/info"), IconTint::quiet()), + QLineEdit::TrailingPosition); connect(help, &QAction::triggered, this, [this] { createDeckSearchSyntaxHelpWindow(searchBar); }); layout->addWidget(searchBar); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp index 4e1d6293f..14f3c19de 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp @@ -1,7 +1,7 @@ #include "visual_deck_storage_widget.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "deck_preview/deck_preview_color_identity_filter_widget.h" #include "deck_preview/deck_preview_widget.h" #include "libcockatrice/card/database/card_database.h" @@ -62,12 +62,12 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare searchWidget = new VisualDeckStorageSearchWidget(this); refreshButton = new QToolButton(this); - refreshButton->setIcon(themePixmap(QStringLiteral("icons/reload"))); + refreshButton->setIcon(loadThemedIcon(QStringLiteral("icons/reload"))); refreshButton->setFixedSize(32, 32); connect(refreshButton, &QPushButton::clicked, this, &VisualDeckStorageWidget::refreshIfPossible); shareButton = new QToolButton(this); - shareButton->setIcon(themePixmap(QStringLiteral("icons/share"))); + shareButton->setIcon(loadThemedIcon(QStringLiteral("icons/share"), AppColor::Informational)); shareButton->setFixedSize(32, 32); shareButton->setVisible(false); connect(shareButton, &QPushButton::clicked, this, &VisualDeckStorageWidget::shareRequested); diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index d546676bc..715505926 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -50,11 +50,20 @@ #include "latency_tracker.h" #include "logger.h" #include "pixel_map_generator.h" +#include "theme_icon.h" #include "version_string.h" #include "widgets/dialogs/dlg_connect.h" #include "widgets/server/handle_public_servers.h" #include "widgets/settings_page/abstract_settings_page.h" #include "widgets/tabs/api/commander_spellbook/handle_commander_brackets.h" +#ifdef WITH_GUI_TEST +#include "../game_graphics/pq_cockatrice_event_player.h" +#include "pqEventObserver.h" +#include "pqEventSource.h" +#include "pqEventTypes.h" +#include "pqTestUtility.h" +#include "recording.h" +#endif #include #include @@ -310,6 +319,7 @@ void MainWindow::actAbout() mb.setIconPixmap( themePixmap(QStringLiteral("cockatrice")).scaled(64, 64, Qt::KeepAspectRatio, Qt::SmoothTransformation)); mb.setTextInteractionFlags(Qt::TextBrowserInteraction); + mb.setObjectName("aboutDialog"); mb.exec(); } @@ -348,6 +358,24 @@ void MainWindow::actOpenSettingsFolder() QtUtils::openPathInFileManager(dir); } +#ifdef WITH_GUI_TEST +void MainWindow::actRecord() +{ + QString filename = QFileDialog::getSaveFileName(this, tr("Test File Name"), QString(), tr("XML Files (*.xml)")); + if (!filename.isEmpty()) { + testUtility->recordTests(filename); + } +} + +void MainWindow::actPlayRecording() +{ + QString filename = QFileDialog::getOpenFileName(this, tr("Test File Name"), QString(), tr("XML Files (*.xml)")); + if (!filename.isEmpty()) { + testUtility->playTests(filename); + } +} +#endif + void MainWindow::retranslateUi() { connectionController->refreshWindowTitle(); @@ -360,10 +388,10 @@ void MainWindow::retranslateUi() aRegister->setText(tr("&Register to server...")); aForgotPassword->setText(tr("&Restore password...")); aSettings->setText(tr("&Settings...")); - aSettings->setIcon(themePixmap(QStringLiteral("icons/settings"))); + aSettings->setIcon(loadThemedIcon(QStringLiteral("icons/settings"))); settingsButton->setToolTip(tr("Settings")); settingsButton->setAccessibleName(tr("Settings")); - settingsButton->setIcon(themePixmap(QStringLiteral("icons/cogwheel"))); + settingsButton->setIcon(loadThemedIcon(QStringLiteral("icons/settings"))); aExit->setText(tr("&Exit")); #if defined(__APPLE__) /* For OSX */ @@ -392,6 +420,10 @@ void MainWindow::retranslateUi() aViewLog->setText(tr("View &Debug Log")); aOpenSettingsFolder->setText(tr("Open Settings Folder")); aFirstRunWizard->setText(tr("Re-run Onboarding Wizard...")); +#ifdef WITH_GUI_TEST + aRecord->setText(tr("&Record Test...")); + aPlayRecording->setText(tr("&Play Test...")); +#endif aShow->setText(tr("Show/Hide")); @@ -401,24 +433,33 @@ void MainWindow::retranslateUi() void MainWindow::createActions() { aConnect = new QAction(this); + aConnect->setObjectName("actionConnect"); connect(aConnect, &QAction::triggered, this, &MainWindow::actConnect); aDisconnect = new QAction(this); + aDisconnect->setObjectName("actionDisconnect"); aDisconnect->setEnabled(false); connect(aDisconnect, &QAction::triggered, this, &MainWindow::actDisconnect); aSinglePlayer = new QAction(this); + aSinglePlayer->setObjectName("actionSinglePlayer"); connect(aSinglePlayer, &QAction::triggered, this, &MainWindow::actSinglePlayer); aWatchReplay = new QAction(this); + aWatchReplay->setObjectName("actionWatchReplay"); connect(aWatchReplay, &QAction::triggered, this, &MainWindow::actWatchReplay); aFullScreen = new QAction(this); + aFullScreen->setObjectName("actionFullScreen"); aFullScreen->setCheckable(true); connect(aFullScreen, &QAction::toggled, this, &MainWindow::actFullScreen); aRegister = new QAction(this); + aRegister->setObjectName("actionRegister"); connect(aRegister, &QAction::triggered, connectionController, &ConnectionController::registerToServer); aForgotPassword = new QAction(this); + aForgotPassword->setObjectName("actionForgotPassword"); connect(aForgotPassword, &QAction::triggered, connectionController, &ConnectionController::forgotPasswordRequest); aSettings = new QAction(this); + aSettings->setObjectName("actionSettings"); connect(aSettings, &QAction::triggered, this, &MainWindow::actSettings); aExit = new QAction(this); + aExit->setObjectName("actionExit"); connect(aExit, &QAction::triggered, this, &MainWindow::actExit); aManageSets = new QAction(QString(), this); @@ -455,6 +496,12 @@ void MainWindow::createActions() connect(aOpenSettingsFolder, &QAction::triggered, this, &MainWindow::actOpenSettingsFolder); aFirstRunWizard = new QAction(this); connect(aFirstRunWizard, &QAction::triggered, this, [this] { runFirstRunWizard(); }); +#ifdef WITH_GUI_TEST + aRecord = new QAction(this); + connect(aRecord, &QAction::triggered, this, &MainWindow::actRecord); + aPlayRecording = new QAction(this); + connect(aPlayRecording, &QAction::triggered, this, &MainWindow::actPlayRecording); +#endif aShow = new QAction(this); connect(aShow, &QAction::triggered, this, &MainWindow::actShow); @@ -495,7 +542,9 @@ void MainWindow::createActions() void MainWindow::createMenus() { + menuBar()->setObjectName("menubar"); cockatriceMenu = menuBar()->addMenu(QString()); + cockatriceMenu->setObjectName("cockatriceMenu"); cockatriceMenu->addAction(aConnect); cockatriceMenu->addAction(aDisconnect); cockatriceMenu->addAction(aRegister); @@ -511,6 +560,7 @@ void MainWindow::createMenus() cockatriceMenu->addAction(aExit); dbMenu = menuBar()->addMenu(QString()); + dbMenu->setObjectName("dbMenu"); dbMenu->addAction(aManageSets); dbMenu->addAction(aEditTokens); dbMenu->addSeparator(); @@ -521,8 +571,10 @@ void MainWindow::createMenus() dbMenu->addAction(aReloadCardDatabase); tabsMenu = menuBar()->addMenu(QString()); + tabsMenu->setObjectName("tabsMenu"); helpMenu = menuBar()->addMenu(QString()); + helpMenu->setObjectName("helpMenu"); helpMenu->addAction(aAbout); helpMenu->addAction(aTips); helpMenu->addSeparator(); @@ -535,12 +587,19 @@ void MainWindow::createMenus() helpMenu->addAction(aOpenSettingsFolder); helpMenu->addSeparator(); helpMenu->addAction(aFirstRunWizard); +#ifdef WITH_GUI_TEST + helpMenu->addSeparator(); + helpMenu->addAction(aRecord); + helpMenu->addAction(aPlayRecording); +#endif } MainWindow::MainWindow(QWidget *parent) - : QMainWindow(parent), localServer(nullptr), bHasActivated(false), askedForDbUpdater(false), + : QMainWindow(parent), localServer(nullptr), bHasActivated(false), askedForDbUpdater(false), noConnect(false), cardUpdateProcess(nullptr), logviewDialog(nullptr) { + setObjectName("MainWindow"); + connect(&SettingsCache::instance().cacheStorage(), &CacheStorageSettings::pixmapCacheSizeChanged, this, &MainWindow::pixmapCacheSizeChanged); pixmapCacheSizeChanged(SettingsCache::instance().cacheStorage().getPixmapCacheSize()); @@ -626,6 +685,14 @@ MainWindow::MainWindow(QWidget *parent) // run startup check async QTimer::singleShot(0, this, &MainWindow::startupConfigCheck); QTimer::singleShot(0, this, &MainWindow::applyStartupDestination); + +#ifdef WITH_GUI_TEST + testUtility = new pqTestUtility(this); + testUtility->setRecordWithDialog(true); + testUtility->addEventObserver("xml", new XMLEventObserver(this)); + testUtility->addEventSource("xml", new XMLEventSource(this)); + testUtility->eventPlayer()->addWidgetEventPlayer(new pqCockatriceEventPlayer(this)); +#endif } void MainWindow::startupConfigCheck() @@ -780,6 +847,11 @@ void MainWindow::runFirstRunWizard(bool firstRun) */ void MainWindow::applyStartupDestination() { + if (noConnect) { + qCInfo(WindowMainStartupAutoconnectLog) << "Startup destination suppressed (--no-connect)"; + return; + } + // An explicit command-line connect takes precedence over the startup destination. if (!connectTo.isEmpty()) { return; @@ -1049,6 +1121,10 @@ void MainWindow::handleCockatriceLink(const QString &url) void MainWindow::attemptStartupAutoConnect() { + if (noConnect) { + qCInfo(WindowMainStartupAutoconnectLog) << "Auto-connect suppressed (--no-connect)"; + return; + } if (startupAutoConnectAttempted || skipStartupAutoConnect) { return; } diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index 9e342e4b0..48d093a38 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -48,6 +48,7 @@ class QAction; class QToolButton; class QWidget; +class pqTestUtility; struct LocalGameOptions; inline Q_LOGGING_CATEGORY(WindowMainLog, "window_main"); @@ -104,6 +105,11 @@ private slots: void actUpdate(); void actViewLog(); void actOpenSettingsFolder(); +#ifdef WITH_GUI_TEST + void actRecord(); + void actPlayRecording(); +#endif + void actShow(); void showWindowIfHidden(); void onUrlChainFinished(bool connected); @@ -185,6 +191,9 @@ private: *aReloadCardDatabase; QAction *aTips, *aUpdate, *aCheckCardUpdates, *aCheckCardUpdatesBackground, *aFirstRunWizard, *aStatusBar, *aViewLog, *aOpenSettingsFolder; +#ifdef WITH_GUI_TEST + QAction *aRecord, *aPlayRecording; +#endif TabSupervisor *tabSupervisor; IntentUrlParser *urlParser; @@ -208,6 +217,7 @@ private: bool skipStartupAutoConnect = false; bool startupAutoConnectAttempted = false; bool firstRunWizardActive = false; + bool noConnect = false; ///< --no-connect: skip every startup connection QProcess *cardUpdateProcess; QByteArray cardUpdateOutputBuffer; QByteArray cardUpdateErrorOutput; ///< diagnostic output collected from the updater's stderr @@ -219,6 +229,9 @@ private: GameReplay *replay; DlgTipOfTheDay *tip; QUrl connectTo; +#ifdef WITH_GUI_TEST + pqTestUtility *testUtility; +#endif public: explicit MainWindow(QWidget *parent = nullptr); @@ -226,6 +239,16 @@ public: { connectTo = QUrl(QString("cockatrice://%1").arg(url)); } + /** + * @brief Suppresses every startup connection: --connect, auto-connect and the + * configured startup destination. Used by the GUI test harness so a + * playback run never dials out. + */ + void setNoConnect(bool value) + { + noConnect = value; + } + // When set, the window's own startup connection (--connect or auto-connect // on first activation) is skipped. Used for activation launches: the intent // chain triggered by a cockatrice:// URL owns the connection, and letting @@ -266,6 +289,13 @@ public: */ void requestInstallerClose(); +#ifdef WITH_GUI_TEST + pqTestUtility *getTestUtility() const + { + return testUtility; + } +#endif + protected: void closeEvent(QCloseEvent *event) override; void changeEvent(QEvent *event) override; diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp index 829a8ac42..e82867f3f 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -39,6 +39,10 @@ #include "single_instance_manager.h" #include "version_string.h" +#ifdef WITH_GUI_TEST +#include "pqTestUtility.h" +#endif + #include #include #include @@ -62,10 +66,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -343,9 +349,16 @@ int main(int argc, char *argv[]) parser.addOptions( {{{"c", "connect"}, QCoreApplication::translate("main", "Connect on startup"), "user:pass@host:port"}, {{"d", "debug-output"}, QCoreApplication::translate("main", "Debug to file")}, - {"simulate-crash", - QCoreApplication::translate("main", - "Show the crash-report dialog (saves a mock file when no DSN is configured)")}}); + {"simulate-crash", QCoreApplication::translate( + "main", "Show the crash-report dialog (saves a mock file when no DSN is configured)")}, + {"no-connect", QCoreApplication::translate("main", "Do not connect to any server on startup")} +#ifdef WITH_GUI_TEST + , + {{"t", "test"}, + QCoreApplication::translate("main", "Play back a QtTesting XML test file and exit"), + "test-file.xml"} +#endif + }); parser.process(app); @@ -414,6 +427,23 @@ int main(int argc, char *argv[]) CardDatabaseManager::setCardDatabasePathProvider(&SettingsCache::instance()); CardDatabaseManager::setCardSetPriorityController(&SettingsCache::instance().cardDatabase()); + // Lifecycle breadcrumbs for native crash reports (see COCKATRICE-DESKTOP-1R): the live + // card maps are replaced wholesale on reset, so knowing whether a crash followed a reload + // narrows down which QMap was being read. Direct connections: the SDK call is thread-safe + // and loading signals may come from a QtConcurrent worker. + { + CardDatabase *crumbDb = CardDatabaseManager::getInstance(); + QObject::connect( + crumbDb, &CardDatabase::cardDatabaseReset, crumbDb, + [] { CrashReporter::addBreadcrumb("card_db", "card database snapshot swapped in"); }, Qt::DirectConnection); + QObject::connect( + crumbDb, &CardDatabase::cardDatabaseLoadingFinished, crumbDb, + [] { CrashReporter::addBreadcrumb("card_db", "card database loading finished"); }, Qt::DirectConnection); + QObject::connect( + crumbDb, &CardDatabase::cardDatabaseLoadingFailed, crumbDb, + [] { CrashReporter::addBreadcrumb("card_db", "card database loading failed"); }, Qt::DirectConnection); + } + qCInfo(MainLog) << "Starting main program"; // Front-load the card database before constructing the main window. The @@ -483,6 +513,9 @@ int main(int argc, char *argv[]) if (parser.isSet("connect")) { ui.setConnectTo(parser.value("connect")); } + if (parser.isSet("no-connect")) { + ui.setNoConnect(true); + } qCInfo(MainLog) << "MainWindow constructor finished"; ui.setWindowIcon(themePixmap(QStringLiteral("cockatrice"))); @@ -523,6 +556,27 @@ int main(int argc, char *argv[]) handleActivation(file); } +#ifdef WITH_GUI_TEST + if (parser.isSet("test")) { + QString testFile = parser.value("test"); + qCInfo(MainLog) << "Playing test file:" << testFile; + QTimer::singleShot(0, &ui, [&ui, testFile]() { + bool success = ui.getTestUtility()->playTests(testFile); + qCInfo(MainLog) << "Test playback" << (success ? "succeeded" : "FAILED"); + qApp->exit(success ? 0 : 1); + + // An event that fails while a modal dialog is open aborts the dispatcher, + // but the dialog's nested event loop can then keep the application alive + // forever. Hard-exit after a grace period so a red test always terminates. + auto *watchdog = QThread::create([success] { + QThread::sleep(10); + ::_Exit(success ? 0 : 1); + }); + watchdog->start(); + }); + } +#endif + // Connect to future file/URL events from other instances QObject::connect(&instance, &SingleInstanceManager::filesReceived, [&handleActivation](const QStringList &files) { for (const QString &file : files) { diff --git a/cockatrice/themes/Fabric/palette-default-dark.toml b/cockatrice/themes/Fabric/palette-default-dark.toml index cad18921a..419859844 100644 --- a/cockatrice/themes/Fabric/palette-default-dark.toml +++ b/cockatrice/themes/Fabric/palette-default-dark.toml @@ -71,4 +71,12 @@ Accent = #4a82e6 [AppColors] AccentStrong = #3a6fd6 AccentSoft = #8fb8f2 - +Icon = #7ca0e5 +IconStrong = #6a9dff +IconSoft = #93a1ba +Positive = #5bb174 +PositiveStrong = #30b758 +PositiveSoft = #84a98f +Negative = #ed8077 +NegativeStrong = #ff7367 +NegativeSoft = #be9794 diff --git a/cockatrice/themes/Fabric/palette-default-light.toml b/cockatrice/themes/Fabric/palette-default-light.toml index f5151af3e..95973457f 100644 --- a/cockatrice/themes/Fabric/palette-default-light.toml +++ b/cockatrice/themes/Fabric/palette-default-light.toml @@ -71,4 +71,12 @@ Accent = #2f6fd6 [AppColors] AccentStrong = #3a6fd6 AccentSoft = #8fb8f2 - +Icon = #2f58ac +IconStrong = #054ddf +IconSoft = #505c76 +Positive = #166931 +PositiveStrong = #006b22 +PositiveSoft = #40634b +Negative = #b0251d +NegativeStrong = #ba0a00 +NegativeSoft = #7c504d diff --git a/cockatrice/themes/Fusion/palette-default-dark.toml b/cockatrice/themes/Fusion/palette-default-dark.toml index b180e9b63..adc992a89 100644 --- a/cockatrice/themes/Fusion/palette-default-dark.toml +++ b/cockatrice/themes/Fusion/palette-default-dark.toml @@ -69,5 +69,14 @@ Accent = #ff1e1e1e [AppColors] -AccentStrong = #ff139740 -AccentSoft = #ffc9fd62 +AccentStrong = #ff139740 +AccentSoft = #ffc9fd62 +Icon = #74ace4 +IconStrong = #58acff +IconSoft = #95aabf +Positive = #5fb97a +PositiveStrong = #32bf5c +PositiveSoft = #8ab196 +Negative = #f8867c +NegativeStrong = #ff8075 +NegativeSoft = #c79e9b diff --git a/cockatrice/themes/Fusion/palette-default-light.toml b/cockatrice/themes/Fusion/palette-default-light.toml index 5a1163a2b..5e2caa4b8 100644 --- a/cockatrice/themes/Fusion/palette-default-light.toml +++ b/cockatrice/themes/Fusion/palette-default-light.toml @@ -69,5 +69,14 @@ Accent = #fff0f0f0 [AppColors] -AccentStrong = #ff139740 -AccentSoft = #ffc9fd62 +AccentStrong = #ff139740 +AccentSoft = #ffc9fd62 +Icon = #196dc2 +IconStrong = #006ad4 +IconSoft = #546f8a +Positive = #1b7d3a +PositiveStrong = #007e28 +PositiveSoft = #4c765a +Negative = #cf2c23 +NegativeStrong = #da0c00 +NegativeSoft = #935f5c diff --git a/cockatrice/themes/Leather/palette-default-dark.toml b/cockatrice/themes/Leather/palette-default-dark.toml index b0db0e90a..6782bb7c4 100644 --- a/cockatrice/themes/Leather/palette-default-dark.toml +++ b/cockatrice/themes/Leather/palette-default-dark.toml @@ -71,4 +71,12 @@ Accent = #c9995a [AppColors] AccentStrong = #b7823b AccentSoft = #e4c58f - +Icon = #bd8d4a +IconStrong = #d0851e +IconSoft = #a79479 +Positive = #55a66d +PositiveStrong = #2dab52 +PositiveSoft = #7c9f86 +Negative = #df7970 +NegativeStrong = #ff5e50 +NegativeSoft = #b38e8b diff --git a/cockatrice/themes/Leather/palette-default-light.toml b/cockatrice/themes/Leather/palette-default-light.toml index ff122db76..e405881cd 100644 --- a/cockatrice/themes/Leather/palette-default-light.toml +++ b/cockatrice/themes/Leather/palette-default-light.toml @@ -71,4 +71,12 @@ Accent = #a5712f [AppColors] AccentStrong = #b7823b AccentSoft = #e4c58f - +Icon = #725025 +IconStrong = #7e4b09 +IconSoft = #625544 +Positive = #15642e +PositiveStrong = #006621 +PositiveSoft = #3d5f48 +Negative = #a7231c +NegativeStrong = #b10a00 +NegativeSoft = #764c4a diff --git a/cockatrice/themes/Plasma/palette-default-dark.toml b/cockatrice/themes/Plasma/palette-default-dark.toml index 222456f4f..de36f449a 100644 --- a/cockatrice/themes/Plasma/palette-default-dark.toml +++ b/cockatrice/themes/Plasma/palette-default-dark.toml @@ -71,4 +71,12 @@ Accent = #6a4df0 [AppColors] AccentStrong = #5b3ee0 AccentSoft = #a28bf7 - +Icon = #9380eb +IconStrong = #927aff +IconSoft = #928cb0 +Positive = #519f69 +PositiveStrong = #2ba44f +PositiveSoft = #779881 +Negative = #d5736b +NegativeStrong = #ff4f40 +NegativeSoft = #ab8885 diff --git a/cockatrice/themes/Plasma/palette-default-light.toml b/cockatrice/themes/Plasma/palette-default-light.toml index d54db31d2..b93002e25 100644 --- a/cockatrice/themes/Plasma/palette-default-light.toml +++ b/cockatrice/themes/Plasma/palette-default-light.toml @@ -71,4 +71,12 @@ Accent = #5a3ee2 [AppColors] AccentStrong = #5b3ee0 AccentSoft = #a28bf7 - +Icon = #5338ce +IconStrong = #4118fe +IconSoft = #585078 +Positive = #15622e +PositiveStrong = #006420 +PositiveSoft = #3c5d47 +Negative = #a4231b +NegativeStrong = #ae0900 +NegativeSoft = #744b48 diff --git a/cockatrice/themes/System/palette-default-dark.toml b/cockatrice/themes/System/palette-default-dark.toml index 9dfe6e19f..b26bbd268 100644 --- a/cockatrice/themes/System/palette-default-dark.toml +++ b/cockatrice/themes/System/palette-default-dark.toml @@ -1,7 +1,19 @@ # The System theme derives all of its colors from the platform palette # (e.g. qt6ct's custom color scheme on Linux), so it must NOT ship palette -# overrides. Only the identity app colors are provided here, so buttons keep -# their classic greens while the actual palette stays native to the OS. +# overrides. Only the identity and semantic app colors are provided here, so +# buttons keep their classic greens while the actual palette stays native to +# the OS. +# The neutral icon family is deliberately not authored: the palette file is +# picked from the theme's scheme, which on Qt < 6.5 is always light and on newer +# Qt can be unknown, so a fixed black-or-white ladder would be invisible on a +# platform palette of the other scheme. Neutral ink derives from the live +# WindowText instead. [AppColors] -AccentStrong = #ff148c3c -AccentSoft = #ff78c850 +AccentStrong = #ff148c3c +AccentSoft = #ff78c850 +Positive = #ff6dd58c +PositiveStrong = #ff32c05c +PositiveSoft = #ff8ab196 +Negative = #ffff8a80 +NegativeStrong = #ffff8378 +NegativeSoft = #ffc79e9b diff --git a/cockatrice/themes/System/palette-default-light.toml b/cockatrice/themes/System/palette-default-light.toml index 9dfe6e19f..6d6d7ebf4 100644 --- a/cockatrice/themes/System/palette-default-light.toml +++ b/cockatrice/themes/System/palette-default-light.toml @@ -1,7 +1,19 @@ # The System theme derives all of its colors from the platform palette # (e.g. qt6ct's custom color scheme on Linux), so it must NOT ship palette -# overrides. Only the identity app colors are provided here, so buttons keep -# their classic greens while the actual palette stays native to the OS. +# overrides. Only the identity and semantic app colors are provided here, so +# buttons keep their classic greens while the actual palette stays native to +# the OS. +# The neutral icon family is deliberately not authored: the palette file is +# picked from the theme's scheme, which on Qt < 6.5 is always light and on newer +# Qt can be unknown, so a fixed black-or-white ladder would be invisible on a +# platform palette of the other scheme. Neutral ink derives from the live +# WindowText instead. [AppColors] -AccentStrong = #ff148c3c -AccentSoft = #ff78c850 +AccentStrong = #ff148c3c +AccentSoft = #ff78c850 +Positive = #ff1a7d3a +PositiveStrong = #ff007e28 +PositiveSoft = #ff4c7659 +Negative = #ffb3261e +NegativeStrong = #ffda0b00 +NegativeSoft = #ff935f5c diff --git a/cockatrice/themes/VelvetMarble/palette-default-dark.toml b/cockatrice/themes/VelvetMarble/palette-default-dark.toml index 496943931..1fb24eb65 100644 --- a/cockatrice/themes/VelvetMarble/palette-default-dark.toml +++ b/cockatrice/themes/VelvetMarble/palette-default-dark.toml @@ -71,4 +71,12 @@ Accent = #8fa6c0 [AppColors] AccentStrong = #54687e AccentSoft = #9db0c4 - +Icon = #8493a2 +IconStrong = #6b95bf +IconSoft = #8c9399 +Positive = #52a16a +PositiveStrong = #2ba650 +PositiveSoft = #789a82 +Negative = #d8756c +NegativeStrong = #ff5344 +NegativeSoft = #ad8a86 diff --git a/cockatrice/themes/VelvetMarble/palette-default-light.toml b/cockatrice/themes/VelvetMarble/palette-default-light.toml index 5323fd320..1599d71fc 100644 --- a/cockatrice/themes/VelvetMarble/palette-default-light.toml +++ b/cockatrice/themes/VelvetMarble/palette-default-light.toml @@ -71,4 +71,12 @@ Accent = #54687e [AppColors] AccentStrong = #54687e AccentSoft = #9db0c4 - +Icon = #495a6e +IconStrong = #375a84 +IconSoft = #525961 +Positive = #16662f +PositiveStrong = #006721 +PositiveSoft = #3e6049 +Negative = #aa241c +NegativeStrong = #b40a00 +NegativeSoft = #784d4b diff --git a/doc/doxygen/pages/user_documentation/deck_management/editing_decks.md b/doc/doxygen/pages/user_documentation/deck_management/editing_decks.md deleted file mode 100644 index 37b613461..000000000 --- a/doc/doxygen/pages/user_documentation/deck_management/editing_decks.md +++ /dev/null @@ -1,6 +0,0 @@ -@page editing_decks Editing Decks - - - @subpage editing_decks_classic - - @subpage editing_decks_visual - - - @subpage editing_decks_printings diff --git a/.github/CONTRIBUTING.md b/docs/CONTRIBUTING.md similarity index 100% rename from .github/CONTRIBUTING.md rename to docs/CONTRIBUTING.md diff --git a/doc/doxygen/DoxygenLayout.xml b/docs/doxygen/DoxygenLayout.xml similarity index 100% rename from doc/doxygen/DoxygenLayout.xml rename to docs/doxygen/DoxygenLayout.xml diff --git a/doc/doxygen/README.md b/docs/doxygen/README.md similarity index 80% rename from doc/doxygen/README.md rename to docs/doxygen/README.md index 230422303..ca4acc1fc 100644 --- a/doc/doxygen/README.md +++ b/docs/doxygen/README.md @@ -1,11 +1,11 @@ ## Building Doxygen Documentation We use [Doxygen](https://www.doxygen.nl) for our code documentation. -After installing the tool, you can build a HTML webpage into the `/docs` folder according to our [configuration](https://github.com/Cockatrice/Cockatrice/blob/master/Doxyfile): +After installing the tool, you can build a HTML webpage into the `/docs-output` folder according to our [configuration](https://github.com/Cockatrice/Cockatrice/blob/master/Doxyfile): ```bash doxygen Doxyfile ``` -Open the `index.html` file in the `/docs/html/` folder to browse the result locally. +Open the `index.html` file in the `/docs-output/html/` folder to browse the result locally. There is also an online version we host at the Cockatrice webpage: [cockatrice.github.io/docs](https://cockatrice.github.io/docs) Doxygen's own documenation can be found at [www.doxygen.nl/manual](https://www.doxygen.nl/manual). @@ -25,4 +25,4 @@ FULL_SIDEBAR = NO # Required for doxygen-awesome
    -Cockatrice dedicated color adjustments are configured in the [cockatrice_docs_style.css](https://github.com/Cockatrice/Cockatrice/blob/master/doc/doxygen/css/cockatrice_docs_style.css) file that uses & overrides the theme definitions. +Cockatrice dedicated color adjustments are configured in the [cockatrice_docs_style.css](https://github.com/Cockatrice/Cockatrice/blob/master/docs/doxygen/css/cockatrice_docs_style.css) file that uses & overrides the theme definitions. diff --git a/doc/doxygen/css/cockatrice_docs_style.css b/docs/doxygen/css/cockatrice_docs_style.css similarity index 100% rename from doc/doxygen/css/cockatrice_docs_style.css rename to docs/doxygen/css/cockatrice_docs_style.css diff --git a/doc/doxygen/css/doxygen_style_old.css b/docs/doxygen/css/doxygen_style_old.css similarity index 100% rename from doc/doxygen/css/doxygen_style_old.css rename to docs/doxygen/css/doxygen_style_old.css diff --git a/doc/doxygen/css/hide_nav_sync.css b/docs/doxygen/css/hide_nav_sync.css similarity index 100% rename from doc/doxygen/css/hide_nav_sync.css rename to docs/doxygen/css/hide_nav_sync.css diff --git a/doc/doxygen/filters/proto2cpp.py b/docs/doxygen/filters/proto2cpp.py similarity index 100% rename from doc/doxygen/filters/proto2cpp.py rename to docs/doxygen/filters/proto2cpp.py diff --git a/doc/doxygen/groups/doc_groups.dox b/docs/doxygen/groups/doc_groups.dox similarity index 100% rename from doc/doxygen/groups/doc_groups.dox rename to docs/doxygen/groups/doc_groups.dox diff --git a/doc/doxygen/html/footer.html b/docs/doxygen/html/footer.html similarity index 100% rename from doc/doxygen/html/footer.html rename to docs/doxygen/html/footer.html diff --git a/doc/doxygen/html/header.html b/docs/doxygen/html/header.html similarity index 100% rename from doc/doxygen/html/header.html rename to docs/doxygen/html/header.html diff --git a/doc/doxygen/images/cockatrice_logo.svg b/docs/doxygen/images/cockatrice_logo.svg similarity index 100% rename from doc/doxygen/images/cockatrice_logo.svg rename to docs/doxygen/images/cockatrice_logo.svg diff --git a/doc/doxygen/images/screenshots/classic_database_display.png b/docs/doxygen/images/screenshots/classic_database_display.png similarity index 100% rename from doc/doxygen/images/screenshots/classic_database_display.png rename to docs/doxygen/images/screenshots/classic_database_display.png diff --git a/doc/doxygen/images/screenshots/classic_database_display_add_buttons.png b/docs/doxygen/images/screenshots/classic_database_display_add_buttons.png similarity index 100% rename from doc/doxygen/images/screenshots/classic_database_display_add_buttons.png rename to docs/doxygen/images/screenshots/classic_database_display_add_buttons.png diff --git a/doc/doxygen/images/screenshots/classic_deck_editor.png b/docs/doxygen/images/screenshots/classic_deck_editor.png similarity index 100% rename from doc/doxygen/images/screenshots/classic_deck_editor.png rename to docs/doxygen/images/screenshots/classic_deck_editor.png diff --git a/doc/doxygen/images/screenshots/deck_dock_deck_list.png b/docs/doxygen/images/screenshots/deck_dock_deck_list.png similarity index 100% rename from doc/doxygen/images/screenshots/deck_dock_deck_list.png rename to docs/doxygen/images/screenshots/deck_dock_deck_list.png diff --git a/doc/doxygen/images/screenshots/deck_dock_deck_list_buttons.png b/docs/doxygen/images/screenshots/deck_dock_deck_list_buttons.png similarity index 100% rename from doc/doxygen/images/screenshots/deck_dock_deck_list_buttons.png rename to docs/doxygen/images/screenshots/deck_dock_deck_list_buttons.png diff --git a/doc/doxygen/images/screenshots/deck_dock_deck_list_group_by.png b/docs/doxygen/images/screenshots/deck_dock_deck_list_group_by.png similarity index 100% rename from doc/doxygen/images/screenshots/deck_dock_deck_list_group_by.png rename to docs/doxygen/images/screenshots/deck_dock_deck_list_group_by.png diff --git a/doc/doxygen/images/screenshots/deckeditordeckdockwidget.png b/docs/doxygen/images/screenshots/deckeditordeckdockwidget.png similarity index 100% rename from doc/doxygen/images/screenshots/deckeditordeckdockwidget.png rename to docs/doxygen/images/screenshots/deckeditordeckdockwidget.png diff --git a/doc/doxygen/images/screenshots/printing_selector.png b/docs/doxygen/images/screenshots/printing_selector.png similarity index 100% rename from doc/doxygen/images/screenshots/printing_selector.png rename to docs/doxygen/images/screenshots/printing_selector.png diff --git a/doc/doxygen/images/screenshots/printing_selector_disable.png b/docs/doxygen/images/screenshots/printing_selector_disable.png similarity index 100% rename from doc/doxygen/images/screenshots/printing_selector_disable.png rename to docs/doxygen/images/screenshots/printing_selector_disable.png diff --git a/doc/doxygen/images/screenshots/printing_selector_enable.png b/docs/doxygen/images/screenshots/printing_selector_enable.png similarity index 100% rename from doc/doxygen/images/screenshots/printing_selector_enable.png rename to docs/doxygen/images/screenshots/printing_selector_enable.png diff --git a/doc/doxygen/images/screenshots/printing_selector_navigation.png b/docs/doxygen/images/screenshots/printing_selector_navigation.png similarity index 100% rename from doc/doxygen/images/screenshots/printing_selector_navigation.png rename to docs/doxygen/images/screenshots/printing_selector_navigation.png diff --git a/doc/doxygen/images/screenshots/printing_selector_options.png b/docs/doxygen/images/screenshots/printing_selector_options.png similarity index 100% rename from doc/doxygen/images/screenshots/printing_selector_options.png rename to docs/doxygen/images/screenshots/printing_selector_options.png diff --git a/doc/doxygen/images/screenshots/printing_selector_pre_providerid.png b/docs/doxygen/images/screenshots/printing_selector_pre_providerid.png similarity index 100% rename from doc/doxygen/images/screenshots/printing_selector_pre_providerid.png rename to docs/doxygen/images/screenshots/printing_selector_pre_providerid.png diff --git a/doc/doxygen/images/screenshots/release_channel_beta.png b/docs/doxygen/images/screenshots/release_channel_beta.png similarity index 100% rename from doc/doxygen/images/screenshots/release_channel_beta.png rename to docs/doxygen/images/screenshots/release_channel_beta.png diff --git a/doc/doxygen/images/screenshots/vde_deck_analytics.png b/docs/doxygen/images/screenshots/vde_deck_analytics.png similarity index 100% rename from doc/doxygen/images/screenshots/vde_deck_analytics.png rename to docs/doxygen/images/screenshots/vde_deck_analytics.png diff --git a/doc/doxygen/images/screenshots/vde_flat_layout_color_grouped.png b/docs/doxygen/images/screenshots/vde_flat_layout_color_grouped.png similarity index 100% rename from doc/doxygen/images/screenshots/vde_flat_layout_color_grouped.png rename to docs/doxygen/images/screenshots/vde_flat_layout_color_grouped.png diff --git a/doc/doxygen/images/screenshots/vde_flat_layout_type_grouped.png b/docs/doxygen/images/screenshots/vde_flat_layout_type_grouped.png similarity index 100% rename from doc/doxygen/images/screenshots/vde_flat_layout_type_grouped.png rename to docs/doxygen/images/screenshots/vde_flat_layout_type_grouped.png diff --git a/doc/doxygen/images/screenshots/vde_overlap_layout_type_grouped.png b/docs/doxygen/images/screenshots/vde_overlap_layout_type_grouped.png similarity index 100% rename from doc/doxygen/images/screenshots/vde_overlap_layout_type_grouped.png rename to docs/doxygen/images/screenshots/vde_overlap_layout_type_grouped.png diff --git a/doc/doxygen/images/screenshots/vde_sample_hand.png b/docs/doxygen/images/screenshots/vde_sample_hand.png similarity index 100% rename from doc/doxygen/images/screenshots/vde_sample_hand.png rename to docs/doxygen/images/screenshots/vde_sample_hand.png diff --git a/doc/doxygen/js/graph_toggle.js b/docs/doxygen/js/graph_toggle.js similarity index 100% rename from doc/doxygen/js/graph_toggle.js rename to docs/doxygen/js/graph_toggle.js diff --git a/doc/doxygen/pages/developer_documentation/card_database_schema_and_parsing.md b/docs/doxygen/pages/developer_documentation/card_database_schema_and_parsing.md similarity index 81% rename from doc/doxygen/pages/developer_documentation/card_database_schema_and_parsing.md rename to docs/doxygen/pages/developer_documentation/card_database_schema_and_parsing.md index 7b4bdafc3..8679b910b 100644 --- a/doc/doxygen/pages/developer_documentation/card_database_schema_and_parsing.md +++ b/docs/doxygen/pages/developer_documentation/card_database_schema_and_parsing.md @@ -7,8 +7,8 @@ The token file follows the schema of the card database (`tokens.xml`), too. Saved decks (`.xml`) use a different schema. -- [XSD Schema for `Card Databases`](https://github.com/Cockatrice/Cockatrice/blob/master/doc/carddatabase_v4/cards.xsd) v4 -- [XSD Schema for `Decks`](https://github.com/Cockatrice/Cockatrice/blob/master/doc/deck.xsd) v1 +- [XSD Schema for `Card Databases`](https://github.com/Cockatrice/Cockatrice/blob/master/docs/xsd/cards.xsd) v4 +- [XSD Schema for `Decks`](https://github.com/Cockatrice/Cockatrice/blob/master/docs/xsd/deck.xsd) v1 # Card Database Parsing diff --git a/doc/doxygen/pages/developer_documentation/displaying_cards.md b/docs/doxygen/pages/developer_documentation/displaying_cards.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/displaying_cards.md rename to docs/doxygen/pages/developer_documentation/displaying_cards.md diff --git a/doc/doxygen/pages/developer_documentation/index.md b/docs/doxygen/pages/developer_documentation/index.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/index.md rename to docs/doxygen/pages/developer_documentation/index.md diff --git a/doc/doxygen/pages/developer_documentation/loading_card_pictures.md b/docs/doxygen/pages/developer_documentation/loading_card_pictures.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/loading_card_pictures.md rename to docs/doxygen/pages/developer_documentation/loading_card_pictures.md diff --git a/doc/doxygen/pages/developer_documentation/logging.md b/docs/doxygen/pages/developer_documentation/logging.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/logging.md rename to docs/doxygen/pages/developer_documentation/logging.md diff --git a/doc/doxygen/pages/developer_documentation/network/client/game_event_handler.md b/docs/doxygen/pages/developer_documentation/network/client/game_event_handler.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/network/client/game_event_handler.md rename to docs/doxygen/pages/developer_documentation/network/client/game_event_handler.md diff --git a/doc/doxygen/pages/developer_documentation/network/client/index.md b/docs/doxygen/pages/developer_documentation/network/client/index.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/network/client/index.md rename to docs/doxygen/pages/developer_documentation/network/client/index.md diff --git a/doc/doxygen/pages/developer_documentation/network/client/player_event_handler.md b/docs/doxygen/pages/developer_documentation/network/client/player_event_handler.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/network/client/player_event_handler.md rename to docs/doxygen/pages/developer_documentation/network/client/player_event_handler.md diff --git a/doc/doxygen/pages/developer_documentation/network/index.md b/docs/doxygen/pages/developer_documentation/network/index.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/network/index.md rename to docs/doxygen/pages/developer_documentation/network/index.md diff --git a/doc/doxygen/pages/developer_documentation/network/protocol/index.md b/docs/doxygen/pages/developer_documentation/network/protocol/index.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/network/protocol/index.md rename to docs/doxygen/pages/developer_documentation/network/protocol/index.md diff --git a/doc/doxygen/pages/developer_documentation/network/protocol/protocol_command_container.md b/docs/doxygen/pages/developer_documentation/network/protocol/protocol_command_container.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/network/protocol/protocol_command_container.md rename to docs/doxygen/pages/developer_documentation/network/protocol/protocol_command_container.md diff --git a/doc/doxygen/pages/developer_documentation/network/protocol/protocol_game_command.md b/docs/doxygen/pages/developer_documentation/network/protocol/protocol_game_command.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/network/protocol/protocol_game_command.md rename to docs/doxygen/pages/developer_documentation/network/protocol/protocol_game_command.md diff --git a/doc/doxygen/pages/developer_documentation/network/protocol/protocol_overview.md b/docs/doxygen/pages/developer_documentation/network/protocol/protocol_overview.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/network/protocol/protocol_overview.md rename to docs/doxygen/pages/developer_documentation/network/protocol/protocol_overview.md diff --git a/doc/doxygen/pages/developer_documentation/network/protocol/protocol_response.md b/docs/doxygen/pages/developer_documentation/network/protocol/protocol_response.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/network/protocol/protocol_response.md rename to docs/doxygen/pages/developer_documentation/network/protocol/protocol_response.md diff --git a/doc/doxygen/pages/developer_documentation/network/protocol/protocol_server_message.md b/docs/doxygen/pages/developer_documentation/network/protocol/protocol_server_message.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/network/protocol/protocol_server_message.md rename to docs/doxygen/pages/developer_documentation/network/protocol/protocol_server_message.md diff --git a/doc/doxygen/pages/developer_documentation/primer_cards.md b/docs/doxygen/pages/developer_documentation/primer_cards.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/primer_cards.md rename to docs/doxygen/pages/developer_documentation/primer_cards.md diff --git a/doc/doxygen/pages/developer_documentation/querying_the_card_database.md b/docs/doxygen/pages/developer_documentation/querying_the_card_database.md similarity index 100% rename from doc/doxygen/pages/developer_documentation/querying_the_card_database.md rename to docs/doxygen/pages/developer_documentation/querying_the_card_database.md diff --git a/doc/doxygen/pages/index.md b/docs/doxygen/pages/index.md similarity index 100% rename from doc/doxygen/pages/index.md rename to docs/doxygen/pages/index.md diff --git a/doc/doxygen/pages/user_documentation/card_pictures/custom_card_pictures.md b/docs/doxygen/pages/user_documentation/card_pictures/custom_card_pictures.md similarity index 100% rename from doc/doxygen/pages/user_documentation/card_pictures/custom_card_pictures.md rename to docs/doxygen/pages/user_documentation/card_pictures/custom_card_pictures.md diff --git a/doc/doxygen/pages/user_documentation/deck_management/creating_decks.md b/docs/doxygen/pages/user_documentation/deck_management/creating_decks.md similarity index 100% rename from doc/doxygen/pages/user_documentation/deck_management/creating_decks.md rename to docs/doxygen/pages/user_documentation/deck_management/creating_decks.md diff --git a/docs/doxygen/pages/user_documentation/deck_management/editing_decks.md b/docs/doxygen/pages/user_documentation/deck_management/editing_decks.md new file mode 100644 index 000000000..04833282d --- /dev/null +++ b/docs/doxygen/pages/user_documentation/deck_management/editing_decks.md @@ -0,0 +1,7 @@ +@page editing_decks Editing Decks + +- @subpage editing_decks_classic +- @subpage editing_decks_visual + +- @subpage visual_database_display +- @subpage editing_decks_printings diff --git a/doc/doxygen/pages/user_documentation/deck_management/editing_decks_classic.md b/docs/doxygen/pages/user_documentation/deck_management/editing_decks_classic.md similarity index 100% rename from doc/doxygen/pages/user_documentation/deck_management/editing_decks_classic.md rename to docs/doxygen/pages/user_documentation/deck_management/editing_decks_classic.md diff --git a/doc/doxygen/pages/user_documentation/deck_management/editing_decks_printings.md b/docs/doxygen/pages/user_documentation/deck_management/editing_decks_printings.md similarity index 100% rename from doc/doxygen/pages/user_documentation/deck_management/editing_decks_printings.md rename to docs/doxygen/pages/user_documentation/deck_management/editing_decks_printings.md diff --git a/doc/doxygen/pages/user_documentation/deck_management/editing_decks_visual.md b/docs/doxygen/pages/user_documentation/deck_management/editing_decks_visual.md similarity index 100% rename from doc/doxygen/pages/user_documentation/deck_management/editing_decks_visual.md rename to docs/doxygen/pages/user_documentation/deck_management/editing_decks_visual.md diff --git a/doc/doxygen/pages/user_documentation/deck_management/exporting_decks.md b/docs/doxygen/pages/user_documentation/deck_management/exporting_decks.md similarity index 100% rename from doc/doxygen/pages/user_documentation/deck_management/exporting_decks.md rename to docs/doxygen/pages/user_documentation/deck_management/exporting_decks.md diff --git a/doc/doxygen/pages/user_documentation/deck_management/importing_decks.md b/docs/doxygen/pages/user_documentation/deck_management/importing_decks.md similarity index 97% rename from doc/doxygen/pages/user_documentation/deck_management/importing_decks.md rename to docs/doxygen/pages/user_documentation/deck_management/importing_decks.md index 4aab7ad2c..fbfb8fe3c 100644 --- a/doc/doxygen/pages/user_documentation/deck_management/importing_decks.md +++ b/docs/doxygen/pages/user_documentation/deck_management/importing_decks.md @@ -70,4 +70,4 @@ confirm. The action will automatically import the deck from the online service without any other required user action. -Currently supported services are Archidekt, Deckstats, Moxfield, and TappedOut. \ No newline at end of file +Currently supported services are Archidekt, Deckstats, ManaBox, Moxfield, and TappedOut. diff --git a/docs/doxygen/pages/user_documentation/deck_management/visual_database_display.md b/docs/doxygen/pages/user_documentation/deck_management/visual_database_display.md new file mode 100644 index 000000000..097848ab8 --- /dev/null +++ b/docs/doxygen/pages/user_documentation/deck_management/visual_database_display.md @@ -0,0 +1,25 @@ +@page visual_database_display Visual Database Display + +# Purpose + +The VisualDatabaseDisplay presents all the cards from the enabled sets in the CardDatabase, providing a visual way to find and add cards to your DeckList. + +# Using the Visual Database Display + +The VisualDatabaseDisplay can be opened by itself using the Tabs section in the menu bar at the top of the screen, or by selecting the "Visual Database Display" tab in the visual deck editor. When opened as a part of the visual deck editor, the VisualDatabaseDisplay can be used to add and remove cards from your deck. See @ref editing_decks_visual for more information about modifying your deck. + +By default the cards are displayed as a grid of pictures. However, a table view using card names can be toggled by pressing the Visual/Table button at the top right of the widget. + +# Filtering cards + +The top section of the VisualDatabaseDisplay can be used to filter cards based on various card properties, as well as specifying in which way they should be sorted. Filters can be saved and reused at a later time by using the Save/Load button at the right of this panel. + +# Modifying the Deck list + +When the VisualDatabaseDisplay is opened as part of the visual deck editor, clicking on cards adds or removes them to and from your deck. + +In the picture view, left-clicking on a card will add that card to the main board of your deck, while right-clicking will remove that printing of the card from the main board, if it exists. When clicking cards, modifier keys can be used to add (or remove) the card to zones other than the main board. Holding a Ctrl key on Windows and Linux or a Command key on MacOS adds/removes cards to the sideboard, while the Shift keys add/remove cards to the maybeboard. + +In the table view, double-clicking on a card adds that card to the main board of your deck, while right-click opens the card menu. Holding the Ctrl key on Windows and Linux or the Command key on MacOS when double-clicking adds the card to the sideboard instead. + +Tokens are always added to the token zone, regardless of which view is open or which modifier key is pressed. diff --git a/docs/doxygen/pages/user_documentation/gameplay/revealing_cards.md b/docs/doxygen/pages/user_documentation/gameplay/revealing_cards.md new file mode 100644 index 000000000..5d47bd3b4 --- /dev/null +++ b/docs/doxygen/pages/user_documentation/gameplay/revealing_cards.md @@ -0,0 +1,11 @@ +@page revealing_cards Revealing Cards + +The **Hand** menu offers **Reveal a chosen number of random cards to...**. Choose **All players** or a specific player, then choose how many cards to reveal. The count dialog displays the chosen recipient. + +The server chooses distinct cards without replacement. Two copies of the same card are separate physical cards and can both be chosen. The operation does not reorder, move, or otherwise change the hand. + +When you choose a specific player, that player receives the selected card identities. Judges and spectators configured to see all hidden information retain their usual access. Ordinary participants other than the chosen player, including the hand's owner, receive a notice with the count and recipient, but do not learn which cards were chosen. Revealing to all players sends the selected identities to everyone, using the ordinary reveal window. + +If the hand becomes too small or the recipient leaves before the server processes the request, the server rejects the request and no cards are revealed. Cancelling the count dialog sends nothing. + +This operation requires a server that supports multiple random reveals. An older server rejects it without revealing any cards. Neither rejection is reported in the client; the reveal simply does not happen. The existing **Reveal random card to...** action and its shortcut remain available for single-card reveals. diff --git a/doc/doxygen/pages/user_documentation/index.md b/docs/doxygen/pages/user_documentation/index.md similarity index 91% rename from doc/doxygen/pages/user_documentation/index.md rename to docs/doxygen/pages/user_documentation/index.md index f4ecbe8a1..e95706727 100644 --- a/doc/doxygen/pages/user_documentation/index.md +++ b/docs/doxygen/pages/user_documentation/index.md @@ -7,6 +7,10 @@ - @subpage editing_decks - @subpage exporting_decks +## Gameplay + +- @subpage revealing_cards + ## Release Channels - @subpage beta_release diff --git a/doc/doxygen/pages/user_documentation/settings/beta.md b/docs/doxygen/pages/user_documentation/settings/beta.md similarity index 100% rename from doc/doxygen/pages/user_documentation/settings/beta.md rename to docs/doxygen/pages/user_documentation/settings/beta.md diff --git a/doc/doxygen/pages/user_documentation/settings/settings_folder.md b/docs/doxygen/pages/user_documentation/settings/settings_folder.md similarity index 100% rename from doc/doxygen/pages/user_documentation/settings/settings_folder.md rename to docs/doxygen/pages/user_documentation/settings/settings_folder.md diff --git a/doc/doxygen/pages/user_documentation/troubleshooting/enabling_debug_logs.md b/docs/doxygen/pages/user_documentation/troubleshooting/enabling_debug_logs.md similarity index 100% rename from doc/doxygen/pages/user_documentation/troubleshooting/enabling_debug_logs.md rename to docs/doxygen/pages/user_documentation/troubleshooting/enabling_debug_logs.md diff --git a/doc/doxygen/pages/user_documentation/troubleshooting/fixing_card_pictures.md b/docs/doxygen/pages/user_documentation/troubleshooting/fixing_card_pictures.md similarity index 100% rename from doc/doxygen/pages/user_documentation/troubleshooting/fixing_card_pictures.md rename to docs/doxygen/pages/user_documentation/troubleshooting/fixing_card_pictures.md diff --git a/doc/doxygen/theme b/docs/doxygen/theme similarity index 100% rename from doc/doxygen/theme rename to docs/doxygen/theme diff --git a/doc/carddatabase_v4/cards.xsd b/docs/xsd/cards.xsd similarity index 100% rename from doc/carddatabase_v4/cards.xsd rename to docs/xsd/cards.xsd diff --git a/doc/carddatabase_v3/cards.xsd b/docs/xsd/cards_v3.xsd similarity index 100% rename from doc/carddatabase_v3/cards.xsd rename to docs/xsd/cards_v3.xsd diff --git a/doc/deck.xsd b/docs/xsd/deck.xsd similarity index 100% rename from doc/deck.xsd rename to docs/xsd/deck.xsd diff --git a/external/qttesting b/external/qttesting new file mode 160000 index 000000000..c7bb5d372 --- /dev/null +++ b/external/qttesting @@ -0,0 +1 @@ +Subproject commit c7bb5d372133e94c75cbdc70b04e039b864ea379 diff --git a/gcovr.cfg b/gcovr.cfg index ac893b6a4..7b25dbc8d 100644 --- a/gcovr.cfg +++ b/gcovr.cfg @@ -32,7 +32,7 @@ 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 = ^docs/doxygen/theme/ exclude = ^libcockatrice_rng/libcockatrice/rng/sfmt/ exclude = ^libcockatrice_utility/libcockatrice/utility/peglib\.h$ exclude = ^oracle/src/lzma/ diff --git a/libcockatrice_card/libcockatrice/card/card_info.cpp b/libcockatrice_card/libcockatrice/card/card_info.cpp index 90bfd3f44..36f0cdeb8 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.cpp +++ b/libcockatrice_card/libcockatrice/card/card_info.cpp @@ -316,6 +316,10 @@ void CardInfo::setColors(const QString &value) { setProperty(Mtg::Colors, value); } +QString CardInfo::getColorIdentity() const +{ + return getProperty(Mtg::ColorIdentity); +} QString CardInfo::getLoyalty() const { return getProperty(Mtg::Loyalty); diff --git a/libcockatrice_card/libcockatrice/card/card_info.h b/libcockatrice_card/libcockatrice/card/card_info.h index 5452ef98b..075b5b264 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.h +++ b/libcockatrice_card/libcockatrice/card/card_info.h @@ -469,6 +469,7 @@ public: [[nodiscard]] QString getCmc() const; [[nodiscard]] QString getColors() const; void setColors(const QString &value); + [[nodiscard]] QString getColorIdentity() const; [[nodiscard]] QString getLoyalty() const; [[nodiscard]] QString getMainCardType() const; [[nodiscard]] QString getManaCost() const; diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp index 00b21f88e..7ca5ba433 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp @@ -83,7 +83,7 @@ LoadStatus CardDatabaseLoader::loadCardDatabase(const QString &path, CardDatabas auto startTime = QTime::currentTime(); LoadStatus tempLoadStatus = NotLoaded; if (!path.isEmpty()) { - QMutexLocker locker(loadFromFileMutex); + QMutexLocker locker(&loadFromFileMutex); tempLoadStatus = loadFromFile(path, data); } @@ -102,7 +102,7 @@ LoadStatus CardDatabaseLoader::loadCardDatabases(bool ignoreCache) LoadStatus CardDatabaseLoader::doLoadCardDatabases(bool ignoreCache) { - QMutexLocker locker(reloadDatabaseMutex); + QMutexLocker locker(&reloadDatabaseMutex); if (!database) { qCWarning(CardDatabaseLoadingLog) << "Loader has no database pointer"; diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_loader.h b/libcockatrice_card/libcockatrice/card/database/card_database_loader.h index bfbaa0f51..72aeeff3e 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_loader.h +++ b/libcockatrice_card/libcockatrice/card/database/card_database_loader.h @@ -177,8 +177,8 @@ private: ICardSetPriorityController *priorityController; /**< Controller for reconstructing set state. */ QList availableParsers; /**< List of available parsers for different formats. */ - QBasicMutex *loadFromFileMutex = new QBasicMutex(); /**< Mutex for single-file loading. */ - QBasicMutex *reloadDatabaseMutex = new QBasicMutex(); /**< Mutex for reloading entire database. */ + QBasicMutex loadFromFileMutex; /**< Mutex for single-file loading. */ + QBasicMutex reloadDatabaseMutex; /**< Mutex for reloading entire database. */ }; #endif // COCKATRICE_CARD_DATABASE_LOADER_H diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp index 8df0d2e20..fb8542808 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp @@ -1,6 +1,7 @@ #include "card_database_querier.h" #include "../card_info.h" +#include "../game_specific_terms.h" #include "../printing/exact_card.h" #include "../set/card_set_comparator.h" #include "card_database.h" @@ -31,6 +32,7 @@ void CardDatabaseQuerier::invalidateCaches() mainCardTypeCountsCache.clear(); subCardTypeCountsCache.clear(); formatsCountCache.clear(); + tagCountsCache.clear(); } /** @@ -381,6 +383,21 @@ FormatRulesPtr CardDatabaseQuerier::getFormat(const QString &formatName) const return db->formats.value(formatName.toLower()); } +QMap CardDatabaseQuerier::getAllTagsWithCount() const +{ + if (tagCountsCache.isEmpty()) { + for (const auto &card : db->cards.values()) { + const QStringList tags = card->getProperty(Mtg::Tags).split(" ", Qt::SkipEmptyParts); + + for (const QString &tag : tags) { + tagCountsCache[tag]++; + } + } + } + + return tagCountsCache; +} + QMap CardDatabaseQuerier::getAllFormatsWithCount() const { if (formatsCountCache.isEmpty()) { diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_querier.h b/libcockatrice_card/libcockatrice/card/database/card_database_querier.h index a1409b3cc..8508e4245 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_querier.h +++ b/libcockatrice_card/libcockatrice/card/database/card_database_querier.h @@ -220,6 +220,16 @@ public: * @return Map of subtype string to count. */ [[nodiscard]] QMap getAllSubCardTypesWithCount() const; + + /** + * @brief Returns a mapping of Scryfall Tagger tag slugs to their occurrence counts. + * + * Tags are stored space-separated in the `tags` card property, so a card + * tagged "ramp" and "removal" contributes one to each slug's count. + * + * @return Map of tag slug to count. + */ + [[nodiscard]] QMap getAllTagsWithCount() const; FormatRulesPtr getFormat(const QString &formatName) const; QMap getAllFormatsWithCount() const; @@ -233,6 +243,7 @@ private: mutable QMap mainCardTypeCountsCache; mutable QMap subCardTypeCountsCache; mutable QMap formatsCountCache; + mutable QMap tagCountsCache; private slots: void invalidateCaches(); diff --git a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp index d40c1c063..99440628a 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -32,7 +33,7 @@ class ICardSetPriorityController; #define COCKATRICE_XML3_TAGNAME "cockatrice_carddatabase" #define COCKATRICE_XML3_TAGVER 3 #define COCKATRICE_XML3_SCHEMALOCATION \ - "https://raw.githubusercontent.com/Cockatrice/Cockatrice/master/doc/carddatabase_v3/cards.xsd" + "https://raw.githubusercontent.com/Cockatrice/Cockatrice/master/docs/xsd/cards_v3.xsd" CockatriceXml3Parser::CockatriceXml3Parser(ICardSetPriorityController *_cardSetPriorityController) : ICardDatabaseParser(_cardSetPriorityController) @@ -499,7 +500,7 @@ bool CockatriceXml3Parser::saveToFile(FormatRulesNameMap _formats, { Q_UNUSED(_formats); - QFile file(fileName); + QSaveFile file(fileName); if (!file.open(QIODevice::WriteOnly)) { return false; } @@ -539,5 +540,5 @@ bool CockatriceXml3Parser::saveToFile(FormatRulesNameMap _formats, xml.writeEndElement(); // cockatrice_carddatabase xml.writeEndDocument(); - return true; + return !xml.hasError() && file.commit(); } diff --git a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp index 3ee1e808e..84fc27155 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp @@ -18,6 +18,7 @@ #include #include #include +#include #include #include #include @@ -35,7 +36,7 @@ class ICardSetPriorityController; #define COCKATRICE_XML4_TAGNAME "cockatrice_carddatabase" #define COCKATRICE_XML4_TAGVER 4 #define COCKATRICE_XML4_SCHEMALOCATION \ - "https://raw.githubusercontent.com/Cockatrice/Cockatrice/master/doc/carddatabase_v4/cards.xsd" + "https://raw.githubusercontent.com/Cockatrice/Cockatrice/master/docs/xsd/cards.xsd" CockatriceXml4Parser::CockatriceXml4Parser(ICardSetPriorityController *_cardSetPriorityController) : ICardDatabaseParser(_cardSetPriorityController) @@ -682,7 +683,7 @@ bool CockatriceXml4Parser::saveToFile(FormatRulesNameMap _formats, const QString &sourceUrl, const QString &sourceVersion) { - QFile file(fileName); + QSaveFile file(fileName); if (!file.open(QIODevice::WriteOnly)) { return false; } @@ -730,5 +731,5 @@ bool CockatriceXml4Parser::saveToFile(FormatRulesNameMap _formats, xml.writeEndElement(); // cockatrice_carddatabase xml.writeEndDocument(); - return true; + return !xml.hasError() && file.commit(); } diff --git a/libcockatrice_card/libcockatrice/card/game_specific_terms.h b/libcockatrice_card/libcockatrice/card/game_specific_terms.h index e9160e514..5be8ec6d5 100644 --- a/libcockatrice_card/libcockatrice/card/game_specific_terms.h +++ b/libcockatrice_card/libcockatrice/card/game_specific_terms.h @@ -28,6 +28,7 @@ QString const PowTough("pt"); QString const Side("side"); QString const Layout("layout"); QString const ColorIdentity("coloridentity"); +QString const Tags("tags"); inline static const QString getNicePropertyName(QString key) { @@ -61,6 +62,9 @@ inline static const QString getNicePropertyName(QString key) if (key == ColorIdentity) { return QCoreApplication::translate("Mtg", "Color Identity"); } + if (key == Tags) { + return QCoreApplication::translate("Mtg", "Tags"); + } return key; } } // namespace Mtg diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp index 821b53dff..b357b5801 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp @@ -340,7 +340,17 @@ bool DeckList::saveToStream_Plain(QTextStream &stream, bool prefixSideboardCards } }; - forEachCard(writeToStream); + // Export the main deck first without changing the saved tree's zone order. + forEachCard([&](const auto node, const auto card) { + if (node->getName() == DECK_ZONE_MAIN) { + writeToStream(node, card); + } + }); + forEachCard([&](const auto node, const auto card) { + if (node->getName() != DECK_ZONE_MAIN) { + writeToStream(node, card); + } + }); return true; } @@ -423,8 +433,23 @@ DecklistCardNode *DeckList::addCard(const QString &cardName, const QString &cardProviderId, bool formatLegal) { - auto node = - tree.addCard(cardName, 1, zoneName, position, cardSetName, cardSetCollectorNumber, cardProviderId, formatLegal); + return addCards(cardName, 1, zoneName, position, cardSetName, cardSetCollectorNumber, cardProviderId, formatLegal); +} + +DecklistCardNode *DeckList::addCards(const QString &cardName, + int amount, + const QString &zoneName, + int position, + const QString &cardSetName, + const QString &cardSetCollectorNumber, + const QString &cardProviderId, + bool formatLegal) +{ + if (amount <= 0) { + return nullptr; + } + auto node = tree.addCard(cardName, amount, zoneName, position, cardSetName, cardSetCollectorNumber, cardProviderId, + formatLegal); refreshDeckHash(); return node; } diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h index 6796e9c33..9d0f20586 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h @@ -284,6 +284,14 @@ public: const QString &cardSetCollectorNumber = QString(), const QString &cardProviderId = QString(), const bool formatLegal = true); + DecklistCardNode *addCards(const QString &cardName, + int amount, + const QString &zoneName, + int position = -1, + const QString &cardSetName = QString(), + const QString &cardSetCollectorNumber = QString(), + const QString &cardProviderId = QString(), + const bool formatLegal = true); ///@} /** @name Deck identity */ diff --git a/libcockatrice_filters/CMakeLists.txt b/libcockatrice_filters/CMakeLists.txt index 070c04637..1b4303fc9 100644 --- a/libcockatrice_filters/CMakeLists.txt +++ b/libcockatrice_filters/CMakeLists.txt @@ -1,10 +1,10 @@ -set(HEADERS libcockatrice/filters/filter_card.h libcockatrice/filters/filter_string.h - libcockatrice/filters/filter_tree.h +set(HEADERS libcockatrice/filters/filter_card.h libcockatrice/filters/filter_mode.h + libcockatrice/filters/filter_string.h libcockatrice/filters/filter_tree.h ) add_library( - libcockatrice_filters STATIC ${HEADERS} libcockatrice/filters/filter_card.cpp libcockatrice/filters/filter_string.cpp - libcockatrice/filters/filter_tree.cpp + libcockatrice_filters STATIC ${HEADERS} libcockatrice/filters/filter_card.cpp libcockatrice/filters/filter_mode.cpp + libcockatrice/filters/filter_string.cpp libcockatrice/filters/filter_tree.cpp ) add_dependencies(libcockatrice_filters libcockatrice_card) diff --git a/libcockatrice_filters/libcockatrice/filters/filter_card.cpp b/libcockatrice_filters/libcockatrice/filters/filter_card.cpp index 26cddd53c..041ab9c0c 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_card.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_card.cpp @@ -69,6 +69,8 @@ const QString CardFilter::attrName(Attr a) return tr("Type"); case AttrColor: return tr("Color"); + case AttrColorIdentity: + return tr("Color Identity"); case AttrText: return tr("Text"); case AttrSet: @@ -87,6 +89,8 @@ const QString CardFilter::attrName(Attr a) return tr("Loyalty"); case AttrFormat: return tr("Format"); + case AttrTag: + return tr("Tag"); case AttrMainType: return tr("Main Type"); case AttrSubType: diff --git a/libcockatrice_filters/libcockatrice/filters/filter_card.h b/libcockatrice_filters/libcockatrice/filters/filter_card.h index dbeffd18f..dee9e14bc 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_card.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_card.h @@ -32,6 +32,7 @@ public: { AttrCmc = 0, AttrColor, + AttrColorIdentity, AttrLoyalty, AttrManaCost, AttrName, @@ -45,6 +46,7 @@ public: AttrMainType, AttrSubType, AttrFormat, + AttrTag, AttrEnd, }; diff --git a/libcockatrice_filters/libcockatrice/filters/filter_mode.cpp b/libcockatrice_filters/libcockatrice/filters/filter_mode.cpp new file mode 100644 index 000000000..356ccf608 --- /dev/null +++ b/libcockatrice_filters/libcockatrice/filters/filter_mode.cpp @@ -0,0 +1,77 @@ +/** + * @file filter_mode.cpp + * @ingroup Filters + */ + +#include "libcockatrice/filters/filter_mode.h" + +#include + +FilterModeInfo getFilterModeInfo(FilterMode filterMode) +{ + switch (filterMode) { + case FilterMode::Includes: + return {CardFilter::Type::TypeOr, CardFilter::Type::TypeEnd, CardFilter::Attr::AttrColor}; + case FilterMode::Excludes: + return {CardFilter::Type::TypeAndNot, CardFilter::Type::TypeEnd, CardFilter::Attr::AttrColor}; + case FilterMode::IncludeExclude: + return {CardFilter::Type::TypeOr, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColor}; + case FilterMode::ExactMatch: + return {CardFilter::Type::TypeAnd, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColor}; + case FilterMode::ColorIdentity: + return {CardFilter::Type::TypeEnd, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColorIdentity}; + case FilterMode::Custom: + case FilterMode::End: + break; + } + return {CardFilter::Type::TypeEnd, CardFilter::Type::TypeEnd, CardFilter::Attr::AttrEnd}; +} + +FilterMode deriveFilterMode(const QList &filters, FilterMode currentMode) +{ + constexpr int modeCount = static_cast(FilterMode::End); + bool modeImpossible[modeCount] = {}; + const QString colors = "WUBRG"; + + for (int i = 0; i < modeCount; ++i) { + const FilterModeInfo modeInfo = getFilterModeInfo(static_cast(i)); + + // Remove the modes that cannot create one of the filters. + for (const CardFilter *filter : filters) { + if (filter->attr() != modeInfo.attr || + (filter->type() != modeInfo.includeType && filter->type() != modeInfo.excludeType)) { + modeImpossible[i] = true; + break; + } + } + + // Modes that have both an include and an exclude filter should always have a filter for each color. + if (modeInfo.includeType != CardFilter::Type::TypeEnd && modeInfo.excludeType != CardFilter::Type::TypeEnd && + !modeImpossible[i]) { + for (const QChar &color : colors) { + int count = 0; + for (const CardFilter *filter : filters) { + count += filter->term() == color; + } + if (count != 1) { + modeImpossible[i] = true; + break; + } + } + } + } + + // Keep the current mode if it can produce the filters, so that a live mode is never swapped for another. + // Switching to and from the custom mode is always possible. + if (!modeImpossible[static_cast(currentMode)] && currentMode != FilterMode::Custom) { + return currentMode; + } + + for (int i = 0; i < modeCount; ++i) { + if (!modeImpossible[i] || i == static_cast(FilterMode::Custom)) { + return static_cast(i); + } + } + + return FilterMode::Custom; +} diff --git a/libcockatrice_filters/libcockatrice/filters/filter_mode.h b/libcockatrice_filters/libcockatrice/filters/filter_mode.h new file mode 100644 index 000000000..f7662d662 --- /dev/null +++ b/libcockatrice_filters/libcockatrice/filters/filter_mode.h @@ -0,0 +1,62 @@ +/** + * @file filter_mode.h + * @ingroup Filters + */ + +#ifndef LIBCOCKATRICE_FILTERS_FILTER_MODE_H +#define LIBCOCKATRICE_FILTERS_FILTER_MODE_H + +#include "libcockatrice/filters/filter_card.h" + +#include +#include +#include + +/** + * @brief The way selected and unselected colors are combined into card filters. + */ +enum class FilterMode +{ + Includes = 0, // Include selected colors (OR condition). + Excludes, // Exclude selected colors (AND NOT). + IncludeExclude, // Include selected colors (OR) and exclude unselected colors (AND NOT). + ExactMatch, // Only selected colors are included, all others are excluded. + ColorIdentity, // Keep cards whose color identity is a subset of the selected colors (stored as AND NOT for each + // unselected color). + Custom, // The color filters do not match any of the above modes. Not included in the combo box. + End +}; + +Q_DECLARE_METATYPE(FilterMode) + +/** + * @brief The filters a single color contributes in a given mode. + */ +struct FilterModeInfo +{ + // When a color symbol is enabled, add filters of this type. Skipped if equal to CardFilter::Type::TypeEnd. + CardFilter::Type includeType; + + // When a color symbol is disabled, add filters of this type. Skipped if equal to CardFilter::Type::TypeEnd. + CardFilter::Type excludeType; + + // The attribute to filter for when adding the filters. + CardFilter::Attr attr; +}; + +/** @brief Return the filters the given mode contributes for one color. */ +FilterModeInfo getFilterModeInfo(FilterMode filterMode); + +/** + * @brief Determine the mode that produced the given color filters. + * + * Returns @p currentMode unchanged when that mode can still produce @p filters, so that a live mode is never + * replaced by an equally valid one. Otherwise returns the first mode that can produce them, falling back to + * FilterMode::Custom when none can. + * + * @param filters The filters on CardFilter::Attr::AttrColor and CardFilter::Attr::AttrColorIdentity. + * @param currentMode The mode currently selected in the UI. + */ +FilterMode deriveFilterMode(const QList &filters, FilterMode currentMode); + +#endif // LIBCOCKATRICE_FILTERS_FILTER_MODE_H diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp index 081ada824..e91817967 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -41,7 +42,7 @@ ComplexQueryPart <- SomewhatComplexQueryPart ws "OR" ws ComplexQueryPart / Somew SomewhatComplexQueryPart <- [(] QueryPartList [)] / QueryPart -QueryPart <- NotQuery / SetQuery / RarityQuery / CMCQuery / FormatQuery / PowerQuery / ToughnessQuery / ColorQuery / TypeQuery / OracleQuery / FieldQuery / GenericQuery +QueryPart <- NotQuery / SetQuery / RarityQuery / CMCQuery / FormatQuery / PowerQuery / ToughnessQuery / ColorQuery / TagQuery / TypeQuery / OracleQuery / FieldQuery / GenericQuery NotQuery <- ('NOT' ws/'-') SomewhatComplexQueryPart SetQuery <- ('e'/'set') ws? SetQueryValue @@ -63,6 +64,8 @@ Legality <- [Ll] 'egal'? / [Bb] 'anned'? / [Rr] 'estricted' TypeQuery <- [tT] 'ype'? ws? [:] ws? StringValue +TagQuery <- [tT]('ags'/'ag') [:] String + Color <- < [Ww] 'hite'? / [Uu] / [Bb] 'lack'? / [Rr] 'ed'? / [Gg] 'reen'? / [Bb] 'lue'? > ColorEx <- Color / [mc] @@ -158,6 +161,19 @@ static void setupParserRules() const auto matcher = std::any_cast(sv[0]); return [=](const CardData &x) -> bool { return matcher(x->getCardType()); }; }; + search["TagQuery"] = [](const peg::SemanticValues &sv) -> Filter { + // Tags are stored space-separated, so matching whole tokens keeps + // `tags:ram` from matching `ramp`. Combine tags with AND: + // `tags:draw tags:ramp`. + const auto tag = std::any_cast(sv[0]).trimmed(); + return [=](const CardData &x) -> bool { + const QString stored = x->getProperty(Mtg::Tags); + if (stored.isEmpty() || tag.isEmpty()) { + return false; + } + return stored.split(" ", Qt::SkipEmptyParts).contains(tag, Qt::CaseInsensitive); + }; + }; search["SetQueryValue"] = [](const peg::SemanticValues &sv) -> Filter { if (sv.choice() == 0) { auto matcher = std::any_cast(sv[0]); @@ -409,7 +425,7 @@ static void setupParserRules() const bool identity = sv.tokens[0].empty() || sv.tokens[0][0] != 'i'; if (sv.tokens[1][0] == ':') { return [=](const CardData &x) { - const QString match = identity ? x->getColors() : x->getProperty("coloridentity"); + const QString match = identity ? x->getColors() : x->getColorIdentity(); if (parts.contains("m") && match.length() < 2) { return false; } @@ -427,7 +443,7 @@ static void setupParserRules() } return [=](const CardData &x) { - const QString match = identity ? x->getColors() : x->getProperty("colorIdentity"); + const QString match = identity ? x->getColors() : x->getColorIdentity(); if (parts.contains("m") && match.length() < 2) { return false; } @@ -506,6 +522,14 @@ FilterString::FilterString() _error = "Not initialized"; } +QString FilterString::error() const +{ + if (_errorColumn == 0) { + return _error; + } + return tr("Error at position %1: %2").arg(_errorColumn).arg(_error); +} + FilterString::FilterString(const QString &expr, const CardSearchLanguage &searchLanguage) { QByteArray ba = expr.simplified().toUtf8(); @@ -522,7 +546,8 @@ FilterString::FilterString(const QString &expr, const CardSearchLanguage &search searchLanguageContext = searchLanguage; search.set_logger([&](size_t /*ln*/, size_t col, const std::string &msg) { - _error = QString("Error at position %1: %2").arg(col).arg(QString::fromStdString(msg)); + _error = QString::fromStdString(msg); + _errorColumn = col; }); if (!search.parse(ba.data(), result)) { diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.h b/libcockatrice_filters/libcockatrice/filters/filter_string.h index 2abe95201..34e2bfb35 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.h @@ -7,6 +7,7 @@ #ifndef FILTER_STRING_H #define FILTER_STRING_H +#include #include #include #include @@ -33,6 +34,8 @@ typedef AstBase Ast; class FilterString { + Q_DECLARE_TR_FUNCTIONS(FilterString) + public: FilterString(); explicit FilterString(const QString &exp, const CardSearchLanguage &searchLanguage = {}); @@ -45,18 +48,18 @@ public: return result(card); } - bool valid() + [[nodiscard]] bool valid() const { return _error.isEmpty(); } - QString error() - { - return _error; - } + [[nodiscard]] QString error() const; private: + // Untranslated parser message; error() adds the translated prefix, logging uses it as-is. QString _error; + // Column reported by the parser, or 0 when the message carries no position. + size_t _errorColumn = 0; Filter result; }; diff --git a/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp b/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp index f12f87181..13c78e1f7 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp @@ -2,6 +2,7 @@ #include "filter_card.h" #include "libcockatrice/card/card_localization.h" +#include "libcockatrice/card/game_specific_terms.h" #include "libcockatrice/card/printing/printing_info.h" #include "libcockatrice/card/set/card_set.h" @@ -176,23 +177,24 @@ FilterItem::FilterItem(QString trm, FilterItemList *parent) : p(parent), term(st lowercaseTerm = term.toLower(); switch (attr()) { - case CardFilter::AttrColor: { - QString converted = term.trimmed(); + case CardFilter::AttrColor: + case CardFilter::AttrColorIdentity: { + QString converted = term.trimmed().toLower(); - converted.replace("green", "g", Qt::CaseInsensitive); - converted.replace("grn", "g", Qt::CaseInsensitive); - converted.replace("blue", "u", Qt::CaseInsensitive); - converted.replace("blu", "u", Qt::CaseInsensitive); - converted.replace("black", "b", Qt::CaseInsensitive); - converted.replace("blk", "b", Qt::CaseInsensitive); - converted.replace("red", "r", Qt::CaseInsensitive); - converted.replace("white", "w", Qt::CaseInsensitive); - converted.replace("wht", "w", Qt::CaseInsensitive); - converted.replace("colorless", "c", Qt::CaseInsensitive); - converted.replace("colourless", "c", Qt::CaseInsensitive); - converted.replace("none", "c", Qt::CaseInsensitive); + converted.replace("green", "g"); + converted.replace("grn", "g"); + converted.replace("blue", "u"); + converted.replace("blu", "u"); + converted.replace("black", "b"); + converted.replace("blk", "b"); + converted.replace("red", "r"); + converted.replace("white", "w"); + converted.replace("wht", "w"); + converted.replace("colorless", "c"); + converted.replace("colourless", "c"); + converted.replace("none", "c"); - converted.replace(QString(" "), QString(""), Qt::CaseInsensitive); + converted.replace(QString(" "), QString("")); convertedColorTerm = converted; break; @@ -358,12 +360,17 @@ bool FilterItem::acceptSubType(const CardInfoPtr &info) const return subType.contains(term, Qt::CaseInsensitive); } -bool FilterItem::acceptColor(const CardInfoPtr &info) const +/** + * @brief Return true when every letter of the given term appears in the given colors string. + * If the term is equal to "c", then only return true if the color string is empty. + * + * @param term The letters which should be included, or "c" + * @param colors The string to check for letters in + */ +static bool matchesColor(const QString &term, const QString &colors) { - const QString colors = info->getColors(); - // Colorless card filter - if (convertedColorTerm.toLower() == "c" && colors.length() < 1) { + if (term == "c" && colors.length() < 1) { return true; } @@ -372,13 +379,23 @@ bool FilterItem::acceptColor(const CardInfoPtr &info) const * then we should match all of them to the card's colors */ int match_count = 0; - for (const QChar &color : convertedColorTerm) { + for (const QChar &color : term) { if (colors.contains(color, Qt::CaseInsensitive)) { match_count++; } } - return match_count == convertedColorTerm.length(); + return match_count == term.length(); +} + +bool FilterItem::acceptColor(const CardInfoPtr &info) const +{ + return matchesColor(convertedColorTerm, info->getColors()); +} + +bool FilterItem::acceptColorIdentity(const CardInfoPtr &info) const +{ + return matchesColor(convertedColorTerm, info->getColorIdentity()); } bool FilterItem::acceptText(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const @@ -461,6 +478,16 @@ bool FilterItem::acceptFormat(const CardInfoPtr &info) const return info->getLegalityProp(lowercaseTerm) == "legal"; } +bool FilterItem::acceptTag(const CardInfoPtr &info) const +{ + const QString stored = info->getProperty(Mtg::Tags); + if (stored.isEmpty()) { + return false; + } + // Tags are stored space-separated; match whole slugs, not substrings. + return stored.split(" ", Qt::SkipEmptyParts).contains(term.trimmed(), Qt::CaseInsensitive); +} + bool FilterItem::acceptLoyalty(const CardInfoPtr &info) const { const QString loyaltyString = info->getLoyalty(); @@ -540,6 +567,8 @@ bool FilterItem::acceptCardAttr(const CardInfoPtr &info, return acceptType(info); case CardFilter::AttrColor: return acceptColor(info); + case CardFilter::AttrColorIdentity: + return acceptColorIdentity(info); case CardFilter::AttrText: return acceptText(info, searchLanguage); case CardFilter::AttrSet: @@ -558,6 +587,8 @@ bool FilterItem::acceptCardAttr(const CardInfoPtr &info, return acceptLoyalty(info); case CardFilter::AttrFormat: return acceptFormat(info); + case CardFilter::AttrTag: + return acceptTag(info); case CardFilter::AttrMainType: return acceptMainType(info); case CardFilter::AttrSubType: diff --git a/libcockatrice_filters/libcockatrice/filters/filter_tree.h b/libcockatrice_filters/libcockatrice/filters/filter_tree.h index 85a57304d..1b899fed7 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_tree.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_tree.h @@ -244,6 +244,7 @@ public: [[nodiscard]] bool acceptMainType(const CardInfoPtr &info) const; [[nodiscard]] bool acceptSubType(const CardInfoPtr &info) const; [[nodiscard]] bool acceptColor(const CardInfoPtr &info) const; + [[nodiscard]] bool acceptColorIdentity(const CardInfoPtr &info) const; [[nodiscard]] bool acceptText(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const; [[nodiscard]] bool acceptSet(const CardInfoPtr &info) const; [[nodiscard]] bool acceptManaCost(const CardInfoPtr &info) const; @@ -254,6 +255,7 @@ public: [[nodiscard]] bool acceptCardAttr(const CardInfoPtr &info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; [[nodiscard]] bool acceptFormat(const CardInfoPtr &info) const; + [[nodiscard]] bool acceptTag(const CardInfoPtr &info) const; [[nodiscard]] bool relationCheck(int cardInfo) const; }; diff --git a/libcockatrice_game/libcockatrice/game/game_event_handler.cpp b/libcockatrice_game/libcockatrice/game/game_event_handler.cpp index 856475dfa..7ef502400 100644 --- a/libcockatrice_game/libcockatrice/game/game_event_handler.cpp +++ b/libcockatrice_game/libcockatrice/game/game_event_handler.cpp @@ -21,6 +21,7 @@ #include #include #include +#include #include #include #include @@ -38,6 +39,7 @@ #include #include #include +#include #include #include #include @@ -50,6 +52,7 @@ #include #include #include +#include #include #include #include @@ -189,6 +192,9 @@ void GameEventHandler::processGameEventContainer(const GameEventContainer &cont, case GameEvent::REVERSE_TURN: eventReverseTurn(event.GetExtension(Event_ReverseTurn::ext), playerId, context); break; + case GameEvent::PLAYER_ORDER_CHANGED: + eventPlayerOrderChanged(event.GetExtension(Event_PlayerOrderChanged::ext), playerId, context); + break; default: { PlayerLogic *player = game->getPlayerManager()->getPlayers().value(playerId, 0); @@ -215,6 +221,16 @@ void GameEventHandler::handleReverseTurn() sendGameCommand(Command_ReverseTurn()); } +void GameEventHandler::handleSetPlayerOrder(const QList &playerNames, bool randomize) +{ + Command_SetPlayerOrder command; + command.set_randomize(randomize); + for (const QString &name : playerNames) { + command.add_player_names(name.toStdString()); + } + sendGameCommand(command); +} + void GameEventHandler::handleActiveLocalPlayerConceded() { sendGameCommand(Command_Concede()); @@ -377,6 +393,27 @@ void GameEventHandler::eventGameStateChanged(const Event_GameStateChanged &event { const int playerListSize = event.player_list_size(); + if (event.has_local_player_id()) { + game->getPlayerManager()->setLocalPlayerId(event.local_player_id()); + } + + QSet authoritativePlayerIds; + for (int i = 0; i < playerListSize; ++i) { + const ServerInfo_PlayerProperties &prop = event.player_list(i).properties(); + if (!prop.spectator()) { + authoritativePlayerIds.insert(prop.player_id()); + } + } + + // Players whose seats were shuffled away from the previous game are no longer + // part of the authoritative list and must be removed. + for (int stalePlayerId : game->getPlayerManager()->getPlayers().keys()) { + if (!authoritativePlayerIds.contains(stalePlayerId)) { + game->getPlayerManager()->removePlayer(stalePlayerId); + emit playerLeft(stalePlayerId); + } + } + QVector>> opponentDecksToDisplay; for (int i = 0; i < playerListSize; ++i) { @@ -421,6 +458,19 @@ void GameEventHandler::eventGameStateChanged(const Event_GameStateChanged &event emit remotePlayersDecksSelected(opponentDecksToDisplay); + // The server emits player_list in seat order, so record it to lay out the + // table by seating rather than by player id. + QList seatOrder; + for (int i = 0; i < playerListSize; ++i) { + const ServerInfo_PlayerProperties &prop = event.player_list(i).properties(); + if (!prop.spectator()) { + seatOrder.append(prop.player_id()); + } + } + if (!seatOrder.isEmpty()) { + game->getGameState()->setSeatOrder(seatOrder); + } + game->getGameState()->setGameTime(event.seconds_elapsed()); if (event.game_started() && !game->getGameMetaInfo()->started()) { @@ -625,6 +675,33 @@ void GameEventHandler::eventReverseTurn(const Event_ReverseTurn &event, emit logTurnReversed(player, event.reversed()); } +void GameEventHandler::eventPlayerOrderChanged(const Event_PlayerOrderChanged &event, + int /*eventPlayerId*/, + const GameEventContext & /*context*/) +{ + QStringList playerNames; + for (const auto &name : event.player_names()) { + playerNames.append(QString::fromStdString(name)); + } + + // Track the seating by stable player id so the table layout and the turn + // order dialog follow the shuffled order instead of id order. + QList seatOrder; + const auto &players = game->getPlayerManager()->getPlayers(); + for (const QString &name : playerNames) { + for (auto it = players.constBegin(); it != players.constEnd(); ++it) { + if (it.value() && it.value()->getPlayerInfo()->getName() == name) { + seatOrder.append(it.key()); + break; + } + } + } + if (!seatOrder.isEmpty()) { + game->getGameState()->setSeatOrder(seatOrder); + } + emit logTurnOrderChanged(playerNames, event.randomized()); +} + void GameEventHandler::eventGameHostChanged(const Event_GameHostChanged & /*event*/, int eventPlayerId, const GameEventContext & /*context*/) diff --git a/libcockatrice_game/libcockatrice/game/game_event_handler.h b/libcockatrice_game/libcockatrice/game/game_event_handler.h index f7b11780b..bfcf355b0 100644 --- a/libcockatrice_game/libcockatrice/game/game_event_handler.h +++ b/libcockatrice_game/libcockatrice/game/game_event_handler.h @@ -51,6 +51,7 @@ class Event_GameSay; class Event_Kicked; class Event_ReverseTurn; class Event_Ping; +class Event_PlayerOrderChanged; class ServerInfo_PlayerProperties; namespace google { @@ -113,6 +114,16 @@ public: /** @brief Request reversing the current turn order. */ void handleReverseTurn(); + /** + * @brief Request a new seating / turn order. + * + * Only the game host (or a moderator) may do this before the game starts. + * + * @param playerNames Ordered list of player names. Ignored when randomize is true. + * @param randomize Ask the server to shuffle the seats instead. + */ + void handleSetPlayerOrder(const QList &playerNames, bool randomize); + /** @brief Concede the game for the currently active local player. */ void handleActiveLocalPlayerConceded(); @@ -316,6 +327,10 @@ public: /** @brief Handle a turn reversal event. */ void eventReverseTurn(const Event_ReverseTurn &event, int eventPlayerId, const GameEventContext &context); + /** @brief Handle a change of the seating / turn order. */ + void + eventPlayerOrderChanged(const Event_PlayerOrderChanged &event, int eventPlayerId, const GameEventContext &context); + /** @brief Handle ping / latency updates. */ void eventPing(const Event_Ping &event, int eventPlayerId, const GameEventContext &context); @@ -443,6 +458,7 @@ signals: void logLeave(PlayerLogic *player, QString reason); void logKicked(); void logTurnReversed(PlayerLogic *player, bool reversed); + void logTurnOrderChanged(QStringList playerNames, bool randomized); void logGameClosed(); void logActivePlayer(PlayerLogic *activePlayer); void logActivePhaseChanged(int activePhase); diff --git a/libcockatrice_game/libcockatrice/game/game_state.h b/libcockatrice_game/libcockatrice/game/game_state.h index 55784fb91..4d02019ce 100644 --- a/libcockatrice_game/libcockatrice/game/game_state.h +++ b/libcockatrice_game/libcockatrice/game/game_state.h @@ -33,7 +33,11 @@ public: void setHostId(int _hostId) { + if (hostId == _hostId) { + return; + } hostId = _hostId; + emit hostChanged(hostId); } QList getClients() const @@ -112,6 +116,19 @@ public: return hostId; } + void setSeatOrder(const QList &order) + { + if (seatOrder != order) { + seatOrder = order; + emit seatOrderChanged(seatOrder); + } + } + + QList getSeatOrder() const + { + return seatOrder; + } + int getSecondsElapsed() const { return secondsElapsed; @@ -123,6 +140,8 @@ signals: void gameStopped(); void activePhaseChanged(int activePhase); void activePlayerChanged(int playerId); + void hostChanged(int hostId); + void seatOrderChanged(const QList &seatOrder); public slots: void incrementGameTime(); @@ -142,6 +161,9 @@ private: int currentPhase; int activePlayer; bool gameClosed; + //! Player ids in seating (turn) order, first to play first. Empty until the + //! server sends an order, in which case players are laid out in id order. + QList seatOrder; }; #endif // COCKATRICE_GAME_STATE_H diff --git a/libcockatrice_game/libcockatrice/game/player/player_actions.cpp b/libcockatrice_game/libcockatrice/game/player/player_actions.cpp index 90823fec6..1e854cbd4 100644 --- a/libcockatrice_game/libcockatrice/game/player/player_actions.cpp +++ b/libcockatrice_game/libcockatrice/game/player/player_actions.cpp @@ -1756,6 +1756,17 @@ void PlayerActions::actRevealRandomHandCard(int revealToPlayerId) sendGameCommand(cmd); } +void PlayerActions::actRevealRandomHandCards(int revealToPlayerId, int amount) +{ + Command_RevealRandomCards cmd; + if (revealToPlayerId != -1) { + cmd.set_player_id(revealToPlayerId); + } + cmd.set_zone_name(ZoneNames::HAND); + cmd.set_number_of_cards(amount); + sendGameCommand(cmd); +} + void PlayerActions::actRevealLibrary(int revealToPlayerId) { Command_RevealCards cmd; diff --git a/libcockatrice_game/libcockatrice/game/player/player_actions.h b/libcockatrice_game/libcockatrice/game/player/player_actions.h index 7ed52b9a1..81d18315c 100644 --- a/libcockatrice_game/libcockatrice/game/player/player_actions.h +++ b/libcockatrice_game/libcockatrice/game/player/player_actions.h @@ -279,6 +279,7 @@ public slots: void actReveal(QList selectedCards, QAction *action); void actRevealHand(int revealToPlayerId); void actRevealRandomHandCard(int revealToPlayerId); + void actRevealRandomHandCards(int revealToPlayerId, int amount); void actRevealLibrary(int revealToPlayerId); void actSortHand(); diff --git a/libcockatrice_game/libcockatrice/game/player/player_manager.h b/libcockatrice_game/libcockatrice/game/player/player_manager.h index c95bbbb96..950574112 100644 --- a/libcockatrice_game/libcockatrice/game/player/player_manager.h +++ b/libcockatrice_game/libcockatrice/game/player/player_manager.h @@ -57,6 +57,11 @@ public: return localPlayerId; } + void setLocalPlayerId(int _localPlayerId) + { + localPlayerId = _localPlayerId; + } + [[nodiscard]] const QMap &getPlayers() const { return players; diff --git a/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp index db1fa32c3..f5fcd8786 100644 --- a/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp +++ b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.cpp @@ -7,6 +7,7 @@ #include #include #include +#include class PlayerLogic; class QObject; @@ -36,6 +37,11 @@ bool ZoneViewZoneLogic::getCloseEmptyCardView() const return interfaceSettings ? interfaceSettings->getCloseEmptyCardView() : true; } +bool ZoneViewZoneLogic::isLibraryEdgeView() const +{ + return origZone != nullptr && numberCards > 0 && origZone->getName() == ZoneNames::DECK; +} + /** * Checks if inserting a card at the given position requires an actual new card to be created and added to the view. * Also does any cardId updates that would be required if a card is inserted in that position. diff --git a/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h index a29e543a3..ee8156058 100644 --- a/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h +++ b/libcockatrice_game/libcockatrice/game/zones/view_zone_logic.h @@ -82,6 +82,27 @@ public: return isReversed; } + /** + * @brief Whether this view is a limited look at one end of a library. + * + * Only scry/surveil-style views of the top or bottom cards of a deck + * qualify. Full-zone views, revealed-card views and views of other zones + * do not, because their first and last cards have no library edge meaning. + */ + [[nodiscard]] bool isLibraryEdgeView() const; + + /** @brief Whether the first card in the view is the top card of the library. */ + [[nodiscard]] bool showsTopOfLibrary() const + { + return isLibraryEdgeView() && !isReversed; + } + + /** @brief Whether the last card in the view is the bottom card of the library. */ + [[nodiscard]] bool showsBottomOfLibrary() const + { + return isLibraryEdgeView() && isReversed; + } + CardZoneLogic *getOriginalZone() const { return origZone; diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h index a5ab4fc37..8eb81d2bc 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h @@ -19,6 +19,11 @@ public: [[nodiscard]] virtual bool getArrowDrawAnimation() const = 0; [[nodiscard]] virtual bool getAutoRotateSidewaysLayoutCards() const = 0; [[nodiscard]] virtual bool getScaleCards() const = 0; + [[nodiscard]] virtual int getHoverPanelTrigger() const = 0; + [[nodiscard]] virtual int getHoverZoomTrigger() const = 0; + [[nodiscard]] virtual bool getHoverPanelDoubleConfirm() const = 0; + [[nodiscard]] virtual int getHoverZoomDelayMs() const = 0; + [[nodiscard]] virtual int getHoverPanelDelayMs() const = 0; [[nodiscard]] virtual int getStackCardOverlapPercent() const = 0; [[nodiscard]] virtual int getCardInfoViewMode() const = 0; [[nodiscard]] virtual int getVisualDeckStorageCardSize() const = 0; diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_game_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_game_settings_provider.h index 2b1ddf8ec..00763d031 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_game_settings_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_game_settings_provider.h @@ -20,6 +20,7 @@ public: [[nodiscard]] virtual bool getCreateGameAsSpectator() const = 0; [[nodiscard]] virtual int getDefaultStartingLifeTotal() const = 0; [[nodiscard]] virtual bool getShareDecklistsOnLoad() const = 0; + [[nodiscard]] virtual bool getShufflePlayers() const = 0; [[nodiscard]] virtual bool getRememberGameSettings() const = 0; [[nodiscard]] virtual bool getLocalGameRememberSettings() const = 0; [[nodiscard]] virtual int getLocalGameMaxPlayers() const = 0; diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp index 617324fe3..f3c4545d7 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp @@ -746,7 +746,8 @@ QModelIndex DeckListModel::nodeToIndex(AbstractDecklistNode *node) const void DeckListModel::sortShadowTree(InnerDecklistNode *node, Qt::SortOrder order) { // The mapping is not needed: fresh shadow nodes have no persistent indices yet. - (void)DeckListModelCustomZones::sortWithCustomZonesLast(root, node, order); + (void)DeckListModelCustomZones::sortWithCustomZonesLast( + root, node, order, activeGroupCriteria == DeckListModelGroupCriteria::MANA_COST && lastKnownColumn != 0); for (int i = node->size() - 1; i >= 0; --i) { if (auto *subNode = dynamic_cast(node->at(i))) { @@ -759,7 +760,8 @@ void DeckListModel::sortHelper(InnerDecklistNode *node, Qt::SortOrder order) { // Sort children (custom zones always sorted after groups within a board) and // use the movement mapping to update the list of persistent indices. - const auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(root, node, order); + const auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast( + root, node, order, activeGroupCriteria == DeckListModelGroupCriteria::MANA_COST && lastKnownColumn != 0); QModelIndexList from, to; int columns = columnCount(); diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp index 419c38b04..523708cbd 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp @@ -8,6 +8,8 @@ #include #include #include +#include +#include #include namespace DeckListModelCustomZones @@ -111,7 +113,7 @@ QList> plainSort(InnerDecklistNode *node, Qt::SortOrder order) * * @return The (preSortRow, finalRow) mapping covering both the sort and the shift. */ -QList> boardSort(InnerDecklistNode *node, Qt::SortOrder order) +QList> boardSort(InnerDecklistNode *node, Qt::SortOrder order, bool numericGroups) { const QVector> sortResult = node->sort(order); @@ -132,6 +134,27 @@ QList> boardSort(InnerDecklistNode *node, Qt::SortOrder order) } } + if (numericGroups) { + // Only criteria groups are numeric. Mixing numeric and lexical fallback + // comparisons with custom zone names would not define a strict ordering. + std::sort(groups.begin(), groups.end(), [order](const auto *left, const auto *right) { + const QString leftName = left->getName(); + const QString rightName = right->getName(); + bool leftIsNumber, rightIsNumber; + const double leftValue = leftName.toDouble(&leftIsNumber); + const double rightValue = rightName.toDouble(&rightIsNumber); + leftIsNumber = leftIsNumber && std::isfinite(leftValue); + rightIsNumber = rightIsNumber && std::isfinite(rightValue); + if (leftIsNumber != rightIsNumber) { + return leftIsNumber; + } + if (leftIsNumber && leftValue != rightValue) { + return order == Qt::AscendingOrder ? leftValue < rightValue : leftValue > rightValue; + } + return order == Qt::AscendingOrder ? leftName < rightName : leftName > rightName; + }); + } + QVector ordered = groups + customZones; for (int i = 0; i < ordered.size(); ++i) { node->replace(i, ordered[i]); @@ -147,10 +170,11 @@ QList> boardSort(InnerDecklistNode *node, Qt::SortOrder order) } // namespace -QList> sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order) +QList> +sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order, bool numericGroups) { const bool isBoardZone = (node != root) && (node->getParent() == root); - return isBoardZone ? boardSort(node, order) : plainSort(node, order); + return isBoardZone ? boardSort(node, order, numericGroups) : plainSort(node, order); } } // namespace DeckListModelCustomZones diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h index 53bdeaa1e..d3f477450 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h @@ -91,10 +91,15 @@ void mirrorCustomZones(const InnerDecklistNode *deckBoardZone, InnerDecklistNode * @param root Root of the shadow tree (used to classify board zones). * @param node The shadow node whose children are reordered. * @param order Sort order to apply. + * @param numericGroups Compare criteria-group names as mana values. Custom zone + * names and card names retain their normal comparison; nonnumeric groups follow + * numeric groups in either sort direction. * @return A list of (preSortRow, finalRow) pairs describing how each node moved. */ -[[nodiscard]] QList> -sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order); +[[nodiscard]] QList> sortWithCustomZonesLast(InnerDecklistNode *root, + InnerDecklistNode *node, + Qt::SortOrder order, + bool numericGroups = false); } // namespace DeckListModelCustomZones diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp index faacdc9f7..636c603c6 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp @@ -50,11 +50,12 @@ namespace // The server warns that this account still uses the legacy password hash while this client // holds a scrypt verifier. Sending the verifier would downgrade the proof, so the client // stops; the login only succeeds after the saved verifier is gone and the user types the -// password, which also upgrades the account. +// password, which also upgrades the account. Unticking "Save password" drops the verifier +// for the next connection. constexpr const char *const LOGIN_ABORT_AUTH_DOWNGRADE = QT_TRANSLATE_NOOP( "RemoteClient", "The server still uses an old password format for this account, so the saved login on this computer cannot be " - "used. Untick 'Save password' in the Connect window and connect; when it opens again, type your password."); + "used. Untick 'Save password' in the Connect window and log in with your password."); // The account is still on the legacy hash and only the plaintext password can produce it. constexpr const char *const LOGIN_ABORT_PASSWORD_REQUIRED = QT_TRANSLATE_NOOP("RemoteClient", @@ -137,6 +138,7 @@ RemoteClient::~RemoteClient() void RemoteClient::slotSocketError(QAbstractSocket::SocketError /*error*/) { QString errorString = socket->errorString(); + qCWarning(RemoteClientLog) << "Server connection error:" << errorString; doDisconnectFromServer(); emit socketError(errorString); } @@ -146,6 +148,7 @@ void RemoteClient::slotWebSocketError(QAbstractSocket::SocketError /*error*/) QString errorString = websocket->errorString(); if (getStatus() != ClientStatus::StatusDisconnected) { + qCWarning(RemoteClientLog) << "Server connection error:" << errorString; doDisconnectFromServer(); emit socketError(errorString); } @@ -153,6 +156,7 @@ void RemoteClient::slotWebSocketError(QAbstractSocket::SocketError /*error*/) void RemoteClient::slotConnected() { + qCInfo(RemoteClientLog) << "Server transport connected"; timeRunning = lastDataReceived = 0; timer->start(); @@ -166,6 +170,12 @@ void RemoteClient::slotConnected() void RemoteClient::processServerIdentificationEvent(const Event_ServerIdentification &event) { + // Identification arrives before login, including when protocol versions differ. + // Quote the server-supplied strings in the log and limit their size. + const QString serverName = QString::fromStdString(event.server_name()).left(256); + const QString serverVersion = QString::fromStdString(event.server_version()).left(256); + qCInfo(RemoteClientLog) << "Server identification:" << serverName << "version" << serverVersion + << "protocol version" << event.protocol_version(); if (event.protocol_version() != protocolVersion) { emit protocolVersionMismatch(protocolVersion, event.protocol_version()); setStatus(StatusDisconnecting); @@ -698,6 +708,8 @@ void RemoteClient::sendCommandContainer(const CommandContainer &cont) void RemoteClient::connectToHost(const QString &hostname, unsigned int port) { usingWebSocket = port == 443 || port == 80 || port == 4748 || port == 8080; + qCInfo(RemoteClientLog) << "Connecting to server" << hostname << "port" << port + << (usingWebSocket ? "using WebSocket" : "using TCP"); if (usingWebSocket) { QUrl url(QString("%1://%2:%3/servatrice").arg(port == 443 ? "wss" : "ws").arg(hostname).arg(port)); websocket->open(url); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/game_config.h b/libcockatrice_network/libcockatrice/network/server/remote/game/game_config.h index baf946632..0661bd2aa 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/game_config.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/game_config.h @@ -21,6 +21,7 @@ struct GameConfig bool spectatorsSeeEverything = true; int startingLifeTotal = 20; bool shareDecklistsOnLoad = true; + bool shufflePlayers = false; }; #endif diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp index ff8ea14f3..f7739bc85 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp @@ -43,6 +43,7 @@ #include #include #include +#include #include #include #include @@ -404,6 +405,13 @@ Response::ResponseCode Server_AbstractParticipant::cmdRevealCards(const Command_ return Response::RespFunctionNotAllowed; } +Response::ResponseCode Server_AbstractParticipant::cmdRevealRandomCards(const Command_RevealRandomCards & /*cmd*/, + ResponseContainer & /*rc*/, + GameEventStorage & /*ges*/) +{ + return Response::RespFunctionNotAllowed; +} + Response::ResponseCode Server_AbstractParticipant::cmdChangeZoneProperties(const Command_ChangeZoneProperties & /*cmd*/, ResponseContainer & /*rc*/, GameEventStorage & /*ges*/) @@ -434,6 +442,33 @@ Response::ResponseCode Server_AbstractParticipant::cmdReverseTurn(const Command_ return Response::RespOk; } +Response::ResponseCode Server_AbstractParticipant::cmdSetPlayerOrder(const Command_SetPlayerOrder &cmd, + ResponseContainer & /*rc*/, + GameEventStorage & /*ges*/) +{ + if ((game->getHostId() != playerId) && !(userInfo->user_level() & ServerInfo_User::IsModerator)) { + return Response::RespFunctionNotAllowed; + } + + if (game->getGameStarted()) { + return Response::RespContextError; + } + + if (cmd.randomize()) { + game->shufflePlayerSeats(); + } else { + QList playerNames; + for (int i = 0; i < cmd.player_names_size(); ++i) { + playerNames.append(QString::fromStdString(cmd.player_names(i))); + } + game->reorderPlayerSeats(playerNames); + } + + // Clients learn the new seating from the Event_PlayerOrderChanged that + // reseatPlayers broadcasts; no full game state resend is needed for it. + return Response::RespOk; +} + Response::ResponseCode Server_AbstractParticipant::processGameCommand(const GameCommand &command, ResponseContainer &rc, GameEventStorage &ges) { @@ -516,6 +551,9 @@ Server_AbstractParticipant::processGameCommand(const GameCommand &command, Respo case GameCommand::REVEAL_CARDS: return cmdRevealCards(command.GetExtension(Command_RevealCards::ext), rc, ges); break; + case GameCommand::REVEAL_RANDOM_CARDS: + return cmdRevealRandomCards(command.GetExtension(Command_RevealRandomCards::ext), rc, ges); + break; case GameCommand::MOVE_CARD: return cmdMoveCard(command.GetExtension(Command_MoveCard::ext), rc, ges); break; @@ -543,6 +581,9 @@ Server_AbstractParticipant::processGameCommand(const GameCommand &command, Respo case GameCommand::SET_PLAYMAT: return cmdSetPlaymat(command.GetExtension(Command_SetPlaymat::ext), rc, ges); break; + case GameCommand::SET_PLAYER_ORDER: + return cmdSetPlayerOrder(command.GetExtension(Command_SetPlayerOrder::ext), rc, ges); + break; default: return Response::RespInvalidCommand; } diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h index b6d4a8025..6b2a93e48 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h @@ -46,6 +46,7 @@ class Command_NextTurn; class Command_SetActivePhase; class Command_DumpZone; class Command_RevealCards; +class Command_RevealRandomCards; class Command_ReverseTurn; class Command_MoveCard; class Command_SetSideboardPlan; @@ -53,6 +54,7 @@ class Command_DeckSelect; class Command_SetSideboardLock; class Command_ChangeZoneProperties; class Command_SetPlaymat; +class Command_SetPlayerOrder; class Server_AbstractParticipant : public Server_ArrowTarget, public ServerInfo_User_Container { @@ -89,6 +91,10 @@ public: { return playerId; } + void setPlayerId(int _playerId) + { + playerId = _playerId; + } bool isSpectator() const { return roleInfo.isSpectator; @@ -171,8 +177,12 @@ public: virtual Response::ResponseCode cmdRevealCards(const Command_RevealCards &cmd, ResponseContainer &rc, GameEventStorage &ges); virtual Response::ResponseCode + cmdRevealRandomCards(const Command_RevealRandomCards &cmd, ResponseContainer &rc, GameEventStorage &ges); + virtual Response::ResponseCode cmdReverseTurn(const Command_ReverseTurn & /*cmd*/, ResponseContainer & /*rc*/, GameEventStorage &ges); virtual Response::ResponseCode + cmdSetPlayerOrder(const Command_SetPlayerOrder &cmd, ResponseContainer &rc, GameEventStorage &ges); + virtual Response::ResponseCode cmdChangeZoneProperties(const Command_ChangeZoneProperties &cmd, ResponseContainer &rc, GameEventStorage &ges); Response::ResponseCode processGameCommand(const GameCommand &command, ResponseContainer &rc, GameEventStorage &ges); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp index b5a620798..b9617b547 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp @@ -1494,8 +1494,10 @@ Server_AbstractPlayer::cmdDumpZone(const Command_DumpZone &cmd, ResponseContaine return Response::RespOk; } -Response::ResponseCode -Server_AbstractPlayer::cmdRevealCards(const Command_RevealCards &cmd, ResponseContainer & /*rc*/, GameEventStorage &ges) +Response::ResponseCode Server_AbstractPlayer::resolveRevealZone(const std::string &zoneName, + bool hasRecipient, + int recipientId, + Server_CardZone *&zone) { if (!game->getGameStarted()) { return Response::RespGameNotStarted; @@ -1504,17 +1506,61 @@ Server_AbstractPlayer::cmdRevealCards(const Command_RevealCards &cmd, ResponseCo return Response::RespContextError; } - if (cmd.has_player_id()) { - Server_AbstractPlayer *otherPlayer = game->getPlayer(cmd.player_id()); - if (!otherPlayer) { - return Response::RespNameNotFound; - } + if (hasRecipient && !game->getPlayer(recipientId)) { + return Response::RespNameNotFound; } - Server_CardZone *zone = zones.value(nameFromStdString(cmd.zone_name())); + + zone = zones.value(nameFromStdString(zoneName)); if (!zone) { return Response::RespNameNotFound; } + return Response::RespOk; +} + +Response::ResponseCode Server_AbstractPlayer::cmdRevealRandomCards(const Command_RevealRandomCards &cmd, + ResponseContainer & /*rc*/, + GameEventStorage &ges) +{ + if (cmd.number_of_cards() <= 0) { + return Response::RespInvalidData; + } + + Server_CardZone *zone = nullptr; + const Response::ResponseCode result = + resolveRevealZone(cmd.zone_name(), cmd.has_player_id(), cmd.player_id(), zone); + if (result != Response::RespOk) { + return result; + } + + auto candidates = zone->getCards(); + if (cmd.number_of_cards() > candidates.size()) { + return Response::RespContextError; + } + + // Partial Fisher-Yates on a copy: each physical card can appear only once, + // and the hand's order and contents stay unchanged. + QList cardsToReveal; + for (int i = 0; i < cmd.number_of_cards(); ++i) { + const int selected = rng->rand(i, candidates.size() - 1); + candidates.swapItemsAt(i, selected); + cardsToReveal.append(candidates.at(i)); + } + + // -2 in the public event tells other participants only how many cards were revealed. + return revealCards(zone, cardsToReveal, {-2}, cmd.has_player_id(), cmd.player_id(), false, ges); +} + +Response::ResponseCode +Server_AbstractPlayer::cmdRevealCards(const Command_RevealCards &cmd, ResponseContainer & /*rc*/, GameEventStorage &ges) +{ + Server_CardZone *zone = nullptr; + const Response::ResponseCode result = + resolveRevealZone(cmd.zone_name(), cmd.has_player_id(), cmd.player_id(), zone); + if (result != Response::RespOk) { + return result; + } + QList cardsToReveal; if (cmd.top_cards() != -1) { for (int i = 0; i < cmd.top_cards(); i++) { @@ -1547,15 +1593,28 @@ Server_AbstractPlayer::cmdRevealCards(const Command_RevealCards &cmd, ResponseCo } } + const QList cardIdsForOthers(cmd.card_id().begin(), cmd.card_id().end()); + return revealCards(zone, cardsToReveal, cardIdsForOthers, cmd.has_player_id(), cmd.player_id(), + cmd.grant_write_access(), ges); +} + +Response::ResponseCode Server_AbstractPlayer::revealCards(Server_CardZone *zone, + const QList &cardsToReveal, + const QList &cardIdsForOthers, + bool hasRecipient, + int recipientId, + bool grantWriteAccess, + GameEventStorage &ges) +{ Event_RevealCards eventOthers; - eventOthers.set_grant_write_access(cmd.grant_write_access()); + eventOthers.set_grant_write_access(grantWriteAccess); eventOthers.set_zone_name(zone->getName().toStdString()); eventOthers.set_number_of_cards(cardsToReveal.size()); - for (auto cardId : cmd.card_id()) { + for (int cardId : cardIdsForOthers) { eventOthers.add_card_id(cardId); } - if (cmd.has_player_id()) { - eventOthers.set_other_player_id(cmd.player_id()); + if (hasRecipient) { + eventOthers.set_other_player_id(recipientId); } Event_RevealCards eventPrivate(eventOthers); @@ -1592,19 +1651,19 @@ Server_AbstractPlayer::cmdRevealCards(const Command_RevealCards &cmd, ResponseCo } } - if (cmd.has_player_id()) { - if (cmd.grant_write_access()) { - zone->addWritePermission(cmd.player_id()); + if (hasRecipient) { + if (grantWriteAccess) { + zone->addWritePermission(recipientId); } if (isJudge()) { ges.setOverwriteOwnership(true); } - ges.enqueueGameEvent(eventPrivate, playerId, GameEventStorageItem::SendToPrivate, cmd.player_id()); + ges.enqueueGameEvent(eventPrivate, playerId, GameEventStorageItem::SendToPrivate, recipientId); ges.enqueueGameEvent(eventOthers, playerId, GameEventStorageItem::SendToOthers); } else { - if (cmd.grant_write_access()) { + if (grantWriteAccess) { const QList &participantIds = game->getParticipants().keys(); for (int anyParticipantId : participantIds) { zone->addWritePermission(anyParticipantId); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.h index ae20955bf..c92c4c3dd 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.h @@ -32,6 +32,18 @@ private: void sendCreateTokenEvents(Server_CardZone *zone, Server_Card *card, int xCoord, int yCoord, GameEventStorage &ges); void getPlayerProperties(ServerInfo_PlayerProperties &result) override; + /// Validates the preconditions shared by every reveal and resolves the source zone. + Response::ResponseCode + resolveRevealZone(const std::string &zoneName, bool hasRecipient, int recipientId, Server_CardZone *&zone); + /// Builds the private and public reveal events for an already chosen set of cards and enqueues them. + Response::ResponseCode revealCards(Server_CardZone *zone, + const QList &cardsToReveal, + const QList &cardIdsForOthers, + bool hasRecipient, + int recipientId, + bool grantWriteAccess, + GameEventStorage &ges); + protected: bool conceded; DeckList *deck; @@ -163,6 +175,8 @@ public: cmdDumpZone(const Command_DumpZone &cmd, ResponseContainer &rc, GameEventStorage &ges) override; virtual Response::ResponseCode cmdRevealCards(const Command_RevealCards &cmd, ResponseContainer &rc, GameEventStorage &ges) override; + Response::ResponseCode + cmdRevealRandomCards(const Command_RevealRandomCards &cmd, ResponseContainer &rc, GameEventStorage &ges) override; virtual Response::ResponseCode cmdChangeZoneProperties(const Command_ChangeZoneProperties &cmd, ResponseContainer &rc, GameEventStorage &ges) override; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp index 49db999f6..e2724523e 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp @@ -73,11 +73,13 @@ #include #include #include +#include #include #include #include #include #include +#include #include #include #include @@ -91,8 +93,8 @@ Server_Game::Server_Game(const GameConfig &config, Server_Room *_room) spectatorsAllowed(config.spectatorsAllowed), spectatorsNeedPassword(config.spectatorsNeedPassword), spectatorsCanTalk(config.spectatorsCanTalk), spectatorsSeeEverything(config.spectatorsSeeEverything), startingLifeTotal(config.startingLifeTotal), shareDecklistsOnLoad(config.shareDecklistsOnLoad), - inactivityCounter(0), startTimeOfThisGame(0), secondsElapsed(0), firstGameStarted(false), - turnOrderReversed(false), startTime(QDateTime::currentDateTime()), pingClock(nullptr), + shufflePlayers(config.shufflePlayers), inactivityCounter(0), startTimeOfThisGame(0), secondsElapsed(0), + firstGameStarted(false), turnOrderReversed(false), startTime(QDateTime::currentDateTime()), pingClock(nullptr), deckValidationStrategy(new Server_DefaultDeckValidationStrategy), lifecycleStrategy(new Server_DefaultLifecycleStrategy), matchResultStrategy(new Server_NullMatchResultStrategy), gameMutex() @@ -292,6 +294,32 @@ int Server_Game::getSpectatorCount() const return result; } +QList Server_Game::getParticipantsInSeatOrder() const +{ + // Players in seat order first (fall back to id order for seats predating + // seatOrder), then spectators. Player ids are stable; only the ordering + // changes on shuffle/reorder. + QList ordered; + ordered.reserve(participants.size()); + for (int id : seatOrder) { + auto *participant = participants.value(id, nullptr); + if (participant && !participant->isSpectator() && !ordered.contains(participant)) { + ordered.append(participant); + } + } + for (auto *participant : participants.values()) { + if (!participant->isSpectator() && !ordered.contains(participant)) { + ordered.append(participant); + } + } + for (auto *participant : participants.values()) { + if (participant->isSpectator()) { + ordered.append(participant); + } + } + return ordered; +} + void Server_Game::createGameStateChangedEvent(Event_GameStateChanged *event, Server_AbstractParticipant *recipient, bool omniscient, @@ -306,16 +334,24 @@ void Server_Game::createGameStateChangedEvent(Event_GameStateChanged *event, event->set_game_started(false); } - for (Server_AbstractParticipant *participant : participants.values()) { + // Emit the player list in seat order so every client (including old ones + // that ignore Event_PlayerOrderChanged) sees the current seating. Player + // ids are stable; only this ordering changes on shuffle/reorder. + QList ordered = getParticipantsInSeatOrder(); + for (Server_AbstractParticipant *participant : ordered) { participant->getInfo(event->add_player_list(), recipient, omniscient, withUserInfo); } + + if (recipient != nullptr) { + event->set_local_player_id(recipient->getPlayerId()); + } } void Server_Game::sendGameStateToPlayers() { // game state information for replay and omniscient spectators Event_GameStateChanged omniscientEvent; - createGameStateChangedEvent(&omniscientEvent, nullptr, true, false); + createGameStateChangedEvent(&omniscientEvent, nullptr, true, true); GameEventContainer *replayCont = prepareGameEvent(omniscientEvent, -1); replayCont->set_seconds_elapsed(secondsElapsed - startTimeOfThisGame); @@ -327,7 +363,7 @@ void Server_Game::sendGameStateToPlayers() // the data we used for the replay. All spectators are equal, so we don't need to make a createGameStateChangedEvent // call for each one. Event_GameStateChanged spectatorNormalEvent; - createGameStateChangedEvent(&spectatorNormalEvent, nullptr, false, false); + createGameStateChangedEvent(&spectatorNormalEvent, nullptr, false, true); // send game state info to clients according to their role in the game for (auto *participant : participants.values()) { @@ -340,7 +376,7 @@ void Server_Game::sendGameStateToPlayers() } } else { Event_GameStateChanged event; - createGameStateChangedEvent(&event, participant, participant->isJudge(), false); + createGameStateChangedEvent(&event, participant, participant->isJudge(), true); gec = prepareGameEvent(event, -1); } @@ -380,6 +416,11 @@ void Server_Game::doStartGameIfReady(bool forceStartGame) return; } + if (shufflePlayers && players.size() > 1) { + shufflePlayerSeats(); + players = getPlayers(); + } + for (Server_AbstractPlayer *player : players.values()) { player->setupZones(); } @@ -433,6 +474,116 @@ void Server_Game::startGameIfReady(bool forceStartGame) emit sigStartGameIfReady(forceStartGame); } +void Server_Game::shufflePlayerSeats() +{ + QMutexLocker locker(&gameMutex); + + // Collect players in current seat order (fall back to id order for seats + // predating seatOrder). Spectators keep their seats. + QList players; + for (int id : seatOrder) { + auto *participant = participants.value(id, nullptr); + if (participant && !participant->isSpectator()) { + players.append(participant); + } + } + for (auto *participant : participants.values()) { + if (!participant->isSpectator() && !players.contains(participant)) { + players.append(participant); + } + } + QList spectators; + for (auto *participant : participants.values()) { + if (participant->isSpectator()) { + spectators.append(participant); + } + } + + // Fisher-Yates shuffle of the players. + QList shuffledPlayers; + while (!players.isEmpty()) { + const int index = rng->rand(0, players.size() - 1); + shuffledPlayers.append(players.takeAt(index)); + } + + reseatPlayers(shuffledPlayers + spectators, true); +} + +void Server_Game::reorderPlayerSeats(const QList &orderedNames) +{ + QMutexLocker locker(&gameMutex); + + // Collect players in current seat order (fall back to id order for seats + // predating seatOrder). Spectators keep their seats. + QList players; + for (int id : seatOrder) { + auto *participant = participants.value(id, nullptr); + if (participant && !participant->isSpectator()) { + players.append(participant); + } + } + for (auto *participant : participants.values()) { + if (!participant->isSpectator() && !players.contains(participant)) { + players.append(participant); + } + } + QList spectators; + for (auto *participant : participants.values()) { + if (participant->isSpectator()) { + spectators.append(participant); + } + } + + // Order the players according to the given names, appending any player that + // was not listed (should not happen) in their current seat order. + QList orderedPlayers; + QSet placed; + for (const QString &name : orderedNames) { + for (auto *participant : players) { + if (!placed.contains(participant) && (QString::fromStdString(participant->getUserInfo()->name()) == name)) { + orderedPlayers.append(participant); + placed.insert(participant); + break; + } + } + } + for (auto *participant : players) { + if (!placed.contains(participant)) { + orderedPlayers.append(participant); + } + } + + reseatPlayers(orderedPlayers + spectators, false); +} + +void Server_Game::reseatPlayers(const QList &orderedParticipants, bool randomized) +{ + QMutexLocker locker(&gameMutex); + + // Keep player ids stable and only reorder the seating/turn order. Changing + // ids would break old clients (stale localPlayerId from Event_GameJoined + // duplicates their own seat as an opponent), invalidate the home-server + // per-user game/seat mapping used for ISL command routing, and orphan the + // host id. All of those maps stay valid because ids never change here. + // The current host therefore keeps its id and needs no re-key tracking. + seatOrder.clear(); + for (auto *participant : orderedParticipants) { + if (!participant->isSpectator()) { + seatOrder.append(participant->getPlayerId()); + } + } + + // Broadcast the new seating order so that every client can display it. + Event_PlayerOrderChanged event; + for (auto *participant : orderedParticipants) { + if (!participant->isSpectator()) { + event.add_player_names(QString::fromStdString(participant->getUserInfo()->name()).toStdString()); + } + } + event.set_randomized(randomized); + sendGameEventContainer(prepareGameEvent(event, -1)); +} + void Server_Game::stopGameIfFinished() { QMutexLocker locker(&gameMutex); @@ -563,6 +714,7 @@ void Server_Game::addPlayer(Server_AbstractUserInterface *userInterface, allSpectatorsEver.insert(playerName); } else { allPlayersEver.insert(playerName); + seatOrder.append(newParticipant->getPlayerId()); // if the original creator of the game joins, give them host status back //! \todo Transferring host to spectators has side effects. @@ -592,6 +744,7 @@ void Server_Game::removeParticipant(Server_AbstractParticipant *participant, Eve room->getServer()->removePersistentPlayer(QString::fromStdString(participant->getUserInfo()->name()), room->getId(), gameId, participant->getPlayerId()); participants.remove(participant->getPlayerId()); + seatOrder.removeAll(participant->getPlayerId()); bool spectator = participant->isSpectator(); GameEventStorage ges; @@ -611,10 +764,20 @@ void Server_Game::removeParticipant(Server_AbstractParticipant *participant, Eve participant->prepareDestroy(); if (playerHost) { + // Pass host to the next player in seat order so the game always keeps + // a host while players remain. int newHostId = -1; - for (auto *otherPlayer : getPlayers().values()) { - newHostId = otherPlayer->getPlayerId(); - break; + for (int id : seatOrder) { + if (participants.contains(id)) { + newHostId = id; + break; + } + } + if (newHostId == -1) { + for (auto *otherPlayer : getPlayers().values()) { + newHostId = otherPlayer->getPlayerId(); + break; + } } if (newHostId != -1) { hostId = newHostId; @@ -771,7 +934,20 @@ void Server_Game::nextTurn() } auto players = getPlayers(); - const QList keys = players.keys(); + // Advance in seat order, not id order, so shuffles/reorders change the + // turn sequence while ids stay stable. + QList keys; + keys.reserve(players.size()); + for (int id : seatOrder) { + if (players.contains(id)) { + keys.append(id); + } + } + for (int id : players.keys()) { + if (!keys.contains(id)) { + keys.append(id); + } + } int listPos = -1; if (activePlayer != -1) { listPos = keys.indexOf(activePlayer); @@ -821,7 +997,7 @@ void Server_Game::createGameJoinedEvent(Server_AbstractParticipant *joiningParti event2.set_active_phase(activePhase); bool omniscient = (joiningParticipant->isSpectator() && spectatorsSeeEverything) || joiningParticipant->isJudge(); - for (auto *participant : participants.values()) { + for (auto *participant : getParticipantsInSeatOrder()) { participant->getInfo(event2.add_player_list(), joiningParticipant, omniscient, true); } @@ -950,6 +1126,7 @@ void Server_Game::getInfo(ServerInfo_Game &result) const result.set_spectators_can_chat(spectatorsCanTalk); result.set_spectators_omniscient(spectatorsSeeEverything); result.set_share_decklists_on_load(shareDecklistsOnLoad); + result.set_shuffle_players(shufflePlayers); result.set_spectators_count(getSpectatorCount()); result.set_start_time(startTime.toSecsSinceEpoch()); } diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h index 363c0ca94..23ba96598 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h @@ -74,6 +74,11 @@ private: int hostId; ServerInfo_User *creatorInfo; QMap participants; + // Seating/turn order as an ordered list of player ids. Player ids themselves + // are stable for the lifetime of the participant; shuffling or reordering + // only permutes this list so old clients, ISL routes and persistent + // references (all keyed by player id) keep working. + QList seatOrder; QSet allPlayersEver, allSpectatorsEver; bool gameStarted; bool gameClosed; @@ -90,6 +95,7 @@ private: bool spectatorsSeeEverything; int startingLifeTotal; bool shareDecklistsOnLoad; + bool shufflePlayers; int inactivityCounter; int startTimeOfThisGame, secondsElapsed; bool firstGameStarted; @@ -109,6 +115,8 @@ private: Server_AbstractParticipant *recipient, bool omniscient, bool withUserInfo); + QList getParticipantsInSeatOrder() const; + void reseatPlayers(const QList &orderedParticipants, bool randomized); void storeGameInformation(); bool canSeePrivateEventsFor(const Server_AbstractParticipant *participant, int privatePlayerId) const; signals: @@ -149,6 +157,10 @@ public: { return participants; } + QList getSeatOrder() const + { + return seatOrder; + } int getGameId() const { return gameId; @@ -189,6 +201,10 @@ public: { return shareDecklistsOnLoad; } + bool getShufflePlayers() const + { + return shufflePlayers; + } Response::ResponseCode checkJoin(ServerInfo_User *user, const QString &_password, const RoleInfo &roleInfo, bool overrideRestrictions); bool containsUser(const QString &userName) const; @@ -202,6 +218,8 @@ public: bool kickParticipant(int playerId); void startGameIfReady(bool forceStartGame); void stopGameIfFinished(); + void shufflePlayerSeats(); + void reorderPlayerSeats(const QList &orderedNames); int getActivePlayer() const { return activePlayer; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index f9cadc9aa..78b9d955f 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -1116,6 +1116,7 @@ Server_ProtocolHandler::cmdCreateGame(const Command_CreateGame &cmd, Server_Room int startingLifeTotal = cmd.has_starting_life_total() ? cmd.starting_life_total() : 20; bool shareDecklistsOnLoad = cmd.has_share_decklists_on_load() ? cmd.share_decklists_on_load() : false; + bool shufflePlayers = cmd.has_shuffle_players() ? cmd.shuffle_players() : false; const int gameId = databaseInterface->getNextGameId(); if (gameId == -1) { @@ -1137,7 +1138,8 @@ Server_ProtocolHandler::cmdCreateGame(const Command_CreateGame &cmd, Server_Room .spectatorsCanTalk = cmd.spectators_can_talk(), .spectatorsSeeEverything = cmd.spectators_see_everything(), .startingLifeTotal = startingLifeTotal, - .shareDecklistsOnLoad = shareDecklistsOnLoad}; + .shareDecklistsOnLoad = shareDecklistsOnLoad, + .shufflePlayers = shufflePlayers}; auto *game = new Server_Game(config, room); diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index 059e83096..5fba2631d 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -66,6 +66,7 @@ set(PROTO_FILES command_set_card_attr.proto command_set_card_counter.proto command_set_counter.proto + command_set_player_order.proto command_set_playmat.proto command_set_sideboard_lock.proto command_set_sideboard_plan.proto @@ -110,6 +111,7 @@ set(PROTO_FILES event_list_rooms.proto event_move_card.proto event_notify_user.proto + event_player_order_changed.proto event_player_properties_changed.proto event_remove_from_list.proto event_remove_messages.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_reveal_cards.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_reveal_cards.proto index 82216911c..6bca5a69e 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/command_reveal_cards.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_reveal_cards.proto @@ -10,3 +10,13 @@ message Command_RevealCards { optional bool grant_write_access = 4; optional sint32 top_cards = 5 [default = -1]; } + +// Separate extension so older servers reject the operation without revealing any cards. +message Command_RevealRandomCards { + extend GameCommand { + optional Command_RevealRandomCards ext = 1036; + } + optional string zone_name = 1; + optional sint32 number_of_cards = 2; + optional sint32 player_id = 3 [default = -1]; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_set_player_order.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_set_player_order.proto new file mode 100644 index 000000000..97e27038f --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_set_player_order.proto @@ -0,0 +1,15 @@ +syntax = "proto2"; +import "game_commands.proto"; +message Command_SetPlayerOrder { + extend GameCommand { + optional Command_SetPlayerOrder ext = 1206; + } + + // The desired seating order, expressed as player names. Only used when + // randomize is false. + repeated string player_names = 1; + + // When set, the server shuffles the seating order instead of using + // player_names. + optional bool randomize = 2; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/event_game_state_changed.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/event_game_state_changed.proto index 5c8aee3fe..bbf2d0f98 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/event_game_state_changed.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/event_game_state_changed.proto @@ -24,4 +24,9 @@ message Event_GameStateChanged { // the amount of seconds since the game started optional uint32 seconds_elapsed = 5; + + // the id of the player this event is addressed to. Used to re-identify the + // local player after seats have been shuffled. Only set for events sent to + // a specific participant, never for omniscient/replay events. + optional sint32 local_player_id = 6; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/event_player_order_changed.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/event_player_order_changed.proto new file mode 100644 index 000000000..d119398b7 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/event_player_order_changed.proto @@ -0,0 +1,13 @@ +syntax = "proto2"; +import "game_event.proto"; +message Event_PlayerOrderChanged { + extend GameEvent { + optional Event_PlayerOrderChanged ext = 2023; + } + + // The new seating order, expressed as player names. + repeated string player_names = 1; + + // Whether the order was obtained by randomizing rather than set explicitly. + optional bool randomized = 2; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto index 2e5b88978..10ceb33ba 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto @@ -180,6 +180,16 @@ message GameCommand { /// Server: Server_Player::cmdSetPlaymat /// Client: reflected via player properties changed event SET_PLAYMAT = 1035; + + /// Set the seating (turn) order of the players. + /// Server: Server_AbstractParticipant::cmdSetPlayerOrder + /// Client: reflected via Event_PlayerOrderChanged + SET_PLAYER_ORDER = 1206; + + /// Reveal a distinct random sample without exposing identities to other recipients. + /// Server: Server_AbstractPlayer::cmdRevealCards + /// Client: PlayerEventHandler::eventRevealCards + REVEAL_RANDOM_CARDS = 1036; } extensions 100 to max; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/game_event.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/game_event.proto index 7d3147701..573d26453 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/game_event.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/game_event.proto @@ -34,6 +34,7 @@ message GameEvent { CHANGE_ZONE_PROPERTIES = 2020; REVERSE_TURN = 2021; GAME_LOG_NOTICE = 2022; + PLAYER_ORDER_CHANGED = 2023; } optional sint32 player_id = 1 [default = -1]; extensions 100 to max; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto index 04cb31d98..d03f93667 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto @@ -169,6 +169,11 @@ message Command_GetUserAlts { optional Command_GetUserAlts ext = 1014; } optional string user_name = 1; + optional uint32 depth = 2 [default = 1]; // BFS depth: 1 = direct matches only, up to 3 for transitive alts-of-alts + optional bool include_email = 3 [default = true]; // match and expand over registration email + optional bool include_clientid = 4 [default = true]; // match and expand over current and historic client ids + optional bool include_ip = 5 [default = true]; // match and expand over recent session IPs + optional uint32 session_months = 6 [default = 6]; // session lookback window in months, 1-24 } message Command_GetModeratorLastLogins { diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto index 9d9c6d277..3be7e4e64 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/room_commands.proto @@ -78,6 +78,9 @@ message Command_CreateGame { // share decklists with all players when selected optional bool share_decklists_on_load = 14; + + // shuffle the player order (seats) before each game starts + optional bool shuffle_players = 15; } message Command_JoinGame { diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_game.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_game.proto index 9989ae18a..39ec79914 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_game.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_game.proto @@ -48,6 +48,9 @@ message ServerInfo_Game { // decklists are sent to all players when loaded optional bool share_decklists_on_load = 15; + // players are shuffled into new seats before each game starts + optional bool shuffle_players = 16; + // the current player count optional uint32 player_count = 30; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto index 191dd5063..c9ec9aa9a 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto @@ -13,4 +13,9 @@ message ServerInfo_UserAlt { optional uint32 warn_count = 6; // number of warnings on record optional uint32 ban_count = 7; // number of bans on record optional bool is_active = 8; // account is not deactivated/banned + optional string match_reason = 9; // why flagged, e.g. "email", "clientid:abc", "ip:1.2.3.4"; empty = seed/legacy + optional uint32 depth = 10; // BFS distance from investigated account; 1 = direct, 2+ = alt-of-alt + optional uint32 shared_count = + 11; // distinct users sharing the matched identifier; high values suggest VPN/shared network + optional uint32 ip_count = 12; // distinct recent session IPs of this account in the search window } diff --git a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp index f047973f0..bdd494646 100644 --- a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp @@ -68,6 +68,33 @@ bool CardsDisplaySettings::getScaleCards() const return getValue("scaleCards", QString(), QString(), true).toBool(); } +int CardsDisplaySettings::getHoverPanelTrigger() const +{ + return getValue("hoverPanelTrigger", QString(), QString(), static_cast(HoverPanelTriggerHover)).toInt(); +} + +int CardsDisplaySettings::getHoverZoomTrigger() const +{ + // Hover is the default: hovering first zooms the card, then the tab panel shows up. + // Either trigger can still be changed or turned off in the interface settings. + return getValue("hoverZoomTrigger", QString(), QString(), static_cast(HoverPanelTriggerHover)).toInt(); +} + +bool CardsDisplaySettings::getHoverPanelDoubleConfirm() const +{ + return getValue("hoverPanelDoubleConfirm", QString(), QString(), false).toBool(); +} + +int CardsDisplaySettings::getHoverZoomDelayMs() const +{ + return getValue("hoverZoomDelayMs", QString(), QString(), 400).toInt(); +} + +int CardsDisplaySettings::getHoverPanelDelayMs() const +{ + return getValue("hoverPanelDelayMs", QString(), QString(), 200).toInt(); +} + int CardsDisplaySettings::getStackCardOverlapPercent() const { return getValue("verticalCardOverlapPercent", QString(), QString(), 33).toInt(); @@ -188,6 +215,36 @@ void CardsDisplaySettings::setCardScaling(bool _scaleCards) setValue(_scaleCards, "scaleCards"); } +void CardsDisplaySettings::setHoverPanelTrigger(int _trigger) +{ + setValue(_trigger, "hoverPanelTrigger"); + emit hoverPanelTriggerChanged(_trigger); +} + +void CardsDisplaySettings::setHoverZoomTrigger(int _trigger) +{ + setValue(_trigger, "hoverZoomTrigger"); + emit hoverZoomTriggerChanged(_trigger); +} + +void CardsDisplaySettings::setHoverPanelDoubleConfirm(bool _doubleConfirm) +{ + setValue(_doubleConfirm, "hoverPanelDoubleConfirm"); + emit hoverPanelDoubleConfirmChanged(_doubleConfirm); +} + +void CardsDisplaySettings::setHoverZoomDelayMs(int _ms) +{ + setValue(_ms, "hoverZoomDelayMs"); + emit hoverZoomDelayMsChanged(_ms); +} + +void CardsDisplaySettings::setHoverPanelDelayMs(int _ms) +{ + setValue(_ms, "hoverPanelDelayMs"); + emit hoverPanelDelayMsChanged(_ms); +} + void CardsDisplaySettings::setStackCardOverlapPercent(int _verticalCardOverlapPercent) { setValue(_verticalCardOverlapPercent, "verticalCardOverlapPercent"); diff --git a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h index 961550d3c..d8ced83a4 100644 --- a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h @@ -15,6 +15,18 @@ class CardsDisplaySettings : public SettingsManager, public ICardsDisplaySetting friend class SettingsCache; public: + /// What can summon the hover zoom or panel. Stored as int; values are the + /// serialization contract, so never reorder or renumber. + enum HoverPanelTrigger + { + HoverPanelTriggerHover = 0, + HoverPanelTriggerRightClick = 1, + HoverPanelTriggerWheel = 2, + HoverPanelTriggerShortcut = 3, + /// Never fires when selected; lets the user turn the hover zoom and panel off. + HoverPanelTriggerOff = 4 + }; + [[nodiscard]] bool getDisplayCardNames() const override; [[nodiscard]] bool getRoundCardCorners() const override; [[nodiscard]] bool getOverrideAllCardArtWithPersonalPreference() const override; @@ -26,6 +38,16 @@ public: [[nodiscard]] bool getArrowDrawAnimation() const override; [[nodiscard]] bool getAutoRotateSidewaysLayoutCards() const override; [[nodiscard]] bool getScaleCards() const override; + /// What summons the in-game card hover panel (a HoverPanelTrigger value). + [[nodiscard]] int getHoverPanelTrigger() const override; + /// What zooms the hovered card (a HoverPanelTrigger value). Zoom and panel triggers are independent. + [[nodiscard]] int getHoverZoomTrigger() const override; + /// When true, the trigger first only zooms; repeating it shows the panel. + [[nodiscard]] bool getHoverPanelDoubleConfirm() const override; + /// Hover dwell before the zoom kicks in (ms). Only used with the hover trigger. + [[nodiscard]] int getHoverZoomDelayMs() const override; + /// Wait after the zoom before the tab panel shows (ms). Hover trigger, one-step only. + [[nodiscard]] int getHoverPanelDelayMs() const override; [[nodiscard]] int getStackCardOverlapPercent() const override; [[nodiscard]] int getCardInfoViewMode() const override; [[nodiscard]] int getVisualDeckStorageCardSize() const override; @@ -49,6 +71,11 @@ public: void setArrowDrawAnimation(bool _arrowDrawAnimation); void setAutoRotateSidewaysLayoutCards(bool _autoRotateSidewaysLayoutCards); void setCardScaling(bool _scaleCards); + void setHoverPanelTrigger(int _trigger); + void setHoverZoomTrigger(int _trigger); + void setHoverPanelDoubleConfirm(bool _doubleConfirm); + void setHoverZoomDelayMs(int _ms); + void setHoverPanelDelayMs(int _ms); void setStackCardOverlapPercent(int _verticalCardOverlapPercent); void setCardInfoViewMode(int _viewMode); void setVisualDeckStorageCardSize(int _cardSize); @@ -78,6 +105,11 @@ signals: void sampleHandSizeChanged(int amount); void cardLangChanged(const QString &lang); void cardSearchLanguageChanged(int cardSearchLanguage); + void hoverPanelTriggerChanged(int trigger); + void hoverZoomTriggerChanged(int trigger); + void hoverPanelDoubleConfirmChanged(bool doubleConfirm); + void hoverZoomDelayMsChanged(int ms); + void hoverPanelDelayMsChanged(int ms); public: explicit CardsDisplaySettings(const QString &settingPath, QObject *parent = nullptr); diff --git a/libcockatrice_settings/libcockatrice/settings/game_settings.cpp b/libcockatrice_settings/libcockatrice/settings/game_settings.cpp index c8c6463e3..89fe7b85e 100644 --- a/libcockatrice_settings/libcockatrice/settings/game_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/game_settings.cpp @@ -71,6 +71,11 @@ bool GameSettings::getShareDecklistsOnLoad() const return getValue("shareDecklistsOnLoad", "game").toBool(); } +bool GameSettings::getShufflePlayers() const +{ + return getValue("shuffleplayers", "game").toBool(); +} + bool GameSettings::getRememberGameSettings() const { return getValue("rememberGameSettings", "game", QString(), true).toBool(); @@ -151,6 +156,11 @@ void GameSettings::setShareDecklistsOnLoad(bool _shareDecklistsOnLoad) setValue(_shareDecklistsOnLoad, "shareDecklistsOnLoad", "game"); } +void GameSettings::setShufflePlayers(bool _shufflePlayers) +{ + setValue(_shufflePlayers, "shuffleplayers", "game"); +} + void GameSettings::setRememberGameSettings(bool _rememberGameSettings) { setValue(_rememberGameSettings, "rememberGameSettings", "game"); diff --git a/libcockatrice_settings/libcockatrice/settings/game_settings.h b/libcockatrice_settings/libcockatrice/settings/game_settings.h index 3e64234fc..45ec8554b 100644 --- a/libcockatrice_settings/libcockatrice/settings/game_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/game_settings.h @@ -27,6 +27,7 @@ public: [[nodiscard]] bool getCreateGameAsSpectator() const override; [[nodiscard]] int getDefaultStartingLifeTotal() const override; [[nodiscard]] bool getShareDecklistsOnLoad() const override; + [[nodiscard]] bool getShufflePlayers() const override; [[nodiscard]] bool getRememberGameSettings() const override; [[nodiscard]] bool getLocalGameRememberSettings() const override; [[nodiscard]] int getLocalGameMaxPlayers() const override; @@ -44,6 +45,7 @@ public: void setCreateGameAsSpectator(bool _createGameAsSpectator); void setDefaultStartingLifeTotal(int _defaultStartingLifeTotal); void setShareDecklistsOnLoad(bool _shareDecklistsOnLoad); + void setShufflePlayers(bool _shufflePlayers); void setRememberGameSettings(bool _rememberGameSettings); void setLocalGameRememberSettings(bool value); void setLocalGameMaxPlayers(int value); diff --git a/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index 8d2cb5b92..98ad40e69 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -8,6 +8,7 @@ set(UTILITY_SOURCES libcockatrice/utility/passwordhasher.cpp libcockatrice/utility/report_categories.cpp libcockatrice/utility/server_rate_limiter.cpp + libcockatrice/utility/svg_recolor.cpp libcockatrice/utility/translation_loader.cpp libcockatrice/utility/user_data_backup.cpp libcockatrice/utility/warning_categories.cpp @@ -34,6 +35,7 @@ set(UTILITY_HEADERS libcockatrice/utility/zone_names.h libcockatrice/utility/days_years_between.h libcockatrice/utility/server_rate_limiter.h + libcockatrice/utility/svg_recolor.h libcockatrice/utility/translation_loader.h libcockatrice/utility/user_data_backup.h libcockatrice/utility/warning_categories.h diff --git a/libcockatrice_utility/libcockatrice/utility/svg_recolor.cpp b/libcockatrice_utility/libcockatrice/utility/svg_recolor.cpp new file mode 100644 index 000000000..ad51388d2 --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/svg_recolor.cpp @@ -0,0 +1,87 @@ +#include "svg_recolor.h" + +#include + +namespace +{ +constexpr QByteArrayView SENTINEL = "currentColor"; + +/// Case-insensitive byte search for the sentinel, starting at @p from. +/// QByteArray::indexOf() only matches case-sensitively for QByteArrayView, and +/// its char overload cannot match a word. +qsizetype findSentinel(const QByteArray &bytes, qsizetype from) +{ + // QByteArrayView::sliced() is a view, so the scan does not allocate a copy + // at every byte position (QByteArray::sliced() would). + const QByteArrayView view(bytes); + for (qsizetype i = from; i + SENTINEL.size() <= view.size(); ++i) { + if (view.sliced(i, SENTINEL.size()).compare(SENTINEL, Qt::CaseInsensitive) == 0) { + return i; + } + } + return -1; +} +} // namespace + +QByteArray resolveSvgColorSentinel(const QByteArray &svg, const QByteArray &color) +{ + // The sentinel and the replacement are both ASCII, so stay in bytes: a + // QString round trip would decode the whole document (and mangle bytes that + // are not valid UTF-8) even on the no-op path. + qsizetype pos = findSentinel(svg, 0); + if (pos < 0) { + return svg; + } + + QByteArray resolved = svg; + while (pos >= 0) { + resolved.replace(pos, SENTINEL.size(), color); + // Skip past the inserted color: a caller's color never contains the + // sentinel, and this keeps the scan linear. + pos = findSentinel(resolved, pos + color.size()); + } + return resolved; +} + +QByteArray resolveSvgStrokeWidth(const QByteArray &svg, double width) +{ + constexpr QByteArrayView ATTRIBUTE = "stroke-width"; + constexpr QByteArrayView DOUBLE_QUOTE = "\""; + constexpr QByteArrayView SINGLE_QUOTE = "'"; + const QByteArray value = QByteArray::number(width, 'f', 2); + + QByteArray resolved; + resolved.reserve(svg.size()); + + qsizetype cursor = 0; + while (true) { + const qsizetype start = svg.indexOf(ATTRIBUTE, cursor); + if (start < 0) { + resolved.append(svg.sliced(cursor)); + return resolved; + } + + // Only an XML attribute is rewritten. A CSS declaration such as + // `style="stroke-width:2"` has no `=` right after the name, so it stays + // untouched and the scan moves past it. + qsizetype nameEnd = start + ATTRIBUTE.size(); + while (nameEnd < svg.size() && svg.at(nameEnd) == ' ') { + ++nameEnd; + } + const bool isAttribute = nameEnd < svg.size() && svg.at(nameEnd) == '=' && nameEnd + 1 < svg.size(); + const char quoteChar = isAttribute ? svg.at(nameEnd + 1) : '\0'; + const bool quoted = quoteChar == '"' || quoteChar == '\''; + const qsizetype close = quoted ? svg.indexOf(quoteChar == '"' ? DOUBLE_QUOTE : SINGLE_QUOTE, nameEnd + 2) : -1; + if (close < 0) { + resolved.append(svg.sliced(cursor, start + ATTRIBUTE.size() - cursor)); + cursor = start + ATTRIBUTE.size(); + continue; + } + + // Keep the opening quote, replace the value, and resume at the closing + // quote so the rest of the document is copied verbatim. + resolved.append(svg.sliced(cursor, nameEnd + 2 - cursor)); + resolved.append(value); + cursor = close; + } +} diff --git a/libcockatrice_utility/libcockatrice/utility/svg_recolor.h b/libcockatrice_utility/libcockatrice/utility/svg_recolor.h new file mode 100644 index 000000000..e55f1c775 --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/svg_recolor.h @@ -0,0 +1,48 @@ +/** + * @file svg_recolor.h + * @ingroup Core + */ + +#ifndef SVG_RECOLOR_H +#define SVG_RECOLOR_H + +#include + +/** + * @brief Resolves the `currentColor` sentinel in authored SVG data. + * + * Qt's SVG renderer has no CSS cascade or ambient color context, so the + * sentinel is not resolved by Qt. Callers pass the concrete color they want + * (for example a QPalette role serialized to #RRGGBB) and receive SVG bytes + * that are safe to hand to QSvgRenderer. + * + * Matching is a plain, case-insensitive substring search, not a CSS parse: an + * identifier that merely starts with the sentinel (for example + * `id="currentColorStop"`) is rewritten too. That is acceptable for the assets + * this project authors, and keeps the resolver dependency-free. + * + * @param svg SVG source bytes, expected to be UTF-8. Invalid UTF-8 is preserved + * byte-for-byte, since the resolver never decodes the document. + * @param color Concrete SVG color (for example "#ff00ff"), as ASCII bytes. + * @return The recolored SVG source; the input is left untouched. + */ +QByteArray resolveSvgColorSentinel(const QByteArray &svg, const QByteArray &color); + +/** + * @brief Rewrites the value of every `stroke-width` attribute in authored SVG. + * + * A treatment can need a heavier or lighter stroke than the one authored for a + * line icon at text size. Like the sentinel resolver, this rewriter works on the + * bytes and understands only the XML attribute form, in either quote style + * (`stroke-width="2"` or `stroke-width='2'`): a CSS declaration such as + * `style="stroke-width:2"` is left untouched. Documents without the attribute + * are returned unchanged. + * + * @param svg SVG source bytes, expected to be UTF-8. Invalid UTF-8 is preserved + * byte-for-byte. + * @param width Replacement width in the document's user units. + * @return The rewritten SVG source; the input is left untouched. + */ +QByteArray resolveSvgStrokeWidth(const QByteArray &svg, double width); + +#endif // SVG_RECOLOR_H diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index 587463572..6c61a7e27 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -17,6 +17,7 @@ set(ORACLE_WIN32_QM_INSTALL_DIR "translations") # Sources # ------------------------ set(oracle_SOURCES + src/database_writer.cpp src/main.cpp src/oraclewizard.cpp src/oracleimporter.cpp @@ -24,12 +25,18 @@ set(oracle_SOURCES src/pagetemplates.cpp src/parsehelpers.cpp src/raw_json_scanner.cpp + src/tag_database.cpp ../cockatrice/src/client/settings/cache_settings.cpp ../cockatrice/src/client/settings/card_counter_settings.cpp ../cockatrice/src/client/settings/shortcuts_settings.cpp ../cockatrice/src/client/network/update/client/release_channel.cpp ../cockatrice/src/interface/pixel_map_generator.cpp ../cockatrice/src/interface/theme_config.cpp + ../cockatrice/src/interface/theme_icon.cpp + ../cockatrice/src/interface/theme_icon_badge.cpp + ../cockatrice/src/interface/theme_icon_fill.cpp + ../cockatrice/src/interface/theme_icon_ink.cpp + ../cockatrice/src/interface/theme_icon_sticker.cpp ../cockatrice/src/interface/theme_manager.cpp ${VERSION_STRING_CPP} ) diff --git a/oracle/src/database_writer.cpp b/oracle/src/database_writer.cpp new file mode 100644 index 000000000..e80aa853a --- /dev/null +++ b/oracle/src/database_writer.cpp @@ -0,0 +1,52 @@ +#include "database_writer.h" + +#include +#include +#include +#include + +namespace OracleDatabase +{ +QString validateXml(const QByteArray &data) +{ + QXmlStreamReader xml(data); + if (!xml.readNextStartElement() || xml.name() != QLatin1String("cockatrice_carddatabase")) { + return QCoreApplication::translate("OracleDatabase", "The download is not a Cockatrice card database."); + } + const auto version = xml.attributes().value("version"); + if (version != QLatin1String("3") && version != QLatin1String("4")) { + return QCoreApplication::translate("OracleDatabase", "The downloaded card database version is not supported."); + } + while (!xml.atEnd()) { + xml.readNext(); + } + if (xml.hasError()) { + return QCoreApplication::translate("OracleDatabase", + "The downloaded card database is incomplete or invalid: %1") + .arg(xml.errorString()); + } + return {}; +} + +bool saveXml(const QString &fileName, const QByteArray &data, QString *error) +{ + const QString validationError = validateXml(data); + if (!validationError.isEmpty()) { + if (error) { + *error = validationError; + } + return false; + } + QSaveFile file(fileName); + if (!file.open(QIODevice::WriteOnly) || file.write(data) != data.size() || !file.commit()) { + if (error) { + *error = file.errorString(); + } + return false; + } + if (error) { + error->clear(); + } + return true; +} +} // namespace OracleDatabase diff --git a/oracle/src/database_writer.h b/oracle/src/database_writer.h new file mode 100644 index 000000000..173fd23ab --- /dev/null +++ b/oracle/src/database_writer.h @@ -0,0 +1,17 @@ +#ifndef ORACLE_DATABASE_WRITER_H +#define ORACLE_DATABASE_WRITER_H + +#include +#include + +namespace OracleDatabase +{ +// Validate the complete downloaded document without allocating a card database. +// Empty string means valid; otherwise returns a diagnostic suitable for the wizard. +QString validateXml(const QByteArray &data); +// Never truncate the installed database: validate, write a sibling temporary file, +// check the complete write and atomically commit it. +bool saveXml(const QString &fileName, const QByteArray &data, QString *error = nullptr); +} // namespace OracleDatabase + +#endif diff --git a/oracle/src/oracleimporter.cpp b/oracle/src/oracleimporter.cpp index 477c54d21..4657bf055 100644 --- a/oracle/src/oracleimporter.cpp +++ b/oracle/src/oracleimporter.cpp @@ -6,6 +6,7 @@ #include "libcockatrice/card/relation/card_relation_type.h" #include "libcockatrice/interfaces/noop_card_set_priority_controller.h" #include "parsehelpers.h" +#include "tag_database.h" #include #include @@ -100,6 +101,10 @@ static CardSet::Priority getSetPriority(const QString &setType, const QString &s bool OracleImporter::readSetsFromByteArray(QByteArray data) { + importSuccessful = false; + importError.clear(); + allSets.clear(); + rawSetsData.clear(); const RawJson::ScanProgressCallback progress = progressReporting ? [this]( @@ -517,6 +522,31 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson properties.insert("coloridentity", colorIdentity); } + // Card tags: the source's own `tags` array is used when present (MTGJson + // may ship one eventually), then the configured tag database is joined in + // while the card's identifiers are still available. Stored space-separated + // so `tags:` can match each slug as a discrete token; slugs never contain + // whitespace. + QStringList tags; + for (const QJsonValue &tag : card.value("tags").toArray()) { + const QString tagSlug = tag.toString().trimmed().toLower(); + if (!tagSlug.isEmpty() && !tags.contains(tagSlug)) { + tags.append(tagSlug); + } + } + if (tagDatabase) { + const QString oracleId = getJsonString(identifiers, "scryfallOracleId"); + for (const QString &tagSlug : tagDatabase->tagsForCard(oracleId, name)) { + if (!tags.contains(tagSlug)) { + tags.append(tagSlug); + } + } + } + if (!tags.isEmpty()) { + tags.sort(); + properties.insert("tags", tags.join(" ")); + } + const auto &mainCardType = getMainCardType(card.value("types").toVariant().toStringList()); if (mainCardType.isEmpty()) { qDebug() << "warning: no mainCardType for card:" << name; @@ -663,6 +693,14 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJson if (newPriority >= 0 && (currentPriority < 0 || newPriority < currentPriority)) { properties.insert(prop, thisCardPropertyValue); } + } else if (prop == "tags") { + // Tags are oracle-level: union both faces instead of + // concatenating them with the split-card separator. + QStringList merged = + (originalPropertyValue + " " + thisCardPropertyValue).split(" ", Qt::SkipEmptyParts); + merged.removeDuplicates(); + merged.sort(); + properties.insert(prop, merged.join(" ")); } else { properties.insert(prop, originalPropertyValue + splitCardPropSeparator + thisCardPropertyValue); @@ -764,7 +802,15 @@ int OracleImporter::startImport() { static ICardSetPriorityController *noOpController = new NoopCardSetPriorityController(); - importCancelled.storeRelease(0); + importSuccessful = false; + importError.clear(); + sets.clear(); + cards.clear(); + localizedEntries.clear(); + splitLocalizedTexts.clear(); + if (allSets.isEmpty()) { + return failImport(tr("No set has been selected for import.")); + } // Pre-allocate the cards hash to avoid rehashing during import. Keys are // distinct card names while raw ranges only count printings (AllPrintings @@ -786,9 +832,7 @@ int OracleImporter::startImport() for (const SetToDownload &curSetToParse : allSets) { if (importCancelled.loadAcquire()) { - // The wizard was closed mid-import: stop at the next set boundary so - // the caller can wait for this future without processing every set. - break; + return failImport(tr("The card database import was cancelled.")); } CardSetPtr newSet = CardSet::newInstance(noOpController, curSetToParse.getShortName(), @@ -798,30 +842,20 @@ int OracleImporter::startImport() // parse only this set's slice of the raw document so the whole JSON tree is // never kept in memory at once const RawJson::SetDataRange &rawRange = curSetToParse.getRawRange(); - const qsizetype rangeEnd = rawRange.start + rawRange.length; - if (rawRange.start < 0 || rawRange.length <= 0 || rangeEnd > rawSetsData.size()) { - // rawSetsData is cleared by releaseSetData() while SetToDownload copies - // taken from getSets() keep their ranges, and nothing else enforces the - // pairing — so never index past the buffer on stale/mismatched ranges. - qWarning() << "error: out-of-bounds raw range for set" << curSetToParse.getShortName() << "skipping"; - ++setIndex; - emit setIndexChanged(0, setIndex, curSetToParse.getLongName()); - continue; + if (rawRange.start < 0 || rawRange.length <= 0 || rawRange.start > rawSetsData.size() || + rawRange.length > rawSetsData.size() - rawRange.start) { + return failImport(tr("The source data for set %1 is unavailable. Load the sets file again.") + .arg(curSetToParse.getShortName())); } - // sliced() shares the buffer instead of deep-copying the slice; the largest - // sets in AllPrintings are tens of MB, so the copy is worth avoiding here. const QByteArray setBytes = rawSetsData.sliced(rawRange.start, rawRange.length); QJsonParseError parseError; const QJsonDocument setDoc = QJsonDocument::fromJson(setBytes, &parseError); if (parseError.error != QJsonParseError::NoError) { - qWarning() << "error: parsing card data for set" << curSetToParse.getShortName() << ":" - << parseError.errorString(); - ++setIndex; - // Keep the progress accounting honest: a set that failed to parse - // still advanced the index, so report it (with zero imported cards) - // rather than letting SaveSetsPage's bar stall per failed set. - emit setIndexChanged(0, setIndex, curSetToParse.getLongName()); - continue; + return failImport( + tr("Could not import set %1: %2").arg(curSetToParse.getShortName(), parseError.errorString())); + } + if (!setDoc.isObject() || !setDoc.object().value("cards").isArray()) { + return failImport(tr("Set %1 does not contain a card list.").arg(curSetToParse.getShortName())); } // Only add the set to the database once its slice parsed cleanly; @@ -838,7 +872,14 @@ int OracleImporter::startImport() emit setIndexChanged(numCardsInSet, setIndex, curSetToParse.getLongName()); } + if (importCancelled.loadAcquire()) { + return failImport(tr("The card database import was cancelled.")); + } + if (cards.isEmpty()) { + return failImport(tr("No cards were imported. The existing card database has been kept.")); + } applyLocalizedData(); + importSuccessful = true; emit setIndexChanged(0, setIndex, QString()); @@ -846,8 +887,23 @@ int OracleImporter::startImport() return setIndex; } +int OracleImporter::failImport(const QString &message) +{ + importSuccessful = false; + importError = message; + sets.clear(); + cards.clear(); + localizedEntries.clear(); + splitLocalizedTexts.clear(); + qWarning() << message; + return 0; +} + bool OracleImporter::saveToFile(const QString &fileName, const QString &sourceUrl, const QString &sourceVersion) { + if (!importSuccessful) { + return false; + } CockatriceXml4Parser parser(new NoopCardSetPriorityController()); return parser.saveToFile(createDefaultMagicFormats(), sets, cards, fileName, sourceUrl, sourceVersion); @@ -861,6 +917,7 @@ void OracleImporter::releaseSetData() void OracleImporter::clear() { + prepareImport(); sets.clear(); cards.clear(); allSets.clear(); diff --git a/oracle/src/oracleimporter.h b/oracle/src/oracleimporter.h index d872e184e..694babf4b 100644 --- a/oracle/src/oracleimporter.h +++ b/oracle/src/oracleimporter.h @@ -17,12 +17,14 @@ #include #include #include +#include #include #include #include class CardRelation; class QRegularExpression; +class TagDatabase; // many users prefer not to see these sets with non english arts // they will given priority PriorityLowest @@ -198,6 +200,11 @@ private: * the whole import (or racing it). */ QAtomicInt importCancelled; + bool importSuccessful = false; + QString importError; + + // Discard the incomplete in-memory database; the installed file is untouched. + int failImport(const QString &message); /** * The ISO-639 language code whose foreignData is imported; "en" by default. @@ -210,6 +217,12 @@ private: */ bool localizationEnabled = false; + /** + * Optional tag lookup applied to every imported card. Owned by the wizard + * so it outlives the import worker; null when tags are disabled. + */ + std::shared_ptr tagDatabase; + /** * Localized name/text collected per imported card key while parsing sets, * applied to the CardInfo objects by applyLocalizedData() once all @@ -264,6 +277,16 @@ public: { progressReporting = enabled; } + /** + * @brief Supplies the tag database applied to every imported card. + * + * Pass nullptr to import without tags. The wizard keeps the database alive + * for the whole import, so the worker sees a stable pointer. + */ + void setTagDatabase(std::shared_ptr database) + { + tagDatabase = std::move(database); + } /** * Selects the ISO-639 language code whose foreignData is imported. * English (the default) and unsupported codes disable localization. @@ -278,7 +301,24 @@ public: * the wizard can hand over its decompressed buffer without copying it. */ bool readSetsFromByteArray(QByteArray data); + // Returns the complete selected-set count on success, zero on failure or cancellation. int startImport(); + bool hasSuccessfulImport() const + { + return importSuccessful; + } + const QString &getImportError() const + { + return importError; + } + // Call on the owning thread before scheduling a worker, so a cancellation + // arriving before that worker starts is not lost. + void prepareImport() + { + importCancelled.storeRelease(0); + importSuccessful = false; + importError.clear(); + } /** * @brief Requests an in-flight startImport() to stop at the next set boundary. * diff --git a/oracle/src/oraclewizard.cpp b/oracle/src/oraclewizard.cpp index c33d821a5..43e6f30ca 100644 --- a/oracle/src/oraclewizard.cpp +++ b/oracle/src/oraclewizard.cpp @@ -1,6 +1,7 @@ #include "oraclewizard.h" #include "client/settings/cache_settings.h" +#include "database_writer.h" #include "main.h" #include "oracleimporter.h" #include "pages.h" @@ -56,7 +57,8 @@ OracleWizard::OracleWizard(QWidget *parent) : QWizard(parent) QList pages; if (!isSpoilersOnly) { - pages << new IntroPage << new LoadSetsPage << new SaveSetsPage << new LoadTokensPage << new OutroPage; + pages << new IntroPage << new LoadSetsPage << new TagsPage << new SaveSetsPage << new LoadTokensPage + << new OutroPage; } else { pages << new LoadSpoilersPage << new OutroPage; } @@ -141,6 +143,13 @@ void OracleWizard::reject() } QWizard::reject(); + // A hidden wizard does not close the application's last visible window. + // Stop background refreshes explicitly so the hosting client receives a verdict, + // using the same rule as main(): a reject after the database was written is still + // a successful update, only a reject that wrote nothing counts as a failure. + if (backgroundMode) { + QCoreApplication::exit(hasSavedDatabase() ? 0 : 1); + } } void OracleWizard::runInBackground() @@ -212,17 +221,10 @@ void OracleWizard::disableButtons() bool OracleWizard::saveTokensToFile(const QString &fileName) { - QFile file(fileName); - if (!file.open(QIODevice::WriteOnly)) { - qDebug() << "File open (w) failed for" << fileName; + QString error; + if (!OracleDatabase::saveXml(fileName, tokensData, &error)) { + qWarning() << error; return false; } - - if (file.write(tokensData) == -1) { - qDebug() << "File write (w) failed for" << fileName; - return false; - } - - file.close(); return true; } diff --git a/oracle/src/oraclewizard.h b/oracle/src/oraclewizard.h index b1dc5eab2..121acd577 100644 --- a/oracle/src/oraclewizard.h +++ b/oracle/src/oraclewizard.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -11,6 +12,7 @@ class QNetworkAccessManager; class OracleImporter; class QLocalServer; class QSettings; +class TagDatabase; class QWidget; class OracleWizard : public QWizard @@ -68,6 +70,8 @@ public: OracleImporter *importer; QSettings *settings; QNetworkAccessManager *nam; + /** @brief Tag data loaded by the Tags page, consumed by the importer. */ + std::shared_ptr tagDatabase; bool downloadedPlainXml = false; QByteArray xmlData; bool backgroundMode = false; diff --git a/oracle/src/pages.cpp b/oracle/src/pages.cpp index 4c664a38c..cf23412c2 100644 --- a/oracle/src/pages.cpp +++ b/oracle/src/pages.cpp @@ -1,10 +1,12 @@ #include "pages.h" #include "client/settings/cache_settings.h" +#include "database_writer.h" #include "main.h" #include "oracleimporter.h" #include "oraclewizard.h" #include "pagetemplates.h" +#include "tag_database.h" #include "version_string.h" #include @@ -18,7 +20,9 @@ #include #include #include +#include #include +#include #include #include #include @@ -35,6 +39,7 @@ #include #include #include +#include #include #include #include @@ -43,6 +48,7 @@ #include #include #include +#include #include #include #include @@ -70,6 +76,7 @@ class QWidget; #define MTGJSON_V4_URL_COMPONENT "mtgjson.com/files/" #define ALLSETS_URL_FALLBACK "https://www.mtgjson.com/api/v5/AllPrintings.json" #define MTGJSON_VERSION_URL "https://www.mtgjson.com/api/v5/Meta.json" +#define SCRYFALL_TAGS_URL "https://api.scryfall.com/bulk-data/oracle-tags" #ifdef HAS_LZMA #define ALLSETS_URL "https://www.mtgjson.com/api/v5/AllPrintings.json.xz" @@ -169,7 +176,16 @@ LoadSetsResult loadSetsData(const QPointer &importer, QByteArray break; } - if (data.startsWith("<")) { + // Test the first non-space byte in place: trimmed() would duplicate the whole + // payload, which on the plain-XML path is the largest download we handle. + const auto firstNonSpace = std::find_if_not(data.cbegin(), data.cend(), + [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; }); + const bool looksLikeXml = firstNonSpace != data.cend() && *firstNonSpace == '<'; + if (looksLikeXml) { + result.errorMessage = OracleDatabase::validateXml(data); + if (!result.errorMessage.isEmpty()) { + return result; + } result.ok = true; result.plainXml = true; result.xmlData = std::move(data); @@ -651,6 +667,10 @@ void LoadSetsPage::importFinished() } else if (!result.ok && !result.plainXml) { qWarning() << tr("The file was retrieved successfully, but it does not contain any sets data."); } + if (!result.ok) { + wizard()->reject(); + return; + } emit readyToContinue(); return; } @@ -692,6 +712,502 @@ void LoadSetsPage::importFinished() }); } +namespace +{ +/** + * @brief True when a URL points at a tag file rather than Scryfall's bulk metadata. + */ +bool looksLikeTagFileUrl(const QUrl &url) +{ + const QString path = url.path().toLower(); + return path.endsWith(".json") || path.endsWith(".jsonl") || path.endsWith(".gz") || path.endsWith(".xz") || + path.endsWith(".zip"); +} + +/** + * @brief Extends Qt's terse network error with a JSON error body. + * + * Scryfall explains a rejected request in the response body (for example a + * missing Accept header); QNetworkReply::errorString() only says "status code + * 400". Non-JSON bodies (CDN HTML pages) are ignored so the dialog stays short. + */ +QString networkErrorDetail(QNetworkReply *reply) +{ + QString detail = reply->errorString(); + const QString body = QString::fromUtf8(reply->readAll()).trimmed(); + if (body.startsWith('{') && body.size() <= 300) { + detail += QStringLiteral(" ") + body; + } + return detail; +} +} // namespace + +TagsPage::TagsPage(QWidget *parent) : OracleWizardPage(parent) +{ + enableCheckBox = new QCheckBox(this); + enableCheckBox->setChecked(true); + connect(enableCheckBox, &QCheckBox::toggled, this, &TagsPage::updateSourceEnabled); + + urlRadioButton = new QRadioButton(this); + fileRadioButton = new QRadioButton(this); + urlRadioButton->setChecked(true); + connect(urlRadioButton, &QRadioButton::toggled, this, &TagsPage::updateSourceEnabled); + connect(fileRadioButton, &QRadioButton::toggled, this, &TagsPage::updateSourceEnabled); + + urlLineEdit = new QLineEdit(this); + fileLineEdit = new QLineEdit(this); + + urlButton = new QPushButton(this); + connect(urlButton, &QPushButton::clicked, this, &TagsPage::actRestoreDefaultSource); + + fileButton = new QPushButton(this); + connect(fileButton, &QPushButton::clicked, this, &TagsPage::actChooseFile); + + parentsCheckBox = new QCheckBox(this); + + parentsExplainer = new QLabel(this); + parentsExplainer->setWordWrap(true); + // Indent under the checkbox and give the wrapped text room above and below, + // so it reads as an explanation of the option rather than a caption glued + // to the controls around it. + parentsExplainer->setContentsMargins(24, 4, 0, 10); + // A word-wrapped label reports a one-line sizeHint, so a short page squeezes + // it and clips the text top and bottom. Pinning the vertical policy makes + // the wrapped height part of the page's minimum size. + parentsExplainer->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Minimum); + + statusLabel = new QLabel(this); + statusLabel->setWordWrap(true); + statusLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); + statusLabel->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Minimum); + + progressLabel = new QLabel(this); + progressBar = new QProgressBar(this); + progressLabel->hide(); + progressBar->hide(); + + auto *layout = new QGridLayout(this); + layout->addWidget(enableCheckBox, 0, 0, 1, 2); + layout->addWidget(urlRadioButton, 1, 0); + layout->addWidget(urlLineEdit, 1, 1); + layout->addWidget(urlButton, 2, 1, Qt::AlignRight); + layout->addWidget(fileRadioButton, 3, 0); + layout->addWidget(fileLineEdit, 3, 1); + layout->addWidget(fileButton, 4, 1, Qt::AlignRight); + layout->addWidget(parentsCheckBox, 5, 0, 1, 2); + layout->addWidget(parentsExplainer, 6, 0, 1, 2); + layout->addWidget(statusLabel, 7, 0, 1, 2); + layout->addWidget(progressLabel, 8, 0); + layout->addWidget(progressBar, 8, 1); + layout->setRowStretch(9, 1); + setLayout(layout); + + connect(&watcher, &QFutureWatcher::finished, this, &TagsPage::actLoadFinished); + + updateSourceEnabled(); + retranslateUi(); +} + +void TagsPage::retranslateUi() +{ + setTitle(tr("Tag data")); + setSubTitle(tr("Card tags let you search and filter by tag. Oracle downloads Scryfall's Oracle Tags by " + "default; you can also point it at your own tag database.")); + + enableCheckBox->setText(tr("Download card tags")); + enableCheckBox->setToolTip( + tr("Apply tag slugs to the card database so cards can be searched and filtered by tag.")); + urlRadioButton->setText(tr("Download URL:")); + urlButton->setText(tr("Restore default URL")); + fileRadioButton->setText(tr("Local file:")); + fileButton->setText(tr("Choose file...")); + parentsCheckBox->setText(tr("Include parent tags")); + parentsCheckBox->setToolTip(tr("Also apply the broader tags that this tag belongs to.")); + parentsExplainer->setText( + tr("Tags form a hierarchy: \"tutor-creature-giant\" belongs to \"tutor-creature\", which belongs to " + "\"tutor\". With this on, a card tagged with the specific tag also matches searches for its broader " + "parent tags. It adds many more tags to each card.")); +} + +bool TagsPage::isComplete() const +{ + return !loadActive; +} + +void TagsPage::initializePage() +{ + enableCheckBox->setChecked(wizard()->settings->value("tagsenabled", true).toBool()); + parentsCheckBox->setChecked(wizard()->settings->value("tagsparents", false).toBool()); + + const QString source = wizard()->settings->value("tagssource").toString(); + if (source.isEmpty() || source.startsWith("http://") || source.startsWith("https://")) { + urlRadioButton->setChecked(true); + urlLineEdit->setText(source.isEmpty() ? QString(SCRYFALL_TAGS_URL) : source); + fileLineEdit->clear(); + } else { + fileRadioButton->setChecked(true); + fileLineEdit->setText(source); + urlLineEdit->setText(SCRYFALL_TAGS_URL); + } + + statusLabel->clear(); + progressLabel->hide(); + progressBar->hide(); + updateSourceEnabled(); + + if (wizard()->backgroundMode && isEnabled()) { + // In background mode runInBackground() calls initializePage() instead of + // next(), so a page that has nothing to do must advance itself. + if (validatePage()) { + emit readyToContinue(); + } + } +} + +QString TagsPage::selectedSource() const +{ + return urlRadioButton->isChecked() ? urlLineEdit->text().trimmed() : fileLineEdit->text().trimmed(); +} + +void TagsPage::updateSourceEnabled() +{ + const bool enabled = enableCheckBox->isChecked(); + const bool usingUrl = urlRadioButton->isChecked(); + const bool usingFile = fileRadioButton->isChecked(); + + urlRadioButton->setEnabled(enabled); + fileRadioButton->setEnabled(enabled); + urlLineEdit->setEnabled(enabled && usingUrl); + urlButton->setEnabled(enabled && usingUrl); + fileLineEdit->setEnabled(enabled && usingFile); + fileButton->setEnabled(enabled && usingFile); + parentsCheckBox->setEnabled(enabled); + parentsExplainer->setEnabled(enabled); +} + +void TagsPage::saveSettings() +{ + wizard()->settings->setValue("tagsenabled", enableCheckBox->isChecked()); + wizard()->settings->setValue("tagsparents", parentsCheckBox->isChecked()); + + QString source = selectedSource(); + if (urlRadioButton->isChecked() && source == SCRYFALL_TAGS_URL) { + source.clear(); + } + if (source.isEmpty()) { + wizard()->settings->remove("tagssource"); + } else { + wizard()->settings->setValue("tagssource", source); + } +} + +bool TagsPage::validatePage() +{ + if (!enableCheckBox->isChecked()) { + wizard()->tagDatabase.reset(); + saveSettings(); + return true; + } + + const auto rejectValidation = [this](const QString &message) { + if (wizard()->backgroundMode) { + qWarning() << message; + wizard()->tagDatabase.reset(); + emit readyToContinue(); + } else { + QMessageBox::critical(this, tr("Error"), message); + } + return false; + }; + + const QString source = selectedSource(); + const bool includeParents = parentsCheckBox->isChecked(); + + // Going back and forth through the wizard must not reload an unchanged source. + if (wizard()->tagDatabase && source == loadedSource && includeParents == loadedParents) { + return true; + } + + if (loadActive) { + return false; + } + + if (urlRadioButton->isChecked()) { + const QUrl url = QUrl::fromUserInput(source); + if (!url.isValid() || url.isEmpty()) { + return rejectValidation(tr("The provided tag URL is not valid.")); + } + } else if (source.isEmpty() || !QFile::exists(source)) { + return rejectValidation(tr("Please choose an existing tag database file.")); + } + + saveSettings(); + loadedSource = source; + loadedParents = includeParents; + + wizard()->disableButtons(); + setEnabled(false); + loadActive = true; + emit completeChanged(); + + progressBar->setRange(0, 0); + progressBar->setValue(0); + progressLabel->setText(tr("Fetching tag data...")); + progressLabel->show(); + progressBar->show(); + statusLabel->clear(); + + if (urlRadioButton->isChecked()) { + const QUrl url = QUrl::fromUserInput(source); + if (looksLikeTagFileUrl(url)) { + downloadTagFile(url); + } else { + downloadMetadata(url); + } + } else { + loadFromFile(source); + } + return false; +} + +void TagsPage::actChooseFile() +{ + QFileDialog dialog(this, tr("Load tag database")); + dialog.setFileMode(QFileDialog::ExistingFile); + + QString extensions = "*.json *.jsonl"; +#ifdef HAS_ZLIB + extensions += " *.gz *.zip"; +#endif +#ifdef HAS_LZMA + extensions += " *.xz"; +#endif + dialog.setNameFilter(tr("Tag database (%1)").arg(extensions)); + + if (!fileLineEdit->text().isEmpty() && QFile::exists(fileLineEdit->text())) { + dialog.selectFile(fileLineEdit->text()); + } + + if (!dialog.exec()) { + return; + } + + fileLineEdit->setText(dialog.selectedFiles().at(0)); + fileRadioButton->setChecked(true); + updateSourceEnabled(); +} + +void TagsPage::actRestoreDefaultSource() +{ + urlLineEdit->setText(SCRYFALL_TAGS_URL); + urlRadioButton->setChecked(true); + updateSourceEnabled(); +} + +void TagsPage::downloadMetadata(const QUrl &url) +{ + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); + // api.scryfall.com rejects requests without an Accept header (HTTP 400); + // QNetworkAccessManager does not add one on its own. + request.setRawHeader("Accept", "application/json"); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); + + activeReply = wizard()->nam->get(request); + connect(activeReply, &QNetworkReply::finished, this, &TagsPage::actMetadataFinished); +} + +void TagsPage::actMetadataFinished() +{ + auto *reply = qobject_cast(sender()); + if (!reply) { + return; + } + activeReply = nullptr; + reply->deleteLater(); + + if (wizard()->wasCancelled()) { + return; + } + if (reply->error() != QNetworkReply::NoError) { + failLoad( + tr("Could not fetch tag metadata from %1: %2").arg(reply->url().toString(), networkErrorDetail(reply))); + return; + } + + QJsonParseError parseError{}; + const QJsonDocument document = QJsonDocument::fromJson(reply->readAll(), &parseError); + if (parseError.error != QJsonParseError::NoError || !document.isObject()) { + failLoad(tr("The tag metadata could not be read.")); + return; + } + + const QJsonObject metadata = document.object(); + QString downloadUrl = metadata.value("jsonl_download_uri").toString(); + if (downloadUrl.isEmpty()) { + downloadUrl = metadata.value("download_uri").toString(); + } + if (downloadUrl.isEmpty()) { + failLoad(tr("The tag data source did not provide a download URL.")); + return; + } + + downloadTagFile(QUrl::fromUserInput(downloadUrl)); +} + +void TagsPage::downloadTagFile(const QUrl &url) +{ + QNetworkRequest request(url); + request.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); + request.setRawHeader("Accept", "*/*"); + request.setAttribute(QNetworkRequest::RedirectPolicyAttribute, QNetworkRequest::NoLessSafeRedirectPolicy); + + activeReply = wizard()->nam->get(request); + connect(activeReply, &QNetworkReply::finished, this, &TagsPage::actTagFileFinished); + connect(activeReply, &QNetworkReply::downloadProgress, this, &TagsPage::actDownloadProgress); +} + +void TagsPage::actDownloadProgress(qint64 received, qint64 total) +{ + emitProgress("download", received, total); + if (total > 0) { + progressBar->setRange(0, static_cast(total)); + progressBar->setValue(static_cast(received)); + } + progressLabel->setText(tr("Downloading tag data (%1 MB)").arg(received / (1024 * 1024))); +} + +void TagsPage::actTagFileFinished() +{ + auto *reply = qobject_cast(sender()); + if (!reply) { + return; + } + activeReply = nullptr; + reply->deleteLater(); + + if (wizard()->wasCancelled()) { + return; + } + if (reply->error() != QNetworkReply::NoError) { + failLoad(tr("Could not download tag data from %1: %2").arg(reply->url().toString(), networkErrorDetail(reply))); + return; + } + + const QByteArray data = reply->readAll(); + if (data.isEmpty()) { + failLoad(tr("The tag download was empty.")); + return; + } + beginLoad(data); +} + +void TagsPage::loadFromFile(const QString &path) +{ + QFile file(path); + if (!file.open(QIODevice::ReadOnly)) { + failLoad(tr("Cannot open file '%1'.").arg(path)); + return; + } + beginLoad(file.readAll()); +} + +void TagsPage::beginLoad(const QByteArray &data) +{ + progressBar->setRange(0, 0); + progressLabel->setText(tr("Reading tag data...")); + + const bool includeParents = parentsCheckBox->isChecked(); + future = QtConcurrent::run([data, includeParents]() { + LoadResult result; + result.database = TagDatabase::loadFromData(data, TagDatabase::LoadOptions{.includeParentTags = includeParents}, + &result.errorMessage); + return result; + }); + watcher.setFuture(future); +} + +void TagsPage::actLoadFinished() +{ + if (wizard()->wasCancelled()) { + return; + } + + const LoadResult result = watcher.result(); + + loadActive = false; + emit completeChanged(); + wizard()->enableButtons(); + setEnabled(true); + progressLabel->hide(); + progressBar->hide(); + + if (!result.database) { + wizard()->tagDatabase.reset(); + failLoad(result.errorMessage.isEmpty() ? tr("The tag data could not be read.") : result.errorMessage); + return; + } + + wizard()->tagDatabase = result.database; + statusLabel->setText(tr("Loaded %1 tags for %2 cards.") + .arg(wizard()->tagDatabase->tagCount()) + .arg(wizard()->tagDatabase->taggedCardCount())); + qInfo() << "Loaded tag database:" << wizard()->tagDatabase->tagCount() << "tags for" + << wizard()->tagDatabase->taggedCardCount() << "cards"; + advance(); +} + +void TagsPage::failLoad(const QString &message) +{ + loadActive = false; + emit completeChanged(); + wizard()->enableButtons(); + setEnabled(true); + progressLabel->hide(); + progressBar->hide(); + qWarning() << message; + + // The failed load leaves no usable database; never keep tags from a + // previous, different source. + wizard()->tagDatabase.reset(); + + if (wizard()->backgroundMode) { + statusLabel->setText(message); + emit readyToContinue(); + return; + } + + // Do not advance here. Advancing runs validatePage() again, which starts + // the same failing download and traps the user in an error loop. Staying + // on the page lets them fix the source, retry with Next, or opt out. + const QString guidance = tr("You can try again, choose another source, or untick \"Download card tags\" " + "to import without tags."); + statusLabel->setText(message + "\n\n" + guidance); + QMessageBox::warning(this, tr("Tags not loaded"), message + "\n\n" + guidance); +} + +void TagsPage::advance() +{ + if (wizard()->backgroundMode) { + emit readyToContinue(); + } else { + wizard()->next(); + } +} + +void TagsPage::cancelWork() +{ + if (activeReply) { + activeReply->abort(); + activeReply = nullptr; + } + if (future.isRunning()) { + future.cancel(); + watcher.cancel(); + future.waitForFinished(); + } +} + SaveSetsPage::SaveSetsPage(QWidget *parent) : OracleWizardPage(parent) { pathLabel = new QLabel(this); @@ -717,7 +1233,7 @@ SaveSetsPage::SaveSetsPage(QWidget *parent) : OracleWizardPage(parent) bool SaveSetsPage::isComplete() const { - return !importActive; + return !importActive && importReady; } void SaveSetsPage::cleanupPage() @@ -730,8 +1246,11 @@ void SaveSetsPage::cleanupPage() void SaveSetsPage::initializePage() { + importReady = false; retranslateUi(); if (wizard()->downloadedPlainXml) { + importReady = true; + emit completeChanged(); messageLog->hide(); progressBar->hide(); if (wizard()->backgroundMode) { @@ -752,11 +1271,18 @@ void SaveSetsPage::initializePage() Qt::UniqueConnection); connect(&importWatcher, &QFutureWatcher::finished, this, &SaveSetsPage::importFinished, Qt::UniqueConnection); + // The Tags page has already loaded (or deliberately skipped) the tag data. + // The wizard keeps it alive past this page; the importer only borrows it. + wizard()->importer->setTagDatabase(wizard()->tagDatabase); + wizard()->disableButtons(); importActive = true; emit completeChanged(); const QPointer importer = wizard()->importer; + if (auto *activeImporter = importer.data()) { + activeImporter->prepareImport(); + } importFuture = QtConcurrent::run([importer] { return importer ? importer->startImport() : 0; }); importWatcher.setFuture(importFuture); } @@ -788,22 +1314,28 @@ void SaveSetsPage::importFinished() const int setsImported = importWatcher.result(); const QPointer importer = wizard()->importer; + importReady = importer && importer->hasSuccessfulImport() && setsImported == totalSets; + const QString error = importer ? importer->getImportError() : tr("The card database importer is unavailable."); if (importer) { importer->releaseSetData(); } + wizard()->button(QWizard::NextButton)->setEnabled(importReady); + emit completeChanged(); - if (wizard()->backgroundMode) { - if (setsImported == 0) { - qWarning() << tr("No set has been imported."); + if (!importReady) { + const QString message = error.isEmpty() ? tr("No set has been imported.") : error; + if (wizard()->backgroundMode) { + qWarning() << message; + wizard()->reject(); + } else { + QMessageBox::critical(this, tr("Error"), message); } - emit readyToContinue(); return; } progressBar->setValue(progressBar->maximum()); - - if (setsImported == 0) { - QMessageBox::critical(this, tr("Error"), tr("No set has been imported.")); + if (wizard()->backgroundMode) { + emit readyToContinue(); } } @@ -854,6 +1386,18 @@ void SaveSetsPage::updateTotalProgress(int cardsImported, int setIndex, const QS bool SaveSetsPage::validatePage() { + if (!isComplete()) { + return false; + } + const auto failSave = [this](const QString &message) { + qWarning() << message; + if (wizard()->backgroundMode) { + wizard()->reject(); + } else { + QMessageBox::critical(this, tr("Error"), message); + } + return false; + }; QString defaultPath = SettingsCache::instance().getCardDatabasePath(); QString windowName = tr("Save card database"); QString fileType = tr("XML; card database (*.xml)"); @@ -872,24 +1416,21 @@ bool SaveSetsPage::validatePage() QFileInfo fi(fileName); QDir fileDir(fi.path()); if (!fileDir.exists() && !fileDir.mkpath(fileDir.absolutePath())) { - return false; + return failSave(tr("The database directory could not be created: %1").arg(fileDir.absolutePath())); } if (wizard()->downloadedPlainXml) { - QFile file(fileName); - if (!file.open(QIODevice::WriteOnly)) { - qDebug() << "File write (w) failed for" << fileName; - return false; - } - if (file.write(wizard()->xmlData) < 1) { - qDebug() << "File write (w) failed for" << fileName; - return false; + QString error; + if (!OracleDatabase::saveXml(fileName, wizard()->xmlData, &error)) { + return failSave(tr("The file could not be saved to %1: %2").arg(fileName, error)); } wizard()->xmlData.clear(); } else if (!wizard()->importer->saveToFile(fileName, wizard()->getCardSourceUrl(), wizard()->getCardSourceVersion())) { - QMessageBox::critical(this, tr("Error"), tr("The file could not be saved to %1").arg(fileName)); - return false; + // The importer refuses to write a database whose import did not finish, and + // reports the reason there; do not dress an import failure up as a disk failure. + const QString importError = wizard()->importer->getImportError(); + return failSave(importError.isEmpty() ? tr("The file could not be saved to %1").arg(fileName) : importError); } wizard()->markDatabaseSaved(); diff --git a/oracle/src/pages.h b/oracle/src/pages.h index 0530010ba..49331765d 100644 --- a/oracle/src/pages.h +++ b/oracle/src/pages.h @@ -6,21 +6,25 @@ #include #include #include +#include #include #include #include +#include #include class QCheckBox; class QComboBox; class QLabel; class QLineEdit; +class QNetworkReply; class QRadioButton; class QProgressBar; class QTextEdit; class QPushButton; class QUrl; class QWidget; +class TagDatabase; class IntroPage : public OracleWizardPage { @@ -109,6 +113,72 @@ private slots: void zipDownloadFailed(const QString &message); }; +/** + * @brief Optional wizard step that loads the tag database used by the importer. + * + * Reloading is skipped when the source and parent-tag setting are unchanged, so + * going back and forth through the wizard does not re-download the tag data. + */ +class TagsPage : public OracleWizardPage +{ + Q_OBJECT +public: + explicit TagsPage(QWidget *parent = nullptr); + void retranslateUi() override; + bool isComplete() const override; + +protected: + void initializePage() override; + bool validatePage() override; + void cancelWork() override; + +private: + struct LoadResult + { + std::shared_ptr database; + QString errorMessage; + }; + + QCheckBox *enableCheckBox; + QRadioButton *urlRadioButton; + QRadioButton *fileRadioButton; + QLineEdit *urlLineEdit; + QLineEdit *fileLineEdit; + QPushButton *urlButton; + QPushButton *fileButton; + QCheckBox *parentsCheckBox; + QLabel *parentsExplainer; + QLabel *statusLabel; + QLabel *progressLabel; + QProgressBar *progressBar; + + QFutureWatcher watcher; + QFuture future; + QPointer activeReply; + bool loadActive = false; + QString loadedSource; + bool loadedParents = false; + + [[nodiscard]] QString selectedSource() const; + void updateSourceEnabled(); + void saveSettings(); + void beginLoad(const QByteArray &data); + void loadFromFile(const QString &path); + void downloadMetadata(const QUrl &url); + void downloadTagFile(const QUrl &url); + void finishLoad(); + void failLoad(const QString &message); + void advance(); + +private slots: + void actChooseFile(); + void actRestoreDefaultSource(); + void actDownloadProgress(qint64 received, qint64 total); + void actMetadataFinished(); + void actTagFileFinished(); + void actLoadFinished(); +}; + class SaveSetsPage : public OracleWizardPage { Q_OBJECT @@ -128,6 +198,7 @@ private: QFuture importFuture; int totalSets = 0; bool importActive = false; + bool importReady = false; protected: void initializePage() override; diff --git a/oracle/src/pagetemplates.cpp b/oracle/src/pagetemplates.cpp index dcc8e070c..7e5f30bb7 100644 --- a/oracle/src/pagetemplates.cpp +++ b/oracle/src/pagetemplates.cpp @@ -1,5 +1,6 @@ #include "pagetemplates.h" +#include "database_writer.h" #include "oraclewizard.h" #include @@ -184,9 +185,17 @@ void SimpleDownloadFilePage::actDownloadFinished() } QNetworkReply::NetworkError errorCode = reply->error(); if (errorCode != QNetworkReply::NoError) { - QMessageBox::critical(this, tr("Error"), tr("Network error: %1.").arg(reply->errorString())); - wizard()->enableButtons(); + const QString message = tr("Network error: %1.").arg(reply->errorString()); reply->deleteLater(); + // A hidden wizard cannot show a modal box: without an explicit stop it would sit + // here forever and the hosting client would wait with no timeout. + if (wizard()->backgroundMode) { + qWarning() << message; + wizard()->reject(); + return; + } + QMessageBox::critical(this, tr("Error"), message); + wizard()->enableButtons(); return; } @@ -218,6 +227,15 @@ void SimpleDownloadFilePage::actDownloadFinished() bool SimpleDownloadFilePage::saveToFile() { + const auto failSave = [this](const QString &message) { + qWarning() << message; + if (wizard()->backgroundMode) { + wizard()->reject(); + } else { + QMessageBox::critical(this, tr("Error"), message); + } + return false; + }; QString defaultPath = getDefaultSavePath(); QString windowName = getWindowTitle(); QString fileType = getFileType(); @@ -236,12 +254,11 @@ bool SimpleDownloadFilePage::saveToFile() QFileInfo fi(fileName); QDir fileDir(fi.path()); if (!fileDir.exists() && !fileDir.mkpath(fileDir.absolutePath())) { - return false; + return failSave(tr("The database directory could not be created: %1").arg(fileDir.absolutePath())); } if (!internalSaveToFile(fileName)) { - QMessageBox::critical(this, tr("Error"), tr("The file could not be saved to %1").arg(fileName)); - return false; + return failSave(tr("The file could not be saved to %1").arg(fileName)); } // clean saved downloadData @@ -252,17 +269,10 @@ bool SimpleDownloadFilePage::saveToFile() bool SimpleDownloadFilePage::internalSaveToFile(const QString &fileName) { - QFile file(fileName); - if (!file.open(QIODevice::WriteOnly)) { - qDebug() << "File open (w) failed for" << fileName; + QString error; + if (!OracleDatabase::saveXml(fileName, downloadData, &error)) { + qWarning() << error; return false; } - - if (file.write(downloadData) == -1) { - qDebug() << "File write (w) failed for" << fileName; - return false; - } - - file.close(); return true; } diff --git a/oracle/src/tag_database.cpp b/oracle/src/tag_database.cpp new file mode 100644 index 000000000..b9c57a969 --- /dev/null +++ b/oracle/src/tag_database.cpp @@ -0,0 +1,643 @@ +#include "tag_database.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#ifdef HAS_LZMA +#include "lzma/decompress.h" +#endif + +#ifdef HAS_ZLIB +#include "zip/unzip.h" + +#include +#endif + +#include + +namespace +{ +using TagSets = QHash>; + +struct ParsedTag +{ + QString slug; + QStringList parentIds; + QList oracleIds; +}; + +struct ParseContext +{ + bool includeParentTags = false; + QHash tagsById; + TagSets oracleTags; + TagSets nameTags; +}; + +const QByteArray &gzipMagic() +{ + static const QByteArray magic = QByteArray::fromHex("1f8b"); + return magic; +} + +const QByteArray &xzMagic() +{ + static const QByteArray magic = QByteArray::fromHex("fd377a585a00"); + return magic; +} + +const QByteArray &zipMagic() +{ + static const QByteArray magic = QByteArray::fromHex("504b0304"); + return magic; +} + +QString normalizedSlug(const QString &slug) +{ + return slug.trimmed().toLower(); +} + +/** + * @brief Rejects slugs that cannot survive the space-separated `tags` property. + */ +bool isValidSlug(const QString &slug) +{ + if (slug.isEmpty()) { + return false; + } + for (const QChar &character : slug) { + if (character.isSpace()) { + return false; + } + } + return true; +} + +QString normalizedName(const QString &name) +{ + return name.trimmed().toLower(); +} + +/** + * @brief True when a map key has the shape of a Scryfall oracle id. + * + * A manifest with an explicit `key` removes the ambiguity for card names that + * happen to look like UUIDs. + */ +bool looksLikeOracleId(const QString &key) +{ + return !QUuid::fromString(key.trimmed()).isNull(); +} + +void addTags(TagSets &target, const QString &key, const QStringList &slugs) +{ + // Both key spaces are case-insensitive: Scryfall UUIDs are hex, and card + // names must match regardless of the source's capitalization. + const QString normalizedKey = key.trimmed().toLower(); + if (normalizedKey.isEmpty()) { + return; + } + + QSet &tags = target[normalizedKey]; + for (const QString &rawSlug : slugs) { + const QString slug = normalizedSlug(rawSlug); + if (isValidSlug(slug)) { + tags.insert(slug); + } + } + if (tags.isEmpty()) { + target.remove(normalizedKey); + } +} + +#ifdef HAS_ZLIB +bool decompressGzip(const QByteArray &input, QByteArray &output, QString *errorMessage) +{ + z_stream stream{}; + if (inflateInit2(&stream, 16 + MAX_WBITS) != Z_OK) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("Gzip support could not be initialized."); + } + return false; + } + + stream.next_in = reinterpret_cast(const_cast(input.constData())); + stream.avail_in = static_cast(input.size()); + + int result = Z_OK; + char buffer[64 * 1024]; + while (result == Z_OK) { + stream.next_out = reinterpret_cast(buffer); + stream.avail_out = sizeof(buffer); + result = inflate(&stream, Z_NO_FLUSH); + + const uInt produced = static_cast(sizeof(buffer)) - stream.avail_out; + if (produced > 0) { + output.append(buffer, static_cast(produced)); + } + + if (result != Z_OK && result != Z_STREAM_END) { + inflateEnd(&stream); + if (errorMessage) { + *errorMessage = TagDatabase::tr("Gzip decompression failed."); + } + return false; + } + if (produced == 0 && stream.avail_in == 0 && result == Z_OK) { + inflateEnd(&stream); + if (errorMessage) { + *errorMessage = TagDatabase::tr("The gzip stream is truncated."); + } + return false; + } + } + + inflateEnd(&stream); + return result == Z_STREAM_END; +} +#endif + +#ifdef HAS_LZMA +bool decompressXz(const QByteArray &input, QByteArray &output, QString *errorMessage) +{ + QByteArray inputCopy = input; + QBuffer inputBuffer(&inputCopy); + QBuffer outputBuffer(&output); + inputBuffer.open(QBuffer::ReadOnly); + outputBuffer.open(QBuffer::WriteOnly); + + XzDecompressor decompressor; + if (!decompressor.decompress(&inputBuffer, &outputBuffer)) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("Xz decompression failed."); + } + return false; + } + return true; +} +#endif + +#ifdef HAS_ZLIB +bool decompressZip(const QByteArray &input, QByteArray &output, QString *errorMessage) +{ + QByteArray inputCopy = input; + QBuffer inputBuffer(&inputCopy); + UnZip archive; + const UnZip::ErrorCode openError = archive.openArchive(&inputBuffer); + if (openError != UnZip::Ok) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("Failed to open the Zip archive: %1.").arg(archive.formatError(openError)); + } + return false; + } + + const QStringList files = archive.fileList(); + if (files.size() != 1) { + archive.closeArchive(); + if (errorMessage) { + *errorMessage = TagDatabase::tr("The Zip archive must contain exactly one tag database file."); + } + return false; + } + + QBuffer outputBuffer(&output); + outputBuffer.open(QBuffer::ReadWrite); + const UnZip::ErrorCode extractError = archive.extractFile(files.first(), &outputBuffer); + archive.closeArchive(); + if (extractError != UnZip::Ok) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("Zip extraction failed: %1.").arg(archive.formatError(extractError)); + } + return false; + } + return true; +} +#endif + +/** + * @brief Unwraps gzip/xz/zip payloads, including nested archives. + */ +bool decompressData(QByteArray &data, QString *errorMessage) +{ + // Nested compression is legitimate (a zip around a gzip), but a handful of + // layers is enough; the cap keeps a hostile file from looping forever. + for (int layer = 0; layer < 4; ++layer) { + if (data.startsWith(gzipMagic())) { +#ifdef HAS_ZLIB + QByteArray decompressed; + if (!decompressGzip(data, decompressed, errorMessage)) { + return false; + } + data = std::move(decompressed); + continue; +#else + if (errorMessage) { + *errorMessage = TagDatabase::tr("This version of Oracle does not support gzip compressed files."); + } + return false; +#endif + } + + if (data.startsWith(xzMagic())) { +#ifdef HAS_LZMA + QByteArray decompressed; + if (!decompressXz(data, decompressed, errorMessage)) { + return false; + } + data = std::move(decompressed); + continue; +#else + if (errorMessage) { + *errorMessage = TagDatabase::tr("This version of Oracle does not support xz compressed files."); + } + return false; +#endif + } + + if (data.startsWith(zipMagic())) { +#ifdef HAS_ZLIB + QByteArray decompressed; + if (!decompressZip(data, decompressed, errorMessage)) { + return false; + } + data = std::move(decompressed); + continue; +#else + if (errorMessage) { + *errorMessage = TagDatabase::tr("This version of Oracle does not support zipped files."); + } + return false; +#endif + } + + return true; + } + + if (errorMessage) { + *errorMessage = TagDatabase::tr("The tag database is compressed in too many layers."); + } + return false; +} + +bool parseKeyedMap(const QJsonObject &map, const QString &explicitKey, ParseContext &context, QString *errorMessage) +{ + if (map.isEmpty()) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("The tag database contains no tag entries."); + } + return false; + } + + for (auto it = map.constBegin(); it != map.constEnd(); ++it) { + if (!it.value().isArray()) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("The tag list for \"%1\" must be an array of slugs.").arg(it.key()); + } + return false; + } + + QStringList slugs; + for (const QJsonValue &value : it.value().toArray()) { + if (!value.isString()) { + if (errorMessage) { + *errorMessage = + TagDatabase::tr("The tag list for \"%1\" must contain only slug strings.").arg(it.key()); + } + return false; + } + slugs.append(value.toString()); + } + + const bool oracleKey = explicitKey.isEmpty() ? looksLikeOracleId(it.key()) + : (explicitKey == "oracle_id" || explicitKey == "oracleid"); + if (oracleKey) { + addTags(context.oracleTags, it.key().trimmed(), slugs); + } else { + addTags(context.nameTags, normalizedName(it.key()), slugs); + } + } + return true; +} + +bool parseTagRow(const QJsonObject &object, ParseContext &context) +{ + QStringList slugs; + for (const QJsonValue &value : object.value("tags").toArray()) { + if (value.isString()) { + slugs.append(value.toString()); + } + } + + const QString oracleId = object.value("oracle_id").toString().trimmed(); + if (!oracleId.isEmpty()) { + addTags(context.oracleTags, oracleId, slugs); + } + + const QString name = object.value("name").toString().trimmed(); + if (!name.isEmpty()) { + addTags(context.nameTags, normalizedName(name), slugs); + } + return true; +} + +bool parseTagObject(const QJsonObject &object, ParseContext &context) +{ + // Art and illustration tags describe a printing's artwork, not the oracle + // card, so they never belong in the card-level tag property. + const QString type = object.value("type").toString().trimmed().toLower(); + if (!type.isEmpty() && type != "oracle") { + return true; + } + + const QString slug = normalizedSlug(object.value("slug").toString()); + if (!isValidSlug(slug)) { + return true; + } + + QList oracleIds; + for (const QJsonValue &value : object.value("taggings").toArray()) { + const QString oracleId = value.toObject().value("oracle_id").toString().trimmed(); + if (!oracleId.isEmpty() && !oracleIds.contains(oracleId)) { + oracleIds.append(oracleId); + } + } + + const QString id = object.value("id").toString().trimmed(); + if (!context.includeParentTags || id.isEmpty()) { + for (const QString &oracleId : oracleIds) { + addTags(context.oracleTags, oracleId, QStringList{slug}); + } + return true; + } + + ParsedTag &tag = context.tagsById[id]; + if (tag.slug.isEmpty()) { + tag.slug = slug; + } + for (const QJsonValue &parent : object.value("parent_ids").toArray()) { + const QString parentId = parent.toString().trimmed(); + if (!parentId.isEmpty() && !tag.parentIds.contains(parentId)) { + tag.parentIds.append(parentId); + } + } + for (const QString &oracleId : oracleIds) { + if (!tag.oracleIds.contains(oracleId)) { + tag.oracleIds.append(oracleId); + } + } + return true; +} + +bool handleObject(const QJsonObject &object, ParseContext &context, QString *errorMessage) +{ + if (object.contains("slug")) { + return parseTagObject(object, context); + } + + const QJsonValue tagsValue = object.value("tags"); + if (tagsValue.isArray() && (object.contains("oracle_id") || object.contains("name"))) { + return parseTagRow(object, context); + } + if (tagsValue.isObject()) { + const QString key = object.value("key").toString().trimmed().toLower(); + return parseKeyedMap(tagsValue.toObject(), key, context, errorMessage); + } + + bool allArrays = !object.isEmpty(); + for (auto it = object.constBegin(); it != object.constEnd(); ++it) { + if (!it.value().isArray()) { + allArrays = false; + break; + } + } + if (allArrays) { + return parseKeyedMap(object, QString(), context, errorMessage); + } + + if (errorMessage) { + *errorMessage = TagDatabase::tr("Unrecognized tag database format."); + } + return false; +} + +bool parseJsonArray(const QJsonArray &array, ParseContext &context, QString *errorMessage) +{ + if (array.isEmpty()) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("The tag database contains no tag entries."); + } + return false; + } + + for (const QJsonValue &value : array) { + if (!value.isObject()) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("The tag database array must contain only objects."); + } + return false; + } + if (!handleObject(value.toObject(), context, errorMessage)) { + return false; + } + } + return true; +} + +bool parseJsonLines(const QByteArray &payload, ParseContext &context, QString *errorMessage) +{ + const QList lines = payload.split('\n'); + bool parsedAny = false; + + for (int index = 0; index < lines.size(); ++index) { + const QByteArray line = lines.at(index).trimmed(); + if (line.isEmpty()) { + continue; + } + + QJsonParseError lineError{}; + const QJsonDocument lineDocument = QJsonDocument::fromJson(line, &lineError); + if (lineError.error != QJsonParseError::NoError || !lineDocument.isObject()) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("Malformed tag database at line %1: %2") + .arg(index + 1) + .arg(lineError.errorString()); + } + return false; + } + if (!handleObject(lineDocument.object(), context, errorMessage)) { + return false; + } + parsedAny = true; + } + + if (!parsedAny) { + if (errorMessage) { + *errorMessage = TagDatabase::tr("The tag database contains no tag entries."); + } + return false; + } + return true; +} + +QHash sortedLists(const TagSets &sets) +{ + QHash result; + result.reserve(sets.size()); + for (auto it = sets.constBegin(); it != sets.constEnd(); ++it) { + if (it.value().isEmpty()) { + continue; + } + QStringList slugs = it.value().values(); + slugs.sort(); + result.insert(it.key(), slugs); + } + return result; +} + +} // namespace + +std::shared_ptr +TagDatabase::loadFromData(const QByteArray &data, const LoadOptions &options, QString *errorMessage) +{ + if (errorMessage) { + errorMessage->clear(); + } + + if (data.trimmed().isEmpty()) { + if (errorMessage) { + *errorMessage = tr("The tag database is empty."); + } + return nullptr; + } + + QByteArray payload = data; + if (!decompressData(payload, errorMessage)) { + return nullptr; + } + if (payload.trimmed().isEmpty()) { + if (errorMessage) { + *errorMessage = tr("The tag database is empty."); + } + return nullptr; + } + + ParseContext context; + context.includeParentTags = options.includeParentTags; + + QJsonParseError parseError{}; + const QJsonDocument document = QJsonDocument::fromJson(payload, &parseError); + if (parseError.error == QJsonParseError::NoError) { + if (document.isArray()) { + if (!parseJsonArray(document.array(), context, errorMessage)) { + return nullptr; + } + } else if (document.isObject()) { + if (!handleObject(document.object(), context, errorMessage)) { + return nullptr; + } + } else { + if (errorMessage) { + *errorMessage = tr("The tag database is not a JSON object or array."); + } + return nullptr; + } + } else if (payload.trimmed().startsWith('[')) { + // A broken array is not JSONL; report the parser error instead of + // blaming an arbitrary line. + if (errorMessage) { + *errorMessage = tr("Malformed tag database: %1").arg(parseError.errorString()); + } + return nullptr; + } else if (!parseJsonLines(payload, context, errorMessage)) { + return nullptr; + } + + if (context.includeParentTags) { + // Expand upward only after every tag is known: a parent object may + // appear after its children in the file. + for (auto it = context.tagsById.constBegin(); it != context.tagsById.constEnd(); ++it) { + QStringList slugs{it->slug}; + QSet visited; + QList pending = it->parentIds; + + while (!pending.isEmpty()) { + const QString parentId = pending.takeLast(); + if (visited.contains(parentId)) { + continue; + } + visited.insert(parentId); + + const auto parent = context.tagsById.constFind(parentId); + if (parent == context.tagsById.constEnd()) { + continue; + } + slugs.append(parent->slug); + pending.append(parent->parentIds); + } + + for (const QString &oracleId : it->oracleIds) { + addTags(context.oracleTags, oracleId, slugs); + } + } + } + + auto database = std::make_shared(); + database->tagsByOracleId = sortedLists(context.oracleTags); + database->tagsByName = sortedLists(context.nameTags); + + QSet distinctSlugs; + for (const QStringList &slugs : database->tagsByOracleId) { + for (const QString &slug : slugs) { + distinctSlugs.insert(slug); + } + } + for (const QStringList &slugs : database->tagsByName) { + for (const QString &slug : slugs) { + distinctSlugs.insert(slug); + } + } + database->distinctTagCount = distinctSlugs.size(); + + if (database->isEmpty()) { + if (errorMessage) { + *errorMessage = tr("The tag database contains no usable tags."); + } + return nullptr; + } + return database; +} + +QStringList TagDatabase::tagsForCard(const QString &oracleId, const QString &name) const +{ + QStringList result; + if (!oracleId.isEmpty()) { + result = tagsByOracleId.value(oracleId.trimmed().toLower()); + } + + const QString normalizedCardName = normalizedName(name); + if (!normalizedCardName.isEmpty()) { + const QStringList byName = tagsByName.value(normalizedCardName); + for (const QString &slug : byName) { + if (!result.contains(slug)) { + result.append(slug); + } + } + } + + if (!result.isEmpty()) { + result.sort(); + } + return result; +} + +bool TagDatabase::isEmpty() const +{ + return tagsByOracleId.isEmpty() && tagsByName.isEmpty(); +} diff --git a/oracle/src/tag_database.h b/oracle/src/tag_database.h new file mode 100644 index 000000000..5874000f7 --- /dev/null +++ b/oracle/src/tag_database.h @@ -0,0 +1,69 @@ +#ifndef TAG_DATABASE_H +#define TAG_DATABASE_H + +#include +#include +#include +#include +#include +#include + +/** + * @brief Tag slugs keyed by Scryfall oracle id and/or card name. + * + * Loaded from Scryfall's Oracle Tags bulk data or from a user-supplied tag + * database. The data is import-time only: Oracle applies the slugs to the + * `tags` card property before writing cards.xml, so the client needs no + * tag-specific code. + * + * Accepted inputs, auto-detected: + * - Scryfall tag objects, either a JSON array or one object per JSONL line; + * - a flat map of `oracle_id` (or card name) to an array of slugs; + * - a manifest object `{"key": "oracle_id"|"name", "tags": { ... }}`; + * - JSONL rows `{"oracle_id": "...", "tags": [...]}` or the `name` equivalent. + * + * Gzip, xz and zip inputs are decompressed transparently when the build has + * the matching library. Scryfall serves its bulk data as gzipped JSONL. + */ +class TagDatabase +{ + Q_DECLARE_TR_FUNCTIONS(TagDatabase) +public: + struct LoadOptions + { + bool includeParentTags = false; + }; + + /** + * @brief Parses and decompresses a tag database. + * @return The database, or nullptr with @p errorMessage set on failure. + */ + static std::shared_ptr + loadFromData(const QByteArray &data, const LoadOptions &options, QString *errorMessage = nullptr); + + /** + * @brief Returns the sorted slugs for one card. + * + * Oracle ids are the primary key. A name-keyed database is consulted as a + * fallback, case-insensitively. An empty list means the card has no tags. + */ + [[nodiscard]] QStringList tagsForCard(const QString &oracleId, const QString &name) const; + [[nodiscard]] bool isEmpty() const; + /** @brief Number of distinct slugs in the database. */ + [[nodiscard]] int tagCount() const + { + return distinctTagCount; + } + /** @brief Number of cards that received at least one tag. */ + [[nodiscard]] int taggedCardCount() const + { + return tagsByOracleId.size() + tagsByName.size(); + } + +private: + QHash tagsByOracleId; + QHash tagsByName; ///< keys are lowercased + int distinctTagCount = 0; +}; + +#endif // TAG_DATABASE_H diff --git a/oracle/translations/oracle_de.ts b/oracle/translations/oracle_de.ts index faf08d5ce..50a5f8e20 100644 --- a/oracle/translations/oracle_de.ts +++ b/oracle/translations/oracle_de.ts @@ -137,22 +137,22 @@ Downloading (0 MB) - + Herunterladen (0 MB) Downloading (%1 MB) - + Herunterladen (%1 MB) Parsing file (%1%) - + Datei wird verarbeitet (%1%) Extracting file... - + Extrahiere Datei... @@ -168,7 +168,7 @@ Parsing file (100%) - + Datei wird verarbeitet (100%) @@ -348,7 +348,7 @@ A Cockatrice card database file of %1 MB has been downloaded. - + Eine Cockatrice-Kartendatenbankdatei der Größe %1 MB wurde heruntergeladen. @@ -434,7 +434,7 @@ Downloading (0 MB) Downloading (0MB) - + Herunterladen (0 MB) diff --git a/servatrice/check_schema_version.sh b/servatrice/check_schema_version.sh index c4aadf356..8b645b5d2 100755 --- a/servatrice/check_schema_version.sh +++ b/servatrice/check_schema_version.sh @@ -10,7 +10,7 @@ declare -i schema_ver="${version_line%%)*}" latest_migration="$(ls -1 servatrice/migrations/ | tail -n1)" xtoysql="${latest_migration#servatrice_}" xtoy="${xtoysql%.sql}" -declare -i old_ver="10#${xtoy%_to_*}" #declare as integer with base 10, numbers with a leading 0 are normally interpreted as base 16 +declare -i old_ver="10#${xtoy%_to_*}" # declare as integer with base 10, numbers with a leading 0 are normally interpreted as base 16 declare -i new_ver="10#${xtoy#*_to_}" if ((old_ver >= new_ver)); then diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index a4f184e38..1327c85d5 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -34,6 +34,7 @@ #include #include #include +#include #include #include #include @@ -1977,7 +1978,72 @@ QList Servatrice_DatabaseInterface::getUserSessions(cons return results; } -QList Servatrice_DatabaseInterface::getUserAlts(const QString &userName) +namespace +{ +constexpr int MAX_USER_ALT_DEPTH = 3; +constexpr int MAX_USER_ALTS_PER_LEVEL = 200; +// Bounds per-level work and the size of each dynamic IN-list. +// Identifier-heavy frontiers, e.g. VPN users with hundreds of recent IPs, are +// truncated: this tool is deliberately a first-pass sweep, not an audit. +constexpr int MAX_USER_ALT_IDENTIFIERS = 200; +constexpr int MIN_USER_ALT_MONTHS = 1; +constexpr int MAX_USER_ALT_MONTHS = 24; + +QString altInClause(const QString &prefix, const QList &values) +{ + QStringList placeholders; + placeholders.reserve(values.size()); + for (int i = 0; i < values.size(); ++i) { + placeholders << prefix + QString::number(i); + } + return QStringLiteral("(") + placeholders.join(QStringLiteral(",")) + QStringLiteral(")"); +} + +void altBindValues(QSqlQuery *query, const QString &prefix, const QList &values) +{ + for (int i = 0; i < values.size(); ++i) { + query->bindValue(prefix + QString::number(i), values.at(i)); + } +} + +struct AltCandidate +{ + QString name; + QString email; + QString clientId; + qint64 registrationTime = 0; + qint64 lastLogin = 0; + bool hasLastLogin = false; + int warnCount = 0; + int banCount = 0; + bool isActive = false; +}; + +// Keeps the first-seen order: callers add the strongest evidence first so the +// cap below drops the least relevant identifiers rather than an arbitrary slice. +void appendIdentifier(QList &list, QSet &seen, const QString &value) +{ + if (!value.isEmpty() && !seen.contains(value)) { + seen.insert(value); + list << value; + } +} + +QList cappedIdentifiers(const QList &values) +{ + if (values.size() <= MAX_USER_ALT_IDENTIFIERS) { + return values; + } + return values.mid(0, MAX_USER_ALT_IDENTIFIERS); +} +} // namespace + +QList Servatrice_DatabaseInterface::getUserAlts(const QString &userName, + int depth, + bool useEmail, + bool useClientId, + bool useIp, + int sessionMonths) { QList results; @@ -1985,63 +2051,394 @@ QList Servatrice_DatabaseInterface::getUserAlts(const QStrin return results; } - // Seed account identifiers used to find related accounts - QSqlQuery *seedQuery = prepareQuery("SELECT email, clientid FROM {prefix}_users WHERE name = :user_name"); + if (depth < 1) { + depth = 1; + } else if (depth > MAX_USER_ALT_DEPTH) { + depth = MAX_USER_ALT_DEPTH; + } + if (sessionMonths < MIN_USER_ALT_MONTHS) { + sessionMonths = MIN_USER_ALT_MONTHS; + } else if (sessionMonths > MAX_USER_ALT_MONTHS) { + sessionMonths = MAX_USER_ALT_MONTHS; + } + + // Interpolated, not bound: placeholders are not allowed in INTERVAL + // expressions, and the clamped integer cannot inject SQL. + const QString sessionWindow = QStringLiteral(" AND start_time >= DATE_SUB(NOW(), INTERVAL ") + + QString::number(sessionMonths) + QStringLiteral(" MONTH)"); + + // Seed must exist; its row anchors the result at depth 0. + QSqlQuery *seedQuery = prepareQuery("SELECT u.name, u.email, u.clientid, UNIX_TIMESTAMP(u.registrationDate), " + "UNIX_TIMESTAMP(a.last_login), " + "(SELECT COUNT(*) FROM {prefix}_warnings w WHERE w.user_id = u.id), " + "(SELECT COUNT(*) FROM {prefix}_bans b WHERE b.user_name = u.name), " + "u.active " + "FROM {prefix}_users u " + "LEFT JOIN {prefix}_user_analytics a ON a.id = u.id " + "WHERE u.name = :user_name"); seedQuery->bindValue(":user_name", userName); if (!execSqlQuery(seedQuery) || !seedQuery->next()) { return results; } - const QString seedEmail = seedQuery->value(0).toString(); - const QString seedClientId = seedQuery->value(1).toString(); - QString queryString = "SELECT u.name, u.email, u.clientid, UNIX_TIMESTAMP(u.registrationDate), " - "UNIX_TIMESTAMP(a.last_login), " - "(SELECT COUNT(*) FROM {prefix}_warnings w WHERE w.user_id = u.id), " - "(SELECT COUNT(*) FROM {prefix}_bans b WHERE b.user_name = u.name), " - "u.active " - "FROM {prefix}_users u " - "LEFT JOIN {prefix}_user_analytics a ON a.id = u.id " - "WHERE u.name = :user_name"; - if (!seedEmail.isEmpty()) { - queryString.append(" OR u.email = :seed_email"); - } - if (!seedClientId.isEmpty()) { - queryString.append(" OR u.clientid = :seed_clientid"); - } - queryString.append(" OR u.name IN (SELECT DISTINCT s.user_name FROM {prefix}_sessions s " - "WHERE s.ip_address IN (SELECT DISTINCT s2.ip_address FROM {prefix}_sessions s2 " - "WHERE s2.user_name = :user_name" - " AND s2.start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH))" - " AND s.start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH)) " - "ORDER BY u.name LIMIT 200"); - - QSqlQuery *query = prepareQuery(queryString); - query->bindValue(":user_name", userName); - if (!seedEmail.isEmpty()) { - query->bindValue(":seed_email", seedEmail); - } - if (!seedClientId.isEmpty()) { - query->bindValue(":seed_clientid", seedClientId); - } - - if (!execSqlQuery(query)) { - qCWarning(DatabaseInterfaceLog) << "Failed to collect user alt information: SQL Error"; - return results; - } - - while (query->next()) { + auto appendAlt = [&results](const AltCandidate &candidate, const QString &matchReason, int matchDepth, + int sharedCount) { ServerInfo_UserAlt altDetails; - altDetails.set_user_name(query->value(0).toString().toStdString()); - altDetails.set_email(query->value(1).toString().toStdString()); - altDetails.set_clientid(query->value(2).toString().toStdString()); - altDetails.set_registration_time(query->value(3).toLongLong()); - if (!query->value(4).isNull()) { - altDetails.set_last_login(query->value(4).toLongLong()); + altDetails.set_user_name(candidate.name.toStdString()); + altDetails.set_email(candidate.email.toStdString()); + altDetails.set_clientid(candidate.clientId.toStdString()); + altDetails.set_registration_time(candidate.registrationTime); + if (candidate.hasLastLogin) { + altDetails.set_last_login(candidate.lastLogin); } - altDetails.set_warn_count(query->value(5).toInt()); - altDetails.set_ban_count(query->value(6).toInt()); - altDetails.set_is_active(query->value(7).toBool()); + altDetails.set_warn_count(candidate.warnCount); + altDetails.set_ban_count(candidate.banCount); + altDetails.set_is_active(candidate.isActive); + altDetails.set_match_reason(matchReason.toStdString()); + altDetails.set_depth(static_cast(matchDepth)); + altDetails.set_shared_count(static_cast(sharedCount)); results << altDetails; + }; + + AltCandidate seed{.name = seedQuery->value(0).toString(), + .email = seedQuery->value(1).toString(), + .clientId = seedQuery->value(2).toString(), + .registrationTime = seedQuery->value(3).toLongLong(), + .lastLogin = seedQuery->value(4).toLongLong(), + .hasLastLogin = !seedQuery->value(4).isNull(), + .warnCount = seedQuery->value(5).toInt(), + .banCount = seedQuery->value(6).toInt(), + .isActive = seedQuery->value(7).toBool()}; + appendAlt(seed, QStringLiteral("self"), 0, 1); + + // Identifier lookups are case-insensitive in SQL, so the seed must be keyed by + // its stored casing: otherwise the level-1 query re-lists it as an alt. + QSet visited{seed.name}; + QList frontier{seed.name}; + + // Dynamic IN-lists bypass prepareQuery(): it caches by query text, so every + // distinct list size would leave a prepared statement in the long-lived cache + // and can exhaust MySQL's max_prepared_stmt_count. + const QString dbPrefix = server->getDbPrefix(); + auto prepareDynamicQuery = [&dbPrefix](QSqlQuery &query, const QString &queryText) { + QString text = queryText; + text.replace(QStringLiteral("{prefix}"), dbPrefix); + query.prepare(text); + }; + + for (int level = 1; level <= depth && !frontier.isEmpty(); ++level) { + const QString frontierClause = altInClause(QStringLiteral(":alt_name"), frontier); + + // Current identifiers of the frontier: registration email + client id. + // Kept strongest-first so cappedIdentifiers() drops the least useful ones. + QList emails; + QList clientIds; + QList ips; + QSet emailKeys; + QSet clientIdKeys; + QSet ipKeys; + + if (useEmail || useClientId) { + QSqlQuery frontierQuery(sqlDatabase); + prepareDynamicQuery(frontierQuery, + QStringLiteral("SELECT email, clientid FROM {prefix}_users WHERE name IN ") + + frontierClause); + altBindValues(&frontierQuery, QStringLiteral(":alt_name"), frontier); + if (execSqlQuery(&frontierQuery)) { + while (frontierQuery.next()) { + // Email comparison is case-insensitive in SQL, so normalise the + // keys too or the membership tests below miss real matches. + if (useEmail) { + appendIdentifier(emails, emailKeys, frontierQuery.value(0).toString().toLower()); + } + if (useClientId) { + appendIdentifier(clientIds, clientIdKeys, frontierQuery.value(1).toString()); + } + } + } + } + + // Recent session identifiers: IPs plus historic client ids, most recent + // first so a capped list keeps what the account actually used lately. + if (useIp || useClientId) { + QSqlQuery sessionQuery(sqlDatabase); + prepareDynamicQuery(sessionQuery, + QStringLiteral("SELECT ip_address, clientid, MAX(start_time) AS last_seen " + "FROM {prefix}_sessions WHERE user_name IN ") + + frontierClause + sessionWindow + + QStringLiteral(" GROUP BY ip_address, clientid " + "ORDER BY last_seen DESC")); + altBindValues(&sessionQuery, QStringLiteral(":alt_name"), frontier); + if (execSqlQuery(&sessionQuery)) { + while (sessionQuery.next()) { + if (useIp) { + appendIdentifier(ips, ipKeys, sessionQuery.value(0).toString()); + } + if (useClientId) { + appendIdentifier(clientIds, clientIdKeys, sessionQuery.value(1).toString()); + } + } + } + } + + if (emails.isEmpty() && clientIds.isEmpty() && ips.isEmpty()) { + break; + } + + // Accounts sharing any frontier identifier. Historic client ids are matched + // through past sessions so identifier changes do not hide older alts. + QString candidateString = + QStringLiteral("SELECT u.name, u.email, u.clientid, UNIX_TIMESTAMP(u.registrationDate), " + "UNIX_TIMESTAMP(a.last_login), " + "(SELECT COUNT(*) FROM {prefix}_warnings w WHERE w.user_id = u.id), " + "(SELECT COUNT(*) FROM {prefix}_bans b WHERE b.user_name = u.name), " + "u.active " + "FROM {prefix}_users u " + "LEFT JOIN {prefix}_user_analytics a ON a.id = u.id " + "WHERE "); + QStringList conditions; + const QList emailList = cappedIdentifiers(emails); + const QList clientIdList = cappedIdentifiers(clientIds); + const QList ipList = cappedIdentifiers(ips); + if (!emailList.isEmpty()) { + conditions << QStringLiteral("u.email IN ") + altInClause(QStringLiteral(":alt_email"), emailList); + } + if (!clientIdList.isEmpty()) { + conditions << QStringLiteral("u.clientid IN ") + altInClause(QStringLiteral(":alt_cid"), clientIdList); + } + if (!ipList.isEmpty()) { + conditions << QStringLiteral("u.name IN (SELECT DISTINCT s.user_name FROM {prefix}_sessions s WHERE " + "s.ip_address IN ") + + altInClause(QStringLiteral(":alt_ip"), ipList) + sessionWindow + QStringLiteral(")"); + } + if (!clientIdList.isEmpty()) { + conditions << QStringLiteral("u.name IN (SELECT DISTINCT s.user_name FROM {prefix}_sessions s WHERE " + "s.clientid IN ") + + altInClause(QStringLiteral(":alt_hcid"), clientIdList) + sessionWindow + + QStringLiteral(")"); + } + candidateString += conditions.join(QStringLiteral(" OR ")); + // Already-visited accounts match their own identifiers at every level and + // are dropped below, so widen the row limit by that count: otherwise a + // second level can return only visited rows and no new candidates. + candidateString += + QStringLiteral(" ORDER BY u.name LIMIT ") + QString::number(MAX_USER_ALTS_PER_LEVEL + visited.size()); + + QSqlQuery candidateQuery(sqlDatabase); + prepareDynamicQuery(candidateQuery, candidateString); + altBindValues(&candidateQuery, QStringLiteral(":alt_email"), emailList); + altBindValues(&candidateQuery, QStringLiteral(":alt_cid"), clientIdList); + altBindValues(&candidateQuery, QStringLiteral(":alt_ip"), ipList); + altBindValues(&candidateQuery, QStringLiteral(":alt_hcid"), clientIdList); + if (!execSqlQuery(&candidateQuery)) { + qCWarning(DatabaseInterfaceLog) << "Failed to collect user alt information: SQL Error"; + return results; + } + + QList candidates; + while (candidateQuery.next()) { + AltCandidate candidate; + candidate.name = candidateQuery.value(0).toString(); + if (visited.contains(candidate.name)) { + continue; + } + candidate.email = candidateQuery.value(1).toString(); + candidate.clientId = candidateQuery.value(2).toString(); + candidate.registrationTime = candidateQuery.value(3).toLongLong(); + candidate.hasLastLogin = !candidateQuery.value(4).isNull(); + candidate.lastLogin = candidateQuery.value(4).toLongLong(); + candidate.warnCount = candidateQuery.value(5).toInt(); + candidate.banCount = candidateQuery.value(6).toInt(); + candidate.isActive = candidateQuery.value(7).toBool(); + candidates << candidate; + if (candidates.size() >= MAX_USER_ALTS_PER_LEVEL) { + break; + } + } + + // Resolve session-level evidence (shared IP or historic client id) for + // candidates that do not match on current email or client id. + QMap candidateIp; + QMap candidateHistoricClientId; + QList unexplained; + for (const AltCandidate &candidate : candidates) { + const bool emailMatch = !candidate.email.isEmpty() && emailKeys.contains(candidate.email.toLower()); + const bool clientIdMatch = !candidate.clientId.isEmpty() && clientIdKeys.contains(candidate.clientId); + if (!emailMatch && !clientIdMatch) { + unexplained << candidate.name; + } + } + if (!unexplained.isEmpty() && (!ipKeys.isEmpty() || !clientIdKeys.isEmpty())) { + QString evidenceString = + QStringLiteral("SELECT DISTINCT user_name, ip_address, clientid FROM {prefix}_sessions WHERE " + "user_name IN ") + + altInClause(QStringLiteral(":alt_ev"), unexplained) + QStringLiteral(" AND ("); + QStringList evidenceConditions; + if (!ipList.isEmpty()) { + evidenceConditions << QStringLiteral("ip_address IN ") + + altInClause(QStringLiteral(":alt_eip"), ipList); + } + if (!clientIdList.isEmpty()) { + evidenceConditions << QStringLiteral("clientid IN ") + + altInClause(QStringLiteral(":alt_ecid"), clientIdList); + } + evidenceString += evidenceConditions.join(QStringLiteral(" OR ")); + evidenceString += QStringLiteral(")") + sessionWindow; + + QSqlQuery evidenceQuery(sqlDatabase); + prepareDynamicQuery(evidenceQuery, evidenceString); + altBindValues(&evidenceQuery, QStringLiteral(":alt_ev"), unexplained); + altBindValues(&evidenceQuery, QStringLiteral(":alt_eip"), ipList); + altBindValues(&evidenceQuery, QStringLiteral(":alt_ecid"), clientIdList); + if (execSqlQuery(&evidenceQuery)) { + while (evidenceQuery.next()) { + const QString name = evidenceQuery.value(0).toString(); + const QString ip = evidenceQuery.value(1).toString(); + const QString clientId = evidenceQuery.value(2).toString(); + if (!ip.isEmpty() && ipKeys.contains(ip) && !candidateIp.contains(name)) { + candidateIp.insert(name, ip); + } + if (!clientId.isEmpty() && clientIdKeys.contains(clientId) && + !candidateHistoricClientId.contains(name)) { + candidateHistoricClientId.insert(name, clientId); + } + } + } + } + + // Fan-out counts so moderators can spot VPN/shared-network false positives. + QMap emailCounts; + QMap clientIdCounts; + QMap historicClientIdCounts; + QMap ipCounts; + QSet neededEmails; + QSet neededClientIds; + QSet neededHistoricClientIds; + QSet neededIps; + for (const AltCandidate &candidate : candidates) { + if (!candidate.email.isEmpty() && emailKeys.contains(candidate.email.toLower())) { + neededEmails << candidate.email.toLower(); + } else if (!candidate.clientId.isEmpty() && clientIdKeys.contains(candidate.clientId)) { + neededClientIds << candidate.clientId; + } else if (candidateHistoricClientId.contains(candidate.name)) { + neededHistoricClientIds << candidateHistoricClientId.value(candidate.name); + } else if (candidateIp.contains(candidate.name)) { + neededIps << candidateIp.value(candidate.name); + } + } + if (!neededEmails.isEmpty()) { + const QList neededEmailList = neededEmails.values(); + QSqlQuery countQuery(sqlDatabase); + prepareDynamicQuery(countQuery, QStringLiteral("SELECT email, COUNT(*) FROM {prefix}_users WHERE " + "email IN ") + + altInClause(QStringLiteral(":alt_ce"), neededEmailList) + + QStringLiteral(" GROUP BY email")); + altBindValues(&countQuery, QStringLiteral(":alt_ce"), neededEmailList); + if (execSqlQuery(&countQuery)) { + while (countQuery.next()) { + emailCounts.insert(countQuery.value(0).toString().toLower(), countQuery.value(1).toInt()); + } + } + } + if (!neededClientIds.isEmpty()) { + const QList neededClientIdList = neededClientIds.values(); + QSqlQuery countQuery(sqlDatabase); + prepareDynamicQuery(countQuery, QStringLiteral("SELECT clientid, COUNT(*) FROM {prefix}_users WHERE " + "clientid IN ") + + altInClause(QStringLiteral(":alt_cc"), neededClientIdList) + + QStringLiteral(" GROUP BY clientid")); + altBindValues(&countQuery, QStringLiteral(":alt_cc"), neededClientIdList); + if (execSqlQuery(&countQuery)) { + while (countQuery.next()) { + clientIdCounts.insert(countQuery.value(0).toString(), countQuery.value(1).toInt()); + } + } + } + // Historic client ids are matched through sessions, so their fan-out must + // come from _sessions too: _users only knows each account's current id. + if (!neededHistoricClientIds.isEmpty()) { + const QList neededHistoricClientIdList = neededHistoricClientIds.values(); + QSqlQuery countQuery(sqlDatabase); + prepareDynamicQuery(countQuery, + QStringLiteral("SELECT clientid, COUNT(DISTINCT user_name) FROM {prefix}_sessions " + "WHERE clientid IN ") + + altInClause(QStringLiteral(":alt_hcc"), neededHistoricClientIdList) + + QStringLiteral(" AND start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH) " + "GROUP BY clientid")); + altBindValues(&countQuery, QStringLiteral(":alt_hcc"), neededHistoricClientIdList); + if (execSqlQuery(&countQuery)) { + while (countQuery.next()) { + historicClientIdCounts.insert(countQuery.value(0).toString(), countQuery.value(1).toInt()); + } + } + } + if (!neededIps.isEmpty()) { + const QList neededIpList = neededIps.values(); + QSqlQuery countQuery(sqlDatabase); + prepareDynamicQuery(countQuery, + QStringLiteral("SELECT ip_address, COUNT(DISTINCT user_name) FROM {prefix}_sessions " + "WHERE ip_address IN ") + + altInClause(QStringLiteral(":alt_ci"), neededIpList) + sessionWindow + + QStringLiteral(" GROUP BY ip_address")); + altBindValues(&countQuery, QStringLiteral(":alt_ci"), neededIpList); + if (execSqlQuery(&countQuery)) { + while (countQuery.next()) { + ipCounts.insert(countQuery.value(0).toString(), countQuery.value(1).toInt()); + } + } + } + + QList nextFrontier; + for (const AltCandidate &candidate : candidates) { + QString matchReason; + int sharedCount = 1; + if (!candidate.email.isEmpty() && emailKeys.contains(candidate.email.toLower())) { + matchReason = QStringLiteral("email"); + sharedCount = qMax(1, emailCounts.value(candidate.email.toLower(), 1)); + } else if (!candidate.clientId.isEmpty() && clientIdKeys.contains(candidate.clientId)) { + matchReason = QStringLiteral("clientid:") + candidate.clientId; + sharedCount = qMax(1, clientIdCounts.value(candidate.clientId, 1)); + } else if (candidateHistoricClientId.contains(candidate.name)) { + const QString historicId = candidateHistoricClientId.value(candidate.name); + matchReason = QStringLiteral("clientid:") + historicId; + sharedCount = qMax(1, historicClientIdCounts.value(historicId, 1)); + } else if (candidateIp.contains(candidate.name)) { + const QString ip = candidateIp.value(candidate.name); + matchReason = QStringLiteral("ip:") + ip; + sharedCount = qMax(1, ipCounts.value(ip, 1)); + } else { + continue; + } + appendAlt(candidate, matchReason, level, sharedCount); + visited.insert(candidate.name); + nextFrontier << candidate.name; + } + frontier = nextFrontier; + } + + // Distinct recent IP counts per account: cheap group signal without + // shipping full address histories to the client. + if (!results.isEmpty()) { + QList resultNames; + resultNames.reserve(results.size()); + for (const ServerInfo_UserAlt &alt : results) { + resultNames << QString::fromStdString(alt.user_name()); + } + QSqlQuery *ipCountQuery = prepareQuery( + QStringLiteral("SELECT user_name, COUNT(DISTINCT ip_address) FROM {prefix}_sessions WHERE user_name IN ") + + altInClause(QStringLiteral(":alt_ic"), resultNames) + sessionWindow + + QStringLiteral(" GROUP BY user_name")); + altBindValues(ipCountQuery, QStringLiteral(":alt_ic"), resultNames); + QMap ipCountsByUser; + if (execSqlQuery(ipCountQuery)) { + while (ipCountQuery->next()) { + ipCountsByUser.insert(ipCountQuery->value(0).toString(), ipCountQuery->value(1).toInt()); + } + } + for (ServerInfo_UserAlt &alt : results) { + alt.set_ip_count(static_cast( + ipCountsByUser.value(QString::fromStdString(alt.user_name()), 0))); + } } return results; diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index eb74063ac..c3cc57ab2 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -224,7 +224,12 @@ public: const QString &beforeTime = QString(), bool *hasMore = nullptr); QList getUserSessions(const QString &userName, int limit); - QList getUserAlts(const QString &userName); + QList getUserAlts(const QString &userName, + int depth = 1, + bool useEmail = true, + bool useClientId = true, + bool useIp = true, + int sessionMonths = 6); QList getModeratorLastLogins(); // Uptime snapshot as recorded by Servatrice::statusUpdate() into the diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index c38d790b1..c32ccad49 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -2595,7 +2595,10 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetUserAlts(const Comma } Response_UserAlts *re = new Response_UserAlts; - const QList alts = sqlInterface->getUserAlts(userName); + const int depth = cmd.has_depth() ? static_cast(cmd.depth()) : 1; + const int sessionMonths = cmd.has_session_months() ? static_cast(cmd.session_months()) : 6; + const QList alts = sqlInterface->getUserAlts( + userName, depth, cmd.include_email(), cmd.include_clientid(), cmd.include_ip(), sessionMonths); for (const ServerInfo_UserAlt &alt : alts) { re->add_alts()->CopyFrom(alt); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0bb9654a1..ae90ee896 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -171,13 +171,37 @@ endfunction() # --- Unit tests ------------------------------------------------------------------ +cockatrice_add_test(NAME image_request_queue_test SOURCES image_request_queue_test.cpp) +cockatrice_add_test(NAME image_redirect_chain_test SOURCES image_redirect_chain_test.cpp) cockatrice_add_test(NAME expression_test SOURCES expression_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME clamped_arithmetic_test SOURCES clamped_arithmetic_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME test_age_formatting SOURCES test_age_formatting.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME user_data_backup_test SOURCES user_data_backup_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME password_hash_test SOURCES password_hash_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME report_categories_test SOURCES report_categories_test.cpp LIBS libcockatrice_utility) +cockatrice_add_test(NAME svg_recolor_test SOURCES svg_recolor_test.cpp LIBS libcockatrice_utility) cockatrice_add_test(NAME warning_categories_test SOURCES warning_categories_test.cpp LIBS libcockatrice_utility) +cockatrice_add_test( + NAME + user_games_model_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/server/user/user_games_model.cpp + user_games_model_test.cpp + LIBS + libcockatrice_network_client_abstract + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src + ${CMAKE_SOURCE_DIR}/libcockatrice_network +) +cockatrice_add_test( + NAME + spoiler_file_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/card_spoiler/spoiler_file.cpp + spoiler_file_test.cpp + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src +) # The installer's holder probe is Windows-only, but the wording it produces and the # question which programs are the payload's own are plain C++ and are what a stuck user # has to act on, so they are tested everywhere. @@ -208,6 +232,18 @@ cockatrice_add_test( libcockatrice_deck_list libcockatrice_utility ) +cockatrice_add_test( + NAME + card_art_crop_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/cards/card_art_utils.cpp + ${VERSION_STRING_CPP} + card_art_crop_test.cpp + LIBS + libcockatrice_card + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src +) cockatrice_add_test( NAME server_rate_limiter_test @@ -266,6 +302,7 @@ cockatrice_add_test( NAME server_card_counter_test SOURCES + ${VERSION_STRING_CPP} server_card_counter_test.cpp LIBS libcockatrice_network @@ -276,6 +313,7 @@ cockatrice_add_test( NAME server_dump_zone_test SOURCES + ${VERSION_STRING_CPP} server_dump_zone_test.cpp LIBS libcockatrice_rng @@ -287,6 +325,7 @@ cockatrice_add_test( NAME server_counter_test SOURCES + ${VERSION_STRING_CPP} server_counter_test.cpp LIBS libcockatrice_network @@ -298,6 +337,7 @@ cockatrice_add_test( server_developer_role_test NO_SANITIZE SOURCES + ${VERSION_STRING_CPP} server_developer_role_test.cpp LIBS libcockatrice_network @@ -310,6 +350,7 @@ cockatrice_add_test( server_moderator_role_test NO_SANITIZE SOURCES + ${VERSION_STRING_CPP} server_moderator_role_test.cpp LIBS libcockatrice_network @@ -317,6 +358,17 @@ cockatrice_add_test( LABELS server ) +cockatrice_add_test( + NAME + random_reveal_test + SOURCES + random_reveal_test.cpp + LIBS + libcockatrice_network_server_remote + libcockatrice_rng + LABELS + server +) cockatrice_add_test( NAME server_game_join_test @@ -356,6 +408,7 @@ cockatrice_add_test( NAME local_client_lifetime_test SOURCES + ${VERSION_STRING_CPP} local_client_lifetime_test.cpp LIBS libcockatrice_network @@ -377,6 +430,7 @@ cockatrice_add_test( NAME latency_tracker_test SOURCES + ${VERSION_STRING_CPP} latency_tracker_test.cpp LIBS libcockatrice_network @@ -414,18 +468,35 @@ cockatrice_add_test( ) cockatrice_add_test( NAME - loader_local_matching_test + font_config_test SOURCES - ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp - ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp - ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp - ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp - ${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp - ${VERSION_STRING_CPP} - loader_local_matching_test.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_config.cpp + font_config_test.cpp + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src +) +cockatrice_add_test( + NAME + game_creation_options_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/game_hosting/game_creation_options.cpp + game_creation_options_test.cpp LIBS - libcockatrice_network - libcockatrice_settings + libcockatrice_interfaces + libcockatrice_protocol + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src +) +cockatrice_add_test( + NAME + create_game_form_widget_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/game_hosting/create_game_form_widget.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/game_hosting/game_creation_options.cpp + create_game_form_widget_test.cpp + LIBS + libcockatrice_interfaces + libcockatrice_protocol INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/cockatrice/src ) @@ -480,6 +551,19 @@ endif() # Guard must match the condition for src/single_instance_manager.cpp in cockatrice/CMakeLists.txt. if(WITH_CLIENT) + cockatrice_add_test( + NAME + moxfield_signature_spells_test + SOURCES + ${VERSION_STRING_CPP} + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/deck_loader/card_node_function.cpp + moxfield_signature_spells_test.cpp + LIBS + libcockatrice_card + libcockatrice_deck_list + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src + ) cockatrice_add_test( NAME single_instance_manager_test @@ -527,6 +611,7 @@ add_subdirectory(card_zone_algorithms) add_subdirectory(carddatabase) add_subdirectory(deck_list_model) add_subdirectory(deck_list_zones) +add_subdirectory(icon_assets) add_subdirectory(loading_from_clipboard) add_subdirectory(model_contract) add_subdirectory(movecard_tests) @@ -548,3 +633,46 @@ endif() if(WITH_ORACLE OR WITH_CLIENT) add_subdirectory(picture_loader_benchmark) endif() + +# loader_local_matching_test and deck_state_manager_test links libcockatrice_settings, +# which only exists when a client/UI-capable target is being built. +if(WITH_ORACLE OR WITH_CLIENT) + set(SETTINGS_SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp + ${VERSION_STRING_CPP} + ) + set(SETTINGS_LIBS libcockatrice_network libcockatrice_settings) + set(SETTINGS_INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/cockatrice/src) + + cockatrice_add_test( + NAME + loader_local_matching_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp + ${SETTINGS_SOURCES} + loader_local_matching_test.cpp + LIBS + ${SETTINGS_LIBS} + INCLUDE_DIRS + ${SETTINGS_INCLUDE_DIRS} + ) + cockatrice_add_test( + NAME + deck_state_manager_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp + ${SETTINGS_SOURCES} + deck_state_manager_test.cpp + LIBS + libcockatrice_card + libcockatrice_deck_list + libcockatrice_models + libcockatrice_utility + ${SETTINGS_LIBS} + INCLUDE_DIRS + ${SETTINGS_INCLUDE_DIRS} + ) +endif() diff --git a/tests/card_art_crop_test.cpp b/tests/card_art_crop_test.cpp new file mode 100644 index 000000000..1c4a9ff8d --- /dev/null +++ b/tests/card_art_crop_test.cpp @@ -0,0 +1,92 @@ +#include "interface/widgets/cards/card_art_utils.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +ExactCard makeCard(bool landscape) +{ + CardInfo::UiAttributes attributes; + attributes.landscapeOrientation = landscape; + return ExactCard(CardInfo::newInstance("Artwork", "", false, {}, {}, {}, {}, attributes)); +} + +QPixmap landscapeCardWithFrame() +{ + // Normalized landscape frame: the title reaches 14% down the upright + // image, and the Battle type line starts at 56%. Artwork has two colors + // so the test also detects a wrong or missing rotation. + QImage upright(1400, 1000, QImage::Format_RGB32); + upright.fill(Qt::red); + QPainter painter(&upright); + painter.fillRect(QRect(70, 140, 1260, 420), Qt::green); + painter.fillRect(QRect(700, 140, 630, 420), Qt::blue); + painter.end(); + QTransform storedOrientation; + storedOrientation.rotate(-90); + return QPixmap::fromImage(upright.transformed(storedOrientation)); +} + +TEST(CardArtCrop, LandscapeCropExcludesNameAndRulesBoxesAndRotatesUpright) +{ + const QImage crop = CardArtUtils::cropCardArt(landscapeCardWithFrame(), makeCard(true)).toImage(); + ASSERT_GT(crop.width(), crop.height()); + ASSERT_FALSE(crop.isNull()); + for (int y = 0; y < crop.height(); ++y) { + for (int x = 0; x < crop.width(); ++x) { + EXPECT_EQ(x < crop.width() / 2 ? QColor(Qt::green) : QColor(Qt::blue), crop.pixelColor(x, y)); + } + } +} + +TEST(CardArtCrop, PortraitCropRetainsItsExistingArtworkBounds) +{ + QImage source(745, 1040, QImage::Format_RGB32); + for (int y = 0; y < source.height(); ++y) { + for (int x = 0; x < source.width(); ++x) { + source.setPixelColor(x, y, QColor(x % 256, y % 256, (x + y) % 256)); + } + } + const QPixmap input = QPixmap::fromImage(source); + const QImage crop = CardArtUtils::cropCardArt(input, makeCard(false)).toImage(); + EXPECT_EQ(source.copy(QRect(52, 114, 641, 458)), crop); +} + +TEST(CardArtCrop, DevicePixelRatioDoesNotChangeTheArtworkPixels) +{ + QPixmap source = landscapeCardWithFrame(); + const QPixmap normal = CardArtUtils::cropCardArt(source, makeCard(true)); + source.setDevicePixelRatio(2.0); + const QPixmap highDpi = CardArtUtils::cropCardArt(source, makeCard(true)); + EXPECT_EQ(normal.size(), highDpi.size()); + EXPECT_EQ(2.0, highDpi.devicePixelRatio()); + QImage normalPixels = normal.toImage(); + QImage highDpiPixels = highDpi.toImage(); + normalPixels.setDevicePixelRatio(1.0); + highDpiPixels.setDevicePixelRatio(1.0); + EXPECT_EQ(normalPixels, highDpiPixels); +} + +TEST(CardArtCrop, EmptyAndSmallImagesStayWithinTheirBounds) +{ + EXPECT_TRUE(CardArtUtils::cropCardArt({}, makeCard(true)).isNull()); + QPixmap small(1, 1); + small.fill(Qt::green); + EXPECT_EQ(small.toImage(), CardArtUtils::cropCardArt(small, makeCard(true)).toImage()); + EXPECT_EQ(small.toImage(), CardArtUtils::cropCardArt(small, makeCard(false)).toImage()); +} +} // namespace + +int main(int argc, char **argv) +{ + qputenv("QT_QPA_PLATFORM", "offscreen"); + QApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/carddatabase/CMakeLists.txt b/tests/carddatabase/CMakeLists.txt index 7d83640a1..012081fd2 100644 --- a/tests/carddatabase/CMakeLists.txt +++ b/tests/carddatabase/CMakeLists.txt @@ -21,10 +21,12 @@ cockatrice_add_test( ) # ------------------------ -# Filter String Test -# (guard must match the condition for libcockatrice_filters in the root CMakeLists.txt) +# Guard to match condition for libcockatrice_filters in the root CMakeLists.txt # ------------------------ if(WITH_ORACLE OR WITH_CLIENT) + # ------------------------ + # Filter String Test + # ------------------------ cockatrice_add_test( NAME filter_string_test @@ -36,6 +38,34 @@ if(WITH_ORACLE OR WITH_CLIENT) libcockatrice_filters ) + # ------------------------ + # Filter Tree Test + # ------------------------ + cockatrice_add_test( + NAME + filter_tree_test + SOURCES + ${VERSION_STRING_CPP} + filter_tree_test.cpp + mocks.cpp + LIBS + libcockatrice_filters + ) + + # ------------------------ + # Filter Mode Test + # ------------------------ + cockatrice_add_test( + NAME + filter_mode_test + SOURCES + ${VERSION_STRING_CPP} + filter_mode_test.cpp + mocks.cpp + LIBS + libcockatrice_filters + ) + # ------------------------ # Visual Display Model Test # ------------------------ diff --git a/tests/carddatabase/carddatabase_test.cpp b/tests/carddatabase/carddatabase_test.cpp index 22cbe9074..350d9be7e 100644 --- a/tests/carddatabase/carddatabase_test.cpp +++ b/tests/carddatabase/carddatabase_test.cpp @@ -1,3 +1,4 @@ +#include "../support/scoped_file_size_limit.h" #include "libcockatrice/card/database/card_database.h" #include "libcockatrice/card/database/card_database_loader.h" #include "libcockatrice/card/database/card_database_querier.h" @@ -204,6 +205,57 @@ TEST(CardDatabaseTest, Xml4LocalizedDataRoundTrip) ASSERT_EQ(printings.first().getProperty("manacost"), "1R"); } +#ifdef Q_OS_UNIX +/** + * @brief A failed write keeps the previously installed card database intact. + * + * This is the writer Oracle uses to install a refreshed database, so the truncation + * bug has to be covered here and not only through OracleDatabase::saveXml. + */ +TEST(CardDatabaseTest, Xml4ShortWriteKeepsInstalledFile) +{ + NoopCardSetPriorityController controller; + CardSetPtr set = + CardSet::newInstance(&controller, "TST", "Test Set", "expansion", QDate(), CardSet::PriorityPrimary); + + QHash props; + props["manacost"] = "1R"; + PrintingInfo printing(set, LazyPropertiesHash(props)); + SetToPrintingsMap setsInfo; + setsInfo["TST"].append(printing); + + CardInfo::UiAttributes attributes = {.tableRow = 1}; + CardInfoPtr card = + CardInfo::newInstance("Lightning Bolt", QString(1024, 'x'), false, {}, {}, {}, setsInfo, attributes); + + SetNameMap sets; + sets.insert("TST", set); + CardNameMap cards; + cards.insert("Lightning Bolt", card); + + QTemporaryDir tempDir; + ASSERT_TRUE(tempDir.isValid()); + const QString fileName = tempDir.filePath("cards.xml"); + + QFile installed(fileName); + ASSERT_TRUE(installed.open(QIODevice::WriteOnly)); + ASSERT_EQ(installed.write("installed database"), 18); + installed.close(); + + // A real filesystem write failure: the soft file-size cap makes the writer's own + // flush fail without filling a disk. The cap is scoped rather than forked, because a + // death-test child under RLIMIT_FSIZE is killed by SIGSEGV on macOS. + test_support::ScopedFileSizeLimit fileSizeCap(128); + ASSERT_TRUE(fileSizeCap.isValid()); + + CockatriceXml4Parser writer(&controller); + EXPECT_FALSE(writer.saveToFile({}, sets, cards, fileName)); + + ASSERT_TRUE(installed.open(QIODevice::ReadOnly)); + EXPECT_EQ(installed.readAll(), "installed database"); +} +#endif + /** * @brief Path provider serving a sandboxed copy of the test card database. */ diff --git a/tests/carddatabase/filter_mode_test.cpp b/tests/carddatabase/filter_mode_test.cpp new file mode 100644 index 000000000..3fec7e25d --- /dev/null +++ b/tests/carddatabase/filter_mode_test.cpp @@ -0,0 +1,147 @@ +#include "libcockatrice/filters/filter_mode.h" + +#include + +class FilterModeTest : public ::testing::Test +{ +protected: + ~FilterModeTest() override + { + for (CardFilter *filter : owned) { + delete filter; + } + } + + /** @brief Create a filter owned by the fixture and append it to the set under test. */ + const CardFilter * + add(const QString &term, CardFilter::Type type, CardFilter::Attr attr = CardFilter::Attr::AttrColor) + { + owned.append(new CardFilter(term, type, attr)); + filters.append(owned.last()); + return owned.last(); + } + + QList owned; + QList filters; +}; + +TEST(FilterModeInfoTest, EachModeDescribesItsOwnFilters) +{ + EXPECT_EQ(getFilterModeInfo(FilterMode::Includes).includeType, CardFilter::Type::TypeOr); + EXPECT_EQ(getFilterModeInfo(FilterMode::Includes).excludeType, CardFilter::Type::TypeEnd); + EXPECT_EQ(getFilterModeInfo(FilterMode::Includes).attr, CardFilter::Attr::AttrColor); + + EXPECT_EQ(getFilterModeInfo(FilterMode::Excludes).includeType, CardFilter::Type::TypeAndNot); + EXPECT_EQ(getFilterModeInfo(FilterMode::Excludes).excludeType, CardFilter::Type::TypeEnd); + + EXPECT_EQ(getFilterModeInfo(FilterMode::IncludeExclude).includeType, CardFilter::Type::TypeOr); + EXPECT_EQ(getFilterModeInfo(FilterMode::IncludeExclude).excludeType, CardFilter::Type::TypeAndNot); + + EXPECT_EQ(getFilterModeInfo(FilterMode::ExactMatch).includeType, CardFilter::Type::TypeAnd); + EXPECT_EQ(getFilterModeInfo(FilterMode::ExactMatch).excludeType, CardFilter::Type::TypeAndNot); + + // Color identity only excludes: the selected colors must be the identity, so nothing is included positively. + EXPECT_EQ(getFilterModeInfo(FilterMode::ColorIdentity).includeType, CardFilter::Type::TypeEnd); + EXPECT_EQ(getFilterModeInfo(FilterMode::ColorIdentity).excludeType, CardFilter::Type::TypeAndNot); + EXPECT_EQ(getFilterModeInfo(FilterMode::ColorIdentity).attr, CardFilter::Attr::AttrColorIdentity); + + EXPECT_EQ(getFilterModeInfo(FilterMode::Custom).attr, CardFilter::Attr::AttrEnd); +} + +TEST_F(FilterModeTest, EmptyFilterSetKeepsACompatibleMode) +{ + // Includes and Color identity both allow an empty set, so the live mode is not swapped for another. + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Includes), FilterMode::Includes); + EXPECT_EQ(deriveFilterMode(filters, FilterMode::ColorIdentity), FilterMode::ColorIdentity); +} + +// Include / Exclude and Exact match always hold one filter per color, so an empty set cannot have come from +// them and falls back to the first mode that can describe it. +TEST_F(FilterModeTest, EmptyFilterSetFallsBackWhenTheModeNeedsOneFilterPerColor) +{ + EXPECT_EQ(deriveFilterMode(filters, FilterMode::IncludeExclude), FilterMode::Includes); + EXPECT_EQ(deriveFilterMode(filters, FilterMode::ExactMatch), FilterMode::Includes); +} + +TEST_F(FilterModeTest, IncludesFromOrFilters) +{ + add("W", CardFilter::Type::TypeOr); + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Includes), FilterMode::Includes); + // A single OR filter cannot come from Excludes, so the first mode that can produce it is selected. + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Excludes), FilterMode::Includes); +} + +TEST_F(FilterModeTest, ExcludesFromAndNotFilters) +{ + add("W", CardFilter::Type::TypeAndNot); + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Excludes), FilterMode::Excludes); + // Includes needs an OR or an end filter, so an AND NOT filter selects Excludes. + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Includes), FilterMode::Excludes); +} + +TEST_F(FilterModeTest, IncludeExcludeNeedsOneFilterPerColor) +{ + add("W", CardFilter::Type::TypeOr); + for (const QChar color : QString("UBRG")) { + add(QString(color), CardFilter::Type::TypeAndNot); + } + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::IncludeExclude), FilterMode::IncludeExclude); +} + +TEST_F(FilterModeTest, IncludeExcludeRejectsAnIncompleteColorSet) +{ + add("W", CardFilter::Type::TypeOr); + add("U", CardFilter::Type::TypeAndNot); + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::IncludeExclude), FilterMode::Custom); +} + +TEST_F(FilterModeTest, ExactMatchFromAndAndAndNotFilters) +{ + add("W", CardFilter::Type::TypeAnd); + for (const QChar color : QString("UBRG")) { + add(QString(color), CardFilter::Type::TypeAndNot); + } + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::ExactMatch), FilterMode::ExactMatch); +} + +TEST_F(FilterModeTest, ColorIdentityFromIdentityAndNotFilters) +{ + for (const QChar color : QString("BRG")) { + add(QString(color), CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColorIdentity); + } + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::ColorIdentity), FilterMode::ColorIdentity); +} + +TEST_F(FilterModeTest, MixedAttributesAreCustom) +{ + add("W", CardFilter::Type::TypeOr, CardFilter::Attr::AttrColor); + add("U", CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColorIdentity); + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Includes), FilterMode::Custom); +} + +TEST_F(FilterModeTest, CustomLeavesAsSoonAsAModeCanExplainTheFilters) +{ + add("W", CardFilter::Type::TypeOr); + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Custom), FilterMode::Includes); +} + +// Exact match with no color selected stores one AND NOT filter per color. That is a legitimate Exact match +// state, so the live mode is kept; a mode that cannot produce the set re-derives to Excludes, which produces +// the identical filters. +TEST_F(FilterModeTest, FullyExcludedColorSetIsExactMatchOrExcludes) +{ + for (const QChar color : QString("WUBRG")) { + add(QString(color), CardFilter::Type::TypeAndNot); + } + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::ExactMatch), FilterMode::ExactMatch); + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Includes), FilterMode::Excludes); +} diff --git a/tests/carddatabase/filter_string_test.cpp b/tests/carddatabase/filter_string_test.cpp index e561db590..8cea24950 100644 --- a/tests/carddatabase/filter_string_test.cpp +++ b/tests/carddatabase/filter_string_test.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -123,6 +124,25 @@ CardInfoPtr greenIdentityCard() return CardInfo::newInstance("Identity Cat", "", false, {{"colors", "G"}, {"coloridentity", "G"}}, {}, {}, {}, {}); } +// A card whose color identity differs from its colors, so that a query reading the wrong one is visible. +CardInfoPtr splitIdentityCard() +{ + return CardInfo::newInstance("Split Identity", "", false, {{"colors", "G"}, {"coloridentity", "W"}}, {}, {}, {}, + {}); +} + +// "ci!" (exactly these color identities) used to read the camelCase "colorIdentity" property instead of the +// lowercase "coloridentity" key, so it matched the empty string and rejected every card. +TEST_F(CardQuery, ColorIdentityExactReadsTheColorIdentityProperty) +{ + const CardData card = splitIdentityCard(); + ASSERT_TRUE(FilterString("ci!w").check(card)); + ASSERT_FALSE(FilterString("ci!g").check(card)); + // "c!" still reads colors, not identity. + ASSERT_TRUE(FilterString("c!g").check(card)); + ASSERT_FALSE(FilterString("c!w").check(card)); +} + // Whitespace belongs around the separator, never inside an operator token: "c i: g" must not // be read as the color-identity operator "ci" (it is a generic "c" AND a field query on "i"). TEST_F(CardQuery, ColorIdentityOperatorIsNotSplitByWhitespace) @@ -184,6 +204,64 @@ TEST_F(CardQuery, SearchLanguageIsBoundPerInstance) ASSERT_TRUE(germanQuery.check(localized)); } +CardInfoPtr taggedCard() +{ + return CardInfo::newInstance("Tagged Card", "text", false, {{"tags", "ramp removal"}}, {}, {}, {}, {}); +} + +TEST_F(CardQuery, TagsMatchWholeSlugs) +{ + const CardData tagged = taggedCard(); + ASSERT_TRUE(FilterString("tags:ramp").check(tagged)); + ASSERT_TRUE(FilterString("tags:removal").check(tagged)); + ASSERT_TRUE(FilterString("tags:RAMP").check(tagged)); + ASSERT_TRUE(FilterString("tag:ramp").check(tagged)); + ASSERT_FALSE(FilterString("tags:squirrel").check(tagged)); +} + +TEST_F(CardQuery, TagQueryDoesNotMatchPartialSlugs) +{ + const CardData tagged = taggedCard(); + ASSERT_FALSE(FilterString("tags:ram").check(tagged)); + ASSERT_FALSE(FilterString("tags:mov").check(tagged)); +} + +TEST_F(CardQuery, TagQueryCombinesWithAnd) +{ + const CardData tagged = taggedCard(); + ASSERT_TRUE(FilterString("tags:ramp tags:removal").check(tagged)); + ASSERT_FALSE(FilterString("tags:ramp tags:squirrel").check(tagged)); +} + +TEST_F(CardQuery, TagQueryTreatsCommasAsPartOfTheSlug) +{ + // Tag lists are not a thing: `tags:draw` and `tags:ramp` are separate terms. + const CardData tagged = taggedCard(); + ASSERT_FALSE(FilterString("tags:ramp,removal").check(tagged)); +} + +TEST_F(CardQuery, FilterTreeTagAttribute) +{ + const CardData tagged = taggedCard(); + + FilterTree matching; + matching.termNode(CardFilter::AttrTag, CardFilter::TypeAnd, "ramp"); + ASSERT_TRUE(matching.acceptsCard(tagged, CardSearchLanguage{})); + + FilterTree partial; + partial.termNode(CardFilter::AttrTag, CardFilter::TypeAnd, "ram"); + ASSERT_FALSE(partial.acceptsCard(tagged, CardSearchLanguage{})); + + FilterTree missing; + missing.termNode(CardFilter::AttrTag, CardFilter::TypeAnd, "squirrel"); + ASSERT_FALSE(missing.acceptsCard(tagged, CardSearchLanguage{})); +} + +TEST_F(CardQuery, TagQueryFalseWhenCardHasNoTags) +{ + ASSERT_FALSE(FilterString("tags:ramp").check(cat)); +} + } // namespace class SetQuery : public ::testing::Test @@ -260,6 +338,22 @@ TEST_F(SetQuery, ComparisonMatchesByReleaseDate) EXPECT_FALSE(FilterString("set +#include +#include + +namespace +{ +class FilterTreeTest : public ::testing::Test +{ +protected: + void SetUp() override + { + language = CardSearchLanguage{}; + + island = CardInfo::newInstance("Island", "", false, {{"colors", ""}, {"coloridentity", "U"}}, {}, {}, {}, {}); + artifact = + CardInfo::newInstance("Artifact", "", false, {{"colors", ""}, {"coloridentity", ""}}, {}, {}, {}, {}); + truth = CardInfo::newInstance("Truth", "", false, {{"colors", "U"}, {"coloridentity", "U"}}, {}, {}, {}, {}); + notDead = + CardInfo::newInstance("Not Dead", "", false, {{"colors", "B"}, {"coloridentity", "B"}}, {}, {}, {}, {}); + wallOfDenial = CardInfo::newInstance("Wall of Denial", "", false, {{"colors", "WU"}, {"coloridentity", "WU"}}, + {}, {}, {}, {}); + maelstromAngel = CardInfo::newInstance("Maelstrom Archangel", "", false, + {{"colors", "WUBRG"}, {"coloridentity", "WUBRG"}}, {}, {}, {}, {}); + } + + CardSearchLanguage language; + + CardData island; // c/u + CardData artifact; // c + CardData truth; // u + CardData notDead; // b + CardData wallOfDenial; // wu + CardData maelstromAngel; // wubrg +}; +} // namespace + +// ===================================================================================================================== +// acceptColor +// ===================================================================================================================== + +TEST_F(FilterTreeTest, Color) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "wu"); + ASSERT_FALSE(filterTree.acceptsCard(island, language)); + ASSERT_FALSE(filterTree.acceptsCard(artifact, language)); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_TRUE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorColorless) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "c"); + ASSERT_TRUE(filterTree.acceptsCard(island, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_FALSE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_FALSE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorNot) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAndNot, "b"); + ASSERT_TRUE(filterTree.acceptsCard(island, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + ASSERT_TRUE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_FALSE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorAliases) +{ + FilterTree filterTree; + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "green"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(maelstromAngel, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "grn"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(maelstromAngel, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "blue"); + ASSERT_TRUE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "blu"); + ASSERT_TRUE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "black"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(notDead, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "blk"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(notDead, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "red"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(maelstromAngel, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "white"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "wht"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "colorless"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "colourless"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "none"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + filterTree.clear(); +} + +// ===================================================================================================================== +// acceptColorIdentity +// ===================================================================================================================== + +TEST_F(FilterTreeTest, ColorIdentity) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAnd, "wu"); + ASSERT_FALSE(filterTree.acceptsCard(island, language)); + ASSERT_FALSE(filterTree.acceptsCard(artifact, language)); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_TRUE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorIdentityColorless) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAnd, "c"); + ASSERT_FALSE(filterTree.acceptsCard(island, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_FALSE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_FALSE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorIdentityNot) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAndNot, "b"); + ASSERT_TRUE(filterTree.acceptsCard(island, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + ASSERT_TRUE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_FALSE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorIdentityMultiple) +{ + // Simulate a WU commander. + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAndNot, "b"); + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAndNot, "r"); + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAndNot, "g"); + ASSERT_TRUE(filterTree.acceptsCard(island, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + ASSERT_TRUE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_FALSE(filterTree.acceptsCard(maelstromAngel, language)); +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/client_login_status_test.cpp b/tests/client_login_status_test.cpp index d8ea63550..a2e073973 100644 --- a/tests/client_login_status_test.cpp +++ b/tests/client_login_status_test.cpp @@ -13,6 +13,10 @@ #include #include #include +#include + +// Referenced by other translation units in the server_remote library. +RNG_Abstract *rng = nullptr; namespace { diff --git a/tests/crash_reporter_test.cpp b/tests/crash_reporter_test.cpp index e44a34a60..208812a4b 100644 --- a/tests/crash_reporter_test.cpp +++ b/tests/crash_reporter_test.cpp @@ -418,6 +418,16 @@ TEST(CrashReporterTest, BeginSessionEndSessionRoundTripReportsClean) reporter.endSession(); } +TEST(CrashReporterTest, AddBreadcrumbIsSafeWithoutInitAndWithNullArguments) +{ + // Breadcrumbs are emitted from GUI slots and QtConcurrent workers regardless of whether + // the SDK was initialised (empty DSN, Never consent, OFF builds); none of these may crash. + CrashReporter::addBreadcrumb("card_db", "card database snapshot swapped in"); + CrashReporter::addBreadcrumb(nullptr, "message"); + CrashReporter::addBreadcrumb("category", nullptr); + SUCCEED(); +} + } // namespace int main(int argc, char **argv) diff --git a/tests/create_game_form_widget_test.cpp b/tests/create_game_form_widget_test.cpp new file mode 100644 index 000000000..f919e3443 --- /dev/null +++ b/tests/create_game_form_widget_test.cpp @@ -0,0 +1,132 @@ +#include "interface/widgets/game_hosting/create_game_form_widget.h" + +#include +#include +#include + +namespace +{ +const QMap gameTypes{{1, "Draft"}, {2, "Commander"}}; + +void expectSameOptions(const GameCreationOptions &actual, const GameCreationOptions &expected) +{ + EXPECT_EQ(actual.description, expected.description); + EXPECT_EQ(actual.password, expected.password); + EXPECT_EQ(actual.maxPlayers, expected.maxPlayers); + EXPECT_EQ(actual.onlyBuddies, expected.onlyBuddies); + EXPECT_EQ(actual.onlyRegistered, expected.onlyRegistered); + EXPECT_EQ(actual.spectatorsAllowed, expected.spectatorsAllowed); + EXPECT_EQ(actual.spectatorsNeedPassword, expected.spectatorsNeedPassword); + EXPECT_EQ(actual.spectatorsCanTalk, expected.spectatorsCanTalk); + EXPECT_EQ(actual.spectatorsSeeEverything, expected.spectatorsSeeEverything); + EXPECT_EQ(actual.joinAsJudge, expected.joinAsJudge); + EXPECT_EQ(actual.joinAsSpectator, expected.joinAsSpectator); + EXPECT_EQ(actual.startingLifeTotal, expected.startingLifeTotal); + EXPECT_EQ(actual.shareDecklistsOnLoad, expected.shareDecklistsOnLoad); + EXPECT_EQ(actual.shufflePlayers, expected.shufflePlayers); + EXPECT_EQ(actual.gameTypeIds, expected.gameTypeIds); +} +} // namespace + +TEST(CreateGameFormWidget, RegisteredHostDefaultsToRegisteredOnly) +{ + const CreateGameFormWidget form(gameTypes, true, false); + + const GameCreationOptions options = form.options(); + + EXPECT_TRUE(options.onlyRegistered); + EXPECT_FALSE(options.onlyBuddies); + EXPECT_EQ(options.maxPlayers, 2); + EXPECT_TRUE(options.spectatorsAllowed); + EXPECT_EQ(options.startingLifeTotal, 20); + EXPECT_TRUE(options.gameTypeIds.isEmpty()); +} + +TEST(CreateGameFormWidget, UnregisteredHostDefaultsToOpenJoin) +{ + const CreateGameFormWidget form(gameTypes, false, false); + + const GameCreationOptions options = form.options(); + + EXPECT_FALSE(options.onlyRegistered); + EXPECT_FALSE(options.onlyBuddies); +} + +TEST(CreateGameFormWidget, SetOptionsRoundTripsEveryField) +{ + CreateGameFormWidget form(gameTypes, true, true); + + GameCreationOptions expected; + expected.description = "Some game"; + expected.password = "hunter2"; + expected.maxPlayers = 5; + expected.onlyBuddies = true; + expected.onlyRegistered = false; + expected.spectatorsAllowed = false; + expected.spectatorsNeedPassword = true; + expected.spectatorsCanTalk = true; + expected.spectatorsSeeEverything = true; + expected.joinAsJudge = true; + expected.joinAsSpectator = true; + expected.startingLifeTotal = 40; + expected.shareDecklistsOnLoad = true; + expected.shufflePlayers = true; + expected.gameTypeIds = {2}; + + form.setOptions(expected); + + expectSameOptions(form.options(), expected); +} + +TEST(CreateGameFormWidget, ResetRestoresDefaultsAndClearsTheGameType) +{ + CreateGameFormWidget form(gameTypes, true, false); + GameCreationOptions configured; + configured.description = "Configured"; + configured.maxPlayers = 8; + configured.onlyBuddies = true; + configured.joinAsJudge = true; + configured.gameTypeIds = {2}; + form.setOptions(configured); + + form.resetToDefaults(); + + const GameCreationOptions options = form.options(); + EXPECT_TRUE(options.description.isEmpty()); + EXPECT_EQ(options.maxPlayers, 2); + EXPECT_FALSE(options.onlyBuddies); + EXPECT_TRUE(options.onlyRegistered); + EXPECT_TRUE(options.spectatorsAllowed); + EXPECT_EQ(options.startingLifeTotal, 20); + EXPECT_FALSE(options.joinAsJudge); + EXPECT_TRUE(options.gameTypeIds.isEmpty()); +} + +TEST(CreateGameFormWidget, RememberSettingsFlagRoundTrips) +{ + CreateGameFormWidget form(gameTypes, true, false); + + EXPECT_FALSE(form.isRememberSettingsChecked()); + + form.setRememberSettingsChecked(true); + + EXPECT_TRUE(form.isRememberSettingsChecked()); +} + +TEST(CreateGameFormWidget, SelectedGameTypesSettingStringUsesTheLegacyEncoding) +{ + CreateGameFormWidget form(gameTypes, true, false); + GameCreationOptions configured; + configured.gameTypeIds = {1}; + form.setOptions(configured); + + EXPECT_EQ(form.selectedGameTypesSettingString(), "Draft, "); +} + +int main(int argc, char **argv) +{ + qputenv("QT_QPA_PLATFORM", "offscreen"); + QApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/deck_list_model/deck_list_model_custom_zones_test.cpp b/tests/deck_list_model/deck_list_model_custom_zones_test.cpp index 95df74702..d4e44125c 100644 --- a/tests/deck_list_model/deck_list_model_custom_zones_test.cpp +++ b/tests/deck_list_model/deck_list_model_custom_zones_test.cpp @@ -278,6 +278,37 @@ TEST(DeckListModelCustomZones, SortPlainNodeDoesNotReorderCustomZones) EXPECT_EQ(mapping[1].second, 1); } +TEST(DeckListModelCustomZones, ManaGroupsUseNumericOrderWithoutChangingOtherNodes) +{ + InnerDecklistNode root; + auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); + const QStringList original = {"10", "2", "0.5", "1000000", "1", "unknown", "nan"}; + for (const auto &name : original) { + new InnerDecklistNode(name, board); + } + auto *custom = new DecklistModelSubZoneNode("1x", board); + new InnerDecklistNode("10", custom); + new InnerDecklistNode("2", custom); + root.setSortMethod(DeckSortMethod::ByName); + + const auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::AscendingOrder, true); + EXPECT_EQ(childNames(board), (QStringList{"0.5", "1", "2", "10", "1000000", "nan", "unknown", "1x"})); + QStringList before = original; + before.append("1x"); + for (const auto &move : mapping) { + EXPECT_EQ(board->at(move.second)->getName(), before.at(move.first)); + } + + (void)DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::DescendingOrder, true); + EXPECT_EQ(childNames(board), (QStringList{"1000000", "10", "2", "1", "0.5", "unknown", "nan", "1x"})); + + // The numeric flag only affects groups directly under a board. + (void)DeckListModelCustomZones::sortWithCustomZonesLast(&root, custom, Qt::AscendingOrder, true); + EXPECT_EQ(childNames(custom), (QStringList{"10", "2"})); + (void)DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::AscendingOrder); + EXPECT_EQ(childNames(board), (QStringList{"0.5", "1", "10", "1000000", "2", "nan", "unknown", "1x"})); +} + int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); diff --git a/tests/deck_list_zones/deck_list_zones_test.cpp b/tests/deck_list_zones/deck_list_zones_test.cpp index 1903b70df..a67e689cf 100644 --- a/tests/deck_list_zones/deck_list_zones_test.cpp +++ b/tests/deck_list_zones/deck_list_zones_test.cpp @@ -393,6 +393,28 @@ TEST(DeckListZones, MaybeboardCustomZoneCardsAreExcludedFromHash) EXPECT_EQ(deck.getDeckHash(), expected.getDeckHash()); } +TEST(DeckListZones, PlainExportPutsMainFirstWithoutReorderingSavedZones) +{ + DeckList deck; + auto *tree = deck.getTree(); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_SIDE, "Side Tech"), nullptr); + tree->addCard("Side Card", 2, "Side Tech", -1); + tree->addCard("Token Card", 1, DECK_ZONE_TOKENS, -1); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + tree->addCard("Main Card", 3, "Removal", -1); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAYBEBOARD, "Candidates"), nullptr); + tree->addCard("Maybe Card", 4, "Candidates", -1); + + const QString native = deck.writeToString_Native(); + EXPECT_EQ(deck.writeToString_Plain(true, false), QString("3 Main Card\nSB: 2 Side Card\n1 Token Card\n")); + EXPECT_EQ(deck.writeToString_Plain(false, false), QString("3 Main Card\n2 Side Card\n1 Token Card\n")); + EXPECT_EQ(deck.writeToString_Native(), native); + + DeckList reopened(native); + EXPECT_EQ(reopened.getZoneNodes().first()->getName(), DECK_ZONE_SIDE); + EXPECT_EQ(reopened.writeToString_Plain(true, false), deck.writeToString_Plain(true, false)); +} + int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); diff --git a/tests/deck_state_manager_test.cpp b/tests/deck_state_manager_test.cpp new file mode 100644 index 000000000..ddd17912c --- /dev/null +++ b/tests/deck_state_manager_test.cpp @@ -0,0 +1,190 @@ +/** @file deck_state_manager_test.cpp + * @brief Tests for DeckStateManager operations. + * @ingroup Tests + */ + +#include "libcockatrice/card/database/card_database.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +/** + * @brief Assert that the DeckStateManager's deck list contains only the given zone, + * and that that zone only contains the given number of cards, all with the given card name. + * + * @param manager the DeckStateManager + * @param restrictToZones In which zones to look for the zone and cards. Empty means use the entire deck + * @param zone The only zone that should exist in the deck + * @param cardName The name of the only card that should exist in the deck + * @param cardNumber The number of cards that should be in the deck + */ +static void containsExactly(const DeckStateManager &manager, + const QSet &restrictToZones, + QString zone, + QString cardName, + int cardNumber) +{ + QList zones = manager.getDeckList().getZoneNodes(restrictToZones); + ASSERT_EQ(zones.size(), 1); + ASSERT_EQ(zones[0]->getName(), zone); + ASSERT_EQ(zones[0]->size(), 1); + const DecklistCardNode *card = dynamic_cast((*zones[0])[0]); + ASSERT_NE(card, nullptr); + ASSERT_EQ(card->getName(), cardName); + ASSERT_EQ(card->getNumber(), cardNumber); +} + +/** @brief Return CardInfo of a token with the given name. */ +static CardInfoPtr makeToken(QString name) +{ + return CardInfo::newInstance(name, "", true, {}, {}, {}, {}, {}); +} + +// ===================================================================================================================== +// addCard +// ===================================================================================================================== + +TEST(DeckStateManager, AddCards) +{ + DeckStateManager manager; + manager.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN); + containsExactly(manager, {}, DECK_ZONE_MAIN, "Lightning Bolt", 1); + manager.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN); + containsExactly(manager, {}, DECK_ZONE_MAIN, "Lightning Bolt", 2); +} + +TEST(DeckStateManager, AddCardZones) +{ + DeckStateManager manager; + manager.addCard(ExactCard(CardInfo::newInstance("Grizzly Bears")), DECK_ZONE_MAIN); + manager.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_SIDE); + manager.addCard(ExactCard(CardInfo::newInstance("Swords to Plowshares")), DECK_ZONE_MAYBEBOARD); + + // Check if the cards got put into the correct zones. + ASSERT_EQ(manager.getDeckList().getZoneNodes().size(), 3); + containsExactly(manager, {DECK_ZONE_MAIN}, DECK_ZONE_MAIN, "Grizzly Bears", 1); + containsExactly(manager, {DECK_ZONE_SIDE}, DECK_ZONE_SIDE, "Lightning Bolt", 1); + containsExactly(manager, {DECK_ZONE_MAYBEBOARD}, DECK_ZONE_MAYBEBOARD, "Swords to Plowshares", 1); +} + +TEST(DeckStateManager, AddTokens) +{ + DeckStateManager manager; + manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAIN); + manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_SIDE); + manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAYBEBOARD); + manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_TOKENS); + + // A token should always be put into the tokens zone. + containsExactly(manager, {}, DECK_ZONE_TOKENS, "Beast", 4); +} + +// ===================================================================================================================== +// decrementCard +// ===================================================================================================================== + +TEST(DeckStateManager, DecrementCards) +{ + DeckStateManager manager; + manager.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN); + manager.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN); + containsExactly(manager, {}, DECK_ZONE_MAIN, "Lightning Bolt", 2); + + // Removing from the sideboard should do nothing, as there is nothing in the side board. + manager.decrementCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_SIDE); + containsExactly(manager, {}, DECK_ZONE_MAIN, "Lightning Bolt", 2); + + // Remove the first card. + manager.decrementCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN); + containsExactly(manager, {}, DECK_ZONE_MAIN, "Lightning Bolt", 1); + + // Removing the second card should also remove the zone. + manager.decrementCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), DECK_ZONE_MAIN); + ASSERT_EQ(manager.getDeckList().getZoneNodes().size(), 0); +} + +TEST(DeckStateManager, DecrementTokens) +{ + DeckStateManager manager; + manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAIN); + manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_SIDE); + manager.addCard(ExactCard(makeToken("Beast")), DECK_ZONE_TOKENS); + containsExactly(manager, {}, DECK_ZONE_TOKENS, "Beast", 3); + + manager.decrementCard(ExactCard(makeToken("Beast")), DECK_ZONE_TOKENS); + containsExactly(manager, {}, DECK_ZONE_TOKENS, "Beast", 2); + + // Removing tokens from a given zone should remove them from the + // token zone if the given zone does not contain the token. + manager.decrementCard(ExactCard(makeToken("Beast")), DECK_ZONE_SIDE); + containsExactly(manager, {}, DECK_ZONE_TOKENS, "Beast", 1); + + manager.decrementCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAIN); + ASSERT_EQ(manager.getDeckList().getZoneNodes().size(), 0); +} + +TEST(DeckStateManager, DecrementTokenMainboard) +{ + DeckStateManager manager; + + // Add cards to the deck list directly so tokens are not + // automatically added to the token zone. + manager.getDeckListShared()->addCard("Beast", DECK_ZONE_MAIN); + manager.getDeckListShared()->addCard("Beast", DECK_ZONE_TOKENS); + ASSERT_EQ(manager.getDeckList().getZoneNodes().size(), 2); + containsExactly(manager, {DECK_ZONE_MAIN}, DECK_ZONE_MAIN, "Beast", 1); + containsExactly(manager, {DECK_ZONE_TOKENS}, DECK_ZONE_TOKENS, "Beast", 1); + + // decrementCard uses the model to find the card, so we need to rebuild it. + manager.getModel()->rebuildTree(); + + // Tokens should be removed from the main zone first. + manager.decrementCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAIN); + ASSERT_EQ(manager.getDeckList().getZoneNodes({DECK_ZONE_MAIN}).size(), 0); + containsExactly(manager, {DECK_ZONE_TOKENS}, DECK_ZONE_TOKENS, "Beast", 1); + + // If the main zone is empty they can be removed from the token zone. + manager.decrementCard(ExactCard(makeToken("Beast")), DECK_ZONE_MAIN); + ASSERT_EQ(manager.getDeckList().getZoneNodes().size(), 0); +} +} // namespace + +int main(int argc, char **argv) +{ + // Redirect SettingsCache reads/writes (app-data location) away from the real user profile. + QStandardPaths::setTestModeEnabled(true); + QCoreApplication app(argc, argv); + QCoreApplication::setOrganizationName(QStringLiteral("CockatriceTest")); + QCoreApplication::setApplicationName( + QStringLiteral("deck-state-manager-test-%1").arg(QCoreApplication::applicationPid())); + + ::testing::InitGoogleTest(&argc, argv); + + // Add cards to the card database + auto *db = CardDatabaseManager::getInstance(); + db->addCard(CardInfo::newInstance("Lightning Bolt")); + db->addCard(CardInfo::newInstance("Grizzly Bears")); + db->addCard(CardInfo::newInstance("Swords to Plowshares")); + db->addCard(makeToken("Beast")); + + int result = RUN_ALL_TESTS(); + + // Clean up the settings directory in ~/.qttest if the tests ran. + if (!::testing::GTEST_FLAG(list_tests)) { + QDir(SettingsCache::instance().getDataPath()).removeRecursively(); + QDir(SettingsCache::instance().getCachePath()).removeRecursively(); + } + + return result; +} diff --git a/tests/font_config_test.cpp b/tests/font_config_test.cpp new file mode 100644 index 000000000..c8b1fb10d --- /dev/null +++ b/tests/font_config_test.cpp @@ -0,0 +1,195 @@ +#include "interface/theme_config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +// Writes content to a file inside tmp, creating parents as needed. +QString writeTempFile(QTemporaryDir &tmp, const QString &relativePath, const QString &content) +{ + const QString filePath = QDir(tmp.path()).absoluteFilePath(relativePath); + EXPECT_TRUE(QDir().mkpath(QFileInfo(filePath).absolutePath())); + QFile file(filePath); + EXPECT_TRUE(file.open(QIODevice::WriteOnly | QIODevice::Text)); + QTextStream out(&file); + out << content; + file.close(); + return filePath; +} + +QFont makeFont(const QString &family, int pointSize) +{ + QFont font(family); + font.setPointSize(pointSize); + return font; +} +} // namespace + +TEST(FontConfigTest, FileNameIsFontsToml) +{ + EXPECT_EQ(FontConfig::fileName(), QStringLiteral("fonts.toml")); +} + +TEST(FontConfigTest, EmptyByDefault) +{ + FontConfig cfg; + EXPECT_FALSE(cfg.hasFonts()); + EXPECT_TRUE(cfg.toToml().isEmpty()); + EXPECT_EQ(cfg, FontConfig()); + EXPECT_EQ(cfg, FontConfig::fromFile("/nonexistent/path/fonts.toml")); + EXPECT_EQ(cfg, FontConfig::fromThemeDir(QString())); +} + +TEST(FontConfigTest, RoundTripPreservesFamilyAndSize) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + + FontConfig cfg; + cfg.fonts[AppFont::InterfaceDefault] = makeFont("Inter", 10); + cfg.fonts[AppFont::ChatLog] = makeFont("JetBrains Mono", 9); + cfg.fonts[AppFont::CardText] = makeFont("Noto Serif", 11); + cfg.fonts[AppFont::DeckList] = makeFont("Roboto", 12); + ASSERT_TRUE(cfg.hasFonts()); + + const QString filePath = writeTempFile(tmp, "fonts.toml", cfg.toToml()); + const FontConfig loaded = FontConfig::fromFile(filePath); + + EXPECT_EQ(loaded, cfg); + EXPECT_EQ(loaded.fonts[AppFont::InterfaceDefault].family(), QStringLiteral("Inter")); + EXPECT_EQ(loaded.fonts[AppFont::InterfaceDefault].pointSize(), 10); + EXPECT_EQ(loaded.fonts[AppFont::ChatLog].family(), QStringLiteral("JetBrains Mono")); + EXPECT_EQ(loaded.fonts[AppFont::ChatLog].pointSize(), 9); + EXPECT_EQ(loaded.fonts[AppFont::CardText].family(), QStringLiteral("Noto Serif")); + EXPECT_EQ(loaded.fonts[AppFont::CardText].pointSize(), 11); + EXPECT_EQ(loaded.fonts[AppFont::DeckList].family(), QStringLiteral("Roboto")); + EXPECT_EQ(loaded.fonts[AppFont::DeckList].pointSize(), 12); +} + +TEST(FontConfigTest, RoundTripStripsWeightAndItalic) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + + QFont styled("Inter"); + styled.setPointSize(10); + styled.setBold(true); + styled.setItalic(true); + + FontConfig cfg; + cfg.fonts[AppFont::ChatLog] = styled; + + const QString filePath = writeTempFile(tmp, "fonts.toml", cfg.toToml()); + const FontConfig loaded = FontConfig::fromFile(filePath); + + ASSERT_TRUE(loaded.fonts.contains(AppFont::ChatLog)); + EXPECT_EQ(loaded.fonts[AppFont::ChatLog].family(), QStringLiteral("Inter")); + EXPECT_EQ(loaded.fonts[AppFont::ChatLog].pointSize(), 10); + EXPECT_FALSE(loaded.fonts[AppFont::ChatLog].bold()); + EXPECT_FALSE(loaded.fonts[AppFont::ChatLog].italic()); + EXPECT_EQ(loaded.fonts[AppFont::ChatLog], makeFont("Inter", 10)); +} + +TEST(FontConfigTest, ToTomlFormat) +{ + FontConfig cfg; + cfg.fonts[AppFont::InterfaceDefault] = makeFont("Inter", 10); + + const QString toml = cfg.toToml(); + EXPECT_TRUE(toml.contains(QStringLiteral("[Fonts]"))); + EXPECT_TRUE(toml.contains(QStringLiteral("InterfaceDefault"))); + EXPECT_TRUE(toml.contains(QStringLiteral("\"Inter, 10\""))); +} + +TEST(FontConfigTest, MalformedLinesAreSkipped) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + + const QString filePath = writeTempFile(tmp, "fonts.toml", + "# a comment\n" + "; another comment\n" + "\n" + "StrayBeforeSection = \"Inter, 10\"\n" + "\n" + "[Other]\n" + "ChatLog = \"ShouldBeIgnored, 10\"\n" + "\n" + "[Fonts]\n" + "InterfaceDefault = \"Inter, 10\"\n" + "ChatLog = \"JetBrains Mono\", 9 # inline comment\n" + "CardText = Noto Serif, 11\n" + "AppFont::DeckList = \"Roboto, 12\"\n" + "UnknownRole = \"Inter, 10\"\n" + "QPalette::Window = \"Inter, 10\"\n" + "NoComma = \"Inter\"\n" + "EmptyFamily = \", 10\"\n" + "ZeroSize = \"Inter, 0\"\n" + "NegativeSize = \"Inter, -3\"\n" + "NotANumber = \"Inter, big\"\n" + "NotAnEntry\n"); + + const FontConfig loaded = FontConfig::fromFile(filePath); + + ASSERT_TRUE(loaded.hasFonts()); + EXPECT_EQ(loaded.fonts.size(), 4); + EXPECT_EQ(loaded.fonts[AppFont::InterfaceDefault], makeFont("Inter", 10)); + EXPECT_EQ(loaded.fonts[AppFont::ChatLog], makeFont("JetBrains Mono", 9)); + EXPECT_EQ(loaded.fonts[AppFont::CardText], makeFont("Noto Serif", 11)); + EXPECT_EQ(loaded.fonts[AppFont::DeckList], makeFont("Roboto", 12)); +} + +TEST(FontConfigTest, FromThemeDirReadsFontsToml) +{ + QTemporaryDir tmp; + ASSERT_TRUE(tmp.isValid()); + + const QString themeDir = QDir(tmp.path()).absoluteFilePath("MyTheme"); + ASSERT_TRUE(QDir().mkpath(themeDir)); + + FontConfig cfg; + cfg.fonts[AppFont::CardText] = makeFont("Noto Serif", 11); + writeTempFile(tmp, "MyTheme/fonts.toml", cfg.toToml()); + + const FontConfig loaded = FontConfig::fromThemeDir(themeDir); + EXPECT_EQ(loaded, cfg); + + // A theme directory without fonts.toml loads as empty. + const QString bareDir = QDir(tmp.path()).absoluteFilePath("BareTheme"); + ASSERT_TRUE(QDir().mkpath(bareDir)); + EXPECT_FALSE(FontConfig::fromThemeDir(bareDir).hasFonts()); +} + +TEST(FontConfigTest, InequalityComparesRoles) +{ + FontConfig left; + left.fonts[AppFont::InterfaceDefault] = makeFont("Inter", 10); + + FontConfig right = left; + EXPECT_EQ(left, right); + EXPECT_FALSE(left != right); + + right.fonts[AppFont::InterfaceDefault] = makeFont("Inter", 11); + EXPECT_NE(left, right); + + right = left; + right.fonts[AppFont::ChatLog] = makeFont("Inter", 10); + EXPECT_NE(left, right); +} + +// Every test brings its own main(): GTest is not guaranteed to be found on the build machines, and +// the fallback then links the bare gtest library without gtest_main. +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/game/card_zone_logic_test.cpp b/tests/game/card_zone_logic_test.cpp index 7f0c39b47..e308d95a6 100644 --- a/tests/game/card_zone_logic_test.cpp +++ b/tests/game/card_zone_logic_test.cpp @@ -5,6 +5,7 @@ #include #include #include +#include namespace { @@ -105,3 +106,30 @@ TEST(CardZoneLogicTest, ReorganizeSignalFiresOnAdd) EXPECT_EQ(reorganizeSpy.count(), 1); } + +TEST(CardZoneLogicTest, LibraryEdgeViewIsLimitedToOneEndOfTheLibrary) +{ + HandZoneLogic deck(nullptr, QString::fromLatin1(ZoneNames::DECK), false, true, true); + HandZoneLogic grave(nullptr, QString::fromLatin1(ZoneNames::GRAVE), false, true, true); + + ZoneViewZoneLogic fullDeckView(nullptr, &deck, -1, false, false, false); + EXPECT_FALSE(fullDeckView.isLibraryEdgeView()); + EXPECT_FALSE(fullDeckView.showsTopOfLibrary()); + EXPECT_FALSE(fullDeckView.showsBottomOfLibrary()); + + ZoneViewZoneLogic emptyDeckView(nullptr, &deck, 0, false, false, false); + EXPECT_FALSE(emptyDeckView.isLibraryEdgeView()); + + ZoneViewZoneLogic topView(nullptr, &deck, 3, false, false, false); + EXPECT_TRUE(topView.isLibraryEdgeView()); + EXPECT_TRUE(topView.showsTopOfLibrary()); + EXPECT_FALSE(topView.showsBottomOfLibrary()); + + ZoneViewZoneLogic bottomView(nullptr, &deck, 3, false, false, true); + EXPECT_TRUE(bottomView.isLibraryEdgeView()); + EXPECT_FALSE(bottomView.showsTopOfLibrary()); + EXPECT_TRUE(bottomView.showsBottomOfLibrary()); + + ZoneViewZoneLogic graveView(nullptr, &grave, 3, false, false, false); + EXPECT_FALSE(graveView.isLibraryEdgeView()); +} diff --git a/tests/game_creation_options_test.cpp b/tests/game_creation_options_test.cpp new file mode 100644 index 000000000..cd8344ba9 --- /dev/null +++ b/tests/game_creation_options_test.cpp @@ -0,0 +1,330 @@ +#include "interface/widgets/game_hosting/game_creation_options.h" + +#include +#include +#include +#include + +namespace +{ +/** + * @brief The remembered settings a test hands to fromSettings. + * + * The fake cannot itself be an aggregate (it derives from a polymorphic interface), so the + * remembered values live in a GameCreationOptions member and the getters read them from + * there. That keeps the test data in one designated initializer. + */ +class FakeGameSettings : public IGameSettingsProvider +{ +public: + explicit FakeGameSettings(const GameCreationOptions &_options = {}) : options(_options) + { + } + + [[nodiscard]] QString getGameDescription() const override + { + return options.description; + } + [[nodiscard]] int getMaxPlayers() const override + { + return options.maxPlayers; + } + [[nodiscard]] QString getGameTypes() const override + { + return gameTypes; + } + [[nodiscard]] bool getOnlyBuddies() const override + { + return options.onlyBuddies; + } + [[nodiscard]] bool getOnlyRegistered() const override + { + return options.onlyRegistered; + } + [[nodiscard]] bool getSpectatorsAllowed() const override + { + return options.spectatorsAllowed; + } + [[nodiscard]] bool getSpectatorsNeedPassword() const override + { + return options.spectatorsNeedPassword; + } + [[nodiscard]] bool getSpectatorsCanTalk() const override + { + return options.spectatorsCanTalk; + } + [[nodiscard]] bool getSpectatorsCanSeeEverything() const override + { + return options.spectatorsSeeEverything; + } + [[nodiscard]] bool getCreateGameAsSpectator() const override + { + return options.joinAsSpectator; + } + [[nodiscard]] int getDefaultStartingLifeTotal() const override + { + return options.startingLifeTotal; + } + [[nodiscard]] bool getShareDecklistsOnLoad() const override + { + return options.shareDecklistsOnLoad; + } + [[nodiscard]] bool getShufflePlayers() const override + { + return options.shufflePlayers; + } + [[nodiscard]] bool getRememberGameSettings() const override + { + return false; + } + [[nodiscard]] bool getLocalGameRememberSettings() const override + { + return false; + } + [[nodiscard]] int getLocalGameMaxPlayers() const override + { + return 0; + } + [[nodiscard]] int getLocalGameStartingLifeTotal() const override + { + return 0; + } + + GameCreationOptions options; + /// The legacy "Name, " list; it has no GameCreationOptions counterpart. + QString gameTypes; +}; +} // namespace + +TEST(GameCreationOptions, DefaultsMatchTheDialog) +{ + const GameCreationOptions options; + + EXPECT_TRUE(options.description.isEmpty()); + EXPECT_TRUE(options.password.isEmpty()); + EXPECT_EQ(options.maxPlayers, 2); + EXPECT_FALSE(options.onlyBuddies); + EXPECT_FALSE(options.onlyRegistered); + EXPECT_TRUE(options.spectatorsAllowed); + EXPECT_FALSE(options.spectatorsNeedPassword); + EXPECT_FALSE(options.spectatorsCanTalk); + EXPECT_FALSE(options.spectatorsSeeEverything); + EXPECT_FALSE(options.joinAsJudge); + EXPECT_FALSE(options.joinAsSpectator); + EXPECT_EQ(options.startingLifeTotal, 20); + EXPECT_FALSE(options.shareDecklistsOnLoad); + EXPECT_FALSE(options.shufflePlayers); + EXPECT_TRUE(options.gameTypeIds.isEmpty()); +} + +TEST(GameCreationOptions, EveryFieldMapsToTheCommand) +{ + GameCreationOptions options; + options.description = "Some game"; + options.password = "hunter2"; + options.maxPlayers = 4; + options.onlyBuddies = true; + options.onlyRegistered = true; + options.spectatorsAllowed = false; + options.spectatorsNeedPassword = true; + options.spectatorsCanTalk = true; + options.spectatorsSeeEverything = true; + options.joinAsJudge = true; + options.joinAsSpectator = true; + options.startingLifeTotal = 40; + options.shareDecklistsOnLoad = true; + options.shufflePlayers = true; + + const Command_CreateGame cmd = options.toCreateGameCommand(); + + EXPECT_EQ(cmd.description(), "Some game"); + EXPECT_EQ(cmd.password(), "hunter2"); + EXPECT_EQ(cmd.max_players(), 4); + EXPECT_TRUE(cmd.only_buddies()); + EXPECT_TRUE(cmd.only_registered()); + EXPECT_FALSE(cmd.spectators_allowed()); + EXPECT_TRUE(cmd.spectators_need_password()); + EXPECT_TRUE(cmd.spectators_can_talk()); + EXPECT_TRUE(cmd.spectators_see_everything()); + EXPECT_TRUE(cmd.join_as_judge()); + EXPECT_TRUE(cmd.join_as_spectator()); + EXPECT_EQ(cmd.starting_life_total(), 40); + EXPECT_TRUE(cmd.share_decklists_on_load()); + EXPECT_TRUE(cmd.shuffle_players()); +} + +TEST(GameCreationOptions, GameTypeIdsKeepTheirOrder) +{ + GameCreationOptions options; + options.gameTypeIds = {7, 2, 5}; + + const Command_CreateGame cmd = options.toCreateGameCommand(); + + ASSERT_EQ(cmd.game_type_ids_size(), 3); + EXPECT_EQ(cmd.game_type_ids(0), 7); + EXPECT_EQ(cmd.game_type_ids(1), 2); + EXPECT_EQ(cmd.game_type_ids(2), 5); +} + +TEST(GameCreationOptions, EmptyGameTypeIdsProduceAnEmptyRepeatedField) +{ + GameCreationOptions options; + + const Command_CreateGame cmd = options.toCreateGameCommand(); + + EXPECT_EQ(cmd.game_type_ids_size(), 0); +} + +TEST(GameCreationOptions, DescriptionWhitespaceIsSimplified) +{ + GameCreationOptions options; + options.description = " a b "; + + const Command_CreateGame cmd = options.toCreateGameCommand(); + + EXPECT_EQ(cmd.description(), "a b"); +} + +TEST(GameCreationOptions, DefaultsForRegisteredHostEnforceRegistration) +{ + const GameCreationOptions registered = GameCreationOptions::defaultsFor(true); + + EXPECT_TRUE(registered.onlyRegistered); + EXPECT_FALSE(registered.onlyBuddies); + EXPECT_EQ(registered.maxPlayers, 2); + EXPECT_TRUE(registered.spectatorsAllowed); + EXPECT_EQ(registered.startingLifeTotal, 20); + EXPECT_TRUE(registered.gameTypeIds.isEmpty()); +} + +TEST(GameCreationOptions, DefaultsForUnregisteredHostAllowOpenJoining) +{ + const GameCreationOptions unregistered = GameCreationOptions::defaultsFor(false); + + EXPECT_FALSE(unregistered.onlyRegistered); + EXPECT_FALSE(unregistered.onlyBuddies); +} + +TEST(GameCreationOptions, FromSettingsCopiesEveryRememberedField) +{ + const FakeGameSettings settings{GameCreationOptions{.description = "Remembered game", + .password = {}, + .maxPlayers = 6, + .onlyBuddies = true, + .onlyRegistered = true, + .spectatorsAllowed = false, + .spectatorsNeedPassword = true, + .spectatorsCanTalk = true, + .spectatorsSeeEverything = true, + .joinAsJudge = false, + .joinAsSpectator = true, + .startingLifeTotal = 40, + .shareDecklistsOnLoad = true, + .shufflePlayers = true, + .gameTypeIds = {}}}; + + const GameCreationOptions options = GameCreationOptions::fromSettings(settings, {}, true); + + EXPECT_EQ(options.description, "Remembered game"); + EXPECT_EQ(options.maxPlayers, 6); + EXPECT_TRUE(options.onlyBuddies); + EXPECT_TRUE(options.onlyRegistered); + EXPECT_FALSE(options.spectatorsAllowed); + EXPECT_TRUE(options.spectatorsNeedPassword); + EXPECT_TRUE(options.spectatorsCanTalk); + EXPECT_TRUE(options.spectatorsSeeEverything); + EXPECT_TRUE(options.joinAsSpectator); + EXPECT_EQ(options.startingLifeTotal, 40); + EXPECT_TRUE(options.shareDecklistsOnLoad); + EXPECT_TRUE(options.shufflePlayers); +} + +TEST(GameCreationOptions, FromSettingsDropsJoinRestrictionsForUnregisteredHosts) +{ + FakeGameSettings settings; + settings.options.onlyBuddies = true; + settings.options.onlyRegistered = true; + + const GameCreationOptions options = GameCreationOptions::fromSettings(settings, {}, false); + + EXPECT_FALSE(options.onlyBuddies); + EXPECT_FALSE(options.onlyRegistered); +} + +TEST(GameCreationOptions, FromSettingsMatchesGameTypesByTheLegacyNameList) +{ + FakeGameSettings settings; + settings.gameTypes = "Draft, Commander, "; + const QMap gameTypes{{1, "Draft"}, {2, "Commander"}, {3, "Standard"}}; + + const GameCreationOptions options = GameCreationOptions::fromSettings(settings, gameTypes, true); + + EXPECT_EQ(options.gameTypeIds, QList({1, 2})); +} + +TEST(GameCreationOptions, FromSettingsDoesNotMatchGameTypesWithoutTheLegacySeparator) +{ + FakeGameSettings settings; + settings.gameTypes = "Draft Commander"; + const QMap gameTypes{{1, "Draft"}, {2, "Commander"}}; + + const GameCreationOptions options = GameCreationOptions::fromSettings(settings, gameTypes, true); + + EXPECT_TRUE(options.gameTypeIds.isEmpty()); +} + +TEST(GameCreationOptions, FromGameInfoCopiesTheVisibleFields) +{ + ServerInfo_Game gameInfo; + gameInfo.set_description("Some game"); + gameInfo.set_max_players(4); + gameInfo.set_only_buddies(true); + gameInfo.set_only_registered(true); + gameInfo.set_spectators_allowed(false); + gameInfo.set_spectators_need_password(true); + gameInfo.set_spectators_can_chat(true); + gameInfo.set_spectators_omniscient(true); + gameInfo.set_share_decklists_on_load(true); + gameInfo.set_shuffle_players(true); + gameInfo.add_game_types(2); + + const QMap gameTypes{{1, "Draft"}, {2, "Commander"}}; + const GameCreationOptions options = GameCreationOptions::fromGameInfo(gameInfo, gameTypes); + + EXPECT_EQ(options.description, "Some game"); + EXPECT_EQ(options.maxPlayers, 4); + EXPECT_TRUE(options.onlyBuddies); + EXPECT_TRUE(options.onlyRegistered); + EXPECT_FALSE(options.spectatorsAllowed); + EXPECT_TRUE(options.spectatorsNeedPassword); + EXPECT_TRUE(options.spectatorsCanTalk); + EXPECT_TRUE(options.spectatorsSeeEverything); + EXPECT_TRUE(options.shareDecklistsOnLoad); + EXPECT_TRUE(options.shufflePlayers); + EXPECT_EQ(options.gameTypeIds, QList({2})); +} + +TEST(GameCreationOptions, FromGameInfoLeavesUnshownFieldsAtDefaults) +{ + ServerInfo_Game gameInfo; + gameInfo.set_max_players(3); + + const GameCreationOptions options = GameCreationOptions::fromGameInfo(gameInfo, {}); + + EXPECT_TRUE(options.password.isEmpty()); + EXPECT_EQ(options.startingLifeTotal, 20); + EXPECT_FALSE(options.joinAsJudge); + EXPECT_FALSE(options.joinAsSpectator); +} + +TEST(GameCreationOptions, FromGameInfoIgnoresUnknownGameTypeIds) +{ + ServerInfo_Game gameInfo; + gameInfo.add_game_types(7); + gameInfo.add_game_types(9); + + const QMap gameTypes{{1, "Draft"}, {7, "Commander"}}; + const GameCreationOptions options = GameCreationOptions::fromGameInfo(gameInfo, gameTypes); + + EXPECT_EQ(options.gameTypeIds, QList({7})); +} diff --git a/tests/icon_assets/CMakeLists.txt b/tests/icon_assets/CMakeLists.txt new file mode 100644 index 000000000..da9776f8e --- /dev/null +++ b/tests/icon_assets/CMakeLists.txt @@ -0,0 +1,3 @@ +cockatrice_add_test( + NAME icon_assets_test SOURCES icon_assets_test.cpp DEFINITIONS COCKATRICE_SOURCE_DIR="${CMAKE_SOURCE_DIR}" +) diff --git a/tests/icon_assets/icon_assets_test.cpp b/tests/icon_assets/icon_assets_test.cpp new file mode 100644 index 000000000..f0b06d5cc --- /dev/null +++ b/tests/icon_assets/icon_assets_test.cpp @@ -0,0 +1,241 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +const QString ICONS_RELATIVE_PATH = QStringLiteral("cockatrice/resources/icons"); + +QString iconsDir() +{ + return QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(ICONS_RELATIVE_PATH); +} + +/** @brief True for content art that is allowed to carry full color. */ +bool isContentArt(const QString &relativePath) +{ + return relativePath.startsWith(QStringLiteral("mana/")); +} + +QString violation(const QString &rule, const QString &relativePath) +{ + return rule + QLatin1Char(' ') + relativePath; +} + +/** + * @brief Reads one SVG and returns true when the root carries a usable viewBox. + * + * Also reports malformed XML and editor-only metadata through the out params. + */ +bool inspectSvg(const QString &absolutePath, + bool &validXml, + bool &hasViewBox, + bool &hasEditorMetadata, + bool &hasPhysicalSize) +{ + validXml = false; + hasViewBox = false; + hasEditorMetadata = false; + hasPhysicalSize = false; + + QFile file(absolutePath); + if (!file.open(QIODevice::ReadOnly)) { + return false; + } + + QXmlStreamReader reader(&file); + bool sawRoot = false; + + while (!reader.atEnd()) { + reader.readNext(); + + if (reader.hasError()) { + return false; + } + + if (!reader.isStartElement()) { + continue; + } + + if (!sawRoot) { + sawRoot = true; + if (reader.name() != QStringLiteral("svg")) { + return false; + } + const QString viewBox = reader.attributes().value(QStringLiteral("viewBox")).toString().simplified(); + hasViewBox = viewBox == QStringLiteral("0 0 24 24"); + hasPhysicalSize = !reader.attributes().value(QStringLiteral("width")).isEmpty() || + !reader.attributes().value(QStringLiteral("height")).isEmpty(); + } + + if (reader.name() == QStringLiteral("metadata") || reader.name() == QStringLiteral("namedview")) { + hasEditorMetadata = true; + } + + for (const QXmlStreamAttribute &attribute : reader.attributes()) { + const QString prefix = attribute.prefix().toString(); + if (prefix == QStringLiteral("inkscape") || prefix == QStringLiteral("sodipodi") || + prefix == QStringLiteral("dc") || prefix == QStringLiteral("cc") || prefix == QStringLiteral("rdf")) { + hasEditorMetadata = true; + } + } + } + + validXml = sawRoot && !reader.hasError(); + return validXml; +} + +bool isAllowedPaintValue(const QString &value) +{ + const QString normalized = value.trimmed().toLower(); + return normalized.isEmpty() || normalized == QStringLiteral("none") || + normalized == QStringLiteral("currentcolor") || normalized == QStringLiteral("inherit") || + normalized == QStringLiteral("context-fill") || normalized == QStringLiteral("context-stroke") || + normalized.startsWith(QStringLiteral("url(")); +} + +QRegularExpression paintValuePattern() +{ + static const QRegularExpression pattern( + QStringLiteral(R"re((?:fill|stroke|stop-color|color)\s*[:=]\s*["']?\s*([^"'\s;>]+))re"), + QRegularExpression::CaseInsensitiveOption); + return pattern; +} + +bool hasHardcodedColor(const QString &absolutePath) +{ + QFile file(absolutePath); + if (!file.open(QIODevice::ReadOnly)) { + return false; + } + + const QString content = QString::fromUtf8(file.readAll()); + auto matches = paintValuePattern().globalMatch(content); + while (matches.hasNext()) { + const QString value = matches.next().captured(1).trimmed(); + if (isAllowedPaintValue(value)) { + continue; + } + const QString normalized = value.toLower(); + if (normalized.startsWith(QLatin1Char('#')) || normalized.contains(QStringLiteral("rgb(")) || + normalized.contains(QStringLiteral("hsl(")) || QColor(value).isValid()) { + return true; + } + } + return false; +} + +bool hasCurrentColorToken(const QString &absolutePath) +{ + QFile file(absolutePath); + if (!file.open(QIODevice::ReadOnly)) { + return false; + } + + const QString content = QString::fromUtf8(file.readAll()); + auto matches = paintValuePattern().globalMatch(content); + while (matches.hasNext()) { + if (matches.next().captured(1).trimmed().compare(QStringLiteral("currentColor"), Qt::CaseInsensitive) == 0) { + return true; + } + } + return false; +} + +bool isSchemeVariant(const QString &fileName) +{ + return fileName.contains(QStringLiteral("-dark."), Qt::CaseInsensitive) || + fileName.contains(QStringLiteral("-light."), Qt::CaseInsensitive); +} + +bool hasColorInFilename(const QString &fileName) +{ + static const QRegularExpression colorSuffix( + QStringLiteral(R"(_(green|blue|red|yellow|black|white|gray|grey)\.svg$)"), + QRegularExpression::CaseInsensitiveOption); + return colorSuffix.match(fileName).hasMatch(); +} + +/** @brief Collects every current violation as "rule relative/path.svg". */ +QSet collectViolations() +{ + QSet violations; + + QDirIterator it(iconsDir(), QDir::Files, QDirIterator::Subdirectories); + while (it.hasNext()) { + it.next(); + const QFileInfo info = it.fileInfo(); + const QString relativePath = QDir(iconsDir()).relativeFilePath(info.absoluteFilePath()); + if (isContentArt(relativePath)) { + continue; + } + if (info.suffix().compare(QStringLiteral("svg"), Qt::CaseInsensitive) != 0) { + violations.insert(violation(QStringLiteral("raster"), relativePath)); + continue; + } + + bool validXml = false; + bool hasViewBox = false; + bool hasEditorMetadata = false; + bool hasPhysicalSize = false; + inspectSvg(info.absoluteFilePath(), validXml, hasViewBox, hasEditorMetadata, hasPhysicalSize); + + if (!validXml) { + violations.insert(violation(QStringLiteral("xml"), relativePath)); + } + if (validXml && !hasViewBox) { + violations.insert(violation(QStringLiteral("viewbox"), relativePath)); + } + if (hasPhysicalSize) { + violations.insert(violation(QStringLiteral("physical-size"), relativePath)); + } + if (hasEditorMetadata) { + violations.insert(violation(QStringLiteral("editor-metadata"), relativePath)); + } + if (hasHardcodedColor(info.absoluteFilePath()) && !isSchemeVariant(info.fileName())) { + violations.insert(violation(QStringLiteral("hardcoded-color"), relativePath)); + } + if (!hasCurrentColorToken(info.absoluteFilePath()) && !isSchemeVariant(info.fileName())) { + violations.insert(violation(QStringLiteral("no-token"), relativePath)); + } + if (hasColorInFilename(info.fileName())) { + violations.insert(violation(QStringLiteral("color-filename"), relativePath)); + } + } + + return violations; +} + +QString formatSet(const QSet &entries) +{ + QStringList lines(entries.begin(), entries.end()); + lines.sort(); + return lines.join(QLatin1Char('\n')); +} + +} // namespace + +/** + * @brief Every UI glyph follows the palette-token rules. + * + * The legacy set was paid down icon by icon, so no baseline grandfathers + * violations: a new violation fails right here. Scheme variants + * (`*-dark.svg`/`*-light.svg`) and mana art are exempt inside collectViolations(). + */ +TEST(IconAssets, AssetsFollowTheRules) +{ + ASSERT_TRUE(QDir::isAbsolutePath(iconsDir())); + ASSERT_TRUE(QDir(iconsDir()).exists()) << "Missing icon directory: " << iconsDir().toStdString(); + + const QSet violations = collectViolations(); + EXPECT_TRUE(violations.isEmpty()) << "Icon rule violations. Fix the icon; do not add exceptions:\n" + << formatSet(violations).toStdString(); +} diff --git a/tests/image_redirect_chain_test.cpp b/tests/image_redirect_chain_test.cpp new file mode 100644 index 000000000..072512d96 --- /dev/null +++ b/tests/image_redirect_chain_test.cpp @@ -0,0 +1,82 @@ +#include "../cockatrice/src/interface/card_picture_loader/redirect_chain.h" + +#include + +namespace +{ +using RedirectCache = QHash>; + +QUrl url(int index) +{ + return QUrl(QStringLiteral("https://cards.example/%1").arg(index)); +} + +RedirectCache chain(int hops) +{ + RedirectCache cache; + for (int index = 0; index < hops; ++index) { + cache.insert(url(index), {url(index + 1), QDateTime()}); + } + return cache; +} +} // namespace + +TEST(ImageRedirectChain, MissingHopReturnsLastKnownUrlWithoutRemovingMappings) +{ + auto cache = chain(2); + const auto original = cache; + EXPECT_EQ(resolveImageRedirects(url(0), cache, 10), url(2)); + EXPECT_EQ(cache, original); + EXPECT_EQ(resolveImageRedirects(url(9), cache, 10), url(9)); +} + +TEST(ImageRedirectChain, ExactlyMaxHopsResolvesAndKeepsCache) +{ + auto cache = chain(10); + const auto original = cache; + EXPECT_EQ(resolveImageRedirects(url(0), cache, 10), url(10)); + EXPECT_EQ(cache, original); +} + +TEST(ImageRedirectChain, CycleReturnsOriginalAndPurgesVisitedMappings) +{ + auto cache = chain(2); + cache.insert(url(2), {url(1), QDateTime()}); + cache.insert(url(9), {url(10), QDateTime()}); + EXPECT_EQ(resolveImageRedirects(url(0), cache, 10), url(0)); + EXPECT_FALSE(cache.contains(url(0))); + EXPECT_FALSE(cache.contains(url(1))); + EXPECT_FALSE(cache.contains(url(2))); + EXPECT_TRUE(cache.contains(url(9))); +} + +TEST(ImageRedirectChain, OverlongChainReturnsOriginalAndPurgesVisitedMappings) +{ + auto cache = chain(12); + cache.insert(url(20), {url(21), QDateTime()}); + EXPECT_EQ(resolveImageRedirects(url(0), cache, 10), url(0)); + for (int index = 0; index < 10; ++index) { + EXPECT_FALSE(cache.contains(url(index))); + } + EXPECT_TRUE(cache.contains(url(10))); + EXPECT_TRUE(cache.contains(url(11))); + EXPECT_TRUE(cache.contains(url(20))); +} + +TEST(ImageRedirectChain, EmptyTargetStopsAtItsSource) +{ + auto cache = chain(1); + cache.insert(url(1), {QUrl(), QDateTime()}); + const auto original = cache; + EXPECT_EQ(resolveImageRedirects(url(0), cache, 10), url(1)); + EXPECT_EQ(cache, original); +} + +TEST(ImageRedirectChain, ZeroHopsDoesNotFollowOrRemoveMappings) +{ + auto cache = chain(1); + EXPECT_EQ(resolveImageRedirects(url(5), cache, 0), url(5)); + EXPECT_TRUE(cache.contains(url(0))); + EXPECT_EQ(resolveImageRedirects(url(0), cache, 0), url(0)); + EXPECT_TRUE(cache.contains(url(0))); +} diff --git a/tests/image_request_queue_test.cpp b/tests/image_request_queue_test.cpp new file mode 100644 index 000000000..b911483c1 --- /dev/null +++ b/tests/image_request_queue_test.cpp @@ -0,0 +1,85 @@ +#include "../cockatrice/src/interface/card_picture_loader/card_picture_loader_request_queue.h" + +#include +#include +#include +#include +#include + +namespace +{ +using TestRequest = QPair>; + +QUrl url(int index) +{ + return QUrl(QStringLiteral("https://cards.example/%1").arg(index)); +} + +TestRequest live(int index, QObject &owner) +{ + return qMakePair(url(index), QPointer(&owner)); +} + +TestRequest cancelled(int index) +{ + return qMakePair(url(index), QPointer()); +} + +QList queuedUrls(const QQueue &queue) +{ + QList urls; + for (const auto &entry : queue) { + urls.append(entry.first); + } + return urls; +} +} // namespace + +TEST(PictureLoaderRequestQueue, EmptyQueueHasNothingToRetire) +{ + QQueue queue; + EXPECT_TRUE(takeCancelledRequests(queue).isEmpty()); + EXPECT_TRUE(queue.isEmpty()); +} + +TEST(PictureLoaderRequestQueue, LiveRequestsStayQueuedInOrder) +{ + QObject first; + QObject second; + QQueue queue; + queue.enqueue(live(1, first)); + queue.enqueue(live(2, second)); + + EXPECT_TRUE(takeCancelledRequests(queue).isEmpty()); + EXPECT_EQ(queuedUrls(queue), (QList{url(1), url(2)})); +} + +TEST(PictureLoaderRequestQueue, CancelledRequestsAreRetiredInQueueOrder) +{ + QObject owner; + QQueue queue; + queue.enqueue(live(1, owner)); + queue.enqueue(cancelled(2)); + queue.enqueue(live(3, owner)); + queue.enqueue(cancelled(4)); + queue.enqueue(cancelled(5)); + + // Exactly one status entry is retired per vanished work item, in queue order. + EXPECT_EQ(takeCancelledRequests(queue), (QList{url(2), url(4), url(5)})); + // The surviving requests keep their relative order. + EXPECT_EQ(queuedUrls(queue), (QList{url(1), url(3)})); + + // Nothing is retired twice on a later sweep. + EXPECT_TRUE(takeCancelledRequests(queue).isEmpty()); + EXPECT_EQ(queuedUrls(queue), (QList{url(1), url(3)})); +} + +TEST(PictureLoaderRequestQueue, AllCancelledEmptiesTheQueue) +{ + QQueue queue; + queue.enqueue(cancelled(1)); + queue.enqueue(cancelled(2)); + + EXPECT_EQ(takeCancelledRequests(queue), (QList{url(1), url(2)})); + EXPECT_TRUE(queue.isEmpty()); +} diff --git a/tests/latency_tracker_test.cpp b/tests/latency_tracker_test.cpp index a77051799..a8ecf9e8d 100644 --- a/tests/latency_tracker_test.cpp +++ b/tests/latency_tracker_test.cpp @@ -3,8 +3,12 @@ #include #include #include +#include #include +// Referenced by other translation units in the server_remote library. +RNG_Abstract *rng = nullptr; + TEST(LatencyTrackerTest, EmptyTrackerYieldsZeroedStats) { LatencyTracker tracker; diff --git a/tests/loader_local_matching_test.cpp b/tests/loader_local_matching_test.cpp index 7632e60d4..ef6018790 100644 --- a/tests/loader_local_matching_test.cpp +++ b/tests/loader_local_matching_test.cpp @@ -23,10 +23,14 @@ #include #include #include +#include #include #include #include +// Referenced by other translation units in the server_remote library. +RNG_Abstract *rng = nullptr; + namespace { diff --git a/tests/movecard_tests/CMakeLists.txt b/tests/movecard_tests/CMakeLists.txt index 68d8fc84c..35d7b44ed 100755 --- a/tests/movecard_tests/CMakeLists.txt +++ b/tests/movecard_tests/CMakeLists.txt @@ -9,3 +9,15 @@ cockatrice_add_test( LABELS server ) + +cockatrice_add_test( + NAME + player_order_test + SOURCES + player_order_test.cpp + LIBS + libcockatrice_network_server_remote + libcockatrice_rng + LABELS + server +) diff --git a/tests/movecard_tests/player_order_test.cpp b/tests/movecard_tests/player_order_test.cpp new file mode 100644 index 000000000..6e398af24 --- /dev/null +++ b/tests/movecard_tests/player_order_test.cpp @@ -0,0 +1,268 @@ +#include "game/game_config.h" +#include "game/role_info.h" +#include "game/server_abstract_participant.h" +#include "game/server_game.h" +#include "server_abstractuserinterface.h" +#include "server_response_containers.h" +#include "server_room.h" +#include "server_test_helpers.h" + +#include +#include +#include +#include +#include +#include +#include + +RNG_Abstract *rng = nullptr; // this needs to be defined due to other functions in server + +class ScriptedRng : public RNG_Abstract +{ +public: + QList values; + + unsigned int rand(int min, int max) override + { + const unsigned int value = values.takeFirst(); + EXPECT_GE(value, static_cast(min)); + EXPECT_LE(value, static_cast(max)); + return value; + } +}; + +class FakeUserInterface : public Server_AbstractUserInterface +{ +public: + FakeUserInterface(Server *_server, const ServerInfo_User &_userInfo) : Server_AbstractUserInterface(_server) + { + setUserInfo(_userInfo); + } + + int getLastCommandTime() const override + { + return 0; + } + + bool addSaidMessageSize(int) override + { + return true; + } + + void sendProtocolItem(const Response &) override + { + } + + void sendProtocolItem(const SessionEvent &) override + { + } + + void sendProtocolItem(const GameEventContainer &) override + { + } + + void sendProtocolItem(const RoomEvent &) override + { + } +}; + +namespace +{ +QStringList participantNamesInOrder(const Server_Game &game) +{ + QStringList names; + for (int id : game.getSeatOrder()) { + const auto *participant = game.getParticipants().value(id, nullptr); + if (participant) { + names.append(QString::fromStdString(participant->getUserInfo()->name())); + } + } + return names; +} + +QList participantIdsInOrder(const Server_Game &game) +{ + return game.getSeatOrder(); +} +} // namespace + +class PlayerOrderTest : public ::testing::Test +{ +protected: + ServerInfo_User creator; + ServerInfo_User userB; + ServerInfo_User userC; + + PlayerOrderTest() + { + creator.set_name("A"); + creator.set_user_level(ServerInfo_User::IsRegistered); + userB.set_name("B"); + userB.set_user_level(ServerInfo_User::IsRegistered); + userC.set_name("C"); + userC.set_user_level(ServerInfo_User::IsRegistered); + } +}; + +TEST_F(PlayerOrderTest, JoinOrderIsSeatOrder) +{ + FakeServer server; + Server_Room room(0, 0, "", "", "", "", false, "", {}, &server); + FakeUserInterface uiA(&server, creator); + FakeUserInterface uiB(&server, userB); + FakeUserInterface uiC(&server, userC); + GameConfig config{.creatorInfo = creator, + .gameId = 1, + .description = QString(), + .password = QString(), + .maxPlayers = 3, + .gameTypes = QList(), + .onlyBuddies = false, + .onlyRegistered = false, + .spectatorsAllowed = false, + .spectatorsNeedPassword = false, + .spectatorsCanTalk = false, + .spectatorsSeeEverything = false, + .startingLifeTotal = 20, + .shareDecklistsOnLoad = false, + .shufflePlayers = false}; + Server_Game game(config, &room); + ResponseContainer rc(0); + + game.addPlayer(&uiA, rc, RoleInfo{}, false); + game.addPlayer(&uiB, rc, RoleInfo{}, false); + game.addPlayer(&uiC, rc, RoleInfo{}, false); + + EXPECT_EQ(participantNamesInOrder(game), QStringList({"A", "B", "C"})); + EXPECT_EQ(participantIdsInOrder(game), QList({0, 1, 2})); + EXPECT_EQ(game.getHostId(), 0); + EXPECT_EQ(game.getPlayerCount(), 3); +} + +TEST_F(PlayerOrderTest, ReorderPlayerSeats) +{ + FakeServer server; + Server_Room room(0, 0, "", "", "", "", false, "", {}, &server); + FakeUserInterface uiA(&server, creator); + FakeUserInterface uiB(&server, userB); + FakeUserInterface uiC(&server, userC); + GameConfig config{.creatorInfo = creator, + .gameId = 1, + .description = QString(), + .password = QString(), + .maxPlayers = 3, + .gameTypes = QList(), + .onlyBuddies = false, + .onlyRegistered = false, + .spectatorsAllowed = false, + .spectatorsNeedPassword = false, + .spectatorsCanTalk = false, + .spectatorsSeeEverything = false, + .startingLifeTotal = 20, + .shareDecklistsOnLoad = false, + .shufflePlayers = false}; + Server_Game game(config, &room); + ResponseContainer rc(0); + + game.addPlayer(&uiA, rc, RoleInfo{}, false); + game.addPlayer(&uiB, rc, RoleInfo{}, false); + game.addPlayer(&uiC, rc, RoleInfo{}, false); + + game.reorderPlayerSeats({"C", "A", "B"}); + + // player ids are stable; only the seat order changes + EXPECT_EQ(participantNamesInOrder(game), QStringList({"C", "A", "B"})); + EXPECT_EQ(participantIdsInOrder(game), QList({2, 0, 1})); + EXPECT_EQ(game.getParticipants().size(), 3); + + // the host keeps its seat: creator A is still in the game, so host stays 0 + EXPECT_EQ(game.getHostId(), 0); + + // no re-keying means every mapping keyed by player id stays valid + const int gameId = game.getGameId(); + EXPECT_EQ(uiA.getGames().value(gameId).second, 0); + EXPECT_EQ(uiB.getGames().value(gameId).second, 1); + EXPECT_EQ(uiC.getGames().value(gameId).second, 2); +} + +TEST_F(PlayerOrderTest, ReorderPlayerSeatsKeepsPlayerCount) +{ + FakeServer server; + Server_Room room(0, 0, "", "", "", "", false, "", {}, &server); + FakeUserInterface uiA(&server, creator); + FakeUserInterface uiB(&server, userB); + FakeUserInterface uiC(&server, userC); + GameConfig config{.creatorInfo = creator, + .gameId = 1, + .description = QString(), + .password = QString(), + .maxPlayers = 3, + .gameTypes = QList(), + .onlyBuddies = false, + .onlyRegistered = false, + .spectatorsAllowed = false, + .spectatorsNeedPassword = false, + .spectatorsCanTalk = false, + .spectatorsSeeEverything = false, + .startingLifeTotal = 20, + .shareDecklistsOnLoad = false, + .shufflePlayers = false}; + Server_Game game(config, &room); + ResponseContainer rc(0); + + game.addPlayer(&uiA, rc, RoleInfo{}, false); + game.addPlayer(&uiB, rc, RoleInfo{}, false); + game.addPlayer(&uiC, rc, RoleInfo{}, false); + + // listing only a subset appends the remaining players in their current order + game.reorderPlayerSeats({"C"}); + + EXPECT_EQ(participantNamesInOrder(game), QStringList({"C", "A", "B"})); + EXPECT_EQ(participantIdsInOrder(game), QList({2, 0, 1})); + EXPECT_EQ(game.getPlayerCount(), 3); + EXPECT_EQ(game.getHostId(), 0); +} + +TEST_F(PlayerOrderTest, ShufflePlayerSeatsIsAPermutation) +{ + FakeServer server; + Server_Room room(0, 0, "", "", "", "", false, "", {}, &server); + FakeUserInterface uiA(&server, creator); + FakeUserInterface uiB(&server, userB); + FakeUserInterface uiC(&server, userC); + GameConfig config{.creatorInfo = creator, + .gameId = 1, + .description = QString(), + .password = QString(), + .maxPlayers = 3, + .gameTypes = QList(), + .onlyBuddies = false, + .onlyRegistered = false, + .spectatorsAllowed = false, + .spectatorsNeedPassword = false, + .spectatorsCanTalk = false, + .spectatorsSeeEverything = false, + .startingLifeTotal = 20, + .shareDecklistsOnLoad = false, + .shufflePlayers = false}; + Server_Game game(config, &room); + ResponseContainer rc(0); + + game.addPlayer(&uiA, rc, RoleInfo{}, false); + game.addPlayer(&uiB, rc, RoleInfo{}, false); + game.addPlayer(&uiC, rc, RoleInfo{}, false); + + // fisher-yates on [A, B, C]: rand(0, 2) -> 1 (take B), rand(0, 1) -> 0 (take A), rand(0, 0) -> 0 (take C) + auto *scriptedRng = new ScriptedRng; + scriptedRng->values = {1, 0, 0}; + rng = scriptedRng; + + game.shufflePlayerSeats(); + + EXPECT_EQ(participantNamesInOrder(game), QStringList({"B", "A", "C"})); + EXPECT_EQ(participantIdsInOrder(game), QList({1, 0, 2})); + QSet idSet(participantIdsInOrder(game).cbegin(), participantIdsInOrder(game).cend()); + EXPECT_EQ(idSet.size(), 3); // ids are unique + // ids are stable, so the host (creator A, id 0) is unchanged + EXPECT_EQ(game.getHostId(), 0); +} diff --git a/tests/moxfield_signature_spells_test.cpp b/tests/moxfield_signature_spells_test.cpp new file mode 100644 index 000000000..54346a934 --- /dev/null +++ b/tests/moxfield_signature_spells_test.cpp @@ -0,0 +1,84 @@ +#include "client/network/parsers/interface_json_deck_parser.h" + +#include +#include +#include +#include + +TEST(MoxfieldImport, SignatureSpellIsAddedToMainWithoutReplacingCommanderBanner) +{ + // Moxfield v2 board objects map IDs to entries with quantity and card. + const auto input = QJsonDocument::fromJson(R"json({ + "name": "Oathbreaker example", "description": "Notes retained", + "mainboard": {"land": {"quantity": 1, "card": {"name": "Island", "set": "abc", "cn": "1"}}}, + "sideboard": {"side": {"quantity": 1, "card": {"name": "Mountain", "set": "abc", "cn": "2"}}}, + "commanders": {"walker": {"quantity": 1, "card": {"name": "Jace", "set": "abc", "cn": "3", "scryfall_id": "walker-id"}}}, + "signatureSpells": {"spell": {"quantity": 1, "card": {"name": "Lightning Bolt", "set": "xyz", "cn": "4", "scryfall_id": "spell-id"}}} + })json") + .object(); + MoxfieldJsonParser parser; + DeckList deck = parser.parse(input); + int mainSpellCount = 0; + int sideSpellCount = 0; + int mainLands = 0; + int sideLands = 0; + deck.forEachCard([&](InnerDecklistNode *zone, DecklistCardNode *card) { + if (card->getName() == "Island" && zone->getName() == DECK_ZONE_MAIN) { + mainLands += card->getNumber(); + } + if (card->getName() == "Mountain" && zone->getName() == DECK_ZONE_SIDE) { + sideLands += card->getNumber(); + } + if (card->getName() != "Lightning Bolt") { + return; + } + if (zone->getName() == DECK_ZONE_MAIN) { + mainSpellCount += card->getNumber(); + EXPECT_EQ(card->getCardSetShortName(), "XYZ"); + EXPECT_EQ(card->getCardCollectorNumber(), "4"); + EXPECT_EQ(card->getCardProviderId(), "spell-id"); + } else if (zone->getName() == DECK_ZONE_SIDE) { + sideSpellCount += card->getNumber(); + } + }); + EXPECT_EQ(mainSpellCount, 1); + EXPECT_EQ(sideSpellCount, 0); + EXPECT_EQ(mainLands, 1); + EXPECT_EQ(sideLands, 1); + EXPECT_EQ(deck.getName(), "Oathbreaker example"); + EXPECT_EQ(deck.getComments(), "Notes retained"); + EXPECT_EQ(deck.getBannerCard().name, "Jace"); + EXPECT_EQ(deck.getBannerCard().providerId, "walker-id"); +} + +TEST(MoxfieldImport, QuantityAwareDeckInsertionRetainsCountAndPrinting) +{ + DeckList deck; + auto *card = deck.addCards("Island", 2, DECK_ZONE_MAIN, -1, "ABC", "4", "id"); + ASSERT_NE(card, nullptr); + EXPECT_EQ(card->getNumber(), 2); + EXPECT_EQ(card->getCardProviderId(), "id"); + EXPECT_EQ(deck.addCards("Island", 0, DECK_ZONE_MAIN), nullptr); +} + +TEST(OnlineImport, ArchidektPreservesNameDescriptionAndCards) +{ + const auto input = QJsonDocument::fromJson(R"json({ + "name": "Online deck", "description": "Deck notes", + "cards": [{"quantity": 2, "card": { + "oracleCard": {"name": "Island"}, "edition": {"editioncode": "abc"}, "collectorNumber": "1" + }}] + })json") + .object(); + ArchidektJsonParser parser; + const DeckList deck = parser.parse(input); + int count = 0; + deck.forEachCard([&](InnerDecklistNode *zone, DecklistCardNode *card) { + if (zone->getName() == DECK_ZONE_MAIN && card->getName() == "Island") { + count += card->getNumber(); + } + }); + EXPECT_EQ(count, 2); + EXPECT_EQ(deck.getName(), "Online deck"); + EXPECT_EQ(deck.getComments(), "Deck notes"); +} diff --git a/tests/oracle/CMakeLists.txt b/tests/oracle/CMakeLists.txt index 6958673f7..3f657412b 100644 --- a/tests/oracle/CMakeLists.txt +++ b/tests/oracle/CMakeLists.txt @@ -1,3 +1,35 @@ +# Optional compression libs, mirrored from oracle/CMakeLists.txt, so the tag database +# loader can decompress whatever format the tag source serves, and the benchmark can +# download and decompress whatever AllPrintings format the default URL selects. +# add_definitions() is directory-scoped and order-independent, so HAS_ZLIB/HAS_LZMA +# reach every test target below regardless of where they are declared. +set(_ORACLE_COMPRESSION_SOURCES) +set(_ORACLE_COMPRESSION_LIBRARIES) + +find_package(ZLIB) +if(ZLIB_FOUND) + add_definitions("-DHAS_ZLIB") + list(APPEND _ORACLE_COMPRESSION_SOURCES ../../oracle/src/zip/unzip.cpp ../../oracle/src/zip/zipglobal.cpp) + cockatrice_disable_sanitizers_for(${_ORACLE_COMPRESSION_SOURCES}) + cockatrice_disable_coverage_for(${_ORACLE_COMPRESSION_SOURCES}) + list(APPEND _ORACLE_COMPRESSION_LIBRARIES ${ZLIB_LIBRARIES}) + include_directories(${ZLIB_INCLUDE_DIRS}) +else() + message(STATUS "Oracle tests: zlib not found; gzip/zip tag databases and the zip download benchmark disabled") +endif() + +find_package(LibLZMA) +if(LIBLZMA_FOUND) + add_definitions("-DHAS_LZMA") + list(APPEND _ORACLE_COMPRESSION_SOURCES ../../oracle/src/lzma/decompress.cpp) + cockatrice_disable_sanitizers_for(../../oracle/src/lzma/decompress.cpp) + cockatrice_disable_coverage_for(../../oracle/src/lzma/decompress.cpp) + list(APPEND _ORACLE_COMPRESSION_LIBRARIES ${LIBLZMA_LIBRARIES}) + include_directories(${LIBLZMA_INCLUDE_DIRS}) +else() + message(STATUS "Oracle tests: LibLZMA not found; xz tag databases and the xz download benchmark disabled") +endif() + cockatrice_add_test(NAME parse_cipt_test SOURCES ../../oracle/src/parsehelpers.cpp parse_cipt_test.cpp) # Oracle importer unit tests @@ -9,42 +41,30 @@ cockatrice_add_test( ../../oracle/src/oracleimporter.cpp ../../oracle/src/parsehelpers.cpp ../../oracle/src/raw_json_scanner.cpp + ../../oracle/src/tag_database.cpp + ../../oracle/src/database_writer.cpp oracle_importer_test.cpp + ${_ORACLE_COMPRESSION_SOURCES} LIBS libcockatrice_card libcockatrice_interfaces + EXTRA_LIBS + ${_ORACLE_COMPRESSION_LIBRARIES} +) + +# Tag database format detection, parsing and parent expansion +cockatrice_add_test( + NAME + tag_database_test + SOURCES + ../../oracle/src/tag_database.cpp + tag_database_test.cpp + ${_ORACLE_COMPRESSION_SOURCES} + EXTRA_LIBS + ${_ORACLE_COMPRESSION_LIBRARIES} ) # Oracle importer benchmark tests (manual, not run in CI, incl. RAM benchmark) -# Optional compression libs, mirrored from oracle/CMakeLists.txt, so the benchmark -# can download and decompress whatever AllPrintings format the default URL selects. -# add_definitions() is directory-scoped and order-independent, so HAS_ZLIB/HAS_LZMA also -# reach the two unit tests above. That is harmless: those macros gate code only in -# oracle/src/pages.cpp and oracle/src/pagetemplates.cpp, which no test target compiles. -find_package(ZLIB) -if(ZLIB_FOUND) - add_definitions("-DHAS_ZLIB") - set(_ORACLE_BENCH_EXTRA_SOURCES ../../oracle/src/zip/unzip.cpp ../../oracle/src/zip/zipglobal.cpp) - cockatrice_disable_sanitizers_for(${_ORACLE_BENCH_EXTRA_SOURCES}) - cockatrice_disable_coverage_for(${_ORACLE_BENCH_EXTRA_SOURCES}) - set(_ORACLE_BENCH_EXTRA_LIBRARIES ${ZLIB_LIBRARIES}) - include_directories(${ZLIB_INCLUDE_DIRS}) -else() - message(STATUS "Oracle tests: zlib not found; zip download benchmark disabled") -endif() - -find_package(LibLZMA) -if(LIBLZMA_FOUND) - add_definitions("-DHAS_LZMA") - list(APPEND _ORACLE_BENCH_EXTRA_SOURCES ../../oracle/src/lzma/decompress.cpp) - cockatrice_disable_sanitizers_for(../../oracle/src/lzma/decompress.cpp) - cockatrice_disable_coverage_for(../../oracle/src/lzma/decompress.cpp) - list(APPEND _ORACLE_BENCH_EXTRA_LIBRARIES ${LIBLZMA_LIBRARIES}) - include_directories(${LIBLZMA_INCLUDE_DIRS}) -else() - message(STATUS "Oracle tests: LibLZMA not found; xz download benchmark disabled") -endif() - cockatrice_add_manual_test( NAME oracle_importer_benchmark_test @@ -53,12 +73,13 @@ cockatrice_add_manual_test( ../../oracle/src/oracleimporter.cpp ../../oracle/src/parsehelpers.cpp ../../oracle/src/raw_json_scanner.cpp + ../../oracle/src/tag_database.cpp oracle_importer_benchmark_test.cpp - ${_ORACLE_BENCH_EXTRA_SOURCES} + ${_ORACLE_COMPRESSION_SOURCES} LIBS libcockatrice_card libcockatrice_interfaces EXTRA_LIBS - ${_ORACLE_BENCH_EXTRA_LIBRARIES} + ${_ORACLE_COMPRESSION_LIBRARIES} MANUAL ) diff --git a/tests/oracle/oracle_importer_test.cpp b/tests/oracle/oracle_importer_test.cpp index 83d77d465..84995797d 100644 --- a/tests/oracle/oracle_importer_test.cpp +++ b/tests/oracle/oracle_importer_test.cpp @@ -1,11 +1,15 @@ +#include "../../oracle/src/database_writer.h" #include "../../oracle/src/oracleimporter.h" #include "../../oracle/src/raw_json_scanner.h" +#include "../../oracle/src/tag_database.h" +#include "../support/scoped_file_size_limit.h" #include "libcockatrice/card/card_info.h" #include "gtest/gtest.h" #include #include #include +#include #include #include #include @@ -20,6 +24,7 @@ #include #include #include +#include #include #include #include @@ -542,6 +547,158 @@ TEST_F(OracleImporterTest, LegacyConvertedManaCostCoercedToCmc) ASSERT_EQ(result->getProperty("cmc"), "3"); } +// ============================================================================ +// Scryfall Tagger tag tests +// ============================================================================ + +TEST_F(OracleImporterTest, ImportsScryfallTags) +{ + QJsonObject card = makeCard("Ramp Card"); + card["tags"] = QJsonArray{"ramp", "removal"}; + QJsonArray cards{card}; + + importer->importCardsFromSet(set, cards); + auto result = importer->getCardList().value("Ramp Card"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getProperty("tags"), "ramp removal"); +} + +TEST_F(OracleImporterTest, TagsAreNormalizedDedupedAndSorted) +{ + QJsonObject card = makeCard("Messy Tags"); + card["tags"] = QJsonArray{"Ramp", " removal ", "ramp", ""}; + QJsonArray cards{card}; + + importer->importCardsFromSet(set, cards); + auto result = importer->getCardList().value("Messy Tags"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getProperty("tags"), "ramp removal"); +} + +TEST_F(OracleImporterTest, CardsWithoutTagsHaveNoTagsProperty) +{ + QJsonArray cards{makeCard("Untagged Card")}; + importer->importCardsFromSet(set, cards); + + auto result = importer->getCardList().value("Untagged Card"); + ASSERT_FALSE(result.isNull()); + ASSERT_FALSE(result->hasProperty("tags")); +} + +TEST_F(OracleImporterTest, SplitCardTagsAreUnioned) +{ + QJsonObject face1 = makeCard("Fire // Ice"); + face1["layout"] = "split"; + face1["side"] = "a"; + face1["faceName"] = "Fire"; + face1["tags"] = QJsonArray{"removal"}; + QJsonObject face2 = makeCard("Fire // Ice"); + face2["layout"] = "split"; + face2["side"] = "b"; + face2["faceName"] = "Ice"; + face2["tags"] = QJsonArray{"card-advantage", "removal"}; + QJsonArray cards{face1, face2}; + + importer->importCardsFromSet(set, cards); + auto result = importer->getCardList().value("Fire // Ice"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getProperty("tags"), "card-advantage removal"); +} + +// ============================================================================ +// Tag database join tests +// ============================================================================ + +TEST_F(OracleImporterTest, AppliesTagDatabaseByOracleId) +{ + QJsonObject card = makeCard("Tagged By Id"); + QJsonObject identifiers = card["identifiers"].toObject(); + identifiers["scryfallOracleId"] = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + card["identifiers"] = identifiers; + + QString error; + const auto database = TagDatabase::loadFromData( + R"json({"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa": ["removal", "ramp"]})json", TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + importer->setTagDatabase(database); + + importer->importCardsFromSet(set, QJsonArray{card}); + + auto result = importer->getCardList().value("Tagged By Id"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getProperty("tags"), "ramp removal"); +} + +TEST_F(OracleImporterTest, AppliesTagDatabaseByNameWhenOracleIdIsMissing) +{ + QJsonObject card = makeCard("Lightning Bolt"); + + QString error; + const auto database = TagDatabase::loadFromData(R"json({"Lightning Bolt": ["burn", "removal"]})json", + TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + importer->setTagDatabase(database); + + importer->importCardsFromSet(set, QJsonArray{card}); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getProperty("tags"), "burn removal"); +} + +TEST_F(OracleImporterTest, TagDatabaseUnionsWithSourceTags) +{ + QJsonObject card = makeCard("Union Card"); + QJsonObject identifiers = card["identifiers"].toObject(); + identifiers["scryfallOracleId"] = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + card["identifiers"] = identifiers; + card["tags"] = QJsonArray{"ramp"}; + + QString error; + const auto database = TagDatabase::loadFromData( + R"json({"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa": ["removal", "ramp"]})json", TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + importer->setTagDatabase(database); + + importer->importCardsFromSet(set, QJsonArray{card}); + + auto result = importer->getCardList().value("Union Card"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getProperty("tags"), "ramp removal"); +} + +TEST_F(OracleImporterTest, TagDatabaseSplitFacesUnionTheirTags) +{ + QJsonObject face1 = makeCard("Fire // Ice"); + face1["layout"] = "split"; + face1["side"] = "a"; + face1["faceName"] = "Fire"; + QJsonObject identifiers1 = face1["identifiers"].toObject(); + identifiers1["scryfallOracleId"] = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"; + face1["identifiers"] = identifiers1; + + QJsonObject face2 = makeCard("Fire // Ice"); + face2["layout"] = "split"; + face2["side"] = "b"; + face2["faceName"] = "Ice"; + QJsonObject identifiers2 = face2["identifiers"].toObject(); + identifiers2["scryfallOracleId"] = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"; + face2["identifiers"] = identifiers2; + + QString error; + const auto database = TagDatabase::loadFromData( + R"json({"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa": ["removal"], "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb": ["card-advantage"]})json", + TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + importer->setTagDatabase(database); + + importer->importCardsFromSet(set, QJsonArray{face1, face2}); + + auto result = importer->getCardList().value("Fire // Ice"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getProperty("tags"), "card-advantage removal"); +} + // ============================================================================ // Card deduplication tests // ============================================================================ @@ -1140,6 +1297,208 @@ TEST_F(OracleImporterTest, StartImportAppliesLocalizedData) ASSERT_EQ(result->getLocalizedText("pt"), "Raio causa 3 de dano a qualquer alvo."); } +namespace +{ +QByteArray refreshSource(const QString &name = "New Card") +{ + QJsonObject card{{"name", name}, {"text", "Rules text."}, {"layout", "normal"}, + {"type", "Artifact"}, {"number", "1"}, {"rarity", "common"}}; + QJsonObject set{{"code", "TST"}, {"name", "Test Set"}, {"type", "expansion"}, {"cards", QJsonArray{card}}}; + return QJsonDocument(QJsonObject{{"data", QJsonObject{{"TST", set}}}}).toJson(QJsonDocument::Compact); +} +} // namespace + +TEST_F(OracleImporterTest, FailedLazyParseKeepsInstalledDatabase) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = dir.filePath("cards.xml"); + QFile original(path); + ASSERT_TRUE(original.open(QIODevice::WriteOnly)); + ASSERT_EQ(original.write("existing database"), 17); + original.close(); + + // The metadata scanner skips numeric values without converting them. Qt's + // per-set parser rejects an overflowing number after selection succeeds. + auto badSet = QJsonDocument::fromJson(refreshSource()).object().value("data").toObject().value("TST").toObject(); + auto goodSet = badSet; + goodSet["code"] = "AAA"; + goodSet["name"] = "A successful first set"; + auto badCard = badSet.value("cards").toArray().first().toObject(); + badCard["number"] = "overflow"; + badSet["cards"] = QJsonArray{badCard}; + QByteArray data = QJsonDocument(QJsonObject{{"data", QJsonObject{{"AAA", goodSet}, {"TST", badSet}}}}) + .toJson(QJsonDocument::Compact); + data.replace("\"number\":\"overflow\"", "\"number\":1e9999"); + ASSERT_TRUE(importer->readSetsFromByteArray(data)); + EXPECT_EQ(importer->startImport(), 0); + EXPECT_FALSE(importer->hasSuccessfulImport()); + EXPECT_TRUE(importer->getImportError().contains("TST")); + EXPECT_TRUE(importer->getCardList().isEmpty()); + EXPECT_FALSE(importer->saveToFile(path, {}, {})); + ASSERT_TRUE(original.open(QIODevice::ReadOnly)); + EXPECT_EQ(original.readAll(), "existing database"); +} + +TEST_F(OracleImporterTest, EmptyImportCannotReplaceInstalledCards) +{ + const QByteArray source = R"({"data":{"TST":{"code":"TST","name":"Test","type":"expansion","cards":[]}}})"; + ASSERT_TRUE(importer->readSetsFromByteArray(source)); + EXPECT_EQ(importer->startImport(), 0); + EXPECT_FALSE(importer->hasSuccessfulImport()); + EXPECT_FALSE(importer->getImportError().isEmpty()); +} + +TEST_F(OracleImporterTest, MissingSourceBytesCannotProduceSuccessfulImport) +{ + ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource())); + const auto selected = importer->getSets(); + importer->releaseSetData(); + importer->getSets() = selected; + EXPECT_EQ(importer->startImport(), 0); + EXPECT_FALSE(importer->hasSuccessfulImport()); + EXPECT_TRUE(importer->getImportError().contains("TST")); +} + +TEST_F(OracleImporterTest, CancellationDuringFinalSetCannotSavePartialDatabase) +{ + ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource())); + QObject::connect( + importer, &OracleImporter::setIndexChanged, importer, + [this](int, int, const QString &name) { + if (!name.isEmpty()) { + importer->cancelImport(); + } + }, + Qt::DirectConnection); + EXPECT_EQ(importer->startImport(), 0); + EXPECT_FALSE(importer->hasSuccessfulImport()); + EXPECT_TRUE(importer->getCardList().isEmpty()); +} + +TEST_F(OracleImporterTest, CancellationBeforeWorkerStartsIsNotLost) +{ + ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource())); + importer->prepareImport(); + importer->cancelImport(); + EXPECT_EQ(importer->startImport(), 0); + EXPECT_FALSE(importer->hasSuccessfulImport()); + importer->prepareImport(); + EXPECT_EQ(importer->startImport(), 1); + EXPECT_TRUE(importer->hasSuccessfulImport()); +} + +TEST_F(OracleImporterTest, NewImportReplacesPreviousInMemoryCards) +{ + ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource("Old Card"))); + ASSERT_EQ(importer->startImport(), 1); + ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource("New Card"))); + EXPECT_FALSE(importer->hasSuccessfulImport()); + ASSERT_EQ(importer->startImport(), 1); + EXPECT_EQ(importer->getCardList().size(), 1); + EXPECT_FALSE(importer->getCardList().contains("Old Card")); + EXPECT_TRUE(importer->getCardList().contains("New Card")); +} + +TEST_F(OracleImporterTest, FailedSourceLoadCannotReusePreviousSuccessfulImport) +{ + ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource())); + ASSERT_EQ(importer->startImport(), 1); + ASSERT_FALSE(importer->readSetsFromByteArray("invalid JSON")); + EXPECT_FALSE(importer->hasSuccessfulImport()); + EXPECT_EQ(importer->startImport(), 0); +} + +TEST_F(OracleImporterTest, SuccessfulRefreshCommitsCompleteXml) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = dir.filePath("cards.xml"); + ASSERT_TRUE(importer->readSetsFromByteArray(refreshSource())); + ASSERT_EQ(importer->startImport(), 1); + importer->releaseSetData(); + ASSERT_TRUE(importer->saveToFile(path, "test-source", "v1")); + QFile file(path); + ASSERT_TRUE(file.open(QIODevice::ReadOnly)); + const QByteArray xml = file.readAll(); + EXPECT_TRUE(xml.contains("New Card")); + EXPECT_TRUE(xml.contains("test-source")); + EXPECT_TRUE(xml.contains("")); +} + +TEST(OracleDatabaseWriter, AcceptsCompleteSupportedVersions) +{ + EXPECT_TRUE(OracleDatabase::validateXml( + "") + .isEmpty()); + EXPECT_TRUE(OracleDatabase::validateXml("") + .isEmpty()); + EXPECT_FALSE( + OracleDatabase::validateXml("") + .isEmpty()); +} + +TEST(OracleDatabaseWriter, InvalidDownloadPreservesInstalledBytes) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = dir.filePath("tokens.xml"); + QFile original(path); + ASSERT_TRUE(original.open(QIODevice::WriteOnly)); + ASSERT_EQ(original.write("installed tokens"), 16); + original.close(); + for (const QByteArray &payload : {QByteArray("server error"), + QByteArray(""), QByteArray()}) { + QString error; + EXPECT_FALSE(OracleDatabase::saveXml(path, payload, &error)); + EXPECT_FALSE(error.isEmpty()); + ASSERT_TRUE(original.open(QIODevice::ReadOnly)); + EXPECT_EQ(original.readAll(), "installed tokens"); + original.close(); + } +} + +TEST(OracleDatabaseWriter, CompleteDownloadReplacesInstalledBytes) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = dir.filePath("cards.xml"); + const QByteArray data(""); + QString error = "previous failure"; + ASSERT_TRUE(OracleDatabase::saveXml(path, data, &error)); + EXPECT_TRUE(error.isEmpty()); + QFile file(path); + ASSERT_TRUE(file.open(QIODevice::ReadOnly)); + EXPECT_EQ(file.readAll(), data); +} + +#ifdef Q_OS_UNIX +TEST(OracleDatabaseWriter, ShortWriteKeepsPreviouslyInstalledDatabase) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + const QString path = dir.filePath("cards.xml"); + QFile original(path); + ASSERT_TRUE(original.open(QIODevice::WriteOnly)); + ASSERT_EQ(original.write("installed database"), 18); + original.close(); + const QByteArray data = ""; + // A real filesystem write failure: the soft file-size cap makes the writer's own + // flush fail without filling a disk. The cap is scoped rather than forked, because a + // death-test child under RLIMIT_FSIZE is killed by SIGSEGV on macOS. + test_support::ScopedFileSizeLimit fileSizeCap(128); + ASSERT_TRUE(fileSizeCap.isValid()); + + QString error; + EXPECT_FALSE(OracleDatabase::saveXml(path, data, &error)); + EXPECT_FALSE(error.isEmpty()); + + ASSERT_TRUE(original.open(QIODevice::ReadOnly)); + EXPECT_EQ(original.readAll(), "installed database"); +} +#endif + int main(int argc, char **argv) { ::testing::InitGoogleTest(&argc, argv); diff --git a/tests/oracle/tag_database_test.cpp b/tests/oracle/tag_database_test.cpp new file mode 100644 index 000000000..92b4013a2 --- /dev/null +++ b/tests/oracle/tag_database_test.cpp @@ -0,0 +1,254 @@ +#include "../../oracle/src/tag_database.h" + +#include "gtest/gtest.h" +#include +#include +#include +#include + +#ifdef HAS_ZLIB +#include +#endif + +namespace +{ +const QString ORACLE_A = QStringLiteral("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); +const QString ORACLE_B = QStringLiteral("bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb"); + +std::shared_ptr load(const QByteArray &data, + const TagDatabase::LoadOptions &options = TagDatabase::LoadOptions{}, + QString *errorMessage = nullptr) +{ + return TagDatabase::loadFromData(data, options, errorMessage); +} + +QString keyedByOracleId(const QString &oracleId, const QString &slugs) +{ + return QStringLiteral("{\"%1\": [%2]}").arg(oracleId, slugs); +} + +#ifdef HAS_ZLIB +QByteArray gzipCompress(const QByteArray &input) +{ + z_stream stream{}; + if (deflateInit2(&stream, Z_DEFAULT_COMPRESSION, Z_DEFLATED, 16 + MAX_WBITS, 8, Z_DEFAULT_STRATEGY) != Z_OK) { + return {}; + } + + stream.next_in = reinterpret_cast(const_cast(input.constData())); + stream.avail_in = static_cast(input.size()); + + QByteArray output; + char buffer[4096]; + int result = Z_OK; + while (result == Z_OK) { + stream.next_out = reinterpret_cast(buffer); + stream.avail_out = sizeof(buffer); + result = deflate(&stream, Z_FINISH); + output.append(buffer, static_cast(sizeof(buffer) - stream.avail_out)); + } + + deflateEnd(&stream); + return result == Z_STREAM_END ? output : QByteArray(); +} +#endif +} // namespace + +TEST(TagDatabaseTest, ParsesScryfallJsonLines) +{ + const QByteArray data = R"json( +{"object":"tag","id":"11111111-1111-1111-1111-111111111111","slug":"removal","type":"oracle","parent_ids":[],"taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","weight":"median"}]} +{"object":"tag","id":"22222222-2222-2222-2222-222222222222","slug":"ramp","type":"oracle","parent_ids":[],"taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa","weight":"median"},{"oracle_id":"bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb","weight":"strong"}]} +)json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + EXPECT_EQ(database->tagCount(), 2); + EXPECT_EQ(database->taggedCardCount(), 2); + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"ramp", "removal"})); + EXPECT_EQ(database->tagsForCard(ORACLE_B, QString()), QStringList({"ramp"})); +} + +TEST(TagDatabaseTest, ParsesScryfallJsonArray) +{ + const QByteArray data = R"json([ +{"object":"tag","id":"11111111-1111-1111-1111-111111111111","slug":"removal","type":"oracle","taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}]} +])json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"removal"})); +} + +TEST(TagDatabaseTest, IgnoresNonOracleTagTypes) +{ + const QByteArray data = R"json( +{"object":"tag","id":"11111111-1111-1111-1111-111111111111","slug":"removal","type":"oracle","taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}]} +{"object":"tag","id":"22222222-2222-2222-2222-222222222222","slug":"art-only","type":"art","taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}]} +)json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"removal"})); +} + +TEST(TagDatabaseTest, ParsesFlatOracleIdMap) +{ + const QByteArray data = keyedByOracleId(ORACLE_A, R"("Removal", "removal", " ramp ")").toUtf8(); + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + // Mixed case, duplicates and surrounding whitespace are normalized. + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"ramp", "removal"})); + EXPECT_EQ(database->tagsForCard(ORACLE_A.toUpper(), QString()), QStringList({"ramp", "removal"})); + EXPECT_EQ(database->tagsForCard(ORACLE_B, QString()), QStringList()); +} + +TEST(TagDatabaseTest, ParsesFlatNameMap) +{ + const QByteArray data = R"json({"Lightning Bolt": ["removal", "burn"]})json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + // Name lookups are case-insensitive; an absent oracle id does not block them. + EXPECT_EQ(database->tagsForCard(QString(), QStringLiteral("lightning bolt")), QStringList({"burn", "removal"})); + EXPECT_EQ(database->tagsForCard(ORACLE_A, QStringLiteral("Lightning Bolt")), QStringList({"burn", "removal"})); +} + +TEST(TagDatabaseTest, ParsesManifestWithNameKey) +{ + const QByteArray data = + R"json({"version": 2, "key": "name", "tags": {"Lightning Bolt": ["removal"], "zzz-not-a-uuid": ["burn"]}})json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + EXPECT_EQ(database->tagsForCard(QString(), QStringLiteral("Lightning Bolt")), QStringList({"removal"})); + EXPECT_EQ(database->tagsForCard(QString(), QStringLiteral("ZZZ-not-a-uuid")), QStringList({"burn"})); +} + +TEST(TagDatabaseTest, ParsesJsonLinesRows) +{ + const QByteArray data = R"json( +{"oracle_id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "tags": ["removal"]} +{"name": "Lightning Bolt", "tags": ["burn", "removal"]} +)json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"removal"})); + EXPECT_EQ(database->tagsForCard(QString(), QStringLiteral("Lightning Bolt")), QStringList({"burn", "removal"})); +} + +TEST(TagDatabaseTest, MergesOracleIdAndNameMatches) +{ + const QByteArray data = + R"json({"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa": ["removal"], "Lightning Bolt": ["burn", "removal"]})json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + EXPECT_EQ(database->tagsForCard(ORACLE_A, QStringLiteral("Lightning Bolt")), QStringList({"burn", "removal"})); +} + +TEST(TagDatabaseTest, DropsSlugsContainingWhitespace) +{ + const QByteArray data = keyedByOracleId(ORACLE_A, R"("two words", "valid")").toUtf8(); + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + // A slug with a space would corrupt the space-separated `tags` property. + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"valid"})); +} + +TEST(TagDatabaseTest, ParentTagsAreOffByDefault) +{ + const QByteArray data = R"json( +{"object":"tag","id":"child","slug":"tutor-creature-giant","type":"oracle","parent_ids":["parent","missing"],"taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}]} +{"object":"tag","id":"parent","slug":"tutor-creature","type":"oracle","parent_ids":["grandparent"],"taggings":[]} +{"object":"tag","id":"grandparent","slug":"tutor","type":"oracle","parent_ids":[],"taggings":[]} +)json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"tutor-creature-giant"})); +} + +TEST(TagDatabaseTest, ParentTagsExpandTransitivelyWhenEnabled) +{ + const QByteArray data = R"json( +{"object":"tag","id":"child","slug":"tutor-creature-giant","type":"oracle","parent_ids":["parent","missing"],"taggings":[{"oracle_id":"aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"}]} +{"object":"tag","id":"parent","slug":"tutor-creature","type":"oracle","parent_ids":["grandparent"],"taggings":[]} +{"object":"tag","id":"grandparent","slug":"tutor","type":"oracle","parent_ids":[],"taggings":[]} +)json"; + + QString error; + const auto database = load(data, TagDatabase::LoadOptions{.includeParentTags = true}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + // The child precedes its parents in the file; expansion must not depend on order. + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), + QStringList({"tutor", "tutor-creature", "tutor-creature-giant"})); +} + +TEST(TagDatabaseTest, RejectsEmptyData) +{ + QString error; + EXPECT_FALSE(load(QByteArray(), TagDatabase::LoadOptions{}, &error)); + EXPECT_FALSE(error.isEmpty()); +} + +TEST(TagDatabaseTest, RejectsMalformedJson) +{ + QString error; + EXPECT_FALSE(load("not a tag database", TagDatabase::LoadOptions{}, &error)); + EXPECT_FALSE(error.isEmpty()); +} + +TEST(TagDatabaseTest, RejectsTagListsThatAreNotArrays) +{ + QString error; + EXPECT_FALSE(load(R"json({"tags": {"Lightning Bolt": "removal"}})json", TagDatabase::LoadOptions{}, &error)); + EXPECT_FALSE(error.isEmpty()); +} + +TEST(TagDatabaseTest, RejectsDatabaseWithoutUsableTags) +{ + QString error; + EXPECT_FALSE(load(R"json({"Lightning Bolt": []})json", TagDatabase::LoadOptions{}, &error)); + EXPECT_FALSE(error.isEmpty()); +} + +#ifdef HAS_ZLIB +TEST(TagDatabaseTest, DecompressesGzipPayloads) +{ + const QByteArray plain = keyedByOracleId(ORACLE_A, R"("removal")").toUtf8(); + const QByteArray compressed = gzipCompress(plain); + ASSERT_FALSE(compressed.isEmpty()); + ASSERT_EQ(compressed.left(2), QByteArray::fromHex("1f8b")); + + QString error; + const auto database = load(compressed, TagDatabase::LoadOptions{}, &error); + ASSERT_TRUE(database) << error.toStdString(); + + EXPECT_EQ(database->tagsForCard(ORACLE_A, QString()), QStringList({"removal"})); +} +#endif diff --git a/tests/random_reveal_test.cpp b/tests/random_reveal_test.cpp new file mode 100644 index 000000000..62defe183 --- /dev/null +++ b/tests/random_reveal_test.cpp @@ -0,0 +1,151 @@ +#include "game/game_config.h" +#include "game/server_abstract_player.h" +#include "game/server_card.h" +#include "game/server_cardzone.h" +#include "game/server_game.h" +#include "movecard_tests/server_test_helpers.h" +#include "server_abstractuserinterface.h" +#include "server_response_containers.h" +#include "server_room.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +class FirstRng : public RNG_Abstract +{ + unsigned int rand(int min, int) override + { + return min; + } +} firstRng; + +class User : public Server_AbstractUserInterface +{ +public: + User(Server *server, const char *name) : Server_AbstractUserInterface(server) + { + ServerInfo_User info; + info.set_name(name); + setUserInfo(info); + } + int getLastCommandTime() const override + { + return 0; + } + bool addSaidMessageSize(int) override + { + return true; + } + void sendProtocolItem(const Response &) override + { + } + void sendProtocolItem(const SessionEvent &) override + { + } + void sendProtocolItem(const RoomEvent &) override + { + } + void sendProtocolItem(const GameEventContainer &) override + { + } +}; + +class RandomReveal : public ::testing::Test +{ +protected: + FakeServer server; + User owner{&server, "Owner"}, target{&server, "Target"}; + Server_Room room{0, 0, "", "", "", "", false, "", {}, &server}; + std::unique_ptr game; + Server_AbstractPlayer *player; + Server_CardZone *hand; + ResponseContainer response{1}; + + void SetUp() override + { + GameConfig config; + config.creatorInfo = *owner.getUserInfo(); + config.gameId = 1; + config.maxPlayers = 2; + config.shareDecklistsOnLoad = false; + game = std::make_unique(config, &room); + game->addPlayer(&owner, response, {}); + game->addPlayer(&target, response, {}); + for (auto *participant : game->getPlayers()) { + GameCommand command; + command.MutableExtension(Command_DeckSelect::ext) + ->set_deck(DeckList().writeToString_Native().toStdString()); + GameEventStorage events; + ASSERT_EQ(participant->processGameCommand(command, response, events), Response::RespOk); + participant->setReadyStart(true); + } + game->startGameIfReady(false); + QCoreApplication::processEvents(); + ASSERT_TRUE(game->getGameStarted()); + player = game->getPlayer(0); + hand = player->getZones().value(ZoneNames::HAND); + for (int i = 0; i < 4; ++i) { + hand->insertCard( + new Server_Card({.name = "Twin", .providerId = QString::number(i)}, player->newCardId(), i, 0), -1, 0); + } + } + + Response::ResponseCode reveal(int count, GameEventStorage &events) + { + GameCommand command; + auto *request = command.MutableExtension(Command_RevealRandomCards::ext); + request->set_zone_name(ZoneNames::HAND); + request->set_player_id(1); + request->set_number_of_cards(count); + return player->processGameCommand(command, response, events); + } +}; + +TEST_F(RandomReveal, DistinctPhysicalCardsArePrivateAndLeaveTheHandUnchanged) +{ + const auto original = hand->getCards(); + GameEventStorage events; + ASSERT_EQ(reveal(2, events), Response::RespOk); + ASSERT_EQ(events.getGameEventList().size(), 2); + EXPECT_EQ(events.getPrivatePlayerId(), 1); + const auto *privateEvent = events.getGameEventList().at(0); + EXPECT_EQ(privateEvent->getRecipients(), GameEventStorageItem::SendToPrivate); + const auto &cards = privateEvent->getGameEvent().GetExtension(Event_RevealCards::ext); + ASSERT_EQ(cards.cards_size(), 2); + EXPECT_NE(cards.cards(0).id(), cards.cards(1).id()); + const auto *publicEvent = events.getGameEventList().at(1); + EXPECT_EQ(publicEvent->getRecipients(), GameEventStorageItem::SendToOthers); + const auto ¬ice = publicEvent->getGameEvent().GetExtension(Event_RevealCards::ext); + EXPECT_EQ(notice.cards_size(), 0); + EXPECT_EQ(notice.number_of_cards(), 2); + EXPECT_EQ(hand->getCards(), original); +} + +TEST_F(RandomReveal, InvalidCountsDoNotQueueAnyReveal) +{ + for (int count : {0, -1, 5}) { + GameEventStorage events; + EXPECT_NE(reveal(count, events), Response::RespOk); + EXPECT_TRUE(events.getGameEventList().isEmpty()); + } +} +} // namespace + +RNG_Abstract *rng = &firstRng; + +int main(int argc, char **argv) +{ + QCoreApplication application(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/server_counter_test.cpp b/tests/server_counter_test.cpp index 492aecf66..f894abdbf 100644 --- a/tests/server_counter_test.cpp +++ b/tests/server_counter_test.cpp @@ -8,9 +8,13 @@ #include #include #include +#include #include #include +// Referenced by other translation units in the server_remote library. +RNG_Abstract *rng = nullptr; + TEST(ServerCounter, IncrementDoesNotOverflow) { Server_Counter c(1, "test", color(), 10, std::numeric_limits::max()); diff --git a/tests/server_dump_zone_test.cpp b/tests/server_dump_zone_test.cpp index 71c410655..e3284efbb 100644 --- a/tests/server_dump_zone_test.cpp +++ b/tests/server_dump_zone_test.cpp @@ -7,8 +7,12 @@ #include #include #include +#include #include +// Referenced by other translation units in the server_remote library. +RNG_Abstract *rng = nullptr; + namespace { /// Indices a dump request resolves to, as the handler would walk them. diff --git a/tests/settings/settings_defaults_test.cpp b/tests/settings/settings_defaults_test.cpp index aea7f1d54..acffae9a8 100644 --- a/tests/settings/settings_defaults_test.cpp +++ b/tests/settings/settings_defaults_test.cpp @@ -683,6 +683,75 @@ TEST_F(SettingsDefaultsTest, CardsDisplay_ArrowDrawAnimation_Default) ASSERT_EQ(s.getArrowDrawAnimation(), true); } +TEST_F(SettingsDefaultsTest, CardsDisplay_HoverPanelTrigger_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getHoverPanelTrigger(), CardsDisplaySettings::HoverPanelTriggerHover); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_HoverPanelTrigger_SetAndGet) +{ + CardsDisplaySettings s(settingsPath, nullptr); + s.setHoverPanelTrigger(2); + ASSERT_EQ(s.getHoverPanelTrigger(), 2); + s.setHoverPanelTrigger(0); + ASSERT_EQ(s.getHoverPanelTrigger(), 0); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_HoverZoomTrigger_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getHoverZoomTrigger(), CardsDisplaySettings::HoverPanelTriggerHover); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_HoverZoomTrigger_SetAndGet) +{ + CardsDisplaySettings s(settingsPath, nullptr); + s.setHoverZoomTrigger(1); + ASSERT_EQ(s.getHoverZoomTrigger(), 1); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_HoverPanelDoubleConfirm_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getHoverPanelDoubleConfirm(), false); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_HoverPanelDoubleConfirm_SetAndGet) +{ + CardsDisplaySettings s(settingsPath, nullptr); + s.setHoverPanelDoubleConfirm(true); + ASSERT_EQ(s.getHoverPanelDoubleConfirm(), true); + s.setHoverPanelDoubleConfirm(false); + ASSERT_EQ(s.getHoverPanelDoubleConfirm(), false); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_HoverZoomDelayMs_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getHoverZoomDelayMs(), 400); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_HoverZoomDelayMs_SetAndGet) +{ + CardsDisplaySettings s(settingsPath, nullptr); + s.setHoverZoomDelayMs(750); + ASSERT_EQ(s.getHoverZoomDelayMs(), 750); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_HoverPanelDelayMs_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getHoverPanelDelayMs(), 200); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_HoverPanelDelayMs_SetAndGet) +{ + CardsDisplaySettings s(settingsPath, nullptr); + s.setHoverPanelDelayMs(0); + ASSERT_EQ(s.getHoverPanelDelayMs(), 0); +} + TEST_F(SettingsDefaultsTest, CardsDisplay_CardLang_Default) { CardsDisplaySettings s(settingsPath, nullptr); diff --git a/tests/spoiler_file_test.cpp b/tests/spoiler_file_test.cpp new file mode 100644 index 000000000..ecdc3285f --- /dev/null +++ b/tests/spoiler_file_test.cpp @@ -0,0 +1,135 @@ +#include "client/network/update/card_spoiler/spoiler_file.h" +#include "support/scoped_file_size_limit.h" + +#include +#include +#include +#include +#include + +namespace +{ +class SpoilerFileTest : public ::testing::Test +{ +protected: + QTemporaryDir directory; + QString path; + QString error; + const QByteArray original = ""; + + void SetUp() override + { + ASSERT_TRUE(directory.isValid()); + path = directory.filePath("spoiler.xml"); + QFile file(path); + ASSERT_TRUE(file.open(QIODevice::WriteOnly)); + ASSERT_EQ(file.write(original), original.size()); + } + + QByteArray read() + { + QFile file(path); + EXPECT_TRUE(file.open(QIODevice::ReadOnly)); + return file.readAll(); + } + + SpoilerFile::SaveResult save(const QByteArray &data) + { + return SpoilerFile::save(path, data, [] { return true; }, error); + } +}; + +TEST_F(SpoilerFileTest, InvalidDocumentsPreserveExistingFile) +{ + for (const QByteArray &invalid : + {QByteArray(), QByteArray(""), + QByteArray(""), + QByteArray(""), + QByteArray(""), + QByteArray(""), + QByteArray("")}) { + EXPECT_EQ(save(invalid), SpoilerFile::SaveResult::Failed); + EXPECT_FALSE(error.isEmpty()); + EXPECT_EQ(read(), original); + } +} + +TEST_F(SpoilerFileTest, SupportedVersionsAreAccepted) +{ + EXPECT_TRUE(SpoilerFile::validate(original).isEmpty()); + EXPECT_TRUE( + SpoilerFile::validate("").isEmpty()); +} + +TEST_F(SpoilerFileTest, IdenticalFileIsUnchanged) +{ + EXPECT_EQ(save(original), SpoilerFile::SaveResult::Unchanged); + EXPECT_TRUE(error.isEmpty()); + EXPECT_EQ(read(), original); +} + +TEST_F(SpoilerFileTest, CompleteFileHashDetectsChangesAfterLargeCommonPrefix) +{ + QByteArray changed = ""; + ASSERT_EQ(save(changed), SpoilerFile::SaveResult::Written); + changed.replace("", ""); + EXPECT_EQ(save(changed), SpoilerFile::SaveResult::Written); + EXPECT_EQ(read(), changed); + EXPECT_TRUE(error.isEmpty()); +} + +TEST_F(SpoilerFileTest, CancellationBeforeWritePreservesExistingFile) +{ + EXPECT_EQ(SpoilerFile::save(path, original, [] { return false; }, error), SpoilerFile::SaveResult::Failed); + EXPECT_FALSE(error.isEmpty()); + EXPECT_EQ(read(), original); +} + +TEST_F(SpoilerFileTest, CancellationBeforeCommitPreservesExistingFile) +{ + int checks = 0; + EXPECT_EQ(SpoilerFile::save( + path, original + "\n", [&checks] { return ++checks == 1; }, error), + SpoilerFile::SaveResult::Failed); + EXPECT_EQ(checks, 2); + EXPECT_FALSE(error.isEmpty()); + EXPECT_EQ(read(), original); + EXPECT_EQ(QDir(directory.path()).entryList(QDir::Files), QStringList{"spoiler.xml"}); +} + +TEST_F(SpoilerFileTest, MissingParentIsCreated) +{ + path = directory.filePath("nested/spoiler.xml"); + EXPECT_EQ(save(original), SpoilerFile::SaveResult::Written); + EXPECT_EQ(read(), original); +} + +TEST_F(SpoilerFileTest, FileInParentPathReportsFailureWithoutModifyingIt) +{ + EXPECT_EQ(SpoilerFile::save( + QDir(path).filePath("spoiler.xml"), original, [] { return true; }, error), + SpoilerFile::SaveResult::Failed); + EXPECT_FALSE(error.isEmpty()); + EXPECT_EQ(read(), original); +} + +#ifdef Q_OS_UNIX +TEST_F(SpoilerFileTest, ShortWriteLeavesExistingFileIntact) +{ + const QByteArray changed = ""; + // A real short write exercises QSaveFile's buffered flush failure without depending + // on disk capacity. The cap is scoped rather than forked, because a death-test child + // under RLIMIT_FSIZE is killed by SIGSEGV on macOS. + test_support::ScopedFileSizeLimit fileSizeCap(128); + ASSERT_TRUE(fileSizeCap.isValid()); + + EXPECT_EQ(save(changed), SpoilerFile::SaveResult::Failed); + EXPECT_FALSE(error.isEmpty()); + EXPECT_EQ(read(), original); + EXPECT_EQ(QDir(directory.path()).entryList(QDir::Files), QStringList{"spoiler.xml"}); +} +#endif +} // namespace diff --git a/tests/support/scoped_file_size_limit.h b/tests/support/scoped_file_size_limit.h new file mode 100644 index 000000000..1c1f3d939 --- /dev/null +++ b/tests/support/scoped_file_size_limit.h @@ -0,0 +1,83 @@ +#ifndef COCKATRICE_TEST_SCOPED_FILE_SIZE_LIMIT_H +#define COCKATRICE_TEST_SCOPED_FILE_SIZE_LIMIT_H + +#include + +#ifdef Q_OS_UNIX + +#include +#include +#include + +namespace test_support +{ +/** + * @brief Caps how large a single file may grow, so a writer's short-write path is reachable. + * + * Lowering RLIMIT_FSIZE makes the next write past the cap fail with EFBIG instead of + * filling a filesystem, which is the only way to fail an install step mid-write without + * depending on free disk space or file ownership. Only the soft limit is lowered, so the + * destructor can hand the process back exactly as it found it: ctest runs one case per + * process, but a whole test binary must not carry the cap into its neighbours. + * + * Do not use this inside a gtest death test. A forked child under RLIMIT_FSIZE is killed + * by SIGSEGV on the macOS runners, while the same case passes on Linux, so the fork is + * what breaks - not the writer. The guard alone isolates the failure just as well. + */ +class ScopedFileSizeLimit +{ +public: + explicit ScopedFileSizeLimit(rlim_t maxBytes) + { + if (getrlimit(RLIMIT_FSIZE, &previousLimit) != 0) { + return; + } + + struct sigaction ignore = {}; + ignore.sa_handler = SIG_IGN; + sigemptyset(&ignore.sa_mask); + if (sigaction(SIGXFSZ, &ignore, &previousAction) != 0) { + return; + } + actionSaved = true; + + struct rlimit capped = previousLimit; + capped.rlim_cur = std::min(maxBytes, previousLimit.rlim_max); + if (setrlimit(RLIMIT_FSIZE, &capped) != 0) { + return; + } + active = true; + } + + ~ScopedFileSizeLimit() + { + if (active) { + setrlimit(RLIMIT_FSIZE, &previousLimit); + } + if (actionSaved) { + sigaction(SIGXFSZ, &previousAction, nullptr); + } + } + + ScopedFileSizeLimit(const ScopedFileSizeLimit &) = delete; + ScopedFileSizeLimit &operator=(const ScopedFileSizeLimit &) = delete; + + /** + * @brief Whether the cap is in force; a test that needs the failure must assert this. + */ + [[nodiscard]] bool isValid() const + { + return active; + } + +private: + bool active = false; + bool actionSaved = false; + struct rlimit previousLimit = {}; + struct sigaction previousAction = {}; +}; +} // namespace test_support + +#endif // Q_OS_UNIX + +#endif // COCKATRICE_TEST_SCOPED_FILE_SIZE_LIMIT_H diff --git a/tests/svg_recolor_test.cpp b/tests/svg_recolor_test.cpp new file mode 100644 index 000000000..ada58a7a7 --- /dev/null +++ b/tests/svg_recolor_test.cpp @@ -0,0 +1,119 @@ +#include "libcockatrice/utility/svg_recolor.h" + +#include +#include + +namespace +{ + +TEST(SvgRecolor, ReplacesEverySentinel) +{ + const QByteArray svg = R"()"; + const QByteArray result = resolveSvgColorSentinel(svg, "#ff8800"); + + EXPECT_EQ(result.count("#ff8800"), 2); + EXPECT_FALSE(result.contains("currentColor")); +} + +TEST(SvgRecolor, ReplacesSentinelRegardlessOfCase) +{ + const QByteArray svg = R"()"; + + EXPECT_EQ(resolveSvgColorSentinel(svg, "#ff8800").count("#ff8800"), 2); +} + +TEST(SvgRecolor, LeavesTheRestOfTheDocumentIntact) +{ + const QByteArray svg = + R"()"; + const QByteArray result = resolveSvgColorSentinel(svg, "#123456"); + + EXPECT_TRUE(result.contains(R"(viewBox="0 0 24 24")")); + EXPECT_TRUE(result.contains(R"(d="M0 0h24v24")")); + EXPECT_TRUE(result.contains("an ordinary comment")); +} + +TEST(SvgRecolor, DoesNotModifyTheInput) +{ + const QByteArray svg = R"()"; + const QByteArray original = svg; + + resolveSvgColorSentinel(svg, "#000000"); + + EXPECT_EQ(svg, original); +} + +TEST(SvgRecolor, PreservesBytesThatAreNotValidUtf8) +{ + QByteArray svg = ""; + svg.append("\xff\xfe"); + svg.append(R"()"); + + const QByteArray result = resolveSvgColorSentinel(svg, "#ff8800"); + + EXPECT_TRUE(result.contains("\xff\xfe")); + EXPECT_FALSE(result.contains("currentColor")); + EXPECT_EQ(result.count("#ff8800"), 1); +} + +TEST(SvgRecolor, NoSentinelIsANoOp) +{ + const QByteArray svg = R"()"; + + EXPECT_EQ(resolveSvgColorSentinel(svg, "#ffffff"), svg); +} + +TEST(SvgStrokeWidth, RewritesEveryAttribute) +{ + const QByteArray svg = R"()"; + + const QByteArray result = resolveSvgStrokeWidth(svg, 4.25); + + EXPECT_EQ(result.count(R"(stroke-width="4.25")"), 2); + EXPECT_EQ(result.count(R"(stroke-width='4.25')"), 1); + EXPECT_FALSE(result.contains("1.5")); + EXPECT_FALSE(result.contains("3'/")); +} + +TEST(SvgStrokeWidth, LeavesCssDeclarationsAlone) +{ + const QByteArray svg = R"()"; + + const QByteArray result = resolveSvgStrokeWidth(svg, 3.0); + + EXPECT_TRUE(result.contains(R"(style="stroke-width:2")")); + EXPECT_EQ(result.count(R"(stroke-width="3.00")"), 1); +} + +TEST(SvgStrokeWidth, NoAttributeIsANoOp) +{ + const QByteArray svg = R"()"; + + EXPECT_EQ(resolveSvgStrokeWidth(svg, 3.0), svg); +} + +TEST(SvgStrokeWidth, DoesNotModifyTheInputAndPreservesBytes) +{ + QByteArray svg = ""; + svg.append("\xff\xfe"); + svg.append(R"()"); + const QByteArray original = svg; + + const QByteArray result = resolveSvgStrokeWidth(svg, 3.5); + + EXPECT_EQ(svg, original); + EXPECT_TRUE(result.contains("\xff\xfe")); + EXPECT_TRUE(result.contains(R"(stroke-width="3.50")")); +} + +TEST(SvgStrokeWidth, TruncatedAttributeTerminates) +{ + // A quote that is never closed must not over-read or hang. + const QByteArray svg = R"(")); +} + +} // namespace diff --git a/tests/theme_migration/CMakeLists.txt b/tests/theme_migration/CMakeLists.txt index 93493ee5f..4f9d6dce4 100644 --- a/tests/theme_migration/CMakeLists.txt +++ b/tests/theme_migration/CMakeLists.txt @@ -5,12 +5,20 @@ cockatrice_add_test( ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_manager.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_config.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/pixel_map_generator.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon_badge.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon_fill.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon_ink.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_icon_sticker.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp ${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp ${VERSION_STRING_CPP} theme_migration_test.cpp + theme_icon_render_test.cpp + icon_tint_preview_test.cpp + icon_treatment_preview_test.cpp LIBS libcockatrice_settings libcockatrice_network @@ -18,4 +26,6 @@ cockatrice_add_test( libcockatrice_protocol INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/cockatrice/src + DEFINITIONS + COCKATRICE_SOURCE_DIR="${CMAKE_SOURCE_DIR}" ) diff --git a/tests/theme_migration/icon_tint_preview_test.cpp b/tests/theme_migration/icon_tint_preview_test.cpp new file mode 100644 index 000000000..b5b57f7b2 --- /dev/null +++ b/tests/theme_migration/icon_tint_preview_test.cpp @@ -0,0 +1,233 @@ +#include "interface/theme_config.h" +#include "libcockatrice/utility/svg_recolor.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +/** + * @brief Creates the GUI application QPixmap needs, using the offscreen plugin. + * + * The theme-migration binary is headless; creating this lazily keeps the other + * test files free of a display dependency. + */ +void ensureGuiApplication() +{ + static int argc = 1; + static char name[] = "icon_tint_preview_test"; + static char *argv[] = {name, nullptr}; + // Leaked on purpose: a function-local static QApplication is destroyed before Qt's + // own library statics at exit, and the network module's destructors then run + // against a torn-down QObject layer (SEGV under AddressSanitizer). + static QApplication *app = new QApplication(argc, argv); + Q_UNUSED(app); +} + +QString resourcesDir() +{ + return QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/resources")); +} + +/** @brief Reads an authored icon from the theme resource directory. */ +QByteArray readIconSvg(const char *name) +{ + QFile file(QDir(resourcesDir()).absoluteFilePath(QStringLiteral("icons/%1.svg").arg(QString::fromLatin1(name)))); + if (!file.open(QFile::ReadOnly)) { + return {}; + } + return file.readAll(); +} + +/** @brief Renders a pixmap at device size and tags it with the ratio. */ +template QPixmap renderAtDpr(const QSize &logicalSize, qreal dpr, Render &&render) +{ + QPixmap pixmap = render(logicalSize * dpr); + pixmap.setDevicePixelRatio(dpr); + return pixmap; +} +} // namespace + +/** + * @brief Writes one semantic-tint sheet per shipped palette theme. + * + * The theme files are the only place the shipped tones live, so each sheet is + * rendered from the palette the theme actually ships: all three semantic + * families, three glyphs each, at the two sizes the UI uses, on the window and + * button surfaces the contrast is measured against. A disabled row closes each + * family so the enabled/disabled order is visible next to the tone that carries + * it. A theme that does not author Informational falls back to the scheme + * default, which is what the client paints. + */ +TEST(IconTintPreview, WritesOneSheetPerShippedTheme) +{ + const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR"); + if (outDir.isEmpty()) { + GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs"; + } + ensureGuiApplication(); + ASSERT_TRUE(QDir().mkpath(outDir)); + + constexpr int MARGIN = 24; + constexpr int LABEL_WIDTH = 190; + constexpr int SLOT = 36; + constexpr int ROW_HEIGHT = 56; + constexpr int PANEL_TITLE = 26; + constexpr int PANEL_PADDING = 12; + constexpr qreal DPR = 2.0; + constexpr int SIZES[] = {16, 24}; + + struct Family + { + const char *name; + AppColor::Role role; + const char *glyphs[3]; + }; + const QList families = { + {"Positive", AppColor::Positive, {"increment", "ready_start", "arrow_up"}}, + {"Negative", AppColor::Negative, {"decrement", "not_ready_start", "remove_row"}}, + {"Informational", AppColor::Informational, {"arrow_right", "view", "arrow_to_top"}}, + {"Hidden", AppColor::Hidden, {"view", "arrow_right", "swap"}}, + {"Marked", AppColor::Marked, {"pencil", "share", "pin"}}, + {"Movement", AppColor::Movement, {"arrow_to_top", "arrow_to_bottom", "arrow_down"}}, + }; + const QStringList themes = { + QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"), + QStringLiteral("Plasma"), QStringLiteral("VelvetMarble"), + }; + + const auto renderLine = [](const QByteArray &svg, const QColor &ink, const QSize &size) { + QSvgRenderer renderer(resolveSvgColorSentinel(svg, ink.name(QColor::HexRgb).toLatin1())); + QPixmap pixmap(size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + renderer.render(&painter); + return pixmap; + }; + + const int glyphCount = static_cast(std::size(families.first().glyphs)); + const int columnCount = glyphCount * static_cast(std::size(SIZES)); + const int panelWidth = LABEL_WIDTH + columnCount * SLOT + PANEL_PADDING; + const int panelHeight = PANEL_TITLE + static_cast(families.size()) * 2 * ROW_HEIGHT + PANEL_PADDING; + const int logicalWidth = 2 * MARGIN + panelWidth; + const int logicalHeight = MARGIN + 30 + 2 * (panelHeight + 24) + MARGIN; + + for (const QString &theme : themes) { + for (const bool dark : {false, true}) { + const QString file = + QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)) + .absoluteFilePath(QStringLiteral("cockatrice/themes/%1/palette-default-%2.toml") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + const PaletteConfig config = PaletteConfig::fromFile(file); + ASSERT_TRUE(config.hasPalette()) << theme.toStdString(); + + const QPalette palette = config.apply(QPalette()); + const QColor window = palette.color(QPalette::Active, QPalette::Window); + const QColor button = palette.color(QPalette::Active, QPalette::Button); + const QColor text = palette.color(QPalette::Active, QPalette::WindowText); + const QColor disabled = palette.color(QPalette::Disabled, QPalette::WindowText); + + QImage image(QSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)), + QImage::Format_ARGB32_Premultiplied); + image.fill(window); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.scale(DPR, DPR); + + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 10, QFont::DemiBold)); + painter.drawText(QPointF(MARGIN, MARGIN), + QStringLiteral("%1 — %2 scheme, semantic tints") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + + struct Surface + { + const char *name; + QColor background; + }; + const QList surfaces = { + {"Window surface", window}, + {"Button surface", button}, + }; + + qreal panelTop = MARGIN + 30; + for (const Surface &surface : surfaces) { + painter.setPen(Qt::NoPen); + painter.setBrush(surface.background); + painter.drawRoundedRect(QRectF(MARGIN, panelTop, panelWidth, panelHeight), 8, 8); + + const qreal contentLeft = MARGIN + PANEL_PADDING; + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 9, QFont::DemiBold)); + painter.drawText(QPointF(contentLeft, panelTop + 18), QString::fromLatin1(surface.name)); + + painter.setFont(QFont(QStringLiteral("Sans"), 8)); + for (std::size_t sizeIndex = 0; sizeIndex < std::size(SIZES); ++sizeIndex) { + const qreal x = contentLeft + LABEL_WIDTH + static_cast(sizeIndex) * glyphCount * SLOT; + painter.drawText(QPointF(x, panelTop + PANEL_TITLE + 14), + QStringLiteral("%1 px").arg(SIZES[sizeIndex])); + } + + qreal y = panelTop + PANEL_TITLE + 20; + for (const Family &family : families) { + const QColor tone = config.appColors.value(family.role, defaultAppColor(family.role, dark)); + ASSERT_TRUE(tone.isValid()) << theme.toStdString() << " " << family.name; + for (int state = 0; state < 2; ++state) { + const bool isDisabled = state == 1; + const QColor ink = isDisabled ? disabled : tone; + + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 8, QFont::DemiBold)); + painter.drawText( + QPointF(contentLeft, y + 14), + QStringLiteral("%1%2").arg(QString::fromLatin1(family.name), + isDisabled ? QStringLiteral(" / disabled") : QString())); + painter.setFont(QFont(QStringLiteral("Mono"), 7)); + painter.drawText(QPointF(contentLeft, y + 28), ink.name(QColor::HexRgb)); + + for (std::size_t sizeIndex = 0; sizeIndex < std::size(SIZES); ++sizeIndex) { + const int side = SIZES[sizeIndex]; + const QSize size(side, side); + for (int glyphIndex = 0; glyphIndex < glyphCount; ++glyphIndex) { + const QByteArray svg = readIconSvg(family.glyphs[glyphIndex]); + ASSERT_FALSE(svg.isEmpty()) << family.glyphs[glyphIndex]; + const QPixmap pixmap = renderAtDpr( + size, DPR, [&](const QSize &pixelSize) { return renderLine(svg, ink, pixelSize); }); + const qreal x = contentLeft + LABEL_WIDTH + + static_cast(sizeIndex * glyphCount + glyphIndex) * SLOT + + (SLOT - side) / 2.0; + painter.drawPixmap(QPointF(x, y + (ROW_HEIGHT - side) / 2.0 - 2), pixmap); + } + } + y += ROW_HEIGHT; + } + } + panelTop += panelHeight + 24; + } + + painter.end(); + const QString path = QDir(outDir).absoluteFilePath( + QStringLiteral("semantic-tint-preview-%1-%2.png") + .arg(theme.toLower(), dark ? QStringLiteral("dark") : QStringLiteral("light"))); + ASSERT_TRUE(image.save(path)) << path.toStdString(); + } + } +} diff --git a/tests/theme_migration/icon_treatment_preview_test.cpp b/tests/theme_migration/icon_treatment_preview_test.cpp new file mode 100644 index 000000000..0af177560 --- /dev/null +++ b/tests/theme_migration/icon_treatment_preview_test.cpp @@ -0,0 +1,566 @@ +#include "interface/theme_config.h" +#include "interface/theme_icon_badge.h" +#include "interface/theme_icon_fill.h" +#include "interface/theme_icon_sticker.h" +#include "libcockatrice/utility/svg_recolor.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +/** + * @brief Creates the GUI application QPixmap needs, using the offscreen plugin. + * + * The theme-migration binary is headless; creating this lazily keeps the other + * test files free of a display dependency. + */ +void ensureGuiApplication() +{ + static int argc = 1; + static char name[] = "icon_treatment_preview_test"; + static char *argv[] = {name, nullptr}; + // Leaked on purpose: a function-local static QApplication is destroyed before Qt's + // own library statics at exit, and the network module's destructors then run + // against a torn-down QObject layer (SEGV under AddressSanitizer). + static QApplication *app = new QApplication(argc, argv); + Q_UNUSED(app); +} + +QString resourcesDir() +{ + return QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/resources")); +} + +/** @brief Reads a sentinel SVG from the shipped icon set. */ +QByteArray readIconSvg(const char *name) +{ + QFile file(QDir(resourcesDir()) + .absoluteFilePath(QStringLiteral("icons/") + QString::fromLatin1(name) + QStringLiteral(".svg"))); + return file.open(QFile::ReadOnly) ? file.readAll() : QByteArray(); +} + +/** @brief Renders a pixmap at device size and tags it with the ratio. */ +template QPixmap renderAtDpr(const QSize &logicalSize, qreal dpr, Render &&render) +{ + QPixmap pixmap = render(logicalSize * dpr); + pixmap.setDevicePixelRatio(dpr); + return pixmap; +} +} // namespace + +/** + * @brief Writes one badge/sticker sheet per shipped palette theme. + * + * The treatments are only useful if they read on every palette, so each sheet + * is rendered from the palette the theme actually ships: all three semantic + * families, three glyphs each, as line glyphs, stickers and badges, at the two + * sizes the UI uses, on the window and button surfaces the contrast is measured + * against. A disabled row closes each family: every treatment falls back to the + * line glyph there, so an unavailable control still reads as unavailable. + */ +TEST(IconTreatmentPreview, WritesOneSheetPerShippedTheme) +{ + const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR"); + if (outDir.isEmpty()) { + GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs"; + } + ensureGuiApplication(); + ASSERT_TRUE(QDir().mkpath(outDir)); + + constexpr int MARGIN = 24; + constexpr int LABEL_WIDTH = 190; + constexpr int SLOT = 36; + constexpr int TREATMENT_WIDTH = 120; + constexpr int ROW_HEIGHT = 56; + constexpr int PANEL_TITLE = 26; + constexpr int COLUMN_HEADER = 20; + constexpr int PANEL_PADDING = 12; + constexpr qreal DPR = 2.0; + constexpr int SIZES[] = {16, 24}; + constexpr const char *TREATMENTS[] = {"line", "sticker", "badge"}; + + struct Family + { + const char *name; + AppColor::Role role; + const char *glyphs[3]; + }; + // The hover panel's action grid is the first call site that puts a + // treatment on an action glyph, so the sheet renders the glyphs that grid + // actually uses: play/clone/hand (Positive), reduce life and graveyard + // (Negative), place face down and exile (Informational). + const QList families = { + {"Positive", AppColor::Positive, {"arrow_right", "increment", "arrow_left"}}, + {"Negative", AppColor::Negative, {"arrow_down", "decrement", "remove_row"}}, + {"Informational", AppColor::Informational, {"arrow_right", "view", "arrow_to_top"}}, + {"Hidden", AppColor::Hidden, {"view", "arrow_right", "swap"}}, + {"Marked", AppColor::Marked, {"pencil", "share", "pin"}}, + {"Movement", AppColor::Movement, {"arrow_to_top", "arrow_to_bottom", "arrow_down"}}, + }; + const QStringList themes = { + QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"), + QStringLiteral("Plasma"), QStringLiteral("VelvetMarble"), + }; + + const auto renderLine = [](const QByteArray &svg, const QColor &ink, const QSize &size) { + QSvgRenderer renderer(resolveSvgColorSentinel(svg, ink.name(QColor::HexRgb).toLatin1())); + QPixmap pixmap(size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + renderer.render(&painter); + return pixmap; + }; + + const int treatmentCount = static_cast(std::size(TREATMENTS)); + const int panelWidth = LABEL_WIDTH + treatmentCount * TREATMENT_WIDTH + PANEL_PADDING; + const int panelHeight = + PANEL_TITLE + COLUMN_HEADER + static_cast(families.size()) * 2 * ROW_HEIGHT + PANEL_PADDING; + const int logicalWidth = 2 * MARGIN + panelWidth; + const int logicalHeight = MARGIN + 30 + 2 * (panelHeight + 24) + MARGIN; + + for (const QString &theme : themes) { + for (const bool dark : {false, true}) { + const QString file = + QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)) + .absoluteFilePath(QStringLiteral("cockatrice/themes/%1/palette-default-%2.toml") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + const PaletteConfig config = PaletteConfig::fromFile(file); + ASSERT_TRUE(config.hasPalette()) << theme.toStdString(); + + const QPalette palette = config.apply(QPalette()); + const QColor window = palette.color(QPalette::Active, QPalette::Window); + const QColor button = palette.color(QPalette::Active, QPalette::Button); + const QColor text = palette.color(QPalette::Active, QPalette::WindowText); + const QColor disabled = palette.color(QPalette::Disabled, QPalette::WindowText); + + QImage image(QSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)), + QImage::Format_ARGB32_Premultiplied); + image.fill(window); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.scale(DPR, DPR); + + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 10, QFont::DemiBold)); + painter.drawText(QPointF(MARGIN, MARGIN), + QStringLiteral("%1 — %2 scheme, treatments across glyphs") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + + struct Surface + { + const char *name; + QColor background; + }; + const QList surfaces = { + {"Window surface", window}, + {"Button surface", button}, + }; + + qreal panelTop = MARGIN + 30; + for (const Surface &surface : surfaces) { + painter.setPen(Qt::NoPen); + painter.setBrush(surface.background); + painter.drawRoundedRect(QRectF(MARGIN, panelTop, panelWidth, panelHeight), 8, 8); + + const qreal contentLeft = MARGIN + PANEL_PADDING; + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 9, QFont::DemiBold)); + painter.drawText(QPointF(contentLeft, panelTop + 18), QString::fromLatin1(surface.name)); + + painter.setFont(QFont(QStringLiteral("Sans"), 8)); + for (int treatment = 0; treatment < treatmentCount; ++treatment) { + const qreal x = contentLeft + LABEL_WIDTH + static_cast(treatment) * TREATMENT_WIDTH; + painter.drawText(QPointF(x, panelTop + PANEL_TITLE + 14), + QString::fromLatin1(TREATMENTS[treatment]) + QStringLiteral(" 16 / 24")); + } + + qreal y = panelTop + PANEL_TITLE + COLUMN_HEADER; + for (const Family &family : families) { + // The authored role is the tone the filled shape carries; the + // treatments apply their own fill rule from it. + const QColor tone = config.appColors.value(family.role, defaultAppColor(family.role, dark)); + ASSERT_TRUE(tone.isValid()) << theme.toStdString() << " " << family.name; + + for (int state = 0; state < 2; ++state) { + const bool isDisabled = state == 1; + const QColor ink = isDisabled ? disabled : tone; + + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 8, QFont::DemiBold)); + painter.drawText( + QPointF(contentLeft, y + 14), + QStringLiteral("%1%2").arg(QString::fromLatin1(family.name), + isDisabled ? QStringLiteral(" / disabled") : QString())); + painter.setFont(QFont(QStringLiteral("Mono"), 7)); + painter.drawText(QPointF(contentLeft, y + 28), ink.name(QColor::HexRgb)); + + for (int treatment = 0; treatment < treatmentCount; ++treatment) { + const qreal cellX = + contentLeft + LABEL_WIDTH + static_cast(treatment) * TREATMENT_WIDTH; + for (std::size_t sizeIndex = 0; sizeIndex < std::size(SIZES); ++sizeIndex) { + const int side = SIZES[sizeIndex]; + const QSize size(side, side); + const qreal rowTop = y + (sizeIndex == 0 ? 4 : 28); + for (int glyphIndex = 0; glyphIndex < 3; ++glyphIndex) { + const QByteArray svg = readIconSvg(family.glyphs[glyphIndex]); + ASSERT_FALSE(svg.isEmpty()) << family.glyphs[glyphIndex]; + const QPixmap pixmap = renderAtDpr(size, DPR, [&](const QSize &pixelSize) { + if (isDisabled || treatment == 0) { + return renderLine(svg, ink, pixelSize); + } + if (treatment == 1) { + return ThemeIconSticker::renderSticker(svg, ink, pixelSize); + } + return ThemeIconBadge::renderBadge(svg, ink, pixelSize, + dark ? ThemeIconFill::Scheme::Dark + : ThemeIconFill::Scheme::Light); + }); + const qreal x = + cellX + 6 + static_cast(glyphIndex) * SLOT + (SLOT - side) / 2.0; + painter.drawPixmap(QPointF(x, rowTop + (SLOT - side) / 2.0), pixmap); + } + } + } + y += ROW_HEIGHT; + } + } + panelTop += panelHeight + 24; + } + + painter.end(); + const QString path = QDir(outDir).absoluteFilePath( + QStringLiteral("treatment-preview-%1-%2.png") + .arg(theme.toLower(), dark ? QStringLiteral("dark") : QStringLiteral("light"))); + ASSERT_TRUE(image.save(path)) << path.toStdString(); + } + } +} + +namespace +{ +/** @brief One action-grid cell, with the role the panel hands the icon engine. */ +struct PreviewAction +{ + QString label; + const char *glyph; + AppColor::Role role; +}; + +/** @brief One titled section of a hover panel page. */ +struct PreviewSection +{ + QString title; + QList actions; +}; + +/** @brief One hover panel page, as the client builds it. */ +struct PreviewPage +{ + QString title; + QList sections; +}; + +/** @brief Renders a sentinel SVG in one ink at the requested pixel size. */ +QPixmap renderInkGlyph(const QByteArray &svg, const QColor &ink, const QSize &size) +{ + QSvgRenderer renderer(resolveSvgColorSentinel(svg, ink.name(QColor::HexRgb).toLatin1())); + QPixmap pixmap(size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + renderer.render(&painter); + return pixmap; +} +} // namespace + +/** + * @brief Writes the hover panel's action grids the way the client paints them. + * + * The treatment sheets show glyphs in isolation; this renders the real layout — + * section headers and two-column cells with the glyph above its label — so the + * emphasis split (quiet tabs, primary grid) and the semantic map can be judged + * as the set the player actually sees. The roles are the ones the panel passes + * to the icon engine, already resolved to the emphasis level it requests, and a + * role the theme does not author resolves through the same scheme default the + * client paints. + */ +TEST(IconTreatmentPreview, HoverPanelGridWritesPngsWhenRequested) +{ + const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR"); + if (outDir.isEmpty()) { + GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs"; + } + ensureGuiApplication(); + ASSERT_TRUE(QDir().mkpath(outDir)); + + constexpr int MARGIN = 24; + constexpr int COLUMNS = 2; + constexpr int CELL_WIDTH = 146; + constexpr int CELL_HEIGHT = 62; + constexpr int SECTION_HEIGHT = 22; + constexpr int PANEL_PADDING = 10; + constexpr int PANEL_TITLE = 24; + constexpr int GLYPH = 22; + constexpr qreal DPR = 2.0; + + // The pages a selected card shows, with every conditional action present + // so the whole map is visible at once. + const QList pages = { + PreviewPage{.title = QStringLiteral("Main — on the battlefield"), + .sections = + { + PreviewSection{.title = QStringLiteral("Battlefield"), + .actions = + { + PreviewAction{.label = QStringLiteral("Tap / Untap"), + .glyph = "sync", + .role = AppColor::InformationalStrong}, + PreviewAction{.label = QStringLiteral("Skip untapping"), + .glyph = "lock", + .role = AppColor::InformationalStrong}, + PreviewAction{.label = QStringLiteral("Clone"), + .glyph = "increment", + .role = AppColor::PositiveStrong}, + PreviewAction{.label = QStringLiteral("Attach to card..."), + .glyph = "pin", + .role = AppColor::PositiveStrong}, + PreviewAction{.label = QStringLiteral("Draw arrow..."), + .glyph = "share", + .role = AppColor::MarkedStrong}, + PreviewAction{.label = QStringLiteral("Set annotation..."), + .glyph = "pencil", + .role = AppColor::MarkedStrong}, + PreviewAction{.label = QStringLiteral("Turn Over"), + .glyph = "swap", + .role = AppColor::HiddenStrong}, + PreviewAction{.label = QStringLiteral("Peek at card face"), + .glyph = "view", + .role = AppColor::HiddenStrong}, + PreviewAction{.label = QStringLiteral("Unattach"), + .glyph = "arrow_undo", + .role = AppColor::NegativeStrong}, + PreviewAction{.label = QStringLiteral("Reduce life"), + .glyph = "arrow_down", + .role = AppColor::NegativeStrong}, + }}, + PreviewSection{.title = QStringLiteral("Select"), + .actions = + { + PreviewAction{.label = QStringLiteral("Select All"), + .glyph = "search", + .role = AppColor::InformationalStrong}, + PreviewAction{.label = QStringLiteral("Select Row"), + .glyph = "filter", + .role = AppColor::InformationalStrong}, + }}, + }}, + PreviewPage{.title = QStringLiteral("Main — in a zone"), + .sections = + { + PreviewSection{.title = QStringLiteral("Zone"), + .actions = + { + PreviewAction{.label = QStringLiteral("Play"), + .glyph = "arrow_right", + .role = AppColor::PositiveStrong}, + PreviewAction{.label = QStringLiteral("Play Face Down"), + .glyph = "arrow_right", + .role = AppColor::HiddenStrong}, + PreviewAction{.label = QStringLiteral("Clone"), + .glyph = "increment", + .role = AppColor::PositiveStrong}, + PreviewAction{.label = QStringLiteral("Attach to card..."), + .glyph = "pin", + .role = AppColor::PositiveStrong}, + PreviewAction{.label = QStringLiteral("Draw arrow..."), + .glyph = "share", + .role = AppColor::MarkedStrong}, + }}, + PreviewSection{.title = QStringLiteral("Select"), + .actions = + { + PreviewAction{.label = QStringLiteral("Select All"), + .glyph = "search", + .role = AppColor::InformationalStrong}, + PreviewAction{.label = QStringLiteral("Select Column"), + .glyph = "filter", + .role = AppColor::InformationalStrong}, + }}, + }}, + PreviewPage{.title = QStringLiteral("Move — card in a zone"), + .sections = + { + PreviewSection{.title = QStringLiteral("Move to"), + .actions = + { + PreviewAction{.label = QStringLiteral("Top of library"), + .glyph = "arrow_to_top", + .role = AppColor::MovementStrong}, + PreviewAction{.label = QStringLiteral("X from top..."), + .glyph = "search", + .role = AppColor::MovementStrong}, + PreviewAction{.label = QStringLiteral("Bottom of library"), + .glyph = "arrow_to_bottom", + .role = AppColor::MovementStrong}, + PreviewAction{.label = QStringLiteral("Table"), + .glyph = "player", + .role = AppColor::PositiveStrong}, + PreviewAction{.label = QStringLiteral("Hand"), + .glyph = "arrow_left", + .role = AppColor::PositiveStrong}, + PreviewAction{.label = QStringLiteral("Graveyard"), + .glyph = "arrow_down", + .role = AppColor::NegativeStrong}, + PreviewAction{.label = QStringLiteral("Exile"), + .glyph = "arrow_right", + .role = AppColor::InformationalStrong}, + }}, + PreviewSection{.title = QStringLiteral("Reveal to..."), + .actions = + { + PreviewAction{.label = QStringLiteral("All players"), + .glyph = "share", + .role = AppColor::InformationalStrong}, + PreviewAction{.label = QStringLiteral("Alice"), + .glyph = "player", + .role = AppColor::InformationalStrong}, + PreviewAction{.label = QStringLiteral("Bob"), + .glyph = "player", + .role = AppColor::InformationalStrong}, + }}, + }}, + }; + + const auto rowsIn = [](const PreviewSection §ion) { return (section.actions.size() + COLUMNS - 1) / COLUMNS; }; + const auto panelHeightFor = [&](const PreviewPage &page) { + int height = PANEL_TITLE + PANEL_PADDING; + for (const PreviewSection §ion : page.sections) { + height += SECTION_HEIGHT + rowsIn(section) * CELL_HEIGHT; + } + return height + PANEL_PADDING; + }; + + const int panelWidth = COLUMNS * CELL_WIDTH + 2 * PANEL_PADDING; + const int pageCount = static_cast(pages.size()); + int tallestPanel = 0; + for (const PreviewPage &page : pages) { + tallestPanel = qMax(tallestPanel, panelHeightFor(page)); + } + const int logicalWidth = 2 * MARGIN + pageCount * panelWidth + (pageCount - 1) * MARGIN; + const int logicalHeight = MARGIN + 30 + tallestPanel + MARGIN; + + const QString theme = QStringLiteral("Fabric"); + for (const bool dark : {false, true}) { + const QString file = + QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)) + .absoluteFilePath(QStringLiteral("cockatrice/themes/%1/palette-default-%2.toml") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + const PaletteConfig config = PaletteConfig::fromFile(file); + ASSERT_TRUE(config.hasPalette()) << theme.toStdString(); + + const QPalette palette = config.apply(QPalette()); + const QColor window = palette.color(QPalette::Active, QPalette::Window); + const QColor text = palette.color(QPalette::Active, QPalette::WindowText); + // The panel's cells sit on the popup surface; painting them on the + // button color is what a QToolButton shows when it is not hovered. + const QColor cell = palette.color(QPalette::Active, QPalette::Button); + + QImage image(QSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)), + QImage::Format_ARGB32_Premultiplied); + image.fill(window); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.scale(DPR, DPR); + + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 10, QFont::DemiBold)); + painter.drawText(QPointF(MARGIN, MARGIN), + QStringLiteral("%1 — %2 scheme, hover panel action grids") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + + const auto toneFor = [&](AppColor::Role role) { + return config.appColors.value(role, defaultAppColor(role, dark)); + }; + + for (int pageIndex = 0; pageIndex < pageCount; ++pageIndex) { + const PreviewPage &page = pages[pageIndex]; + const qreal panelLeft = MARGIN + static_cast(pageIndex) * (panelWidth + MARGIN); + const qreal panelTop = MARGIN + 30; + const int panelHeight = panelHeightFor(page); + + painter.setPen(Qt::NoPen); + painter.setBrush(cell.darker(dark ? 115 : 103)); + painter.drawRoundedRect(QRectF(panelLeft, panelTop, panelWidth, panelHeight), 8, 8); + + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 9, QFont::DemiBold)); + painter.drawText(QPointF(panelLeft + PANEL_PADDING, panelTop + 17), page.title); + + qreal y = panelTop + PANEL_TITLE; + for (const PreviewSection §ion : page.sections) { + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 8, QFont::DemiBold)); + painter.drawText(QPointF(panelLeft + PANEL_PADDING, y + 15), section.title); + y += SECTION_HEIGHT; + + const int rows = rowsIn(section); + for (int row = 0; row < rows; ++row) { + for (int column = 0; column < COLUMNS; ++column) { + const int index = row * COLUMNS + column; + if (index >= section.actions.size()) { + break; + } + const PreviewAction &action = section.actions.at(index); + const QRectF cellRect(panelLeft + PANEL_PADDING + column * CELL_WIDTH, y, CELL_WIDTH - 4, + CELL_HEIGHT - 4); + + painter.setPen(Qt::NoPen); + painter.setBrush(cell); + painter.drawRoundedRect(cellRect, 5, 5); + + const QByteArray svg = readIconSvg(action.glyph); + ASSERT_FALSE(svg.isEmpty()) << action.glyph; + const QPixmap pixmap = renderAtDpr(QSize(GLYPH, GLYPH), DPR, [&](const QSize &pixelSize) { + return renderInkGlyph(svg, toneFor(action.role), pixelSize); + }); + painter.drawPixmap(QPointF(cellRect.center().x() - GLYPH / 2.0, cellRect.top() + 7), pixmap); + + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 7)); + const QFontMetrics metrics(painter.font()); + const QString label = metrics.elidedText(action.label, Qt::ElideRight, CELL_WIDTH - 12); + painter.drawText(QRectF(cellRect.left(), cellRect.bottom() - 20, cellRect.width(), 16), + Qt::AlignHCenter | Qt::AlignVCenter, label); + } + y += CELL_HEIGHT; + } + } + } + + painter.end(); + const QString path = QDir(outDir).absoluteFilePath( + QStringLiteral("hover-panel-map-%1.png").arg(dark ? QStringLiteral("dark") : QStringLiteral("light"))); + ASSERT_TRUE(image.save(path)) << path.toStdString(); + } +} diff --git a/tests/theme_migration/theme_icon_render_test.cpp b/tests/theme_migration/theme_icon_render_test.cpp new file mode 100644 index 000000000..a56c723b4 --- /dev/null +++ b/tests/theme_migration/theme_icon_render_test.cpp @@ -0,0 +1,2324 @@ +#include "interface/theme_icon.h" +#include "interface/theme_icon_badge.h" +#include "interface/theme_icon_fill.h" +#include "interface/theme_icon_ink.h" +#include "interface/theme_icon_sticker.h" +#include "interface/theme_manager.h" +#include "libcockatrice/utility/svg_recolor.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +/** + * @brief Creates the GUI application QPixmap needs, using the offscreen plugin. + * + * The theme-migration binary is headless; creating this lazily keeps its other + * tests free of a display dependency. + */ +void ensureGuiApplication() +{ + static int argc = 1; + static char name[] = "theme_icon_test"; + static char *argv[] = {name, nullptr}; + // Leaked on purpose: a function-local static QApplication is destroyed before Qt's + // own library statics at exit, and the network module's destructors then run + // against a torn-down QObject layer (SEGV under AddressSanitizer). + static QApplication *app = new QApplication(argc, argv); + Q_UNUSED(app); +} + +QString resourcesDir() +{ + return QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/resources")); +} + +/** @brief Reads a sentinel SVG from the shipped icon set. */ +QByteArray readIconSvg(const char *name) +{ + QFile file(QDir(resourcesDir()) + .absoluteFilePath(QStringLiteral("icons/") + QString::fromLatin1(name) + QStringLiteral(".svg"))); + return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray(); +} + +int countOpaquePixels(const QPixmap &pixmap, const QColor &expected) +{ + const QImage image = pixmap.toImage(); + int count = 0; + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + const QColor pixel = image.pixelColor(x, y); + if (pixel.alpha() > 200 && pixel.red() == expected.red() && pixel.green() == expected.green() && + pixel.blue() == expected.blue()) { + ++count; + } + } + } + return count; +} + +/** + * @brief The ink the engine resolves a neutral glyph to, for @p palette. + * + * Neutral glyphs are not painted with WindowText verbatim: theme_icon_ink holds + * the platform text color to a contrast floor. Tests that pin the rendered color + * ask the rule instead of restating it. No accent chroma is borrowed: this is + * the fallback an unauthored neutral family takes, so it works whatever scheme + * the platform palette actually is. + */ +QColor expectedNeutralInk(const QPalette &palette) +{ + const QColor text = palette.color(QPalette::Active, QPalette::WindowText); + const QColor surface = ThemeIconInk::harderSurface(palette.color(QPalette::Active, QPalette::Window), + palette.color(QPalette::Active, QPalette::Button), text); + return ThemeIconInk::glyphInk(text, surface, palette.color(QPalette::Active, QPalette::Highlight), 0.0); +} + +/** + * @brief Renders a glyph at the sheet's device pixel ratio. + * + * The preview sheets paint through a painter scaled by @p dpr, so a pixmap + * rendered at logical size would be upscaled on the way into the file and read + * as blurry. Rendering at device size and tagging the pixmap keeps one source + * pixel per device pixel. + */ +template QPixmap renderAtDpr(const QSize &logicalSize, qreal dpr, Render &&render) +{ + QPixmap pixmap = render(logicalSize * dpr); + pixmap.setDevicePixelRatio(dpr); + return pixmap; +} + +/** @brief Opaque pixels that are not the given rim color, i.e. the gradient fill. */ +int countOpaquePixelsOtherThan(const QPixmap &pixmap, const QColor &excluded) +{ + const QImage image = pixmap.toImage(); + int count = 0; + for (int y = 0; y < image.height(); ++y) { + for (int x = 0; x < image.width(); ++x) { + const QColor pixel = image.pixelColor(x, y); + if (pixel.alpha() > 200 && (pixel.red() != excluded.red() || pixel.green() != excluded.green() || + pixel.blue() != excluded.blue())) { + ++count; + } + } + } + return count; +} + +QByteArray svgWith(const QByteArray &colorToken) +{ + return QByteArrayLiteral(R"()"); +} + +/** + * @brief Restores the global theme search paths and palette after each test. + * + * The icon engine reads both process-wide values, so a failing assertion must + * not leak a temporary theme directory or palette into the next test. + */ +class ThemeIconRenderTest : public ::testing::Test +{ +protected: + void SetUp() override + { + qputenv("QT_QPA_PLATFORM", "offscreen"); + ensureGuiApplication(); + savedSearchPaths = QDir::searchPaths(QStringLiteral("theme")); + savedPalette = qApp->palette(); + themeManager = new ThemeManager; + QDir::setSearchPaths(QStringLiteral("theme"), {resourcesDir()}); + } + + void TearDown() override + { + delete themeManager; + themeManager = nullptr; + QDir::setSearchPaths(QStringLiteral("theme"), savedSearchPaths); + qApp->setPalette(savedPalette); + } + + QStringList savedSearchPaths; ///< Search paths captured before the test. + QPalette savedPalette; ///< Application palette captured before the test. +}; + +/** @brief A non-square request must letterbox, not squash, a 24×24 glyph. */ +TEST_F(ThemeIconRenderTest, NonSquareRequestPreservesAspectRatio) +{ + const QIcon icon = loadThemedIcon(QStringLiteral("icons/player")); + ASSERT_FALSE(icon.isNull()); + + EXPECT_EQ(icon.actualSize(QSize(20, 15)), QSize(15, 15)); + + const QPixmap pixmap = icon.pixmap(QSize(20, 15)); + ASSERT_FALSE(pixmap.isNull()); + EXPECT_EQ(pixmap.deviceIndependentSize(), QSizeF(15, 15)); +} + +/** @brief The engine must follow palette flips, icon modes, and DPR. */ +TEST_F(ThemeIconRenderTest, ThemedIconFollowsPaletteModeAndDpr) +{ + QPalette palette = qApp->palette(); + palette.setColor(QPalette::WindowText, QColor(QStringLiteral("#123456"))); + qApp->setPalette(palette); + + const QIcon icon = loadThemedIcon(QStringLiteral("icons/search")); + ASSERT_FALSE(icon.isNull()); + + const QColor firstInk = expectedNeutralInk(qApp->palette()); + const QPixmap normal = icon.pixmap(QSize(24, 24), 1.0); + ASSERT_FALSE(normal.isNull()); + EXPECT_EQ(normal.size(), QSize(24, 24)); + EXPECT_DOUBLE_EQ(normal.devicePixelRatio(), 1.0); + EXPECT_GT(countOpaquePixels(normal, firstInk), 0); + + const QPixmap hidpi = icon.pixmap(QSize(24, 24), 2.0); + ASSERT_FALSE(hidpi.isNull()); + EXPECT_DOUBLE_EQ(hidpi.devicePixelRatio(), 2.0); + EXPECT_EQ(hidpi.deviceIndependentSize(), QSizeF(24, 24)); + + palette.setColor(QPalette::WindowText, QColor(QStringLiteral("#abcdef"))); + qApp->setPalette(palette); + const QColor secondInk = expectedNeutralInk(qApp->palette()); + EXPECT_NE(firstInk, secondInk) << "the icon did not follow the palette"; + const QPixmap flipped = icon.pixmap(QSize(24, 24), 1.0); + EXPECT_GT(countOpaquePixels(flipped, secondInk), 0); + + QPalette selectedPalette = qApp->palette(); + selectedPalette.setColor(QPalette::HighlightedText, QColor(QStringLiteral("#00ff00"))); + qApp->setPalette(selectedPalette); + const QPixmap selected = icon.pixmap(QSize(24, 24), 1.0, QIcon::Selected, QIcon::Off); + EXPECT_GT(countOpaquePixels(selected, QColor(QStringLiteral("#00ff00"))), 0); +} +} // namespace + +TEST_F(ThemeIconRenderTest, SentinelUsesPaletteColor) +{ + QPalette palette; + palette.setColor(QPalette::Window, QColor(Qt::black)); + palette.setColor(QPalette::WindowText, QColor(Qt::white)); + qApp->setPalette(palette); + + const QPixmap pixmap = loadSentinelSvgPixmap(QStringLiteral("icons/search.svg")); + + ASSERT_FALSE(pixmap.isNull()); + EXPECT_EQ(pixmap.size(), QSize(24, 24)); + EXPECT_GT(countOpaquePixels(pixmap, expectedNeutralInk(qApp->palette())), 0); +} + +TEST_F(ThemeIconRenderTest, NonSentinelSvgReturnsNull) +{ + QTemporaryDir themeDir; + ASSERT_TRUE(themeDir.isValid()); + QFile file(QDir(themeDir.path()).absoluteFilePath(QStringLiteral("plain.svg"))); + ASSERT_TRUE(file.open(QIODevice::WriteOnly)); + file.write(svgWith("#000000")); + file.close(); + QDir::addSearchPath(QStringLiteral("theme"), themeDir.path()); + + EXPECT_TRUE(loadSentinelSvgPixmap(QStringLiteral("plain.svg")).isNull()); + EXPECT_TRUE(loadSentinelSvgPixmap(QStringLiteral("icons/search.png")).isNull()); +} + +TEST_F(ThemeIconRenderTest, LowercaseSentinelIsResolved) +{ + QTemporaryDir themeDir; + ASSERT_TRUE(themeDir.isValid()); + QFile file(QDir(themeDir.path()).absoluteFilePath(QStringLiteral("lower.svg"))); + ASSERT_TRUE(file.open(QIODevice::WriteOnly)); + file.write(svgWith("currentcolor")); + file.close(); + + QDir::addSearchPath(QStringLiteral("theme"), themeDir.path()); + + QPalette palette; + palette.setColor(QPalette::WindowText, QColor(QStringLiteral("#123456"))); + qApp->setPalette(palette); + + const QPixmap pixmap = loadSentinelSvgPixmap(QStringLiteral("lower.svg")); + + ASSERT_FALSE(pixmap.isNull()); + EXPECT_GT(countOpaquePixels(pixmap, expectedNeutralInk(qApp->palette())), 0); +} + +/** + * @brief A semantic tint must paint its theme color, not the selection accent. + * + * A theme written before Positive/Negative existed has no stored value, so this + * also covers the fallback that used to return the highlight color. + */ +TEST_F(ThemeIconRenderTest, AppColorTintUsesSemanticColorNotTheAccent) +{ + QPalette palette = qApp->palette(); + const QColor accent(QStringLiteral("#ff00ff")); + palette.setColor(QPalette::Active, QPalette::Highlight, accent); + qApp->setPalette(palette); + + const QColor positive = themeManager->appColor(AppColor::Positive); + const QColor negative = themeManager->appColor(AppColor::Negative); + ASSERT_TRUE(positive.isValid()); + ASSERT_TRUE(negative.isValid()); + EXPECT_NE(positive, accent); + EXPECT_NE(negative, accent); + + const QPixmap positivePixmap = loadSentinelSvgPixmap(QStringLiteral("icons/search.svg"), AppColor::Positive); + ASSERT_FALSE(positivePixmap.isNull()); + EXPECT_GT(countOpaquePixels(positivePixmap, positive), 0); + EXPECT_EQ(countOpaquePixels(positivePixmap, accent), 0); + + const QPixmap negativePixmap = loadSentinelSvgPixmap(QStringLiteral("icons/search.svg"), AppColor::Negative); + ASSERT_FALSE(negativePixmap.isNull()); + EXPECT_GT(countOpaquePixels(negativePixmap, negative), 0); + EXPECT_EQ(countOpaquePixels(negativePixmap, accent), 0); +} + +/** + * @brief A disabled glyph recedes past the quiet level, whatever the family. + * + * The palette's own disabled text is a fixed gray, so on the darkest themes it + * out-reads the quiet level and an enabled glyph looks more disabled than a + * disabled one. The disabled ink is derived from the quiet ink instead, and a + * semantic family loses its hue when disabled. + */ +TEST_F(ThemeIconRenderTest, DisabledGlyphRecedesPastTheQuietLevel) +{ + const QColor quiet = themeManager->appColor(AppColor::IconSoft); + ASSERT_TRUE(quiet.isValid()); + const QPalette palette = qApp->palette(); + const QColor surface = ThemeIconInk::harderSurface(palette.color(QPalette::Active, QPalette::Window), + palette.color(QPalette::Active, QPalette::Button), quiet); + const QColor expected = ThemeIconInk::disabledInk(quiet, surface); + + for (const AppColor::Role role : {AppColor::Icon, AppColor::Positive, AppColor::Negative}) { + const QColor disabled = IconTint(role).resolve(QIcon::Disabled); + EXPECT_EQ(disabled, expected) << static_cast(role); + EXPECT_NE(disabled, themeManager->appColor(role)) << static_cast(role); + } + EXPECT_GE(ThemeIconInk::contrastRatio(quiet, surface) - ThemeIconInk::contrastRatio(expected, surface), 1.0); +} + +/** @brief Palette tints keep the WindowText -> HighlightedText selected swap. */ +TEST_F(ThemeIconRenderTest, SelectedPaletteTintUsesHighlightedText) +{ + QPalette palette = qApp->palette(); + const QColor activeText(QStringLiteral("#111111")); + const QColor selectedText(QStringLiteral("#222222")); + palette.setColor(QPalette::Active, QPalette::WindowText, activeText); + palette.setColor(QPalette::Active, QPalette::HighlightedText, selectedText); + qApp->setPalette(palette); + + const IconTint tint(QPalette::WindowText); + // The normal ink is the accent-free neutral fallback: the platform text + // color held to the label floor. A text color that already clears the floor + // is the ink verbatim, so this pins the rule and the floor, not a shift. + const QColor normal = tint.resolve(QIcon::Normal); + EXPECT_EQ(normal, expectedNeutralInk(qApp->palette())); + const QPalette resolved = qApp->palette(); + const QColor surface = ThemeIconInk::harderSurface(resolved.color(QPalette::Active, QPalette::Window), + resolved.color(QPalette::Active, QPalette::Button), + resolved.color(QPalette::Active, QPalette::WindowText)); + EXPECT_GE(ThemeIconInk::contrastRatio(normal, surface), ThemeIconInk::labelContrastFloor()); + // A selected row paints on the highlight, so the ink is the palette's + // highlighted text rather than the icon ink. + EXPECT_EQ(tint.resolve(QIcon::Selected), selectedText); +} + +/** @brief A selected row falls back to HighlightedText when the tint vanishes. */ +TEST_F(ThemeIconRenderTest, SelectedAppColorFallsBackWhenItMatchesTheHighlight) +{ + const QColor semantic = themeManager->appColor(AppColor::Positive); + const QColor selectedText(QStringLiteral("#fedcba")); + QPalette palette = qApp->palette(); + palette.setColor(QPalette::Active, QPalette::Highlight, semantic); + palette.setColor(QPalette::Active, QPalette::HighlightedText, selectedText); + qApp->setPalette(palette); + + EXPECT_EQ(IconTint(AppColor::Positive).resolve(QIcon::Selected), selectedText); +} + +/** @brief A selected row keeps a semantic tint that still contrasts enough. */ +TEST_F(ThemeIconRenderTest, SelectedAppColorKeepsAContrastingTint) +{ + QPalette palette = qApp->palette(); + palette.setColor(QPalette::Active, QPalette::Highlight, QColor(Qt::black)); + palette.setColor(QPalette::Active, QPalette::HighlightedText, QColor(Qt::white)); + qApp->setPalette(palette); + + EXPECT_EQ(IconTint(AppColor::Positive).resolve(QIcon::Selected), themeManager->appColor(AppColor::Positive)); +} + +/** + * @brief A line glyph renders the authored role verbatim. + * + * The emphasis colors are authored data now, so the paint path must not + * rewrite them. A muted quiet role stays muted on a line glyph, and the badge + * and sticker only shape the value of that tone to give the shape depth. + */ +TEST_F(ThemeIconRenderTest, LineTintRendersTheAuthoredRoleVerbatim) +{ + const QColor raw = themeManager->appColor(AppColor::AccentSoft); + ASSERT_TRUE(raw.isValid()); + ASSERT_LT(raw.hsvSaturation(), 170); + + EXPECT_EQ(IconTint(AppColor::AccentSoft).resolve(QIcon::Normal), raw); + + const QIcon icon = loadThemedIcon(QStringLiteral("icons/arrow_right"), AppColor::AccentSoft); + const QPixmap pixmap = icon.pixmap(QSize(24, 24)); + ASSERT_FALSE(pixmap.isNull()); + EXPECT_GT(countOpaquePixels(pixmap, raw), 0); +} + +/** @brief A badge is a disc with a gradient fill, a dark ring and a contrast glyph. */ +TEST_F(ThemeIconRenderTest, BadgeHasDiscGradientAndRing) +{ + const QColor tone = themeManager->appColor(AppColor::Positive); + ASSERT_TRUE(tone.isValid()); + + const QIcon icon = loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive, IconStyle::Badge); + const QPixmap pixmap = icon.pixmap(QSize(64, 64)); + ASSERT_FALSE(pixmap.isNull()); + EXPECT_EQ(pixmap.size(), QSize(64, 64)); + + const QImage image = pixmap.toImage(); + // The disc fills the circle; the square corners stay transparent. + EXPECT_EQ(image.pixelColor(0, 0).alpha(), 0); + EXPECT_GT(image.pixelColor(32, 32).alpha(), 200); + + // The ring is the authored tone darkened, and it must actually be painted. + EXPECT_GT(countOpaquePixels(pixmap, tone.darker(300)), 0); + + // The fill runs from a lighter top to a darker bottom. Sample left of the + // check, which now reaches almost to the disc edge. + const int fillX = 10; + const QColor top = image.pixelColor(fillX, 16); + const QColor bottom = image.pixelColor(fillX, 48); + ASSERT_GT(top.alpha(), 200); + ASSERT_GT(bottom.alpha(), 200); + EXPECT_GT(top.lightness(), bottom.lightness()); + + // The glyph uses the contrast color chosen for this tone. + EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone, ThemeIconFill::Scheme::Light)), 0); +} + +/** + * @brief The dark-scheme disc keeps the authored tone and darkens it. + * + * The authored tone is the top of the gradient, so the level the palette author + * picked survives the treatment; the shape only adds depth by darkening the + * bottom. It must not re-saturate the tone: the authored saturation is the theme + * author's choice. The dark scheme keeps the darkened ink the badge had before + * the fill moved to the sticker ramp. + */ +TEST_F(ThemeIconRenderTest, BadgeDarkSchemeKeepsTheAuthoredTone) +{ + const QColor tone = defaultAppColor(AppColor::Positive, true); + ASSERT_TRUE(tone.isValid()); + ASSERT_GE(tone.value(), 200); + + const QPixmap pixmap = + ThemeIconBadge::renderBadge(readIconSvg("ready_start"), tone, QSize(64, 64), ThemeIconFill::Scheme::Dark); + ASSERT_FALSE(pixmap.isNull()); + const QImage image = pixmap.toImage(); + + const QColor top = image.pixelColor(10, 16); + const QColor bottom = image.pixelColor(10, 48); + ASSERT_GT(top.alpha(), 200); + ASSERT_GT(bottom.alpha(), 200); + // The top is a darkening of the authored tone, never a lift, and it keeps + // the authored saturation; the sample sits a little down the gradient. + EXPECT_LE(top.value(), tone.value()); + EXPECT_GT(top.value(), bottom.value()); + EXPECT_LT(bottom.value(), tone.value()); + EXPECT_GT(top.lightness(), bottom.lightness()); + // Antialiasing costs a point or two of saturation at the sample points. + EXPECT_NEAR(top.hsvSaturation(), tone.hsvSaturation(), 24); + EXPECT_NEAR(bottom.hsvSaturation(), tone.hsvSaturation(), 24); + + // The dark scheme keeps the darkened ink, and it is actually painted. + const QColor ink = ThemeIconBadge::glyphColor(tone, ThemeIconFill::Scheme::Dark); + ASSERT_NE(ink, QColor(Qt::white)); + EXPECT_GT(countOpaquePixels(pixmap, ink), 0); +} + +/** + * @brief The badge ink follows the scheme. + * + * Light-scheme tones keep the white glyph of the old art; dark-scheme tones + * keep the darkened ink, because the lifted disc is light enough that white + * would sit near 2.7:1 at its top. Pinning both cases stops the glyph from + * silently flipping back to white when the fill or the palette changes. + */ +TEST_F(ThemeIconRenderTest, BadgeInkFollowsTheScheme) +{ + const struct + { + AppColor::Role role; + bool dark; + } cases[] = {{AppColor::Positive, false}, + {AppColor::Negative, false}, + {AppColor::Positive, true}, + {AppColor::Negative, true}}; + + for (const auto &testCase : cases) { + const QColor tone = defaultAppColor(testCase.role, testCase.dark); + ASSERT_TRUE(tone.isValid()); + const QColor ink = ThemeIconBadge::glyphColor(tone, testCase.dark ? ThemeIconFill::Scheme::Dark + : ThemeIconFill::Scheme::Light); + if (testCase.dark) { + EXPECT_NE(ink, QColor(Qt::white)); + EXPECT_LT(ink.value(), 100); + } else { + EXPECT_EQ(ink, QColor(Qt::white)); + } + } +} + +/** @brief The badge is rendered at the device-pixel size, not upscaled later. */ +TEST_F(ThemeIconRenderTest, BadgeHonoursDevicePixelRatio) +{ + const QIcon icon = loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive, IconStyle::Badge); + const QPixmap hidpi = icon.pixmap(QSize(32, 32), 2.0); + + ASSERT_FALSE(hidpi.isNull()); + EXPECT_EQ(hidpi.size(), QSize(64, 64)); + EXPECT_DOUBLE_EQ(hidpi.devicePixelRatio(), 2.0); + EXPECT_EQ(hidpi.deviceIndependentSize(), QSizeF(32, 32)); +} + +/** + * @brief The player list is the one place the badge has to work, at 15px. + * + * A 20x15 icon box yields a 15px canvas. The art this replaces kept its check + * legible there, so the badge must still paint solid glyph ink rather than an + * antialiased smudge. + */ +TEST_F(ThemeIconRenderTest, BadgeStaysLegibleAtPlayerListSize) +{ + const QColor tone = themeManager->appColor(AppColor::Positive); + const QIcon icon = loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive, IconStyle::Badge); + const QPixmap pixmap = icon.pixmap(QSize(20, 15)); + ASSERT_FALSE(pixmap.isNull()); + EXPECT_EQ(pixmap.deviceIndependentSize(), QSizeF(15, 15)); + + // The disc survives: the corners stay transparent and the center is solid. + const QImage image = pixmap.toImage(); + EXPECT_EQ(image.pixelColor(0, 0).alpha(), 0); + EXPECT_GT(image.pixelColor(7, 7).alpha(), 200); + // A 1px ring on a 15px canvas antialiases into the disc and has no exact + // color, so the glyph is what has to stay legible. + EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone, ThemeIconFill::Scheme::Light)), 8); +} + +/** @brief The pixmap API's badge path agrees with the engine's. */ +TEST_F(ThemeIconRenderTest, SentinelSvgBadgeMatchesTheEngine) +{ + const QColor tone = themeManager->appColor(AppColor::Positive); + const QPixmap pixmap = loadSentinelSvgPixmap(QStringLiteral("icons/ready_start.svg"), AppColor::Positive, + QSize(48, 48), IconStyle::Badge); + ASSERT_FALSE(pixmap.isNull()); + EXPECT_EQ(pixmap.size(), QSize(48, 48)); + EXPECT_GT(countOpaquePixels(pixmap, tone.darker(300)), 0); + EXPECT_GT(countOpaquePixels(pixmap, ThemeIconBadge::glyphColor(tone, ThemeIconFill::Scheme::Light)), 0); +} + +/** + * @brief Hides Qt's plugin directories while it lives, then restores them. + * + * Only the image-format plugins matter here: the platform plugin the fixture + * needs is already loaded by the time a test runs. + */ +class ScopedPluginPathHiding +{ +public: + ScopedPluginPathHiding() : saved(QCoreApplication::libraryPaths()) + { + QCoreApplication::setLibraryPaths({}); + } + ~ScopedPluginPathHiding() + { + QCoreApplication::setLibraryPaths(saved); + } + ScopedPluginPathHiding(const ScopedPluginPathHiding &) = delete; + ScopedPluginPathHiding &operator=(const ScopedPluginPathHiding &) = delete; + +private: + QStringList saved; +}; + +/** + * @brief Authored SVG art must not depend on Qt's SVG image-format plugin. + * + * Loading an `.svg` through QPixmap needs the separate `qsvg` image plugin. + * Distributions that build Qt with the plugin split out (Debian, and therefore + * CI) ship it in its own package, and a `--no-install-recommends` install does + * not have it, so authored theme art rendered blank there while working on a + * developer machine whose Qt bundles the format. Hiding the plugin directories + * reproduces that install. + */ +TEST_F(ThemeIconRenderTest, AuthoredArtDoesNotNeedTheSvgImagePlugin) +{ + QTemporaryDir themeDir; + ASSERT_TRUE(themeDir.isValid()); + QFile file(QDir(themeDir.path()).absoluteFilePath(QStringLiteral("plain.svg"))); + ASSERT_TRUE(file.open(QIODevice::WriteOnly)); + file.write(svgWith("#336699")); + file.close(); + QDir::addSearchPath(QStringLiteral("theme"), themeDir.path()); + + // The offscreen platform plugin is already loaded by the fixture, so an + // empty library path only removes the image-format plugins. + const ScopedPluginPathHiding hidePlugins; + + const QIcon icon = loadThemedIcon(QStringLiteral("plain")); + const QPixmap pixmap = icon.pixmap(QSize(32, 32)); + + ASSERT_FALSE(pixmap.isNull()) << "authored SVG art went through the QPixmap loader"; + EXPECT_GT(countOpaquePixels(pixmap, QColor(QStringLiteral("#336699"))), 0); +} + +/** @brief Badge never paints authored art: the asset keeps its own colors. */ +TEST_F(ThemeIconRenderTest, BadgeOnAuthoredArtUsesTheAssetPath) +{ + QTemporaryDir themeDir; + ASSERT_TRUE(themeDir.isValid()); + QFile file(QDir(themeDir.path()).absoluteFilePath(QStringLiteral("plain.svg"))); + ASSERT_TRUE(file.open(QIODevice::WriteOnly)); + file.write(svgWith("#336699")); + file.close(); + QDir::addSearchPath(QStringLiteral("theme"), themeDir.path()); + + const QColor tone = themeManager->appColor(AppColor::Positive); + const QIcon icon = loadThemedIcon(QStringLiteral("plain"), AppColor::Positive, IconStyle::Badge); + const QPixmap pixmap = icon.pixmap(QSize(32, 32)); + ASSERT_FALSE(pixmap.isNull()); + + // The authored color survives; no disc tone and no ring are painted. + EXPECT_GT(countOpaquePixels(pixmap, QColor(QStringLiteral("#336699"))), 0); + EXPECT_EQ(countOpaquePixels(pixmap, tone), 0); + EXPECT_EQ(countOpaquePixels(pixmap, tone.darker(300)), 0); +} + +/** @brief Disabled keeps the shape language but drops back to the line glyph. */ +TEST_F(ThemeIconRenderTest, DisabledBadgeFallsBackToLineGlyph) +{ + const QColor disabled = IconTint(AppColor::Icon).resolve(QIcon::Disabled); + ASSERT_TRUE(disabled.isValid()); + + const QColor tone = themeManager->appColor(AppColor::Positive); + const QIcon icon = loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive, IconStyle::Badge); + const QPixmap pixmap = icon.pixmap(QSize(64, 64), QIcon::Disabled); + ASSERT_FALSE(pixmap.isNull()); + + // A filled disc at full mass would not read as unavailable, so Disabled + // drops the badge: no ring, no disc, just the disabled line glyph. + EXPECT_EQ(countOpaquePixels(pixmap, tone.darker(300)), 0); + EXPECT_EQ(countOpaquePixels(pixmap, disabled.darker(300)), 0); + EXPECT_EQ(pixmap.toImage().pixelColor(0, 0).alpha(), 0); + EXPECT_GT(countOpaquePixels(pixmap, disabled), 0); +} + +/** + * @brief Sticker fills the glyph with the tone and rims it with a dark outline. + * + * The old add/remove art was a filled shape with a black outline and no disc, so + * the sticker must show a solid tone interior, a dark rim, and transparent + * corners. + */ +TEST_F(ThemeIconRenderTest, StickerPaintsToneFillWithDarkOutline) +{ + const QColor tone = themeManager->appColor(AppColor::Positive); + const QIcon icon = loadThemedIcon(QStringLiteral("icons/increment"), AppColor::Positive, IconStyle::Sticker); + const QPixmap pixmap = icon.pixmap(QSize(64, 64)); + ASSERT_FALSE(pixmap.isNull()); + EXPECT_EQ(pixmap.size(), QSize(64, 64)); + + const QImage image = pixmap.toImage(); + // No disc: the corners stay transparent, unlike the badge. + EXPECT_EQ(image.pixelColor(0, 0).alpha(), 0); + EXPECT_EQ(image.pixelColor(63, 63).alpha(), 0); + + // The glyph is filled: the vertical arm carries a gradient, lighter at the + // top and darker at the bottom, with the rim painted in the darkened tone. + const QColor top = image.pixelColor(32, 12); + const QColor bottom = image.pixelColor(32, 52); + ASSERT_GT(top.alpha(), 200); + ASSERT_GT(bottom.alpha(), 200); + EXPECT_GT(top.lightness(), bottom.lightness()); + EXPECT_GT(countOpaquePixels(pixmap, ThemeIconSticker::outlineColor(tone)), 20); +} + +/** + * @brief The dark-scheme fill keeps the authored tone and darkens it. + * + * The authored tone is the top of the gradient, so the level the palette author + * picked survives the treatment. The treatment only adds depth by darkening the + * bottom, without re-saturating the tone. + */ +TEST_F(ThemeIconRenderTest, StickerDarkSchemeFillKeepsTheAuthoredTone) +{ + const QColor tone = defaultAppColor(AppColor::Positive, true); + ASSERT_TRUE(tone.isValid()); + ASSERT_GE(tone.value(), 200); + + const QPixmap pixmap = ThemeIconSticker::renderSticker(readIconSvg("increment"), tone, QSize(64, 64)); + ASSERT_FALSE(pixmap.isNull()); + const QImage image = pixmap.toImage(); + + const QColor top = image.pixelColor(32, 14); + const QColor bottom = image.pixelColor(32, 50); + ASSERT_GT(top.alpha(), 200); + ASSERT_GT(bottom.alpha(), 200); + // The top is a darkening of the authored tone, never a lift, and it keeps + // the authored saturation; the sample sits a little down the gradient. + EXPECT_LE(top.value(), tone.value()); + EXPECT_GT(top.value(), bottom.value()); + EXPECT_LT(bottom.value(), tone.value()); + EXPECT_GT(top.lightness(), bottom.lightness()); + EXPECT_NEAR(top.hsvSaturation(), tone.hsvSaturation(), 24); + EXPECT_NEAR(bottom.hsvSaturation(), tone.hsvSaturation(), 24); +} + +/** @brief The sticker is rendered at the device-pixel size, not upscaled later. */ +TEST_F(ThemeIconRenderTest, StickerHonoursDevicePixelRatio) +{ + const QIcon icon = loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker); + const QPixmap hidpi = icon.pixmap(QSize(32, 32), 2.0); + + ASSERT_FALSE(hidpi.isNull()); + EXPECT_EQ(hidpi.size(), QSize(64, 64)); + EXPECT_DOUBLE_EQ(hidpi.devicePixelRatio(), 2.0); + EXPECT_EQ(hidpi.deviceIndependentSize(), QSizeF(32, 32)); +} + +/** + * @brief A sticker has to survive the 16px toolbar slot. + * + * A 15/16px canvas leaves a three-pixel band. The tone interior must still be + * solid ink rather than an antialiased smudge, and the icon must not turn into a + * disc. + */ +TEST_F(ThemeIconRenderTest, StickerStaysLegibleAtToolbarSize) +{ + const QColor tone = themeManager->appColor(AppColor::Negative); + const QIcon icon = loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker); + const QPixmap pixmap = icon.pixmap(QSize(16, 16)); + ASSERT_FALSE(pixmap.isNull()); + EXPECT_EQ(pixmap.size(), QSize(16, 16)); + + const QImage image = pixmap.toImage(); + EXPECT_EQ(image.pixelColor(0, 0).alpha(), 0); + EXPECT_GT(countOpaquePixelsOtherThan(pixmap, ThemeIconSticker::outlineColor(tone)), 8); +} + +/** @brief The pixmap API's sticker path agrees with the engine's. */ +TEST_F(ThemeIconRenderTest, SentinelSvgStickerMatchesTheEngine) +{ + const QColor tone = themeManager->appColor(AppColor::Negative); + const QPixmap pixmap = loadSentinelSvgPixmap(QStringLiteral("icons/remove_row.svg"), AppColor::Negative, + QSize(48, 48), IconStyle::Sticker); + ASSERT_FALSE(pixmap.isNull()); + EXPECT_EQ(pixmap.size(), QSize(48, 48)); + EXPECT_GT(countOpaquePixelsOtherThan(pixmap, ThemeIconSticker::outlineColor(tone)), 0); + EXPECT_GT(countOpaquePixels(pixmap, ThemeIconSticker::outlineColor(tone)), 0); +} + +/** @brief Sticker never paints authored art: the asset keeps its own colors. */ +TEST_F(ThemeIconRenderTest, StickerOnAuthoredArtUsesTheAssetPath) +{ + QTemporaryDir themeDir; + ASSERT_TRUE(themeDir.isValid()); + QFile file(QDir(themeDir.path()).absoluteFilePath(QStringLiteral("plain.svg"))); + ASSERT_TRUE(file.open(QIODevice::WriteOnly)); + file.write(svgWith("#336699")); + file.close(); + QDir::addSearchPath(QStringLiteral("theme"), themeDir.path()); + + const QColor tone = themeManager->appColor(AppColor::Negative); + const QIcon icon = loadThemedIcon(QStringLiteral("plain"), AppColor::Negative, IconStyle::Sticker); + const QPixmap pixmap = icon.pixmap(QSize(32, 32)); + ASSERT_FALSE(pixmap.isNull()); + + EXPECT_GT(countOpaquePixels(pixmap, QColor(QStringLiteral("#336699"))), 0); + EXPECT_EQ(countOpaquePixels(pixmap, tone), 0); + EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconSticker::outlineColor(tone)), 0); +} + +/** @brief Disabled keeps the shape language but drops back to the line glyph. */ +TEST_F(ThemeIconRenderTest, DisabledStickerFallsBackToLineGlyph) +{ + const QColor disabled = IconTint(AppColor::Icon).resolve(QIcon::Disabled); + ASSERT_TRUE(disabled.isValid()); + + const QColor tone = themeManager->appColor(AppColor::Negative); + const QIcon icon = loadThemedIcon(QStringLiteral("icons/remove_row"), AppColor::Negative, IconStyle::Sticker); + const QPixmap pixmap = icon.pixmap(QSize(64, 64), QIcon::Disabled); + ASSERT_FALSE(pixmap.isNull()); + + // No rim in the sticker tone: Disabled drops the treatment and paints the + // thin disabled line glyph instead. + EXPECT_EQ(countOpaquePixels(pixmap, ThemeIconSticker::outlineColor(tone)), 0); + EXPECT_GT(countOpaquePixels(pixmap, disabled), 0); +} + +/** @brief Line stays the default: no disc, no ring, just the recolored glyph. */ +TEST_F(ThemeIconRenderTest, LineStyleStillRendersGlyphAlone) +{ + const QColor tone = themeManager->appColor(AppColor::Positive); + const QPixmap pixmap = + loadThemedIcon(QStringLiteral("icons/ready_start"), AppColor::Positive).pixmap(QSize(64, 64)); + ASSERT_FALSE(pixmap.isNull()); + + EXPECT_EQ(countOpaquePixels(pixmap, tone.darker(300)), 0); + EXPECT_GT(countOpaquePixels(pixmap, tone), 0); +} + +/** + * @brief Writes the semantic-tint preview sheet when asked; skipped otherwise. + * + * #7775 put the semantic tone back on line glyphs, before any filled treatment + * existed. The sheet renders the affected glyphs with the raw per-scheme + * `AppColor` value, which is what that PR paints, at the sizes the UI uses. + */ +TEST(IconTintPreview, WritesPngsWhenRequested) +{ + qputenv("QT_QPA_PLATFORM", "offscreen"); + ensureGuiApplication(); + + const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR"); + if (outDir.isEmpty()) { + GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs"; + } + ASSERT_TRUE(QDir().mkpath(outDir)); + + struct Glyph + { + const char *name; + AppColor::Role role; + }; + constexpr Glyph tinted[] = { + {"ready_start", AppColor::Positive}, {"not_ready_start", AppColor::Negative}, {"increment", AppColor::Positive}, + {"decrement", AppColor::Negative}, {"remove_row", AppColor::Negative}, {"delete", AppColor::Negative}, + {"arrow_right", AppColor::Positive}, + }; + constexpr int SIZES[] = {15, 16, 24, 48}; + + const auto renderLine = [](const QByteArray &svg, const QColor &tone, const QSize &size) { + QSvgRenderer renderer(resolveSvgColorSentinel(svg, tone.name(QColor::HexRgb).toLatin1())); + QPixmap pixmap(size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + renderer.render(&painter); + return pixmap; + }; + + constexpr int MARGIN = 24; + constexpr int LABEL_WIDTH = 130; + constexpr int CELL_WIDTH = 120; + constexpr int ROW_HEIGHT = 70; + constexpr qreal DPR = 2.0; + + const int logicalWidth = MARGIN + LABEL_WIDTH + static_cast(std::size(SIZES)) * CELL_WIDTH + MARGIN; + const int logicalHeight = MARGIN + 24 + 34 + static_cast(std::size(tinted)) * ROW_HEIGHT + 40 + MARGIN; + const QSize imageSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)); + + for (const bool dark : {false, true}) { + const QColor window(dark ? QStringLiteral("#1e1e1e") : QStringLiteral("#ffffff")); + const QColor text(dark ? QStringLiteral("#e8e8e8") : QStringLiteral("#1e1e1e")); + + QImage image(imageSize, QImage::Format_ARGB32_Premultiplied); + image.fill(window); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.scale(DPR, DPR); + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 9)); + + qreal y = MARGIN + 4; + painter.drawText(QPointF(MARGIN, y), dark ? QStringLiteral("Dark scheme") : QStringLiteral("Light scheme")); + + y += 24; + painter.drawText(QPointF(MARGIN, y), QStringLiteral("Semantic tints on line glyphs (#7775)")); + y += 10; + + for (const Glyph &glyph : tinted) { + y += ROW_HEIGHT; + const qreal rowTop = y - ROW_HEIGHT; + QFile file(QDir(resourcesDir()) + .absoluteFilePath(QStringLiteral("icons/") + QString::fromLatin1(glyph.name) + + QStringLiteral(".svg"))); + ASSERT_TRUE(file.open(QIODevice::ReadOnly)) << glyph.name; + const QByteArray svg = file.readAll(); + const QColor tone = defaultAppColor(glyph.role, dark); + painter.drawText(QPointF(MARGIN, rowTop + 16), QString::fromLatin1(glyph.name)); + for (std::size_t i = 0; i < std::size(SIZES); ++i) { + const int side = SIZES[i]; + const QPixmap pixmap = renderAtDpr( + QSize(side, side), DPR, [&](const QSize &pixelSize) { return renderLine(svg, tone, pixelSize); }); + const qreal x = MARGIN + LABEL_WIDTH + static_cast(i) * CELL_WIDTH; + // Bottom-align the sizes and put the caption below the 48px row. + painter.drawPixmap(QPointF(x, rowTop + 54 - side), pixmap); + painter.drawText(QPointF(x, rowTop + 66), QStringLiteral("%1px").arg(side)); + } + } + + y += 20; + painter.drawText(QPointF(MARGIN, y), + QStringLiteral("Positive: ready and add. Negative: not ready, remove and delete.")); + + painter.end(); + const QString path = QDir(outDir).absoluteFilePath(dark ? QStringLiteral("tint-preview-dark.png") + : QStringLiteral("tint-preview-light.png")); + ASSERT_TRUE(image.save(path)) << path.toStdString(); + } +} + +/** + * @brief Writes the sticker preview sheet when asked; skipped otherwise. + * + * The sheet shows each add/remove glyph as line and as sticker at the + * sizes the UI uses (15px in the player list and menus, 16px in toolbars, 24 and + * 48 for inspection), with the ready/not-ready badge for context. It lives in the + * test binary so the colors come from the same place the engine uses. + */ +TEST(IconStickerPreview, WritesPngsWhenRequested) +{ + qputenv("QT_QPA_PLATFORM", "offscreen"); + ensureGuiApplication(); + + const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR"); + if (outDir.isEmpty()) { + GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs"; + } + ASSERT_TRUE(QDir().mkpath(outDir)); + + struct Glyph + { + const char *name; + AppColor::Role role; + }; + constexpr Glyph stickered[] = { + {"increment", AppColor::Positive}, + {"decrement", AppColor::Negative}, + {"remove_row", AppColor::Negative}, + {"delete", AppColor::Negative}, + }; + constexpr int SIZES[] = {15, 16, 24, 48}; + constexpr int CONTEXT_SIZE = 32; + + const auto readSvg = [](const char *name) { + QFile file( + QDir(resourcesDir()) + .absoluteFilePath(QStringLiteral("icons/") + QString::fromLatin1(name) + QStringLiteral(".svg"))); + return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray(); + }; + const auto renderLine = [](const QByteArray &svg, const QColor &tone, const QSize &size) { + QSvgRenderer renderer(resolveSvgColorSentinel(svg, tone.name(QColor::HexRgb).toLatin1())); + QPixmap pixmap(size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + renderer.render(&painter); + return pixmap; + }; + + constexpr int MARGIN = 24; + constexpr int LABEL_WIDTH = 110; + constexpr int CELL_WIDTH = 120; + constexpr int ROW_HEIGHT = 58; + constexpr int GLYPH_BLOCK_HEIGHT = 22 + 2 * ROW_HEIGHT; + constexpr qreal DPR = 2.0; + + const int logicalWidth = MARGIN + LABEL_WIDTH + static_cast(std::size(SIZES)) * CELL_WIDTH + MARGIN; + const int logicalHeight = MARGIN + 24 + 24 + static_cast(std::size(stickered)) * GLYPH_BLOCK_HEIGHT + 24 + 24 + + CONTEXT_SIZE + 46 + CONTEXT_SIZE + 46 + MARGIN; + const QSize imageSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)); + + for (const bool dark : {false, true}) { + const QColor window(dark ? QStringLiteral("#1e1e1e") : QStringLiteral("#ffffff")); + const QColor text(dark ? QStringLiteral("#e8e8e8") : QStringLiteral("#1e1e1e")); + + QImage image(imageSize, QImage::Format_ARGB32_Premultiplied); + image.fill(window); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.scale(DPR, DPR); + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 9)); + + qreal y = MARGIN + 4; + painter.drawText(QPointF(MARGIN, y), dark ? QStringLiteral("Dark scheme") : QStringLiteral("Light scheme")); + + y += 24; + painter.drawText(QPointF(MARGIN, y), QStringLiteral("Add / remove (line above, sticker below)")); + for (const Glyph &glyph : stickered) { + y += GLYPH_BLOCK_HEIGHT; + const QByteArray svg = readSvg(glyph.name); + ASSERT_FALSE(svg.isEmpty()) << glyph.name; + // The preview must show the line glyph the engine paints, and the + // engine renders an authored tone verbatim. + const QColor tone = defaultAppColor(glyph.role, dark); + + for (int row = 0; row < 2; ++row) { + const bool sticker = row == 1; + const qreal rowTop = y - GLYPH_BLOCK_HEIGHT + 22 + row * ROW_HEIGHT; + painter.drawText(QPointF(MARGIN, rowTop + 16), + (sticker ? QStringLiteral("sticker ") : QStringLiteral("line ")) + + QString::fromLatin1(glyph.name)); + for (std::size_t i = 0; i < std::size(SIZES); ++i) { + const int side = SIZES[i]; + const QPixmap pixmap = renderAtDpr(QSize(side, side), DPR, [&](const QSize &pixelSize) { + return sticker ? ThemeIconSticker::renderSticker(svg, tone, pixelSize) + : renderLine(svg, tone, pixelSize); + }); + const qreal x = MARGIN + LABEL_WIDTH + static_cast(i) * CELL_WIDTH; + painter.drawPixmap(QPointF(x, rowTop + 36 - side), pixmap); + if (row == 1) { + painter.drawText(QPointF(x, rowTop + 54), QStringLiteral("%1px").arg(side)); + } + } + } + } + + y += 24; + painter.drawText(QPointF(MARGIN, y), + QStringLiteral("Ready / not ready keep the badge (%1px)").arg(CONTEXT_SIZE)); + constexpr Glyph badgeContext[] = { + {"ready_start", AppColor::Positive}, + {"not_ready_start", AppColor::Negative}, + }; + for (std::size_t i = 0; i < std::size(badgeContext); ++i) { + const QByteArray svg = readSvg(badgeContext[i].name); + ASSERT_FALSE(svg.isEmpty()) << badgeContext[i].name; + const QColor tone = defaultAppColor(badgeContext[i].role, dark); + const qreal x = MARGIN + LABEL_WIDTH + static_cast(i) * CELL_WIDTH; + painter.drawPixmap( + QPointF(x, y + 8), renderAtDpr(QSize(CONTEXT_SIZE, CONTEXT_SIZE), DPR, [&](const QSize &pixelSize) { + return ThemeIconBadge::renderBadge( + svg, tone, pixelSize, dark ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light); + })); + painter.drawText(QPointF(x, y + 8 + CONTEXT_SIZE + 12), QString::fromLatin1(badgeContext[i].name)); + } + + y += CONTEXT_SIZE + 46; + painter.drawText(QPointF(MARGIN, y), + QStringLiteral("Line / badge / sticker share one tone (%1px)").arg(CONTEXT_SIZE)); + constexpr const char *FAMILY_LABELS[] = {"line", "badge", "sticker"}; + for (std::size_t i = 0; i < std::size(badgeContext); ++i) { + const QByteArray svg = readSvg(badgeContext[i].name); + ASSERT_FALSE(svg.isEmpty()) << badgeContext[i].name; + const QColor tone = defaultAppColor(badgeContext[i].role, dark); + const qreal x = MARGIN + LABEL_WIDTH + static_cast(i) * CELL_WIDTH * 1.5; + for (int j = 0; j < 3; ++j) { + const QSize side(CONTEXT_SIZE, CONTEXT_SIZE); + const QPixmap pixmap = renderAtDpr(side, DPR, [&](const QSize &pixelSize) { + if (j == 0) { + return renderLine(svg, tone, pixelSize); + } + if (j == 1) { + return ThemeIconBadge::renderBadge( + svg, tone, pixelSize, dark ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light); + } + return ThemeIconSticker::renderSticker(svg, tone, pixelSize); + }); + const qreal px = x + j * (CONTEXT_SIZE + 16); + painter.drawPixmap(QPointF(px, y + 8), pixmap); + painter.drawText(QPointF(px, y + 8 + CONTEXT_SIZE + 12), QString::fromLatin1(FAMILY_LABELS[j])); + } + } + + painter.end(); + const QString path = QDir(outDir).absoluteFilePath(dark ? QStringLiteral("sticker-preview-dark.png") + : QStringLiteral("sticker-preview-light.png")); + ASSERT_TRUE(image.save(path)) << path.toStdString(); + } +} + +/** + * @brief Writes the PR preview sheets when asked; skipped otherwise. + * + * Two panels per scheme: ready/not-ready as line and badge at the sizes the UI + * actually uses (15px in the player list, 16px in toolbars, 24 and 48 for + * inspection). It lives in the test binary so the colors come from the same + * place the engine uses. The add/remove/delete glyphs and their sticker + * treatment have their own sheet (IconStickerPreview). + */ +TEST(IconBadgePreview, WritesPngsWhenRequested) +{ + qputenv("QT_QPA_PLATFORM", "offscreen"); + ensureGuiApplication(); + + const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR"); + if (outDir.isEmpty()) { + GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs"; + } + ASSERT_TRUE(QDir().mkpath(outDir)); + + struct Glyph + { + const char *name; + AppColor::Role role; + }; + constexpr Glyph badged[] = { + {"ready_start", AppColor::Positive}, + {"not_ready_start", AppColor::Negative}, + }; + constexpr int SIZES[] = {15, 16, 24, 48}; + + const auto readSvg = [](const char *name) { + QFile file( + QDir(resourcesDir()) + .absoluteFilePath(QStringLiteral("icons/") + QString::fromLatin1(name) + QStringLiteral(".svg"))); + return file.open(QIODevice::ReadOnly) ? file.readAll() : QByteArray(); + }; + const auto renderLine = [](const QByteArray &svg, const QColor &tone, const QSize &size) { + QSvgRenderer renderer(resolveSvgColorSentinel(svg, tone.name(QColor::HexRgb).toLatin1())); + QPixmap pixmap(size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + renderer.render(&painter); + return pixmap; + }; + + constexpr int MARGIN = 24; + constexpr int LABEL_WIDTH = 90; + constexpr int CELL_WIDTH = 120; + constexpr int ROW_HEIGHT = 58; + constexpr int GLYPH_BLOCK_HEIGHT = 22 + 2 * ROW_HEIGHT; + constexpr qreal DPR = 2.0; + + const int logicalWidth = MARGIN + LABEL_WIDTH + static_cast(std::size(SIZES)) * CELL_WIDTH + MARGIN; + const int logicalHeight = MARGIN + 24 + 24 + static_cast(std::size(badged)) * GLYPH_BLOCK_HEIGHT + 30 + MARGIN; + const QSize imageSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)); + + for (const bool dark : {false, true}) { + const QColor window(dark ? QStringLiteral("#1e1e1e") : QStringLiteral("#ffffff")); + const QColor text(dark ? QStringLiteral("#e8e8e8") : QStringLiteral("#1e1e1e")); + + QImage image(imageSize, QImage::Format_ARGB32_Premultiplied); + image.fill(window); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.scale(DPR, DPR); + painter.setPen(text); + painter.setFont(QFont(QStringLiteral("Sans"), 9)); + + qreal y = MARGIN + 4; + painter.drawText(QPointF(MARGIN, y), dark ? QStringLiteral("Dark scheme") : QStringLiteral("Light scheme")); + + y += 24; + painter.drawText(QPointF(MARGIN, y), QStringLiteral("Ready / not ready (line above, badge below)")); + for (const Glyph &glyph : badged) { + y += GLYPH_BLOCK_HEIGHT; + const QByteArray svg = readSvg(glyph.name); + ASSERT_FALSE(svg.isEmpty()) << glyph.name; + // The preview must show the line glyph the engine paints, and the + // engine renders an authored tone verbatim. + const QColor tone = defaultAppColor(glyph.role, dark); + + for (int row = 0; row < 2; ++row) { + const bool badge = row == 1; + const qreal rowTop = y - GLYPH_BLOCK_HEIGHT + 22 + row * ROW_HEIGHT; + painter.drawText(QPointF(MARGIN, rowTop + 16), + (badge ? QStringLiteral("badge ") : QStringLiteral("line ")) + + QString::fromLatin1(glyph.name)); + for (std::size_t i = 0; i < std::size(SIZES); ++i) { + const int side = SIZES[i]; + const QPixmap pixmap = renderAtDpr(QSize(side, side), DPR, [&](const QSize &pixelSize) { + return badge ? ThemeIconBadge::renderBadge(svg, tone, pixelSize, + dark ? ThemeIconFill::Scheme::Dark + : ThemeIconFill::Scheme::Light) + : renderLine(svg, tone, pixelSize); + }); + const qreal x = MARGIN + LABEL_WIDTH + static_cast(i) * CELL_WIDTH; + // Align all sizes on a common baseline so the weight + // difference between 15px and 48px is visible. + painter.drawPixmap(QPointF(x, rowTop + 36 - side), pixmap); + if (row == 1) { + painter.drawText(QPointF(x, rowTop + 54), QStringLiteral("%1px").arg(side)); + } + } + } + } + + painter.end(); + const QString path = QDir(outDir).absoluteFilePath(dark ? QStringLiteral("badge-preview-dark.png") + : QStringLiteral("badge-preview-light.png")); + ASSERT_TRUE(image.save(path)) << path.toStdString(); + } +} + +namespace +{ +/** @brief Palette roles the ink rule reads, from one shipped theme file. */ +struct InkPalette +{ + QColor text; + QColor buttonText; + QColor window; + QColor button; + QColor accent; + QMap roles; +}; + +/** + * @brief Loads a shipped palette file, or returns a default-constructed palette. + * + * The theme files are the only place the shipped values live, so the rule is + * tested against them rather than against a copy of them. + */ +InkPalette shippedPalette(const QString &theme, bool dark) +{ + const QString file = + QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)) + .absoluteFilePath(QStringLiteral("cockatrice/themes/%1/palette-default-%2.toml") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + const PaletteConfig config = PaletteConfig::fromFile(file); + // A theme may author only [AppColors] (System does), so appColors alone do + // not mean it ships a palette: the keys have to be present. The authored + // neutral-family assertions therefore cover only themes with a palette; + // System deliberately leaves its neutral family unauthored, which + // ShippedPalettesAuthorTheEmphasisLadder pins, and its semantic fallbacks + // are measured against representative platform surfaces by + // DefaultSemanticFamilyMeetsTheLadderOnPlatformSurfaces. + if (config.colors.isEmpty()) { + return {}; + } + const QPalette palette = config.apply(QPalette()); + return { + palette.color(QPalette::Active, QPalette::WindowText), palette.color(QPalette::Active, QPalette::ButtonText), + palette.color(QPalette::Active, QPalette::Window), palette.color(QPalette::Active, QPalette::Button), + palette.color(QPalette::Active, QPalette::Highlight), config.appColors}; +} + +/** + * @brief The [AppColors] a shipped theme authors, even when it ships no palette. + * + * System deliberately has no palette of its own, but it still authors its + * semantic roles. The neutral icon family stays unauthored there so it follows + * the live platform palette; see ShippedPalettesAuthorTheEmphasisLadder. + */ +QMap shippedAppColors(const QString &theme, bool dark) +{ + const QString file = + QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)) + .absoluteFilePath(QStringLiteral("cockatrice/themes/%1/palette-default-%2.toml") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + return PaletteConfig::fromFile(file).appColors; +} +} // namespace + +TEST(IconInk, GivesNeutralGlyphsAccentChroma) +{ + const InkPalette palette = shippedPalette(QStringLiteral("Fabric"), false); + ASSERT_TRUE(palette.text.isValid()); + + const QColor ink = ThemeIconInk::glyphInk(palette.text, palette.window, palette.accent); + EXPECT_NE(ink, palette.text) << "a glyph must not be painted with body-text ink verbatim"; + // The borrowed accent is a lighter color than the near-black text ink, so + // the glyph gets a little lighter as well as more chromatic. What must not + // change is its legibility, its blue cast, and the fact that it reads as a + // richer color than plain text. + EXPECT_GT(ink.hsvSaturation(), palette.text.hsvSaturation()) << "the glyph gained no chroma"; + EXPECT_GT(ink.blue(), ink.red()) << "accent chroma did not reach the glyph"; + EXPECT_GE(ThemeIconInk::contrastRatio(ink, palette.window), ThemeIconInk::labelContrastFloor()); +} + +/** + * @brief A medium-light surface needs the pole that can actually reach the floor. + * + * #9a9a9a is lighter than mid-gray but still too light for white to reach a text + * ratio, so "light background means move toward black" is the wrong test. + */ +TEST(IconInk, ContrastFloorPicksThePoleItCanReach) +{ + const QColor background(QStringLiteral("#9a9a9a")); + ASSERT_LT(ThemeIconInk::contrastRatio(QColor(Qt::white), background), 4.5); + ASSERT_GE(ThemeIconInk::contrastRatio(QColor(Qt::black), background), 4.5); + + const QColor solved = ThemeIconInk::reachContrast(QColor(QStringLiteral("#d0d0d0")), background, 4.5); + EXPECT_GE(ThemeIconInk::contrastRatio(solved, background), 4.5); +} + +/** + * @brief Primary stays louder than secondary on a light surface. + * + * A saturated blue or violet at full value is already dark enough to clear the + * label floor on a light surface. When solveValue() read the target as a + * minimum in both directions it rejected that tone, walked the whole saturation + * ladder and fell back to the unchanged secondary, so primary was never + * distinguishable. #5a3ee2 on #efeff4 is the shipped Plasma accent's shape. + */ +TEST(IconInk, PrimaryRaisesAChromaticToneOnALightSurface) +{ + const QColor surface(QStringLiteral("#efeff4")); + const QColor violet(QStringLiteral("#5a3ee2")); + ASSERT_GE(ThemeIconInk::contrastRatio(violet, surface), ThemeIconInk::labelContrastFloor()) + << "the fixture must already be legible, so the fallback would be a no-op"; + + const QColor primary = ThemeIconInk::forEmphasis(violet, surface, IconEmphasis::Primary); + ASSERT_TRUE(primary.isValid()); + EXPECT_GE(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::labelContrastFloor()); + EXPECT_GT(primary.hsvSaturation(), violet.hsvSaturation()) << "primary fell back to the unchanged secondary"; + EXPECT_NEAR(primary.hue(), violet.hue(), 2); +} + +/** + * @brief A green or red accent must not tint neutral chrome. + * + * Positive owns green and Negative owns red, so a theme whose accent sits in + * either band would otherwise paint "settings" the same color as "add". + */ +TEST(IconInk, SemanticAccentDoesNotTintNeutralChrome) +{ + const QColor surface(QStringLiteral("#18202e")); + const QColor text = Qt::white; + const QColor green(QStringLiteral("#149740")); + const QColor blue(QStringLiteral("#2f6fd6")); + + ASSERT_TRUE(ThemeIconInk::isSemanticHue(green.hue())); + ASSERT_FALSE(ThemeIconInk::isSemanticHue(blue.hue())); + + const QColor greenInk = ThemeIconInk::glyphInk(text, surface, green); + EXPECT_NEAR(greenInk.hue(), ThemeIconInk::neutralChromeHue(), 2); + EXPECT_FALSE(ThemeIconInk::isSemanticHue(greenInk.hue())); + EXPECT_NEAR(ThemeIconInk::glyphInk(text, surface, blue).hue(), blue.hue(), 1); +} + +TEST(IconInk, MeetsTheLabelFloorOnEveryShippedPalette) +{ + const QString themesRoot = + QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/themes")); + const QStringList themes = QDir(themesRoot).entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name); + ASSERT_FALSE(themes.isEmpty()); + + int checked = 0; + for (const QString &theme : themes) { + for (const bool dark : {false, true}) { + const InkPalette palette = shippedPalette(theme, dark); + if (!palette.text.isValid()) { + continue; + } + ++checked; + const QColor ink = ThemeIconInk::glyphInk(palette.text, palette.window, palette.accent); + EXPECT_GE(ThemeIconInk::contrastRatio(ink, palette.window), ThemeIconInk::labelContrastFloor()) + << theme.toStdString() << (dark ? " dark" : " light"); + } + } + EXPECT_GT(checked, 0) << "no shipped palette was loadable"; +} + +TEST(IconInk, KeepsADeliberateHueWhenTheTextColorIsWeak) +{ + // A theme whose text color is deliberately low-contrast still gets a glyph + // that clears the floor, and the hue survives the adjustment. + const QColor surface(QStringLiteral("#f4f4f4")); + const QColor weakText(QStringLiteral("#c9c9c9")); + const QColor ink = ThemeIconInk::glyphInk(weakText, surface, QColor(QStringLiteral("#c9c9c9"))); + EXPECT_GE(ThemeIconInk::contrastRatio(ink, surface), ThemeIconInk::labelContrastFloor()); + EXPECT_LT(ink.value(), weakText.value()); +} + +/** + * @brief Writes the glyph-ink comparison sheet when asked; skipped otherwise. + * + * One row per accent share, so the trade-off between "still neutral chrome" and + * "clearly an icon" can be judged against the body text drawn beside the glyph. + * Lives in the test binary so the colors come from the same rule the engine uses. + */ +TEST(IconInkPreview, WritesPngsWhenRequested) +{ + qputenv("QT_QPA_PLATFORM", "offscreen"); + ensureGuiApplication(); + + const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR"); + if (outDir.isEmpty()) { + GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs"; + } + ASSERT_TRUE(QDir().mkpath(outDir)); + + constexpr const char *GLYPHS[] = {"search", "info", "settings", "filter", "increment"}; + constexpr int SIZES[] = {15, 16, 24, 48}; + constexpr int LAST_SIZE = SIZES[std::size(SIZES) - 1]; + constexpr int CELL_GAP = 10; + + const auto renderLine = [](const QByteArray &svg, const QColor &tone, const QSize &size) { + QSvgRenderer renderer(resolveSvgColorSentinel(svg, tone.name(QColor::HexRgb).toLatin1())); + QPixmap pixmap(size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + renderer.render(&painter); + return pixmap; + }; + + constexpr int MARGIN = 20; + constexpr int SIZE_LABEL_WIDTH = 46; + constexpr int BODY_WIDTH = 46; + constexpr int COLUMN_GAP = 28; + constexpr int ROW_HEIGHT = 58; + constexpr qreal DPR = 2.0; + + // The same five glyphs at the sizes the client actually draws them, so the + // comparison is one the reviewer can read rather than a color swatch. + const int glyphsWidth = + static_cast(std::size(GLYPHS)) * LAST_SIZE + (static_cast(std::size(GLYPHS)) - 1) * CELL_GAP; + const int oldX = MARGIN + SIZE_LABEL_WIDTH + BODY_WIDTH; + const int newX = oldX + glyphsWidth + COLUMN_GAP; + const int logicalWidth = newX + glyphsWidth + MARGIN; + const int blockHeight = 46 + static_cast(std::size(SIZES)) * ROW_HEIGHT + 10; + const int logicalHeight = MARGIN + 24 + 20 + 26 + 2 * blockHeight + MARGIN; + + for (const bool dark : {false, true}) { + const InkPalette palette = shippedPalette(QStringLiteral("Fabric"), dark); + ASSERT_TRUE(palette.text.isValid()); + + QImage image(QSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)), + QImage::Format_ARGB32_Premultiplied); + image.fill(palette.window); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.scale(DPR, DPR); + + const QFont bodyFont(QStringLiteral("Sans"), 10); + const QFont labelFont(QStringLiteral("Sans"), 8); + const QFont captionFont(QStringLiteral("Sans"), 9, QFont::DemiBold); + + qreal y = MARGIN; + painter.setPen(palette.text); + painter.setFont(QFont(QStringLiteral("Sans"), 11, QFont::DemiBold)); + painter.drawText(QPointF(MARGIN, y), (dark ? QStringLiteral("Dark scheme") : QStringLiteral("Light scheme")) + + QStringLiteral(" — neutral glyph ink")); + y += 18; + painter.setFont(labelFont); + painter.drawText(QPointF(MARGIN, y), + QStringLiteral("Body text is the same in both columns; only the glyph ink changes.")); + y += 20; + + // The two colors as filled swatches: at 15-24px the glyph difference is + // a hue shift, and the hex pair makes it measurable at a glance. + const QColor windowOld = ThemeIconInk::glyphInk(palette.text, palette.window, palette.accent, 0.0); + const QColor windowNew = ThemeIconInk::glyphInk(palette.text, palette.window, palette.accent); + qreal swatchX = MARGIN; + for (const auto &[ink, label] : {std::pair(windowOld, QStringLiteral("before")), + std::pair(windowNew, QStringLiteral("after"))}) { + painter.setPen(Qt::NoPen); + painter.setBrush(ink); + painter.drawRoundedRect(QRectF(swatchX, y - 10, 14, 14), 3, 3); + painter.setBrush(Qt::NoBrush); + painter.setPen(palette.text); + painter.drawText(QPointF(swatchX + 20, y), QStringLiteral("%1 %2").arg(label, ink.name(QColor::HexRgb))); + swatchX += 150; + } + y += 24; + + for (const bool onButton : {false, true}) { + const QColor surface = onButton ? palette.button : palette.window; + const QColor oldInk = ThemeIconInk::glyphInk(palette.text, surface, palette.accent, 0.0); + const QColor newInk = ThemeIconInk::glyphInk(palette.text, surface, palette.accent); + + const qreal blockTop = y; + if (onButton) { + painter.fillRect(QRectF(0, blockTop, logicalWidth, blockHeight), surface); + } + + painter.setPen(palette.text); + painter.setFont(captionFont); + painter.drawText(QPointF(MARGIN, blockTop + 14), + onButton ? QStringLiteral("Button surface") : QStringLiteral("Window surface")); + painter.setFont(labelFont); + painter.drawText(QPointF(MARGIN, blockTop + 34), QStringLiteral("size")); + painter.drawText(QPointF(MARGIN + SIZE_LABEL_WIDTH, blockTop + 34), QStringLiteral("body")); + painter.drawText(QPointF(oldX, blockTop + 34), QStringLiteral("before: WindowText")); + painter.drawText( + QPointF(newX, blockTop + 34), + QStringLiteral("after: %1% accent ink").arg(qRound(ThemeIconInk::defaultAccentMix() * 100))); + + qreal rowY = blockTop + 46; + for (const int side : SIZES) { + const qreal baseline = rowY + ROW_HEIGHT / 2.0 + 3.5; + painter.setFont(labelFont); + painter.drawText(QPointF(MARGIN, baseline), QStringLiteral("%1 px").arg(side)); + painter.setFont(bodyFont); + painter.drawText(QPointF(MARGIN + SIZE_LABEL_WIDTH, baseline), QStringLiteral("Aa")); + + for (std::size_t i = 0; i < std::size(GLYPHS); ++i) { + const QByteArray svg = readIconSvg(GLYPHS[i]); + ASSERT_FALSE(svg.isEmpty()) << GLYPHS[i]; + const qreal cellOffset = static_cast(i) * (side + CELL_GAP); + const qreal glyphY = rowY + (ROW_HEIGHT - side) / 2.0; + painter.drawPixmap(QPointF(oldX + cellOffset, glyphY), + renderAtDpr(QSize(side, side), DPR, [&](const QSize &pixelSize) { + return renderLine(svg, oldInk, pixelSize); + })); + painter.drawPixmap(QPointF(newX + cellOffset, glyphY), + renderAtDpr(QSize(side, side), DPR, [&](const QSize &pixelSize) { + return renderLine(svg, newInk, pixelSize); + })); + } + rowY += ROW_HEIGHT; + } + y = blockTop + blockHeight; + } + + painter.end(); + const QString path = QDir(outDir).absoluteFilePath(dark ? QStringLiteral("ink-preview-dark.png") + : QStringLiteral("ink-preview-light.png")); + ASSERT_TRUE(image.save(path)) << path.toStdString(); + } +} + +namespace +{ +/** @brief Applies a shipped palette file to the application for a test. */ +bool applyShippedPalette(const QString &theme, bool dark) +{ + const QString file = + QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)) + .absoluteFilePath(QStringLiteral("cockatrice/themes/%1/palette-default-%2.toml") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + const PaletteConfig config = PaletteConfig::fromFile(file); + if (!config.hasPalette()) { + return false; + } + qApp->setPalette(config.apply(QPalette())); + return true; +} +} // namespace + +/** + * @brief Emphasis selects the authored variant of a family, not a computed color. + * + * The theme stores one color per level, so the mapping is a lookup: a base role + * follows the caller's emphasis, while naming a variant pins that level at every + * emphasis. This is the contract that makes the palette editor's rows the whole + * source of truth. + */ +TEST_F(ThemeIconRenderTest, EmphasisSelectsTheAuthoredVariant) +{ + EXPECT_EQ(IconTint().resolve(QIcon::Normal), themeManager->appColor(AppColor::Icon)); + EXPECT_EQ(IconTint::primary().resolve(QIcon::Normal), themeManager->appColor(AppColor::IconStrong)); + EXPECT_EQ(IconTint::quiet().resolve(QIcon::Normal), themeManager->appColor(AppColor::IconSoft)); + EXPECT_EQ(IconTint(QPalette::WindowText, IconEmphasis::Primary).resolve(QIcon::Normal), + themeManager->appColor(AppColor::IconStrong)); + + struct Family + { + AppColor::Role base; + AppColor::Role strong; + AppColor::Role soft; + }; + + for (const Family &family : + {Family{AppColor::Icon, AppColor::IconStrong, AppColor::IconSoft}, + Family{AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft}, + Family{AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}, + Family{AppColor::Informational, AppColor::InformationalStrong, AppColor::InformationalSoft}, + Family{AppColor::Hidden, AppColor::HiddenStrong, AppColor::HiddenSoft}, + Family{AppColor::Marked, AppColor::MarkedStrong, AppColor::MarkedSoft}, + Family{AppColor::Movement, AppColor::MovementStrong, AppColor::MovementSoft}}) { + EXPECT_EQ(IconTint(family.base, IconEmphasis::Primary).resolve(QIcon::Normal), + themeManager->appColor(family.strong)); + EXPECT_EQ(IconTint(family.base).resolve(QIcon::Normal), themeManager->appColor(family.base)); + EXPECT_EQ(IconTint(family.base, IconEmphasis::Quiet).resolve(QIcon::Normal), + themeManager->appColor(family.soft)); + // Naming a variant is explicit: emphasis does not rewrite it. + EXPECT_EQ(IconTint(family.strong, IconEmphasis::Quiet).resolve(QIcon::Normal), + themeManager->appColor(family.strong)); + EXPECT_EQ(IconTint(family.soft, IconEmphasis::Primary).resolve(QIcon::Normal), + themeManager->appColor(family.soft)); + } +} + +/** + * @brief The authored primary level is the most chromatic member of its family. + * + * Contrast cannot order the ladder: on a dark surface the highest-contrast tone + * is the palest one, so a contrast target makes the primary read as a washed-out + * secondary. Chroma orders the levels instead; contrast only has to stay above + * the legibility floor on every surface the icon is drawn on. + */ +TEST_F(ThemeIconRenderTest, PrimaryIsTheMostChromaticLevelOnBothSchemes) +{ + for (const bool dark : {false, true}) { + const InkPalette palette = shippedPalette(QStringLiteral("Fabric"), dark); + ASSERT_TRUE(palette.text.isValid()) << (dark ? "dark" : "light"); + const QColor secondary = palette.roles.value(AppColor::Icon); + const QColor primary = palette.roles.value(AppColor::IconStrong); + ASSERT_TRUE(secondary.isValid() && primary.isValid()) << (dark ? "dark" : "light"); + + EXPECT_GT(primary.hsvSaturation(), secondary.hsvSaturation()) << (dark ? "dark" : "light"); + for (const QColor &surface : {palette.window, palette.button}) { + EXPECT_GE(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::primaryContrastFloor()) + << (dark ? "dark" : "light"); + } + } +} + +/** + * @brief Quiet is the least chromatic level; it shares the legibility floor. + * + * Ordering the ladder by contrast inverted it on dark schemes and pushed quiet + * under the disabled ink. Quiet now keeps the same floor as the other levels and + * recedes by carrying less of the family's chroma, which also leaves the + * disabled level the only one below the floor. + */ +TEST_F(ThemeIconRenderTest, QuietIsTheLeastChromaticLevel) +{ + for (const bool dark : {false, true}) { + const InkPalette palette = shippedPalette(QStringLiteral("Fabric"), dark); + ASSERT_TRUE(palette.text.isValid()) << (dark ? "dark" : "light"); + const QColor secondary = palette.roles.value(AppColor::Icon); + const QColor quiet = palette.roles.value(AppColor::IconSoft); + ASSERT_TRUE(secondary.isValid() && quiet.isValid()) << (dark ? "dark" : "light"); + + EXPECT_LT(quiet.hsvSaturation(), secondary.hsvSaturation()) << (dark ? "dark" : "light"); + for (const QColor &surface : {palette.window, palette.button}) { + const double quietContrast = ThemeIconInk::contrastRatio(quiet, surface); + EXPECT_GE(quietContrast, ThemeIconInk::quietContrastFloor()) << (dark ? "dark" : "light"); + EXPECT_GE(quietContrast - ThemeIconInk::contrastRatio(ThemeIconInk::disabledInk(quiet, surface), surface), + 1.0) + << (dark ? "dark" : "light"); + } + } +} + +/** @brief Every authored emphasis level keeps a semantic family's hue. */ +TEST_F(ThemeIconRenderTest, EmphasisKeepsASemanticHue) +{ + const QString themesRoot = + QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/themes")); + const QStringList themes = QDir(themesRoot).entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name); + ASSERT_FALSE(themes.isEmpty()); + + int checked = 0; + for (const QString &theme : themes) { + for (const bool dark : {false, true}) { + const QMap roles = shippedAppColors(theme, dark); + + for (const AppColor::Role role : {AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft}) { + const QColor ink = roles.value(role); + if (!ink.isValid()) { + continue; + } + ++checked; + EXPECT_GT(ink.green(), ink.red()) << theme.toStdString() << static_cast(role); + EXPECT_GT(ink.green(), ink.blue()) << theme.toStdString() << static_cast(role); + } + + for (const AppColor::Role role : {AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}) { + const QColor ink = roles.value(role); + if (!ink.isValid()) { + continue; + } + ++checked; + EXPECT_GT(ink.red(), ink.green()) << theme.toStdString() << static_cast(role); + EXPECT_GT(ink.red(), ink.blue()) << theme.toStdString() << static_cast(role); + } + + for (const AppColor::Role role : + {AppColor::Informational, AppColor::InformationalStrong, AppColor::InformationalSoft}) { + // Informational may come from the scheme default when the theme + // does not author it, so measure the value the client paints. + const QColor ink = roles.value(role, defaultAppColor(role, dark)); + if (!ink.isValid()) { + continue; + } + ++checked; + EXPECT_GT(ink.blue(), ink.red()) << theme.toStdString() << static_cast(role); + EXPECT_GT(ink.blue(), ink.green()) << theme.toStdString() << static_cast(role); + } + + for (const AppColor::Role role : {AppColor::Hidden, AppColor::HiddenStrong, AppColor::HiddenSoft}) { + // Hidden is violet: blue leads, then red, then green. + const QColor ink = roles.value(role, defaultAppColor(role, dark)); + if (!ink.isValid()) { + continue; + } + ++checked; + EXPECT_GT(ink.blue(), ink.red()) << theme.toStdString() << static_cast(role); + EXPECT_GT(ink.red(), ink.green()) << theme.toStdString() << static_cast(role); + } + + for (const AppColor::Role role : {AppColor::Marked, AppColor::MarkedStrong, AppColor::MarkedSoft}) { + // Marked is amber: red leads, then green, then blue. + const QColor ink = roles.value(role, defaultAppColor(role, dark)); + if (!ink.isValid()) { + continue; + } + ++checked; + EXPECT_GT(ink.red(), ink.green()) << theme.toStdString() << static_cast(role); + EXPECT_GT(ink.green(), ink.blue()) << theme.toStdString() << static_cast(role); + } + + for (const AppColor::Role role : {AppColor::Movement, AppColor::MovementStrong, AppColor::MovementSoft}) { + // Movement is teal: green leads, then blue, then red. + const QColor ink = roles.value(role, defaultAppColor(role, dark)); + if (!ink.isValid()) { + continue; + } + ++checked; + EXPECT_GT(ink.green(), ink.blue()) << theme.toStdString() << static_cast(role); + EXPECT_GT(ink.blue(), ink.red()) << theme.toStdString() << static_cast(role); + } + } + } + EXPECT_GT(checked, 0) << "no shipped theme authored a semantic emphasis level"; +} + +/** + * @brief Every palette-shipping theme authors the whole emphasis ladder. + * + * The palette editor treats an unset role as "derive it", so a missing key is + * not a crash: it silently reintroduces the computed colors this change exists + * to remove. Treat a missing role as a test failure instead, and keep the same + * contrast contract the old computed levels had. + */ +TEST(IconInk, ShippedPalettesAuthorTheEmphasisLadder) +{ + const QString themesRoot = + QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/themes")); + const QStringList themes = QDir(themesRoot).entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name); + ASSERT_FALSE(themes.isEmpty()); + + int checked = 0; + for (const QString &theme : themes) { + for (const bool dark : {false, true}) { + const InkPalette palette = shippedPalette(theme, dark); + if (!palette.text.isValid()) { + continue; // System ships no palette, so it has no surfaces to measure. + } + ++checked; + + for (const AppColor::Role role : {AppColor::Icon, AppColor::IconStrong, AppColor::IconSoft, + AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft, + AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}) { + ASSERT_TRUE(palette.roles.contains(role)) + << theme.toStdString() << (dark ? " dark" : " light") << static_cast(role); + } + + for (const QColor &surface : {palette.window, palette.button}) { + const double secondary = ThemeIconInk::contrastRatio(palette.roles.value(AppColor::Icon), surface); + const double primary = ThemeIconInk::contrastRatio(palette.roles.value(AppColor::IconStrong), surface); + const double quiet = ThemeIconInk::contrastRatio(palette.roles.value(AppColor::IconSoft), surface); + EXPECT_GE(secondary, ThemeIconInk::labelContrastFloor()) + << theme.toStdString() << (dark ? " dark" : " light"); + EXPECT_GE(primary, ThemeIconInk::primaryContrastFloor()) + << theme.toStdString() << (dark ? " dark" : " light"); + EXPECT_GT(palette.roles.value(AppColor::IconStrong).hsvSaturation(), + palette.roles.value(AppColor::Icon).hsvSaturation()) + << theme.toStdString() << (dark ? " dark" : " light"); + EXPECT_GE(quiet, ThemeIconInk::quietContrastFloor()) + << theme.toStdString() << (dark ? " dark" : " light"); + EXPECT_LT(palette.roles.value(AppColor::IconSoft).hsvSaturation(), + palette.roles.value(AppColor::Icon).hsvSaturation()) + << theme.toStdString() << (dark ? " dark" : " light"); + + for (const auto &[base, strong, soft] : + {std::tuple{AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft}, + std::tuple{AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}}) { + EXPECT_GE(ThemeIconInk::contrastRatio(palette.roles.value(soft), surface), + ThemeIconInk::quietContrastFloor()) + << theme.toStdString() << (dark ? " dark" : " light"); + EXPECT_LT(palette.roles.value(soft).hsvSaturation(), palette.roles.value(base).hsvSaturation()) + << theme.toStdString() << (dark ? " dark" : " light"); + EXPECT_GT(palette.roles.value(strong).hsvSaturation(), palette.roles.value(base).hsvSaturation()) + << theme.toStdString() << (dark ? " dark" : " light"); + } + } + } + } + EXPECT_GT(checked, 0) << "no shipped palette was loadable"; + + // The System theme ships no palette of its own, so its families have no + // authored surfaces to measure against. It nevertheless authors the + // semantic family, while the neutral family stays unauthored on purpose: + // the palette file is chosen from the theme's scheme, which is always light + // on Qt < 6.5 and can be unknown on newer Qt, so one fixed ladder would be + // invisible against a platform palette of the other scheme. + for (const bool dark : {false, true}) { + const QMap system = shippedAppColors(QStringLiteral("System"), dark); + EXPECT_TRUE(system.contains(AppColor::PositiveStrong)); + EXPECT_TRUE(system.contains(AppColor::NegativeSoft)); + for (const AppColor::Role role : {AppColor::Icon, AppColor::IconStrong, AppColor::IconSoft}) { + EXPECT_FALSE(system.contains(role)) << (dark ? "dark" : "light") << static_cast(role); + } + } +} + +/** + * @brief Neutral chrome keeps a hue distance from the semantic families. + * + * Fusion's accent is green, so deriving neutral ink from it painted every menu + * and toolbar glyph the same color as the Positive family. The authored neutral + * family has to stay a hue a reader can tell apart from "add" and "remove". + */ +TEST(IconInk, NeutralFamilyKeepsItsDistanceFromTheSemanticFamilies) +{ + const QString themesRoot = + QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/themes")); + const QStringList themes = QDir(themesRoot).entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name); + ASSERT_FALSE(themes.isEmpty()); + + int checked = 0; + for (const QString &theme : themes) { + for (const bool dark : {false, true}) { + const InkPalette palette = shippedPalette(theme, dark); + if (!palette.text.isValid()) { + continue; + } + const QColor neutral = palette.roles.value(AppColor::Icon); + for (const AppColor::Role role : {AppColor::Positive, AppColor::Negative}) { + const QColor semantic = palette.roles.value(role); + ASSERT_TRUE(neutral.isValid() && semantic.isValid()) + << theme.toStdString() << (dark ? " dark" : " light"); + const int distance = qAbs(neutral.hue() - semantic.hue()); + EXPECT_GE(qMin(distance, 360 - distance), 30) + << theme.toStdString() << (dark ? " dark" : " light") << static_cast(role); + ++checked; + } + } + } + EXPECT_GT(checked, 0) << "no shipped palette was loadable"; +} + +/** + * @brief Every shipped theme keeps the disabled level below the quiet level. + * + * The palette's disabled text is a fixed gray, so this is the invariant the + * derived disabled ink exists to hold; a regression here makes an enabled quiet + * glyph look unavailable. + */ +TEST(IconInk, DisabledStaysBelowQuietOnEveryShippedTheme) +{ + const QString themesRoot = + QDir(QStringLiteral(COCKATRICE_SOURCE_DIR)).absoluteFilePath(QStringLiteral("cockatrice/themes")); + const QStringList themes = QDir(themesRoot).entryList(QDir::Dirs | QDir::NoDotAndDotDot, QDir::Name); + ASSERT_FALSE(themes.isEmpty()); + + int checked = 0; + for (const QString &theme : themes) { + for (const bool dark : {false, true}) { + const InkPalette palette = shippedPalette(theme, dark); + if (!palette.text.isValid()) { + continue; + } + const QColor quiet = palette.roles.value(AppColor::IconSoft); + ASSERT_TRUE(quiet.isValid()) << theme.toStdString() << (dark ? " dark" : " light"); + const QColor surface = ThemeIconInk::harderSurface(palette.window, palette.button, quiet); + const QColor disabled = ThemeIconInk::disabledInk(quiet, surface); + EXPECT_GE(ThemeIconInk::contrastRatio(quiet, surface) - ThemeIconInk::contrastRatio(disabled, surface), 1.0) + << theme.toStdString() << (dark ? " dark" : " light"); + + for (const QColor &anySurface : {palette.window, palette.button}) { + EXPECT_LT(ThemeIconInk::contrastRatio(disabled, anySurface), + ThemeIconInk::contrastRatio(quiet, anySurface)) + << theme.toStdString() << (dark ? " dark" : " light"); + } + ++checked; + } + } + EXPECT_GT(checked, 0) << "no shipped palette was loadable"; +} + +/** + * @brief The System fallback family keeps the ladder on a platform palette. + * + * System authors no palette and follows the OS surfaces, so its families + * cannot be measured against its own window and button. These are the + * representative extremes of a platform palette, and the fixed values have to + * clear the same floor there that the five authored themes clear on their own + * surfaces. + */ +TEST(IconInk, DefaultSemanticFamilyMeetsTheLadderOnPlatformSurfaces) +{ + struct Scheme + { + bool dark; + QColor window; + QColor button; + }; + const QList schemes = { + {false, QColor(QStringLiteral("#f0f0f0")), QColor(QStringLiteral("#f5f5f5"))}, + {true, QColor(QStringLiteral("#2b2b2b")), QColor(QStringLiteral("#3c3c3c"))}, + }; + + for (const Scheme &scheme : schemes) { + for (const auto &[base, strong, soft] : + {std::tuple{AppColor::Positive, AppColor::PositiveStrong, AppColor::PositiveSoft}, + std::tuple{AppColor::Negative, AppColor::NegativeStrong, AppColor::NegativeSoft}, + std::tuple{AppColor::Informational, AppColor::InformationalStrong, AppColor::InformationalSoft}, + std::tuple{AppColor::Hidden, AppColor::HiddenStrong, AppColor::HiddenSoft}, + std::tuple{AppColor::Marked, AppColor::MarkedStrong, AppColor::MarkedSoft}, + std::tuple{AppColor::Movement, AppColor::MovementStrong, AppColor::MovementSoft}}) { + const QColor secondary = defaultAppColor(base, scheme.dark); + const QColor primary = defaultAppColor(strong, scheme.dark); + const QColor quiet = defaultAppColor(soft, scheme.dark); + ASSERT_TRUE(secondary.isValid() && primary.isValid() && quiet.isValid()); + + for (const QColor &surface : {scheme.window, scheme.button}) { + EXPECT_GE(ThemeIconInk::contrastRatio(secondary, surface), ThemeIconInk::labelContrastFloor()); + EXPECT_GE(ThemeIconInk::contrastRatio(primary, surface), ThemeIconInk::labelContrastFloor()); + EXPECT_GE(ThemeIconInk::contrastRatio(quiet, surface), ThemeIconInk::labelContrastFloor()); + } + EXPECT_LT(quiet.hsvSaturation(), secondary.hsvSaturation()); + EXPECT_GT(primary.hsvSaturation(), secondary.hsvSaturation()); + } + } +} + +/** + * @brief Every emphasis keeps the selected-row swap to HighlightedText. + */ +TEST_F(ThemeIconRenderTest, SelectedStillFallsBackToHighlightedText) +{ + const QColor tone = IconTint(AppColor::AccentSoft).resolve(QIcon::Normal); + ASSERT_TRUE(tone.isValid()); + + QPalette palette = qApp->palette(); + const QColor selectedText(QStringLiteral("#222222")); + palette.setColor(QPalette::Active, QPalette::Highlight, tone); + palette.setColor(QPalette::Active, QPalette::HighlightedText, selectedText); + qApp->setPalette(palette); + + // Tone on a highlight of the same color is illegible, so the selection wins + // for both the default and an explicitly tinted glyph. + EXPECT_EQ(IconTint().resolve(QIcon::Selected), selectedText); + EXPECT_EQ(IconTint(AppColor::AccentSoft).resolve(QIcon::Selected), selectedText); +} + +/** + * @brief Writes the emphasis ladder sheets for every shipped palette theme. + * + * One sheet per theme and scheme: the neutral, positive and negative families at + * their quiet, secondary and primary levels, each as a line glyph, a sticker and + * a badge, three glyphs per family, at 16 and 24 px, on the window and button + * surfaces. The tone is the one the emphasis rule resolves for that surface, so + * the sheet is what the engine paints rather than a hand-picked sample. + */ +TEST_F(ThemeIconRenderTest, EmphasisPreviewWritesPngsWhenRequested) +{ + const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR"); + if (outDir.isEmpty()) { + GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs"; + } + ASSERT_TRUE(QDir().mkpath(outDir)); + + constexpr int MARGIN = 24; + constexpr int LABEL_WIDTH = 230; + constexpr int CELL_WIDTH = 166; + constexpr int ROW_HEIGHT = 58; + constexpr int PANEL_TITLE = 26; + constexpr int COLUMN_HEADER = 20; + constexpr int PANEL_PADDING = 12; + constexpr int GLYPH_SLOT = 32; + constexpr qreal DPR = 2.0; + constexpr int SIZES[] = {16, 24}; + constexpr const char *TREATMENTS[] = {"line", "sticker", "badge"}; + constexpr const char *LEVEL_NAMES[] = {"Quiet", "Secondary", "Primary"}; + + struct Family + { + const char *name; + const char *glyphs[3]; + AppColor::Role quiet; + AppColor::Role secondary; + AppColor::Role primary; + }; + const QList families = { + {"Neutral", {"settings", "info", "player"}, AppColor::IconSoft, AppColor::Icon, AppColor::IconStrong}, + {"Positive", + {"increment", "ready_start", "arrow_up"}, + AppColor::PositiveSoft, + AppColor::Positive, + AppColor::PositiveStrong}, + {"Negative", + {"decrement", "not_ready_start", "remove_row"}, + AppColor::NegativeSoft, + AppColor::Negative, + AppColor::NegativeStrong}, + {"Informational", + {"arrow_right", "view", "arrow_to_top"}, + AppColor::InformationalSoft, + AppColor::Informational, + AppColor::InformationalStrong}, + {"Hidden", {"view", "arrow_right", "swap"}, AppColor::HiddenSoft, AppColor::Hidden, AppColor::HiddenStrong}, + {"Marked", {"pencil", "share", "pin"}, AppColor::MarkedSoft, AppColor::Marked, AppColor::MarkedStrong}, + {"Movement", + {"arrow_to_top", "arrow_to_bottom", "arrow_down"}, + AppColor::MovementSoft, + AppColor::Movement, + AppColor::MovementStrong}, + }; + const QStringList themes = { + QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"), + QStringLiteral("Plasma"), QStringLiteral("VelvetMarble"), + }; + + const auto renderLine = [](const QByteArray &svg, const QColor &ink, const QSize &size) { + QSvgRenderer renderer(resolveSvgColorSentinel(svg, ink.name(QColor::HexRgb).toLatin1())); + QPixmap pixmap(size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + renderer.render(&painter); + return pixmap; + }; + + const int panelWidth = LABEL_WIDTH + static_cast(std::size(TREATMENTS)) * CELL_WIDTH + PANEL_PADDING; + const int panelHeight = + PANEL_TITLE + COLUMN_HEADER + static_cast(families.size()) * 3 * ROW_HEIGHT + PANEL_PADDING; + const int logicalWidth = 2 * MARGIN + panelWidth; + const int logicalHeight = MARGIN + 30 + 2 * (panelHeight + 24) + MARGIN; + + for (const QString &theme : themes) { + for (const bool dark : {false, true}) { + const InkPalette palette = shippedPalette(theme, dark); + ASSERT_TRUE(palette.text.isValid()) << theme.toStdString(); + + // The emphasis rule reads the live palette, so apply the theme + // before resolving tones for the sheet. + ASSERT_TRUE(applyShippedPalette(theme, dark)); + + // The paint path is a lookup now, so the sheet shows the authored + // rows verbatim rather than a color recomputed from the accent. + const auto toneFor = [&](const Family &family, int level) { + const AppColor::Role role = level == 0 ? family.quiet : level == 1 ? family.secondary : family.primary; + // A theme written before the role existed does not author it; + // the client paints the scheme default, so the sheet does too. + return palette.roles.value(role, defaultAppColor(role, dark)); + }; + const auto scheme = dark ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light; + + QImage image(QSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)), + QImage::Format_ARGB32_Premultiplied); + image.fill(palette.window); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.scale(DPR, DPR); + + painter.setPen(palette.text); + painter.setFont(QFont(QStringLiteral("Sans"), 10, QFont::DemiBold)); + painter.drawText(QPointF(MARGIN, MARGIN), + QStringLiteral("%1 — %2 scheme, emphasis across treatments") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + + struct Surface + { + const char *name; + QColor background; + QColor text; + }; + const QList surfaces = { + {"Window surface", palette.window, palette.text}, + {"Button surface", palette.button, palette.text}, + }; + + qreal panelTop = MARGIN + 30; + for (const Surface &surface : surfaces) { + painter.setPen(Qt::NoPen); + painter.setBrush(surface.background); + painter.drawRoundedRect(QRectF(MARGIN, panelTop, panelWidth, panelHeight), 8, 8); + + const qreal contentLeft = MARGIN + PANEL_PADDING; + painter.setPen(surface.text); + painter.setFont(QFont(QStringLiteral("Sans"), 9, QFont::DemiBold)); + painter.drawText(QPointF(contentLeft, panelTop + 18), QString::fromLatin1(surface.name)); + + painter.setFont(QFont(QStringLiteral("Sans"), 8)); + for (std::size_t treatment = 0; treatment < std::size(TREATMENTS); ++treatment) { + const qreal x = contentLeft + LABEL_WIDTH + static_cast(treatment) * CELL_WIDTH; + painter.drawText(QPointF(x, panelTop + PANEL_TITLE + 14), + QString::fromLatin1(TREATMENTS[treatment]) + QStringLiteral(" 16 / 24")); + } + + qreal y = panelTop + PANEL_TITLE + COLUMN_HEADER; + for (const Family &family : families) { + for (int level = 0; level < 3; ++level) { + const QColor ink = toneFor(family, level); + ASSERT_TRUE(ink.isValid()) + << theme.toStdString() << " " << family.name << " " << LEVEL_NAMES[level]; + + painter.setPen(surface.text); + painter.setFont(QFont(QStringLiteral("Sans"), 8, QFont::DemiBold)); + painter.drawText(QPointF(contentLeft, y + 14), + QStringLiteral("%1 / %2").arg(QString::fromLatin1(family.name), + QString::fromLatin1(LEVEL_NAMES[level]))); + painter.setFont(QFont(QStringLiteral("Mono"), 7)); + painter.drawText(QPointF(contentLeft, y + 28), ink.name(QColor::HexRgb)); + + for (std::size_t treatment = 0; treatment < std::size(TREATMENTS); ++treatment) { + const qreal cellX = contentLeft + LABEL_WIDTH + static_cast(treatment) * CELL_WIDTH; + for (std::size_t sizeIndex = 0; sizeIndex < std::size(SIZES); ++sizeIndex) { + const int side = SIZES[sizeIndex]; + const QSize size(side, side); + const qreal rowTop = y + (sizeIndex == 0 ? 4 : 28); + for (std::size_t glyphIndex = 0; glyphIndex < std::size(family.glyphs); ++glyphIndex) { + const QByteArray svg = readIconSvg(family.glyphs[glyphIndex]); + ASSERT_FALSE(svg.isEmpty()) << family.glyphs[glyphIndex]; + const QPixmap pixmap = renderAtDpr(size, DPR, [&](const QSize &pixelSize) { + if (treatment == 0) { + return renderLine(svg, ink, pixelSize); + } + if (treatment == 1) { + return ThemeIconSticker::renderSticker(svg, ink, pixelSize); + } + return ThemeIconBadge::renderBadge(svg, ink, pixelSize, scheme); + }); + const qreal x = cellX + 6 + static_cast(glyphIndex) * GLYPH_SLOT + + (GLYPH_SLOT - side) / 2.0; + painter.drawPixmap(QPointF(x, rowTop + (GLYPH_SLOT - side) / 2.0), pixmap); + } + } + } + y += ROW_HEIGHT; + } + } + panelTop += panelHeight + 24; + } + + painter.end(); + const QString path = QDir(outDir).absoluteFilePath( + QStringLiteral("emphasis-preview-%1-%2.png") + .arg(theme.toLower(), dark ? QStringLiteral("dark") : QStringLiteral("light"))); + ASSERT_TRUE(image.save(path)) << path.toStdString(); + } + } +} + +/** + * @brief Writes the authored emphasis sheets for every shipped palette theme. + * + * The ladder is only useful if it survives the treatment a call site chooses, so + * each sheet renders the same three authored levels as line glyphs, as sticker + * fills and as badges, at the two sizes the UI uses, on the two surfaces the + * contrast targets are measured against. Each family carries three glyphs, so + * the sheet also proves the level, not just one lucky shape. A disabled row + * closes each family: it must read dimmer than quiet, and every treatment falls + * back to the line glyph there. + * + * The authored tone is the top of every filled gradient, so the sheet prints that + * tone and the darker bottom the shape actually carries. + */ +TEST_F(ThemeIconRenderTest, EmphasisTreatmentsPreviewWritesPngsWhenRequested) +{ + const QString outDir = qEnvironmentVariable("COCKATRICE_ICON_PREVIEW_DIR"); + if (outDir.isEmpty()) { + GTEST_SKIP() << "set COCKATRICE_ICON_PREVIEW_DIR to write the preview PNGs"; + } + ASSERT_TRUE(QDir().mkpath(outDir)); + + constexpr int MARGIN = 24; + constexpr int LABEL_WIDTH = 230; + constexpr int CELL_WIDTH = 166; + constexpr int ROW_HEIGHT = 58; + constexpr int PANEL_TITLE = 26; + constexpr int COLUMN_HEADER = 20; + constexpr int PANEL_PADDING = 12; + constexpr int GLYPH_SLOT = 32; + constexpr qreal DPR = 2.0; + constexpr int SIZES[] = {16, 24}; + constexpr const char *TREATMENTS[] = {"line", "sticker", "badge"}; + constexpr const char *LEVEL_NAMES[] = {"Quiet", "Secondary", "Primary"}; + + struct Family + { + const char *name; + const char *glyphs[3]; + AppColor::Role quiet; + AppColor::Role secondary; + AppColor::Role primary; + }; + const QList families = { + {"Neutral", {"settings", "info", "player"}, AppColor::IconSoft, AppColor::Icon, AppColor::IconStrong}, + {"Positive", + {"increment", "ready_start", "arrow_up"}, + AppColor::PositiveSoft, + AppColor::Positive, + AppColor::PositiveStrong}, + {"Negative", + {"decrement", "not_ready_start", "remove_row"}, + AppColor::NegativeSoft, + AppColor::Negative, + AppColor::NegativeStrong}, + {"Informational", + {"arrow_right", "view", "arrow_to_top"}, + AppColor::InformationalSoft, + AppColor::Informational, + AppColor::InformationalStrong}, + {"Hidden", {"view", "arrow_right", "swap"}, AppColor::HiddenSoft, AppColor::Hidden, AppColor::HiddenStrong}, + {"Marked", {"pencil", "share", "pin"}, AppColor::MarkedSoft, AppColor::Marked, AppColor::MarkedStrong}, + {"Movement", + {"arrow_to_top", "arrow_to_bottom", "arrow_down"}, + AppColor::MovementSoft, + AppColor::Movement, + AppColor::MovementStrong}, + }; + const QStringList themes = { + QStringLiteral("Fabric"), QStringLiteral("Fusion"), QStringLiteral("Leather"), + QStringLiteral("Plasma"), QStringLiteral("VelvetMarble"), + }; + + const auto roleAt = [](const Family &family, int level) { + return level == 0 ? family.quiet : level == 1 ? family.secondary : family.primary; + }; + const auto schemeFor = [](bool dark) { return dark ? ThemeIconFill::Scheme::Dark : ThemeIconFill::Scheme::Light; }; + const auto renderLine = [](const QByteArray &svg, const QColor &ink, const QSize &size) { + QSvgRenderer renderer(resolveSvgColorSentinel(svg, ink.name(QColor::HexRgb).toLatin1())); + QPixmap pixmap(size); + pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setRenderHint(QPainter::Antialiasing, true); + renderer.render(&painter); + return pixmap; + }; + + const int panelWidth = LABEL_WIDTH + static_cast(std::size(TREATMENTS)) * CELL_WIDTH + PANEL_PADDING; + const int panelHeight = + PANEL_TITLE + COLUMN_HEADER + static_cast(families.size()) * 4 * ROW_HEIGHT + PANEL_PADDING; + const int logicalWidth = 2 * MARGIN + panelWidth; + const int logicalHeight = MARGIN + 30 + 2 * (panelHeight + 24) + MARGIN; + + for (const QString &theme : themes) { + for (const bool dark : {false, true}) { + const InkPalette palette = shippedPalette(theme, dark); + ASSERT_TRUE(palette.text.isValid()) << theme.toStdString(); + const QColor quietTone = palette.roles.value(AppColor::IconSoft); + ASSERT_TRUE(quietTone.isValid()) << theme.toStdString(); + // What the engine paints for `QIcon::Disabled`: the authored quiet + // level receded past its floor, measured on the harder surface. + const QColor disabledTone = ThemeIconInk::disabledInk( + quietTone, ThemeIconInk::harderSurface(palette.window, palette.button, quietTone)); + + QImage image(QSize(qRound(logicalWidth * DPR), qRound(logicalHeight * DPR)), + QImage::Format_ARGB32_Premultiplied); + image.fill(palette.window); + QPainter painter(&image); + painter.setRenderHint(QPainter::Antialiasing, true); + painter.setRenderHint(QPainter::SmoothPixmapTransform, true); + painter.scale(DPR, DPR); + + painter.setPen(palette.text); + painter.setFont(QFont(QStringLiteral("Sans"), 10, QFont::DemiBold)); + painter.drawText(QPointF(MARGIN, MARGIN), + QStringLiteral("%1 — %2 scheme, authored emphasis across treatments") + .arg(theme, dark ? QStringLiteral("dark") : QStringLiteral("light"))); + + struct Surface + { + const char *name; + QColor background; + QColor text; + }; + const QList surfaces = { + {"Window surface", palette.window, palette.text}, + {"Button surface", palette.button, palette.buttonText}, + }; + + qreal panelTop = MARGIN + 30; + for (const Surface &surface : surfaces) { + painter.setPen(Qt::NoPen); + painter.setBrush(surface.background); + painter.drawRoundedRect(QRectF(MARGIN, panelTop, panelWidth, panelHeight), 8, 8); + + const qreal contentLeft = MARGIN + PANEL_PADDING; + painter.setPen(surface.text); + painter.setFont(QFont(QStringLiteral("Sans"), 9, QFont::DemiBold)); + painter.drawText(QPointF(contentLeft, panelTop + 18), QString::fromLatin1(surface.name)); + + painter.setFont(QFont(QStringLiteral("Sans"), 8)); + for (std::size_t treatment = 0; treatment < std::size(TREATMENTS); ++treatment) { + const qreal x = contentLeft + LABEL_WIDTH + static_cast(treatment) * CELL_WIDTH; + painter.drawText(QPointF(x, panelTop + PANEL_TITLE + 14), + QString::fromLatin1(TREATMENTS[treatment]) + QStringLiteral(" 16 / 24")); + } + + qreal y = panelTop + PANEL_TITLE + COLUMN_HEADER; + for (const Family &family : families) { + for (int level = 0; level < 4; ++level) { + const bool disabled = level == 3; + const QColor ink = disabled ? disabledTone + : palette.roles.value(roleAt(family, level), + defaultAppColor(roleAt(family, level), dark)); + const QString levelName = + disabled ? QStringLiteral("Disabled") : QString::fromLatin1(LEVEL_NAMES[level]); + ASSERT_TRUE(ink.isValid()) + << theme.toStdString() << " " << family.name << " " << levelName.toStdString(); + const QColor bottom = disabled ? ink : ThemeIconFill::bottomColor(ink); + + painter.setPen(surface.text); + painter.setFont(QFont(QStringLiteral("Sans"), 8, QFont::DemiBold)); + painter.drawText(QPointF(contentLeft, y + 14), + QStringLiteral("%1 / %2").arg(QString::fromLatin1(family.name), levelName)); + painter.setFont(QFont(QStringLiteral("Mono"), 7)); + painter.drawText(QPointF(contentLeft, y + 28), ink.name(QColor::HexRgb)); + if (!disabled && bottom != ink) { + painter.drawText(QPointF(contentLeft + 82, y + 28), + QStringLiteral("bottom ") + bottom.name(QColor::HexRgb)); + } + + for (std::size_t treatment = 0; treatment < std::size(TREATMENTS); ++treatment) { + const qreal cellX = contentLeft + LABEL_WIDTH + static_cast(treatment) * CELL_WIDTH; + for (std::size_t sizeIndex = 0; sizeIndex < std::size(SIZES); ++sizeIndex) { + const int side = SIZES[sizeIndex]; + const QSize size(side, side); + const qreal rowTop = y + (sizeIndex == 0 ? 4 : 28); + for (std::size_t glyphIndex = 0; glyphIndex < std::size(family.glyphs); ++glyphIndex) { + const QByteArray svg = readIconSvg(family.glyphs[glyphIndex]); + ASSERT_FALSE(svg.isEmpty()) << family.glyphs[glyphIndex]; + const QPixmap pixmap = renderAtDpr(size, DPR, [&](const QSize &pixelSize) { + if (disabled || treatment == 0) { + return renderLine(svg, ink, pixelSize); + } + if (treatment == 1) { + return ThemeIconSticker::renderSticker(svg, ink, pixelSize); + } + return ThemeIconBadge::renderBadge(svg, ink, pixelSize, schemeFor(dark)); + }); + const qreal x = cellX + 6 + static_cast(glyphIndex) * GLYPH_SLOT + + (GLYPH_SLOT - side) / 2.0; + painter.drawPixmap(QPointF(x, rowTop + (GLYPH_SLOT - side) / 2.0), pixmap); + } + } + } + y += ROW_HEIGHT; + } + } + panelTop += panelHeight + 24; + } + + painter.end(); + const QString path = QDir(outDir).absoluteFilePath( + QStringLiteral("emphasis-treatments-%1-%2.png") + .arg(theme.toLower(), dark ? QStringLiteral("dark") : QStringLiteral("light"))); + ASSERT_TRUE(image.save(path)) << path.toStdString(); + } + } +} diff --git a/tests/theme_migration/theme_migration_test.cpp b/tests/theme_migration/theme_migration_test.cpp index d77c2f62d..329d13458 100644 --- a/tests/theme_migration/theme_migration_test.cpp +++ b/tests/theme_migration/theme_migration_test.cpp @@ -9,12 +9,16 @@ #include #include #include +#include #include // Owned by main.cpp in the real application; pixel_map_generator.cpp references // it, so the test binary needs a definition even though it never renders. ThemeManager *themeManager = nullptr; +// Referenced by other translation units in the server_remote library. +RNG_Abstract *rng = nullptr; + namespace { const QString LEGACY = QStringLiteral("Default"); @@ -149,6 +153,90 @@ TEST_F(ThemeMigrationTest, IsIdempotent) EXPECT_EQ(readThemeFile(themesPath, CURRENT, "style.css").toStdString(), std::string("css")); } +TEST(ThemeAppColorTest, SemanticDefaultsFollowTheScheme) +{ + EXPECT_EQ(defaultAppColor(AppColor::Positive, false), QColor(QStringLiteral("#1a7d3a"))); + EXPECT_EQ(defaultAppColor(AppColor::Positive, true), QColor(QStringLiteral("#6dd58c"))); + EXPECT_EQ(defaultAppColor(AppColor::PositiveStrong, false), QColor(QStringLiteral("#007e28"))); + EXPECT_EQ(defaultAppColor(AppColor::PositiveStrong, true), QColor(QStringLiteral("#32c05c"))); + EXPECT_EQ(defaultAppColor(AppColor::PositiveSoft, false), QColor(QStringLiteral("#4c7659"))); + EXPECT_EQ(defaultAppColor(AppColor::PositiveSoft, true), QColor(QStringLiteral("#8ab196"))); + EXPECT_EQ(defaultAppColor(AppColor::Negative, false), QColor(QStringLiteral("#b3261e"))); + EXPECT_EQ(defaultAppColor(AppColor::Negative, true), QColor(QStringLiteral("#ff8a80"))); + EXPECT_EQ(defaultAppColor(AppColor::NegativeStrong, false), QColor(QStringLiteral("#da0b00"))); + EXPECT_EQ(defaultAppColor(AppColor::NegativeStrong, true), QColor(QStringLiteral("#ff8378"))); + EXPECT_EQ(defaultAppColor(AppColor::NegativeSoft, false), QColor(QStringLiteral("#935f5c"))); + EXPECT_EQ(defaultAppColor(AppColor::NegativeSoft, true), QColor(QStringLiteral("#c79e9b"))); + EXPECT_EQ(defaultAppColor(AppColor::Informational, false), QColor(QStringLiteral("#1c64c4"))); + EXPECT_EQ(defaultAppColor(AppColor::Informational, true), QColor(QStringLiteral("#7aa9f2"))); + EXPECT_EQ(defaultAppColor(AppColor::InformationalStrong, false), QColor(QStringLiteral("#0a4fb5"))); + EXPECT_EQ(defaultAppColor(AppColor::InformationalStrong, true), QColor(QStringLiteral("#69aeff"))); + EXPECT_EQ(defaultAppColor(AppColor::InformationalSoft, false), QColor(QStringLiteral("#526a8a"))); + EXPECT_EQ(defaultAppColor(AppColor::InformationalSoft, true), QColor(QStringLiteral("#9db2cd"))); + EXPECT_EQ(defaultAppColor(AppColor::Hidden, false), QColor(QStringLiteral("#6d28d9"))); + EXPECT_EQ(defaultAppColor(AppColor::Hidden, true), QColor(QStringLiteral("#c6a5fa"))); + EXPECT_EQ(defaultAppColor(AppColor::HiddenStrong, false), QColor(QStringLiteral("#4c0f9e"))); + EXPECT_EQ(defaultAppColor(AppColor::HiddenStrong, true), QColor(QStringLiteral("#c99bff"))); + EXPECT_EQ(defaultAppColor(AppColor::HiddenSoft, false), QColor(QStringLiteral("#6a5a8c"))); + EXPECT_EQ(defaultAppColor(AppColor::HiddenSoft, true), QColor(QStringLiteral("#b3a6c9"))); + EXPECT_EQ(defaultAppColor(AppColor::Marked, false), QColor(QStringLiteral("#9a5b05"))); + EXPECT_EQ(defaultAppColor(AppColor::Marked, true), QColor(QStringLiteral("#e2a63a"))); + EXPECT_EQ(defaultAppColor(AppColor::MarkedStrong, false), QColor(QStringLiteral("#8a4f02"))); + EXPECT_EQ(defaultAppColor(AppColor::MarkedStrong, true), QColor(QStringLiteral("#ffb020"))); + EXPECT_EQ(defaultAppColor(AppColor::MarkedSoft, false), QColor(QStringLiteral("#7b6a4a"))); + EXPECT_EQ(defaultAppColor(AppColor::MarkedSoft, true), QColor(QStringLiteral("#c2ab7d"))); + EXPECT_EQ(defaultAppColor(AppColor::Movement, false), QColor(QStringLiteral("#0e7a72"))); + EXPECT_EQ(defaultAppColor(AppColor::Movement, true), QColor(QStringLiteral("#4cc2b5"))); + EXPECT_EQ(defaultAppColor(AppColor::MovementStrong, false), QColor(QStringLiteral("#096b62"))); + EXPECT_EQ(defaultAppColor(AppColor::MovementStrong, true), QColor(QStringLiteral("#2dd4bf"))); + EXPECT_EQ(defaultAppColor(AppColor::MovementSoft, false), QColor(QStringLiteral("#4a6b67"))); + EXPECT_EQ(defaultAppColor(AppColor::MovementSoft, true), QColor(QStringLiteral("#a3c1bc"))); + // Accent and neutral icon roles have no static default: an older theme + // written before the roles existed falls back to deriving them from its + // palette rather than pinning one scheme's ink. + for (const bool dark : {false, true}) { + EXPECT_FALSE(defaultAppColor(AppColor::AccentStrong, dark).isValid()); + EXPECT_FALSE(defaultAppColor(AppColor::AccentSoft, dark).isValid()); + EXPECT_FALSE(defaultAppColor(AppColor::Icon, dark).isValid()); + EXPECT_FALSE(defaultAppColor(AppColor::IconStrong, dark).isValid()); + EXPECT_FALSE(defaultAppColor(AppColor::IconSoft, dark).isValid()); + } +} + +TEST(ThemeAppColorTest, PaletteConfigRoundTripsSemanticRoles) +{ + QTemporaryDir dir; + ASSERT_TRUE(dir.isValid()); + + PaletteConfig config; + const QMap roles = { + {AppColor::Icon, QColor(QStringLiteral("#2f59ad"))}, + {AppColor::IconStrong, QColor(QStringLiteral("#213e79"))}, + {AppColor::IconSoft, QColor(QStringLiteral("#687899"))}, + {AppColor::Positive, QColor(QStringLiteral("#1b7f3b"))}, + {AppColor::PositiveStrong, QColor(QStringLiteral("#104a22"))}, + {AppColor::PositiveSoft, QColor(QStringLiteral("#5d7f6f"))}, + {AppColor::Negative, QColor(QStringLiteral("#b3261e"))}, + {AppColor::NegativeStrong, QColor(QStringLiteral("#7c1b15"))}, + {AppColor::NegativeSoft, QColor(QStringLiteral("#9a6c6f"))}, + }; + for (auto it = roles.cbegin(); it != roles.cend(); ++it) { + config.appColors[it.key()] = it.value(); + } + + const QString path = QDir(dir.path()).absoluteFilePath(QStringLiteral("palette-light.toml")); + QFile file(path); + ASSERT_TRUE(file.open(QIODevice::WriteOnly | QIODevice::Text)); + file.write(config.toToml().toUtf8()); + file.close(); + + const PaletteConfig parsed = PaletteConfig::fromFile(path); + for (auto it = roles.cbegin(); it != roles.cend(); ++it) { + ASSERT_TRUE(parsed.appColors.contains(it.key())); + EXPECT_EQ(parsed.appColors.value(it.key()), it.value()); + } +} + // Every test brings its own main(): GTest is not guaranteed to be found on the build machines, and // the fallback then links the bare gtest library without gtest_main. int main(int argc, char **argv) diff --git a/tests/user_games_model_test.cpp b/tests/user_games_model_test.cpp new file mode 100644 index 000000000..33c29d6d4 --- /dev/null +++ b/tests/user_games_model_test.cpp @@ -0,0 +1,248 @@ +#include "interface/widgets/server/user/user_games_model.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +RNG_Abstract *rng = nullptr; // required by the transitive server library; this suite sends no game commands + +namespace +{ +class GamesClient : public AbstractClient +{ +public: + QList sent; + + GamesClient() + { + setStatus(StatusLoggedIn); + } + + ~GamesClient() override + { + qDeleteAll(pendingCommands); + pendingCommands.clear(); + } + + void changeStatus(ClientStatus status) + { + setStatus(status); + } + + void answer(int request, Response response) + { + response.set_cmd_id(sent.at(request).cmd_id()); + ServerMessage message; + message.set_message_type(ServerMessage::RESPONSE); + *message.mutable_response() = response; + processProtocolItem(message); + } + +protected: + void sendCommandContainer(const CommandContainer &command) override + { + sent.append(command); + } +}; + +Response gamesResponse(std::initializer_list ids) +{ + Response response; + response.set_response_code(Response::RespOk); + auto *games = response.MutableExtension(Response_GetGamesOfUser::ext); + for (int id : ids) { + auto *game = games->add_game_list(); + game->set_game_id(id); + game->set_room_id(7); + } + return response; +} + +using State = UserGamesModel::State; + +TEST(UserGamesModel, OfflineUserDoesNotSendARequest) +{ + GamesClient client; + UserGamesModel model(&client); + model.setUser("Alice", false); + model.refresh(); + EXPECT_TRUE(client.sent.isEmpty()); + EXPECT_EQ(State::Offline, model.getState()); + EXPECT_FALSE(model.canRefresh()); + EXPECT_EQ(0, model.rowCount()); +} + +TEST(UserGamesModel, PresenceLossInvalidatesTheOutstandingResponse) +{ + GamesClient client; + UserGamesModel model(&client); + model.setUser("Alice", true); + EXPECT_EQ(State::Loading, model.getState()); + EXPECT_FALSE(model.canRefresh()); + model.setUser("Alice", false); + client.answer(0, gamesResponse({1})); + EXPECT_EQ(State::Offline, model.getState()); + EXPECT_EQ(0, model.rowCount()); + + model.setUser("Alice", true); + client.answer(1, gamesResponse({2})); + EXPECT_EQ(State::Ready, model.getState()); + EXPECT_TRUE(model.canRefresh()); + EXPECT_EQ(2, model.data(model.index(0), UserGamesModel::GameRole).value().game_id()); +} + +TEST(UserGamesModel, SwitchingBackToTheSameUserDoesNotAcceptTheirOldRequest) +{ + GamesClient client; + UserGamesModel model(&client); + model.setUser("Alice", true); + model.setUser("Bob", true); + model.setUser("Alice", true); + client.answer(2, gamesResponse({3})); + client.answer(0, gamesResponse({1})); + client.answer(1, gamesResponse({2})); + EXPECT_EQ(1, model.rowCount()); + EXPECT_EQ(3, model.data(model.index(0), UserGamesModel::GameRole).value().game_id()); +} + +TEST(UserGamesModel, RefreshSupersedesThePreviousRequest) +{ + GamesClient client; + UserGamesModel model(&client); + model.setUser("Alice", true); + model.refresh(); + client.answer(0, gamesResponse({1})); + EXPECT_EQ(State::Loading, model.getState()); + EXPECT_EQ(0, model.rowCount()); + client.answer(1, gamesResponse({2})); + EXPECT_EQ(State::Ready, model.getState()); + EXPECT_EQ(2, model.data(model.index(0), UserGamesModel::GameRole).value().game_id()); +} + +TEST(UserGamesModel, EmptySuccessAndMissingPayloadAreDifferentStates) +{ + GamesClient client; + UserGamesModel model(&client); + model.setUser("Alice", true); + client.answer(0, gamesResponse({})); + EXPECT_EQ(State::Empty, model.getState()); + EXPECT_TRUE(model.canRefresh()); + + model.refresh(); + Response missingPayload; + missingPayload.set_response_code(Response::RespOk); + client.answer(1, missingPayload); + EXPECT_EQ(State::Error, model.getState()); + EXPECT_TRUE(model.canRefresh()); +} + +TEST(UserGamesModel, FailedRequestsCanBeRetried) +{ + GamesClient client; + UserGamesModel model(&client); + model.setUser("Alice", true); + Response error; + error.set_response_code(Response::RespNameNotFound); + client.answer(0, error); + EXPECT_EQ(State::Error, model.getState()); + model.refresh(); + client.answer(1, gamesResponse({4})); + EXPECT_EQ(State::Ready, model.getState()); +} + +TEST(UserGamesModel, DisconnectRejectsLateRepliesAndReconnectFetchesAgain) +{ + GamesClient client; + UserGamesModel model(&client); + model.setUser("Alice", true); + client.changeStatus(StatusDisconnecting); + client.answer(0, gamesResponse({1})); + EXPECT_EQ(State::Disconnected, model.getState()); + EXPECT_FALSE(model.canRefresh()); + model.refresh(); + EXPECT_EQ(1, client.sent.size()); + client.changeStatus(StatusLoggedIn); + client.answer(1, gamesResponse({2})); + EXPECT_EQ(State::Ready, model.getState()); +} + +TEST(UserGamesModel, ClientDestructionClearsGamesAndDisablesRefresh) +{ + auto client = std::make_unique(); + UserGamesModel model(client.get()); + model.setUser("Alice", true); + client->answer(0, gamesResponse({1})); + client.reset(); + EXPECT_EQ(State::Disconnected, model.getState()); + EXPECT_EQ(0, model.rowCount()); + EXPECT_FALSE(model.canRefresh()); + model.refresh(); + EXPECT_EQ(State::Disconnected, model.getState()); +} + +TEST(UserGamesModel, ResetInvalidatesAnOpenGameActionAndPreservesTheModelContract) +{ + GamesClient client; + UserGamesModel model(&client); + QAbstractItemModelTester tester(&model, QAbstractItemModelTester::FailureReportingMode::Fatal); + model.setUser("Alice", true); + client.answer(0, gamesResponse({1, 2, 3})); + QPersistentModelIndex selected(model.index(1)); + EXPECT_TRUE(selected.isValid()); + model.refresh(); + EXPECT_FALSE(selected.isValid()); + EXPECT_EQ(0, model.rowCount()); + client.answer(1, gamesResponse({4})); + EXPECT_EQ(1, model.rowCount()); +} + +TEST(UserGamesModel, StateListenerCanSelectAnotherUserBeforeTheRequestIsSent) +{ + GamesClient client; + UserGamesModel model(&client); + bool switched = false; + QObject::connect(&model, &UserGamesModel::stateChanged, &model, [&] { + if (!switched && model.getState() == State::Loading) { + switched = true; + model.setUser("Bob", false); + } + }); + model.setUser("Alice", true); + EXPECT_TRUE(client.sent.isEmpty()); + EXPECT_EQ(State::Offline, model.getState()); +} + +TEST(UserGamesModel, TimedOutResponseCannotReplaceARetry) +{ + GamesClient client; + UserGamesModel model(&client, 50); + model.setUser("Alice", true); + QSignalSpy changes(&model, &UserGamesModel::stateChanged); + ASSERT_TRUE(changes.wait(1000)); + ASSERT_EQ(State::Error, model.getState()); + EXPECT_TRUE(model.canRefresh()); + model.refresh(); + client.answer(0, gamesResponse({1})); + EXPECT_EQ(State::Loading, model.getState()); + client.answer(1, gamesResponse({2})); + EXPECT_EQ(State::Ready, model.getState()); + EXPECT_EQ(2, model.data(model.index(0), UserGamesModel::GameRole).value().game_id()); +} +} // namespace + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +}
    %1%2
    %1%2
    %1%2