diff --git a/.ci/Fedora43/Dockerfile b/.ci/Fedora45/Dockerfile similarity index 96% rename from .ci/Fedora43/Dockerfile rename to .ci/Fedora45/Dockerfile index 8aa345651..a7c49b249 100644 --- a/.ci/Fedora43/Dockerfile +++ b/.ci/Fedora45/Dockerfile @@ -1,4 +1,4 @@ -FROM fedora:43 +FROM fedora:45 RUN dnf install -y \ ca-certificates \ diff --git a/.ci/compile.sh b/.ci/compile.sh index 569ae9f6b..7414b2dc3 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -203,6 +203,11 @@ if [[ ${WITH_CRASH_REPORTING:-} == "ON" ]]; then # developer builds never reach Sentry. flags+=("-DWITH_CRASH_REPORTING=ON") fi +if [[ ${COCKATRICE_DEBUG_INFO_LEVEL:-} ]]; then + # CI lowers the DWARF level so one build configuration fits its compiler-cache bound; + # see the option in CMakeLists.txt. Empty keeps the built-in flags. + flags+=("-DCOCKATRICE_DEBUG_INFO_LEVEL=$COCKATRICE_DEBUG_INFO_LEVEL") +fi if [[ ${BUILDTYPE:-Release} == "Release" && -n ${COCKATRICE_SENTRY_DSN:-} ]]; then # Release-only: Debug builds stay DSN-free so crashes from developer builds # never reach Sentry. Empty (local builds, forks without the secret, and non-tag diff --git a/.ci/docker.sh b/.ci/docker.sh index 64ea6baa7..c78ba3c31 100644 --- a/.ci/docker.sh +++ b/.ci/docker.sh @@ -181,6 +181,11 @@ function RUN () if [[ ${WITH_CRASH_REPORTING:-} ]]; then args+=(--env "WITH_CRASH_REPORTING=${WITH_CRASH_REPORTING}") fi + # Same reason: the DWARF level reaches compile.sh only if it is handed to the container, + # and without it CI would cache objects twice the size its bounds were measured for. + if [[ ${COCKATRICE_DEBUG_INFO_LEVEL:-} ]]; then + args+=(--env "COCKATRICE_DEBUG_INFO_LEVEL=${COCKATRICE_DEBUG_INFO_LEVEL}") + fi # shellcheck disable=2086 docker run "${args[@]}" $RUN_ARGS "$IMAGE_NAME" bash "$BUILD_SCRIPT" $RUN_OPTS "$@" return $? diff --git a/.ci/release_template.md b/.ci/release_template.md index 23b475150..8eb4c7ebd 100644 --- a/.ci/release_template.md +++ b/.ci/release_template.md @@ -21,8 +21,8 @@ Available pre-compiled binaries for installation: • Ubuntu 24.04 LTS Noble Numbat • Debian 13 Trixie • Debian 12 Bookworm + • Fedora 45 • Fedora 44 - • Fedora 43 General Linux support is available via a flatpak package hosted at Flathub (x86 & ARM)! Thanks to courtesy of @FFY00, the app is also available in Arch Linux's official extra repository. diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 4ac28d3a5..b859556aa 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -149,7 +149,8 @@ jobs: allow-failure: yes package: skip # We are packaged in Arch already - debug_test: yes # see the note on Fedora 44 below + debug_test: yes # see the note on Fedora 45 below + ccache_size: 850M # Debug plus the suite: 660 MB of objects at -g1 - distro: Servatrice_Debian version: 12 @@ -157,33 +158,49 @@ jobs: package: DEB server_only: yes test: skip - ccache_size: 400M # server only, no client to compile + # 4.7 min and the server alone: nothing here for a compiler cache to save. + persist_ccache: 'false' - distro: Debian version: 13 package: DEB - ccache_size: 850M # the Release build plus the suite it runs + ccache_size: 800M # Release plus the suite: 614 MB of objects at -g1 + + # The single Fedora leg used to build Debug for the suite and Release for the + # package in one job (measured on Fedora 44: 30.8 min, the only leg slower than + # Windows), so the package compile waited behind the Debug one. Two legs run in + # parallel and each keeps a cache that holds exactly one configuration. + # + # Arch and Fedora 45 are the two newest toolchains, and the -Wno-error= exceptions + # in CMakeLists.txt exist for exactly their warnings. This leg compiles Debug + # because only Debug passes -Werror, so it is where a new warning still fails. + - distro: Fedora + version: 45 + variant: debug # separate image and ccache from the release leg below + + package: skip + debug_test: yes + ccache_size: 850M # Debug plus the suite: 660 MB of objects at -g1 - distro: Fedora - version: 44 + version: 45 + variant: release # separate image and ccache from the debug leg above package: RPM - # 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 + test: skip # The debug leg above runs this distro's suite + ccache_size: 800M # Release plus the package: 614 MB of objects at -g1 - distro: Ubuntu version: 26.04 package: DEB test: skip # The sanitizer leg below runs this distro's suite + # 18.1 min, and the same distro as the sanitize leg below, which runs this suite. + persist_ccache: 'false' # The one leg that runs the suite under AddressSanitizer and - # UndefinedBehaviorSanitizer. Debian 13 and Fedora 44 keep covering the + # UndefinedBehaviorSanitizer. Debian 13 and Fedora 45 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 @@ -196,7 +213,7 @@ jobs: package: skip sanitize: yes - ccache_size: 800M # instrumented objects are larger + ccache_size: 1200M # instrumented objects are larger than the 614 MB measured; estimated name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }} needs: configure @@ -211,25 +228,47 @@ jobs: CCACHE_EVICTION_AGE: 7d # 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. + # not against what one leg would like: the quota also holds about 0.9 GB of buildkit + # layers (mode=min in docker-release.yml), 0.8 GB of vcpkg, 0.7 GB of Qt and the CodeQL + # trap caches (about 1 GB each, which prune-caches below does not manage). # - # 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. + # Measured 2026-11-04: one configuration costs 1.2 GB with full DWARF and 660 MB with + # -g1 (COCKATRICE_DEBUG_INFO_LEVEL below), and the size of the cache decides the hit + # rate far more sharply than its fill suggests. A 6G cache served 1059 of 1059 calls; + # the 700M bound on the same leg served 14%, because ccache fills to the limit and then + # evicts the entries the next build is about to ask for. A cache that cannot hold a + # whole configuration therefore buys almost nothing, and the quota goes to fewer legs + # at a size that holds one: + # + # Arch Debug + suite 660 MB working set 850M + # Debian 13 Release + suite 614 MB 800M + # Fedora 45 debug Debug + suite 660 MB 850M + # Fedora 45 release Release + package 614 MB 800M + # Ubuntu 26.04 sanitize ~1.0 GB 1200M + # + # That is about 5.3 GB of bounds, and with the fixed entries above it puts the quota at + # roughly 9.8 GB of 10 - so only legs worth their share get one. A leg keeps no cache + # when it duplicates another leg's coverage (Ubuntu 26.04 behind the sanitize leg of + # the same distro; macOS 15 and macOS 27 Debug behind macOS 14; Servatrice covers the + # server alone and finishes in 4.7 min). Those four run cold: 4.7, 18.1, 16.5 and + # 15.8 min. The five that stay compile the client on the toolchains a change can break + # and are the ones a cache shortens by the most. Measured job totals on master + # 2026-11-04 also show the ceiling this leaves: Windows 10 is 29.5 min and compiles for + # 23.7 of it with no compiler cache at all, so Windows still sets the pull-request + # wall clock no matter how warm these legs are (see build-vcpkg). + # + # Re-add a leg only with a bound that holds its whole configuration: a smaller one is + # worse than none. # # Every key listed in LIVE_CCACHE_KEYS in prune-caches below must match this matrix. CCACHE_SIZE: ${{ matrix.ccache_size || '700M' }} + # Debug information dominates a cached object: with the built-in flags one + # configuration is 1.2 GB and no per-leg bound can hold it, so CI lowers the level to + # 1 (file and line, which is what a failing test or a sanitizer backtrace reports) and + # one configuration halves. Empty on tags and on the nightly legs, which ship binaries + # and keep full DWARF for the Sentry symbols; a tag restores a cache it cannot use and + # is slower, which is accepted because a tag is rare and only pull requests gate. + COCKATRICE_DEBUG_INFO_LEVEL: ${{ github.ref_type != 'tag' && '1' || '' }} CMAKE_GENERATOR: 'Ninja' NAME: ${{ matrix.distro }}${{ matrix.version }} IMAGE_SUFFIX: ${{ matrix.variant }} @@ -242,11 +281,13 @@ jobs: # 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' + # Whether this leg restores and saves its compiler cache. Not every pull-request leg + # keeps one: a cache only pays for itself when its bound holds a whole configuration + # (see the sizing comment above), and a leg that duplicates another leg's coverage + # spends quota without adding any. The nightly-only legs keep none either. Fedora 45 + # states it per leg: its two configurations have their own job and key, so neither + # bound is shared with a build whose objects it would evict. + PERSIST_CCACHE: ${{ matrix.persist_ccache || 'true' }} # Shared with build-linux-nightly so the two leg sets run the same steps. steps: &linux-steps @@ -466,7 +507,7 @@ jobs: test: skip # Running tests on all distros is superfluous - distro: Fedora - version: 43 # previous release, still covered by the nightly + version: 44 # previous release, still covered by the nightly package: RPM test: skip # Running tests on all distros is superfluous @@ -518,6 +559,12 @@ jobs: CMAKE_GENERATOR: 'Ninja' NAME: ${{ matrix.distro }}${{ matrix.version }} IMAGE_SUFFIX: ${{ matrix.variant }} + # The shared steps below gate on these, so they have to be derived here too. Left + # undefined they silently skipped the build the leg exists for: the coverage leg + # found build/ empty and its report collection failed on every nightly and tag run. + # Same derivation as the pull request job above. + DEBUG_TEST: ${{ matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes' }} + RUN_TESTS: ${{ matrix.test != 'skip' && !(matrix.sanitize == 'yes' || matrix.coverage == 'yes' || matrix.debug_test == 'yes') }} steps: *linux-steps build-vcpkg: @@ -534,6 +581,7 @@ jobs: make_package: 1 override_target: 14 package_suffix: "-macOS14" + ccache_size: 800M # one Release configuration plus the suite at -g1 qt_version: 6.11.* qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets soc: Apple @@ -552,6 +600,7 @@ jobs: make_package: 1 override_target: 15 package_suffix: "-macOS15" + persist_ccache: 'false' # 16.5 min: shorter than the Windows leg qt_version: 6.11.* qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets soc: Apple @@ -566,6 +615,7 @@ jobs: ccache_eviction_age: 7d cmake_generator: Ninja + persist_ccache: 'false' # 15.8 min: shorter than the Windows leg qt_version: 6.11.* qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets soc: Apple @@ -603,13 +653,17 @@ jobs: timeout-minutes: 100 env: CCACHE_DIR: ${{ github.workspace }}/.cache/ - # 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' + # One macOS configuration is about 600 MB of objects at -g1, so its bound needs to be + # 800M to hold one whole; a smaller cache serves a few percent and is worthless (see + # the sizing comment in build-linux). macOS 14 keeps the one macOS cache: it runs the + # suite against the shipping Release build and is the longest of the three at 24.2 min. + # macOS 15 (16.5) and macOS 27 Debug (15.8) duplicate its coverage and are shorter, so + # they run cold. + CCACHE_SIZE: ${{ matrix.ccache_size || '700M' }} + PERSIST_CCACHE: ${{ matrix.persist_ccache || 'true' }} + # MSVC has no -g level, so the Windows leg does not ask for one; it has no compiler + # cache to shrink anyway (see the sizing comment in build-linux). + COCKATRICE_DEBUG_INFO_LEVEL: ${{ matrix.os != 'Windows' && github.ref_type != 'tag' && '1' || '' }} # Shared with build-vcpkg-nightly so the two leg sets run the same steps. steps: &vcpkg-steps @@ -1159,9 +1213,10 @@ jobs: 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' + # The nightly legs and the pull-request legs that finish under the Windows leg + # keep none, so their leftover entries are reclaimed here instead of waiting out + # the age rule. + LIVE_CCACHE_KEYS: 'ccache-Arch ccache-Debian13 ccache-Fedora45debug ccache-Fedora45release ccache-Ubuntu26.04sanitize ccache-macOS14-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" @@ -1185,8 +1240,9 @@ jobs: 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 + # An exact token compare, because one key can be a prefix of another: + # ccache-Ubuntu26.04 is a prefix of ccache-Ubuntu26.04sanitize, and only + # the sanitize entry is live. if [[ $live_ccache != *" $key "* ]]; then reason="no pull-request leg keeps it" fi diff --git a/CMakeLists.txt b/CMakeLists.txt index 9044bc005..53508fcd1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -238,6 +238,27 @@ include(Sanitizers) include(Coverage) include(CrashReporting) +# Debug information is what makes a cached object large, and a build configuration needs more +# room than a per-leg cache bound can spare when it carries full DWARF: ccache then fills to +# its limit and evicts the entries the next build is about to ask for, so it serves a few +# percent of calls while holding most of a build. CI sets a lower level so one whole +# configuration fits; level 1 keeps file and line, which is what a failing test or a sanitizer +# backtrace reports. Empty (the default) leaves local, distro and release builds exactly as +# they were. This comes after CrashReporting, so the -g it adds for Sentry symbols cannot +# raise the level again. +set(COCKATRICE_DEBUG_INFO_LEVEL + "" + CACHE STRING "DWARF level for Debug and Release objects (empty keeps the built-in flags)" +) +if(NOT COCKATRICE_DEBUG_INFO_LEVEL STREQUAL "") + if(MSVC) + message(WARNING "COCKATRICE_DEBUG_INFO_LEVEL is ignored on MSVC") + else() + string(APPEND CMAKE_CXX_FLAGS_DEBUG " -g${COCKATRICE_DEBUG_INFO_LEVEL}") + string(APPEND CMAKE_CXX_FLAGS_RELEASE " -g${COCKATRICE_DEBUG_INFO_LEVEL}") + endif() +endif() + # GNU systems need to define the Mersenne exponent for the RNG to compile w/o warning if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") add_definitions("-DSFMT_MEXP=19937") diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index b76783167..f34961b0b 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -151,6 +151,7 @@ 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 @@ -252,6 +253,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 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/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/update/client/update_downloader.cpp b/cockatrice/src/client/network/update/client/update_downloader.cpp index 8868876fa..8b5ef3d6c 100644 --- a/cockatrice/src/client/network/update/client/update_downloader.cpp +++ b/cockatrice/src/client/network/update/client/update_downloader.cpp @@ -1,15 +1,16 @@ #include "update_downloader.h" -#include -#include #include -#include +#include #include #include #include +#include +#include #include #include #include +#include UpdateDownloader::UpdateDownloader(QObject *parent) : QObject(parent), response(nullptr) { @@ -23,48 +24,116 @@ void UpdateDownloader::beginDownload(QUrl downloadUrl) originalUrl = downloadUrl; } + if (auto *old = std::exchange(response, nullptr)) { + old->abort(); + old->deleteLater(); + } + + errorReported = false; + cancelRequested = false; + + // The transfer timeout comes from the manager (NetworkManagerFactory). response = netMan->get(QNetworkRequest(downloadUrl)); connect(response, &QNetworkReply::finished, this, &UpdateDownloader::fileFinished); + connect(response, &QNetworkReply::errorOccurred, this, &UpdateDownloader::downloadError); connect(response, &QNetworkReply::downloadProgress, this, &UpdateDownloader::downloadProgress); - connect(this, &UpdateDownloader::stopDownload, response, &QNetworkReply::abort); + connect(this, &UpdateDownloader::stopDownload, this, &UpdateDownloader::cancelDownload); } -void UpdateDownloader::downloadError(QNetworkReply::NetworkError) +void UpdateDownloader::downloadError(QNetworkReply::NetworkError networkError) { - if (response == nullptr) { + // A reply that was superseded by beginDownload() can still deliver a + // queued error; it must not be reported against the current reply. + if (response == nullptr || sender() != response || errorReported) { return; } - emit error(response->errorString().toUtf8()); + // A transfer timeout aborts the reply, so it arrives as OperationCanceledError. + // Only an abort the user asked for is suppressed; every other cancel is a + // stalled transfer and has to be reported. + if (networkError == QNetworkReply::OperationCanceledError) { + if (cancelRequested) { + return; + } + + errorReported = true; + emit error(tr("The download timed out. Check your connection and try again.")); + return; + } + + errorReported = true; + emit error(response->errorString()); +} + +void UpdateDownloader::cancelDownload() +{ + cancelRequested = true; + if (response != nullptr) { + response->abort(); + } } void UpdateDownloader::fileFinished() { - // If we finished but there's a redirect, follow it - QVariant redirect = response->attribute(QNetworkRequest::RedirectionTargetAttribute); - if (!redirect.isNull()) { - beginDownload(redirect.toUrl()); + // An aborted, superseded reply also emits finished(). Only the reply + // currently stored in response may be consumed. + if (response == nullptr || sender() != response) { return; } - // Handle any errors we had - if (response->error()) { - emit error(response->errorString()); + // errorOccurred normally reports this first; keep the check for errors that + // only surface when the reply finishes. + if (response->error() != QNetworkReply::NoError) { + downloadError(response->error()); + response->deleteLater(); + response = nullptr; return; } - // Work out the file name of the download - QString fileName = QDir::temp().path() + QDir::separator() + originalUrl.toString().section('/', -1); + // Give the update its own private directory. The name is unpredictable, so a + // local user cannot pre-create or symlink the path, and the original file + // name (including the installer suffix) is preserved inside it. + QTemporaryDir downloadDir(QDir::temp().filePath(QStringLiteral("cockatrice-update-XXXXXX"))); + if (!downloadDir.isValid()) { + emit error(tr("Could not create a temporary directory for the download.")); + response->deleteLater(); + response = nullptr; + return; + } + downloadDir.setAutoRemove(false); - // Save the build in a temporary directory - QFile file(fileName); + const QString remoteName = QFileInfo(originalUrl.path()).fileName(); + const QString fileName = downloadDir.filePath(remoteName.isEmpty() ? QStringLiteral("update") : remoteName); + + QSaveFile file(fileName); if (!file.open(QIODevice::WriteOnly)) { - emit error(tr("Could not open the file for reading.")); + downloadDir.remove(); + emit error(tr("Could not open the file for writing: %1").arg(file.errorString())); + response->deleteLater(); + response = nullptr; return; } - file.write(response->readAll()); - file.close(); + const QByteArray data = response->readAll(); + if (file.write(data) != data.size()) { + file.cancelWriting(); + downloadDir.remove(); + emit error(tr("Could not write the downloaded file to disk.")); + response->deleteLater(); + response = nullptr; + return; + } + + if (!file.commit()) { + downloadDir.remove(); + emit error(tr("Could not save the downloaded file: %1").arg(file.errorString())); + response->deleteLater(); + response = nullptr; + return; + } + + response->deleteLater(); + response = nullptr; // Emit the success signal with a URL to the download file emit downloadSuccessful(QUrl::fromLocalFile(fileName)); diff --git a/cockatrice/src/client/network/update/client/update_downloader.h b/cockatrice/src/client/network/update/client/update_downloader.h index 8aeefb22a..7704eca38 100644 --- a/cockatrice/src/client/network/update/client/update_downloader.h +++ b/cockatrice/src/client/network/update/client/update_downloader.h @@ -32,10 +32,13 @@ private: QUrl originalUrl; QNetworkAccessManager *netMan; QNetworkReply *response; + bool errorReported = false; + bool cancelRequested = false; private slots: void fileFinished(); void downloadProgress(qint64 bytesRead, qint64 totalBytes); void downloadError(QNetworkReply::NetworkError); + void cancelDownload(); }; #endif // COCKATRICE_UPDATEDOWNLOADER_H diff --git a/cockatrice/src/client/settings/cache_settings.cpp b/cockatrice/src/client/settings/cache_settings.cpp index 08eccf1e4..b40e26377 100644 --- a/cockatrice/src/client/settings/cache_settings.cpp +++ b/cockatrice/src/client/settings/cache_settings.cpp @@ -6,6 +6,7 @@ #include "shortcuts_settings.h" #include +#include #include #include #include @@ -47,7 +48,32 @@ Q_GLOBAL_STATIC(SettingsCache, settingsCache) SettingsCache &SettingsCache::instance() { - return *settingsCache; // returns a QT managed singleton reference + SettingsCache &cache = *settingsCache; // returns a QT managed singleton reference + // The release channels own QNetworkAccessManagers, which must be destroyed + // while the application object is alive. ~SettingsCache() runs during + // static teardown, after QApplication is gone, so hook the application's + // shutdown instead. Retried here because the first access may predate the + // application object. + cache.installShutdownHook(); + return cache; +} + +void SettingsCache::installShutdownHook() +{ + if (shutdownHookInstalled) { + return; + } + QCoreApplication *application = QCoreApplication::instance(); + if (application == nullptr) { + return; + } + connect(application, &QCoreApplication::aboutToQuit, this, [this] { + for (ReleaseChannel *releaseChannel : std::as_const(releaseChannels)) { + delete releaseChannel; + } + releaseChannels.clear(); + }); + shutdownHookInstalled = true; } QString SettingsCache::getDataPath() diff --git a/cockatrice/src/client/settings/cache_settings.h b/cockatrice/src/client/settings/cache_settings.h index 9aeb88a18..8ccf6f76b 100644 --- a/cockatrice/src/client/settings/cache_settings.h +++ b/cockatrice/src/client/settings/cache_settings.h @@ -90,8 +90,13 @@ private: QString themeName; QList releaseChannels; + bool shutdownHookInstalled = false; bool isPortableBuild; + // Connects the aboutToQuit handler that deletes the release channels while + // the application object is still alive; a no-op before it exists. + void installShutdownHook(); + [[nodiscard]] QString getSafeConfigPath(QString configEntry, QString defaultPath) const; [[nodiscard]] QString getSafeConfigFilePath(QString configEntry, QString defaultPath) const; void loadPaths(); diff --git a/cockatrice/src/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/game_graphics/log/message_log_widget.cpp b/cockatrice/src/game_graphics/log/message_log_widget.cpp index 8b351e022..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(); } @@ -855,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) @@ -905,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 5632f5af8..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); 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/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/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/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/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_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/dialogs/dlg_connect.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp index 853a00828..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" @@ -38,7 +38,7 @@ DlgConnect::DlgConnect(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); @@ -46,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); @@ -121,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); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp index 3845029f7..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" @@ -108,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_manage_sets.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp index caca5da92..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" @@ -58,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); @@ -89,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); 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/settings_view.cpp b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp index 005824995..b1a80c86b 100644 --- a/cockatrice/src/interface/widgets/dialogs/settings_view.cpp +++ b/cockatrice/src/interface/widgets/dialogs/settings_view.cpp @@ -6,7 +6,7 @@ #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" @@ -135,7 +135,7 @@ 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"); 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/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.h b/cockatrice/src/interface/widgets/replay/replay_manager.h index 34bae5f9a..e3745778e 100644 --- a/cockatrice/src/interface/widgets/replay/replay_manager.h +++ b/cockatrice/src/interface/widgets/replay/replay_manager.h @@ -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); diff --git a/cockatrice/src/interface/widgets/replay/replay_widget.cpp b/cockatrice/src/interface/widgets/replay/replay_widget.cpp index 3f0bf35d9..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) diff --git a/cockatrice/src/interface/widgets/replay/replay_widget.h b/cockatrice/src/interface/widgets/replay/replay_widget.h index 5d23b358d..cbb676288 100644 --- a/cockatrice/src/interface/widgets/replay/replay_widget.h +++ b/cockatrice/src/interface/widgets/replay/replay_widget.h @@ -38,6 +38,11 @@ public: [[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); diff --git a/cockatrice/src/interface/widgets/server/game_selector.cpp b/cockatrice/src/interface/widgets/server/game_selector.cpp index 2702a527d..468095adb 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); @@ -266,9 +266,13 @@ void GameSelector::actCreate() return; } - DlgCreateGame dlg(room, room->getGameTypes(), this); - dlg.exec(); - updateTitle(); + // Heap-allocate the dialog: if this selector (or its room tab) is destroyed while the + // dialog is open, e.g. on server disconnect, Qt deletes its children. A stack-allocated + // child would then be deleted by Qt and crash. + auto *dlg = new DlgCreateGame(room, room->getGameTypes(), this); + dlg->setAttribute(Qt::WA_DeleteOnClose); + connect(dlg, &QDialog::finished, this, &GameSelector::updateTitle); + dlg->open(); } void GameSelector::checkResponse(const Response &response) @@ -362,8 +366,9 @@ void GameSelector::customContextMenu(const QPoint &point) const ServerInfo_Game &gameInfo = gameListModel->getGame(index.data(Qt::UserRole).toInt()); const QMap &gameTypes = gameListModel->getGameTypes().value(gameInfo.room_id()); - DlgCreateGame dlg(gameInfo, gameTypes, this); - dlg.exec(); + auto *dlg = new DlgCreateGame(gameInfo, gameTypes, this); + dlg->setAttribute(Qt::WA_DeleteOnClose); + dlg->open(); }); QAction copyLink(tr("Cop&y game link")); diff --git a/cockatrice/src/interface/widgets/server/games_model.cpp b/cockatrice/src/interface/widgets/server/games_model.cpp index 8fed95833..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"))) { } @@ -191,7 +193,9 @@ QVariant GamesModel::data(const QModelIndex &index, int role) const 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/settings_page/deck_editor_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.cpp index 79370f512..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,7 +1,7 @@ #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" @@ -80,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; 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/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/tab_archidekt.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp index 9f7369435..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" @@ -282,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); 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 9adda008e..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" @@ -49,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); 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_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index 34d6f8a51..01d021907 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -27,6 +27,7 @@ #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" @@ -98,7 +99,7 @@ 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 @@ -115,6 +116,7 @@ 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); @@ -158,6 +160,7 @@ TabGame::TabGame(TabSupervisor *_tabSupervisor, createPlayAreaWidget(); createDeckViewContainerWidget(); replayDock = nullptr; + historyDock = nullptr; addDockWidget(Qt::RightDockWidgetArea, cardInfoDock); addDockWidget(Qt::RightDockWidgetArea, playerListDock); @@ -353,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())); } @@ -429,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")); } @@ -561,8 +570,10 @@ void TabGame::adminLockChanged(bool lock) void TabGame::actGameInfo() { - DlgCreateGame dlg(game->getGameMetaInfo()->proto(), game->getGameMetaInfo()->getRoomGameTypes(), this); - dlg.exec(); + // Heap-allocated so that destroying this tab while the dialog is open cannot delete a stack object. + auto *dlg = new DlgCreateGame(game->getGameMetaInfo()->proto(), game->getGameMetaInfo()->getRoomGameTypes(), this); + dlg->setAttribute(Qt::WA_DeleteOnClose); + dlg->open(); } void TabGame::actCopyGameLink() @@ -1291,6 +1302,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}); } @@ -1360,6 +1375,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); @@ -1368,6 +1389,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); @@ -1422,6 +1446,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 412529dc2..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,7 +90,7 @@ 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; diff --git a/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp index dc14b6e4a..dfde08f8e 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp @@ -2,6 +2,7 @@ #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" @@ -63,8 +64,8 @@ TabPublicDecks::TabPublicDecks(TabSupervisor *_tabSupervisor, AbstractClient *_c 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); @@ -79,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_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 cf71dd7ed..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" @@ -1360,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/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_color_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp index 464d62e4a..46330ac46 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.cpp @@ -15,9 +15,23 @@ #include #include +namespace +{ + +/** @brief Remove the filters that apply to the given color and attribute from the given model. */ +static void removeFilters(FilterTreeModel *filterModel, QChar color, CardFilter::Attr attr) +{ + for (const CardFilter *filter : filterModel->getFiltersOfType(attr)) { + if (filter->term() == color) { + filterModel->removeFilter(filter); + } + } +} +} // namespace + /** - * This widget provides a graphical control element for the CardFilter::Attr::AttrColor filters applied to the filter - * model. + * This widget provides a graphical control element for the CardFilter::Attr::AttrColor and + * CardFilter::Attr::AttrColorIdentity filters applied to the filter model. * @param parent The Qt Widget that this widget will parent to * @param _filterModel The filter model that this widget will manipulate */ @@ -59,11 +73,25 @@ void VisualDatabaseDisplayColorFilterWidget::retranslateUi() modeComboBox->blockSignals(true); modeComboBox->clear(); - modeComboBox->addItem(tr("Exact match"), QVariant::fromValue(FilterMode::ExactMatch)); modeComboBox->addItem(tr("Includes"), QVariant::fromValue(FilterMode::Includes)); + modeComboBox->addItem(tr("Excludes"), QVariant::fromValue(FilterMode::Excludes)); modeComboBox->addItem(tr("Include / Exclude"), QVariant::fromValue(FilterMode::IncludeExclude)); + modeComboBox->addItem(tr("Exact match"), QVariant::fromValue(FilterMode::ExactMatch)); + modeComboBox->addItem(tr("Color identity"), QVariant::fromValue(FilterMode::ColorIdentity)); + + modeComboBox->setItemData(0, tr("Cards containing any of the selected colors"), Qt::ToolTipRole); + modeComboBox->setItemData(1, tr("Cards containing none of the selected colors"), Qt::ToolTipRole); + modeComboBox->setItemData(2, tr("Cards containing any of the selected colors and none of the unselected colors"), + Qt::ToolTipRole); + modeComboBox->setItemData(3, tr("Cards being exactly of the selected colors"), Qt::ToolTipRole); + modeComboBox->setItemData(4, tr("Cards whose color identity fits within the selected colors"), Qt::ToolTipRole); + + if (currentMode == FilterMode::Custom) { + addCustomFilterModeItem(); + } modeComboBox->setToolTip(tr("How selected and unselected colors are combined in the filter")); + modeComboBox->setAccessibleName(tr("Color filter mode")); // Restore current mode const int index = modeComboBox->findData(QVariant::fromValue(currentMode)); @@ -74,6 +102,28 @@ void VisualDatabaseDisplayColorFilterWidget::retranslateUi() modeComboBox->blockSignals(false); } +void VisualDatabaseDisplayColorFilterWidget::setFilterMode(FilterMode filterMode) +{ + int index = modeComboBox->findData(QVariant::fromValue(filterMode)); + + if (index < 0 && filterMode == FilterMode::Custom) { + index = modeComboBox->count(); + addCustomFilterModeItem(); + } + + if (index >= 0) { + modeComboBox->setCurrentIndex(index); + } +} + +void VisualDatabaseDisplayColorFilterWidget::addCustomFilterModeItem() +{ + int customIndex = modeComboBox->count(); + modeComboBox->addItem(tr("Custom"), QVariant::fromValue(FilterMode::Custom)); + modeComboBox->setItemData(customIndex, tr("Cards matching an unknown combination of color filters"), + Qt::ToolTipRole); +} + void VisualDatabaseDisplayColorFilterWidget::handleColorToggled(QChar color, bool active) { if (active) { @@ -85,74 +135,24 @@ void VisualDatabaseDisplayColorFilterWidget::handleColorToggled(QChar color, boo void VisualDatabaseDisplayColorFilterWidget::addFilter(QChar color) { - QString colorString = color; - QString typeStr; + FilterModeInfo info = getFilterModeInfo(currentMode); - // Remove previous filters + removeFilters(filterModel, color, info.attr); - QList allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor); - QList matchingFilters; - - for (const CardFilter *filter : allColorFilters) { - if (filter->term() == color) { - matchingFilters.append(filter); - } - } - - for (const CardFilter *filter : matchingFilters) { - filterModel->removeFilter(filter); - } - - // Add actual filter - - switch (currentMode) { - case FilterMode::ExactMatch: - filterModel->addFilter(new CardFilter(colorString, CardFilter::Type::TypeAnd, CardFilter::Attr::AttrColor)); - break; - - case FilterMode::Includes: - filterModel->addFilter(new CardFilter(colorString, CardFilter::Type::TypeOr, CardFilter::Attr::AttrColor)); - break; - - case FilterMode::IncludeExclude: - filterModel->addFilter(new CardFilter(colorString, CardFilter::Type::TypeOr, CardFilter::Attr::AttrColor)); - break; + if (info.includeType != CardFilter::Type::TypeEnd) { + filterModel->addFilter(new CardFilter(color, info.includeType, info.attr)); } } void VisualDatabaseDisplayColorFilterWidget::removeFilter(QChar color) { - QString colorString = color; + FilterModeInfo info = getFilterModeInfo(currentMode); - // Remove inclusion filters - QList allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor); - QList matchingFilters; + removeFilters(filterModel, color, info.attr); - for (const CardFilter *filter : allColorFilters) { - if (filter->term() == color) { - matchingFilters.append(filter); - } - } - - for (const CardFilter *filter : matchingFilters) { - filterModel->removeFilter(filter); - } - - // Add exclusion filters if the mode demands it - switch (currentMode) { - case FilterMode::ExactMatch: - filterModel->addFilter( - new CardFilter(colorString, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColor)); - break; - - case FilterMode::IncludeExclude: - filterModel->addFilter( - new CardFilter(colorString, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColor)); - break; - - case FilterMode::Includes: - // No exclusion in Includes mode - break; + // Add exclusion filters if the mode demands it. + if (info.excludeType != CardFilter::Type::TypeEnd) { + filterModel->addFilter(new CardFilter(color, info.excludeType, info.attr)); } } @@ -169,6 +169,7 @@ void VisualDatabaseDisplayColorFilterWidget::updateFilterMode() filterModel->filterTree()->blockSignals(true); filterModel->clearFiltersOfType(CardFilter::Attr::AttrColor); + filterModel->clearFiltersOfType(CardFilter::Attr::AttrColorIdentity); const QList manaSymbolWidgets = findChildren(); @@ -211,52 +212,53 @@ QList VisualDatabaseDisplayColorFilterWidget::getActiveColors() void VisualDatabaseDisplayColorFilterWidget::syncWithFilterModel() { - QList allColorFilters = filterModel->getFiltersOfType(CardFilter::Attr::AttrColor); + QList filters; + filters.append(filterModel->getFiltersOfType(CardFilter::Attr::AttrColor)); + filters.append(filterModel->getFiltersOfType(CardFilter::Attr::AttrColorIdentity)); - QList manaSymbolWidgets = findChildren(); + // Re-derive the mode from the filters in the model, so that a loaded filter set selects the mode that + // produced it (or Custom when none could have). + currentMode = deriveFilterMode(filters, currentMode); - for (ManaSymbolWidget *manaSymbolWidget : manaSymbolWidgets) { + // Update the combobox. Add a Custom item only if we have a custom filter mode. + int customIndex = modeComboBox->findData(QVariant::fromValue(FilterMode::Custom)); + modeComboBox->blockSignals(true); + if (currentMode == FilterMode::Custom) { + if (customIndex < 0) { + customIndex = modeComboBox->count(); + addCustomFilterModeItem(); + } + modeComboBox->setCurrentIndex(customIndex); + } else { + if (customIndex >= 0) { + modeComboBox->removeItem(customIndex); + } + int index = modeComboBox->findData(QVariant::fromValue(currentMode)); + if (index >= 0) { + modeComboBox->setCurrentIndex(index); + } + } + modeComboBox->blockSignals(false); + + // Update the mana symbol widgets by looking for the current mode's include or exclude filters. + const FilterModeInfo modeInfo = getFilterModeInfo(currentMode); + + const bool useExclude = + modeInfo.includeType == CardFilter::Type::TypeEnd && modeInfo.excludeType != CardFilter::Type::TypeEnd; + const CardFilter::Type filterType = useExclude ? modeInfo.excludeType : modeInfo.includeType; + + filters = filterModel->getFiltersOfType(modeInfo.attr); + + for (ManaSymbolWidget *manaSymbolWidget : findChildren()) { bool found = false; - for (const CardFilter *filter : allColorFilters) { - if (manaSymbolWidget->getSymbolChar() == filter->term()) { - switch (currentMode) { - case FilterMode::ExactMatch: - switch (filter->type()) { - case CardFilter::Type::TypeAnd: - setManaSymbolActive(filter->term().at(0), true); - break; - default: - setManaSymbolActive(filter->term().at(0), false); - break; - } - break; - case FilterMode::Includes: - switch (filter->type()) { - case CardFilter::Type::TypeOr: - setManaSymbolActive(filter->term().at(0), true); - break; - default: - setManaSymbolActive(filter->term().at(0), false); - break; - } - break; - case FilterMode::IncludeExclude: - switch (filter->type()) { - case CardFilter::Type::TypeOr: - setManaSymbolActive(filter->term().at(0), true); - break; - default: - setManaSymbolActive(filter->term().at(0), false); - break; - } - break; - } + for (const CardFilter *filter : filters) { + if (filter->type() == filterType && manaSymbolWidget->getSymbolChar() == filter->term()) { found = true; + break; } } - if (!found) { - setManaSymbolActive(manaSymbolWidget->getSymbolChar(), false); - } + manaSymbolWidget->setColorActive(useExclude ? !found : found); + manaSymbolWidget->setDisabled(currentMode == FilterMode::Custom); } } diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.h index 184f1f96c..561c92d09 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_color_filter_widget.h @@ -10,8 +10,8 @@ #include #include #include -#include #include +#include #include #include @@ -38,15 +38,6 @@ private: int circleDiameter; }; -enum class FilterMode -{ - ExactMatch, // Only selected colors are included, all others are excluded. - Includes, // Include selected colors (OR condition). - IncludeExclude // Include selected colors (OR) and exclude unselected colors (AND NOT). -}; - -Q_DECLARE_METATYPE(FilterMode) - class VisualDatabaseDisplayColorFilterWidget : public QWidget { Q_OBJECT @@ -54,11 +45,13 @@ class VisualDatabaseDisplayColorFilterWidget : public QWidget public: explicit VisualDatabaseDisplayColorFilterWidget(QWidget *parent, FilterTreeModel *filterModel); void retranslateUi(); + void setFilterMode(FilterMode filterMode); signals: void filterModeChanged(FilterMode filterMode); private slots: + void addCustomFilterModeItem(); void handleColorToggled(QChar color, bool active); void addFilter(QChar color); void removeFilter(QChar color); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.cpp index 5063cce14..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..6d9b28619 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,10 +156,11 @@ 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); + colorFilterWidget->setFilterMode(FilterMode::Includes); filterModel->clear(); filterModel->blockSignals(false); filterModel->filterTree()->blockSignals(false); diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.cpp index 63ef07835..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_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/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 79a802234..715505926 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -50,6 +50,7 @@ #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" @@ -387,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 */ diff --git a/libcockatrice_card/libcockatrice/card/card_info.cpp b/libcockatrice_card/libcockatrice/card/card_info.cpp index 90bfd3f44..36f0cdeb8 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.cpp +++ b/libcockatrice_card/libcockatrice/card/card_info.cpp @@ -316,6 +316,10 @@ void CardInfo::setColors(const QString &value) { setProperty(Mtg::Colors, value); } +QString CardInfo::getColorIdentity() const +{ + return getProperty(Mtg::ColorIdentity); +} QString CardInfo::getLoyalty() const { return getProperty(Mtg::Loyalty); diff --git a/libcockatrice_card/libcockatrice/card/card_info.h b/libcockatrice_card/libcockatrice/card/card_info.h index 5452ef98b..075b5b264 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.h +++ b/libcockatrice_card/libcockatrice/card/card_info.h @@ -469,6 +469,7 @@ public: [[nodiscard]] QString getCmc() const; [[nodiscard]] QString getColors() const; void setColors(const QString &value); + [[nodiscard]] QString getColorIdentity() const; [[nodiscard]] QString getLoyalty() const; [[nodiscard]] QString getMainCardType() const; [[nodiscard]] QString getManaCost() const; diff --git a/libcockatrice_filters/CMakeLists.txt b/libcockatrice_filters/CMakeLists.txt index 070c04637..1b4303fc9 100644 --- a/libcockatrice_filters/CMakeLists.txt +++ b/libcockatrice_filters/CMakeLists.txt @@ -1,10 +1,10 @@ -set(HEADERS libcockatrice/filters/filter_card.h libcockatrice/filters/filter_string.h - libcockatrice/filters/filter_tree.h +set(HEADERS libcockatrice/filters/filter_card.h libcockatrice/filters/filter_mode.h + libcockatrice/filters/filter_string.h libcockatrice/filters/filter_tree.h ) add_library( - libcockatrice_filters STATIC ${HEADERS} libcockatrice/filters/filter_card.cpp libcockatrice/filters/filter_string.cpp - libcockatrice/filters/filter_tree.cpp + libcockatrice_filters STATIC ${HEADERS} libcockatrice/filters/filter_card.cpp libcockatrice/filters/filter_mode.cpp + libcockatrice/filters/filter_string.cpp libcockatrice/filters/filter_tree.cpp ) add_dependencies(libcockatrice_filters libcockatrice_card) diff --git a/libcockatrice_filters/libcockatrice/filters/filter_card.cpp b/libcockatrice_filters/libcockatrice/filters/filter_card.cpp index 26cddd53c..8af39c098 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_card.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_card.cpp @@ -69,6 +69,8 @@ const QString CardFilter::attrName(Attr a) return tr("Type"); case AttrColor: return tr("Color"); + case AttrColorIdentity: + return tr("Color Identity"); case AttrText: return tr("Text"); case AttrSet: diff --git a/libcockatrice_filters/libcockatrice/filters/filter_card.h b/libcockatrice_filters/libcockatrice/filters/filter_card.h index dbeffd18f..bc613fc9c 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_card.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_card.h @@ -32,6 +32,7 @@ public: { AttrCmc = 0, AttrColor, + AttrColorIdentity, AttrLoyalty, AttrManaCost, AttrName, diff --git a/libcockatrice_filters/libcockatrice/filters/filter_mode.cpp b/libcockatrice_filters/libcockatrice/filters/filter_mode.cpp new file mode 100644 index 000000000..356ccf608 --- /dev/null +++ b/libcockatrice_filters/libcockatrice/filters/filter_mode.cpp @@ -0,0 +1,77 @@ +/** + * @file filter_mode.cpp + * @ingroup Filters + */ + +#include "libcockatrice/filters/filter_mode.h" + +#include + +FilterModeInfo getFilterModeInfo(FilterMode filterMode) +{ + switch (filterMode) { + case FilterMode::Includes: + return {CardFilter::Type::TypeOr, CardFilter::Type::TypeEnd, CardFilter::Attr::AttrColor}; + case FilterMode::Excludes: + return {CardFilter::Type::TypeAndNot, CardFilter::Type::TypeEnd, CardFilter::Attr::AttrColor}; + case FilterMode::IncludeExclude: + return {CardFilter::Type::TypeOr, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColor}; + case FilterMode::ExactMatch: + return {CardFilter::Type::TypeAnd, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColor}; + case FilterMode::ColorIdentity: + return {CardFilter::Type::TypeEnd, CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColorIdentity}; + case FilterMode::Custom: + case FilterMode::End: + break; + } + return {CardFilter::Type::TypeEnd, CardFilter::Type::TypeEnd, CardFilter::Attr::AttrEnd}; +} + +FilterMode deriveFilterMode(const QList &filters, FilterMode currentMode) +{ + constexpr int modeCount = static_cast(FilterMode::End); + bool modeImpossible[modeCount] = {}; + const QString colors = "WUBRG"; + + for (int i = 0; i < modeCount; ++i) { + const FilterModeInfo modeInfo = getFilterModeInfo(static_cast(i)); + + // Remove the modes that cannot create one of the filters. + for (const CardFilter *filter : filters) { + if (filter->attr() != modeInfo.attr || + (filter->type() != modeInfo.includeType && filter->type() != modeInfo.excludeType)) { + modeImpossible[i] = true; + break; + } + } + + // Modes that have both an include and an exclude filter should always have a filter for each color. + if (modeInfo.includeType != CardFilter::Type::TypeEnd && modeInfo.excludeType != CardFilter::Type::TypeEnd && + !modeImpossible[i]) { + for (const QChar &color : colors) { + int count = 0; + for (const CardFilter *filter : filters) { + count += filter->term() == color; + } + if (count != 1) { + modeImpossible[i] = true; + break; + } + } + } + } + + // Keep the current mode if it can produce the filters, so that a live mode is never swapped for another. + // Switching to and from the custom mode is always possible. + if (!modeImpossible[static_cast(currentMode)] && currentMode != FilterMode::Custom) { + return currentMode; + } + + for (int i = 0; i < modeCount; ++i) { + if (!modeImpossible[i] || i == static_cast(FilterMode::Custom)) { + return static_cast(i); + } + } + + return FilterMode::Custom; +} diff --git a/libcockatrice_filters/libcockatrice/filters/filter_mode.h b/libcockatrice_filters/libcockatrice/filters/filter_mode.h new file mode 100644 index 000000000..f7662d662 --- /dev/null +++ b/libcockatrice_filters/libcockatrice/filters/filter_mode.h @@ -0,0 +1,62 @@ +/** + * @file filter_mode.h + * @ingroup Filters + */ + +#ifndef LIBCOCKATRICE_FILTERS_FILTER_MODE_H +#define LIBCOCKATRICE_FILTERS_FILTER_MODE_H + +#include "libcockatrice/filters/filter_card.h" + +#include +#include +#include + +/** + * @brief The way selected and unselected colors are combined into card filters. + */ +enum class FilterMode +{ + Includes = 0, // Include selected colors (OR condition). + Excludes, // Exclude selected colors (AND NOT). + IncludeExclude, // Include selected colors (OR) and exclude unselected colors (AND NOT). + ExactMatch, // Only selected colors are included, all others are excluded. + ColorIdentity, // Keep cards whose color identity is a subset of the selected colors (stored as AND NOT for each + // unselected color). + Custom, // The color filters do not match any of the above modes. Not included in the combo box. + End +}; + +Q_DECLARE_METATYPE(FilterMode) + +/** + * @brief The filters a single color contributes in a given mode. + */ +struct FilterModeInfo +{ + // When a color symbol is enabled, add filters of this type. Skipped if equal to CardFilter::Type::TypeEnd. + CardFilter::Type includeType; + + // When a color symbol is disabled, add filters of this type. Skipped if equal to CardFilter::Type::TypeEnd. + CardFilter::Type excludeType; + + // The attribute to filter for when adding the filters. + CardFilter::Attr attr; +}; + +/** @brief Return the filters the given mode contributes for one color. */ +FilterModeInfo getFilterModeInfo(FilterMode filterMode); + +/** + * @brief Determine the mode that produced the given color filters. + * + * Returns @p currentMode unchanged when that mode can still produce @p filters, so that a live mode is never + * replaced by an equally valid one. Otherwise returns the first mode that can produce them, falling back to + * FilterMode::Custom when none can. + * + * @param filters The filters on CardFilter::Attr::AttrColor and CardFilter::Attr::AttrColorIdentity. + * @param currentMode The mode currently selected in the UI. + */ +FilterMode deriveFilterMode(const QList &filters, FilterMode currentMode); + +#endif // LIBCOCKATRICE_FILTERS_FILTER_MODE_H diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp index 081ada824..1bcb1646b 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp @@ -409,7 +409,7 @@ static void setupParserRules() const bool identity = sv.tokens[0].empty() || sv.tokens[0][0] != 'i'; if (sv.tokens[1][0] == ':') { return [=](const CardData &x) { - const QString match = identity ? x->getColors() : x->getProperty("coloridentity"); + const QString match = identity ? x->getColors() : x->getColorIdentity(); if (parts.contains("m") && match.length() < 2) { return false; } @@ -427,7 +427,7 @@ static void setupParserRules() } return [=](const CardData &x) { - const QString match = identity ? x->getColors() : x->getProperty("colorIdentity"); + const QString match = identity ? x->getColors() : x->getColorIdentity(); if (parts.contains("m") && match.length() < 2) { return false; } diff --git a/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp b/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp index f12f87181..817c7298d 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp @@ -176,23 +176,24 @@ FilterItem::FilterItem(QString trm, FilterItemList *parent) : p(parent), term(st lowercaseTerm = term.toLower(); switch (attr()) { - case CardFilter::AttrColor: { - QString converted = term.trimmed(); + case CardFilter::AttrColor: + case CardFilter::AttrColorIdentity: { + QString converted = term.trimmed().toLower(); - converted.replace("green", "g", Qt::CaseInsensitive); - converted.replace("grn", "g", Qt::CaseInsensitive); - converted.replace("blue", "u", Qt::CaseInsensitive); - converted.replace("blu", "u", Qt::CaseInsensitive); - converted.replace("black", "b", Qt::CaseInsensitive); - converted.replace("blk", "b", Qt::CaseInsensitive); - converted.replace("red", "r", Qt::CaseInsensitive); - converted.replace("white", "w", Qt::CaseInsensitive); - converted.replace("wht", "w", Qt::CaseInsensitive); - converted.replace("colorless", "c", Qt::CaseInsensitive); - converted.replace("colourless", "c", Qt::CaseInsensitive); - converted.replace("none", "c", Qt::CaseInsensitive); + converted.replace("green", "g"); + converted.replace("grn", "g"); + converted.replace("blue", "u"); + converted.replace("blu", "u"); + converted.replace("black", "b"); + converted.replace("blk", "b"); + converted.replace("red", "r"); + converted.replace("white", "w"); + converted.replace("wht", "w"); + converted.replace("colorless", "c"); + converted.replace("colourless", "c"); + converted.replace("none", "c"); - converted.replace(QString(" "), QString(""), Qt::CaseInsensitive); + converted.replace(QString(" "), QString("")); convertedColorTerm = converted; break; @@ -358,12 +359,17 @@ bool FilterItem::acceptSubType(const CardInfoPtr &info) const return subType.contains(term, Qt::CaseInsensitive); } -bool FilterItem::acceptColor(const CardInfoPtr &info) const +/** + * @brief Return true when every letter of the given term appears in the given colors string. + * If the term is equal to "c", then only return true if the color string is empty. + * + * @param term The letters which should be included, or "c" + * @param colors The string to check for letters in + */ +static bool matchesColor(const QString &term, const QString &colors) { - const QString colors = info->getColors(); - // Colorless card filter - if (convertedColorTerm.toLower() == "c" && colors.length() < 1) { + if (term == "c" && colors.length() < 1) { return true; } @@ -372,13 +378,23 @@ bool FilterItem::acceptColor(const CardInfoPtr &info) const * then we should match all of them to the card's colors */ int match_count = 0; - for (const QChar &color : convertedColorTerm) { + for (const QChar &color : term) { if (colors.contains(color, Qt::CaseInsensitive)) { match_count++; } } - return match_count == convertedColorTerm.length(); + return match_count == term.length(); +} + +bool FilterItem::acceptColor(const CardInfoPtr &info) const +{ + return matchesColor(convertedColorTerm, info->getColors()); +} + +bool FilterItem::acceptColorIdentity(const CardInfoPtr &info) const +{ + return matchesColor(convertedColorTerm, info->getColorIdentity()); } bool FilterItem::acceptText(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const @@ -540,6 +556,8 @@ bool FilterItem::acceptCardAttr(const CardInfoPtr &info, return acceptType(info); case CardFilter::AttrColor: return acceptColor(info); + case CardFilter::AttrColorIdentity: + return acceptColorIdentity(info); case CardFilter::AttrText: return acceptText(info, searchLanguage); case CardFilter::AttrSet: diff --git a/libcockatrice_filters/libcockatrice/filters/filter_tree.h b/libcockatrice_filters/libcockatrice/filters/filter_tree.h index 85a57304d..f1a5803e5 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_tree.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_tree.h @@ -244,6 +244,7 @@ public: [[nodiscard]] bool acceptMainType(const CardInfoPtr &info) const; [[nodiscard]] bool acceptSubType(const CardInfoPtr &info) const; [[nodiscard]] bool acceptColor(const CardInfoPtr &info) const; + [[nodiscard]] bool acceptColorIdentity(const CardInfoPtr &info) const; [[nodiscard]] bool acceptText(const CardInfoPtr &info, const CardSearchLanguage &searchLanguage) const; [[nodiscard]] bool acceptSet(const CardInfoPtr &info) const; [[nodiscard]] bool acceptManaCost(const CardInfoPtr &info) const; 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 f68f51f23..4ca2acd07 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -31,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/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5b7e06c20..84b9f02bf 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -177,6 +177,7 @@ 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 @@ -565,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/carddatabase/CMakeLists.txt b/tests/carddatabase/CMakeLists.txt index 7d83640a1..012081fd2 100644 --- a/tests/carddatabase/CMakeLists.txt +++ b/tests/carddatabase/CMakeLists.txt @@ -21,10 +21,12 @@ cockatrice_add_test( ) # ------------------------ -# Filter String Test -# (guard must match the condition for libcockatrice_filters in the root CMakeLists.txt) +# Guard to match condition for libcockatrice_filters in the root CMakeLists.txt # ------------------------ if(WITH_ORACLE OR WITH_CLIENT) + # ------------------------ + # Filter String Test + # ------------------------ cockatrice_add_test( NAME filter_string_test @@ -36,6 +38,34 @@ if(WITH_ORACLE OR WITH_CLIENT) libcockatrice_filters ) + # ------------------------ + # Filter Tree Test + # ------------------------ + cockatrice_add_test( + NAME + filter_tree_test + SOURCES + ${VERSION_STRING_CPP} + filter_tree_test.cpp + mocks.cpp + LIBS + libcockatrice_filters + ) + + # ------------------------ + # Filter Mode Test + # ------------------------ + cockatrice_add_test( + NAME + filter_mode_test + SOURCES + ${VERSION_STRING_CPP} + filter_mode_test.cpp + mocks.cpp + LIBS + libcockatrice_filters + ) + # ------------------------ # Visual Display Model Test # ------------------------ diff --git a/tests/carddatabase/carddatabase_test.cpp b/tests/carddatabase/carddatabase_test.cpp index b54205f30..350d9be7e 100644 --- a/tests/carddatabase/carddatabase_test.cpp +++ b/tests/carddatabase/carddatabase_test.cpp @@ -1,3 +1,4 @@ +#include "../support/scoped_file_size_limit.h" #include "libcockatrice/card/database/card_database.h" #include "libcockatrice/card/database/card_database_loader.h" #include "libcockatrice/card/database/card_database_querier.h" @@ -36,12 +37,6 @@ #include #include -#ifdef Q_OS_UNIX -#include -#include -#include -#endif - namespace { @@ -247,21 +242,14 @@ TEST(CardDatabaseTest, Xml4ShortWriteKeepsInstalledFile) 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), ""); + // A real filesystem write failure: the soft file-size cap makes the writer's own + // flush fail without filling a disk. The cap is scoped rather than forked, because a + // death-test child under RLIMIT_FSIZE is killed by SIGSEGV on macOS. + test_support::ScopedFileSizeLimit fileSizeCap(128); + ASSERT_TRUE(fileSizeCap.isValid()); + + CockatriceXml4Parser writer(&controller); + EXPECT_FALSE(writer.saveToFile({}, sets, cards, fileName)); ASSERT_TRUE(installed.open(QIODevice::ReadOnly)); EXPECT_EQ(installed.readAll(), "installed database"); diff --git a/tests/carddatabase/filter_mode_test.cpp b/tests/carddatabase/filter_mode_test.cpp new file mode 100644 index 000000000..3fec7e25d --- /dev/null +++ b/tests/carddatabase/filter_mode_test.cpp @@ -0,0 +1,147 @@ +#include "libcockatrice/filters/filter_mode.h" + +#include + +class FilterModeTest : public ::testing::Test +{ +protected: + ~FilterModeTest() override + { + for (CardFilter *filter : owned) { + delete filter; + } + } + + /** @brief Create a filter owned by the fixture and append it to the set under test. */ + const CardFilter * + add(const QString &term, CardFilter::Type type, CardFilter::Attr attr = CardFilter::Attr::AttrColor) + { + owned.append(new CardFilter(term, type, attr)); + filters.append(owned.last()); + return owned.last(); + } + + QList owned; + QList filters; +}; + +TEST(FilterModeInfoTest, EachModeDescribesItsOwnFilters) +{ + EXPECT_EQ(getFilterModeInfo(FilterMode::Includes).includeType, CardFilter::Type::TypeOr); + EXPECT_EQ(getFilterModeInfo(FilterMode::Includes).excludeType, CardFilter::Type::TypeEnd); + EXPECT_EQ(getFilterModeInfo(FilterMode::Includes).attr, CardFilter::Attr::AttrColor); + + EXPECT_EQ(getFilterModeInfo(FilterMode::Excludes).includeType, CardFilter::Type::TypeAndNot); + EXPECT_EQ(getFilterModeInfo(FilterMode::Excludes).excludeType, CardFilter::Type::TypeEnd); + + EXPECT_EQ(getFilterModeInfo(FilterMode::IncludeExclude).includeType, CardFilter::Type::TypeOr); + EXPECT_EQ(getFilterModeInfo(FilterMode::IncludeExclude).excludeType, CardFilter::Type::TypeAndNot); + + EXPECT_EQ(getFilterModeInfo(FilterMode::ExactMatch).includeType, CardFilter::Type::TypeAnd); + EXPECT_EQ(getFilterModeInfo(FilterMode::ExactMatch).excludeType, CardFilter::Type::TypeAndNot); + + // Color identity only excludes: the selected colors must be the identity, so nothing is included positively. + EXPECT_EQ(getFilterModeInfo(FilterMode::ColorIdentity).includeType, CardFilter::Type::TypeEnd); + EXPECT_EQ(getFilterModeInfo(FilterMode::ColorIdentity).excludeType, CardFilter::Type::TypeAndNot); + EXPECT_EQ(getFilterModeInfo(FilterMode::ColorIdentity).attr, CardFilter::Attr::AttrColorIdentity); + + EXPECT_EQ(getFilterModeInfo(FilterMode::Custom).attr, CardFilter::Attr::AttrEnd); +} + +TEST_F(FilterModeTest, EmptyFilterSetKeepsACompatibleMode) +{ + // Includes and Color identity both allow an empty set, so the live mode is not swapped for another. + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Includes), FilterMode::Includes); + EXPECT_EQ(deriveFilterMode(filters, FilterMode::ColorIdentity), FilterMode::ColorIdentity); +} + +// Include / Exclude and Exact match always hold one filter per color, so an empty set cannot have come from +// them and falls back to the first mode that can describe it. +TEST_F(FilterModeTest, EmptyFilterSetFallsBackWhenTheModeNeedsOneFilterPerColor) +{ + EXPECT_EQ(deriveFilterMode(filters, FilterMode::IncludeExclude), FilterMode::Includes); + EXPECT_EQ(deriveFilterMode(filters, FilterMode::ExactMatch), FilterMode::Includes); +} + +TEST_F(FilterModeTest, IncludesFromOrFilters) +{ + add("W", CardFilter::Type::TypeOr); + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Includes), FilterMode::Includes); + // A single OR filter cannot come from Excludes, so the first mode that can produce it is selected. + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Excludes), FilterMode::Includes); +} + +TEST_F(FilterModeTest, ExcludesFromAndNotFilters) +{ + add("W", CardFilter::Type::TypeAndNot); + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Excludes), FilterMode::Excludes); + // Includes needs an OR or an end filter, so an AND NOT filter selects Excludes. + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Includes), FilterMode::Excludes); +} + +TEST_F(FilterModeTest, IncludeExcludeNeedsOneFilterPerColor) +{ + add("W", CardFilter::Type::TypeOr); + for (const QChar color : QString("UBRG")) { + add(QString(color), CardFilter::Type::TypeAndNot); + } + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::IncludeExclude), FilterMode::IncludeExclude); +} + +TEST_F(FilterModeTest, IncludeExcludeRejectsAnIncompleteColorSet) +{ + add("W", CardFilter::Type::TypeOr); + add("U", CardFilter::Type::TypeAndNot); + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::IncludeExclude), FilterMode::Custom); +} + +TEST_F(FilterModeTest, ExactMatchFromAndAndAndNotFilters) +{ + add("W", CardFilter::Type::TypeAnd); + for (const QChar color : QString("UBRG")) { + add(QString(color), CardFilter::Type::TypeAndNot); + } + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::ExactMatch), FilterMode::ExactMatch); +} + +TEST_F(FilterModeTest, ColorIdentityFromIdentityAndNotFilters) +{ + for (const QChar color : QString("BRG")) { + add(QString(color), CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColorIdentity); + } + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::ColorIdentity), FilterMode::ColorIdentity); +} + +TEST_F(FilterModeTest, MixedAttributesAreCustom) +{ + add("W", CardFilter::Type::TypeOr, CardFilter::Attr::AttrColor); + add("U", CardFilter::Type::TypeAndNot, CardFilter::Attr::AttrColorIdentity); + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Includes), FilterMode::Custom); +} + +TEST_F(FilterModeTest, CustomLeavesAsSoonAsAModeCanExplainTheFilters) +{ + add("W", CardFilter::Type::TypeOr); + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Custom), FilterMode::Includes); +} + +// Exact match with no color selected stores one AND NOT filter per color. That is a legitimate Exact match +// state, so the live mode is kept; a mode that cannot produce the set re-derives to Excludes, which produces +// the identical filters. +TEST_F(FilterModeTest, FullyExcludedColorSetIsExactMatchOrExcludes) +{ + for (const QChar color : QString("WUBRG")) { + add(QString(color), CardFilter::Type::TypeAndNot); + } + + EXPECT_EQ(deriveFilterMode(filters, FilterMode::ExactMatch), FilterMode::ExactMatch); + EXPECT_EQ(deriveFilterMode(filters, FilterMode::Includes), FilterMode::Excludes); +} diff --git a/tests/carddatabase/filter_string_test.cpp b/tests/carddatabase/filter_string_test.cpp index e561db590..931cb7e0c 100644 --- a/tests/carddatabase/filter_string_test.cpp +++ b/tests/carddatabase/filter_string_test.cpp @@ -123,6 +123,25 @@ CardInfoPtr greenIdentityCard() return CardInfo::newInstance("Identity Cat", "", false, {{"colors", "G"}, {"coloridentity", "G"}}, {}, {}, {}, {}); } +// A card whose color identity differs from its colors, so that a query reading the wrong one is visible. +CardInfoPtr splitIdentityCard() +{ + return CardInfo::newInstance("Split Identity", "", false, {{"colors", "G"}, {"coloridentity", "W"}}, {}, {}, {}, + {}); +} + +// "ci!" (exactly these color identities) used to read the camelCase "colorIdentity" property instead of the +// lowercase "coloridentity" key, so it matched the empty string and rejected every card. +TEST_F(CardQuery, ColorIdentityExactReadsTheColorIdentityProperty) +{ + const CardData card = splitIdentityCard(); + ASSERT_TRUE(FilterString("ci!w").check(card)); + ASSERT_FALSE(FilterString("ci!g").check(card)); + // "c!" still reads colors, not identity. + ASSERT_TRUE(FilterString("c!g").check(card)); + ASSERT_FALSE(FilterString("c!w").check(card)); +} + // Whitespace belongs around the separator, never inside an operator token: "c i: g" must not // be read as the color-identity operator "ci" (it is a generic "c" AND a field query on "i"). TEST_F(CardQuery, ColorIdentityOperatorIsNotSplitByWhitespace) diff --git a/tests/carddatabase/filter_tree_test.cpp b/tests/carddatabase/filter_tree_test.cpp new file mode 100644 index 000000000..8d3706071 --- /dev/null +++ b/tests/carddatabase/filter_tree_test.cpp @@ -0,0 +1,204 @@ + +#include "libcockatrice/filters/filter_tree.h" + +#include +#include +#include + +namespace +{ +class FilterTreeTest : public ::testing::Test +{ +protected: + void SetUp() override + { + language = CardSearchLanguage{}; + + island = CardInfo::newInstance("Island", "", false, {{"colors", ""}, {"coloridentity", "U"}}, {}, {}, {}, {}); + artifact = + CardInfo::newInstance("Artifact", "", false, {{"colors", ""}, {"coloridentity", ""}}, {}, {}, {}, {}); + truth = CardInfo::newInstance("Truth", "", false, {{"colors", "U"}, {"coloridentity", "U"}}, {}, {}, {}, {}); + notDead = + CardInfo::newInstance("Not Dead", "", false, {{"colors", "B"}, {"coloridentity", "B"}}, {}, {}, {}, {}); + wallOfDenial = CardInfo::newInstance("Wall of Denial", "", false, {{"colors", "WU"}, {"coloridentity", "WU"}}, + {}, {}, {}, {}); + maelstromAngel = CardInfo::newInstance("Maelstrom Archangel", "", false, + {{"colors", "WUBRG"}, {"coloridentity", "WUBRG"}}, {}, {}, {}, {}); + } + + CardSearchLanguage language; + + CardData island; // c/u + CardData artifact; // c + CardData truth; // u + CardData notDead; // b + CardData wallOfDenial; // wu + CardData maelstromAngel; // wubrg +}; +} // namespace + +// ===================================================================================================================== +// acceptColor +// ===================================================================================================================== + +TEST_F(FilterTreeTest, Color) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "wu"); + ASSERT_FALSE(filterTree.acceptsCard(island, language)); + ASSERT_FALSE(filterTree.acceptsCard(artifact, language)); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_TRUE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorColorless) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "c"); + ASSERT_TRUE(filterTree.acceptsCard(island, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_FALSE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_FALSE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorNot) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAndNot, "b"); + ASSERT_TRUE(filterTree.acceptsCard(island, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + ASSERT_TRUE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_FALSE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorAliases) +{ + FilterTree filterTree; + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "green"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(maelstromAngel, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "grn"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(maelstromAngel, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "blue"); + ASSERT_TRUE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "blu"); + ASSERT_TRUE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "black"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(notDead, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "blk"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(notDead, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "red"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(maelstromAngel, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "white"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "wht"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "colorless"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "colourless"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + filterTree.clear(); + + filterTree.termNode(CardFilter::Attr::AttrColor, CardFilter::Type::TypeAnd, "none"); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + filterTree.clear(); +} + +// ===================================================================================================================== +// acceptColorIdentity +// ===================================================================================================================== + +TEST_F(FilterTreeTest, ColorIdentity) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAnd, "wu"); + ASSERT_FALSE(filterTree.acceptsCard(island, language)); + ASSERT_FALSE(filterTree.acceptsCard(artifact, language)); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_TRUE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorIdentityColorless) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAnd, "c"); + ASSERT_FALSE(filterTree.acceptsCard(island, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + ASSERT_FALSE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_FALSE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_FALSE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorIdentityNot) +{ + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAndNot, "b"); + ASSERT_TRUE(filterTree.acceptsCard(island, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + ASSERT_TRUE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_FALSE(filterTree.acceptsCard(maelstromAngel, language)); +} + +TEST_F(FilterTreeTest, ColorIdentityMultiple) +{ + // Simulate a WU commander. + FilterTree filterTree; + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAndNot, "b"); + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAndNot, "r"); + filterTree.termNode(CardFilter::Attr::AttrColorIdentity, CardFilter::Type::TypeAndNot, "g"); + ASSERT_TRUE(filterTree.acceptsCard(island, language)); + ASSERT_TRUE(filterTree.acceptsCard(artifact, language)); + ASSERT_TRUE(filterTree.acceptsCard(truth, language)); + ASSERT_FALSE(filterTree.acceptsCard(notDead, language)); + ASSERT_TRUE(filterTree.acceptsCard(wallOfDenial, language)); + ASSERT_FALSE(filterTree.acceptsCard(maelstromAngel, language)); +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/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/oracle/oracle_importer_test.cpp b/tests/oracle/oracle_importer_test.cpp index 259bae087..1b4e2d915 100644 --- a/tests/oracle/oracle_importer_test.cpp +++ b/tests/oracle/oracle_importer_test.cpp @@ -1,6 +1,7 @@ #include "../../oracle/src/database_writer.h" #include "../../oracle/src/oracleimporter.h" #include "../../oracle/src/raw_json_scanner.h" +#include "../support/scoped_file_size_limit.h" #include "libcockatrice/card/card_info.h" #include "gtest/gtest.h" @@ -33,12 +34,6 @@ #include #include -#ifdef Q_OS_UNIX -#include -#include -#include -#endif - class OracleImporterTest : public ::testing::Test { protected: @@ -1336,20 +1331,16 @@ TEST(OracleDatabaseWriter, ShortWriteKeepsPreviouslyInstalledDatabase) 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), ""); + // A real filesystem write failure: the soft file-size cap makes the writer's own + // flush fail without filling a disk. The cap is scoped rather than forked, because a + // death-test child under RLIMIT_FSIZE is killed by SIGSEGV on macOS. + test_support::ScopedFileSizeLimit fileSizeCap(128); + ASSERT_TRUE(fileSizeCap.isValid()); + + QString error; + EXPECT_FALSE(OracleDatabase::saveXml(path, data, &error)); + EXPECT_FALSE(error.isEmpty()); + ASSERT_TRUE(original.open(QIODevice::ReadOnly)); EXPECT_EQ(original.readAll(), "installed database"); } diff --git a/tests/spoiler_file_test.cpp b/tests/spoiler_file_test.cpp index 9944f95bd..ecdc3285f 100644 --- a/tests/spoiler_file_test.cpp +++ b/tests/spoiler_file_test.cpp @@ -1,4 +1,5 @@ #include "client/network/update/card_spoiler/spoiler_file.h" +#include "support/scoped_file_size_limit.h" #include #include @@ -6,12 +7,6 @@ #include #include -#ifdef Q_OS_UNIX -#include -#include -#include -#endif - namespace { class SpoilerFileTest : public ::testing::Test @@ -123,25 +118,16 @@ TEST_F(SpoilerFileTest, FileInParentPathReportsFailureWithoutModifyingIt) #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), ""); + // A real short write exercises QSaveFile's buffered flush failure without depending + // on disk capacity. The cap is scoped rather than forked, because a death-test child + // under RLIMIT_FSIZE is killed by SIGSEGV on macOS. + test_support::ScopedFileSizeLimit fileSizeCap(128); + ASSERT_TRUE(fileSizeCap.isValid()); + + EXPECT_EQ(save(changed), SpoilerFile::SaveResult::Failed); + EXPECT_FALSE(error.isEmpty()); EXPECT_EQ(read(), original); EXPECT_EQ(QDir(directory.path()).entryList(QDir::Files), QStringList{"spoiler.xml"}); } diff --git a/tests/support/scoped_file_size_limit.h b/tests/support/scoped_file_size_limit.h new file mode 100644 index 000000000..1c1f3d939 --- /dev/null +++ b/tests/support/scoped_file_size_limit.h @@ -0,0 +1,83 @@ +#ifndef COCKATRICE_TEST_SCOPED_FILE_SIZE_LIMIT_H +#define COCKATRICE_TEST_SCOPED_FILE_SIZE_LIMIT_H + +#include + +#ifdef Q_OS_UNIX + +#include +#include +#include + +namespace test_support +{ +/** + * @brief Caps how large a single file may grow, so a writer's short-write path is reachable. + * + * Lowering RLIMIT_FSIZE makes the next write past the cap fail with EFBIG instead of + * filling a filesystem, which is the only way to fail an install step mid-write without + * depending on free disk space or file ownership. Only the soft limit is lowered, so the + * destructor can hand the process back exactly as it found it: ctest runs one case per + * process, but a whole test binary must not carry the cap into its neighbours. + * + * Do not use this inside a gtest death test. A forked child under RLIMIT_FSIZE is killed + * by SIGSEGV on the macOS runners, while the same case passes on Linux, so the fork is + * what breaks - not the writer. The guard alone isolates the failure just as well. + */ +class ScopedFileSizeLimit +{ +public: + explicit ScopedFileSizeLimit(rlim_t maxBytes) + { + if (getrlimit(RLIMIT_FSIZE, &previousLimit) != 0) { + return; + } + + struct sigaction ignore = {}; + ignore.sa_handler = SIG_IGN; + sigemptyset(&ignore.sa_mask); + if (sigaction(SIGXFSZ, &ignore, &previousAction) != 0) { + return; + } + actionSaved = true; + + struct rlimit capped = previousLimit; + capped.rlim_cur = std::min(maxBytes, previousLimit.rlim_max); + if (setrlimit(RLIMIT_FSIZE, &capped) != 0) { + return; + } + active = true; + } + + ~ScopedFileSizeLimit() + { + if (active) { + setrlimit(RLIMIT_FSIZE, &previousLimit); + } + if (actionSaved) { + sigaction(SIGXFSZ, &previousAction, nullptr); + } + } + + ScopedFileSizeLimit(const ScopedFileSizeLimit &) = delete; + ScopedFileSizeLimit &operator=(const ScopedFileSizeLimit &) = delete; + + /** + * @brief Whether the cap is in force; a test that needs the failure must assert this. + */ + [[nodiscard]] bool isValid() const + { + return active; + } + +private: + bool active = false; + bool actionSaved = false; + struct rlimit previousLimit = {}; + struct sigaction previousAction = {}; +}; +} // namespace test_support + +#endif // Q_OS_UNIX + +#endif // COCKATRICE_TEST_SCOPED_FILE_SIZE_LIMIT_H diff --git a/tests/svg_recolor_test.cpp b/tests/svg_recolor_test.cpp new file mode 100644 index 000000000..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); +}