diff --git a/.ci/compile.sh b/.ci/compile.sh index 9be8ba741..569ae9f6b 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -175,9 +175,9 @@ fi if [[ $MAKE_SANITIZE ]]; then flags+=("-DENABLE_SANITIZERS=ON") else - # The release-package step re-runs cmake in the same build dir, so an - # ENABLE_SANITIZERS=ON left over from the debug step would otherwise leak - # into the shipped binaries (libasan/libubsan Depends in the DEB). + # 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 @@ -185,9 +185,6 @@ if [[ $MAKE_COVERAGE ]]; then fi if [[ $USE_CCACHE ]]; 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 ccache --max-size "$CCACHE_SIZE" @@ -378,6 +375,21 @@ echo "Running cmake with flags: ${flags[*]}" cmake .. "${flags[@]}" echo "::endgroup::" +if [[ $USE_CCACHE ]]; 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 + echo "::group::Build project" cpuinfo echo "Running cmake --build with flags: ${buildflags[*]}" diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index df054ea26..4ac28d3a5 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -33,10 +33,18 @@ on: - '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' }} jobs: @@ -141,6 +149,7 @@ jobs: allow-failure: yes package: skip # We are packaged in Arch already + debug_test: yes # see the note on Fedora 44 below - distro: Servatrice_Debian version: 12 @@ -148,77 +157,105 @@ jobs: package: DEB server_only: yes test: skip - - - distro: Debian - version: 12 - - package: DEB - test: skip # Running tests on all distros is superfluous + ccache_size: 400M # server only, no client to compile - distro: Debian version: 13 package: DEB - - - distro: Fedora - version: 43 - - package: RPM - test: skip # Running tests on all distros is superfluous + ccache_size: 850M # the Release build plus the suite it runs - distro: Fedora version: 44 package: RPM - - - distro: Ubuntu - version: 24.04 - - package: DEB - test: skip # Running tests on all distros is superfluous + # Arch and Fedora 44 are the two newest toolchains, and the -Wno-error= exceptions + # in CMakeLists.txt exist for exactly their warnings. They keep compiling Debug: + # only the Debug configuration passes -Werror, so these two are the legs where a + # new warning in either compiler still fails the build. + debug_test: yes + ccache_size: 900M # Debug for the suite and Release for the package - distro: Ubuntu version: 26.04 package: DEB + test: skip # The sanitizer leg below runs this distro's suite - # 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 - + # 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. + # + # It packages nothing. A sanitized Debug build is not something to ship, and + # carrying the package build here meant 18 minutes of Release compile queued + # behind the instrumented one in a single job. The leg above publishes this + # distro's DEB. - distro: Ubuntu version: 26.04 - variant: cover # separate image and ccache, same distro + variant: sanitize # 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 + sanitize: yes + ccache_size: 800M # instrumented objects are larger 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. The quota currently holds about 1.1 GB of + # CodeQL trap, 1.1 GB of vcpkg, 0.7 GB of Qt and 2.1 GB of docker buildkit layers + # (mode=min in docker-release.yml, added here, brings the last one down to roughly + # 0.3 GB - the one input here that is an expectation rather than a measurement, since + # mode=min still writes one layer per pushed binary) plus the compiler caches of the nine legs a pull request runs. The five + # nightly-only legs keep no compiler cache (see PERSIST_CCACHE below), which is what + # makes these per-leg bounds affordable. Accounting for all of it: 6.45G of ccache + # bounds + 3.2G of the fixed entries = 9.65G against the 10G quota. + # + # A configuration needs roughly 500-850 MB on Linux, so the old uniform 600M bound sat + # below what a leg writes: ccache spent each build deleting the entries the next run + # asked for (measured: 2624-3720 cleanup passes per build, 8% of calls served). Each + # leg states the size it needs instead: one configuration for most, two for Fedora 44 + # (Debug for the suite, Release for the package), the server alone for Servatrice. + # Raising these further means caching fewer legs, since GitHub evicts a whole entry + # when the total is exceeded. + # + # Every key listed in LIVE_CCACHE_KEYS in prune-caches below must match this matrix. + CCACHE_SIZE: ${{ matrix.ccache_size || '700M' }} 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. Every leg a pull request + # runs keeps one; the nightly-only legs do not, so that the quota is spent on the + # builds that review a change. A tag build runs both sets, and the nightly legs are + # cold there - accepted, because a tag is rare and only the pull-request legs gate. + 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 - name: "Restore compiler cache (ccache)" id: restore_ccache + if: env.PERSIST_CCACHE == 'true' uses: actions/cache/restore@v6 with: key: ccache-${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }} @@ -228,8 +265,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 }}' @@ -269,15 +309,23 @@ jobs: 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 +335,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 +345,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 @@ -345,7 +401,7 @@ jobs: # 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,7 +412,7 @@ jobs: fi - name: "Save updated compiler cache (ccache)" - if: github.ref == 'refs/heads/master' + if: github.ref == 'refs/heads/master' && env.PERSIST_CCACHE == 'true' uses: actions/cache/save@v6 with: key: ${{ steps.restore_ccache.outputs.cache-primary-key }} @@ -398,33 +454,80 @@ 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: 43 # 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 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 one instrument that only reports. + 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: 600M + CMAKE_GENERATOR: 'Ninja' + NAME: ${{ matrix.distro }}${{ matrix.version }} + IMAGE_SUFFIX: ${{ matrix.variant }} + 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 cmake_generator: Ninja @@ -439,11 +542,10 @@ 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 cmake_generator: Ninja @@ -457,11 +559,10 @@ 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 cmake_generator: Ninja @@ -472,7 +573,6 @@ jobs: use_ccache: 1 vcpkg_deployment_target: 14 vcpkg_scope: 'vcpkg/macos14-arm64-xcode26.6/' - xcode: "26.6" - os: Windows target: 10 @@ -497,13 +597,22 @@ 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: 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 + # macOS configurations compile about twice the TUs of a Linux one, so each of these + # three legs gets a bound above what it has been measured to write. The four macOS + # legs used to be bounded at 600M from the same quota as the Linux ones; the three + # pull-request legs now get 700M each while the nightly Intel leg keeps none, and + # that is what fits (see CCACHE_SIZE in build-linux for the full arithmetic). + CCACHE_SIZE: 700M + PERSIST_CCACHE: 'true' - steps: + # Shared with build-vcpkg-nightly so the two leg sets run the same steps. + steps: &vcpkg-steps - name: "Checkout" uses: actions/checkout@v7 with: @@ -520,11 +629,11 @@ jobs: run: brew install ccache - name: "[macOS] Restore compiler cache (ccache)" - if: matrix.os == 'macOS' && matrix.use_ccache == 1 + if: matrix.os == 'macOS' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' id: restore_ccache uses: actions/cache/restore@v6 with: - key: ccache-${{ matrix.runner }}_${{ matrix.override_target }}-Xcode${{ matrix.xcode }} + key: ccache-${{ matrix.os }}${{ matrix.override_target }}-${{ matrix.runner }} path: ${{ env.CCACHE_DIR }} - name: "[macOS] Install aqtinstall" @@ -639,7 +748,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 }} @@ -851,7 +959,7 @@ jobs: # 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 + if: matrix.os == 'macOS' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit continue-on-error: true env: CACHE_PRIMARY_KEY: ${{ steps.restore_ccache.outputs.cache-primary-key }} @@ -862,7 +970,7 @@ jobs: fi - name: "[macOS] Cache updated compiler cache (ccache)" - if: matrix.os == 'macOS' && matrix.use_ccache == 1 && github.ref == 'refs/heads/master' + if: matrix.os == 'macOS' && matrix.use_ccache == 1 && env.PERSIST_CCACHE == 'true' && github.ref == 'refs/heads/master' uses: actions/cache/save@v6 with: key: ${{ steps.restore_ccache.outputs.cache-primary-key }} @@ -978,6 +1086,44 @@ 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 + + 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 + + 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 + env: + CCACHE_DIR: ${{ github.workspace }}/.cache/ + # Like build-linux-nightly: this leg keeps no compiler cache, so the quota stays with + # the pull-request legs (see PERSIST_CCACHE there). + PERSIST_CCACHE: 'false' + CCACHE_SIZE: 600M + steps: *vcpkg-steps + prune-caches: name: Prune caches # Only master writes caches at all, so only master may clean them up too @@ -986,38 +1132,64 @@ 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. - 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 keep none, so their leftover entries are reclaimed here + # instead of waiting out the age rule. + LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Servatrice_Debian12 ccache-Debian13 ccache-Fedora44 ccache-Ubuntu26.04 ccache-Ubuntu26.04sanitize ccache-macOS14-xcode-27 ccache-macOS15-xcode-27 ccache-macOS-xcode-27' 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} " + 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), 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-*) ;; *) continue ;; esac reason="" if [[ $ref != refs/heads/master ]]; then reason="scoped to $ref" + elif [[ $key == ccache-* ]]; then + # An exact token compare: ccache-Ubuntu26.04 is a prefix of + # ccache-Ubuntu26.04sanitize, and both keys 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/}" diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 937386940..1537d6d4d 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -84,12 +84,18 @@ jobs: 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 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 @@ -101,7 +107,7 @@ jobs: uses: docker/build-push-action@v7 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 }} diff --git a/.github/workflows/vcpkg-cache-import.yml b/.github/workflows/vcpkg-cache-import.yml index 3cc7b2203..0a377a8fd 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 @@ -38,7 +40,7 @@ jobs: # the shared scope even if they somehow target the default branch. if: >- github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' && + (github.event.workflow_run.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]') && diff --git a/.gitmodules b/.gitmodules index 732fec7ca..e69c8e686 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,3 +4,6 @@ [submodule "doxygen-awesome-css"] path = doc/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 677ef0c12..9044bc005 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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,6 +31,14 @@ option(ENABLE_COVERAGE "Build instrumented for GCC line coverage" OFF) # Use vcpkg regardless of OS option(USE_VCPKG "Use vcpkg regardless of OS" OFF) +# 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." + ) + set(WITH_GUI_TEST OFF) +endif() + # Default to "Release" build type # User-provided value for CMAKE_BUILD_TYPE must be checked before the PROJECT() call if(DEFINED CMAKE_BUILD_TYPE) @@ -55,6 +65,10 @@ 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}") endif() @@ -463,6 +477,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) 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/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 215a64ada..93195770d 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 @@ -150,11 +152,13 @@ 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_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 @@ -250,6 +254,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 @@ -450,6 +456,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 ) @@ -493,7 +501,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 @@ -519,6 +535,7 @@ qt_add_executable( WIN32 MACOSX_BUNDLE ${cockatrice_SOURCES} + ${cockatrice_RESOURCES} ${cockatrice_RESOURCES_RCC} ${cockatrice_MOC_SRCS} MANUAL_FINALIZATION @@ -565,6 +582,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..229cdd125 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,6 @@ resources/icons/swap.svg resources/icons/sync.svg resources/icons/tab_changed.svg - resources/icons/update.png 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/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/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/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/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/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/filters/filter_builder.cpp b/cockatrice/src/filters/filter_builder.cpp index 22679abb1..9c2a91a5b 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,7 @@ 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")), 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..84e062cd7 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" @@ -154,7 +155,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); 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/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..128899a13 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -1,6 +1,7 @@ #include "game_scene.h" #include "../client/settings/cache_settings.h" +#include "../interface/theme_manager.h" #include "animated_item.h" #include "board/abstract_card_item.h" #include "board/arrow_item.h" @@ -35,6 +36,7 @@ #include #include #include +#include #include #include #include @@ -64,6 +66,14 @@ 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(); + } + }); rearrange(); } @@ -432,6 +442,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; } diff --git a/cockatrice/src/game_graphics/game_view.cpp b/cockatrice/src/game_graphics/game_view.cpp index cb59b3d7d..7a5aa073a 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); @@ -75,15 +93,14 @@ GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, par 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 +117,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) @@ -219,9 +257,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 +268,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..99a73a0a8 100644 --- a/cockatrice/src/game_graphics/game_view.h +++ b/cockatrice/src/game_graphics/game_view.h @@ -50,6 +50,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..d526766af 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 @@ -120,6 +126,7 @@ MessageLogWidget::getFromStr(CardZoneLogic *zone, QString cardName, int position void MessageLogWidget::containerProcessingDone() { currentContext = MessageContext_None; + contextJudgeName.clear(); messageSuffix = messagePrefix = QString(); } @@ -605,6 +612,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 +862,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 +931,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/player_list_widget.cpp b/cockatrice/src/game_graphics/player/player_list_widget.cpp index 7b358010f..6895e7db8 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")); + notReadyIcon = loadThemedIcon(QStringLiteral("icons/not_ready_start")); + 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/zones/view_zone_widget.cpp b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp index b1ca42ee1..3c6cf9d02 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,8 @@ 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")), QLineEdit::LeadingPosition); + auto help = searchEdit.addAction(loadThemedIcon(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); connect(help, &QAction::triggered, this, [this] { createSearchSyntaxHelpWindow(&searchEdit); }); 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_grid_widget.cpp b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp index 463c4038d..392f01d0d 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp @@ -76,6 +76,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 +113,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 +159,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); 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..037293af9 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,19 @@ 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).pixmap(QSize(height, height), qApp->devicePixelRatio()); pmCache.insert(key, pixmap); return pixmap; } @@ -520,30 +506,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 +520,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..54143d7d3 100644 --- a/cockatrice/src/interface/theme_config.cpp +++ b/cockatrice/src/interface/theme_config.cpp @@ -304,4 +304,150 @@ 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())); +} diff --git a/cockatrice/src/interface/theme_config.h b/cockatrice/src/interface/theme_config.h index 8b91613f5..84601a2bf 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 @@ -23,6 +24,23 @@ enum Role 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 + struct ThemeConfig { QString colorScheme; @@ -60,4 +78,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..161c436ca --- /dev/null +++ b/cockatrice/src/interface/theme_icon.cpp @@ -0,0 +1,300 @@ +#include "theme_icon.h" + +#include "libcockatrice/utility/svg_recolor.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 Palette color that replaces `currentColor`, following the icon mode. + */ +QByteArray sentinelColor(QIcon::Mode mode, QPalette::ColorRole role) +{ + const QPalette::ColorGroup group = mode == QIcon::Disabled ? QPalette::Disabled : QPalette::Active; + const QPalette::ColorRole effective = + mode == QIcon::Selected && role == QPalette::WindowText ? QPalette::HighlightedText : role; + return qApp->palette().color(group, effective).name(QColor::HexRgb).toLatin1(); +} + +/** + * @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. 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. + */ +class ThemeSvgIconEngine final : public QObject, public QIconEngine +{ +public: + ThemeSvgIconEngine(QString _prefix, QPalette::ColorRole _role) : prefix(std::move(_prefix)), role(_role) + { + 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, role); + } + + 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(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(); + + // Same cache for both 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 two key spaces cannot collide. + const QByteArray variant = + resolvedHasSentinel ? sentinelColor(mode, role) : 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 (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; + QPalette::ColorRole role; + 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 + +QIcon loadThemedIcon(QStringView prefix, QPalette::ColorRole role) +{ + return QIcon(new ThemeSvgIconEngine(prefix.toString(), role)); +} + +QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, QPalette::ColorRole role, const QSize &size) +{ + if (!resolvedPath.endsWith(QStringLiteral(".svg"), Qt::CaseInsensitive)) { + return {}; + } + + const QByteArray svg = readSvg(resolvedPath); + if (!hasSentinel(svg)) { + return {}; + } + + const QByteArray color = qApp->palette().color(role).name(QColor::HexRgb).toLatin1(); + return renderSvg(resolveSvgColorSentinel(svg, color), 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..e26015ea1 --- /dev/null +++ b/cockatrice/src/interface/theme_icon.h @@ -0,0 +1,50 @@ +/** + * @file theme_icon.h + * @ingroup Interface + */ + +#ifndef THEME_ICON_H +#define THEME_ICON_H + +#include +#include +#include +#include +#include +#include + +/** + * @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 role Palette role whose color replaces the sentinel. Selected glyphs + * use HighlightedText unless a role other than WindowText is given. + * @return An icon that follows the application palette. + */ +QIcon loadThemedIcon(QStringView prefix, QPalette::ColorRole role = QPalette::WindowText); + +/** + * @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 role Palette role whose color replaces the sentinel. + * @param size Pixel size to render at; an empty size uses the SVG's intrinsic size. + * @return The recolored pixmap, or a null pixmap when the asset is not a + * sentinel-carrying SVG. + */ +QPixmap loadSentinelSvgPixmap(const QString &resolvedPath, + QPalette::ColorRole role = QPalette::WindowText, + const QSize &size = {}); + +#endif // THEME_ICON_H diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index 893cdc795..f120af8e4 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -127,8 +128,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 +443,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 +531,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, @@ -591,6 +652,56 @@ QColor ThemeManager::appColor(AppColor::Role role) const 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 +753,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..ffe1043b5 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,31 @@ 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. 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 +177,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_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/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..f25e6c0ff 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,8 @@ 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")), QLineEdit::LeadingPosition); + auto help = searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); setFocusProxy(searchEdit); setFocusPolicy(Qt::ClickFocus); @@ -83,13 +83,13 @@ 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"))); 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"))); + 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 30fde1595..47d2a1aae 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" @@ -248,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"))); 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"))); 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"))); 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); 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..8122d1847 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,11 @@ 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"))); connect(aClearFilterOne, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterOne); aClearFilterAll = new QAction(QString(), this); - aClearFilterAll->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); + aClearFilterAll->setIcon(loadThemedIcon(QStringLiteral("icons/clearsearch"))); 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/dialogs/dlg_connect.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp index 1f567989e..daee5f1f9 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,12 @@ 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"))); btnDeleteServer->setToolTip(tr("Delete the currently selected saved server")); btnDeleteServer->setFixedWidth(30); @@ -45,7 +46,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 +86,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 +121,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 +296,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 +339,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 +355,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 +375,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..ada4237f7 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp @@ -116,14 +116,17 @@ void DlgCreateGame::sharedCtor() 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); + gameSetupOptionsLayout->addWidget(shareDecklistsOnLoadCheckBox, 1, 0, 1, 2); + gameSetupOptionsLayout->addWidget(shufflePlayersCheckBox, 2, 0, 1, 2); if (room && room->getUserInfo()->user_level() & ServerInfo_User::IsJudge) { - gameSetupOptionsLayout->addWidget(createGameAsJudgeCheckBox, 2, 0); + gameSetupOptionsLayout->addWidget(createGameAsJudgeCheckBox, 3, 0, 1, 2); } else { createGameAsJudgeCheckBox->setChecked(false); createGameAsJudgeCheckBox->setHidden(true); @@ -184,6 +187,7 @@ DlgCreateGame::DlgCreateGame(TabRoom *_room, const QMap &_gameType createGameAsSpectatorCheckBox->setChecked(SettingsCache::instance().game().getCreateGameAsSpectator()); startingLifeTotalEdit->setValue(SettingsCache::instance().game().getDefaultStartingLifeTotal()); shareDecklistsOnLoadCheckBox->setChecked(SettingsCache::instance().game().getShareDecklistsOnLoad()); + shufflePlayersCheckBox->setChecked(SettingsCache::instance().game().getShufflePlayers()); if (!rememberGameSettings->isChecked()) { actReset(); @@ -217,6 +221,7 @@ DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMapsetEnabled(false); startingLifeTotalEdit->setEnabled(false); shareDecklistsOnLoadCheckBox->setEnabled(false); + shufflePlayersCheckBox->setEnabled(false); descriptionEdit->setText(QString::fromStdString(gameInfo.description())); maxPlayersEdit->setValue(gameInfo.max_players()); @@ -227,6 +232,7 @@ DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMapsetChecked(gameInfo.spectators_can_chat()); spectatorsSeeEverythingCheckBox->setChecked(gameInfo.spectators_omniscient()); shareDecklistsOnLoadCheckBox->setChecked(gameInfo.share_decklists_on_load()); + shufflePlayersCheckBox->setChecked(gameInfo.shuffle_players()); QSet types; for (int i = 0; i < gameInfo.game_types_size(); ++i) { @@ -264,6 +270,7 @@ void DlgCreateGame::actReset() startingLifeTotalEdit->setValue(20); shareDecklistsOnLoadCheckBox->setChecked(false); + shufflePlayersCheckBox->setChecked(false); createGameAsJudgeCheckBox->setChecked(false); QMapIterator gameTypeCheckBoxIterator(gameTypeCheckBoxes); @@ -294,6 +301,7 @@ void DlgCreateGame::actOK() cmd.set_join_as_spectator(createGameAsSpectatorCheckBox->isChecked()); cmd.set_starting_life_total(startingLifeTotalEdit->value()); cmd.set_share_decklists_on_load(shareDecklistsOnLoadCheckBox->isChecked()); + cmd.set_shuffle_players(shufflePlayersCheckBox->isChecked()); auto _gameTypes = QString(); QMapIterator gameTypeCheckBoxIterator(gameTypeCheckBoxes); @@ -318,6 +326,7 @@ void DlgCreateGame::actOK() SettingsCache::instance().game().setCreateGameAsSpectator(createGameAsSpectatorCheckBox->isChecked()); SettingsCache::instance().game().setDefaultStartingLifeTotal(startingLifeTotalEdit->value()); SettingsCache::instance().game().setShareDecklistsOnLoad(shareDecklistsOnLoadCheckBox->isChecked()); + SettingsCache::instance().game().setShufflePlayers(shufflePlayersCheckBox->isChecked()); SettingsCache::instance().game().setGameTypes(_gameTypes); } PendingCommand *pend = room->prepareRoomCommand(cmd); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h index 71b09997c..58356c9b6 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h @@ -51,6 +51,7 @@ private: QCheckBox *spectatorsAllowedCheckBox, *spectatorsNeedPasswordCheckBox, *spectatorsCanTalkCheckBox, *spectatorsSeeEverythingCheckBox, *createGameAsJudgeCheckBox, *createGameAsSpectatorCheckBox; QCheckBox *shareDecklistsOnLoadCheckBox; + QCheckBox *shufflePlayersCheckBox; QDialogButtonBox *buttonBox; QPushButton *clearButton; QCheckBox *rememberGameSettings; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp index cbf61a394..b06481c4b 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"))); 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"))); connect(aRemoveToken, &QAction::triggered, this, &DlgEditTokens::actRemoveToken); auto *databaseToolBar = new QToolBar; 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_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..c6e640a9f 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,7 @@ 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")), LineEditUnfocusable::LeadingPosition); searchField->setClearButtonEnabled(true); setFocusProxy(searchField); @@ -221,6 +222,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..a7387a068 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; 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..09afee32d 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,7 @@ 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"))); btnDeleteServer->setToolTip(tr("Delete the currently selected saved server")); btnDeleteServer->setFixedWidth(30); @@ -42,7 +42,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..b1a80c86b 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,17 @@ 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")), 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 +204,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 +254,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/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_card_overlay_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp index 94acdbdce..a5177a6bc 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,55 @@ 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")).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 +475,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..2c0050afe 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector.cpp @@ -1,7 +1,7 @@ #include "game_selector.h" #include "../../../client/settings/cache_settings.h" -#include "../../pixel_map_generator.h" +#include "../../theme_icon.h" #include "../interface/widgets/dialogs/dlg_create_game.h" #include "../interface/widgets/dialogs/dlg_filter_games.h" #include "../interface/widgets/tabs/tab_room.h" @@ -130,10 +130,10 @@ 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); 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_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..943b4d5de 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -519,6 +519,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 +630,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(); } @@ -1492,7 +1512,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/deck_editor_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp index 31178ba4c..e90603858 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"))); 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"))); 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..09dd9e9b0 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" @@ -146,15 +146,15 @@ MessagesSettingsPage::MessagesSettingsPage() } aAdd = new QAction(this); - aAdd->setIcon(themePixmap(QStringLiteral("icons/increment"))); + aAdd->setIcon(loadThemedIcon(QStringLiteral("icons/increment"))); 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"))); connect(aRemove, &QAction::triggered, this, &MessagesSettingsPage::actRemove); auto *messageToolBar = new QToolBar; 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/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/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..8af9d5e6e 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,19 @@ 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"))); connect(aDeleteLocalDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteLocalDeck); aOpenDecksFolder = new QAction(this); @@ -208,24 +208,24 @@ 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"))); connect(aDeleteRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteRemoteDeck); aShareDecks = new QAction(this); - aShareDecks->setIcon(themePixmap(QStringLiteral("icons/share"))); + aShareDecks->setIcon(loadThemedIcon(QStringLiteral("icons/share"))); 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..ac9edd1ba 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); @@ -345,6 +356,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 +383,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 +435,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")); } @@ -625,6 +645,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 +923,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 +1040,7 @@ void TabGame::startGame(bool _resuming) aConcede->setText(tr("&Concede")); aConcede->setEnabled(true); } + updateTurnOrderAction(); } void TabGame::stopGame() @@ -966,6 +1063,7 @@ void TabGame::stopGame() aConcede->setText(tr("&Concede")); aConcede->setEnabled(false); } + updateTurnOrderAction(); } void TabGame::closeGame() @@ -1090,6 +1188,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 +1219,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 +1230,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); @@ -1163,6 +1270,7 @@ void TabGame::createReplayMenuItems() aNextPhaseAction = nullptr; aNextTurn = nullptr; aReverseTurn = nullptr; + aTurnOrder = nullptr; aRemoveLocalArrows = nullptr; aRotateViewCW = nullptr; aRotateViewCCW = nullptr; @@ -1192,6 +1300,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 +1373,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 +1387,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 +1414,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 +1444,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..142c02999 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,12 +90,13 @@ 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; @@ -129,6 +131,7 @@ private: PlayerLogic *setActivePlayer(int id); void setActivePhase(int phase); + bool canSetTurnOrder() const; void createMenuItems(); void createReplayMenuItems(); void createViewMenuItems(); @@ -162,6 +165,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_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..bd2a69017 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,17 @@ 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"))); connect(aDeleteLocalReplay, &QAction::triggered, this, &TabReplays::actDeleteLocalReplay); aOpenReplaysFolder = new QAction(this); @@ -207,24 +207,24 @@ 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"))); connect(aDeleteRemoteReplay, &QAction::triggered, this, &TabReplays::actDeleteRemoteReplay); aGetReplayCode = new QAction(this); - aGetReplayCode->setIcon(themePixmap(QStringLiteral("icons/share"))); + aGetReplayCode->setIcon(loadThemedIcon(QStringLiteral("icons/share"))); 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..7986286b3 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_report.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_report.cpp @@ -1,5 +1,6 @@ #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" @@ -12,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -137,7 +139,8 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T chatGroup = new QGroupBox; chatLogEdit = new QTextEdit; chatLogEdit->setReadOnly(true); - chatLogEdit->setFont(QtUtils::monospaceFont()); + applyChatLogFont(); + connect(themeManager, &ThemeManager::fontsChanged, this, &TabReport::applyChatLogFont); auto *chatLayout = new QVBoxLayout(chatGroup); chatLayout->setContentsMargins(4, 4, 4, 4); chatLayout->addWidget(chatLogEdit); @@ -269,6 +272,11 @@ TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : T refreshList(); } +void TabReport::applyChatLogFont() +{ + chatLogEdit->setFont(report_utils::reportLogFont()); +} + void TabReport::retranslateUi() { userContextMenu->retranslateUi(); diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.h b/cockatrice/src/interface/widgets/tabs/tab_report.h index 32ebe1a3f..43e778f4b 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_report.h +++ b/cockatrice/src/interface/widgets/tabs/tab_report.h @@ -62,6 +62,7 @@ private slots: void statsResponse(const Response &response); void reopenReport(); void reopenResponse(const Response &response); + void applyChatLogFont(); private: int selectedReportId() const; diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.cpp b/cockatrice/src/interface/widgets/tabs/tab_room.cpp index f63b77445..329732c64 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" @@ -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); diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 3316232a4..45c320852 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -7,6 +7,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" @@ -54,6 +55,7 @@ #include #include #include +#include #include #include #include @@ -157,6 +159,15 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget * tabReplays(nullptr), tabAdmin(nullptr), tabCardArtRules(nullptr), tabLog(nullptr), tabReport(nullptr), tabModeration(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 +473,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); }); @@ -1347,7 +1361,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); 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/report_utils.cpp b/cockatrice/src/interface/widgets/utility/report_utils.cpp index d7fc083c6..df2400b14 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) diff --git a/cockatrice/src/interface/widgets/utility/report_utils.h b/cockatrice/src/interface/widgets/utility/report_utils.h index 679989486..32cc19eac 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,6 +12,12 @@ 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); 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/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..ecb949b67 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" @@ -45,13 +45,13 @@ 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) { return loadThemedIcon(prefix).pixmap({20, 20}); }; sortByLabel = new QLabel(this); - sortByLabel->setPixmap(scalePixmap("theme:icons/sort_arrow_down")); + sortByLabel->setPixmap(scalePixmap("icons/sort_arrow_down")); filterByLabel = new QLabel(this); - filterByLabel->setPixmap(scalePixmap("theme:icons/filter")); + filterByLabel->setPixmap(scalePixmap("icons/filter")); sortColumnCombo = new QComboBox(this); sortColumnCombo->setSizeAdjustPolicy(QComboBox::SizeAdjustPolicy::AdjustToContents); @@ -89,22 +89,22 @@ VisualDatabaseDisplayFilterToolbarWidget::VisualDatabaseDisplayFilterToolbarWidg }); quickFilterSaveLoadWidget = new SettingsButtonWidget(this); - quickFilterSaveLoadWidget->setButtonIcon(themePixmap(QStringLiteral("icons/floppy_disk"))); + quickFilterSaveLoadWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/floppy_disk"))); quickFilterNameWidget = new SettingsButtonWidget(this); - quickFilterNameWidget->setButtonIcon(themePixmap(QStringLiteral("icons/pen_to_square"))); + quickFilterNameWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/pen_to_square"))); quickFilterMainTypeWidget = new SettingsButtonWidget(this); - quickFilterMainTypeWidget->setButtonIcon(themePixmap(QStringLiteral("icons/circle_half_stroke"))); + quickFilterMainTypeWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/circle_half_stroke"))); quickFilterSubTypeWidget = new SettingsButtonWidget(this); - quickFilterSubTypeWidget->setButtonIcon(themePixmap(QStringLiteral("icons/dragon"))); + quickFilterSubTypeWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/dragon"))); quickFilterSetWidget = new SettingsButtonWidget(this); - quickFilterSetWidget->setButtonIcon(themePixmap(QStringLiteral("icons/scroll"))); + quickFilterSetWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scroll"))); quickFilterFormatLegalityWidget = new SettingsButtonWidget(this); - quickFilterFormatLegalityWidget->setButtonIcon(themePixmap(QStringLiteral("icons/scale_balanced"))); + quickFilterFormatLegalityWidget->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scales"))); retranslateUi(); } 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..af5c74969 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,8 @@ 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")), QLineEdit::LeadingPosition); + auto help = searchEdit->addAction(loadThemedIcon(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); connect(help, &QAction::triggered, this, [this] { createSearchSyntaxHelpWindow(searchEdit); }); setFocusProxy(searchEdit); @@ -154,7 +156,7 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, clearFilterWidget = new QToolButton(); clearFilterWidget->setFixedSize(32, 32); - clearFilterWidget->setIcon(themePixmap(QStringLiteral("icons/delete"))); + clearFilterWidget->setIcon(loadThemedIcon(QStringLiteral("icons/delete"))); connect(clearFilterWidget, &QToolButton::clicked, this, [this] { filterModel->blockSignals(true); filterModel->filterTree()->blockSignals(true); 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..f0c054c3a 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"))); break; case DisplayType::Overlap: displayTypeButton->setButtonText(tr("Toggle Layout: Overlap")); - displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scales"))); + displayTypeButton->setButtonIcon(loadThemedIcon(QStringLiteral("icons/scales"))); 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_search_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp index f2669f55d..10a3bf33a 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,9 @@ 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")), QLineEdit::LeadingPosition); - auto help = searchBar->addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); + auto help = searchBar->addAction(loadThemedIcon(QStringLiteral("icons/info")), 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..bbf68adb9 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"))); 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..b4dc0610f 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); @@ -483,6 +496,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 +539,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/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/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp index d40c1c063..8d5735db7 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 @@ -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..3965b80f0 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 @@ -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_deck_list/libcockatrice/deck_list/deck_list.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp index 2cd5ef914..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; } 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_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_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 fa75e356a..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", 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..dcd14d14c 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 @@ -434,6 +435,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) { @@ -543,6 +571,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..316b9727c 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 @@ -53,6 +53,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 +90,10 @@ public: { return playerId; } + void setPlayerId(int _playerId) + { + playerId = _playerId; + } bool isSpectator() const { return roleInfo.isSpectator; @@ -173,6 +178,8 @@ public: 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_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_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..eb399ffb9 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto @@ -180,6 +180,11 @@ 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; } 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/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_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..041e289fe --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/svg_recolor.cpp @@ -0,0 +1,44 @@ +#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; +} diff --git a/libcockatrice_utility/libcockatrice/utility/svg_recolor.h b/libcockatrice_utility/libcockatrice/utility/svg_recolor.h new file mode 100644 index 000000000..56d609b87 --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/svg_recolor.h @@ -0,0 +1,31 @@ +/** + * @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); + +#endif // SVG_RECOLOR_H diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index 587463572..4ca2acd07 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 @@ -30,6 +31,7 @@ set(oracle_SOURCES ../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_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..c83c962e3 100644 --- a/oracle/src/oracleimporter.cpp +++ b/oracle/src/oracleimporter.cpp @@ -100,6 +100,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]( @@ -764,7 +768,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 +798,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 +808,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 +838,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 +853,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 +883,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..5ebe9cf36 100644 --- a/oracle/src/oracleimporter.h +++ b/oracle/src/oracleimporter.h @@ -198,6 +198,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. @@ -278,7 +283,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..dc8c7ecb3 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" @@ -141,6 +142,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 +220,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/pages.cpp b/oracle/src/pages.cpp index 4c664a38c..f5c38be1c 100644 --- a/oracle/src/pages.cpp +++ b/oracle/src/pages.cpp @@ -1,6 +1,7 @@ #include "pages.h" #include "client/settings/cache_settings.h" +#include "database_writer.h" #include "main.h" #include "oracleimporter.h" #include "oraclewizard.h" @@ -43,6 +44,7 @@ #include #include #include +#include #include #include #include @@ -169,7 +171,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 +662,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; } @@ -717,7 +732,7 @@ SaveSetsPage::SaveSetsPage(QWidget *parent) : OracleWizardPage(parent) bool SaveSetsPage::isComplete() const { - return !importActive; + return !importActive && importReady; } void SaveSetsPage::cleanupPage() @@ -730,8 +745,11 @@ void SaveSetsPage::cleanupPage() void SaveSetsPage::initializePage() { + importReady = false; retranslateUi(); if (wizard()->downloadedPlainXml) { + importReady = true; + emit completeChanged(); messageLog->hide(); progressBar->hide(); if (wizard()->backgroundMode) { @@ -757,6 +775,9 @@ void SaveSetsPage::initializePage() 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 +809,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 +881,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 +911,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..e29a8e84a 100644 --- a/oracle/src/pages.h +++ b/oracle/src/pages.h @@ -128,6 +128,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/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6acd225c2..84b9f02bf 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -177,7 +177,29 @@ cockatrice_add_test(NAME test_age_formatting SOURCES test_age_formatting.cpp LIB 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 +230,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 @@ -412,6 +446,15 @@ cockatrice_add_test( INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/cockatrice/src ) +cockatrice_add_test( + NAME + font_config_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/theme_config.cpp + font_config_test.cpp + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src +) cockatrice_add_test( NAME deck_hash_performance_test @@ -523,6 +566,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) 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/carddatabase_test.cpp b/tests/carddatabase/carddatabase_test.cpp index 22cbe9074..b54205f30 100644 --- a/tests/carddatabase/carddatabase_test.cpp +++ b/tests/carddatabase/carddatabase_test.cpp @@ -36,6 +36,12 @@ #include #include +#ifdef Q_OS_UNIX +#include +#include +#include +#endif + namespace { @@ -204,6 +210,64 @@ 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, isolated in the death-test child so the + // process-wide file limit never affects other cases or the parent runner. + ASSERT_EXIT( + { + std::signal(SIGXFSZ, SIG_IGN); + struct rlimit limit; + limit.rlim_cur = 128; + limit.rlim_max = 128; + if (setrlimit(RLIMIT_FSIZE, &limit) != 0) { + _exit(2); + } + CockatriceXml4Parser writer(&controller); + _exit(writer.saveToFile({}, sets, cards, fileName) ? 1 : 0); + }, + ::testing::ExitedWithCode(0), ""); + + 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/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/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/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/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/oracle/CMakeLists.txt b/tests/oracle/CMakeLists.txt index 6958673f7..1cc1ad952 100644 --- a/tests/oracle/CMakeLists.txt +++ b/tests/oracle/CMakeLists.txt @@ -9,6 +9,7 @@ cockatrice_add_test( ../../oracle/src/oracleimporter.cpp ../../oracle/src/parsehelpers.cpp ../../oracle/src/raw_json_scanner.cpp + ../../oracle/src/database_writer.cpp oracle_importer_test.cpp LIBS libcockatrice_card diff --git a/tests/oracle/oracle_importer_test.cpp b/tests/oracle/oracle_importer_test.cpp index 83d77d465..259bae087 100644 --- a/tests/oracle/oracle_importer_test.cpp +++ b/tests/oracle/oracle_importer_test.cpp @@ -1,3 +1,4 @@ +#include "../../oracle/src/database_writer.h" #include "../../oracle/src/oracleimporter.h" #include "../../oracle/src/raw_json_scanner.h" #include "libcockatrice/card/card_info.h" @@ -6,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -20,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -30,6 +33,12 @@ #include #include +#ifdef Q_OS_UNIX +#include +#include +#include +#endif + class OracleImporterTest : public ::testing::Test { protected: @@ -1140,6 +1149,212 @@ 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, isolated in the death-test child so the + // process-wide file limit never affects other cases or the parent runner. + ASSERT_EXIT( + { + std::signal(SIGXFSZ, SIG_IGN); + struct rlimit limit; + limit.rlim_cur = 128; + limit.rlim_max = 128; + if (setrlimit(RLIMIT_FSIZE, &limit) != 0) { + _exit(2); + } + _exit(OracleDatabase::saveXml(path, data) ? 1 : 0); + }, + ::testing::ExitedWithCode(0), ""); + 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/spoiler_file_test.cpp b/tests/spoiler_file_test.cpp new file mode 100644 index 000000000..9944f95bd --- /dev/null +++ b/tests/spoiler_file_test.cpp @@ -0,0 +1,149 @@ +#include "client/network/update/card_spoiler/spoiler_file.h" + +#include +#include +#include +#include +#include + +#ifdef Q_OS_UNIX +#include +#include +#include +#endif + +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) +{ + // Limit only the child process: a real short write exercises QSaveFile's + // buffered flush failure without depending on disk capacity or file ownership. + const QByteArray changed = ""; + EXPECT_EXIT( + { + struct rlimit limit; + if (getrlimit(RLIMIT_FSIZE, &limit) != 0) { + _exit(2); + } + limit.rlim_cur = 128; + if (setrlimit(RLIMIT_FSIZE, &limit) != 0) { + _exit(3); + } + std::signal(SIGXFSZ, SIG_IGN); + const auto result = save(changed); + _exit(result == SpoilerFile::SaveResult::Failed && !error.isEmpty() ? 0 : 1); + }, + ::testing::ExitedWithCode(0), ""); + EXPECT_EQ(read(), original); + EXPECT_EQ(QDir(directory.path()).entryList(QDir::Files), QStringList{"spoiler.xml"}); +} +#endif +} // namespace diff --git a/tests/svg_recolor_test.cpp b/tests/svg_recolor_test.cpp new file mode 100644 index 000000000..abe1e8d1a --- /dev/null +++ b/tests/svg_recolor_test.cpp @@ -0,0 +1,66 @@ +#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); +} + +} // namespace diff --git a/tests/theme_migration/CMakeLists.txt b/tests/theme_migration/CMakeLists.txt index 93493ee5f..818c1bb16 100644 --- a/tests/theme_migration/CMakeLists.txt +++ b/tests/theme_migration/CMakeLists.txt @@ -5,12 +5,14 @@ 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/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 LIBS libcockatrice_settings libcockatrice_network @@ -18,4 +20,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/theme_icon_render_test.cpp b/tests/theme_migration/theme_icon_render_test.cpp new file mode 100644 index 000000000..f12db5b16 --- /dev/null +++ b/tests/theme_migration/theme_icon_render_test.cpp @@ -0,0 +1,189 @@ +#include "interface/theme_icon.h" +#include "interface/theme_manager.h" + +#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")); +} + +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; +} + +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 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, QColor(QStringLiteral("#123456"))), 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 QPixmap flipped = icon.pixmap(QSize(24, 24), 1.0); + EXPECT_GT(countOpaquePixels(flipped, QColor(QStringLiteral("#abcdef"))), 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::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, Qt::white), 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, QColor(QStringLiteral("#123456"))), 0); +} 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(); +}