diff --git a/.ci/Arch/Dockerfile b/.ci/Arch/Dockerfile index 36cf5c4ae..b08e568f3 100644 --- a/.ci/Arch/Dockerfile +++ b/.ci/Arch/Dockerfile @@ -8,10 +8,13 @@ RUN pacman --sync --refresh --sysupgrade --needed --noconfirm \ gtest \ mariadb-libs \ ninja \ + openssl \ protobuf \ qt6-base \ + qt6-declarative \ qt6-imageformats \ qt6-multimedia \ + qt6-shadertools \ qt6-svg \ qt6-tools \ qt6-translations \ diff --git a/.ci/Debian12/Dockerfile b/.ci/Debian12/Dockerfile index 202405b84..fc756aac2 100644 --- a/.ci/Debian12/Dockerfile +++ b/.ci/Debian12/Dockerfile @@ -15,11 +15,14 @@ RUN apt-get update && \ libprotobuf-dev \ libqt6multimedia6 \ libqt6sql6-mysql \ + libssl-dev \ ninja-build \ protobuf-compiler \ + qt6-declarative-dev \ qt6-image-formats-plugins \ qt6-l10n-tools \ qt6-multimedia-dev \ + qt6-shadertools-dev \ qt6-svg-dev \ qt6-tools-dev \ qt6-tools-dev-tools \ diff --git a/.ci/Debian13/Dockerfile b/.ci/Debian13/Dockerfile index d7ab6ac86..bdecb56df 100644 --- a/.ci/Debian13/Dockerfile +++ b/.ci/Debian13/Dockerfile @@ -16,11 +16,14 @@ RUN apt-get update && \ libprotobuf-dev \ libqt6multimedia6 \ libqt6sql6-mysql \ + libssl-dev \ ninja-build \ protobuf-compiler \ + qt6-declarative-dev \ qt6-image-formats-plugins \ qt6-l10n-tools \ qt6-multimedia-dev \ + qt6-shadertools-dev \ qt6-svg-dev \ qt6-tools-dev \ qt6-tools-dev-tools \ diff --git a/.ci/Fedora43/Dockerfile b/.ci/Fedora43/Dockerfile index 27570cf99..463da5a51 100644 --- a/.ci/Fedora43/Dockerfile +++ b/.ci/Fedora43/Dockerfile @@ -7,8 +7,9 @@ RUN dnf install -y \ git \ mariadb-devel \ ninja-build \ + openssl-devel \ protobuf-devel \ - qt6-{qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \ + qt6-{qtdeclarative,qtmultimedia,qtshadertools,qtsvg,qttools,qtwebsockets}-devel \ qt6-qtimageformats \ rpm-build \ xz-devel \ diff --git a/.ci/Fedora44/Dockerfile b/.ci/Fedora44/Dockerfile index e6c8da7f3..62238e760 100644 --- a/.ci/Fedora44/Dockerfile +++ b/.ci/Fedora44/Dockerfile @@ -7,8 +7,9 @@ RUN dnf install -y \ git \ mariadb-devel \ ninja-build \ + openssl-devel \ protobuf-devel \ - qt6-{qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \ + qt6-{qtdeclarative,qtmultimedia,qtshadertools,qtsvg,qttools,qtwebsockets}-devel \ qt6-qtimageformats \ rpm-build \ xz-devel \ diff --git a/.ci/Servatrice_Debian12/Dockerfile b/.ci/Servatrice_Debian12/Dockerfile index 21f6a036e..321aa7c0f 100644 --- a/.ci/Servatrice_Debian12/Dockerfile +++ b/.ci/Servatrice_Debian12/Dockerfile @@ -12,6 +12,7 @@ RUN apt-get update && \ libmariadb-dev-compat \ libprotobuf-dev \ libqt6sql6-mysql \ + libssl-dev \ ninja-build \ protobuf-compiler \ qt6-tools-dev \ diff --git a/.ci/Ubuntu24.04/Dockerfile b/.ci/Ubuntu24.04/Dockerfile index 809b2e43a..715997474 100644 --- a/.ci/Ubuntu24.04/Dockerfile +++ b/.ci/Ubuntu24.04/Dockerfile @@ -15,11 +15,14 @@ RUN apt-get update && \ libprotobuf-dev \ libqt6multimedia6 \ libqt6sql6-mysql \ + libssl-dev \ ninja-build \ protobuf-compiler \ + qt6-declarative-dev \ qt6-image-formats-plugins \ qt6-l10n-tools \ qt6-multimedia-dev \ + qt6-shadertools-dev \ qt6-svg-dev \ qt6-tools-dev \ qt6-tools-dev-tools \ diff --git a/.ci/Ubuntu26.04/Dockerfile b/.ci/Ubuntu26.04/Dockerfile index 7b0cd389f..3166b7eb4 100644 --- a/.ci/Ubuntu26.04/Dockerfile +++ b/.ci/Ubuntu26.04/Dockerfile @@ -9,6 +9,7 @@ RUN apt-get update && \ cmake \ file \ g++ \ + gcovr \ git \ libgl-dev \ liblzma-dev \ @@ -16,11 +17,14 @@ RUN apt-get update && \ libprotobuf-dev \ libqt6multimedia6 \ libqt6sql6-mysql \ + libssl-dev \ ninja-build \ protobuf-compiler \ + qt6-declarative-dev \ qt6-image-formats-plugins \ qt6-l10n-tools \ qt6-multimedia-dev \ + qt6-shadertools-dev \ qt6-svg-dev \ qt6-tools-dev \ qt6-tools-dev-tools \ diff --git a/.ci/compile.sh b/.ci/compile.sh index 8a16d3243..606b9d201 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -8,14 +8,17 @@ # --suffix renames package with this suffix, requires arg # --server compiles servatrice # --test runs tests +# --sanitize builds with AddressSanitizer and UndefinedBehaviorSanitizer, gcc/clang only +# --coverage instruments the test run for line coverage and prints a gcovr summary, gcc only + # --debug or --release sets the build type ie CMAKE_BUILD_TYPE # --ccache [] uses ccache and shows stats, optionally provide size # --evict-ccache runs ccache eviction based on given age after build # --dir sets the name of the build dir, default is "build" # --cmake-generator sets CMAKE_GENERATOR as used by cmake # --target-macos-version sets the min os version - only used for macOS builds -# uses env: BUILDTYPE MAKE_INSTALL MAKE_PACKAGE PACKAGE_TYPE PACKAGE_SUFFIX MAKE_SERVER MAKE_NO_CLIENT MAKE_TEST USE_CCACHE CCACHE_SIZE CCACHE_EVICTION_AGE BUILD_DIR CMAKE_GENERATOR TARGET_MACOS_VERSION -# (correspond to args: --debug/--release --install --package --suffix --server --test --ccache --dir ) +# uses env: BUILDTYPE MAKE_INSTALL MAKE_PACKAGE PACKAGE_TYPE PACKAGE_SUFFIX MAKE_SERVER MAKE_NO_CLIENT MAKE_TEST USE_CCACHE CCACHE_SIZE CCACHE_EVICTION_AGE BUILD_DIR CMAKE_GENERATOR TARGET_MACOS_VERSION MAKE_SANITIZE MAKE_COVERAGE +# (correspond to args: --debug/--release --install --package --suffix --server --test --sanitize --coverage --ccache --dir ) # exitcode: 1 for failure, 3 for invalid arguments # Read arguments @@ -57,6 +60,14 @@ while [[ $# != 0 ]]; do MAKE_TEST=1 shift ;; + '--sanitize') + MAKE_SANITIZE=1 + shift + ;; + '--coverage') + MAKE_COVERAGE=1 + shift + ;; '--debug') BUILDTYPE="Debug" shift @@ -131,6 +142,15 @@ if [[ ! $BUILD_DIR ]]; then BUILD_DIR="build" fi mkdir -p "$BUILD_DIR" + +if [[ $MAKE_COVERAGE ]]; then + # Resolved while the working directory is still the source root. $0 is relative to the + # directory the script was invoked from, so after the cd below it no longer resolves, and + # under `set -e` the failed subshell would abort the build rather than just yielding an empty + # variable. Only the coverage report needs this, so only coverage pays for it. + SRC_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +fi + cd "$BUILD_DIR" # Set minimum CMake Version @@ -147,8 +167,22 @@ fi if [[ $MAKE_TEST ]]; then flags+=("-DTEST=1") fi +if [[ $MAKE_SANITIZE ]]; then + flags+=("-DENABLE_SANITIZERS=ON") +else + # The release-package step re-runs cmake in the same build dir, so an + # ENABLE_SANITIZERS=ON left over from the debug step would otherwise leak + # into the shipped binaries (libasan/libubsan Depends in the DEB). + flags+=("-DENABLE_SANITIZERS=OFF") +fi +if [[ $MAKE_COVERAGE ]]; then + flags+=("-DENABLE_COVERAGE=ON") +fi if [[ $USE_CCACHE ]]; then flags+=("-DUSE_CCACHE=1") + # PCH-aware caching is required or ccache refuses to cache any TU that + # consumes a precompiled header, silently recompiling everything on every run. + ccache --set-config sloppiness=pch_defines,time_macros if [[ $CCACHE_SIZE ]]; then # note, this setting persists after running the script ccache --max-size "$CCACHE_SIZE" @@ -303,7 +337,50 @@ fi if [[ $MAKE_TEST ]]; then echo "::group::Run tests" - ctest -C "$BUILDTYPE" --output-on-failure + if [[ $MAKE_SANITIZE ]]; then + # detect_leaks=0: Qt and the sanitizers' own runtimes both leak at exit by design, and + # LeakSanitizer's default is aggressive enough to bury our real findings under hundreds + # of process-teardown reports. Malloc/free misuse and use-after-free are what ASan is for + # and they stay fully on. + # abort_on_error=1: a bad access leaves the process in an unusable state, so continuing + # only produces a second, more confusing report from the wreckage. + # UBSAN halt_on_error=1: without it UBSan prints "runtime error: ..." but the + # process exits 0 (recover mode), so gtest/ctest report a pass and the job + # stays green. Combined with -fno-sanitize-recover=undefined at compile time, + # the first finding aborts the test process with a non-zero exit. + export ASAN_OPTIONS="detect_leaks=0:abort_on_error=1:print_stacktrace=1:symbolize=1" + export UBSAN_OPTIONS="print_stacktrace=1:halt_on_error=1:symbolize=1" + fi + # Every TEST() is its own CTest entry, so the suite is process-spawn bound rather than CPU + # bound and -j recovers nearly all of the per-process overhead. The test binaries are safe to + # run concurrently: the only one that claims a fixed resource is single_instance_manager_test, + # which already scopes its local socket name to its own pid so parallel copies cannot collide. + ctest -C "$BUILDTYPE" --output-on-failure -j "${CTEST_JOBS:-4}" + echo "::endgroup::" +fi + +if [[ $MAKE_COVERAGE ]]; then + # gcovr is not a build dependency and is not available everywhere, so a missing one is reported + # and skipped rather than failing an otherwise green build. + echo "::group::Coverage report" + GCOVR="$(command -v gcovr || true)" + if [[ -z $GCOVR ]]; then + echo "gcovr not found; install it to produce a coverage report" + else + "$GCOVR" --version + # This script configures with `cmake ..`, so the build dir it was started from is the object + # directory and the source root is one level up. Both are passed explicitly: gcovr otherwise + # walks the current directory and trips over in-tree build directories left by other tooling, + # whose stale .gcno files cannot be resolved. --config is named too, so the filters apply no + # matter what directory gcovr happens to start from. + GCOVR_ARGS=("." --root "$SRC_ROOT" --object-directory "." --config "$SRC_ROOT/gcovr.cfg") + "$GCOVR" "${GCOVR_ARGS[@]}" --txt --decisions + "$GCOVR" "${GCOVR_ARGS[@]}" --html-details coverage-details.html --html-title "Cockatrice line coverage" + "$GCOVR" "${GCOVR_ARGS[@]}" --lcov -o coverage.lcov + "$GCOVR" "${GCOVR_ARGS[@]}" --cobertura-pretty -o coverage.xml + "$GCOVR" "${GCOVR_ARGS[@]}" --json-summary-pretty -o coverage-summary.json + echo "Coverage artifacts written to coverage-details.html, coverage.lcov, coverage.xml and coverage-summary.json" + fi echo "::endgroup::" fi @@ -324,4 +401,153 @@ if [[ $MAKE_PACKAGE ]]; then BUILD_DIR="$BUILD_DIR" .ci/name_build.sh "$PACKAGE_SUFFIX" echo "::endgroup::" fi + + if [[ $RUNNER_OS == Windows ]]; then + echo "::group::Check installer for build-tree artifacts" + cd "$BUILD_DIR" + package="$(find . -maxdepth 1 -type f -name 'Cockatrice-*.exe' -print -quit)" + if [[ ! $package ]]; then + echo "::error file=$0::Could not find installer to inspect" + exit 1 + fi + seven_zip="$(command -v 7z || true)" + if [[ ! $seven_zip ]]; then + seven_zip="/c/Program Files/7-Zip/7z.exe" + fi + if [[ ! -f $seven_zip ]]; then + echo "::warning file=$0::7-Zip not found, skipping installer content check" + else + echo "Inspecting $package" + # Fail the build if the installer contains any path left behind by the MSBuild or + # Qt AUTOMOC tooling (build-tree artifacts must live in the build dir, not the install) + if "$seven_zip" l "$package" | + grep -E "_autogen|\.dir[\\/]|\.tlog|(^|[\\/])x64[\\/]|(^|[\\/])\.qt[\\/]|(^|[\\/])\.qsb[\\/]|(^|[\\/])\.lupdate[\\/]|CMakeFiles"; then + echo "::error file=$0::Installer contains build-tree artifacts" + exit 1 + fi + echo "Installer content is clean" + + # Fail the build if the installer does not carry the runtime the + # applications link against - a file that must be there, or a DLL that must + # not be. The runtime DLLs reach the package by globbing every *.dll out of + # the build output directories, so anything that build produced can ride + # along and nothing else in the build objects to it: a Qt module the client + # links statically (Qt6Multimedia.dll, Qt6QuickWidgets.dll) can be left out, + # and the test framework's gtest.dll can be shipped. Either only surfaces on + # a user's machine after an update has already replaced their install, as + # "The code execution cannot proceed because Qt6Multimedia.dll was not + # found" or "abseil_dll.dll is either not designed to run on Windows or it + # contains an error". required-runtime.txt is written by the top level + # CMakeLists.txt from the same module lists the targets are built against + # plus the vcpkg bin directory the app-local copies come from. + if [[ ! -f required-runtime.txt ]]; then + echo "::error file=$0::required-runtime.txt not found in the build dir, cannot verify the runtime" + exit 1 + fi + # 7-Zip writes CRLF on Windows, so the \r has to come off before any + # whole-line comparison below. Normalize separators to /, and strip the + # install-root prefixes an NSIS listing may carry, so the entries can be + # compared as exact relative paths. -xF keeps a '.' in a DLL name from + # being a wildcard; -i because that is how the installed tree behaves - + # the uninstaller clears both $INSTDIR\plugins and $INSTDIR\Plugins. + # The archive side and the manifest side have to agree on line endings. + # CMake writes required-runtime.txt with the platform's line ending and + # 7-Zip writes CRLF on Windows, so a trailing \r survives on $entry and + # defeats the whole-line match - every entry then reads as missing even + # though the listing holds it verbatim. + # [$] rather than \$ so the literal dollar is a bracket expression and the + # linter does not read these as unexpanded shell variables (SC2016). + installer_paths="$("$seven_zip" l -slt "$package" | tr -d '\r' | + sed -n 's/^Path = //p' | + sed -e 's|\\|/|g' -e 's|^[$]INSTDIR/||' -e 's|^[$]OUTDIR/||' -e 's|^[$]PLUGINSDIR/||' -e 's|^\./||' -e 's|^/||')" + manifest="$(tr -d '\r' sets the location to cache the image or for ccache # # requires: docker -# uses env: NAME CACHE BUILD GET SAVE INTERACTIVE +# uses env: NAME IMAGE_SUFFIX CACHE BUILD GET SAVE INTERACTIVE # (correspond to args: --set-cache --build --get --save --interactive) # sets env: RUN CCACHE_DIR IMAGE_NAME RUN_ARGS RUN_OPTS BUILD_SCRIPT # exitcode: 1 for failure, 2 for missing dockerfile, 3 for invalid arguments # +# IMAGE_SUFFIX is optional and only appended to the image name, never to NAME, so that a matrix +# leg can have its own image and image cache without needing its own directory under .ci. Two legs +# building and tagging the same image concurrently would race on `docker build --tag`. +# It is deliberately not called SUFFIX: compile.sh already uses SUFFIX for output file names, and +# a job-level SUFFIX would be silently overridden by those per-step env blocks. +# # exported RUN function will run the BUILD_SCRIPT inside of the docker container. # note that the docker container will not inherit any environment variables! # @@ -81,7 +87,7 @@ if ! [[ $NAME ]]; then return 3 fi -export IMAGE_NAME="${project_name,,}_${NAME,,}" # lower case +export IMAGE_NAME="${project_name,,}_${NAME,,}${IMAGE_SUFFIX:+_${IMAGE_SUFFIX,,}}" # lower case docker_dir=".ci/$NAME" if ! [[ -r $docker_dir/Dockerfile ]]; then diff --git a/.ci/release_template.md b/.ci/release_template.md index ac78a193a..23b475150 100644 --- a/.ci/release_template.md +++ b/.ci/release_template.md @@ -12,9 +12,9 @@ Available pre-compiled binaries for installation: • Windows 10+ macOS - • macOS 15+ Sequoia Apple M - • macOS 14+ Sonoma Apple M - • macOS 13+ Ventura Intel + • macOS 15+ Sequoia + • macOS 14+ Sonoma + • macOS 13+ Ventura (x86) Linux • Ubuntu 26.04 LTS Resolute Racoon @@ -24,10 +24,10 @@ Available pre-compiled binaries for installation: • Fedora 44 • Fedora 43 -We are also packaged in Arch Linux's official extra repository, courtesy of @FFY00. -General Linux support is available via a flatpak package at Flathub! + 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. -We provide a Docker image for "Servatrice" in GHCR. You can docker pull it or use our Docker Compose files! + We maintain a Docker image for "Servatrice" in GHCR (x86 & ARM). You can docker pull it or use our Docker Compose files! diff --git a/.dockerignore b/.dockerignore index 2abeb2727..8e0b69332 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,3 @@ -.git/ build/ .github/ .tx/ diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..75fbc59f1 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,95 @@ +# GitHub Docs on Code Scanning: +# https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning +# https://docs.github.com/en/code-security/how-tos/find-and-fix-code-vulnerabilities/manage-your-configuration +# https://docs.github.com/en/code-security/reference/code-scanning/workflow-configuration-options +# +# CodeQL Docs: +# https://codeql.github.com/docs/ + +name: CodeQL + +permissions: + contents: read + security-events: write # needed to post results + +on: + push: + branches: + - master + pull_request: + +# Cancel earlier, unfinished runs of this workflow on the same branch +concurrency: + group: "${{ github.workflow }} @ ${{ github.ref_name }}" + cancel-in-progress: true + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + include: + # https://codeql.github.com/docs/codeql-overview/supported-languages-and-frameworks/ + - language: cpp + build-mode: manual + - language: actions + build-mode: none + + steps: + - name: "Checkout repository" + uses: actions/checkout@v7 + + - name: "Initialize CodeQL" + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # https://docs.github.com/en/code-security/reference/code-scanning/codeql/codeql-queries/c-cpp-built-in-queries + # https://docs.github.com/en/code-security/reference/code-scanning/codeql/codeql-queries/actions-built-in-queries + queries: security-extended + dependency-caching: true + + - name: "[C++] Install dependencies" + if: matrix.language == 'cpp' && matrix.build-mode == 'manual' + shell: bash + env: + DEBIAN_FRONTEND: noninteractive + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + cmake \ + g++ \ + libgl-dev \ + liblzma-dev \ + libmariadb-dev-compat \ + libprotobuf-dev \ + ninja-build \ + protobuf-compiler \ + qt6-declarative-dev \ + qt6-multimedia-dev \ + qt6-shadertools-dev \ + qt6-svg-dev \ + qt6-tools-dev \ + qt6-tools-dev-tools \ + qt6-websockets-dev + +# Minimize dependency install +# Add ccache usage for faster compilation, (install ccache dep, actions/cache step + append DUSE_CCACHE=1 in cmake config, CCACHE env values) + + - name: "[C++] Configure CMake" + if: matrix.language == 'cpp' && matrix.build-mode == 'manual' + shell: bash + run: cmake -S . -B build -G Ninja -DWITH_SERVER=1 -DCMAKE_BUILD_TYPE=Release + + - name: "[C++] Build application" + if: matrix.language == 'cpp' && matrix.build-mode == 'manual' + shell: bash + run: cmake --build build + + - name: "Perform CodeQL Analysis" + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index f1846ecf6..098aabc4f 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -3,7 +3,7 @@ name: Build Desktop permissions: actions: write # needed to delete entries in GHA cache (update ccache) attestations: write # needed to persist the attestation. - contents: write + contents: write # needed for e.g. vcpkg dependency graph updates id-token: write # needed for signing certificate in attestation on: @@ -34,7 +34,7 @@ on: - 'vcpkg.json' - 'vcpkg' # needed to match submodule bumps (gitlink) -# Cancel earlier, unfinished runs of this workflow on the same branch (unless on release) +# Cancel earlier, unfinished runs of this workflow on the same branch (unless on tag --> release) concurrency: group: "${{ github.workflow }} @ ${{ github.ref_name }}" cancel-in-progress: ${{ github.ref_type != 'tag' }} @@ -42,7 +42,7 @@ concurrency: jobs: configure: name: Configure - runs-on: ubuntu-slim + runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md outputs: tag: ${{ steps.configure.outputs.tag }} sha: ${{ steps.configure.outputs.sha }} @@ -144,31 +144,47 @@ jobs: package: DEB - name: ${{ matrix.distro }} ${{ matrix.version }} + # The one leg that runs the suite under AddressSanitizer and + # UndefinedBehaviorSanitizer. Debian 13 and Fedora 44 keep covering the + # uninstrumented suite, so nothing is lost by instrumenting this one. + sanitize: yes + + - distro: Ubuntu + version: 26.04 + variant: cover # separate image and ccache, same distro + + # The one leg that runs instrumented for line coverage and publishes the report as + # a workflow artifact. It is a separate leg rather than a second flag on the + # sanitizer leg because the two instruments answer different questions: the + # sanitizer leg reports what the run did wrong, the coverage leg reports what the + # run never touched at all. Combining them would also slow both down. Packaging is + # skipped because the leg above already publishes the DEB for this distro. + coverage: yes + package: skip + + name: ${{ matrix.distro }} ${{ matrix.version }}${{ matrix.variant && format(' {0}', matrix.variant) || '' }} needs: configure - runs-on: ubuntu-latest + runs-on: ubuntu-latest # https://github.com/actions/runner-images continue-on-error: ${{ matrix.allow-failure == 'yes' }} timeout-minutes: 70 env: - CACHE: ${{ github.workspace }}/.cache/${{ matrix.distro }}${{ matrix.version }} # directory for caching docker image and ccache + CACHE: ${{ github.workspace }}/.cache/${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }} # directory for caching docker image and ccache CCACHE_EVICTION_AGE: 7d - CCACHE_SIZE: 550M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy + CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy CMAKE_GENERATOR: 'Ninja' NAME: ${{ matrix.distro }}${{ matrix.version }} + IMAGE_SUFFIX: ${{ matrix.variant }} steps: - name: "Checkout" uses: actions/checkout@v7 - name: "Restore compiler cache (ccache)" - id: ccache_restore + id: restore_ccache uses: actions/cache/restore@v6 - env: - BRANCH_NAME: ${{ github.head_ref || github.ref_name }} with: - key: ccache-${{ matrix.distro }}${{ matrix.version }}-${{ env.BRANCH_NAME }} + key: ccache-${{ matrix.distro }}${{ matrix.version }}${{ matrix.variant }} path: ${{ env.CACHE }} - restore-keys: ccache-${{ matrix.distro }}${{ matrix.version }}- - name: "Build ${{ matrix.distro }} ${{ matrix.version }} Docker image" shell: bash @@ -177,10 +193,43 @@ jobs: - name: "Build debug and test" if: matrix.test != 'skip' shell: bash + env: + sanitize: '${{ matrix.sanitize }}' + coverage: '${{ matrix.coverage }}' run: | source .ci/docker.sh - RUN --server --debug --test --ccache "$CCACHE_SIZE" \ - --cmake-generator "$CMAKE_GENERATOR" + args=() + [[ $GITHUB_REF == "refs/heads/master" ]] && args+=(--evict-ccache "$CCACHE_EVICTION_AGE") + args+=(--ccache "$CCACHE_SIZE") + args+=(--cmake-generator "$CMAKE_GENERATOR") + + extra=() + [[ $sanitize == yes ]] && extra+=(--sanitize) + [[ $coverage == yes ]] && extra+=(--coverage) + + RUN --server --debug --test "${extra[@]}" "${args[@]}" + + # RUN bind-mounts the workspace into the container, so the report the build wrote into + # build/ is already on the host and no second container is needed to collect it. + - name: "Collect coverage report" + if: matrix.coverage == 'yes' + shell: bash + run: | + mkdir -p coverage + # --html-details writes one page per source file plus a stylesheet and script, and the + # index links to all of them by relative name. Copying the index alone would publish a + # report where every link 404s, so the whole family has to travel together. + cp build/coverage-details* coverage/ + cp build/coverage.lcov build/coverage.xml build/coverage-summary.json coverage/ + ls -la coverage/ + + - name: "Upload coverage artifact" + if: matrix.coverage == 'yes' + uses: actions/upload-artifact@v4 + with: + name: coverage-report + path: coverage/ + if-no-files-found: warn - name: "Build release package" id: build @@ -203,10 +252,10 @@ jobs: # Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342 - name: "Delete remote compiler cache (ccache)" - if: github.ref == 'refs/heads/master' && steps.ccache_restore.outputs.cache-hit + if: github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit continue-on-error: true env: - CACHE_PRIMARY_KEY: ${{ steps.ccache_restore.outputs.cache-primary-key }} + CACHE_PRIMARY_KEY: ${{ steps.restore_ccache.outputs.cache-primary-key }} GH_TOKEN: ${{ github.token }} run: | if gh cache delete --repo "$GITHUB_REPOSITORY" "$CACHE_PRIMARY_KEY"; then @@ -217,7 +266,7 @@ jobs: if: github.ref == 'refs/heads/master' uses: actions/cache/save@v6 with: - key: ${{ steps.ccache_restore.outputs.cache-primary-key }} + key: ${{ steps.restore_ccache.outputs.cache-primary-key }} path: ${{ env.CACHE }} - name: "Upload artifact" @@ -262,74 +311,77 @@ jobs: matrix: include: - os: macOS - target: 13 - runner: macos-15-intel + target: 13 # EOL 2025-09-15 + runner: macos-15-intel # https://github.com/actions/runner-images/blob/main/images/macos/macos-15-Readme.md + # macos-26-intel is very slow and fails in CPack during DMG config if not increasing Finder timeout ccache_eviction_age: 7d cmake_generator: Ninja make_package: 1 override_target: 13 package_suffix: "-macOS13_Intel" - qt_version: 6.11.0 - qt_modules: qtimageformats qtmultimedia qtwebsockets + qt_version: 6.11.* + qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets soc: Intel type: Release use_ccache: 1 - xcode: "16.4" + xcode: "26.3" - os: macOS - target: 14 - runner: macos-14 + target: 14 # EOL 2026-?? + runner: macos-26 # https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md ccache_eviction_age: 7d cmake_generator: Ninja make_package: 1 + override_target: 14 package_suffix: "-macOS14" - qt_version: 6.11.0 - qt_modules: qtimageformats qtmultimedia qtwebsockets + qt_version: 6.11.* + qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets soc: Apple type: Release use_ccache: 1 - xcode: "15.4" + xcode: "26.6" - os: macOS target: 15 - runner: macos-15 + runner: macos-26 # https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md ccache_eviction_age: 7d cmake_generator: Ninja make_package: 1 + override_target: 15 package_suffix: "-macOS15" - qt_version: 6.11.0 - qt_modules: qtimageformats qtmultimedia qtwebsockets + qt_version: 6.11.* + qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets soc: Apple type: Release use_ccache: 1 - xcode: "16.4" + xcode: "26.6" - os: macOS - target: 15 - runner: macos-15 + target: 26 + runner: macos-26 # https://github.com/actions/runner-images/blob/main/images/macos/macos-26-arm64-Readme.md ccache_eviction_age: 7d cmake_generator: Ninja - qt_version: 6.11.0 - qt_modules: qtimageformats qtmultimedia qtwebsockets + qt_version: 6.11.* + qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets soc: Apple type: Debug use_ccache: 1 - xcode: "16.4" + xcode: "26.6" - os: Windows target: 10 - runner: windows-2025 + runner: windows-2025 # https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-VS2026-Readme.md cmake_generator: "Visual Studio 18 2026" cmake_generator_platform: x64 make_package: 1 package_suffix: "-Win10" - qt_version: 6.11.0 - qt_modules: qtimageformats qtmultimedia qtwebsockets + qt_version: 6.11.* + qt_modules: qtimageformats qtmultimedia qtshadertools qtwebsockets type: Release name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }} @@ -338,7 +390,7 @@ jobs: timeout-minutes: 100 env: CCACHE_DIR: ${{ github.workspace }}/.cache/ - CCACHE_SIZE: 550M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy + CCACHE_SIZE: 600M # space of all repo is 10Gi: https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#usage-limits-and-eviction-policy steps: - name: "Checkout" @@ -348,7 +400,6 @@ jobs: - name: "[Windows] Add msbuild to PATH" if: matrix.os == 'Windows' - id: add-msbuild uses: microsoft/setup-msbuild@v3 with: msbuild-architecture: x64 @@ -359,54 +410,54 @@ jobs: - name: "[macOS] Restore compiler cache (ccache)" if: matrix.os == 'macOS' && matrix.use_ccache == 1 - id: ccache_restore + id: restore_ccache uses: actions/cache/restore@v6 - env: - BRANCH_NAME: ${{ github.head_ref || github.ref_name }} with: - key: ccache-${{ matrix.runner }}-${{ matrix.soc }}-${{ matrix.type }}-${{ env.BRANCH_NAME }} + key: ccache-${{ matrix.runner }}_${{ matrix.override_target }}-Xcode${{ matrix.xcode }} path: ${{ env.CCACHE_DIR }} - restore-keys: ccache-${{ matrix.runner }}-${{ matrix.soc }}-${{ matrix.type }}- - - name: "Install aqtinstall" + - name: "[macOS] Install aqtinstall" + if: matrix.os == 'macOS' run: pipx install aqtinstall # Resolve given wildcard versions (e.g. Qt 6.6.*) to latest version via aqtinstall to avoid stale caches on new releases - - name: "Resolve latest Qt patch version" + - name: "[macOS] Resolve latest Qt from ${{ matrix.qt_version }} input" + if: matrix.os == 'macOS' env: QT_VERSION: ${{ matrix.qt_version }} id: resolve_qt_version shell: bash run: .ci/resolve_latest_aqt_qt_version.sh "$QT_VERSION" - - name: "[macOS] Restore thin Qt ${{ steps.resolve_qt_version.outputs.version }} libraries" + - name: "[macOS] Restore thin Qt ${{ steps.resolve_qt_version.outputs.version }}" if: matrix.os == 'macOS' id: restore_qt uses: actions/cache/restore@v6 with: - key: thin-qt-macos-${{ matrix.soc }}-${{ steps.resolve_qt_version.outputs.version }} + key: Qt-${{ steps.resolve_qt_version.outputs.version }}-macOS-${{ matrix.soc }}-${{ matrix.qt_modules }}-thin path: ${{ github.workspace }}/Qt # Using jurplel/install-qt-action to install Qt without using brew # Qt build using vcpkg either just fails or takes too long to build - - name: "[macOS] Install fat Qt ${{ steps.resolve_qt_version.outputs.version }}" + - name: "[macOS] Install fat Qt ${{ matrix.qt_version }}" if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true' uses: jurplel/install-qt-action@v4 with: cache: false - dir: ${{ github.workspace }} + # cache-key-prefix: Qt + dir: ${{ github.workspace }} # thinning script depends on this location modules: ${{ matrix.qt_modules }} - version: ${{ steps.resolve_qt_version.outputs.version }} + version: ${{ matrix.qt_version }} - name: "[macOS] Create thin Qt libraries" if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true' run: .ci/thin_macos_qtlib.sh - name: "[macOS] Cache thin Qt libraries" - if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true' + if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true' && github.ref == 'refs/heads/master' uses: actions/cache/save@v6 with: - key: thin-qt-macos-${{ matrix.soc }}-${{ steps.resolve_qt_version.outputs.version }} + key: ${{ steps.restore_qt.outputs.cache-primary-key }} path: ${{ github.workspace }}/Qt - name: "[Windows] Install Qt ${{ matrix.qt_version }}" @@ -415,9 +466,10 @@ jobs: with: # Qt 6.11.0 only works with aqtinstall directly from git until aqtinstall 3.4 is released aqtsource: git+https://github.com/miurahr/aqtinstall.git - cache: true + cache: ${{ github.ref == 'refs/heads/master' }} + cache-key-prefix: Qt modules: ${{ matrix.qt_modules }} - version: ${{ steps.resolve_qt_version.outputs.version }} + version: ${{ matrix.qt_version }} - name: "[Windows] Install NSIS" if: matrix.os == 'Windows' @@ -440,6 +492,7 @@ jobs: CMAKE_GENERATOR: ${{ matrix.cmake_generator }} CMAKE_GENERATOR_PLATFORM: ${{ matrix.cmake_generator_platform }} DEVELOPER_DIR: '/Applications/Xcode_${{ matrix.xcode }}.app/Contents/Developer' + GITHUB_TOKEN: ${{ github.token }} # needed for vcpkg dependency graph updates, see VCPKG_FEATURE_FLAGS MACOS_CERTIFICATE: ${{ secrets.PROD_MACOS_CERTIFICATE }} MACOS_CERTIFICATE_NAME: ${{ secrets.PROD_MACOS_CERTIFICATE_NAME }} MACOS_CERTIFICATE_PWD: ${{ secrets.PROD_MACOS_CERTIFICATE_PWD }} @@ -448,27 +501,39 @@ jobs: PACKAGE_SUFFIX: '${{ matrix.package_suffix }}' TARGET_MACOS_VERSION: ${{ matrix.override_target }} USE_CCACHE: ${{ matrix.use_ccache }} - VCPKG_BINARY_SOURCES: 'clear;files,${{ steps.vcpkg-cache.outputs.path }},readwrite' + VCPKG_BINARY_SOURCES: "clear;files,${{ steps.vcpkg-cache.outputs.path }},${{ case(github.ref == 'refs/heads/master', 'readwrite', 'read') }}" VCPKG_DISABLE_METRICS: 1 + VCPKG_FEATURE_FLAGS: dependencygraph run: .ci/compile.sh --server --test --vcpkg + # CPack reports a failed makensis run as "Problem running NSIS command" + # plus a path to NSISOutput.log, and that log lives only on the runner, so + # the actual compile error is otherwise nowhere in the workflow output. + # Dump it, or a broken installer script is undiagnosable from CI alone. + # shell: bash matters here - the default pwsh resolves `find` to Windows' + # text-search find.exe, which errors instead of searching. + - name: "Dump NSIS compile log" + if: failure() && matrix.os == 'Windows' + shell: bash + run: find . -name NSISOutput.log -exec cat {} + + # Delete used cache to emulate a ccache update. See https://github.com/actions/cache/issues/342 - name: "[macOS] Delete remote compiler cache (ccache)" - if: matrix.os == 'macOS' && matrix.use_ccache == 1 && github.ref == 'refs/heads/master' && steps.ccache_restore.outputs.cache-hit + if: matrix.os == 'macOS' && matrix.use_ccache == 1 && github.ref == 'refs/heads/master' && steps.restore_ccache.outputs.cache-hit continue-on-error: true env: - CACHE_PRIMARY_KEY: ${{ steps.ccache_restore.outputs.cache-primary-key }} + CACHE_PRIMARY_KEY: ${{ steps.restore_ccache.outputs.cache-primary-key }} GH_TOKEN: ${{ github.token }} run: | if gh cache delete --repo "$GITHUB_REPOSITORY" "$CACHE_PRIMARY_KEY"; then echo "Cache deleted successfully" fi - - name: "[macOS] Save updated compiler cache (ccache)" + - name: "[macOS] Cache updated compiler cache (ccache)" if: matrix.os == 'macOS' && matrix.use_ccache == 1 && github.ref == 'refs/heads/master' uses: actions/cache/save@v6 with: - key: ${{ steps.ccache_restore.outputs.cache-primary-key }} + key: ${{ steps.restore_ccache.outputs.cache-primary-key }} path: ${{ env.CCACHE_DIR }} - name: "[macOS] Sign app bundle" diff --git a/.github/workflows/desktop-lint.yml b/.github/workflows/desktop-lint.yml index 5f31ea59c..93ba79464 100644 --- a/.github/workflows/desktop-lint.yml +++ b/.github/workflows/desktop-lint.yml @@ -1,5 +1,8 @@ name: Code Style (C++) +permissions: + contents: read + on: # Push trigger not needed for linting, we do not allow direct pushes to master pull_request: diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 5384c9e64..937386940 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -1,8 +1,8 @@ -name: Build Docker Image +name: Build Docker permissions: - contents: read - packages: write + contents: read # needed to checkout repo + packages: write # needed for interacting with GHCR on: push: @@ -13,66 +13,185 @@ on: - master paths: - '.github/workflows/docker-release.yml' + - '.dockerignore' - 'Dockerfile' + - 'docker-compose.yml' + - 'docker-compose.yml.windows' release: types: - released # publishing of stable releases + workflow_dispatch: # (re)publish the image for an existing stable release + inputs: + tag: + description: 'Existing release tag to build and publish (e.g. 2026-10-03-Release-3.1.0)' + required: true + type: string + latest: + description: 'Also update the "latest" tag' + required: true + type: boolean + default: true -# Cancel earlier, unfinished runs of this workflow on the same branch (unless on release) +# Cancel earlier, unfinished runs of this workflow on the same branch (unless publishing) concurrency: - group: "${{ github.workflow }} @ ${{ github.ref_name }}" - cancel-in-progress: ${{ github.event_name != 'release' }} + group: "${{ github.workflow }} @ ${{ inputs.tag || github.ref_name }}" + cancel-in-progress: ${{ github.event_name != 'release' && github.event_name != 'workflow_dispatch' }} + +env: + GHCR_IMAGE: ghcr.io/cockatrice/servatrice + OCI_DESCRIPTION: Server for Cockatrice, a cross-platform virtual tabletop for multiplayer card games + OCI_TITLE: Servatrice + OCI_URL: https://cockatrice.github.io/ jobs: - docker: - name: amd64 & arm64 - if: ${{ github.repository_owner == 'Cockatrice' }} - runs-on: ubuntu-latest - + # Create one platform-specific image and publish its OCI image manifest per matrix job + build: + name: "Servatrice (${{ matrix.label }})" + if: github.repository_owner == 'Cockatrice' + runs-on: ${{ matrix.runner }} + + strategy: + fail-fast: false + matrix: + include: + - label: x86 + platform: linux/amd64 + runner: ubuntu-latest # https://github.com/actions/runner-images + + - label: arm + platform: linux/arm64 + runner: ubuntu-24.04-arm # https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2404-Arm64-Readme.md, replace with "ubuntu-latest-arm" once available + + env: + CACHE_SCOPE: servatrice-${{ matrix.label }} + steps: - name: "Checkout" uses: actions/checkout@v7 - - - name: "Docker metadata" - id: metadata - uses: docker/metadata-action@v6 - env: - DOCKER_METADATA_ANNOTATIONS_LEVELS: index # needed for GHCR with: - annotations: | - org.opencontainers.image.title=Servatrice - org.opencontainers.image.url=https://cockatrice.github.io/ - org.opencontainers.image.description=Server for Cockatrice, a cross-platform virtual tabletop for multiplayer card games - images: | - ghcr.io/cockatrice/servatrice - labels: | - org.opencontainers.image.title=Servatrice - org.opencontainers.image.url=https://cockatrice.github.io/ - org.opencontainers.image.description=Server for Cockatrice, a cross-platform virtual tabletop for multiplayer card games - - - name: "Set up QEMU" - uses: docker/setup-qemu-action@v4 + ref: ${{ case(github.event_name == 'workflow_dispatch', format('refs/tags/{0}', inputs.tag), '') }} - name: "Set up Docker buildx" uses: docker/setup-buildx-action@v4 - name: "Login to GitHub Container Registry (GHCR)" - if: github.event_name == 'release' && github.event.release.prerelease == false + if: (github.event_name == 'release' && github.event.release.prerelease == false) || github.event_name == 'workflow_dispatch' id: login uses: docker/login-action@v4 with: - password: ${{ github.token }} registry: ghcr.io username: ${{ github.actor }} + password: ${{ github.token }} - - name: "Build and push Docker image" + # Don't push for non-publishing triggers + - name: "Build image" + if: steps.login.outcome != 'success' uses: docker/build-push-action@v7 with: - annotations: ${{ steps.metadata.outputs.annotations }} - cache-from: type=gha,scope=servatrice - cache-to: type=gha,mode=max,scope=servatrice + cache-from: type=gha,scope=${{ env.CACHE_SCOPE }} + cache-to: ${{ case(github.ref == 'refs/heads/master', format('type=gha,mode=max,scope={0}', env.CACHE_SCOPE), '') }} context: . - labels: ${{ steps.metadata.outputs.labels }} - platforms: linux/amd64,linux/arm64 - push: ${{ steps.login.outcome == 'success' }} - tags: ${{ steps.metadata.outputs.tags }} + platforms: ${{ matrix.platform }} + push: false + + # Add OCI labels and push single-platform image by digest (without tags) + - name: "Build image and push by digest" + if: steps.login.outcome == 'success' + id: build + uses: docker/build-push-action@v7 + with: + cache-from: type=gha,scope=${{ env.CACHE_SCOPE }} + cache-to: type=gha,mode=max,scope=${{ env.CACHE_SCOPE }} + context: . + labels: | + org.opencontainers.image.description=${{ env.OCI_DESCRIPTION }} + org.opencontainers.image.title=${{ env.OCI_TITLE }} + org.opencontainers.image.url=${{ env.OCI_URL }} + outputs: type=image,name=${{ env.GHCR_IMAGE }},name-canonical=true,push=true,push-by-digest=true + platforms: ${{ matrix.platform }} + provenance: mode=max # Do not pass secrets as build arguments with this option + sbom: true + + - name: "Export digest" + if: steps.login.outcome == 'success' + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: | + mkdir -p "$RUNNER_TEMP/digests" + touch "$RUNNER_TEMP/digests/${DIGEST#sha256:}" + + - name: "Upload digest" + if: steps.login.outcome == 'success' + uses: actions/upload-artifact@v7 + with: + if-no-files-found: error + name: digest-${{ matrix.label }} + path: ${{ runner.temp }}/digests/* + retention-days: 1 + + + # Create an OCI image index from the platform-specific image manifests + index: + name: "Publish multi-platform Servatrice image" + if: github.repository_owner == 'Cockatrice' && ((github.event_name == 'release' && github.event.release.prerelease == false) || github.event_name == 'workflow_dispatch') + needs: build + runs-on: ubuntu-slim # https://github.com/actions/runner-images/blob/main/images/ubuntu-slim/ubuntu-slim-Readme.md + + steps: + - name: "Download digests" + uses: actions/download-artifact@v8 + with: + path: ${{ runner.temp }}/digests + pattern: digest-* + merge-multiple: true + + - name: "Login to GitHub Container Registry (GHCR)" + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: "Docker metadata" + id: metadata + uses: docker/metadata-action@v6 + with: + images: ${{ env.GHCR_IMAGE }} + flavor: | + latest=auto + # if semver, also: type=semver,pattern={{version}} / {{major}}.{{minor}} + tags: | + type=ref,event=tag + type=raw,value=${{ inputs.tag }},enable=${{ github.event_name == 'workflow_dispatch' }} + type=raw,value=latest,enable=${{ github.event_name == 'workflow_dispatch' && inputs.latest }} + + # Add OCI annotations to image index and publish tags + - name: "Create image index" + env: + DOCKER_TAGS: ${{ steps.metadata.outputs.tags }} + working-directory: ${{ runner.temp }}/digests + run: | + TAG_ARGS=() + while IFS= read -r tag; do + TAG_ARGS+=(--tag "$tag") + done <<< "$DOCKER_TAGS" + + DIGEST_ARGS=() + for digest in *; do + DIGEST_ARGS+=("$GHCR_IMAGE@sha256:$digest") + done + + docker buildx imagetools create \ + --prefer-index=true \ + --annotation "index:org.opencontainers.image.description=$OCI_DESCRIPTION" \ + --annotation "index:org.opencontainers.image.title=$OCI_TITLE" \ + --annotation "index:org.opencontainers.image.url=$OCI_URL" \ + "${TAG_ARGS[@]}" \ + "${DIGEST_ARGS[@]}" + + - name: "Inspect images" + env: + GITHUB_TAG: ${{ inputs.tag || github.ref_name }} + run: | + docker buildx imagetools inspect "$GHCR_IMAGE:latest" + docker buildx imagetools inspect "$GHCR_IMAGE:$GITHUB_TAG" diff --git a/.github/workflows/documentation-build.yml b/.github/workflows/documentation-build.yml index 4b9ca79ab..91ee14e3e 100644 --- a/.github/workflows/documentation-build.yml +++ b/.github/workflows/documentation-build.yml @@ -1,5 +1,8 @@ name: Generate Docs +permissions: + contents: read # write permission to the destination repo come from 'deploy_key' + on: pull_request: paths: @@ -11,6 +14,11 @@ on: - published # publishing of stable releases and pre-releases workflow_dispatch: +# Cancel earlier, unfinished runs of this workflow on the same branch (unless on release) +concurrency: + group: "${{ github.workflow }} @ ${{ github.ref_name }}" + cancel-in-progress: ${{ github.event_name != 'release' }} + env: COCKATRICE_REF: ${{ github.ref_name }} # tag name if the commit is tagged, otherwise branch name @@ -20,12 +28,13 @@ jobs: runs-on: ubuntu-latest steps: - - name: "Checkout code" + - name: "Checkout" uses: actions/checkout@v7 with: submodules: recursive - name: "Install Graphviz" + shell: bash run: | sudo apt-get install -y graphviz dot -V @@ -33,9 +42,10 @@ jobs: - name: "Install Doxygen" uses: ssciwr/doxygen-install@v2 with: - version: "1.16.1" + version: "1.18.0" - name: "Update Doxygen Configuration" + shell: bash run: | git diff Doxyfile doxygen -u Doxyfile @@ -50,9 +60,10 @@ jobs: - name: "Generate Documentation" if: always() + shell: bash run: doxygen Doxyfile - - name: "Deploy to cockatrice.github.io" + - name: "Deploy to cockatrice.github.io/docs" if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' uses: peaceiris/actions-gh-pages@v4 with: @@ -61,3 +72,8 @@ jobs: external_repository: Cockatrice/cockatrice.github.io publish_branch: master publish_dir: ./docs/html + + - name: Link to Documentation Page + if: github.event_name == 'release' || github.event_name == 'workflow_dispatch' + shell: bash + run: echo "::notice title=New documentation published::Shortly available at https://cockatrice.github.io/docs/" diff --git a/.github/workflows/translations-pull.yml b/.github/workflows/translations-pull.yml index a3db5f86d..da3df1714 100644 --- a/.github/workflows/translations-pull.yml +++ b/.github/workflows/translations-pull.yml @@ -1,5 +1,9 @@ name: Update Translations +permissions: + contents: write + pull-requests: write + on: pull_request: paths: diff --git a/.github/workflows/translations-push.yml b/.github/workflows/translations-push.yml index c4d3f61fb..17645cab2 100644 --- a/.github/workflows/translations-push.yml +++ b/.github/workflows/translations-push.yml @@ -1,5 +1,9 @@ name: Update Translation Source +permissions: + contents: write + pull-requests: write + on: pull_request: paths: diff --git a/CMakeLists.txt b/CMakeLists.txt index 27fecc979..b272d4ad8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,23 +5,27 @@ # This file sets all the variables shared between the projects # like the installation path, compilation flags etc.. -# cmake 3.16 is required if using qt6 -cmake_minimum_required(VERSION 3.10) +# 3.16 required for Qt6 and target_precompile_headers() +cmake_minimum_required(VERSION 3.16) -# Early detect ccache +# Use compiler cache (ccache) option(USE_CCACHE "Cache the build results with ccache" ON) # Treat warnings as errors (Debug builds only) option(WARNING_AS_ERROR "Treat warnings as errors in debug builds" ON) # Check for translation updates option(UPDATE_TRANSLATIONS "Update translations on compile" OFF) -# Compile servatrice -option(WITH_SERVER "build servatrice" OFF) -# Compile cockatrice -option(WITH_CLIENT "build cockatrice" ON) -# Compile oracle -option(WITH_ORACLE "build oracle" ON) +# Compile Cockatrice +option(WITH_CLIENT "Build Cockatrice client" ON) +# Compile Oracle +option(WITH_ORACLE "Build Cockatrice card database tool (Oracle)" ON) +# Compile Servatrice +option(WITH_SERVER "Build Cockatrice server (Servatrice)" OFF) # Compile tests -option(TEST "build tests" OFF) +option(TEST "Build tests" OFF) +# Build with AddressSanitizer and UndefinedBehaviorSanitizer +option(ENABLE_SANITIZERS "Build with AddressSanitizer and UndefinedBehaviorSanitizer" OFF) +# Build instrumented for GCC line coverage, reported with gcovr +option(ENABLE_COVERAGE "Build instrumented for GCC line coverage" OFF) # Use vcpkg regardless of OS option(USE_VCPKG "Use vcpkg regardless of OS" OFF) @@ -39,13 +43,24 @@ else() ) endif() -if(USE_CCACHE) +# ccache does not support MSVC and must not auto-engage on Windows +# (it is installed unintentionally on the Windows CI runner). +# NOTE: this keys off the target OS, so a mingw/Ninja configuration on Windows +# also opts out of ccache even though the GNUCXX branch below supports it. +if(USE_CCACHE AND NOT WIN32) find_program(CCACHE_PROGRAM ccache) if(CCACHE_PROGRAM) # Support Unix Makefiles and Ninja set_property(GLOBAL PROPERTY RULE_LAUNCH_COMPILE "${CCACHE_PROGRAM}") + # PCH-aware caching, matching .ci/compile.sh: without this ccache refuses + # to cache any TU that consumes a precompiled header, so every PCH-backed + # target recompiles from scratch on each build. + execute_process(COMMAND ${CCACHE_PROGRAM} --set-config sloppiness=pch_defines,time_macros) message(STATUS "Found CCache ${CCACHE_PROGRAM}") endif() +elseif(USE_CCACHE AND WIN32) + # An explicit opt-in must not disappear silently on Windows. + message(STATUS "ccache disabled: not supported for the MSVC toolchain on Windows") endif() if(WIN32 OR USE_VCPKG) @@ -75,11 +90,11 @@ endif() # A project name is needed for CPack # Version can be overriden by git tags, see cmake/getversion.cmake -project("Cockatrice" VERSION 3.1.0) +project("Cockatrice" VERSION 3.1.1) # Set release name if not provided via env/cmake var if(NOT DEFINED GIT_TAG_RELEASENAME) - set(GIT_TAG_RELEASENAME "Graduation Day") + set(GIT_TAG_RELEASENAME "Reality Check") endif() # Use c++20 for all targets @@ -184,6 +199,9 @@ elseif(CMAKE_COMPILER_IS_GNUCXX) set(CMAKE_CXX_FLAGS_DEBUG "${CMAKE_CXX_FLAGS_DEBUG} ${FLAG}") endif() endforeach() + + # Reduce compiler I/O by using pipes between stages instead of temp files + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe") else() # other: osx/llvm, bsd/llvm set(CMAKE_CXX_FLAGS_RELEASE "-O2") @@ -192,8 +210,17 @@ else() else() set(CMAKE_CXX_FLAGS_DEBUG "-g -O0 -Wall -Wextra") endif() + + # Reduce compiler I/O by using pipes between stages instead of temp files + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pipe") endif() +# Always included, because they define cockatrice_disable_sanitizers_for() and +# cockatrice_disable_coverage_for() for the vendored trees. They only touch the global flags +# when ENABLE_SANITIZERS or ENABLE_COVERAGE is on. +include(Sanitizers) +include(Coverage) + # GNU systems need to define the Mersenne exponent for the RNG to compile w/o warning if(CMAKE_CXX_COMPILER_ID MATCHES "GNU|Clang") add_definitions("-DSFMT_MEXP=19937") @@ -239,11 +266,6 @@ if(WIN32) find_package(OpenSSL REQUIRED) if(OPENSSL_FOUND) include_directories(${OPENSSL_INCLUDE_DIRS}) - else() - message( - WARNING - "Could not find OpenSSL runtime libraries. They are not required for compiling, but needs to be available at runtime." - ) endif() endif() @@ -281,7 +303,7 @@ if(UNIX) if(CPACK_GENERATOR STREQUAL "RPM") set(CPACK_RPM_PACKAGE_LICENSE "GPLv2") set(CPACK_RPM_MAIN_COMPONENT "cockatrice") - set(CPACK_RPM_PACKAGE_REQUIRES "protobuf, qt6-qttools, qt6-qtsvg, qt6-qtmultimedia, qt6-qtimageformats") + set(CPACK_RPM_PACKAGE_REQUIRES "protobuf, qt6-qtimageformats, qt6-qtmultimedia, qt6-qtsvg, qt6-qttools") set(CPACK_RPM_PACKAGE_GROUP "Amusements/Games") set(CPACK_RPM_PACKAGE_URL "http://github.com/Cockatrice/Cockatrice") # stop directories from making package conflicts @@ -299,7 +321,7 @@ if(UNIX) set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON) set(CPACK_DEBIAN_PACKAGE_SECTION "games") set(CPACK_DEBIAN_PACKAGE_HOMEPAGE "http://github.com/Cockatrice/Cockatrice") - set(CPACK_DEBIAN_PACKAGE_DEPENDS "libqt6multimedia6, libqt6svg6, qt6-qpa-plugins, qt6-image-formats-plugins") + set(CPACK_DEBIAN_PACKAGE_DEPENDS "libqt6multimedia6, libqt6svg6, qt6-image-formats-plugins, qt6-qpa-plugins") set(CPACK_DEBIAN_PACKAGE_RECOMMENDS "libqt6sql6-mysql") # for connecting servatrice to a mysql db endif() endif() @@ -314,6 +336,55 @@ elseif(WIN32) # Configure file with custom definitions for NSIS. configure_file("${COCKATRICE_CMAKE_PATH}/NSIS.definitions.nsh.in" "${PROJECT_BINARY_DIR}/NSIS.definitions.nsh") + # Every runtime DLL a Windows install has to contain, as paths relative to the + # install root. .ci/compile.sh reads this and fails the build when the packaged + # installer is missing one of them, or carries a runtime it does not know about. + # + # All three applications are packaged into the same directory on Windows, so + # this is the union of their Qt module lists, not just the client's: Servatrice + # contributes Qt6Sql.dll and Oracle contributes nothing the client lacks, and a + # client-only manifest leaves Qt6Sql.dll unasserted even though servatrice.exe + # is installed next to cockatrice.exe and cannot start without it. + # + # A statically linked Qt module ships no runtime DLL, so requiring one would + # fail the package check on a static build for no reason. cockatrice/CMakeLists.txt + # installs the runtime of every module listed here, and skips static ones the + # same way, so the two can never disagree. + # + # Only the Qt modules, and that is deliberate. protobuf, Abseil, liblzma, zlib + # and OpenSSL also ride in on the "*.dll" glob, and a client whose runtime is + # absent, stale or of the wrong architecture fails to start with "abseil_dll.dll + # is either not designed to run on Windows or it contains an error", so they + # are exactly the files worth asserting. + # + # Their names cannot be derived from the vcpkg bin directory, which is what a + # first attempt did and what a Windows build immediately refuted: bin holds + # everything vcpkg installed, not everything the applications import. It also + # contains gmock.dll, libprotobuf-lite.dll, libprotoc.dll, legacy.dll and three + # pthreadVC*.dll FindThreads probes, none of which any shipped executable + # links, so requiring them failed a build over a package that was correct. + # + # Deriving them from the executables' own import tables is the real answer, + # and fixup_bundle already walks it to decide what to copy. Until that result + # is available to CMake, requiring the vcpkg half would mean guessing names + # again, which is what produced this bug report in the first place. So assert + # what is derived from the build's own link lists and leave the rest to the + # purge in cmake/NSIS.template.in, which removes every DLL regardless of name. + set(_required_runtime "") + foreach(_qt_module IN LISTS COCKATRICE_QT_MODULES SERVATRICE_QT_MODULES ORACLE_QT_MODULES) + get_target_property(_qt_module_type ${_qt_module} TYPE) + if(_qt_module_type STREQUAL "SHARED_LIBRARY") + string(REGEX REPLACE "^Qt6::" "" _qt_component "${_qt_module}") + list(APPEND _required_runtime "Qt6${_qt_component}.dll") + endif() + endforeach() + list(APPEND _required_runtime "Plugins/platforms/qwindows.dll") + + list(REMOVE_DUPLICATES _required_runtime) + list(SORT _required_runtime) + string(REPLACE ";" "\n" _required_runtime "${_required_runtime}") + file(WRITE "${PROJECT_BINARY_DIR}/required-runtime.txt" "${_required_runtime}\n") + # include vcredist into the package; NSIS will take care of running it if(VCREDISTRUNTIME_FOUND) install(FILES "${VCREDISTRUNTIME_FILE}" DESTINATION ./) diff --git a/Dockerfile b/Dockerfile index 7c5c773c9..7d3deb5fb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,35 +3,47 @@ FROM ubuntu:26.04 AS build ARG DEBIAN_FRONTEND=noninteractive -RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential \ - cmake \ - file \ - g++ \ - git \ - libmariadb-dev-compat \ - libprotobuf-dev \ - libqt6sql6-mysql \ - qt6-websockets-dev \ - protobuf-compiler \ - qt6-tools-dev \ - qt6-tools-dev-tools +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + cmake \ + ninja-build \ + file \ + g++ \ + git \ + libmariadb-dev-compat \ + libprotobuf-dev \ + libqt6sql6-mysql \ + libssl-dev \ + qt6-websockets-dev \ + protobuf-compiler \ + qt6-tools-dev \ + qt6-tools-dev-tools WORKDIR /src COPY . . -RUN mkdir build && cd build && \ - cmake .. -DWITH_SERVER=1 -DWITH_CLIENT=0 -DWITH_ORACLE=0 && \ - make -j$(nproc) && \ - make install +RUN cmake \ + -S . \ + -B build \ + -G Ninja \ + -DWITH_CLIENT=0 \ + -DWITH_ORACLE=0 \ + -DWITH_SERVER=1 \ + && cmake --build build \ + && cmake --install build # -------- Runtime Stage (clean) -------- FROM ubuntu:26.04 -RUN apt-get update && apt-get install -y --no-install-recommends \ - libprotobuf32t64 \ - libqt6sql6-mysql \ - libqt6websockets6 \ +ARG DEBIAN_FRONTEND=noninteractive + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + libprotobuf32t64 \ + libqt6sql6-mysql \ + libqt6websockets6 \ + libssl3 \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* diff --git a/Doxyfile b/Doxyfile index 9b05682cc..c12ff4a8a 100644 --- a/Doxyfile +++ b/Doxyfile @@ -1,4 +1,4 @@ -# Doxyfile 1.16.1 +# Doxyfile 1.18.0 # This file describes the settings to be used by the documentation system # Doxygen (www.doxygen.org) for a project. @@ -11,7 +11,7 @@ # TAG = value [value, ...] # For lists, items can also be appended using: # TAG += value [value, ...] -# Values that contain spaces should be placed between quotes (\" \"). +# Values that contain spaces should be placed between quotes (" "). # # Note: # @@ -81,7 +81,7 @@ OUTPUT_DIRECTORY = docs # and will distribute the generated files over these directories. Enabling this # option can be useful when feeding Doxygen a huge amount of source files, where # putting all generated files in the same directory would otherwise cause -# performance problems for the file system. Adapt CREATE_SUBDIRS_LEVEL to +# performance problems for the file system. Adjust CREATE_SUBDIRS_LEVEL to # control the number of sub-directories. # The default value is: NO. @@ -802,7 +802,7 @@ GENERATE_REQUIREMENTS = YES # The REQ_TRACEABILITY_INFO tag controls if traceability information is shown on # the requirements page (only relevant when using \requirement comment blocks). -# The setting NO will disable the traceablility information altogether. The +# The setting NO will disable the traceability information altogether. The # setting UNSATISFIED_ONLY will show a list of requirements that are missing a # satisfies relation (through the command: \satisfies). Similarly the setting # UNVERIFIED_ONLY will show a list of requirements that are missing a verifies @@ -855,8 +855,8 @@ SHOW_NAMESPACES = YES # The FILE_VERSION_FILTER tag can be used to specify a program or script that # Doxygen should invoke to get the current version for each file (typically from # the version control system). Doxygen will invoke the program by executing (via -# popen()) the command command input-file, where command is the value of the -# FILE_VERSION_FILTER tag, and input-file is the name of an input file provided +# popen()) the command command input_file, where command is the value of the +# FILE_VERSION_FILTER tag, and input_file is the name of an input file provided # by Doxygen. Whatever the program writes to standard output is used as the file # version. For an example see the documentation. @@ -1066,25 +1066,11 @@ INPUT_FILE_ENCODING = # provided as Doxygen C comment), *.py, *.pyw, *.f90, *.f95, *.f03, *.f08, # *.f18, *.f, *.for, *.vhd, *.vhdl, *.ucf, *.qsf and *.ice. -FILE_PATTERNS = *.cc \ - *.cxx \ - *.cxxm \ - *.cpp \ +FILE_PATTERNS = *.cpp \ *.cppm \ - *.ccm \ - *.c++ \ - *.c++m \ - *.ii \ - *.ixx \ *.ipp \ - *.i++ \ *.inl \ *.h \ - *.hh \ - *.hxx \ - *.hpp \ - *.h++ \ - *.markdown \ *.md \ *.dox \ *.proto @@ -1643,8 +1629,8 @@ DOCSET_PUBLISHER_NAME = Publisher # a.o. the download links, offline (the HTML help workshop was already many # years in maintenance mode). You can download the HTML help workshop from the # web archives at Installation executable (see: -# http://web.archive.org/web/20160201063255/http://download.microsoft.com/downlo -# ad/0/A/9/0A939EF6-E31C-430F-A3DF-DFAE7960D564/htmlhelp.exe). +# http://web.archive.org/web/20160201063255/https://download.microsoft.com/downl +# oad/0/A/9/0A939EF6-E31C-430F-A3DF-DFAE7960D564/htmlhelp.exe). # # The HTML Help Workshop contains a compiler that can convert all HTML output # generated by Doxygen into a single compiled HTML file (.chm). Compiled HTML @@ -1951,7 +1937,7 @@ MATHJAX_FORMAT = HTML-CSS # output directory using the MATHJAX_RELPATH option. For Mathjax version 2 the # destination directory should contain the MathJax.js script. For instance, if # the mathjax directory is located at the same level as the HTML output -# directory, then MATHJAX_RELPATH should be ../mathjax.s For Mathjax versions 3 +# directory, then MATHJAX_RELPATH should be ../mathjax. For Mathjax versions 3 # and 4 the destination directory should contain the tex-.js script # (where is either chtml or svg). The default value points to the # MathJax Content Delivery Network so you can quickly see the result without @@ -2172,9 +2158,8 @@ EXTRA_PACKAGES = # # Note: Only use a user-defined header if you know what you are doing! # Note: The header is subject to change so you typically have to regenerate the -# default header when upgrading to a newer version of Doxygen. The following -# commands have a special meaning inside the header (and footer): For a -# description of the possible markers and block names see the documentation. +# default header when upgrading to a newer version of Doxygen. For a description +# of the possible markers and block names see the documentation. # This tag requires that the tag GENERATE_LATEX is set to YES. LATEX_HEADER = @@ -2657,6 +2642,19 @@ HAVE_DOT = YES DOT_NUM_THREADS = 0 +# The DOT_BATCH_SIZE specifies the number of dot graphs Doxygen is allowed to +# compile in a single invocation of dot. When set to 1 Doxygen will invoke dot +# for each graph separately, which can cause significant process creation +# overhead especially on systems with many CPU cores. Together with +# DOT_NUM_THREADS this setting can be used to optimise the dot processing speed +# for a particular system. Doxygen will try to give each thread a balanced batch +# of work. If the total number of graphs to process exceeds DOT_NUM_THREADS * +# DOT_BATCH_SIZE then additional batches will be created for dot to process. +# Minimum value: 1, maximum value: 1000, default value: 50. +# This tag requires that the tag HAVE_DOT is set to YES. + +DOT_BATCH_SIZE = 50 + # DOT_COMMON_ATTR is common attributes for nodes, edges and labels of # subgraphs. When you want a differently looking font in the dot files that # Doxygen generates you can specify fontname, fontcolor and fontsize attributes. @@ -2948,6 +2946,58 @@ PLANTUML_INCLUDE_PATH = PLANTUMLFILE_DIRS = +# When using Mermaid diagrams with CLI rendering, the MERMAID_PATH tag should be +# used to specify the directory where the mmdc (Mermaid CLI) executable can be +# found. If left blank, CLI-based rendering is disabled. For HTML output, +# client-side rendering via JavaScript is used by default and does not require +# mmdc. For LaTeX/PDF output, mmdc is required to pre-generate images. Doxygen +# will generate a warning when CLI rendering is needed but mmdc is not +# available. + +MERMAID_PATH = + +# When using Mermaid diagrams, the MERMAID_CONFIG_FILE tag can be used to +# specify a JSON configuration file for the Mermaid CLI tool (mmdc). This file +# can contain theme settings and other Mermaid configuration options. + +MERMAID_CONFIG_FILE = + +# The MERMAID_RENDER_MODE tag selects how Mermaid diagrams are rendered. +# Possible values are: AUTO (use client-side rendering for HTML and mmdc for +# LaTeX/PDF and other formats. If MERMAID_PATH is not set, non-HTML diagrams +# will produce a warning), CLI (use the mmdc tool to pre-generate images +# (requires Node.js and mermaid-js/mermaid-cli). Works for all output formats) +# and CLIENT_SIDE (embed mermaid.js in HTML output for client-side rendering. +# Does not require mmdc but only works for HTML output). +# The default value is: AUTO. + +MERMAID_RENDER_MODE = AUTO + +# The MERMAID_JS_URL tag specifies the URL to load mermaid.js from when using +# client-side rendering (MERMAID_RENDER_MODE is CLIENT_SIDE or AUTO). The +# default points to the latest Mermaid v11 release on the jsDelivr CDN. +# +# The default CDN URL requires internet access when viewing the generated +# documentation. For offline use, download mermaid.esm.min.mjs and set this to a +# relative path, or use MERMAID_RENDER_MODE=CLI to pre-generate images instead. +# Examples: +# - Latest v11 (default): +# https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs +# - Pinned version: +# https://cdn.jsdelivr.net/npm/mermaid@11.3.0/dist/mermaid.esm.min.mjs +# - Local copy: ./mermaid.esm.min.mjs (user must place file in HTML output +# directory) +# The default value is: +# https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs. + +MERMAID_JS_URL = https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs + +# The MERMAIDFILE_DIRS tag can be used to specify one or more directories that +# contain Mermaid files that are included in the documentation (see the +# \mermaidfile command). + +MERMAIDFILE_DIRS = + # The DOT_GRAPH_MAX_NODES tag can be used to set the maximum number of nodes # that will be shown in the graph. If the number of nodes in a graph becomes # larger than this value, Doxygen will truncate the graph, which is visualized @@ -2972,15 +3022,6 @@ DOT_GRAPH_MAX_NODES = 50 MAX_DOT_GRAPH_DEPTH = 0 -# Set the DOT_MULTI_TARGETS tag to YES to allow dot to generate multiple output -# files in one run (i.e. multiple -o and -T options on the command line). This -# makes dot run faster, but since only newer versions of dot (>1.8.10) support -# this, this feature is disabled by default. -# The default value is: NO. -# This tag requires that the tag HAVE_DOT is set to YES. - -DOT_MULTI_TARGETS = NO - # If the GENERATE_LEGEND tag is set to YES Doxygen will generate a legend page # explaining the meaning of the various boxes and arrows in the dot generated # graphs. diff --git a/README.md b/README.md index f22df461f..f2a170868 100644 --- a/README.md +++ b/README.md @@ -102,6 +102,7 @@ There are various places where useful information for different needs are mainta - [Official Wiki](https://github.com/Cockatrice/Cockatrice/wiki) `Community supported` - [Official Webpage](https://cockatrice.github.io/) - [Official README](https://github.com/Cockatrice/Cockatrice/blob/master/README.md) `This file` +- [Official CONTRIBUTING](https://github.com/Cockatrice/Cockatrice/blob/master/.github/CONTRIBUTING.md) Cockatrice tries to use the [Google Developer Documentation Style Guide](https://developers.google.com/style/) to ensure consistent documentation. We encourage you to improve the documentation by suggesting edits based on this guide. @@ -149,15 +150,21 @@ You can then
The following flags (with their non-default values) can be passed to `cmake`: -| Flag | Description | -| --- | --- | -| `-DWITH_SERVER=1` | Build Servatrice server | -| `-DWITH_CLIENT=0` | Don't build Cockatrice client | -| `-DWITH_ORACLE=0` | Don't build Oracle card database tool | -| `-DCMAKE_BUILD_TYPE=Debug` | Compile in debug mode
Enables extra logging output, debug symbols, and much more verbose compiler warnings | -| `-DWARNING_AS_ERROR=0` | Don't treat compilation warnings as errors in debug mode | -| `-DUPDATE_TRANSLATIONS=1` | Configure `make` to update the translation .ts files for new strings in the source code
**Note:** `make clean` will remove the .ts files | -| `-DTEST=1` | Enable regression tests
**Note:** `make test` to run tests, *googletest* will be downloaded if not available | +| Flag | Description | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `-DWITH_SERVER=1` | Build Servatrice server | +| `-DWITH_CLIENT=0` | Don't build Cockatrice client | +| `-DWITH_ORACLE=0` | Don't build Oracle card database tool | +| `-DCMAKE_BUILD_TYPE=Debug` | Compile in debug mode
Enables extra logging output, debug symbols, and much more verbose compiler warnings | +| `-DWARNING_AS_ERROR=0` | Don't treat compilation warnings as errors in debug mode | +| `-DUPDATE_TRANSLATIONS=1` | Configure `make` to update the translation .ts files for new strings in the source code
**Note:** `make clean` will remove the .ts files | +| `-DTEST=1` | Enable regression tests
**Note:** `make test` to run tests, *googletest* will be downloaded if not available | + +> [!TIP] +> Build code documentation ([Doxygen](https://www.doxygen.nl)) according to our [configuration](https://github.com/Cockatrice/Cockatrice/blob/master/Doxyfile) into `/docs`: +> ```bash +> doxygen Doxyfile +> ``` # Run diff --git a/cmake/Coverage.cmake b/cmake/Coverage.cmake new file mode 100644 index 000000000..0345a2d36 --- /dev/null +++ b/cmake/Coverage.cmake @@ -0,0 +1,75 @@ +# Line-coverage instrumentation opt-in, plus per-source opt-out +# Input: ENABLE_COVERAGE +# Defines: cockatrice_disable_coverage_for +# +# Included once from the root CMakeLists.txt, immediately after the per-compiler flag +# block, so add_compile_options() reaches every library and executable defined below. +# +# Note the name deliberately shadows CMake's own built-in Modules/Coverage.cmake. It resolves +# to this file only because the root CMakeLists.txt puts cmake/ at the front of +# CMAKE_MODULE_PATH; if that ever moves, include(Coverage) would silently pick up the built-in +# module, which only defines a `COVERAGE` custom target and touches no global flags, so +# instrumentation would quietly stop happening rather than erroring. + +include(CheckCXXSourceCompiles) + +# Opt a set of vendored sources out of instrumentation. +# +# cockatrice_disable_coverage_for( ...) +# +# Third-party code drags the total down with lines no test can ever reach, and a total that +# cannot move is not a useful signal. This is the same carve-out the sanitizer build uses. +# +# The sources must be named relative to the directory that compiles them. CMake resolves a +# source-file property against the directory scope the target was declared in, so calling +# this from the root with absolute paths silently does nothing. That is why the call sites +# live next to the add_library/add_executable that pull the vendored code in. +# +# SKIP_PRECOMPILE_HEADERS mirrors the sanitizer helper. The precompiled header carries the +# instrumentation flags, and mixing an instrumented PCH with an uninstrumented translation +# unit makes GCC reject it. +function(cockatrice_disable_coverage_for) + if(NOT ENABLE_COVERAGE) + return() + endif() + # There is no -fno-coverage: --coverage is shorthand for -fprofile-arcs -ftest-coverage, so both + # halves have to be negated. CC rejects "-fno-coverage" outright, which is how the vendored C + # files below hit it first. + set_source_files_properties( + ${ARGN} PROPERTIES COMPILE_OPTIONS "-fno-profile-arcs;-fno-test-coverage" SKIP_PRECOMPILE_HEADERS ON + ) +endfunction() + +if(ENABLE_COVERAGE) + # Line coverage needs the compiler's own toolchain, so only GCC is supported. Clang's + # equivalent is llvm-cov, which reads a different intermediate format and would need a + # separate report path; rejecting it is better than emitting .gcno files that gcovr cannot + # read. + if(NOT CMAKE_CXX_COMPILER_ID STREQUAL "GNU") + message(FATAL_ERROR "ENABLE_COVERAGE requires GCC, but the compiler is ${CMAKE_CXX_COMPILER_ID}. " + "Clang coverage needs llvm-cov and is not wired up." + ) + endif() + + # Probe the way the flag will actually be used: compile AND link. A .gcno file comes from the + # compile step, but the runtime that writes .gcda files comes from the link step, so probing + # only the compile step would pass on a toolchain that cannot actually produce coverage. + set(_cockatrice_saved_required_flags "${CMAKE_REQUIRED_FLAGS}") + set(_cockatrice_saved_required_link_options "${CMAKE_REQUIRED_LINK_OPTIONS}") + set(CMAKE_REQUIRED_FLAGS "-O0 -g") + set(CMAKE_REQUIRED_LINK_OPTIONS -fprofile-arcs -ftest-coverage) + check_cxx_source_compiles("int main() { return 0; }" COCKATRICE_HAVE_COVERAGE_FLAGS) + set(CMAKE_REQUIRED_FLAGS "${_cockatrice_saved_required_flags}") + set(CMAKE_REQUIRED_LINK_OPTIONS "${_cockatrice_saved_required_link_options}") + + if(NOT COCKATRICE_HAVE_COVERAGE_FLAGS) + message(FATAL_ERROR "ENABLE_COVERAGE is on, but this toolchain rejected the coverage flags.") + endif() + + # -O0 keeps the line mapping honest: at higher optimisation levels the compiler merges and + # relocates lines, and the report describes code that no longer exists in that form. + add_compile_options(--coverage -O0 -g) + add_link_options(--coverage) + + message(STATUS "Coverage instrumentation enabled (--coverage). Report with gcovr over the build dir.") +endif() diff --git a/cmake/FindQtRuntime.cmake b/cmake/FindQtRuntime.cmake index 8a3050813..2d74aca4d 100644 --- a/cmake/FindQtRuntime.cmake +++ b/cmake/FindQtRuntime.cmake @@ -7,6 +7,12 @@ # Output: ORACLE_QT_MODULES # Output: TEST_QT_MODULES +# Every Qt module an application links against, as the DLL names a Windows +# install has to contain. Core is listed explicitly rather than left to arrive +# transitively: it is the one module nothing can do without, and required-runtime.txt +# is built from these lists, so a module missing here is a runtime the package +# check never asserts. That is how Qt6Core.dll went unasserted while every other +# Qt module was checked. set(REQUIRED_QT_COMPONENTS Core) if(WITH_SERVER) set(_SERVATRICE_NEEDED Network Sql WebSockets) @@ -14,23 +20,29 @@ endif() if(WITH_CLIENT) set(_COCKATRICE_NEEDED Concurrent + Core Gui Multimedia Network PrintSupport + ShaderTools Svg WebSockets Widgets Xml + Quick + QuickWidgets ) endif() if(WITH_ORACLE) - set(_ORACLE_NEEDED Concurrent Network Svg Widgets) + set(_ORACLE_NEEDED Concurrent Network Svg Widgets Xml) endif() if(TEST) # Union of Qt modules required across all test targets (independent of application targets). # When adding a new test that needs additional Qt modules, add them here rather than in the test's CMakeLists.txt. - set(_TEST_NEEDED Concurrent Network Svg Widgets) + # Test is QtTest: it brings QAbstractItemModelTester and QSignalSpy, which are how the Qt model + # and signal contracts get verified without standing up a GUI. + set(_TEST_NEEDED Concurrent Network Svg Widgets Test) endif() set(REQUIRED_QT_COMPONENTS ${REQUIRED_QT_COMPONENTS} ${_SERVATRICE_NEEDED} ${_COCKATRICE_NEEDED} ${_ORACLE_NEEDED} diff --git a/cmake/NSIS.template.in b/cmake/NSIS.template.in index 5af116470..4c55c00b3 100644 --- a/cmake/NSIS.template.in +++ b/cmake/NSIS.template.in @@ -12,6 +12,22 @@ Var NormalDestDir Var PortableDestDir Var PortableMode Var ReinstallMode +Var AllowForceClose +Var GuardDir +Var OldUninstallString +Var StaleFindHandle +Var StaleFindName +Var IsUpgrade +Var AbortReason +Var WaitPid +Var LauncherExited +Var InstallLogPath +Var LogHandle +Var LogMessage +Var LogFindHandle +Var LogFindName +Var LogMatchLabel +Var LogMatchPattern !include LogicLib.nsh !include FileFunc.nsh @@ -55,8 +71,67 @@ Page Custom PortableModePageCreate PortableModePageLeave !insertmacro MUI_LANGUAGE English +; Installer log +; Every run appends a step-by-step trace to a file so a failed upgrade on a +; user's machine can be diagnosed after the fact. Kept in $TEMP rather than in +; $INSTDIR because the previous version's uninstaller removes the whole install +; directory part-way through an upgrade. Read it back with: +; notepad %TEMP%\Cockatrice-installer.log +!macro CockLog text + Push "${text}" + Call CockLogWrite +!macroend + +Function CockLogWrite + Pop $LogMessage + FileOpen $LogHandle "$InstallLogPath" a + ${If} $LogHandle == "" + Return + ${EndIf} + ; FileOpen places the pointer at the start of the file in every mode, so + ; 'a' alone would overwrite the log from the beginning on each line. Seek + ; past the existing content first to actually append. + FileSeek $LogHandle 0 END + FileWrite $LogHandle "$LogMessage$\r$\n" + FileClose $LogHandle +FunctionEnd + +; Logs every file matching $INSTDIR\. Used to record the DLL inventory +; before and after the upgrade purge, which is what shows whether Qt6Gui.dll was +; on disk and what (if anything) survived the deletes. +!macro CockLogListing label mask + Push "${label}" + Push "$INSTDIR\${mask}" + Call CockLogListing +!macroend + +Function CockLogListing + Pop $LogMatchPattern + Pop $LogMatchLabel + !insertmacro CockLog "listing [$LogMatchLabel] pattern=$LogMatchPattern" + FindFirst $LogFindHandle $LogFindName "$LogMatchPattern" + ${If} $LogFindHandle == "" + !insertmacro CockLog "listing [$LogMatchLabel] (no matches)" + Return + ${EndIf} + log_listing_loop: + !insertmacro CockLog "listing [$LogMatchLabel] $LogFindName" + ClearErrors + FindNext $LogFindHandle $LogFindName + IfErrors 0 log_listing_loop + FindClose $LogFindHandle +FunctionEnd + Function .onInit +StrCpy $InstallLogPath "$TEMP\Cockatrice-installer.log" +; Start each run from a clean file so the log that gets sent back is the last +; run, not every past run glued together. +FileOpen $LogHandle "$InstallLogPath" w +FileClose $LogHandle +!insertmacro CockLog "==================================================================" +!insertmacro CockLog "installer start: @CPACK_PACKAGE_NAME@ @CPACK_PACKAGE_VERSION@" + ${If} ${NSIS_IS_64_BIT} == 1 #NSIS 64bit ${IfNot} ${RunningX64} MessageBox MB_OK|MB_ICONSTOP "This version of Cockatrice requires a 64-bit Windows system." @@ -85,6 +160,7 @@ ${IfNot} ${Errors} /PORTABLE : Install in portable mode$\n\ /S : Silent install$\n\ /R : Silent upgrade$\n\ + /PID=%pid% : Process to wait for before replacing files (used by the in-app updater)$\n\ /D=%directory% : Specify destination directory$\n" Quit ${EndIf} @@ -112,6 +188,23 @@ ${Else} StrCpy $ReinstallMode 0 ${EndIf} +; /PID= carries the process id of the application that started this installer. +; The in-app updater launches the installer and only then quits itself, so +; without it the installer has to guess whether the launching process is still +; alive by polling powershell.exe for an image name - a guess that can come back +; "not running" or "could not determine" while the process is very much alive and +; still has its Qt DLLs mapped. An older client does not pass /PID= and an older +; installer ignores it, so both sides of the pair have to keep working. +; Note that /D= must stay the last parameter on the command line, which is why +; the client puts it last and this one anywhere. +StrCpy $WaitPid "" +StrCpy $LauncherExited 0 +ClearErrors +${GetOptions} $9 "/PID=" $8 +${IfNot} ${Errors} + StrCpy $WaitPid $8 +${EndIf} + ${If} $InstDir == "" ; User did not use /D to specify a directory, ; we need to set a default based on the install mode @@ -131,8 +224,38 @@ ${EndIf} ; Now that $PortableMode reflects reality, commit InstDir into the correct slot Call SetModeDestinationFromInstdir +!insertmacro CockLog "options: InstDir=$InstDir PortableMode=$PortableMode ReinstallMode=$ReinstallMode WaitPid=$WaitPid" +${If} ${Silent} + !insertmacro CockLog "options: running silently" +${Else} + !insertmacro CockLog "options: interactive" +${EndIf} + +; Wait for the process that launched this installer to exit before anything is +; replaced or deleted. It goes first because it is the one check here that needs +; no powershell.exe: on a machine where the process lookup cannot run at all, the +; launcher is still proven gone here instead of the whole upgrade being cancelled +; by a check that was only ever a proxy for this one. +; +; Then make sure no *other* application instance is still running (and holding +; file locks) before the previous version is uninstalled. On a silent update +; (/R /S) the running processes are asked to close gracefully and waited for; +; Cockatrice itself is never force-killed (data loss, e.g. an unsaved deck) so the +; update is aborted instead, while the console applications Oracle and Servatrice +; are force-stopped by PID once the grace period is over. On interactive installs +; the user is prompted to close them instead. +; Only needed this early for /R, where AutoUninstallIfNeeded runs below; for +; interactive installs the section re-checks after the directory page, when +; $INSTDIR is the directory the user actually picked. +${If} $ReinstallMode = 1 + !insertmacro CockLog "reinstall: waiting for launcher and other instances" + Call WaitForLauncherToExit + Call EnsureAppsNotRunning +${EndIf} + ${If} $ReinstallMode = 1 ${AndIf} $PortableMode = 0 + !insertmacro CockLog "reinstall: auto-uninstalling previous version" Call AutoUninstallIfNeeded ${EndIf} @@ -144,6 +267,9 @@ ${If} ${NSIS_IS_64_BIT} == 1 SetRegView 64 ${EndIf} +; Ensure no application instance is still running before removing files. +Call un.EnsureAppsNotRunning + FunctionEnd Function RequireAdmin @@ -175,13 +301,22 @@ Function AutoUninstallIfNeeded SetShellVarContext all +; _?=$INSTDIR keeps the old uninstaller running in place. An NSIS uninstaller +; normally copies itself to %TEMP% and runs from there so that it can delete its +; own directory, and the process we started exits at once. ExecWait then returns +; before the uninstall has run, and the temp copy's "RMDir /r $INSTDIR" executes +; while the section below is still writing the new payload. _?= disables the copy +; and makes ExecWait block until the uninstall finishes. It must be the last +; argument and must not be quoted, even when the path contains spaces. +; ; --- 32-bit uninstall --- SetRegView 32 ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString" +!insertmacro CockLog "uninstall (32-bit): '$R0 _?=$INSTDIR'" StrCmp $R0 "" done32 DetailPrint "Removing previous version (32-bit)..." -ExecWait '$R0' +ExecWait '$R0 _?=$INSTDIR' done32: @@ -190,15 +325,653 @@ ${If} ${RunningX64} SetRegView 64 ReadRegStr $R0 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "QuietUninstallString" + !insertmacro CockLog "uninstall (64-bit): '$R0 _?=$INSTDIR'" StrCmp $R0 "" done64 DetailPrint "Removing previous version (64-bit)..." - ExecWait '$R0' + ExecWait '$R0 _?=$INSTDIR' done64: ${EndIf} FunctionEnd +; --- Running instance handling --- +; Cockatrice, Oracle and Servatrice must not be running while files are +; replaced or deleted. A still-running process holds locks on its .exe and +; Qt runtime DLLs, so a silent upgrade could otherwise end up with a mix of +; old and new Qt DLLs next to the new executable, failing with +; "The procedure entry point X could not be located in the dynamic link +; library ...Qt6Network.dll" on the next start. +; +; On a silent update (/R /S) matching processes are asked to close gracefully +; (WM_CLOSE) and waited for up to 60 s. Cockatrice is never force-stopped: it +; may be holding an unsaved deck or showing a "really leave this game?" +; confirmation, so if it refuses to close the install is aborted - nothing has +; been replaced yet, so the update is simply deferred. Oracle and Servatrice +; are console applications without a main window, so CloseMainWindow() does +; nothing for them and waiting cannot help; they hold no unsaved user data and +; are force-stopped by PID once the grace period is over. On interactive +; installs and uninstalls the user is prompted to close them instead. +; +; Processes are matched by image name AND by their executable path living +; under the guarded directory ($GuardDir, normally $INSTDIR), so unrelated +; processes that merely share an image name (e.g. the Oracle DB instance +; "oracle.exe") are never touched. + +; usage: set $R2 to the base image name (without extension, e.g. "cockatrice", +; as accepted by Get-Process -Name), call this, result in $R0 +; (1 = running from $GuardDir, 0 = not running, 2 = could not determine) +Function IsAppRunning + ; Every caller has to set $GuardDir; an empty one would make the path check + ; below match every process that merely shares the image name, which is the + ; opposite of what this guard is for. + ${If} $GuardDir == "" + StrCpy $R0 2 + Return + ${EndIf} + ; Pass the directory to guard to PowerShell via an environment variable so + ; it never has to be embedded into the -Command string: apostrophes in a + ; path would terminate the command early, and the path in a -like pattern + ; would treat [ and ] as wildcard characters. + System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' + ; Exit the process explicitly so nsExec's pop reflects whether a matching + ; process was found: powershell.exe exits 0 whether or not the pipeline + ; produced objects. 0 = not running, 1 = running, anything else = could not + ; be determined (see IsProcessLookupAvailable). + ; A process is only a match when its path is readable and lives under + ; $GuardDir. A process we cannot inspect is not one of ours: the updater is + ; always started by the application it updates, so it runs in the same user + ; session, and an interactive install is elevated and can read the paths of + ; the user's own processes. Assuming a match instead would be a hard stop + ; rather than a fail-closed guard - an Oracle database instance running as + ; SYSTEM has a process named "oracle" whose path raises Access denied, and + ; that would make every silent update abort and every interactive install + ; loop on the Retry prompt with no way out but Cancel. + nsExec::ExecToLog 'powershell -NoProfile -Command "if (Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | Select-Object -First 1) { exit 1 } else { exit 0 }"' + Pop $R0 + ${If} $R0 == "0" + StrCpy $R0 0 + ${ElseIf} $R0 == "1" + StrCpy $R0 1 + ${Else} + ; powershell.exe could not be run at all (nsExec pushes the string + ; "error"), or died with an unexpected code. That is "could not + ; determine", which has to stay distinguishable from "definitely + ; running": a state that can never clear would block the installer + ; forever. IsProcessLookupAvailable warns about that case once. + StrCpy $R0 2 + ${EndIf} +FunctionEnd + +; Sets $R0 to 1 if PowerShell can be started at all, 0 if it cannot. The +; running instance check is built on top of it, so a machine where +; powershell.exe is missing, blocked by policy or refused by antivirus is +; reported once instead of being reported as "Cockatrice is running" on every +; single check. +Function IsProcessLookupAvailable + nsExec::ExecToLog 'powershell -NoProfile -Command "exit 0"' + Pop $R0 + ${If} $R0 == "0" + StrCpy $R0 1 + ${Else} + StrCpy $R0 0 + ${EndIf} +FunctionEnd + +Function CloseMatchingApps + ; gracefully ask every matching instance to close (sends WM_CLOSE) + ; + ; The filter here is deliberately the strict one: a process is only asked to + ; close when its path is readable and lives under $GuardDir. Treating a + ; process whose path cannot be read (an unrelated oracle.exe / servatrice.exe, + ; or one owned by another account running as SYSTEM) as a match would make + ; the installer close a third party application, and it runs elevated, so + ; CloseMainWindow() would succeed on it. + System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' + nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | ForEach-Object { $$null = $$_.CloseMainWindow() }"' + Pop $R3 +FunctionEnd + +Function ForceCloseApps + ; stop the matching instances by PID once the grace period is over. Only + ; used for the console applications ($AllowForceClose), which have no window + ; to close and hold no unsaved user data. Same strict filter as above. + System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' + nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | ForEach-Object { Stop-Process -Id $$_.Id -Force -ErrorAction SilentlyContinue }"' + Pop $R3 +FunctionEnd + +Function WaitForAppToClose + ; usage: set $R1 to the display name (e.g. "cockatrice.exe") and $R2 to the + ; base image name (e.g. "cockatrice") + Call IsAppRunning + !insertmacro CockLog "guard $R1: state=$R0 (dir=$GuardDir)" + ; Only a definite "running" is worth waiting for. "Not running" is done, and + ; "could not determine" must not keep the installer waiting on an answer it + ; will never get - but it cannot simply be waved through either, see + ; HandleUnknownAppState. + ${If} $R0 = 0 + Return + ${EndIf} + ${If} $R0 = 2 + Call HandleUnknownAppState + Return + ${EndIf} + + ${If} ${Silent} + ; ask the application to close gracefully (WM_CLOSE), then wait for it to exit + DetailPrint "Closing $R1 ..." + Call CloseMatchingApps + ; wall-clock deadline: each poll has to spawn a fresh powershell.exe, so + ; a bare loop counter would not bound the real wait time to 60 s. + System::Call 'kernel32::GetTickCount()i.s' + Pop $R8 + ck_wait_loop: + Sleep 500 + Call IsAppRunning + ${If} $R0 = 0 + !insertmacro CockLog "guard $R1: closed gracefully" + DetailPrint "$R1 closed." + Return + ${EndIf} + ${If} $R0 = 2 + Call HandleUnknownAppState + Return + ${EndIf} + System::Call 'kernel32::GetTickCount()i.s' + Pop $R9 + IntOp $R9 $R9 - $R8 + ${If} $R9 < 60000 + Goto ck_wait_loop + ${EndIf} + ; The grace period is over. Cockatrice is never force-stopped: it may be + ; showing a "really leave this game?" confirmation or holding an unsaved + ; deck, and nothing has been replaced yet, so aborting just defers the + ; update and preserves the user's data. + ${If} $AllowForceClose = 0 + !insertmacro CockLog "guard $R1: still running after 60s, aborting (no force close)" + DetailPrint "$R1 is still running after 60 s; aborting the update so that no data is lost." + StrCpy $AbortReason \ + "$R1 did not close within 60 seconds, so the installation was aborted to avoid losing unsaved data.$\r$\n$\r$\nPlease close $R1 and run the installer again." + Call AbortInstall + ${EndIf} + ; Oracle and Servatrice are console applications: CloseMainWindow() + ; returned false for them and waiting longer cannot help. They hold no + ; unsaved user data, so stop them by PID. + !insertmacro CockLog "guard $R1: still running after 60s, force-stopping" + DetailPrint "Force-stopping $R1 ..." + Call ForceCloseApps + Sleep 2000 + Call IsAppRunning + ${If} $R0 = 0 + !insertmacro CockLog "guard $R1: force-stopped" + DetailPrint "$R1 closed." + Return + ${EndIf} + ${If} $R0 = 2 + Call HandleUnknownAppState + Return + ${EndIf} + !insertmacro CockLog "guard $R1: could not be stopped, aborting" + DetailPrint "$R1 is still running after 60 s; aborting the update." + StrCpy $AbortReason \ + "$R1 could not be stopped, so the installation was aborted.$\r$\n$\r$\nPlease close $R1 and run the installer again." + Call AbortInstall + ${Else} + ck_wait_prompt: + MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ + "$R1 is still running.$\r$\n$\r$\nPlease close it, then click Retry.$\r$\nClick Cancel to abort." \ + IDCANCEL ck_abort_install + Call IsAppRunning + ${If} $R0 = 0 + Return + ${EndIf} + ${If} $R0 = 2 + Call HandleUnknownAppState + Return + ${EndIf} + Goto ck_wait_prompt + ck_abort_install: + Abort + ${EndIf} +FunctionEnd + +; A process lookup came back "could not determine": powershell.exe ran but the +; answer was not usable, e.g. the process could not be inspected at all. That +; state is fine to carry on from when the user is at the keyboard - they can +; close the applications by hand and the stale-runtime check still stands +; between a failed guard and a mismatched install. +; +; It is not fine on a silent upgrade. There the whole point of this guard is to +; know that nothing holds locks on the files about to be replaced, and this is +; exactly the state where that is unknown, with no one to click a dialog: the +; old code returned and let the install carry on into the mixed Qt DLL set that +; made the next start fail. Cancel the update instead; the next attempt runs +; with the applications closed and succeeds. +Function HandleUnknownAppState + ${If} $ReinstallMode = 1 + StrCpy $AbortReason \ + "The installation was cancelled because it could not determine whether Cockatrice is still running.$\r$\n$\r$\nPlease close Cockatrice, Oracle and Servatrice and run the installer again." + Call AbortInstall + ${EndIf} + DetailPrint "Could not determine whether $R1 is running; close Cockatrice, Oracle and Servatrice manually if the installation reports a problem." + ${IfNot} ${Silent} + MessageBox MB_OK|MB_ICONEXCLAMATION \ + "This installer could not determine whether $R1 is still running.$\r$\n$\r$\nPlease close Cockatrice, Oracle and Servatrice manually before continuing." + ${EndIf} +FunctionEnd + +Function EnsureAppsNotRunning + ; guards the install directory + StrCpy $GuardDir $INSTDIR + Call GuardAppsNotRunning +FunctionEnd + +; Aborts the installation, showing $AbortReason to the user. +; +; Every abort goes through here so the unattended case is handled the same way +; everywhere: the in-app updater runs this installer silent and detached and +; never looks at its exit code, so a plain Abort would leave the user with no +; update and no explanation, while a /S install without /R is unattended - by a +; package manager, for instance - and a message box there would block forever. +; Only the first of those two gets a dialog; the second reports through the log +; and the exit code. +Function AbortInstall + !insertmacro CockLog "ABORT: $AbortReason" + !insertmacro CockLog "full installer log: $InstallLogPath" + ; Show a dialog whenever someone is at the keyboard to see it: an interactive + ; install, or the in-app updater's /R upgrade. The /R upgrade is silent, but + ; the client that launched the installer has already quit, so the user is + ; still there. Only a /S install without /R is attended by a package manager + ; and must go straight to the exit code. + ${If} $ReinstallMode = 1 + ${OrIfNot} ${Silent} + MessageBox MB_OK|MB_ICONEXCLAMATION "$AbortReason$\r$\n$\r$\nA detailed installer log was written to:$\r$\n$InstallLogPath" + ${EndIf} + SetErrorLevel 1 + Abort +FunctionEnd + +; Waits for the application that launched this installer to exit, using the /PID= +; it passed. Sets $LauncherExited to 1 when that process is provably gone. +; +; This is the authority for the one question the rest of the installer can only +; approximate: is the process that started us still holding its Qt DLLs mapped? +; A mapped module cannot be deleted, so an answer of "not running" obtained by +; enumerating process names is what allows the update to write a new payload on +; top of files it could not remove. Waiting on a real handle has no such gap, +; needs no powershell.exe, and is unaffected by a process whose path cannot be +; read - the case the name based check has to treat as "not ours" so it never +; closes a third party application. +; +; Sets $LauncherExited to 0 when no usable /PID= was given, which is the older +; client that does not pass one; the caller then has no authority available and +; falls back to the name based guard. +Function WaitForLauncherToExit + StrCpy $LauncherExited 0 + !insertmacro CockLog "wait-launcher: pid='$WaitPid'" + ${If} $WaitPid == "" + !insertmacro CockLog "wait-launcher: no pid given, falling back to name check" + Return + ${EndIf} + ; PROCESS_SYNCHRONIZE is the only access this needs - enough to wait on the + ; handle, and the least likely to be refused for a reason that has nothing to + ; do with the upgrade. + ; + ; System.dll maps its output registers so that .rN writes $N and .RN writes + ; $RN. The caller reads $R0/$R1, so the uppercase .RN form is required; the + ; original .r0/.r1 wrote $0/$1 and left $R1 empty, which made the "did not + ; exit" test below compare "" against "0" and abort on every /PID= upgrade. + ClearErrors + System::Call 'kernel32::OpenProcess(i 0x00100000, i 0, i $WaitPid) p.R0' + ; OpenProcess returns a null handle for a process that is already gone. The + ; System plugin does not raise the NSIS error flag from a called function's + ; return value, so ${Errors} alone never saw that case and the null handle was + ; passed straight to WaitForSingleObject. Fold a plugin failure into the same + ; null-handle case instead of reading a handle that was never written. + ${If} ${Errors} + StrCpy $R0 0 + ${EndIf} + ${If} $R0 == 0 + ; The launcher is gone - OpenProcess cannot open a dead process - which is + ; exactly the state the wait was for. + !insertmacro CockLog "wait-launcher: already exited" + DetailPrint "The application that started this installer has already exited." + StrCpy $LauncherExited 1 + Return + ${EndIf} + DetailPrint "Waiting for the application that started this installer to exit ..." + ; Same 60 s grace period the name based check uses, so a client that is slow + ; to flush its card database does not get less room than it used to. + ; WAIT_OBJECT_0 is 0; WAIT_TIMEOUT is 258 and a failed wait reports 0xFFFFFFFF. + System::Call 'kernel32::WaitForSingleObject(p R0, i 60000) i.R1' + System::Call 'kernel32::CloseHandle(p R0)' + ${If} $R1 != 0 + !insertmacro CockLog "wait-launcher: did not exit within 60s (wait=$R1)" + DetailPrint "The application that started this installer did not exit within 60 s." + StrCpy $AbortReason \ + "The application that started this update has not exited yet, so the installation was cancelled rather than replace files it is still using.$\r$\n$\r$\nPlease close Cockatrice and run the installer again." + Call AbortInstall + ${EndIf} + !insertmacro CockLog "wait-launcher: launcher exited" + DetailPrint "The application that started this installer has exited." + StrCpy $LauncherExited 1 +FunctionEnd + +Function EnsureOldInstallNotRunning + ; Guards the directory of a *previous* installation, as recorded in the + ; registry. Needed by componentsPagePre, which runs the old uninstaller + ; before the directory page, so $INSTDIR is still the default there and not + ; the directory the old install lives in. Every uninstaller shipped before + ; this change had no running instance guard of its own, so the old one would + ; delete files while an instance of the application is still holding them. + ReadRegStr $R3 HKLM "SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "InstallLocation" + ${If} $R3 == "" + ; no usable record of where the old install is; $INSTDIR is the default + ; install directory at this point, which is where it usually is anyway + DetailPrint "Could not read the previous installation directory from the registry; checking $INSTDIR instead." + StrCpy $R3 $INSTDIR + ${EndIf} + StrCpy $GuardDir $R3 + Call GuardAppsNotRunning +FunctionEnd + +Function GuardAppsNotRunning + ; usage: set $GuardDir to the directory to guard, then call this + Call IsProcessLookupAvailable + !insertmacro CockLog "guard: powershell available=$R0 dir=$GuardDir launcherExited=$LauncherExited" + ${If} $R0 = 0 + ; Without PowerShell we cannot tell whether a running instance is + ; holding locks on the files we are about to replace. + !insertmacro CockLog "guard: powershell unavailable, skipping instance check" + DetailPrint "Could not start powershell.exe; skipping the running instance check. Close Cockatrice, Oracle and Servatrice manually if the installation reports a problem." + ; On a silent upgrade that used to be a warning and nothing more, which + ; left the very process that launched the installer free to go on + ; replacing files it still had mapped - the one outcome this guard exists + ; to prevent - and then writing the new payload on top of them. Cancel + ; instead. An interactive install can still continue: the user is at the + ; keyboard, and refusing outright would leave them with an installer that + ; can never complete. + ; + ; Unless the launcher has already been waited on directly: WaitForLauncherToExit + ; needs no powershell.exe, so when it has run, Cockatrice is known to be + ; gone and the lookup failing to run costs nothing but the Oracle and + ; Servatrice checks. An older client that passes no /PID= leaves no such + ; authority, and there the upgrade is cancelled as before. + ${If} $LauncherExited != 1 + ${AndIf} $ReinstallMode = 1 + StrCpy $AbortReason \ + "The installation was cancelled because it could not check whether Cockatrice is still running, because powershell.exe could not be started.$\r$\n$\r$\nPlease close Cockatrice, Oracle and Servatrice and run the installer again." + Call AbortInstall + ${EndIf} + ; The user has to be told to close the applications by hand, but a /S + ; install without /R is unattended and would block on this forever. + ${If} $ReinstallMode = 1 + ${OrIfNot} ${Silent} + MessageBox MB_OK|MB_ICONEXCLAMATION "This installer could not check whether Cockatrice is still running, because powershell.exe could not be started.$\r$\n$\r$\nPlease close Cockatrice, Oracle and Servatrice manually before continuing." + ${EndIf} + Return + ${EndIf} + StrCpy $R1 "cockatrice.exe" + StrCpy $R2 "cockatrice" + ; a GUI application with unsaved user data: graceful close only + StrCpy $AllowForceClose 0 + Call WaitForAppToClose + StrCpy $R1 "oracle.exe" + StrCpy $R2 "oracle" + StrCpy $AllowForceClose 1 + Call WaitForAppToClose + StrCpy $R1 "servatrice.exe" + StrCpy $R2 "servatrice" + StrCpy $AllowForceClose 1 + Call WaitForAppToClose +FunctionEnd + +; Sets $R0 to 1 if $INSTDIR looks like an existing Cockatrice install. Used to +; guard the stale-runtime-DLL purge so a first-time install into a pre-existing +; directory with an unrelated "Plugins" folder is never touched, while an +; install that only ever held e.g. servatrice.exe is still purged. +Function IsExistingCockatriceInstall + StrCpy $R0 0 + ${If} ${FileExists} "$INSTDIR\cockatrice.exe" + StrCpy $R0 1 + ${EndIf} + ${If} ${FileExists} "$INSTDIR\oracle.exe" + StrCpy $R0 1 + ${EndIf} + ${If} ${FileExists} "$INSTDIR\servatrice.exe" + StrCpy $R0 1 + ${EndIf} + ${If} ${FileExists} "$INSTDIR\uninstall.exe" + StrCpy $R0 1 + ${EndIf} + ; A leftover runtime DLL counts as an install as well. A previously failed + ; update can leave a directory in which the old uninstaller removed the + ; executables and uninstall.exe but silently skipped the DLLs its running + ; instance had locked - which is exactly the broken install the purge is + ; meant to heal, and without these markers none of the files above would be + ; there to prove that it is one. Every Qt module Cockatrice links is part of + ; the payload, so match them all: probing only Qt6Core.dll would miss a + ; directory where that one file happened to be deletable and the others + ; were not. Qt6*.dll rather than *.dll on purpose: this function decides + ; whether a directory is ours, and a folder that merely holds some DLLs of + ; its own must not be purged. Qt6*.dll is enough even when only the protobuf + ; runtime survived, because the whole Qt set is mapped by the same process + ; that maps abseil_dll.dll and so is locked or free with it. + StrCpy $R3 "Qt6*.dll" + Call CheckStaleRuntimeMask + ${If} ${FileExists} "$INSTDIR\qt.conf" + StrCpy $R0 1 + ${EndIf} +FunctionEnd + +; Sets $R0 to 1 if a runtime file of a previous version is still present after +; the purge. NSIS silently skips whatever it cannot delete, so one surviving file +; is enough to prove that something still holds a lock on the install directory. +; +; Any DLL at all counts, not a list of expected ones. The purge removes every +; top-level DLL, so after it has run the only DLL that can still be there is one +; it failed to delete, whatever its name. A mask per library family (Qt, then +; OpenSSL, then zlib) is what let abseil_dll.dll through: it is in neither the +; purge nor this check, so it survived a 3.0.2 to 3.1 update untouched, the new +; executables were written next to it, and the client then failed to start with +; "abseil_dll.dll is either not designed to run on Windows or it contains an +; error". Probing *.dll has no list to fall out of date when the next +; transitive dependency appears. +Function HasStaleRuntimeFiles + StrCpy $R0 0 + StrCpy $R3 "*.dll" + Call CheckStaleRuntimeMask + ; IfFileExists takes wildcards, so a Plugins directory the purge could not + ; remove counts as a leftover too. + ${If} ${FileExists} "$INSTDIR\Plugins\*.*" + !insertmacro CockLog "stale-runtime: Plugins directory survived the purge" + StrCpy $R0 1 + ${EndIf} +FunctionEnd + +; usage: set $R3 to a file mask, call this. Sets $R0 to 1 when $INSTDIR holds a +; matching file. Returns early once something was found, so $R0 stays 1. +Function CheckStaleRuntimeMask + ${If} $R0 = 1 + Return + ${EndIf} + FindFirst $StaleFindHandle $StaleFindName "$INSTDIR\$R3" + ${If} $StaleFindHandle != "" + !insertmacro CockLog "stale-runtime: '$StaleFindName' matched $R3 in $INSTDIR" + StrCpy $R0 1 + FindClose $StaleFindHandle + ${EndIf} +FunctionEnd + +; Uninstaller copies of the same routines (the uninstaller gets its own +; function set compiled in, it cannot call the installer functions). +Function un.IsAppRunning + ; An unset $GuardDir would match every process that merely shares the image + ; name, see IsAppRunning in the installer. + ${If} $GuardDir == "" + StrCpy $R0 2 + Return + ${EndIf} + ; Pass the directory to guard to PowerShell via an environment variable so + ; it never has to be embedded into the -Command string: apostrophes in a + ; path would terminate the command early, and the path in a -like pattern + ; would treat [ and ] as wildcard characters. + System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' + ; Exit the process explicitly so nsExec's pop reflects whether a matching + ; process was found: powershell.exe exits 0 whether or not the pipeline + ; produced objects. 0 = not running, 1 = running, anything else = could not + ; be determined (see un.IsProcessLookupAvailable). + ; Strict match: a readable path under $GuardDir, same as in the installer. + nsExec::ExecToLog 'powershell -NoProfile -Command "if (Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | Select-Object -First 1) { exit 1 } else { exit 0 }"' + Pop $R0 + ${If} $R0 == "0" + StrCpy $R0 0 + ${ElseIf} $R0 == "1" + StrCpy $R0 1 + ${Else} + ; powershell.exe could not be run at all (nsExec pushes the string + ; "error"), or died with an unexpected code. That is "could not + ; determine", which has to stay distinguishable from "definitely + ; running": a state that can never clear would block the uninstaller + ; forever. un.IsProcessLookupAvailable warns about that case once. + StrCpy $R0 2 + ${EndIf} +FunctionEnd + +Function un.IsProcessLookupAvailable + nsExec::ExecToLog 'powershell -NoProfile -Command "exit 0"' + Pop $R0 + ${If} $R0 == "0" + StrCpy $R0 1 + ${Else} + StrCpy $R0 0 + ${EndIf} +FunctionEnd + +Function un.CloseMatchingApps + ; gracefully ask every matching instance to close (sends WM_CLOSE) + ; + ; Strict filter, same as in the installer: never close a process whose path + ; cannot be read, it is not necessarily one of ours. + System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' + nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | ForEach-Object { $$null = $$_.CloseMainWindow() }"' + Pop $R3 +FunctionEnd + +Function un.ForceCloseApps + ; stop the matching instances by PID once the grace period is over, see + ; ForceCloseApps in the installer. + System::Call 'kernel32::SetEnvironmentVariable(t, t) i ("COCKATRICE_GUARD_DIR", "$GuardDir")' + nsExec::ExecToLog 'powershell -NoProfile -Command "Get-Process -Name $\'$R2$\' -ErrorAction SilentlyContinue | Where-Object { try { $$p = $$_.Path; if ($$p) { $$p.StartsWith($$env:COCKATRICE_GUARD_DIR, [StringComparison]::OrdinalIgnoreCase) } else { $$false } } catch { $$false } } | ForEach-Object { Stop-Process -Id $$_.Id -Force -ErrorAction SilentlyContinue }"' + Pop $R3 +FunctionEnd + +Function un.WaitForAppToClose + Call un.IsAppRunning + ; Only a definite "running" is worth waiting for, see WaitForAppToClose. + ${If} $R0 != 1 + Return + ${EndIf} + + ${If} ${Silent} + DetailPrint "Closing $R1 ..." + Call un.CloseMatchingApps + ; wall-clock deadline: each poll has to spawn a fresh powershell.exe, so + ; a bare loop counter would not bound the real wait time to 60 s. + System::Call 'kernel32::GetTickCount()i.s' + Pop $R8 + un_ck_wait_loop: + Sleep 500 + Call un.IsAppRunning + ${If} $R0 = 0 + DetailPrint "$R1 closed." + Return + ${EndIf} + ${If} $R0 != 1 + Return + ${EndIf} + System::Call 'kernel32::GetTickCount()i.s' + Pop $R9 + IntOp $R9 $R9 - $R8 + ${If} $R9 < 60000 + Goto un_ck_wait_loop + ${EndIf} + ; The grace period is over. Cockatrice is never force-stopped, see + ; WaitForAppToClose in the installer. A silent uninstall only reports + ; through the log and its exit code: it is also run unattended (the /R + ; updater uses it via QuietUninstallString, package managers use it + ; directly), and a message box would block there. The installer that ran + ; us reports the incomplete result to the user itself. + ${If} $AllowForceClose = 0 + DetailPrint "$R1 is still running after 60 s; aborting so that no data is lost." + SetErrorLevel 1 + Abort + ${EndIf} + DetailPrint "Force-stopping $R1 ..." + Call un.ForceCloseApps + Sleep 2000 + Call un.IsAppRunning + ${If} $R0 = 0 + DetailPrint "$R1 closed." + Return + ${EndIf} + ${If} $R0 != 1 + Return + ${EndIf} + DetailPrint "$R1 is still running after 60 s; aborting." + SetErrorLevel 1 + Abort + ${Else} + un_ck_wait_prompt: + MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION|MB_DEFBUTTON1 \ + "$R1 is still running.$\r$\n$\r$\nPlease close it, then click Retry.$\r$\nClick Cancel to abort." \ + IDCANCEL un_ck_abort_install + Call un.IsAppRunning + ${If} $R0 = 0 + Return + ${EndIf} + ${If} $R0 != 1 + Return + ${EndIf} + Goto un_ck_wait_prompt + un_ck_abort_install: + Abort + ${EndIf} +FunctionEnd + +Function un.EnsureAppsNotRunning + ; guards the install directory; the uninstaller only ever has to check this one + StrCpy $GuardDir $INSTDIR + Call un.GuardAppsNotRunning +FunctionEnd + +Function un.GuardAppsNotRunning + Call un.IsProcessLookupAvailable + ${If} $R0 = 0 + ; Same reasoning as in the installer: without PowerShell we cannot tell + ; whether a running instance still holds file locks, and refusing to + ; uninstall would leave the user with an uninstaller that never + ; completes. Warn once and continue. + DetailPrint "Could not start powershell.exe; skipping the running instance check. Close Cockatrice, Oracle and Servatrice manually if the uninstall reports a problem." + MessageBox MB_OK|MB_ICONEXCLAMATION "This uninstaller could not check whether Cockatrice is still running, because powershell.exe could not be started.$\r$\n$\r$\nPlease close Cockatrice, Oracle and Servatrice manually before continuing." + Return + ${EndIf} + StrCpy $R1 "cockatrice.exe" + StrCpy $R2 "cockatrice" + ; a GUI application with unsaved user data: graceful close only + StrCpy $AllowForceClose 0 + Call un.WaitForAppToClose + StrCpy $R1 "oracle.exe" + StrCpy $R2 "oracle" + StrCpy $AllowForceClose 1 + Call un.WaitForAppToClose + StrCpy $R1 "servatrice.exe" + StrCpy $R2 "servatrice" + StrCpy $AllowForceClose 1 + Call un.WaitForAppToClose +FunctionEnd + Function PortableModePageCreate ${If} $ReinstallMode = 1 @@ -258,8 +1031,16 @@ ${If} $PortableMode = 0 ${EndIf} uninst32: + ; the old uninstaller is run here, before the directory page, and every + ; uninstaller predating this change has no guard of its own: make sure no + ; instance of the application is still running out of the *old* install + ; directory before letting it delete anything. The uninstall string is + ; stashed away first, the check works on $R0. + StrCpy $OldUninstallString $R0 + !insertmacro CockLog "componentsPagePre: old 32-bit uninstall '$OldUninstallString _?=$INSTDIR'" + Call EnsureOldInstallNotRunning ClearErrors - ExecWait "$R0" + ExecWait "$OldUninstallString _?=$INSTDIR" done32: @@ -278,8 +1059,11 @@ ${If} $PortableMode = 0 ${EndIf} uninst64: + StrCpy $OldUninstallString $R0 + !insertmacro CockLog "componentsPagePre: old 64-bit uninstall '$OldUninstallString _?=$INSTDIR'" + Call EnsureOldInstallNotRunning ClearErrors - ExecWait "$R0" + ExecWait "$OldUninstallString _?=$INSTDIR" done64: ${EndIf} @@ -294,9 +1078,35 @@ Section "Application" SecApplication SetShellVarContext all SetOutPath "$INSTDIR" +!insertmacro CockLog "section: InstDir=$INSTDIR PortableMode=$PortableMode ReinstallMode=$ReinstallMode" + +; No application instance may be running while any file below is deleted or +; replaced: both upgrade blocks start by removing the old executables and +; runtime DLLs, and NSIS silently skips whatever is locked, which is how an +; install ends up with a mix of old and new runtimes next to the new executable. +; The next start then fails with "The procedure entry point X could not be +; located in the dynamic link library ...Qt6Network.dll" for the Qt ones, or +; "abseil_dll.dll is either not designed to run on Windows or it contains an +; error" for the protobuf ones. So the check has to come before the first +; Delete/RMDir and not after it: an interactive install into an existing portable +; directory with cockatrice.exe still running would otherwise delete the old +; files under its feet. +; +; It is skipped on the /R path: .onInit already ran it before +; AutoUninstallIfNeeded removed the previous installation, so by the time this +; section starts the old files are gone and a second Abort here would leave the +; user with the old version uninstalled and the new one not installed at all, +; which is worse than the locked files being prevented. The leftover runtime +; files are reported at the end of the purge instead. +${If} $ReinstallMode = 0 + Call EnsureAppsNotRunning +${EndIf} + ${If} $PortableMode = 1 ${AndIf} ${FileExists} "$INSTDIR\portable.dat" ; upgrade portable mode + !insertmacro CockLog "section: portable upgrade purge" + !insertmacro CockLogListing "before-portable-purge" "*.dll" RMDir /r "$INSTDIR\plugins" RMDir /r "$INSTDIR\sounds" RMDir /r "$INSTDIR\themes" @@ -305,21 +1115,74 @@ ${AndIf} ${FileExists} "$INSTDIR\portable.dat" Delete "$INSTDIR\cockatrice.exe" Delete "$INSTDIR\oracle.exe" Delete "$INSTDIR\servatrice.exe" - Delete "$INSTDIR\Qt*.dll" - Delete "$INSTDIR\libmysql.dll" - Delete "$INSTDIR\icu*.dll" - Delete "$INSTDIR\libeay32.dll" - Delete "$INSTDIR\ssleay32.dll" + ; Every top-level DLL, not a list of expected ones. The payload is written + ; after this block, so a DLL the new build still needs is put straight back; + ; what this has to guarantee is that nothing from the previous build is left + ; to be loaded next to the new executables. Naming the families by hand is + ; what let abseil_dll.dll through; see HasStaleRuntimeFiles. + Delete "$INSTDIR\*.dll" Delete "$INSTDIR\qt.conf" Delete "$INSTDIR\qdebug.txt" Delete "$INSTDIR\servatrice.sql" Delete "$INSTDIR\servatrice.ini.example" - Delete "$INSTDIR\zlib*.dll" RMDir "$INSTDIR" + !insertmacro CockLogListing "after-portable-purge" "*.dll" +${EndIf} + +; Remove any runtime DLLs left over from older versions, so a mismatched +; Qt/OpenSSL/protobuf set cannot survive next to the new executables. +Call IsExistingCockatriceInstall +!insertmacro CockLog "section: IsExistingCockatriceInstall=$R0" +; Remembered because the stale-runtime check below must only ever fire for a +; directory this installer recognised as a Cockatrice install. Scanning for +; leftovers in an unrelated directory (a first-time install into a folder that +; happens to hold some Qt5 DLLs, say) would otherwise turn those files into an +; abort reason even though the payload is about to overwrite nothing of theirs. +StrCpy $IsUpgrade $R0 +${If} $PortableMode = 0 +${AndIf} $R0 = 1 + !insertmacro CockLog "section: non-portable upgrade purge" + !insertmacro CockLogListing "before-purge" "*.dll" + RMDir /r "$INSTDIR\Plugins" + Delete "$INSTDIR\*.dll" + !insertmacro CockLogListing "after-purge" "*.dll" + !insertmacro CockLogListing "after-plugins" "Plugins\*.*" +${EndIf} + +; Whatever was locked during the purge (by an instance that started while the +; installer was running, or because the running instance check could not be +; performed at all) is still on disk, and the new executables are about to be +; written next to it. That mix is exactly what makes the next start fail with +; "The procedure entry point X could not be located in the dynamic link library +; ...Qt6QuickWidgets.dll", "The code execution cannot proceed because +; Qt6Multimedia.dll was not found", or "abseil_dll.dll is either not designed to +; run on Windows or it contains an error", so stop here instead of compounding +; it. Nothing of the new payload has been written at this point, so the next run, +; with the applications closed, gets a clean install. +${If} $IsUpgrade = 1 + Call HasStaleRuntimeFiles + !insertmacro CockLog "section: HasStaleRuntimeFiles=$R0" +${Else} + StrCpy $R0 0 +${EndIf} +${If} $R0 = 1 + !insertmacro CockLog "section: aborting, files of a previous version survived the purge" + DetailPrint "Files of a previous version could not be removed; aborting the installation." + ; The in-app updater runs this installer silent and detached and never looks + ; at its exit code, so the user has to be told what happened. A /S install + ; without /R is unattended (a package manager, for instance) and a message + ; box there would block forever, so that case only reports through the log + ; and the exit code. AbortInstall owns that decision. + StrCpy $AbortReason \ + "The installation could not remove all files of a previous version of Cockatrice, most likely because it is still running or was started as administrator, and was cancelled rather than leave a mismatched install behind.$\r$\n$\r$\nPlease close Cockatrice, Oracle and Servatrice and run the installer again." + Call AbortInstall ${EndIf} @CPACK_NSIS_EXTRA_PREINSTALL_COMMANDS@ +!insertmacro CockLog "section: writing payload to $INSTDIR" @CPACK_NSIS_FULL_INSTALL@ +!insertmacro CockLog "section: payload written" +!insertmacro CockLogListing "final" "Qt6*.dll" ${If} $PortableMode = 0 WriteUninstaller "$INSTDIR\uninstall.exe" @@ -345,6 +1208,16 @@ ${If} $PortableMode = 0 WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "VersionMajor" "@CPACK_PACKAGE_VERSION_MAJOR@" WriteRegDWORD HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" "VersionMinor" "@CPACK_PACKAGE_VERSION_MINOR@" + ; --- Register .cod file type --- + WriteRegStr HKCR ".cod" "" "Cockatrice" + WriteRegStr HKCR "Cockatrice" "" "Cockatrice Deck File" + WriteRegStr HKCR "Cockatrice\shell\open\command" "" '"$INSTDIR\cockatrice.exe" "%1"' + + ; --- Register custom URI protocol --- + WriteRegStr HKCR "cockatrice" "" "URL: Cockatrice Protocol" + WriteRegStr HKCR "cockatrice" "URL Protocol" "" + WriteRegStr HKCR "cockatrice\shell\open\command" "" '"$INSTDIR\cockatrice.exe" "%1"' + IfFileExists "$INSTDIR\vc_redist.x86.exe" VcRedist86Exists PastVcRedist86Check VcRedist86Exists: ExecWait '"$INSTDIR\vc_redist.x86.exe" /passive /norestart' @@ -373,6 +1246,7 @@ ${If} $ReinstallMode = 1 Exec '"$INSTDIR\cockatrice.exe"' ${EndIf} +!insertmacro CockLog "section: application install complete" SectionEnd Section "Start menu item" SecStartMenu @@ -387,21 +1261,34 @@ SectionEnd Section "un.Application" UnSecApplication SetShellVarContext all - RMDir /r "$INSTDIR\plugins" - RMDir /r "$INSTDIR\sounds" - RMDir /r "$INSTDIR\themes" - RMDir /r "$INSTDIR\translations" - Delete "$INSTDIR\*.exe" - Delete "$INSTDIR\*.dll" - Delete "$INSTDIR\qt.conf" - Delete "$INSTDIR\qdebug.txt" - Delete "$INSTDIR\servatrice.sql" - Delete "$INSTDIR\servatrice.ini.example" - RMDir "$INSTDIR" + StrCpy $R1 0 - RMDir "$SMPROGRAMS\Cockatrice" + ; Record whether this was a portable install before the directory (and the + ; portable.dat marker inside it) is removed below. + IfFileExists "$INSTDIR\portable.dat" 0 +2 + StrCpy $R1 1 + + ; Remove the entire application directory so any file that is not part of + ; the installed payload (e.g. build-tree artifacts such as *.dir folders, + ; *_autogen and *.tlog files from a build) cannot survive between an + ; uninstall and a fresh reinstall. + RMDir /r "$INSTDIR" + + RMDir /r "$SMPROGRAMS\Cockatrice" DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Cockatrice" + + ; Only remove the file/protocol associations if we registered them (i.e. the + ; install was not portable) and .cod is still owned by Cockatrice, so we don't + ; clobber a .cod association installed by another application. + ${If} $R1 == 0 + ReadRegStr $0 HKCR ".cod" "" + ${If} $0 == "Cockatrice" + DeleteRegKey HKCR ".cod" + DeleteRegKey HKCR "Cockatrice" + DeleteRegKey HKCR "cockatrice" + ${EndIf} + ${EndIf} SectionEnd ; unselected because it is /o diff --git a/cmake/Sanitizers.cmake b/cmake/Sanitizers.cmake new file mode 100644 index 000000000..2e5d794de --- /dev/null +++ b/cmake/Sanitizers.cmake @@ -0,0 +1,109 @@ +# AddressSanitizer + UndefinedBehaviorSanitizer opt-in, plus per-source opt-out +# Input: ENABLE_SANITIZERS +# Defines: cockatrice_disable_sanitizers_for +# +# Included once from the root CMakeLists.txt, immediately after the per-compiler flag +# block, so add_compile_options() reaches every library and executable defined below. + +include(CheckCXXSourceCompiles) + +# Opt a set of vendored sources out of instrumentation. +# +# cockatrice_disable_sanitizers_for( ...) +# +# Sanitizer findings inside vendored code are not actionable for us, and letting them +# through buries our own bugs in noise until everyone learns to skip the job. +# +# The sources must be named relative to the directory that compiles them. CMake resolves a +# source-file property against the directory scope the target was declared in, so calling +# this from the root with absolute paths silently does nothing. That is why the call sites +# live next to the add_library/add_executable that pull the vendored code in. +# +# SKIP_PRECOMPILE_HEADERS is not optional. The precompiled header is built with the +# sanitizer flags, so it defines __SANITIZE_ADDRESS__; a translation unit compiled with +# -fno-sanitize=all does not, and GCC then refuses the PCH with +# "not used because __SANITIZE_ADDRESS__ not defined", which is a fatal -Werror=invalid-pch +# in the servatrice and oracle builds. +function(cockatrice_disable_sanitizers_for) + if(NOT ENABLE_SANITIZERS) + return() + endif() + set_source_files_properties(${ARGN} PROPERTIES COMPILE_OPTIONS "-fno-sanitize=all" SKIP_PRECOMPILE_HEADERS ON) +endfunction() + +if(ENABLE_SANITIZERS) + # Probe a flag the way it will actually be used: on the compile step AND the link step. + # check_cxx_compiler_flag() only probes the compile step, which makes every sanitizer + # flag look unsupported because linking an ASan object without -fsanitize=address leaves + # __asan_init undefined. That is a silent misconfiguration rather than an error, so the + # probe has to link. + function(cockatrice_probe_flag FLAG VARNAME) + set(CMAKE_REQUIRED_FLAGS "${FLAG}") + set(CMAKE_REQUIRED_LINK_OPTIONS "${FLAG}") + check_cxx_source_compiles("int main() { return 0; }" ${VARNAME}) + unset(CMAKE_REQUIRED_FLAGS) + unset(CMAKE_REQUIRED_LINK_OPTIONS) + endfunction() + + set(SANITIZER_FLAGS + -fsanitize=address,undefined + -fno-omit-frame-pointer + # UBSan defaults to recover mode: it prints "runtime error: ..." but the + # process exits 0, so gtest/ctest report a pass and CI stays green. + # -fno-sanitize-recover makes the first finding abort instead. This is + # probed like the other cosmetic flags, but CI also sets + # UBSAN_OPTIONS=halt_on_error=1 as the runtime-side guarantee. + -fno-sanitize-recover=undefined + # Qt builds QObject subclasses into raw storage and writes the vptr afterwards, which + # the vptr check reports on essentially every QObject instantiation. The check has no + # value for us because nothing outside moc-generated code inherits QObject, so it is + # off outright rather than suppressed piecemeal. + -fno-sanitize=vptr + ) + + # MSVC has no ASan/UBSan equivalent, and clang-cl would need /fsanitize=address plus a + # matching runtime link, which is a separate piece of work. + if(MSVC) + message(FATAL_ERROR "ENABLE_SANITIZERS is not supported with MSVC") + endif() + + # Probe rather than assume, so an older toolchain gets the sanitizers it can support + # instead of failing the build on a flag it does not recognize. + set(SANITIZER_SUPPORTED_FLAGS) + foreach(FLAG ${SANITIZER_FLAGS}) + string(MAKE_C_IDENTIFIER "COCKATRICE_HAS${FLAG}" _FLAG_VAR) + cockatrice_probe_flag("${FLAG}" ${_FLAG_VAR}) + if(${_FLAG_VAR}) + list(APPEND SANITIZER_SUPPORTED_FLAGS ${FLAG}) + else() + message(STATUS "Sanitizers: ${FLAG} unsupported by ${CMAKE_CXX_COMPILER_ID}, skipping") + endif() + endforeach() + + # The whole point of the option is the sanitizers, so an unsupported pair is fatal even + # though the cosmetic flags above are allowed to degrade. + if(NOT "-fsanitize=address,undefined" IN_LIST SANITIZER_SUPPORTED_FLAGS) + message(FATAL_ERROR "ENABLE_SANITIZERS requires -fsanitize=address,undefined, which " + "${CMAKE_CXX_COMPILER_ID} does not accept" + ) + endif() + + add_compile_options(${SANITIZER_SUPPORTED_FLAGS}) + add_link_options(-fsanitize=address,undefined) + + # Recorded so the test tree can label its entries and a plain `make` at the same time + # cannot quietly disagree with what CI ran. + set(COCKATRICE_SANITIZERS + ON + CACHE INTERNAL "AddressSanitizer and UBSan are enabled" + ) +else() + # ENABLE_SANITIZERS is OFF, but COCKATRICE_SANITIZERS is CACHE INTERNAL, so a + # reused build dir (e.g. CI's release-package step reconfiguring the same dir + # after the sanitized debug step) would otherwise keep skipping NO_SANITIZE + # tests and keep the 300s discovery timeout. Reset it explicitly. + set(COCKATRICE_SANITIZERS + OFF + CACHE INTERNAL "AddressSanitizer and UBSan are enabled" FORCE + ) +endif() diff --git a/cmake/pch/qtcore_pch.h b/cmake/pch/qtcore_pch.h new file mode 100644 index 000000000..176f2939c --- /dev/null +++ b/cmake/pch/qtcore_pch.h @@ -0,0 +1,55 @@ +/** @file qtcore_pch.h + * @brief Precompiled header for all Qt targets (Qt Core only). + * + * Safe for every target that links Qt Core, including the headless + * Servatrice binary. Keep this header free of any widget/gui types. + * + * Candidates are chosen from IWYU include statistics: a header earns a + * spot when it is not already reachable from the rest of this list and + * enough translation units re-parse it. Qt headers are stable across + * builds, so the precompiled header rarely invalidates. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include diff --git a/cmake/pch/qtwidgets_pch.h b/cmake/pch/qtwidgets_pch.h new file mode 100644 index 000000000..030bec69c --- /dev/null +++ b/cmake/pch/qtwidgets_pch.h @@ -0,0 +1,59 @@ +/** @file qtwidgets_pch.h + * @brief Precompiled header for GUI targets (Cockatrice client, Oracle). + * + * Includes the Qt Core precompiled header plus the heavy Gui, Widgets and + * Network layers that virtually every client translation unit re-parses. + * Do not use on Servatrice (headless, QT_DONT_USE_QTGUI). + * + * Candidates are chosen from IWYU include statistics: a header earns a + * spot when it is not already reachable from the rest of this list and + * enough translation units re-parse it. Qt headers are stable across + * builds, so the precompiled header rarely invalidates. + */ + +#include "qtcore_pch.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index 678ff27bc..44221fe85 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -15,6 +15,9 @@ set(cockatrice_SOURCES src/client/network/update/client/client_update_checker.cpp src/client/network/update/client/release_channel.cpp src/client/network/update/card_spoiler/spoiler_background_updater.cpp + src/client/latency_graph_widget.cpp + src/client/latency_status_widget.cpp + src/client/lag_monitor.cpp src/client/sound_engine.cpp src/client/settings/cache_settings.cpp src/client/settings/card_counter_settings.cpp @@ -36,14 +39,20 @@ set(cockatrice_SOURCES src/interface/widgets/dialogs/dlg_forgot_password_challenge.cpp src/interface/widgets/dialogs/dlg_forgot_password_request.cpp src/interface/widgets/dialogs/dlg_forgot_password_reset.cpp + src/interface/widgets/dialogs/dlg_invite_to_game.cpp src/interface/widgets/dialogs/dlg_load_deck.cpp src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp src/interface/widgets/dialogs/dlg_load_remote_deck.cpp src/interface/widgets/dialogs/dlg_local_game_options.cpp + src/interface/widgets/dialogs/dlg_login_prompt.cpp src/interface/widgets/dialogs/dlg_manage_sets.cpp + src/interface/widgets/dialogs/dlg_my_reports.cpp src/interface/widgets/dialogs/dlg_register.cpp + src/interface/widgets/dialogs/dlg_report_user.cpp src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp + src/interface/widgets/dialogs/dlg_share_deck.cpp + src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp src/interface/widgets/dialogs/dlg_settings.cpp src/interface/widgets/dialogs/dlg_startup_card_check.cpp src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp @@ -51,6 +60,9 @@ set(cockatrice_SOURCES src/interface/widgets/dialogs/dlg_view_log.cpp src/interface/widgets/dialogs/override_printing_warning.cpp src/interface/widgets/dialogs/tip_of_the_day.cpp + src/interface/widgets/deck_share/deck_share_utils.cpp + src/interface/widgets/deck_share/shared_deck_preview_widget.cpp + src/interface/widgets/deck_share/share_bar_widget.cpp src/filters/deck_filter_string.cpp src/filters/filter_builder.cpp src/filters/filter_tree_model.cpp @@ -128,6 +140,10 @@ set(cockatrice_SOURCES src/game_graphics/zones/view_zone.cpp src/game_graphics/zones/view_zone_widget.cpp src/game_graphics/board/abstract_graphics_item.cpp + src/interface/card_database_update/card_database_update_status_bar.cpp + src/interface/card_database_update/card_database_update_status_bar.h + src/interface/card_database_update/card_update_progress.cpp + src/interface/card_database_update/card_update_progress.h src/interface/card_picture_loader/card_picture_loader.cpp src/interface/card_picture_loader/card_picture_loader_local.cpp src/interface/card_picture_loader/card_picture_loader_request_status_display_widget.cpp @@ -143,7 +159,11 @@ set(cockatrice_SOURCES src/interface/intents/intent_wait_for_database_load.h src/interface/layouts/flow_layout.cpp src/interface/layouts/overlap_layout.cpp + src/interface/widgets/utility/card_completer_delegate.cpp + src/interface/widgets/utility/card_completer_styler.cpp + src/interface/widgets/utility/completer_utils.cpp src/interface/widgets/utility/line_edit_completer.cpp + src/interface/widgets/utility/reversed_completer_model.cpp src/interface/pixel_map_generator.cpp src/interface/theme_config.cpp src/interface/theme_manager.cpp @@ -153,8 +173,11 @@ set(cockatrice_SOURCES src/interface/palette_editor/palette_grid_widget.cpp src/interface/palette_editor/palette_editor_dialog.cpp src/interface/widgets/cards/additional_info/color_identity_widget.cpp + src/interface/widgets/cards/additional_info/deck_color_identity.cpp src/interface/widgets/cards/additional_info/mana_cost_widget.cpp src/interface/widgets/cards/additional_info/mana_symbol_widget.cpp + src/interface/widgets/cards/art_crop_attribution.cpp + src/interface/widgets/cards/card_art_utils.cpp src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp src/interface/widgets/cards/card_group_display_widgets/flat_card_group_display_widget.cpp src/interface/widgets/cards/card_group_display_widgets/overlapped_card_group_display_widget.cpp @@ -203,6 +226,7 @@ set(cockatrice_SOURCES src/interface/widgets/deck_editor/deck_editor_printing_selector_dock_widget.cpp src/interface/widgets/deck_editor/deck_list_style_proxy.cpp src/interface/widgets/deck_editor/deck_state_manager.cpp + src/interface/widgets/deck_editor/deck_zone_dialog.cpp src/interface/widgets/deck_editor/printing_disabled_info_widget.cpp src/interface/widgets/general/background_sources.cpp src/interface/widgets/general/display/background_plate_widget.cpp @@ -219,6 +243,7 @@ set(cockatrice_SOURCES src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp src/interface/widgets/general/display/charts/pies/color_pie.cpp src/interface/widgets/general/home_styled_button.cpp + src/interface/widgets/general/home_tab_button_color.h src/interface/widgets/general/home_widget.cpp src/interface/widgets/general/layout_containers/flow_widget.cpp src/interface/widgets/general/layout_containers/overlap_control_widget.cpp @@ -237,6 +262,10 @@ set(cockatrice_SOURCES src/interface/widgets/printing_selector/printing_selector_card_selection_widget.cpp src/interface/widgets/printing_selector/printing_selector_card_sorting_widget.cpp src/interface/widgets/printing_selector/set_name_and_collectors_number_display_widget.cpp + src/interface/widgets/playmat/playmat_collection_dialog.cpp + src/interface/widgets/playmat/playmat_collection_dialog.h + src/interface/widgets/playmat/playmat_preview_widget.cpp + 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_manager.cpp @@ -245,6 +274,7 @@ set(cockatrice_SOURCES src/interface/widgets/replay/replay_widget.cpp src/interface/widgets/server/chat_view/chat_view.cpp src/interface/widgets/server/game_filter_configs.cpp + src/interface/widgets/server/game_link.cpp src/interface/widgets/server/game_selector.cpp src/interface/widgets/server/game_selector_quick_filter_toolbar.cpp src/interface/widgets/server/games_model.cpp @@ -257,8 +287,10 @@ set(cockatrice_SOURCES src/interface/widgets/server/user/user_context_menu.cpp src/interface/widgets/server/user/user_info_box.cpp src/interface/widgets/server/user/user_info_connection.cpp + src/interface/widgets/server/user/user_list_dialog.cpp src/interface/widgets/server/user/user_list_manager.cpp src/interface/widgets/server/user/user_list_painter.cpp + src/interface/widgets/server/user/user_list_panel_widget.cpp src/interface/widgets/server/user/user_list_widget.cpp src/interface/widgets/settings_page/abstract_settings_page.cpp src/interface/widgets/settings_page/appearance_settings_page.cpp @@ -273,6 +305,8 @@ set(cockatrice_SOURCES src/interface/widgets/settings_page/user_interface_settings_page.cpp src/interface/widgets/utility/custom_line_edit.cpp src/interface/widgets/utility/get_text_with_max.cpp + src/interface/widgets/utility/report_utils.cpp + src/interface/widgets/utility/report_utils.h src/interface/widgets/utility/sequence_edit.cpp src/interface/widgets/utility/visibility_change_listener.cpp src/interface/widgets/utility/visibility_change_listener.h @@ -297,9 +331,13 @@ set(cockatrice_SOURCES src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_item_widget.cpp src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp + src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.cpp + src/interface/widgets/visual_deck_storage/remote_public_decks_model.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp + src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp + src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.cpp @@ -360,10 +398,14 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/tab_card_art_rules.cpp src/interface/widgets/tabs/tab_deck_editor.cpp src/interface/widgets/tabs/tab_deck_storage.cpp + src/interface/widgets/tabs/tab_developer.cpp src/interface/widgets/tabs/tab_game.cpp src/interface/widgets/tabs/tab_home.cpp src/interface/widgets/tabs/tab_logs.cpp src/interface/widgets/tabs/tab_message.cpp + src/interface/widgets/tabs/tab_moderation.cpp + src/interface/widgets/tabs/tab_public_decks.cpp + src/interface/widgets/tabs/tab_report.cpp src/interface/widgets/tabs/tab_replays.cpp src/interface/widgets/tabs/tab_room.cpp src/interface/widgets/tabs/tab_server.cpp @@ -377,6 +419,28 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.cpp src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp + src/interface/widgets/onboarding/banner_shader_config.h + src/interface/widgets/onboarding/brand_colors.h + src/interface/widgets/onboarding/first_run_wizard.cpp + src/interface/widgets/onboarding/first_run_wizard.h + src/interface/widgets/onboarding/first_run_wizard_page.cpp + src/interface/widgets/onboarding/first_run_wizard_page.h + src/interface/widgets/onboarding/pages/account_setup_page.cpp + src/interface/widgets/onboarding/pages/account_setup_page.h + src/interface/widgets/onboarding/pages/card_database_setup_page.cpp + src/interface/widgets/onboarding/pages/card_database_setup_page.h + src/interface/widgets/onboarding/pages/finish_page.cpp + src/interface/widgets/onboarding/pages/finish_page.h + src/interface/widgets/onboarding/pages/preferences_setup_page.cpp + src/interface/widgets/onboarding/pages/preferences_setup_page.h + src/interface/widgets/onboarding/pages/theme_setup_page.cpp + src/interface/widgets/onboarding/pages/theme_setup_page.h + src/interface/widgets/onboarding/pages/welcome_page.cpp + src/interface/widgets/onboarding/pages/welcome_page.h + src/interface/widgets/onboarding/shader_banner_widget.cpp + src/interface/widgets/onboarding/shader_banner_widget.h + src/interface/widgets/onboarding/step_indicator_widget.cpp + src/interface/widgets/onboarding/step_indicator_widget.h src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.cpp src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.h src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.cpp @@ -395,6 +459,10 @@ set(cockatrice_SOURCES src/interface/intents/intent_join_server_room.h src/interface/intents/intent_login.cpp src/interface/intents/intent_login.h + src/interface/intents/intent_open_server_room_by_name.cpp + src/interface/intents/intent_open_server_room_by_name.h + src/interface/intents/intent_open_shared_deck.cpp + src/interface/intents/intent_open_shared_deck.h src/interface/intents/url_parser.cpp src/interface/intents/url_parser.h src/interface/widgets/server/user/user_info_popup.cpp @@ -471,6 +539,31 @@ qt6_add_executable( MANUAL_FINALIZATION ) +target_precompile_headers(cockatrice PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtwidgets_pch.h") + +qt6_add_shaders( + cockatrice + "onboarding_shaders" + PREFIX + "/onboarding/shaders" + BASE + "src/interface/widgets/onboarding/shaders" + FILES + src/interface/widgets/onboarding/shaders/brand_banner.frag + src/interface/widgets/onboarding/shaders/brand_plate.frag +) + +qt6_add_resources( + cockatrice + "onboarding_qml" + PREFIX + "/onboarding/qml" + BASE + "src/interface/widgets/onboarding/qml" + FILES + src/interface/widgets/onboarding/qml/BrandBanner.qml +) + target_link_libraries( cockatrice PUBLIC libcockatrice_card @@ -582,18 +675,53 @@ if(WIN32) set(qtconf_dest_dir .) install( - DIRECTORY "${CMAKE_BINARY_DIR}/${PROJECT_NAME}/${CMAKE_BUILD_TYPE}/" + DIRECTORY "$/" DESTINATION ./ FILES_MATCHING PATTERN "*.dll" + PATTERN "*.pdb" EXCLUDE + PATTERN "*.dir*" EXCLUDE + PATTERN "*_autogen*" EXCLUDE + PATTERN "*.tlog*" EXCLUDE + PATTERN "CMakeFiles*" EXCLUDE + PATTERN "x64*" EXCLUDE + PATTERN ".qt*" EXCLUDE + PATTERN ".qsb*" EXCLUDE + PATTERN ".lupdate*" EXCLUDE ) + # fixup_bundle further down copies only the DLLs it can find in Cockatrice.exe's + # import table, and the glob above only sees DLLs that were already copied next + # to the executable. Qt6::Concurrent is linked but never imported - QtConcurrent::run + # and QFutureWatcher are header-only in Qt 6 - so neither step ships + # Qt6Concurrent.dll, even though required-runtime.txt lists it and the + # Windows package check then fails on a package that is fine to run. + # Qt6::ShaderTools is skipped for the same reason. + # + # Install the runtime of every linked module explicitly. An unused DLL costs a + # few hundred kilobytes; a missing one is what stops the client starting after + # an update, and it stays invisible until then. + foreach(_qt_module IN LISTS COCKATRICE_QT_MODULES) + get_target_property(_qt_module_type ${_qt_module} TYPE) + if(_qt_module_type STREQUAL "SHARED_LIBRARY") + install(FILES "$" DESTINATION ./) + endif() + endforeach() + install( DIRECTORY "${CMAKE_BINARY_DIR}/cockatrice/" DESTINATION ./ FILES_MATCHING - PATTERN "CMakeFiles" EXCLUDE PATTERN "*.ini" + PATTERN "CMakeFiles*" EXCLUDE + PATTERN "*.dir*" EXCLUDE + PATTERN "*_autogen*" EXCLUDE + PATTERN "*.tlog*" EXCLUDE + PATTERN "*.pdb" EXCLUDE + PATTERN "x64*" EXCLUDE + PATTERN ".qt*" EXCLUDE + PATTERN ".qsb*" EXCLUDE + PATTERN ".lupdate*" EXCLUDE ) # Qt plugins: audio, iconengines, imageformats, multimedia, platforms, printsupport, styles, tls @@ -626,11 +754,15 @@ if(WIN32) PATTERN "tls/qschannelbackend.dll" ) + # The Windows install has no Resources directory: the .qm files go to + # ${COCKATRICE_WIN32_QM_INSTALL_DIR} and main.cpp looks for them at + # applicationDirPath() + "/translations". The macOS bundle above keeps + # Resources/translations, which is correct for it. install( CODE " file(WRITE \"\${CMAKE_INSTALL_PREFIX}/${qtconf_dest_dir}/qt.conf\" \"[Paths] Plugins = Plugins -Translations = Resources/translations +Translations = translations Data = Resources\") " COMPONENT Runtime @@ -646,10 +778,6 @@ Data = Resources\") " COMPONENT Runtime ) - - if(OPENSSL_FOUND) - install(FILES ${OPENSSL_INCLUDE_DIRS} DESTINATION ./) - endif() endif() if(Qt6LinguistTools_FOUND) @@ -677,6 +805,18 @@ if(Qt6LinguistTools_FOUND) elseif(WIN32) install(FILES ${cockatrice_QM} DESTINATION ${COCKATRICE_WIN32_QM_INSTALL_DIR}) endif() + + # Qt attaches the .qm files to the default build target only, so building the executable on its own + # (CLion, --target cockatrice) leaves it without any translations: nothing is translated and the + # language selector stays empty. + # Depend on Qt's own lrelease target instead of attaching the .qm custom commands to a second + # target: two independent targets owning the same outputs race on MSBuild/Makefiles and are an + # error on Xcode. Older Qt versions already add this dependency themselves. + foreach(_lrelease_target Cockatrice_lrelease cockatrice_lrelease) + if(TARGET ${_lrelease_target}) + add_dependencies(cockatrice ${_lrelease_target}) + endif() + endforeach() endif() qt6_finalize_target(cockatrice) diff --git a/cockatrice/cockatrice.desktop b/cockatrice/cockatrice.desktop index 4b15fa9c1..70db9d472 100644 --- a/cockatrice/cockatrice.desktop +++ b/cockatrice/cockatrice.desktop @@ -6,5 +6,4 @@ Name=Cockatrice Exec=cockatrice %U Icon=cockatrice Categories=Game;CardGame; -MimeType=application/x-cockatrice; -X-Scheme-Handler/cockatrice=true +MimeType=application/x-cockatrice;x-scheme-handler/cockatrice; diff --git a/cockatrice/cockatrice.qrc b/cockatrice/cockatrice.qrc index 9c34929b7..4ec3d2385 100644 --- a/cockatrice/cockatrice.qrc +++ b/cockatrice/cockatrice.qrc @@ -2,6 +2,7 @@ resources/cardback.svg resources/cockatrice.svg + resources/cockatrice-logo-white.svg resources/hand.svg resources/hr.jpg @@ -61,7 +62,9 @@ resources/icons/mana/U.svg resources/icons/mana/W.svg - resources/backgrounds/home.png + resources/backgrounds/home.jpg + resources/backgrounds/home-dark.jpg + resources/backgrounds/home-light.jpg resources/backgrounds/card_triplet.svg resources/backgrounds/placeholder_printing_selector.svg @@ -362,6 +365,8 @@ resources/usericons/pawn_single.svg resources/usericons/pawn_double.svg + resources/usericons/pawn_dev_single.svg + resources/usericons/pawn_dev_double.svg resources/usericons/pawn_donator_single.svg resources/usericons/pawn_donator_double.svg resources/usericons/pawn_judge_single.svg diff --git a/cockatrice/cockatrice_en@source.ts b/cockatrice/cockatrice_en@source.ts index e06972ddc..426822209 100644 --- a/cockatrice/cockatrice_en@source.ts +++ b/cockatrice/cockatrice_en@source.ts @@ -4,7 +4,7 @@ AbstractCounter - + &Set counter... @@ -12,12 +12,12 @@ AbstractCounterDialog - + Set counter - + New value for counter '%1': @@ -38,64 +38,113 @@ AbstractTabDeckEditor - + Open in new tab - + Are you sure? - + The decklist has been modified. Do you want to save the changes? - - - - - - + + + + + + Error - + Could not open deck at %1 - + Could not save remote deck - - + + The deck could not be saved. Please check that the directory is writable and try again. - + Save deck - + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + The deck is empty. Add cards before sharing it. + + + + The deck could not be saved. - + There are no cards in your deck to be exported + + AccountSetupPage + + + Join a Server + + + + + Optional — you can always do this later from the menu. + + + + + Playing online needs a server account. + + + + + Register a new account… + + + + + I already have one — Connect… + + + + + Just want to play locally? Skip this and connect whenever you're ready. + + + AddAnalyticsPanelDialog @@ -107,12 +156,12 @@ Please check that the directory is writable and try again. AdminNotesDialog - + Update Notes - + Admin Notes for %1 @@ -138,218 +187,304 @@ Please check that the directory is writable and try again. AppearanceSettingsPage - + seconds - + Error - + Could not create themes directory at '%1'. - + Theme settings - + Current theme: - + Open themes folder - + Home tab background source: - + Home tab background shuffle frequency: - + Disabled - + Menu settings - + Show keyboard shortcuts in right-click menus - + Show game filter toolbar above list in room tab - + Card rendering - + Display card names on cards having a picture - + Auto-Rotate cards with sideways layout - + Override all card art with personal set preference (Pre-ProviderID change behavior) - + Light - + Dark - + + System - + + Show all playmats + + + + + Show own playmat only + + + + + Don't use playmats + + + + + Override deck playmat + + + + + Fallback if deck has none + + + + + Deck only, ignore collection + + + + Active theme palette: - + + Active theme style: + + + + + Qt widget style saved to this theme ("System" lets the application decide) + + + + Edit theme palette - + Home tab settings - + Display card name of background in bottom right - + + Dim the home tab background + + + + + Draw a translucent overlay over the home tab background so buttons and text stand out + + + + + Home tab button color: + + + + + Use the theme's identity accent colors, or extract colors from the background image + + + + + Playmat settings + + + + + Playmat visibility: + + + + + Default collection behavior: + + + + + Default playmat collection: + + + + + Edit... + + + + Styling settings - + Style user list - + Card printings - + Bump sets that the deck contains cards from to the top in the printing selector - + Scale cards on mouse over - + Use rounded card corners - + Card layout - + Minimum overlap percentage of cards on the stack and in vertical hand - + Maximum initial height for card view window: - - + + rows - + Maximum expanded height for card view window: - + Card counters - + Counter %1 - + Hand layout - + Display hand horizontally (wastes space) - + Enable left justification - + Table grid layout - + Invert vertical coordinate - + Minimum player count for multi-column layout: - + Maximum font size for information displayed on cards: @@ -357,12 +492,12 @@ Please check that the directory is writable and try again. ArchidektApiResponseDeckDisplayWidget - + Back to results - + Open Deck in Deck Editor @@ -388,111 +523,111 @@ Please check that the directory is writable and try again. BanDialog - + ban &user name - + ban &IP address - + ban client I&D - + Ban type - + &permanent ban - + &temporary ban - + &Days: - + &Hours: - + &Minutes: - + Duration of the ban - + Please enter the reason for the ban. This is only saved for moderators and cannot be seen by the banned person. - + Please enter the reason for the ban that will be visible to the banned person. - + Redact all messages from this user in all rooms - + &OK - + &Cancel - + Ban user from server - - - - + + + + Error - + You have to select a name-based, IP-based, clientId based, or some combination of the three to place a ban. - + You must have a value in the name ban when selecting the name ban checkbox. - + You must have a value in the ip ban when selecting the ip ban checkbox. - + You must have a value in the clientid ban when selecting the clientid ban checkbox. @@ -500,22 +635,22 @@ This is only saved for moderators and cannot be seen by the banned person. BetaReleaseChannel - + Beta - + No reply received from the release update server. - + Invalid reply received from the release update server. - + No reply received from the file update server. @@ -523,7 +658,20 @@ This is only saved for moderators and cannot be seen by the banned person. CardArtPreviewWidget - + + Banner preview + + + + + + + + Zoom %1× + + + + No card selected @@ -531,22 +679,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardArtRulesModel - + Card - + ProviderId - + Mode - + Reason @@ -554,65 +702,209 @@ This is only saved for moderators and cannot be seen by the banned person. CardDatabaseModel - + Name - + Sets - + Mana cost - + Card type - + P/T - + Color(s) + + CardDatabaseSetupPage + + + + Press Download to fetch the card database, or Skip to do it later. + + + + + Downloading the card database (%1%)… + + + + + Parsing the card database (%1%)… + + + + + Importing cards (%1%)… + + + + + Download + + + + + ▼ Advanced: custom download source + + + + + ▶ Advanced: custom download source + + + + + Invalid URL + + + + + That doesn't look like a valid URL. Double-check it and try again, or clear the field to use the default source. + + + + + Downloading the latest card database… + + + + + Card database ready ✓ + + + + + Couldn't download the card database automatically. Check your connection and retry, set it up manually, or skip this for now — you can do it later from the Card Database menu. + + + + + Card Database + + + + + Cockatrice needs card data to know what you're playing with. + + + + + Retry + + + + + Set up manually… + + + + + Leave blank to use the default source + + + + + Only change this if you know you need a mirror or a custom card data source. + + + + + Restore default + + + + + Apply && retry + + + + + Check for card database updates on startup + + + + + Don't check + + + + + Prompt for update + + + + + Always update in the background + + + + + Check for card database updates every + + + + + days + + + CardDatabaseView - + Add to Deck - + Add to Sideboard - + Select Printing - + + Add to Zone + + + + + Create &new zone... + + + + Show on EDHRec (Commander) - + Show on EDHRec (Card) - + Show Related cards @@ -722,22 +1014,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoFrameWidget - + Image - + Description - + Both - + View transformation @@ -745,22 +1037,22 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoPictureWidget - + View related cards - + Add card to deck - + Mainboard - + Sideboard @@ -768,27 +1060,27 @@ This is only saved for moderators and cannot be seen by the banned person. CardInfoTextWidget - + Name: - + Set: - + Collector Number: - + Related cards: - + Unknown card: @@ -796,129 +1088,129 @@ This is only saved for moderators and cannot be seen by the banned person. CardMenu - + Re&veal to... - + &All players - + View related cards - + Token: - + All tokens - + &Select All - + S&elect Row - + S&elect Column - + &Play - + &Hide - + Play &Face Down - + &Tap / Untap Turn sideways or back again - + Skip &untapping - + T&urn Over Turn face up/face down - + &Peek at card face - + &Clone - + Attac&h to card... - + Unattac&h - + &Draw arrow... - + &Set annotation... - + Reduce life by power - + Ca&rd counters - + &Add counter (%1) - + &Remove counter (%1) - + &Set counters (%1)... @@ -939,7 +1231,7 @@ This is only saved for moderators and cannot be seen by the banned person. CardSizeWidget - + Card Size @@ -1079,6 +1371,24 @@ This is only saved for moderators and cannot be seen by the banned person. + + ChatView + + + Join game "%1" (#%2) on %3 + + + + + Join game #%1 on %2 + + + + + Join game + + + CockatriceXml3Parser @@ -1108,63 +1418,155 @@ This is only saved for moderators and cannot be seen by the banned person. + + CommanderBracketNames + + + CommanderSpellbook + + + + + Official (approximate) + + + + + The bracket system combines both objective data, as well as subjective play experience to estimate a bracket for a deck. +CommanderSpellbook's estimation is algorithmical, which means that it can only operate on the objective data, not the subjective intent. +They have chosen to represent this by defining their own bracket system which matches their algorithm. +This custom bracket system maps loosely to the standard system. +You may choose to use these mapped standardized names if these are more familiar to you, however, you should keep in mind that these are just rough estimations. + +Always consider the subjective factors of the bracket system when determing a deck's final bracket! + + + + + CommanderBracketWidget + + + + Bracket: + + + + + CommanderSpellbook integration + + + + + CommanderSpellbook can analyze your deck and estimate its Commander bracket. + +This sends your deck list to an external service. + +CommanderSpellbook uses its own bracket naming system based on their own algorithm. These names can be mapped to the official Commander brackets, but the mapping is only an approximation. + + + + + CommanderSpellbook bracket names + + + + + Official Commander bracket names (approximate) + + + + + Bracket naming: + + + + + Enable + + + + + Automatic + + + + + Disable + + + + + Calculating… + + + + + Why this bracket? + + + + + Recalculate bracket + + + ConnectionController - - + + The server has reached its maximum user capacity, please check back later. - + There are too many concurrent connections from your address. - + Banned by moderator - + Expected end time: %1 - + This ban lasts indefinitely. - + Scheduled server shutdown. - - + + Invalid username. - + You have been logged out due to logging in at another location. - + Connection closed - + The server has terminated your connection. Reason: %1 - + The server is going to be restarted in %n minute(s). All running games will be lost. Reason for shutdown: %1 @@ -1174,217 +1576,227 @@ Reason for shutdown: %1 - + Scheduled server shutdown - + Failed Login - + Your client seems to be missing features this server requires for connection. - + To update your client, go to 'Help -> Check for Client Updates'. - - - - - - - - - - - + + + + + + + + + + - - + + + Error - + Incorrect username or password. Please check your authentication information and try again. - + There is already an active session using this user name. Please close that session first and re-login. - - + + You are banned until %1. - - + + You are banned indefinitely. - + This server requires user registration. Do you want to register now? - + This server requires client IDs. Your client is either failing to generate an ID or you are running a modified client. Please close and reopen your client to try again. - + An internal error has occurred, please close and reopen Cockatrice before trying again. If the error persists, ensure you are running the latest version of the software and if needed contact the software developers. - + Account activation - + Your account has not been activated yet. You need to provide the activation token received in the activation email. - + + Password Change Required + + + + + An administrator has reset your password. Please contact your server administrator to obtain your temporary password, then log in and change it via Account -> Change Password. + + + + Server Full - + Unknown login error: %1 - - + + This usually means that your client version is out of date, and the server sent a reply your client doesn't understand. - - - - + + + + Registration denied - + Registration is currently disabled on this server - + There is already an existing account with the same user name. - + It's mandatory to specify a valid email address when registering. - + It appears you are attempting to register a new account on this server yet you already have an account registered with the email provided. This server restricts the number of accounts a user can register per address. Please contact the server operator for further assistance or to obtain your credential information. - + Password too short. - + Registration failed for a technical problem on the server. - + The connection to the server has been lost. - + Unknown registration error: %1 - + Account activation failed - + Socket error: %1 - + Server timeout - + You are trying to connect to an obsolete server. Please downgrade your Cockatrice version or connect to a suitable server. Local version is %1, remote version is %2. - + Your Cockatrice client is obsolete. Please update your Cockatrice version. Local version is %1, remote version is %2. - - + + Success - + Registration accepted. Will now login. - + Account activation accepted. Will now login. - + Information - + This server supports additional features that your client doesn't have. This is most likely not a problem, but this message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -1392,113 +1804,113 @@ To update your client, go to Help -> Check for Updates. - - - + + + Reset Password - + Your password has been reset successfully, you can now log in using the new credentials. - + Failed to reset user account password, please contact the server operator to reset your password. - + Activation request received, please check your email for an activation token. - + Connecting to %1... - + Registering to %1 as %2... - + Disconnected - + Connected, logging in at %1 - + Requesting forgotten password to %1 as %2... - + Your username must respect these rules: - + is %1 - %2 characters long - + can %1 contain lowercase characters - - - - + + + + NOT - + can %1 contain uppercase characters - + can %1 contain numeric characters - + can contain the following punctuation: %1 - + first character can %1 be a punctuation mark - + no unacceptable language as specified by these server rules: note that the following lines will not be translated - + can not contain any of the following words: %1 - + can not match any of the following expressions: %1 - + You may only use A-Z, a-z, 0-9, _, ., and - in your username. @@ -1548,7 +1960,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorCardDatabaseDockWidget - + Card Database @@ -1564,17 +1976,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorDatabaseDisplayWidget - + Search by card name (or search expressions) - + Add card to &maindeck - + Add card to &sideboard @@ -1582,97 +1994,158 @@ To update your client, go to Help -> Check for Updates. DeckEditorDeckDockWidget - + Loading Database... - + Banner Card - + Main Type - + Mana Cost - + Colors - + + Playmat + + + + + + Edit Playmat... + + + + + Edit Playmat (%1) + + + + + &Rename zone... + + + + + Change &board + + + + + &Delete zone + + + + + Move or remove all cards first. + + + + + Delete zone + + + + + Delete the zone "%1"? + + + + Select Printing - + + Move to &zone + + + + + Create new zone and move &here... + + + + + Create &new zone... + + + + Deck - + Deck &name: - + Banner Card/Tags Visibility Settings - + Show banner card selection menu - + Show tags selection menu - + &Comments: - + Group by: - + Format: - + Hash: - + &Increment number - + &Decrement number - + &Remove row - + Swap card to/from sideboard @@ -1680,17 +2153,17 @@ To update your client, go to Help -> Check for Updates. DeckEditorFilterDockWidget - + Filters - + &Clear all filters - + Delete selected @@ -1698,114 +2171,119 @@ To update your client, go to Help -> Check for Updates. DeckEditorMenu - + &Deck Editor - + &New deck - + &Load deck... - + Load recent deck... - + Clear - + &Save deck - + Save deck &as... - + + Share deck... + + + + Load deck from cl&ipboard... - + Edit deck in clipboard - - + + Annotated - - + + Not Annotated - + Save deck to clipboard - + Annotated (No set info) - + Not Annotated (No set info) - + &Print deck... - + Load deck from online service... - + &Send deck to online service - + Create decklist (decklist.org) - + Create decklist (decklist.xyz) - + Analyze deck (deckstats.net) - + Analyze deck (tappedout.net) - + &Close @@ -1813,7 +2291,7 @@ To update your client, go to Help -> Check for Updates. DeckEditorPrintingSelectorDockWidget - + Printing Selector @@ -1821,110 +2299,152 @@ To update your client, go to Help -> Check for Updates. DeckEditorSettingsPage - - + + Update Spoilers - + Success - + Download URLs have been reset. - + Add URL - - + + URL: - - + + Edit URL - + Add New URL - + Remove URL - + Updating... - + + %1 (unlimited) + + + + + %1 (%2/s) + + + + + + + Adjust Rate Limit + + + + + Select a URL in the list first. + + + + + The selected URL does not have a valid host. + + + + + Requests per second (0 = unlimited, fastest; up to %1): + + + + + Requests per second (developer maximum is %1): + + + + + Adjust Rate Limit for %1 + + + + Choose path - + URL Download Priority - + Spoilers - + Download Spoilers Automatically - + Spoiler Location: - + Last Change - + Spoilers download automatically on launch - + Press the button to manually update without relaunching - + Do not close settings until manual update is complete - + Download card pictures on the fly - + How to add a custom URL - + Reset Download URLs @@ -1932,32 +2452,32 @@ To update your client, go to Help -> Check for Updates. DeckListHistoryManagerWidget - + Undo - + Redo - + Undo/Redo history - + Click on an entry to revert to that point in the history. - + [redo] - + [undo] @@ -1965,27 +2485,27 @@ To update your client, go to Help -> Check for Updates. DeckListModel - + Count - + Set - + Number - + Provider ID - + Card @@ -2006,17 +2526,22 @@ To update your client, go to Help -> Check for Updates. DeckPreviewColorIdentityFilterWidget - + Mode: Exact Match - + Mode: Includes - + + Mode: Excludes + + + + Color identity filter mode (AND/OR/NOT conjunctions of filters) @@ -2024,7 +2549,7 @@ To update your client, go to Help -> Check for Updates. DeckPreviewDeckTagsDisplayWidget - + Edit tags ... @@ -2032,62 +2557,62 @@ To update your client, go to Help -> Check for Updates. DeckPreviewTagDialog - + Deck Tags Manager - + Manage your deck tags. Check or uncheck tags as needed, or add new ones: - + Add a new tag (e.g., Aggro️) - + Add Tag - + Filter tags... - + OK - + Edit default tags - + Cancel - + Invalid Input - + Tag name cannot be empty! - + Duplicate Tag - + This tag already exists. @@ -2095,94 +2620,109 @@ To update your client, go to Help -> Check for Updates. DeckPreviewWidget - + Banner Card - + Open in deck editor - + + Share deck... + + + + Edit Tags - + Rename Deck - + Save Deck to Clipboard - + Annotated - + Annotated (No set info) - + Not Annotated - + Not Annotated (No set info) - + Rename File - + Delete File - + Set Banner Card - - + + New name: - - + + Rename deck + + + + + Rename file + + + + + Error - + Rename failed - + Delete file - + Are you sure you want to delete the selected file? - + Delete failed @@ -2190,57 +2730,102 @@ To update your client, go to Help -> Check for Updates. DeckStateManager - + Rename deck to "%1" from "%2" - + Updated comments (was %1 chars, now %2 chars) - + Set banner card to %1 (%2) - + + Set playmat to %1 + + + + Tags changed - + Set format to %1 - + Added (%1): %2 (%3) %4 - + Moved to %1 1 × "%2" (%3) - + Removed "%1" (all copies) - + + Moved %1 × "%2" (%3) to %4 + + + + + Created zone "%1" in %2 + + + + + Renamed zone "%1" to "%2" + + + + + Moved zone "%1" to %2 + + + + + Deleted zone "%1" + + + + + Enter a zone name. + + + + + This name is reserved. + + + + + A zone with this name already exists. + + + + %1 1 × "%2" (%3) - + Added - + Removed @@ -2262,94 +2847,122 @@ To update your client, go to Help -> Check for Updates. DeckViewContainer - + Load deck... - + Load remote deck... - + Load from clipboard... - + Load from website... - + Unload deck - + Ready to start - + Force start - + Sideboard unlocked - + Sideboard locked - - + + Error - + The selected file could not be loaded. - + Deck is greater than maximum file size. - + Are you sure you want to force start? This will kick all non-ready players from the game. - + Cockatrice + + DeckZoneDialog + + + New zone + + + + + Rename zone + + + + + Zone &name: + + + + + &Parent zone: + + + + + Enter a zone name. + + + DialogConvertDeckToCodFormat - + Deck Format Conversion - + You tried to add a tag to a .txt format deck. Tags can only be added to .cod format decks. Do you want to convert the deck to the .cod format? - + Remember and automatically apply choice in the future @@ -2362,143 +2975,143 @@ This will kick all non-ready players from the game. - + Known Hosts - + Delete the currently selected saved server - + Refresh the server list with known public servers - + New Host - + Name: - + &Host: - + &Port: - + Player &name: - + P&assword: - + &Save password - + A&uto connect - + Automatically connect to the most recent login when Cockatrice opens - + If you have any trouble connecting or registering then contact the server staff for help! - - + + Webpage - + Reset Password - + Forgot password? - + &Connect - + Server - + Login - + Server Contact - + Connect to Server - + Server URL - + Communication Port - + Unique Server Name - + Connection Warning - + You need to name your new connection profile. - + Connect Warning - + The player name can't be empty. @@ -2506,122 +3119,122 @@ This will kick all non-ready players from the game. DlgCreateGame - + Re&member settings - + &Description: - + P&layers: - + General - + Game type - + &Password: - + Only &buddies can join - + Only &registered users can join - + Joining restrictions - + &Spectators can watch - + Spectators &need a password to watch - + Spectators can &chat - + Spectators can see &hands - + Create game as spectator - + Spectators - + Starting life total: - + Open decklists in lobby - + Create game as judge - + Game setup options - + &Clear - + Create game - + Game information - + Error - + Server error. @@ -2629,97 +3242,97 @@ This will kick all non-ready players from the game. DlgCreateToken - + &Name: - + Token - + C&olor: - + white - + blue - + black - + red - + green - + multicolor - + colorless - + &P/T: - + &Annotation: - + &Destroy token when it leaves the table - + Create face-down (Only hides name) - + Token data - + Show &all tokens - + Show tokens from this &deck - + Choose token from list - + Create token @@ -2727,53 +3340,53 @@ This will kick all non-ready players from the game. DlgDefaultTagsEditor - + Edit Tags - + Add - + Confirm - + Cancel - + Enter a tag and press Enter - - + + ✖ - + Invalid Input - + Tag name cannot be empty! - + Duplicate Tag - + This tag already exists. @@ -2782,7 +3395,7 @@ This will kick all non-ready players from the game. DlgEditAvatar - + No image chosen. @@ -2793,27 +3406,32 @@ To remove your current avatar, confirm without choosing a new image. - + + Images are automatically downscaled to at most %1x%1 pixels. + + + + Browse... - + Change avatar - + Open Image - + Image Files (*.png *.jpg *.bmp) - + Invalid image chosen. @@ -2839,38 +3457,38 @@ To remove your current avatar, confirm without choosing a new image. DlgEditPassword - + Old password: - + New password: - + Confirm new password: - + Change password - - + + Error - + Your password is too short. - + The new passwords don't match. @@ -2878,93 +3496,93 @@ To remove your current avatar, confirm without choosing a new image. DlgEditTokens - + &Name: - + C&olor: - + white - + blue - + black - + red - + green - + multicolor - + colorless - + &P/T: - + &Annotation: - + Token data - - + + Add token - + Remove token - + Edit custom tokens - + Please enter the name of the token: - + Error - + The chosen name conflicts with an existing card or token. Make sure to enable the 'Token' set in the "Manage sets" dialog to display them correctly. @@ -3042,7 +3660,8 @@ Make sure to enable the 'Token' set in the "Manage sets" dia - Hide games that have started + Hide started games + Hide games that have started @@ -3078,7 +3697,8 @@ Make sure to enable the 'Token' set in the "Manage sets" dia - &Creator name: + &Host name: + &Creator name: @@ -3145,52 +3765,52 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordChallenge - + Reset Password Challenge Warning - + A problem has occurred. Please try to request a new password again. - + Enter the information of the server and the account you'd like to request a new password for. - + &Host: - + &Port: - + Player &name: - + Email: - + Reset Password Challenge - + Reset Password Challenge Error - + The email address can't be empty. @@ -3198,37 +3818,37 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordRequest - + Enter the information of the server you'd like to request a new password for. - + &Host: - + &Port: - + Player &name: - + Reset Password Request - + Reset Password Error - + The player name can't be empty. @@ -3236,94 +3856,127 @@ Make sure to enable the 'Token' set in the "Manage sets" dia DlgForgotPasswordReset - + Reset Password Warning - + A problem has occurred. Please try to request a new password again. - + Enter the received token and the new password in order to set your new password. - + &Host: - + &Port: - + Player &name: - + Token: - - + + New Password: - + Reset Password - + The player name can't be empty. - - - - + + + + Reset Password Error - + The token can't be empty. - + The new password can't be empty. - + Error - + Your password is too short. - + The passwords do not match. + + DlgInviteToGame + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Invite to Game + + + + + Search users... + + + + + Invite + + + + + Cancel + + + DlgLoadDeck - + Load Deck @@ -3407,165 +4060,566 @@ https://tappedout.net/mtg-decks/your-deck-name/ DlgLocalGameOptions - + Players: - + General - + Starting life total: - + Game setup options - + Remember settings - + Local game options + + DlgLoginPrompt + + + Sign in + + + + + This link requires you to be signed in. +Sign in to %1: + + + + + Username: + + + + + Password: + + + + + Save password for this server + + + DlgMoveTopCardsUntil - + Card name (or search expressions): - + Number of hits: - + Auto play hits - + Put top cards on stack until... - + No cards matching the search expression exists in the card database. Proceed anyways? - + Cockatrice - + Invalid filter + + DlgMyReports + + + My Reports + + + + + # + + + + + Time + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Report Details + + + + + Chat Log Context + + + + + Comments: + + + + + Add a comment: + + + + + + Type your comment here... + + + + + Send + + + + + Close + + + + + Refresh + + + + + + + Loading... + + + + + Failed to load reports. + + + + + %1 report(s) + + + + + This report is closed. + + + + + Failed to load report details. + + + + + No comments yet. + + + + + [Moderator] + + + + + [You] + + + DlgRegister - + + Known Hosts + + + + + Delete the currently selected saved server + + + + + Refresh the server list with known public servers + + + + + New Host + + + + + Server + + + + Enter your information and the information of the server you'd like to register to. Your email will be used to verify your account. - + &Host: - + &Port: - + Player &name: - + P&assword: - + Password (again): - + Email: - + Email (again): - + Country: - + Undefined - + Real name: - + Register to server - - - - + + Server URL + + + + + Communication Port + + + + + + + Registration Warning - + Your password is too short. - + Your passwords do not match, please try again. - + Your email addresses do not match, please try again. - + The player name can't be empty. + + + Downloading... + + + + + DlgReportUser + + + Report User + + + + + Reports are reviewed by moderators. False reports may result in account penalties. + + + + + Report Details + + + + + Reported User: + + + + + Game ID: + + + + + (Optional) Enter game ID if available + + + + + If the report is related to a specific game, enter its ID. + + + + + Category + + + + + Category: + + + + + Description + + + + + Please describe what happened. Include dates, game details, or any evidence if available. + + + + + Chat Log Context + + + + + No chat context available (not triggered from chat). + + + + + This chat log is captured from your local chat window and may not reflect the full conversation. + + + + + + Submit Report + + + + + Missing description + + + + + Please describe what happened before submitting. + + + + + Confirm Report + + + + + Submit report against %1 for %2? + + + + + Submitting... + + + + + Report Submitted + + + + + Your report has been submitted and will be reviewed by a moderator. Thank you. + + + + + + + Submission Failed + + + + + You have reached the daily report limit. Please try again later. + + + + + The reported user could not be found. Guests (unregistered users) cannot be reported. + + + + + Failed to submit report. Please try again. + + + + + Cheating / Unsporting behavior + + + + + Bug Abuse + + + + + Harassment / Abuse + + + + + Verbal Abuse + + + + + Hate speech + + + + + Spam + + + + + Other + + + + + Using external tools, cheat programs, or exploiting game bugs + + + + + Exploiting a bug or glitch to gain an unfair advantage + + + + + Threatening, bullying, or persistent unwanted contact + + + + + Abusive or offensive language directed at another player + + + + + Discriminatory language targeting race, gender, religion, etc. + + + + + Repeated unwanted messages or advertisements + + + + + Any behavior not covered by the above categories + + DlgRollDice @@ -3631,34 +4685,12 @@ Your email will be used to verify your account. DlgSettings - + Unknown Error loading card database - - Your card database is invalid. - -Cockatrice may not function correctly with an invalid database - -You may need to rerun oracle to update your card database. - -Would you like to change your database location setting? - - - - - Your card database version is too old. - -This can cause problems loading card information or images - -Usually this can be fixed by rerunning oracle to to update your card database. - -Would you like to change your database location setting? - - - - + Your card database did not finish loading Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues with your cards.xml attached @@ -3667,21 +4699,21 @@ Would you like to change your database location setting? - + File Error loading your card database. Would you like to change your database location setting? - + Your card database was loaded but contains no cards. Would you like to change your database location setting? - + Unknown card database load status Please file a ticket at https://github.com/Cockatrice/Cockatrice/issues @@ -3690,104 +4722,235 @@ Would you like to change your database location setting? - - - + + + Error - + The path to your deck directory is invalid. Would you like to go back and set the correct path? - + The path to your card pictures directory is invalid. Would you like to go back and set the correct path? - + Settings - + + Search settings... + + + + + OK + + + + General - + Appearance - + User Interface - + Storage - + Card Sources - + + Your card database is invalid. + +Cockatrice may not function correctly with an invalid database + +You may need to rerun Oracle to update your card database. + +Would you like to change your database location setting? + + + + + Your card database version is too old. + +This can cause problems loading card information or images + +Usually this can be fixed by rerunning Oracle to to update your card database. + +Would you like to change your database location setting? + + + + Chat - + Sound - + Shortcuts + + DlgShareDeck + + + + + + Share deck + + + + + + Shared deck + + + + + Share name: + + + + + Create share link + + + + + Cancel + + + + + Failed to create the share link (server response code %1). + + + + + Share link created and copied to the clipboard: + +%1 + +The share expires on %2. + + + + + The server did not respond in time. Try again. + + + + + DlgSharedDecksPreview + + + Open shared decks + + + + + Share: %1 + + + + + Untitled + + + + + From %1 + + + + + This share link expires on %1 + + + + + Open selected + + + + + Open all + + + + + Cancel + + + + + Downloading deck %1 of %2: %3 + + + DlgStartupCardCheck - + Card Update Check - + It has been more than %2 days since you last checked your card database for updates. Choose how you would like to run the card database updater. You can always change this behavior in the 'General' settings tab. - + Run in foreground - + Run in background - + Run in background and always from now on - + Don't prompt again and don't run - + Don't run this time @@ -3795,17 +4958,17 @@ You can always change this behavior in the 'General' settings tab. DlgTipOfTheDay - + Next - + Previous - + Tip of the Day @@ -3813,164 +4976,174 @@ You can always change this behavior in the 'General' settings tab. DlgUpdate - + Current release channel - + Reinstall - + Cancel Download - + Open Download Page - + Check for Client Updates - - - - + + + + Error - + Cockatrice was not built with SSL support, therefore you cannot download updates automatically! Please visit the download page to update manually. - + Downloading update: %1 - + Checking for updates... - + Finished checking for updates - + No Update Available - + Cockatrice is up to date! - + You are already running the latest version available in the chosen release channel. - + Current version - + Selected release channel - - + + Update Available - - + + A new version of Cockatrice is available! - - + + New version - - + + Released - - + + Changelog - + Do you want to update now? - + Unfortunately, the automatic updater failed to find a compatible download. You may have to manually download the new version. - + Please check the <a href="%1">releases page</a> on our Github and download the build for your system. - - - + + Update + + + + + The update installer is already running and waits about a minute for Cockatrice to close. Cockatrice is still busy, so save your work and close it before then. Otherwise the installer gives up and the update is cancelled. + + + + + + Update Error - + An error occurred while checking for updates: - + An error occurred while downloading an update: - + Installing... - + Cockatrice is unable to open the installer. - + Try to update manually by closing Cockatrice and running the installer. - + Download location @@ -3978,17 +5151,17 @@ You may have to manually download the new version. DlgViewLog - + Clear log when closing - + Copy to clipboard - + Debug Log @@ -4226,7 +5399,7 @@ You may have to manually download the new version. EdhrecCommanderResponseCommanderDetailsDisplayWidget - + Salt: @@ -4234,7 +5407,7 @@ You may have to manually download the new version. FilterBuilder - + Type your filter here @@ -4242,45 +5415,88 @@ You may have to manually download the new version. FilterDisplayWidget - + Confirm Delete - + Are you sure you want to delete the filter '%1'? - + Delete Failed - + Failed to delete filter '%1'. + + FinishPage + + + You're All Set + + + + + That's everything for now. Jump into Settings any time to change your mind about any of this. + +Have fun! + + + + + FirstRunWizard + + + Finish + + + + + Next + + + + + Welcome to Cockatrice + + + + + Back + + + + + Skip + + + GameEventHandler - + kicked by game host or moderator - + player left the game - + player disconnected from server - + reason unknown @@ -4288,140 +5504,156 @@ You may have to manually download the new version. GameSelector - - - - - - - - - + + + + + + + + + Error - + Please join the appropriate room first. - + Wrong password. - + Spectators are not allowed in this game. - + The game is already full. - + The game does not exist any more. - + This game is only open to registered users. - + This game is only open to its creator's buddies. - + You are being ignored by the creator of this game. - + Join Game - + Spectate Game - + Game Information - + + Cop&y game link + + + + Join Game as Judge - + Spectate Game as Judge - + + Join game - - Password: + + The game is full. Join as a spectator instead? - + + Password for game #%1: + + + + + Password for "%1": + + + + Please join the respective room first. - + &Filter games - + C&lear filter - + C&reate - + &Join - + Join as judge - + J&oin as spectator - + Join as judge spectator - + Games shown: %1 / %2 - + Games @@ -4462,12 +5694,12 @@ You may have to manually download the new version. GamesModel - + >1 day - + %1%2 hr short age in hours @@ -4476,12 +5708,12 @@ You may have to manually download the new version. - + new - + %1%2 min short age in minutes @@ -4490,83 +5722,83 @@ You may have to manually download the new version. - + password - + buddies only - + reg. users only - + open decklists - - + + can chat - + see hands - + can see hands - + not allowed - + Room - + Age - + Description - - Creator + + Host - + Type - + Restrictions - + Players - + Spectators @@ -4574,158 +5806,275 @@ You may have to manually download the new version. GeneralSettingsPage - + Reset all paths - + All paths have been reset - - - - - - - + + + + English + + + + + + + + + + Choose path - + + <p>The card database only contains English card data. To see cards in <b>%1</b>, <b>Oracle</b> must run once with this language selected and re-import the card database.</p><p>The cached database and the downloaded card pictures have been cleared, so a re-import is picked up without stale entries.</p> + + + + + <p>The Scryfall URL that resolves card art by translated name was added to the top of your download list. You can remove or reorder it any time.</p> + + + + + <p>Run Oracle now?</p> + + + + + Card text & images language changed + + + + Language settings - + Language: - + + Card text & images language: + + + + + Foreign card names, text and art apply after you update the card database (Oracle). + + + + + Language used in card search: + + + + + Selected card language (untranslated cards still match in English) + + + + + English and selected card language + + + + Version settings - + Card database - + Startup settings - + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + + + The tab shown when Cockatrice starts. If the chosen tab is not open yet, it is opened. + + + + + Server: + + + + + Room: + + + + + Room name + + + + Paths (editing disabled in portable mode) - + Paths - + How to help with translations - + Decks directory: - + Filters directory: - + Replays directory: - + Pictures directory: - + Card database: - + Custom database directory: - + Token database: - + Update channel - + Check for client updates on startup - + Check for card database updates on startup - + Don't check - + Prompt for update - + Always update in the background - + Check for card database updates every - + days - + Notify if a feature supported by the server is missing in my client - + Automatically run Oracle when running a new version of Cockatrice - + Show tips on startup - + Last update check on %1 (%2 days ago) @@ -4733,47 +6082,47 @@ You may have to manually download the new version. GraveyardMenu - + &Graveyard - + &View graveyard - + &Move graveyard to... - + &Top of library - + &Bottom of library - + &All players - + &Hand - + &Exile - + Reveal random card to... @@ -4867,59 +6216,359 @@ You may have to manually download the new version. + + HomeTabButtonColor + + + From theme colors + + + + + Extract from background + + + HomeWidget - + Create New Deck - + Browse Decks - + Browse Card Database - + Browse EDHRec - + Browse Archidekt - + View Replays - + Quit - + Connecting... - + Connect - + Play + + IntentConnectToServer + + + Timed out while connecting to %1:%2 + + + + + Failed to connect to %1:%2: %3 + + + + + IntentGetLoginCredentials + + + + No saved credentials for this server + + + + + IntentJoinServerGame + + + Game %1 not found in the room + + + + + IntentJoinServerRoom + + + No server tab available + + + + + Failed to join the server room %1 + + + + + Timed out while joining the server room %1 + + + + + IntentOpenLocalDeck + + + Unable to load deck file %1 + + + + + IntentOpenServerRoomByName + + + No server tab available + + + + + Timed out while looking for the server room %1 + + + + + Disconnected while looking for the server room %1 + + + + + Failed to join the server room %1: it doesn't exist on the server. + + + + + You do not have the required permission to join the server room %1. + + + + + Failed to join the server room %1 due to an unknown error. + + + + + IntentOpenSharedDeck + + + The shared deck could not be found or has expired + + + + + + The shared deck is empty + + + + + Failed to download the shared deck + + + + + The shared deck could not be loaded + + + + + + Unknown deck + + + + + Open shared decks + + + + + Could not download the deck "%1". + +%n deck(s) were already downloaded. Open them? + + + + + + + + Timed out while loading the shared deck + + + + + Timed out while downloading the shared deck + + + + + IntentUrlParser + + + Open game + + + + + Missing or empty hostname in the game link + + + + + Invalid or missing port in the game link + + + + + Invalid or missing room id in the game link + + + + + Invalid or missing game id in the game link + + + + + Join game + + + + + Join game #%1 in "%2" on %3? + + + + + Join game #%1 on %2? + + + + + Join game "%1" (#%2) in "%3" on %4? + + + + + Join game "%1" (#%2) on %3? + + + + + + + + Open shared deck + + + + + Missing or empty hostname in the share link + + + + + Invalid or missing port in the share link + + + + + Missing or empty share value in the share link + + + + + Opening this share link connects you to %1 instead of %2. + +Continue? + + + + + Opening this share link connects you to %1. + +Continue? + + + + + Opening this share link connects you to %1, a server you have never connected to before. + +Continue? + + + + + LatencyStatusWidget + + + Ping + + + + + Ping: %1 ms + + + + + Connection latency details + + + + + Connection quality over the last %n sample(s): + + + + + + + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + LibraryMenu @@ -5137,300 +6786,321 @@ You may have to manually download the new version. MainWindow - - + + Player %1 - + Load replay - + About Cockatrice - + Version - + Cockatrice Webpage - + Project Manager: - + Past Project Managers: - + Developers: - + Our Developers - + Help Develop! - + Translators: - + Our Translators - + Help Translate! - + Support: - + Report an Issue - + Troubleshooting - + F.A.Q. - - + + Error - + &Connect... - + &Disconnect - + Start &local game... - + &Watch replay... - + &Full screen - + &Register to server... - + &Restore password... - + &Settings... - + &Exit - + A&ctions - + &Cockatrice - + C&ard Database - + &Manage sets... - + Edit custom &tokens... - + Open custom image folder - + Open custom sets folder - + Add custom sets/cards - + Reload card database - + Tabs - + &Help - + &About Cockatrice - + &Tip of the Day - + Check for Client Updates - + Check for Card Updates... - + Check for Card Updates (Automatic) - + Show Status Bar - + View &Debug Log - + Open Settings Folder - + + Re-run Onboarding Wizard... + + + + Show/Hide - + + Sign-in cancelled + + + + New Version - + Congratulations on updating to Cockatrice %1! Oracle will now launch to update your card database. - + Cockatrice installed - + Congratulations on installing Cockatrice %1! Oracle will now launch to install the initial card database. - + + Are you sure? + + + + + A card database update is still running. Quitting now will cancel it. +Are you sure you want to quit? + + + + Card database - + Cockatrice is unable to load the card database. Do you want to update your card database now? If unsure or first time user, choose "Yes" - - + + Yes - - + + No - + Open settings - + New sets found - + %n new set(s) found in the card database Set code(s): %1 Do you want to enable it/them? @@ -5440,110 +7110,110 @@ Do you want to enable it/them? - + Yes, always enable - + View sets - + Welcome - + Hi! It seems like you're running this version of Cockatrice for the first time. All the sets in the card database have been enabled. Read more about changing the set order or disabling specific sets and consequent effects in the "Manage Sets" dialog. - + Information - + A card database update is already running. - + Unable to run the card database updater: - + Card database update running. - + Failed to start. The file might be missing, or permissions might be incorrect. - + The process crashed some time after starting successfully. - + Timed out. The process took too long to respond. The last waitFor...() function timed out. - + An error occurred when attempting to write to the process. For example, the process may not be running, or it may have closed its input channel. - + An error occurred when attempting to read from the process. For example, the process may not be running. - + Unknown error occurred. - + The card database updater exited with an error: %1 - - - - - + + + + + Load sets/cards - + Selected file cannot be found. - + You can only import XML databases at this time. - + The new sets/cards have been added successfully. Cockatrice will now reload the card database. - + Sets/cards failed to import. @@ -5748,297 +7418,297 @@ Cockatrice will now reload the card database. MessageLogWidget - + from play - + from their graveyard - + from exile - + from their hand - + the top card of %1's library - + the top card of their library - + from the top of %1's library - + from the top of their library - + the bottom card of %1's library - + the bottom card of their library - + from the bottom of %1's library - + from the bottom of their library - + from %1's library - + from their library - + from sideboard - + from the stack - + from custom zone '%1' - + %1 is now keeping the top card %2 revealed. - + %1 is not revealing the top card %2 any longer. - + %1 can now look at top card %2 at any time. - + %1 no longer can look at top card %2 at any time. - + %1 attaches %2 to %3's %4. - + %1 has conceded the game. - + %1 has unconceded the game. - + %1 has restored connection to the game. - + %1 has lost connection to the game. - + %1 points from their %2 to themselves. - + %1 points from their %2 to %3. - + %1 points from %2's %3 to themselves. - + %1 points from %2's %3 to %4. - + %1 points from their %2 to their %3. - + %1 points from their %2 to %3's %4. - + %1 points from %2's %3 to their own %4. - + %1 points from %2's %3 to %4's %5. - + %1 creates a face down token. - + %1 creates token: %2%3. - + %1 has loaded a deck (%2). - + %1 has loaded a deck with %2 sideboard cards (%3). - + %1 destroys %2. - + a card - + %1 gives %2 control over %3. - + %1 puts %2 into play%3 face down. - + %1 puts %2 into play%3. - + %1 puts %2%3 into their graveyard face down. - + %1 puts %2%3 into their graveyard. - + %1 exiles %2%3 face down. - + %1 exiles %2%3. - + %1 moves %2%3 to their hand. - + %1 puts %2%3 into their library. - + %1 puts %2%3 onto the bottom of their library. - + %1 puts %2%3 on top of their library. - + %1 puts %2%3 into their library %4 cards from the top. - + %1 moves %2%3 to sideboard. - + %1 plays %2%3 face down. - + %1 plays %2%3. - + %1 moves %2%3 to custom zone '%4' face down. - + %1 moves %2%3 to custom zone '%4'. - + %1 tries to draw from an empty library - + %1 draws %2 card(s). @@ -6046,12 +7716,12 @@ Cockatrice will now reload the card database. - + %1 is looking at %2. - + %1 is looking at the %4 %3 card(s) %2. top card for singular, top %3 cards for plural @@ -6060,72 +7730,72 @@ Cockatrice will now reload the card database. - + bottom - + top - + %1 turns %2 face-down. - + %1 turns %2 face-up. - + The game has been closed. - + The game has started. - + You are flooding the game. Please wait a couple of seconds. - + %1 has joined the game. - + %1 is now watching the game. - + You have been kicked out of the game. - + %1 has left the game (%2). - + %1 is not watching the game any more (%2). - + %1 is not ready to start the game any more. - + %1 shuffles their deck and draws a new hand of %2 card(s). @@ -6133,28 +7803,28 @@ Cockatrice will now reload the card database. - + %1 shuffles their deck and draws a new hand. - + You are watching a replay of game #%1. - + %1 is ready to start the game. - + cards an unknown amount of cards - + %1 card(s) a card for singular, %1 cards for plural @@ -6163,107 +7833,107 @@ Cockatrice will now reload the card database. - + %1 lends %2 to %3. - + %1 reveals %2 to %3. - + %1 reveals %2. - + %1 randomly reveals %2%3 to %4. - + %1 randomly reveals %2%3. - + %1 peeks at face down card #%2. - + %1 peeks at face down card #%2: %3. - + %1 reveals %2%3 to %4. - + %1 reveals %2%3. - + %1 reversed turn order, now it's %2. - + reversed - + normal - + Heads - + Tails - + %1 flipped a coin. It landed as %2. - + %1 rolls a %2 with a %3-sided die. - + %1 flips %2 coins. There are %3 heads and %4 tails. - + %1 rolls a %2-sided dice %3 times: %4. - + %1's turn. - + %1 sets annotation of %2 to %3. - + %1 places %2 %3%4 counter(s) on %5 (now %6). @@ -6271,7 +7941,7 @@ Cockatrice will now reload the card database. - + %1 removes %2 %3%4 counter(s) from %5 (now %6). @@ -6279,102 +7949,102 @@ Cockatrice will now reload the card database. - + %1 failed to undo their last draw. - + %1 sets counter %2 to %3 (%4%5). - + %1 sets %2 to not untap normally. - + %1 sets %2 to untap normally. - + %1 removes the PT of %2. - + %1 changes the PT of %2 from nothing to %4. - + %1 changes the PT of %2 from %3 to %4. - + %1 has locked their sideboard. - + %1 has unlocked their sideboard. - + %1 taps their permanents. - + %1 untaps their permanents. - + %1 taps %2. - + %1 untaps %2. - + %1 shuffles %2. - + %1 shuffles the bottom %3 cards of %2. - + %1 shuffles the top %3 cards of %2. - + %1 shuffles cards %3 - %4 of %2. - + %1 unattaches %2. - + %1 undoes their last draw. - + %1 undoes their last draw (%2). @@ -6382,115 +8052,125 @@ Cockatrice will now reload the card database. MessagesSettingsPage - + Word1 Word2 Word3 - + Add New Message - + Edit Message - + Remove Message - + Add message - - + + Message: - + Edit message - + Chat settings - + Custom alert words - + Enable chat mentions - + Enable mention completer - + In-game message macros - + How to use in-game message macros - + Ignore chat room messages sent by unregistered users - + Ignore private messages sent by unregistered users - + Ignore private messages sent by non-buddy users - - + + Invert text color - + Enable desktop notifications for private messages - + Enable desktop notification for mentions - + Enable room message history on join - - + + Ignore all private messages + + + + + Use game time instead of local time in game logs + + + + + (Color is hexadecimal) - + Separate words with a space, alphanumeric characters only @@ -6498,42 +8178,42 @@ Cockatrice will now reload the card database. MoveMenu - + Move to - + &Top of library in random order - + X cards from the top of library... - + &Bottom of library in random order - + T&able - + &Hand - + &Graveyard - + &Exile @@ -6612,148 +8292,134 @@ Cockatrice will now reload the card database. PaletteEditorDialog - - + + Reset - - - Apply - - - - - - Save && Apply - - - - + ▼ Edit Palette - - + + ▶ Edit Palette - + Palette Editor — %1 - + <b>Palette Editor</b> &nbsp;·&nbsp; %1 - - This theme ships no default palette files - - - - - Replace current colours with the theme author's defaults - - - - - Switch between the light and dark palette files + + + Save - Editing: + Delete this scheme's custom palette and revert to the theme default (or the application palette) - Show or hide the per-role colour grid for manual tweaks + Switch between the light and dark palette files + Editing: + + + + + Show or hide the per-role colour grid for manual tweaks + + + + ↺ Revert to theme default - + Discard unsaved edits and restore the last saved palette - - Preview this palette without saving to disk + + Cannot save: this theme has no writable directory - + Write palette-%1.toml and reload the theme - - Cannot save: this theme has no directory on disk - - - - + + Save failed - + + Could not write %1 to: %2 - - - No default found - - - - - No default palette file found for the "%1" scheme. - - PaletteGridWidget - + Active - + Disabled - + Inactive - + Normal interactive state - + Widget is disabled / not interactive - + Window is in background / unfocused + + + App colors + + + + + Application-specific colors layered on top of the Qt palette + + Phase @@ -6876,30 +8542,21 @@ Cockatrice will now reload the card database. - - PictureLoader - - - en - code for scryfall's language property, not available for all languages - - - PlayerActions - - - - + + + + grave - - - - + + + + exile @@ -7018,16 +8675,272 @@ Cockatrice will now reload the card database. PlayerMenu - + Player "%1" - + &Counters + + PlaymatCollectionDialog + + + Default Playmats + + + + + Add... + + + + + Edit... + + + + + Remove + + + + + Move Up + + + + + Move Down + + + + + List mode: + + + + + Fixed (always the first entry) + + + + + Round-robin (cycle through entries) + + + + + Random (pick one per game) + + + + + PlaymatPreviewWidget + + + Playmat crop + + + + + + + + + Zoom %1× + + + + + No card selected + + + + + PlaymatSettingsDialog + + + Playmat Settings + + + + + Type a card name... + + + + + Card name: + + + + + Printing: + + + + + Show numerical editors + + + + + Left margin (%): + + + + + Right margin (%): + + + + + Vertical offset: + + + + + Zoom: + + + + + Card + + + + + Crop + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. Dimmed strips mark where a wider table crops further. + + + + + Remove Playmat + + + + + PreferencesSetupPage + + + A Few Preferences + + + + + Defaults are fine — tweak these now or from Settings anytime. + + + + + Gameplay + + + + + Double-click cards to play them + + + + + When disabled, a single click plays the selected card onto the table. + + + + + Display hand horizontally + + + + + Shows your hand as a row along the bottom of the table instead of a column beside it. + + + + + Play all nonlands onto the stack by default + + + + + Cards you play appear on the stack so other players can respond to them, as in a tabletop game. + + + + + Notifications && Sound + + + + + Show desktop notifications + + + + + Play sound effects + + + + + Startup + + + + + Startup tab: + + + + + Home + + + + + Visual Deck Storage + + + + + Deck Storage + + + + + Game Replays + + + + + Deck Editor + + + + + Visual Deck Editor + + + + + Server + + + + + Server Room + + + PrintingDisabledInfoWidget @@ -7048,12 +8961,12 @@ This setting means you'll only see the default printing for each card, inst PrintingSelector - + Display Navigation Buttons - + Printing Selector @@ -7061,25 +8974,65 @@ This setting means you'll only see the default printing for each card, inst PrintingSelectorCardOverlayWidget - + Preference - + Pin Printing - + Unpin Printing - + + Image Overrides + + + + + Load Custom Image... + + + + + Clear Custom Image + + + + + %1 %2 + + + + Show Related cards + + + Select Card Image + + + + + Images (*.png *.jpg *.jpeg *.webp) + + + + + Load Custom Image + + + + + The selected file could not be read as an image. + + PrintingSelectorCardSearchWidget @@ -7110,28 +9063,28 @@ This setting means you'll only see the default printing for each card, inst PrintingSelectorCardSortingWidget - + Alphabetical - + Preference - + Release Date - - + + Descending - + Ascending @@ -7147,95 +9100,103 @@ This setting means you'll only see the default printing for each card, inst PtMenu - + Power / toughness - + &Increase power - + &Decrease power - + I&ncrease toughness - + D&ecrease toughness - + In&crease power and toughness - + Dec&rease power and toughness - + Increase power and decrease toughness - + Decrease power and increase toughness - + Set &power and toughness... - + Reset p&ower and toughness + + PublicDeckPreviewWidget + + + Uploaded %1 + + + QMenuBar - + Services - + Hide %1 - + Hide Others - + Show All - + Preferences... - + Quit %1 - + About %1 @@ -7243,17 +9204,17 @@ This setting means you'll only see the default printing for each card, inst QObject - + Cockatrice card database (*.xml) - + All files (*.*) - + Cockatrice replays (*.cor) @@ -7273,17 +9234,22 @@ This setting means you'll only see the default printing for each card, inst - + + Maybeboard + + + + Overwrite Existing File? - + A .cod version of this deck already exists. Overwrite it? - + Enabling this feature will disable the use of the Printing Selector. You will not be able to manage printing preferences on a per-deck basis, or see printings other people have selected for their decks. @@ -7294,7 +9260,7 @@ Are you sure you would like to enable this feature? - + Disabling this feature will enable the Printing Selector. You can now choose printings on a per-deck basis in the Deck Editor and configure which printing gets added to a deck by default by pinning it in the Printing Selector. @@ -7305,100 +9271,110 @@ Are you sure you would like to disable this feature? - + Confirm Change + + + Art: %1 + + + + + Open deck + + QPlatformTheme - + OK - + Save - + Save All - + Open - + &Yes - + Yes to &All - + &No - + N&o to All - + Abort - + Retry - + Ignore - + Close - + Cancel - + Discard - + Help - + Apply - + Reset - + Restore Defaults @@ -7406,121 +9382,189 @@ Are you sure you would like to disable this feature? QuickSetupPanel - + %1% - + <b>Quick Setup</b> - + Generate all palette roles automatically from a single accent colour - + Accent: - + Primary hue. Used directly for highlights and links. At high intensity it also tints buttons and backgrounds. - + Intensity: - + Subtle - + Full colour - + 0–30 Subtle tint — only highlights and links change hue 30–70 Accented — buttons, tooltips, and borders join in 70–100 Full colour — backgrounds, everything - + 70% - - - Generate ↓ - - - - - Derive all palette roles from the accent colour above. -Fine-tune individual colours in the grid afterwards. - - RemoteDeckList_TreeModel - + + + Public + + + + + + Public (inherited) + + + + + + Private + + + + + This folder is visible to other users + + + + + This folder is private, but a parent folder is public (inherited). + + + + + This folder is only visible to you + + + + + This deck is visible to other users + + + + + This deck is private, but a parent folder is public (inherited). + + + + + This deck is only visible to you + + + + Name - + ID - + Upload time + + + Visibility + + + + + RemotePublicDecksModel + + + The server did not respond in time. Try again. + + + + + Failed to load the user's public decks (server response code %1). + + RemoteReplayList_TreeModel - + ID - + Name - + Players - + Keep - + Time started - + Duration (sec) + + ReplayQuickSettingsWidget + + + Fast forward speed: + + + + + Skip empty sections + + + RfgMenu @@ -7562,37 +9606,37 @@ Fine-tune individual colours in the grid afterwards. RoomSelector - + Rooms - + Joi&n - + Room - + Description - + Permissions - + Players - + Games @@ -7600,7 +9644,7 @@ Fine-tune individual colours in the grid afterwards. SayMenu - + S&ay @@ -7608,32 +9652,32 @@ Fine-tune individual colours in the grid afterwards. SequenceEdit - + Choose an action from the table - + Hit the key/combination of keys you want to set for this action - + Invalid key - + Shortcut already in use by: - + Clear - + Restore default @@ -7666,56 +9710,92 @@ Fine-tune individual colours in the grid afterwards. + + ShareBarWidget + + + Share name + + + + + Cancel + + + + + Create share link + + + + + SharedDeckPreviewWidget + + + Select this deck + + + + + (selected) + + + ShortcutSettingsPage - - + + Restore all default shortcuts - + Do you really want to restore all default shortcuts? - + Clear all default shortcuts - + Do you really want to clear all shortcuts? - + Section: - + Action: - + Shortcut: - + + Shortcut editor + + + + How to set custom shortcuts - + Clear all shortcuts - + Search by shortcut name @@ -7769,12 +9849,12 @@ Please check your shortcut settings! SideboardMenu - + &Sideboard - + &View sideboard @@ -7782,27 +9862,27 @@ Please check your shortcut settings! SoundSettingsPage - + Enable &sounds - + Current sounds theme: - + Test system sound engine - + Sound settings - + Master volume @@ -7810,48 +9890,48 @@ Please check your shortcut settings! SpoilerBackgroundUpdater - + Spoilers season has ended - + Deleting spoiler.xml. Please run Oracle - - + + Spoilers download failed - + No internet connection - + Error - + Spoilers already up to date - + No new spoilers added - + Spoilers have been updated! - + Last change: @@ -7859,157 +9939,170 @@ Please check your shortcut settings! StableReleaseChannel - + Default - + No reply received from the release update server. - + Invalid reply received from the release update server. - + No reply received from the tag update server. - + Invalid reply received from the tag update server. + + StatsTally + + + Total Power + + + + + Total Toughness + + + StorageSettingsPage - - - + + + Success - + Cached card pictures have been reset. - + Downloaded card pictures have been reset. - + Error - + One or more downloaded card pictures could not be cleared. - + In-memory (currently loaded) card pictures have been reset. - + Card Picture Loader Caching Method: - + The network cache is the preferred way of storing images. Downloaded images are stored here until the size of the cache exceeds the configured size. Cockatrice automatically monitors this cache and deletes the least recently seen card images to ensure the cache does not exceed the configured size. - + Writing card images directly to a folder on your hard drive is another way of storing images. This does not change how Cockatrice accesses or downloads images. Cockatrice will NOT automatically monitor and clear this folder, so if you enable this option, it is up to you to ensure sufficient available space. It should also be noted that if a provider outage causes you to download the wrong picture (i.e. wrong printing) you will be stuck with it until you manually delete the file, as opposed to using the network cache, which automatically rotates and thus correct errors after a while. - + This is the in-memory picture cache used by the application at runtime. It determines how much memory (RAM) Cockatrice can use before it has to fetch card images from the hard disk again. Increasing this will allow more card images to be displayed at once but shouldn't be necessary. Clearing this will make Cockatrice reload all images from the network cache or the disk. - + Delete Cached Images - + Delete Saved Images - + Clear In-Memory Images - + Card Picture Loader Cache Method - + Network Cache - + Filesystem - + In-Memory Picture Cache - + Network Cache Size: - + On-disk cache for downloaded pictures - + Redirect Cache TTL: - + How long cached redirects for urls are valid for. - + Picture Cache Size: - + In-memory cache for pictures not currently on screen - + Naming scheme: - + Day(s) @@ -8017,17 +10110,33 @@ Please check your shortcut settings! TabAccount - + + + My Reports + + + + Add to Buddy List - + Add to Ignore List - + + Buddies are marked with a star in chat, a sound plays when they join or leave the server, and they can be invited to buddy-only games. + + + + + Ignored users' chat messages are hidden from you, and they cannot send you private messages or join your games. + + + + Account @@ -8148,118 +10257,118 @@ Please check your shortcut settings! TabArchidekt - - - + + + Desc. - - + + AND - - + + Require ALL selected colors - - + + Deck name... - - + + Owner... - - + + Packages - - + + Advanced Filters - + Bracket: - - + + Any - - + + Contains card... - - + + Commander... - - + + Tag... - - + + Deck Size - + Cards: - - + + Asc. - + Sort by: - + Filter by: - + Display Settings - - + + Search - - + + Formats @@ -8272,42 +10381,42 @@ Please check your shortcut settings! TabCardArtRules - + Card: - + ProviderId: - + Mode: - + Reason: - + Type a card name... - + Add rule - + Remove rule - + Refresh @@ -8320,52 +10429,52 @@ Please check your shortcut settings! TabDeckEditor - + Card Info - + Deck - + Filters - + &View - + Card Database - + Printing - + Visible - + Floating - + Reset layout - + Deck: %1 @@ -8373,49 +10482,49 @@ Please check your shortcut settings! TabDeckEditorVisual - + Visual Deck: %1 - + &Visual Deck Editor - - + + Card Info - - + + Deck - + &View - + Printing - + Visible - + Floating - + Reset layout @@ -8423,22 +10532,22 @@ Please check your shortcut settings! TabDeckEditorVisualTabWidget - + Visual Deck View - + Visual Database Display - + Deck Analytics - + Sample Hand @@ -8446,138 +10555,245 @@ Please check your shortcut settings! TabDeckStorage - + Local file system - + Server deck storage - - + + Open in deck editor - + Rename deck or folder - + Upload deck - + Download deck - - - - + + + + New folder - - + + Delete - + + Share decks + + + + + Publish/unpublish deck + + + + Open decks folder - + Rename local folder - + Rename local file - + New name: - - - - + + + + + + + Error - + Rename failed - - + + + Invalid deck file - + Enter deck name - + This decklist does not have a name. Please enter a name: - + Unnamed deck - + Failed to upload deck to server - + Delete local file - + Are you sure you want to delete the selected files? - + Delete remote decks - + Are you sure you want to delete the selected decks? - - + + + Shared decks + + + + + + Only one folder can be shared at a time. + + + + + + Share either a folder or decks, not both. + + + + + Select folders or decks in the tree to share. + + + + + %n folder(s) + + + + + + + + %n deck(s) + + + + + + + + No decks selected + + + + + Selected: %1 + + + + + The root folder cannot be shared. + + + + + Select decks to share. + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + Failed to change deck visibility on server (response code %1). + + + + + Failed to change the visibility of %n selected deck(s). + + + + + + + + Name of new folder: - + Deck Storage @@ -8585,20 +10801,277 @@ Please enter a name: TabDeckStorageVisual - + Visual Deck Storage - + Error - + Could not open deck at %1 + + + + Shared decks + + + + + Share deck + + + + + You must be connected to the server to share a deck. + + + + + %n deck(s) + + + + + + + + Click deck tiles to select the decks you want to share. + + + + + One deck selected. Create the link to share it with other players. + + + + + %n decks selected. Create the link to share them with other players. + + + + + + + + + Share decks + + + + + Select at least one deck to share. + + + + + Unable to load deck file %1 + + + + + Failed to create the share link (server response code %1). + + + + + Share link copied to the clipboard. +Expires on %1. + + + + + Share link + + + + + The server did not respond in time. Try again. + + + + + TabDeveloper + + + Auto-refresh + + + + + Automatically request fresh server statistics at a fixed interval. + + + + + s + + + + + Seconds between automatic refreshes. + + + + + Refresh server stats + + + + + Statistic;Value + + + + + Command;Count;Total ms;Avg ms + + + + + %1 GiB + + + + + %1 MiB + + + + + %1 KiB + + + + + %1 bytes + + + + + %1 s + + + + + %1 ms + + + + + No server statistics available yet. + + + + + Registered users online + + + + + Moderators online + + + + + Games running + + + + + Traffic sent (last tick) + + + + + Traffic received (last tick) + + + + + Server uptime + + + + + %1d %2h %3m + + + + + %1h %2m + + + + + Snapshot taken + + + + + Live Metrics + + + + + Cards in live games + + + + + Total commands processed + + + + + Avg command time + + + + + Active command types + + + + + Event loop stalls + + + + + Last stall overshoot + + + + + Worst stall overshoot + + + + + Game starts + + + + + Avg game start time + + + + + Updated %1 + + + + + Developer + + TabEdhRec @@ -8611,27 +11084,27 @@ Please enter a name: TabEdhRecMain - + &Cards - + Top Commanders - + Tags - + Search for a card ... - + Search @@ -8644,191 +11117,207 @@ Please enter a name: TabGame - - - + + + Replay - - + + Game - - + + Player List - - + + + Invite + + + + + Card Info - - + + Messages - - + + Replay Timeline - + &Phases - + &Game - + Next &phase - + Next phase with &action - + Next &turn - + Reverse turn order - + &Remove all local arrows - + Rotate View Cl&ockwise - + Rotate View Co&unterclockwise - + Game &information - + + Cop&y game link + + + + + Invite to Game... + + + + Un&concede - - - + + + &Concede - + &Leave game - + C&lose replay - + &Focus Chat - + &Say: - + Selected cards - + &View - + Visible - + Floating - + Reset layout - + Concede - + Are you sure you want to concede this game? - + Unconcede - + You have already conceded. Do you want to return to this game? - + Leave game - + Are you sure you want to leave this game? - + A player has joined game #%1 - + %1 has joined the game - + You have been kicked out of the game. @@ -8844,157 +11333,157 @@ Please enter a name: TabLog - + Logs - - - + + + Time;SenderName;SenderIP;Message;TargetID;TargetName - + Room Logs - + Game Logs - + Chat Logs - - + + Error - + You must select at least one filter. - + You have to select a valid number of days to locate. - + Username: - + IP Address: - + Game Name: - + GameID: - + Message: - + Main Room - + Game Room - + Private Chat - + Past X Days: - + Today - + Last Hour - + Maximum Results: - + At least one filter is required. The more information you put in, the more specific your results will be. - + Get User Logs - + Clear Filters - + Filters - + Log Locations - + Date Range - + Maximum Results - - + + Message History - + Failed to collect message history information. - + There are no messages for the selected filters. @@ -9002,218 +11491,558 @@ The more information you put in, the more specific your results will be. TabMessage - + Private &chat - + &Leave - + %1 - Private chat - + + You have ignored %1; your messages are not delivered. + + + + This user is ignoring you, they cannot see your messages in main chat and you cannot join their games. - + Private message from - + %1 has left the server. - + %1 has joined the server. + + + Message not sent — %1 is offline. + + + + + TabModeration + + + User name + + + + + Investigate + + + + + + Reset Password + + + + + + Remove Avatar + + + + + Refresh + + + + + User Info + + + + + Name: + + + + + Registered: + + + + + Last login: + + + + + Status: + + + + + Counts: + + + + + Admin notes: + + + + + Alts + + + + + Sessions + + + + + Staff Last Logins + + + + + + User + + + + + eMail + + + + + + Client ID + + + + + Registered + + + + + + Last login + + + + + Warns + + + + + Bans + + + + + + Active + + + + + IP + + + + + Start + + + + + End + + + + + Type + + + + + Level + + + + + Unknown + + + + + + Loading... + + + + + Error loading user info. + + + + + active + + + + + inactive + + + + + admin + + + + + Reports: %1 Bans: %2 Warnings: %3 + + + + + (none) + + + + + yes + + + + + no + + + + + Admin + + + + + Developer + + + + + Moderator + + + + + Judge + + + + + Reset the password of %1? A temporary password will be generated and shown to you. The user must change it on their first login. + + + + + + Error + + + + + Password reset failed. + + + + + Password reset + + + + + Temporary password for %1: + +%2 + +Pass it to the user through a secure channel. + + + + + Remove the avatar of %1? The user will have to upload a new one. + + + + + Could not remove the avatar. + + + + + Avatar removed + + + + + The avatar of %1 has been removed. + + + + + Moderation + + + + + TabPublicDecks + + + + + Public decks of %1 + + + + + + + This user has not published any decks. + + + + + + No decks match your filters. + + + + + + Refresh + + + + + Public Decks Settings + + + + + Loading public decks… + + + + + + Open public deck + + + + + Failed to open the public deck (server response code %1). + + + + + The public deck could not be parsed. + + TabReplays - + Local file system - + Server replay storage - - + + Watch replay - + Rename - - + + New folder - - + + Delete - + Open replays folder - + Download replay - + Toggle expiration lock - + Get replay share code - - + + Look up replay by share code - + Rename local folder - + Rename local file - + New name: - + Error - + Rename failed - + Name of new folder: - + Delete local file - + Are you sure you want to delete the selected files? - + Are you sure you want to delete the selected replays? - + Failed to get code - - + + Either this server does not support replay sharing, or does not permit replay sharing for you. - - - + + + Failed - + Could not get replay code - + Replay Share Code - + Others can use this code to add the replay to their list of remote replays: %1 - + Copy to clipboard - + Replay share code - + Replay code found - + Replay was added, or you already had access to it. - + Replay code not found - + Failed to submit code - + Unexpected error - + Delete remote replay @@ -9223,65 +12052,478 @@ The more information you put in, the more specific your results will be. + + TabReport + + + Search by username, category... + + + + + All Statuses + + + + + Open + + + + + Assigned + + + + + Resolved + + + + + Dismissed + + + + + Unresolved only + + + + + Refresh + + + + + # + + + + + Time + + + + + Reporter + + + + + Reported User + + + + + Category + + + + + Game ID + + + + + Status + + + + + Assigned To + + + + + Replay + + + + + Room + + + + + Description + + + + + Chat Log Context + + + + + Chat log attached by the reporter. It is captured from their client and may be incomplete or edited. + + + + + Comments / Thread + + + + + No description. + + + + + No chat log attached. + + + + + + No comments yet. + + + + + + Type a reply... + + + + + Send + + + + + Reported User Context + + + + + User: + + + + + Account Age: + + + + + Reports: + + + + + Bans: + + + + + Warns: + + + + + Admin Notes: + + + + + Recent Reports Against User: + + + + + Report Statistics + + + + + Assign to Me + + + + + Resolve + + + + + Resolve with note... + + + + + Dismiss... + + + + + View Replay + + + + + Join Game + + + + + + + + Loading... + + + + + Failed to load reports. + + + + + Yes + + + + + No + + + + + %1 report(s) + + + + + %1 open | %2 assigned | %3 resolved | %4 dismissed + + + + + This report is closed. + + + + + Failed to load report details. + + + + + [Moderator] + + + + + [Reporter] + + + + + Assigning... + + + + + Assigned. + + + + + Assignment failed. + + + + + Dismiss Report + + + + + Resolve Report + + + + + Optional note: + + + + + Resolution note (optional): + + + + + Dismissing... + + + + + Resolving... + + + + + Done. + + + + + Action failed. + + + + + Loading replay... + + + + + No replay available for this game. + + + + + Replay opened. + + + + + Failed to parse replay. + + + + + No room recorded for this report, use the replay instead. + + + + + Attempting to join game... + + + + + Failed to send comment. + + + + + Error loading user info. + + + + + %1 days (since %2) + + + + + (none) + + + + + No previous reports against this user. + + + + + Loading statistics... + + + + + Error loading statistics. + + + + + Total: %1 reports (%2 open, %3 assigned, %4 resolved/dismissed) + + + + + Last 24h: %1 | Last 7d: %2 | Last 30d: %3 | Avg resolution: %4h + + + + + This week: %1 vs last week: %2 (%3%) + + + + + By category: + + + + + === Top Categories === + + + + + === Most Reported Users === + + + + + === Top Reporters === + + + + + Report Queue + + + TabRoom - - Friends - - - - - Online - - - - - Ignored - - - - + &Say: - + Chat - + &Room - + &Leave room - + &Clear chat - + Chat Settings... - + mentioned you. - + Click to view - + You are flooding the chat. Please wait a couple of seconds. @@ -9289,35 +12531,35 @@ The more information you put in, the more specific your results will be. TabServer - + Server - - - - + + + + Error - + Failed to join the server room: it doesn't exist on the server. - + The server thinks you are in the server room but your client is unable to display it. Try restarting your client. - + You do not have the required permission to join this server room. - + Failed to join the server room due to an unknown error: %1. @@ -9325,97 +12567,176 @@ The more information you put in, the more specific your results will be. TabSupervisor - + Deck Editor - + Visual Deck Editor - + EDHRec - + Archidekt - + Home - + &Visual Deck Storage - + Visual Database Display - + Server - + Account - + Deck Storage - + Game Replays - + Administration - + Logs - + + Report Queue + + + + + Moderation + + + + + Card Art Rules + + + + + Developer + + + + Are you sure? - + There are still open games. Are you sure you want to quit? + + + Connection quality over the last %n sample(s): + + + + + - + + Last: %1 ms + + + + + Median: %1 ms + + + + + 95th percentile: %1 ms + + + + + Maximum: %1 ms + + + + + Game joins are only available on a remote server. + + + + + Could not join game %1. +%2 + + + + + Ignored user + + + + + You have ignored %1. Remove them from your ignore list to open a private chat. + + + + + Game #%1 + + + + + Game #%1 — %2 + + + + Click to view - + Your buddy %1 has signed on! - + Unknown Event - + The server has sent you a message that your client does not understand. This message might mean there is a new version of Cockatrice available or this server is running a custom or pre-release version. @@ -9423,38 +12744,38 @@ To update your client, go to Help -> Check for Updates. - + Idle Timeout - + You are about to be logged out due to inactivity. - + Promotion - + You have been promoted. Please log out and back in for changes to take effect. - + Warned - + You have received a warning due to %1. Please refrain from engaging in this activity or further actions may be taken against you. If you have any questions, please private message a moderator. - + You have received the following message from the server. (custom messages like these could be untranslated) @@ -9473,6 +12794,34 @@ Please refrain from engaging in this activity or further actions may be taken ag + + TallyMenu + + + Tally + + + + + None + + + + + Subtypes + + + + + Total Power + + + + + Total Toughness + + + TappedOutInterface @@ -9487,6 +12836,65 @@ Please refrain from engaging in this activity or further actions may be taken ag + + ThemeSetupPage + + + Light + + + + + Dark + + + + + Match system + + + + + Theme: + + + + + Appearance: + + + + + Home screen background: + + + + + Save failed + + + + + Could not write the theme palette to: +%1 + + + + + Pick a Look + + + + + You can fine-tune every colour later from Settings → Appearance. + + + + + Accent colour (optional) + + + TipsOfTheDay @@ -9556,57 +12964,52 @@ Please refrain from engaging in this activity or further actions may be taken ag UserCardArtSettingsDialog - + Card Art Settings - + Type a card name... - + Card name: - - Card ProviderId: + + Printing: - + + Card + + + + + Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. + + + + + Banner + + + + Left margin (%): - + Right margin (%): - - Vertical offset: - - - - - Zoom: - - - - - Parameters - - - - - Preview - - - - + Remove Banner Card @@ -9614,216 +13017,266 @@ Please refrain from engaging in this activity or further actions may be taken ag UserContextMenu - + User &details - + Private &chat - + Show this user's &games - - - Add to &buddy list - - - - - Remove from &buddy list - - - - - Add to &ignore list - - - - - Remove from &ignore list - - - - - Kick from &game - - - - - Warn user - - - View user's war&n history + View this user's &public decks - Ban from &server + Add to &buddy list - View user's &ban history + Remove from &buddy list - &Promote user to moderator + Add to &ignore list - Dem&ote user from moderator + Remove from &ignore list - Promote user to &judge + Kick from &game - Demote user from judge + Report user + Warn user + + + + + View user's war&n history + + + + + Ban from &server + + + + + View user's &ban history + + + + + &Promote user to moderator + + + + + Dem&ote user from moderator + + + + + Promote user to &judge + + + + + Demote user from judge + + + + + Promote user to &developer + + + + + Demote user from de&veloper + + + + View admin notes - - + Investigate user + + + + + + Error - + This user does not exist. - + You are being ignored by %1 and can't see their games. - + Could not get %1's games. - + %1's games - - - + + + Ban History - + Ban Time;Moderator;Ban Length;Ban Reason;Visible Reason - + User has never been banned. - + Failed to collect ban information. - - - + + + Warning History - + Warning Time;Moderator;User Name;Reason - + User has never been warned. - + Failed to collect warning information. - + Failed to get admin notes. - - + + Success - + Successfully promoted user. - + Successfully demoted user. - + + &Invite to Game + + + + + Join my game (#%1): + + + + + Join my game "%1" (#%2): + + + + + Ignore list + + + + + %1 has been added to your ignore list. + + + + Kick Player - + Are you sure you want to kick this player from the game? - - - + + + Failed - + Failed to promote user. - + Failed to demote user. - + Copy hash to clipboard - + Remove this user's messages @@ -9861,61 +13314,61 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Moderator - + Registered user - - + + Unregistered user - + Judge - + Unknown - + The entered password does not match your account. - - - - + + + + Information - + User information updated. - - - - - - + + + + + - - - + + + + Error @@ -9939,8 +13392,13 @@ Please refrain from engaging in this activity or further actions may be taken ag Edit Banner Card + + + Developer + + - + %n Year(s), amount of years (only shown if more than 0) @@ -9949,7 +13407,7 @@ Please refrain from engaging in this activity or further actions may be taken ag - + %10%n Day(s) %20 amount of years (if more than 0), amount of days, date in local short format @@ -9958,270 +13416,274 @@ Please refrain from engaging in this activity or further actions may be taken ag - + Enter Password - + Password verification is required in order to change your email address - - - + + + An error occurred while trying to update your user information. - + The selected card is blacklisted on this server or another error occurred. - + Banner card removed. - + Banner card updated. - + This server does not permit you to update your user informations. - - - Password changed. - - - This server does not permit you to change your password. + Password changed. - The new password is too short. + This server does not permit you to change your password. + The new password is too short. + + + + The old password is incorrect. - + Avatar updated. - + This server does not permit you to update your avatar. - + An error occured while trying to update your avatar. - - - This server does not permit you to update your user informations. - An error occured while trying to updater your avatar. - - UserInfoPopup - - + + Games - + Chat - + Open private chat - + Profile - + View user profile - + Show this user's games - + + Invite + + + + + Invite to your game + + + + − Buddy - + Remove from buddy list - + + Buddy - + Add to buddy list - + − Ignore - + Remove from ignore list - + + Ignore - + Add to ignore list - + Ban - + Ban from server - + Warn - + Warn user - + Ban log - + View ban history - + Warn log - + View warning history - + Notes - + View admin notes - + − Mod - + Demote from moderator - + + Mod - + Promote to moderator - + − Judge - + Demote from judge - + + Judge - + Promote to judge - + Join game - + Spectate - - + + Loading games… - + Could not load games. - + No active games. @@ -10229,223 +13691,344 @@ Please refrain from engaging in this activity or further actions may be taken ag UserInterfaceSettingsPage - + General interface settings - + &Double-click cards to play them (instead of single-click) - + &Clicking plays all selected cards (instead of just the clicked card) - + &Play all nonlands onto the stack (not the battlefield) by default - + Do not delete &arrows inside of subphases - + Close card view window when last card is removed - + Auto focus search bar when card view window is opened - + Annotate card text on tokens - + Show selection count during drag selection - + Show total selection count - - Show subtype breakdown in selection tally + + + Disabled - - Use tear-off menus, allowing right click menus to persist on screen + + + Enabled - - Keep game chat focused when clicking in game (Note: disables card view search bar) - - - - - Notifications settings - - - - - Enable notifications in taskbar - - - - - Notify in the taskbar for game events while you are spectating + + + Automatic - Notify in the taskbar when users in your buddy list connect - - - - - Animation settings + CommanderSpellbook bracket names + Official Commander bracket names (approximate) + + + + + Use tear-off menus, allowing right click menus to persist on screen + + + + + Keep game chat focused when clicking in game (Note: disables card view search bar) + + + + + Notifications settings + + + + + Enable notifications in taskbar + + + + + Notify in the taskbar for game events while you are spectating + + + + + Notify in the taskbar when users in your buddy list connect + + + + + Animation settings + + + + + &Enable all animations + + + + + &Disable all animations + + + + &Tap/untap animation - + + &Arrow draw animation + + + + + Life counter flash + + + + + Battlefield flash on damage + + + + Deck editor/storage settings - + Open deck in new tab by default - + + Show card counts in Visual Deck Editor + + + + Use visual deck storage in game lobby - + Use selection animation for Visual Deck Storage - + When adding a tag in the visual deck storage to a .txt deck: - - do nothing + + Do nothing - - ask to convert to .cod + + Ask to convert to .cod - - always convert to .cod + + Always convert to .cod - + Default deck editor type - + Classic Deck Editor - + Visual Deck Editor - + + Visual deck editor startup tab + + + + + Context + + + + + Deck display + + + + + Database display + + + + + Context mode: New decks open on the database display, existing decks open on the deck view. + + + + + CommanderSpellbook integration to estimate commander bracket + + + + + Bracket naming + + + + Replay settings - + Buffer time for backwards skip via shortcut: + + UserListPanelWidget + + + Search users... + + + UserListWidget - + Users connected to server: %1 - + Users in this room: %1 - + Buddies online: %1 / %2 - + Ignored users online: %1 / %2 + + + Buddies + + + + + Online + + + + + Ignored + + + + + %1 %2 (%3/%4) + + + + + %1 %2 (%3) + + UtilityMenu - + Increment all card counters - + &Untap all permanents - + R&oll die... - + Flip coin - + &Create token... - + C&reate another token - + Cr&eate predefined token @@ -10482,22 +14065,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterSaveLoadWidget - + Search filter... - + Save Filter - + Save all currently applied filters to a file - + Enter filename... @@ -10505,72 +14088,72 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayFilterToolbarWidget - + Sort by - + Filter by - + Save and load filters - + Filter by exact card name - + Filter by card main-type - + Filter by card sub-type - + Filter by set - + Filter by format legality - + Save/Load - + Name - + Main Type - + Sub Type - + Sets - + Formats @@ -10672,7 +14255,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayRecentSetFilterSettingsWidget - + Filter to most recent sets @@ -10680,19 +14263,19 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplaySetFilterWidget - + Search sets... - - + + Mode: Exact Match - - + + Mode: Includes @@ -10738,28 +14321,28 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDatabaseDisplayWidget - + Search by card name (or search expressions) - - + + Visual - + Loading database ... - + Clear all filters - + Table @@ -10767,43 +14350,43 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckDisplayOptionsWidget - + Group by: - + Change how cards are divided into categories/groups. - + Sort by: - + Click and drag to change the sort order within the groups - + Configure how cards are sorted within their groups - - + + Toggle Layout: Overlap - + Change how cards are displayed within zones (i.e. overlapped or fully visible.) - + Toggle Layout: Flat @@ -10819,12 +14402,12 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorSampleHandWidget - + Draw a new sample hand - + Sample hand size @@ -10832,17 +14415,17 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckEditorWidget - + Type a card name here for suggestions from the database... - + Quick search and add card - + Search for closest match in the database (with auto-suggestions) and add preferred printing to the deck on pressing enter @@ -10850,7 +14433,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageFolderDisplayWidget - + Deck Storage @@ -10858,52 +14441,57 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageQuickSettingsWidget - + Show Folders - + Show Tag Filter - + Show Color Identity - + Show Tags On Deck Previews - + + Show Upload Time + + + + Show Banner Card Selection Option - + Draw unused Color Identities - + Unused Color Identities Opacity - + Deck tooltip: - + None - + Filepath @@ -10911,7 +14499,7 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageSearchWidget - + Search by filename (or search expression) @@ -10919,22 +14507,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageSortWidget - + Sort Alphabetically (Deck Name) - + Sort Alphabetically (Filename) - + Sort by Last Modified - + Sort by Last Loaded @@ -10942,17 +14530,22 @@ Please refrain from engaging in this activity or further actions may be taken ag VisualDeckStorageWidget - + Loading database ... - + Refresh loaded files - + + Select decks to share + + + + Visual Deck Storage Settings @@ -10960,177 +14553,202 @@ Please refrain from engaging in this activity or further actions may be taken ag WarningDialog - + Which warning would you like to send? - + Redact all messages from this user in all rooms - + &OK - + &Cancel - + Warn user for misconduct - - + + Error - + User name to send a warning to can not be blank, please specify a user to warn. - + Warning to use can not be blank, please select a valid warning to send. + + + %1 (IL %2) + + + + + WelcomePage + + + Welcome! + + + + + Let's get you set up. This will only take a minute — we'll grab the card database, pick a look you like, and get you ready to connect to a server. + +You can change any of this later from Settings. + + + + + Language: + + WndSets - + Move selected set to the top - + Move selected set up - + Move selected set down - + Move selected set to the bottom - - Search by set name, code, or type - - - - + Default order - + Restore original art priority order - + Enable all sets - + Disable all sets - + Enable selected set(s) - + Disable selected set(s) - + Deck Editor - + Use CTRL+A to select all sets in the view. - + Only cards in enabled sets will appear in the card list of the deck editor. - + Image priority is decided in the following order: - + first the CUSTOM Folder (%1), then the Enabled Sets in this dialog (Top to Bottom) %1 is a link to the wiki - + Include cards rebalanced for Alchemy [requires restart] - + Card Art - - How to use custom card art + + Search by set name, code, type, or release date + How to use custom card art + + + + Hints - + Note - + Sorting by column allows you to find a set while not changing set priority. - + To enable ordering again, click the column header until this message disappears. - + Use the current sorting as the set priority instead - + Sorts the set priority using the same column - + Manage sets @@ -11138,93 +14756,85 @@ Please refrain from engaging in this activity or further actions may be taken ag ZoneViewWidget - + Search by card name (or search expressions) - + Ungrouped - + Group by Type - + Group by Mana Value - + Group by Color - + Unsorted - + Sort by Name - + Sort by Type - + Sort by Mana Cost - + Sort by Colors - + Sort by P/T - + Sort by Set - + shuffle when closing - + pile view - - i18n - - - English - - - main - + Connect on startup - + Debug to file @@ -12008,6 +15618,16 @@ Please refrain from engaging in this activity or further actions may be taken ag Graveyard + + + Report Queue + + + + + Moderation + + diff --git a/cockatrice/resources/backgrounds/home-dark.jpg b/cockatrice/resources/backgrounds/home-dark.jpg new file mode 100644 index 000000000..53d3406dd Binary files /dev/null and b/cockatrice/resources/backgrounds/home-dark.jpg differ diff --git a/cockatrice/resources/backgrounds/home-light.jpg b/cockatrice/resources/backgrounds/home-light.jpg new file mode 100644 index 000000000..112146e4f Binary files /dev/null and b/cockatrice/resources/backgrounds/home-light.jpg differ diff --git a/cockatrice/resources/backgrounds/home.jpg b/cockatrice/resources/backgrounds/home.jpg new file mode 100644 index 000000000..112146e4f Binary files /dev/null and b/cockatrice/resources/backgrounds/home.jpg differ diff --git a/cockatrice/resources/backgrounds/home.png b/cockatrice/resources/backgrounds/home.png deleted file mode 100644 index 68f48e2c2..000000000 Binary files a/cockatrice/resources/backgrounds/home.png and /dev/null differ diff --git a/cockatrice/resources/cockatrice-logo-white.svg b/cockatrice/resources/cockatrice-logo-white.svg new file mode 100644 index 000000000..b3b31077f --- /dev/null +++ b/cockatrice/resources/cockatrice-logo-white.svg @@ -0,0 +1,21 @@ + + + + + + + + + diff --git a/cockatrice/resources/cockatrice.svg b/cockatrice/resources/cockatrice.svg index d2e22da31..89ba62dcf 100644 --- a/cockatrice/resources/cockatrice.svg +++ b/cockatrice/resources/cockatrice.svg @@ -2,20 +2,20 @@ + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:dc="http://purl.org/dc/elements/1.1/" + sodipodi:docname="cockatrice.svg" + xmlns="http://www.w3.org/2000/svg"> + inkscape:current-layer="svg2" + inkscape:showpageshadow="0" + inkscape:pagecheckerboard="0" + inkscape:deskcolor="#505050"> + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + @@ -199,7 +159,7 @@ image/svg+xml - + @@ -213,170 +173,60 @@ inkscape:export-xdpi="91.459999" inkscape:export-ydpi="91.459999"> - - - - - - - - - - - - - - diff --git a/cockatrice/resources/countries/cn.svg b/cockatrice/resources/countries/cn.svg index f510cf049..45b608127 100644 --- a/cockatrice/resources/countries/cn.svg +++ b/cockatrice/resources/countries/cn.svg @@ -52,8 +52,7 @@ id="defs8"> - + diff --git a/cockatrice/resources/help/search.md b/cockatrice/resources/help/search.md index 0c8bdb450..fd0a12507 100644 --- a/cockatrice/resources/help/search.md +++ b/cockatrice/resources/help/search.md @@ -52,6 +52,7 @@ In this list of examples below, each entry has an explanation and can be clicked
Edition:
[set:lea](#set:lea) (Cards that appear in Alpha, which has the set code LEA)
[e:lea OR e:leb](#e:lea OR e:leb) (Cards that appear in Alpha or Beta)
+
[e<8ED](#e<8ED) (Cards that appear before 8th edition)
Negate:
[c:wu -c:m](#c:wu -c:m) (Any card that is white or blue, but not multicolored)
diff --git a/cockatrice/resources/usericons/pawn_dev_double.svg b/cockatrice/resources/usericons/pawn_dev_double.svg new file mode 100644 index 000000000..57ed5c2da --- /dev/null +++ b/cockatrice/resources/usericons/pawn_dev_double.svg @@ -0,0 +1,343 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/cockatrice/resources/usericons/pawn_dev_single.svg b/cockatrice/resources/usericons/pawn_dev_single.svg new file mode 100644 index 000000000..f7c4e7018 --- /dev/null +++ b/cockatrice/resources/usericons/pawn_dev_single.svg @@ -0,0 +1,211 @@ + + + +image/svg+xml + + diff --git a/cockatrice/src/client/lag_monitor.cpp b/cockatrice/src/client/lag_monitor.cpp new file mode 100644 index 000000000..6b72087ec --- /dev/null +++ b/cockatrice/src/client/lag_monitor.cpp @@ -0,0 +1,66 @@ +#include "lag_monitor.h" + +#include +#include +#include +#include +#include +#include + +LagMonitor::LagMonitor(QObject *parent) : QObject(parent) +{ + qApp->installEventFilter(this); + + timer = new QTimer(this); + timer->setInterval(TICK_INTERVAL_MS); + connect(timer, &QTimer::timeout, this, &LagMonitor::checkTick); + tickClock.start(); + timer->start(); +} + +QList LagMonitor::recentStalls() const +{ + return stalls; +} + +void LagMonitor::clearStalls() +{ + stalls.clear(); +} + +bool LagMonitor::eventFilter(QObject *obj, QEvent *event) +{ + if (event->type() == QEvent::ApplicationStateChange) { + // The transition may span a suspend or an arbitrary unfocused period; + // discard the gap so it cannot be mistaken for a stall. + tickClock.restart(); + } + return QObject::eventFilter(obj, event); +} + +void LagMonitor::checkTick() +{ + recordGap(tickClock.restart()); +} + +void LagMonitor::recordGap(qint64 gapMs) +{ + if (gapMs <= STALL_THRESHOLD_MS) { + return; + } + + if (gapMs > MAX_PLAUSIBLE_STALL_MS) { + qCDebug(LagMonitorLog, "Ignoring implausible %lld ms gap (likely suspend)", static_cast(gapMs)); + return; + } + + const StallRecord record{.timestampMsSinceEpoch = QDateTime::currentMSecsSinceEpoch(), .durationMs = gapMs}; + + stalls.append(record); + while (stalls.size() > MAX_RECORDED_STALLS) { + stalls.removeFirst(); + } + + qCWarning(LagMonitorLog, "Event loop stalled for %lld ms (threshold: %d ms)", static_cast(gapMs), + STALL_THRESHOLD_MS); +} diff --git a/cockatrice/src/client/lag_monitor.h b/cockatrice/src/client/lag_monitor.h new file mode 100644 index 000000000..0bb98c25e --- /dev/null +++ b/cockatrice/src/client/lag_monitor.h @@ -0,0 +1,87 @@ +/** + * @file lag_monitor.h + * @ingroup Client + */ + +#ifndef LAG_MONITOR_H +#define LAG_MONITOR_H + +#include +#include +#include +#include +#include +#include + +inline Q_LOGGING_CATEGORY(LagMonitorLog, "lag_monitor"); + +class QTimer; + +/** + * @brief Detects main-thread event loop stalls ("UI freezes") from the inside. + * + * A timer is expected to fire every TICK_INTERVAL_MS of wall time. When the + * observed gap greatly exceeds that interval, some other task blocked the + * event loop for roughly the overshooting duration. This is what separates + * "my client froze" from "the network is lagging" in user reports. + * + * Gaps that span an application state change (suspend, minimize, focus + * loss) are discarded, and implausibly huge gaps are dropped, so operating + * system power events do not fabricate stalls. This handling is load-bearing + * on Windows, where the monotonic clock used by Qt counts sleep time. + * + * Healthy operation costs one timer wakeup per tick and two integer + * comparisons. Allocations happen only when a stall is actually recorded. + */ +class LagMonitor : public QObject +{ + Q_OBJECT + +public: + struct StallRecord + { + qint64 timestampMsSinceEpoch = 0; ///< when the stalled period ended + qint64 durationMs = 0; ///< approximate length of the freeze; measured tick to tick, so it can exceed the true + ///< stall by up to TICK_INTERVAL_MS + }; + + static constexpr int TICK_INTERVAL_MS = 500; + static constexpr int STALL_THRESHOLD_MS = 2000; + static constexpr int MAX_RECORDED_STALLS = 32; + + /// Gaps beyond this are treated as suspend artifacts rather than stalls. + static constexpr qint64 MAX_PLAUSIBLE_STALL_MS = 600000; + + explicit LagMonitor(QObject *parent = nullptr); + + /** + * @brief Stalls recorded during this session, oldest first. + * + * Intended consumers are log output and the diagnostics export. The list + * holds at most MAX_RECORDED_STALLS entries. + */ + QList recentStalls() const; + + void clearStalls(); + + /** + * @brief Feeds a measured tick-to-tick gap through the detection logic. + * + * Split out of checkTick so threshold, plausibility, and trim behavior + * stay unit-testable without real timing. + */ + void recordGap(qint64 gapMs); + +protected: + bool eventFilter(QObject *obj, QEvent *event) override; + +private slots: + void checkTick(); + +private: + QTimer *timer; + QElapsedTimer tickClock; ///< monotonic clock, so wall clock steps do not fabricate stalls + QList stalls; +}; + +#endif diff --git a/cockatrice/src/client/latency_graph_widget.cpp b/cockatrice/src/client/latency_graph_widget.cpp new file mode 100644 index 000000000..72df3ce6c --- /dev/null +++ b/cockatrice/src/client/latency_graph_widget.cpp @@ -0,0 +1,55 @@ +/** + * @file latency_graph_widget.cpp + * @ingroup Client + */ + +#include "latency_graph_widget.h" + +#include +#include +#include +#include +#include + +LatencyGraphWidget::LatencyGraphWidget(QWidget *parent) : QWidget(parent) +{ +} + +void LatencyGraphWidget::setSamples(const QList &samplesMs) +{ + samples = samplesMs; + update(); +} + +void LatencyGraphWidget::paintEvent(QPaintEvent * /* event */) +{ + if (samples.isEmpty()) { + return; + } + + QPainter painter(this); + + // Heights are relative to the window's own worst sample (floored at + // MinScaleMs) so the shape of the variance stays readable even when every + // value is small. + qint64 heightScaleMs = MinScaleMs; + for (int sample : samples) { + heightScaleMs = qMax(heightScaleMs, static_cast(sample)); + } + + const qreal widthPerBar = static_cast(width()) / samples.size(); + for (int i = 0; i < samples.size(); ++i) { + const qreal heightRatio = qBound(0.0, static_cast(samples.at(i)) / heightScaleMs, 1.0); + const qreal barHeight = heightRatio * height(); + + // Colors follow an absolute quality ramp: a steady good ping stays + // green no matter how uniform the window is. + const qreal colorRatio = qBound(0.0, static_cast(samples.at(i)) / ColorScaleMs, 1.0); + QColor color; + color.setHsv(qRound(120.0 * (1.0 - colorRatio)), 255, 255); + + const QRectF bar(static_cast(i) * widthPerBar + 1.0, static_cast(height()) - barHeight, + qMax(1.0, widthPerBar - 2.0), barHeight); + painter.fillRect(bar, color); + } +} diff --git a/cockatrice/src/client/latency_graph_widget.h b/cockatrice/src/client/latency_graph_widget.h new file mode 100644 index 000000000..31a7022b2 --- /dev/null +++ b/cockatrice/src/client/latency_graph_widget.h @@ -0,0 +1,45 @@ +/** + * @file latency_graph_widget.h + * @ingroup Client + */ + +#ifndef LATENCY_GRAPH_WIDGET_H +#define LATENCY_GRAPH_WIDGET_H + +#include +#include +#include +#include + +/** + * @brief Bar graph of recent network round-trip samples. + * + * Draws one bar per sample, oldest on the left. Bar height is relative to the + * window's own scale so the shape of the variance stays readable, while bar + * color maps each sample onto an absolute quality ramp (green at rest through + * red at ColorScaleMs) so a steady good ping never looks alarming. Size + * agnostic: the status bar embeds a small instance while the latency detail + * popup shows a large one. + */ +class LatencyGraphWidget : public QWidget +{ + Q_OBJECT +public: + explicit LatencyGraphWidget(QWidget *parent = nullptr); + + /// Sample in milliseconds that maps to a fully red bar. + static constexpr qint64 ColorScaleMs = 500; + + void setSamples(const QList &samplesMs); + +protected: + void paintEvent(QPaintEvent *event) override; + +private: + /// Floor of the vertical scale in milliseconds. Keeps small windows readable. + static constexpr qint64 MinScaleMs = 100; + + QList samples; +}; + +#endif diff --git a/cockatrice/src/client/latency_status_widget.cpp b/cockatrice/src/client/latency_status_widget.cpp new file mode 100644 index 000000000..940074581 --- /dev/null +++ b/cockatrice/src/client/latency_status_widget.cpp @@ -0,0 +1,115 @@ +/** + * @file latency_status_widget.cpp + * @ingroup Client + */ + +#include "latency_status_widget.h" + +#include "latency_graph_widget.h" +#include "latency_tracker.h" + +#include +#include +#include +#include +#include +#include +#include + +LatencyStatusWidget::LatencyStatusWidget(QWidget *parent) : QWidget(parent) +{ + pingLabel = new QLabel(this); + pingLabel->setAccessibleName(tr("Ping")); + + latencyGraph = new LatencyGraphWidget(this); + latencyGraph->setFixedSize(90, 14); + + auto *layout = new QHBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(4); + layout->addWidget(latencyGraph); + layout->addWidget(pingLabel); + + // Clicking anywhere in the area opens the detail view. + for (QObject *child : QList{pingLabel, latencyGraph}) { + child->installEventFilter(this); + } + setCursor(Qt::PointingHandCursor); + + hide(); +} + +void LatencyStatusWidget::updateData(const LatencyTracker::Stats &stats, const QList &samplesMs) +{ + latestSamples = samplesMs; + latencyGraph->setSamples(samplesMs); + if (popup && popup->isVisible() && detailGraph) { + detailGraph->setSamples(samplesMs); + } + + if (stats.sampleCount == 0) { + hide(); + return; + } + + const QString statsStr = statsText(stats); + + pingLabel->setText(tr("Ping: %1 ms").arg(stats.lastMs)); + pingLabel->setToolTip(statsStr); + pingLabel->setAccessibleDescription(statsStr); + if (popup && popup->isVisible() && detailLabel) { + detailLabel->setText(statsStr); + } + show(); +} + +bool LatencyStatusWidget::eventFilter(QObject *watched, QEvent *event) +{ + if ((watched == pingLabel || watched == latencyGraph) && event->type() == QEvent::MouseButtonPress) { + togglePopup(); + return true; + } + return QWidget::eventFilter(watched, event); +} + +void LatencyStatusWidget::togglePopup() +{ + if (!popup) { + popup = new QWidget(this, Qt::Popup | Qt::FramelessWindowHint); + auto *layout = new QVBoxLayout(popup); + layout->setContentsMargins(8, 8, 8, 8); + + detailLabel = new QLabel(popup); + detailLabel->setAccessibleName(tr("Connection latency details")); + detailLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); + + detailGraph = new LatencyGraphWidget(popup); + detailGraph->setFixedSize(280, 80); + + layout->addWidget(detailLabel, 0, Qt::AlignLeft); + layout->addWidget(detailGraph, 0, Qt::AlignHCenter); + } + + if (popup->isVisible()) { + popup->hide(); + return; + } + + // Qt::Popup closes itself on any outside click, so just position and show. + if (latestSamples.isEmpty()) { + return; + } + detailGraph->setSamples(latestSamples); + detailLabel->setText(pingLabel->toolTip()); + popup->adjustSize(); + const QPoint anchor = mapToGlobal(QPoint(width() / 2, 0)); + popup->move(anchor.x() - popup->width() / 2, anchor.y() - popup->height() - 6); + popup->show(); +} + +QString LatencyStatusWidget::statsText(const LatencyTracker::Stats &stats) const +{ + return tr("Connection quality over the last %n sample(s):", "", stats.sampleCount) + "\n" + + tr("Last: %1 ms").arg(stats.lastMs) + "\n" + tr("Median: %1 ms").arg(stats.medianMs) + "\n" + + tr("95th percentile: %1 ms").arg(stats.p95Ms) + "\n" + tr("Maximum: %1 ms").arg(stats.maxMs); +} diff --git a/cockatrice/src/client/latency_status_widget.h b/cockatrice/src/client/latency_status_widget.h new file mode 100644 index 000000000..24d6fda4d --- /dev/null +++ b/cockatrice/src/client/latency_status_widget.h @@ -0,0 +1,52 @@ +/** + * @file latency_status_widget.h + * @ingroup Client + */ + +#ifndef LATENCY_STATUS_WIDGET_H +#define LATENCY_STATUS_WIDGET_H + +#include +#include +#include +#include +#include + +class QLabel; +class LatencyGraphWidget; + +/** + * @brief Status bar presentation of server round-trip health. + * + * Combines the textual "Ping" readout with a small LatencyGraphWidget + * sparkline of the rolling sample window. Clicking anywhere in the area opens + * a popup with a larger graph and the numeric statistics. It closes on any + * outside click. Stays hidden while disconnected or before any samples exist. + * Owns all latency display state so MainWindow only needs to forward one + * signal here. + */ +class LatencyStatusWidget : public QWidget +{ + Q_OBJECT +public: + explicit LatencyStatusWidget(QWidget *parent = nullptr); + +public slots: + void updateData(const LatencyTracker::Stats &stats, const QList &samplesMs); + +protected: + bool eventFilter(QObject *watched, QEvent *event) override; + +private: + void togglePopup(); + QString statsText(const LatencyTracker::Stats &stats) const; + + QLabel *pingLabel = nullptr; + LatencyGraphWidget *latencyGraph = nullptr; + QWidget *popup = nullptr; + LatencyGraphWidget *detailGraph = nullptr; + QLabel *detailLabel = nullptr; + QList latestSamples; +}; + +#endif diff --git a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp index 4e425fb66..7480da27e 100644 --- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp +++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp @@ -1,5 +1,6 @@ #include "remote_connection_controller.h" +#include "../../../interface/pixel_map_generator.h" #include "../../settings/cache_settings.h" #include "../interface/widgets/dialogs/dlg_connect.h" #include "../interface/widgets/dialogs/dlg_forgot_password_challenge.h" @@ -7,14 +8,25 @@ #include "../interface/widgets/dialogs/dlg_forgot_password_reset.h" #include "../interface/widgets/dialogs/dlg_register.h" #include "../interface/widgets/utility/get_text_with_max.h" +#include "libcockatrice/protocol/pb/event_connection_closed.pb.h" +#include "libcockatrice/protocol/pb/event_server_shutdown.pb.h" +#include #include +#include #include +#include #include +#include +#include +#include #include #include #include #include +#include + +class QWidget; ConnectionController::ConnectionController(QWidget *dialogParent, QObject *parent) : QObject(parent), dialogParent(dialogParent) @@ -44,6 +56,8 @@ void ConnectionController::wireClientSignals() connect(remoteClient, &RemoteClient::statusChanged, this, &ConnectionController::onStatusChanged); + connect(remoteClient, &AbstractClient::pingStatsUpdated, this, &ConnectionController::pingStatsUpdated); + connect(remoteClient, &RemoteClient::userInfoChanged, this, &ConnectionController::onUserInfoReceived, Qt::BlockingQueuedConnection); @@ -178,7 +192,7 @@ void ConnectionController::onServerShutdownEvent(const Event_ServerShutdown &eve "games will be lost.\nReason for shutdown: %1", "", event.minutes()) .arg(QString::fromStdString(event.reason()))); - serverShutdownMessageBox.setIconPixmap(QPixmap("theme:cockatrice").scaled(64, 64)); + serverShutdownMessageBox.setIconPixmap(themePixmap(QStringLiteral("cockatrice")).scaled(64, 64)); serverShutdownMessageBox.setText(tr("Scheduled server shutdown")); serverShutdownMessageBox.setWindowModality(Qt::ApplicationModal); serverShutdownMessageBox.setVisible(true); @@ -296,6 +310,15 @@ void ConnectionController::onLoginError(int r, return; } + case Response::RespPasswordChangeRequired: { + QMessageBox::information( + dialogParent, tr("Password Change Required"), + tr("An administrator has reset your password. Please contact your server administrator to obtain " + "your temporary password, then log in and change it via Account -> Change Password.")); + remoteClient->disconnectFromServer(); + return; + } + case Response::RespServerFull: { QMessageBox::critical(dialogParent, tr("Server Full"), tr("The server has reached its maximum user capacity, please check back later.")); diff --git a/cockatrice/src/client/network/connection_controller/remote_connection_controller.h b/cockatrice/src/client/network/connection_controller/remote_connection_controller.h index 7486bc81a..d4a832c75 100644 --- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.h +++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.h @@ -2,17 +2,22 @@ #define COCKATRICE_REMOTE_CONNECTION_CONTROLLER_H #include "abstract_client.h" +#include "latency_tracker.h" #include #include #include -#include -#include -#include +#include +#include class RemoteClient; class ServerInfo_User; class DlgConnect; +class Event_ConnectionClosed; +class Event_ServerShutdown; +class QThread; +class QWidget; +template class QList; /** * Owns the RemoteClient and its worker thread. @@ -54,6 +59,10 @@ signals: // action enable/disable logic void statusChanged(ClientStatus status); + // Forwarded from AbstractClient::pingStatsUpdated. See that signal for the + // meaning of the parameters. + void pingStatsUpdated(const LatencyTracker::Stats &stats, const QList &samplesMs); + private slots: // Slots wired directly to RemoteClient signals void onStatusChanged(ClientStatus status); diff --git a/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp b/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp index 8689a19e9..d5cfdbbc9 100644 --- a/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp +++ b/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp @@ -1,11 +1,22 @@ #include "deck_stats_interface.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/deck_list/tree/inner_deck_list_node.h" + +#include #include #include +#include #include #include #include +#include +#include +#include +#include #include +#include #include #include #include @@ -72,7 +83,7 @@ void DeckStatsInterface::copyDeckWithoutTokens(const DeckList &source, DeckList { auto copyIfNotAToken = [&destination](const auto node, const auto card) { CardInfoPtr dbCard = CardDatabaseManager::query()->getCardInfo(card->getName()); - if (dbCard && !dbCard->getIsToken()) { + if (dbCard && !dbCard->getIsToken() && node->getName() != DECK_ZONE_MAYBEBOARD) { DecklistCardNode *addedCard = destination.addCard(card->getName(), node->getName(), -1); addedCard->setNumber(card->getNumber()); } diff --git a/cockatrice/src/client/network/interfaces/deck_stats_interface.h b/cockatrice/src/client/network/interfaces/deck_stats_interface.h index 09bf998de..e7791075c 100644 --- a/cockatrice/src/client/network/interfaces/deck_stats_interface.h +++ b/cockatrice/src/client/network/interfaces/deck_stats_interface.h @@ -7,7 +7,8 @@ #ifndef DECKSTATS_INTERFACE_H #define DECKSTATS_INTERFACE_H -#include +#include +#include class QByteArray; class QNetworkAccessManager; diff --git a/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp b/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp index 5dc77fa2c..30044c2b6 100644 --- a/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp +++ b/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp @@ -1,11 +1,28 @@ #include "tapped_out_interface.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/deck_list/tree/inner_deck_list_node.h" + +#include +#include #include +#include #include +#include +#include #include #include #include +#include +#include +#include +#include +#include +#include #include +#include +#include #include #include #include @@ -99,7 +116,7 @@ void TappedOutInterface::copyDeckSplitMainAndSide(const DeckList &source, DeckLi { auto copyMainOrSide = [&mainboard, &sideboard](const auto node, const auto card) { CardInfoPtr dbCard = CardDatabaseManager::query()->getCardInfo(card->getName()); - if (!dbCard || dbCard->getIsToken()) { + if (!dbCard || dbCard->getIsToken() || node->getName() == DECK_ZONE_MAYBEBOARD) { return; } diff --git a/cockatrice/src/client/network/interfaces/tapped_out_interface.h b/cockatrice/src/client/network/interfaces/tapped_out_interface.h index 32f9369d5..615991b07 100644 --- a/cockatrice/src/client/network/interfaces/tapped_out_interface.h +++ b/cockatrice/src/client/network/interfaces/tapped_out_interface.h @@ -9,6 +9,7 @@ #include #include +#include inline Q_LOGGING_CATEGORY(TappedOutInterfaceLog, "tapped_out_interface"); diff --git a/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.cpp b/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.cpp index 010b63684..7127b0a29 100644 --- a/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.cpp +++ b/cockatrice/src/client/network/parsers/deck_link_to_api_transformer.cpp @@ -1,6 +1,7 @@ #include "deck_link_to_api_transformer.h" #include +#include namespace DeckLinkToApiTransformer { diff --git a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp index dae633717..ae5e92d6c 100644 --- a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp +++ b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp @@ -1,21 +1,35 @@ #include "spoiler_background_updater.h" -#include "../../../../interface/window_main.h" #include "../../../../main.h" #include "../../../settings/cache_settings.h" +#include #include #include #include +#include #include +#include +#include +#include #include +#include +#include #include +#include +#include +#include +#include #include -#include +#include +#include +#include #include #include #include #include +#include +#include #include #define SPOILERS_STATUS_URL "https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/SpoilerSeasonEnabled" diff --git a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.h b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.h index 03d4897a2..a6c4ed00f 100644 --- a/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.h +++ b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.h @@ -10,7 +10,11 @@ #include #include #include -#include +#include +#include + +class QProcess; +class QUrl; inline Q_LOGGING_CATEGORY(SpoilerBackgroundUpdaterLog, "spoiler_background_updater"); diff --git a/cockatrice/src/client/network/update/client/client_update_checker.h b/cockatrice/src/client/network/update/client/client_update_checker.h index 1a89de533..16cbd0369 100644 --- a/cockatrice/src/client/network/update/client/client_update_checker.h +++ b/cockatrice/src/client/network/update/client/client_update_checker.h @@ -6,9 +6,12 @@ #ifndef CLIENT_UPDATE_CHECKER_H #define CLIENT_UPDATE_CHECKER_H -#include +#include "release_channel.h" -class Release; +#include +#include +#include +#include /** * We use a singleton instance of UpdateChannel, which can cause interference and feedback loops when multiple objects diff --git a/cockatrice/src/client/network/update/client/release_channel.cpp b/cockatrice/src/client/network/update/client/release_channel.cpp index 260167bc8..45c503623 100644 --- a/cockatrice/src/client/network/update/client/release_channel.cpp +++ b/cockatrice/src/client/network/update/client/release_channel.cpp @@ -2,14 +2,27 @@ #include "version_string.h" +#include +#include #include #include #include -#include +#include +#include +#include +#include +#include #include +#include #include -#include +#include +#include +#include +#include #include +#include +#include +#include #if defined(Q_OS_MACOS) #include @@ -26,6 +39,29 @@ #define GIT_SHORT_HASH_LEN 7 +namespace +{ + +#if defined(Q_OS_MACOS) || defined(Q_OS_WIN) +// QSysInfo::productVersion() is not guaranteed to start with a number, e.g. it returns +// "Server 2022" on Windows Server. Use the first numeric token as the major host version. +std::optional getProductVersionMajor() +{ + const QString versionString = QSysInfo::productVersion(); + bool ok = false; + static const QRegularExpression firstNumber(R"((\d+))"); + const auto match = firstNumber.match(versionString); + const int version = match.hasMatch() ? match.captured(1).toInt(&ok) : 0; + if (!ok) { + qCWarning(ReleaseChannelLog) << "Unable to determine OS version from" << versionString; + return std::nullopt; + } + return version; +} +#endif + +} // namespace + ReleaseChannel::ReleaseChannel() : netMan(new QNetworkAccessManager(this)), response(nullptr), lastRelease(nullptr) { } @@ -43,55 +79,93 @@ void ReleaseChannel::checkForUpdates() connect(response, &QNetworkReply::finished, this, &ReleaseChannel::releaseListFinished); } -// Different release channel checking functions for different operating systems -bool ReleaseChannel::downloadMatchesCurrentOS(const QString &fileName) +// Find assets compatible with host platform (Linux is not supported by in-client updater) +std::optional ReleaseChannel::getTargetVersionForCurrentOS(const QString &fileName) { -#if defined(Q_OS_MACOS) - static QRegularExpression version_regex("macOS(\\d+)"); - auto match = version_regex.match(fileName); - if (!match.hasMatch()) { - return false; +#if defined(Q_OS_WIN) && Q_PROCESSOR_WORDSIZE == 4 + // The published Windows assets are 64-bit only + Q_UNUSED(fileName); + return std::nullopt; +#endif + + const QRegularExpression *regex = nullptr; + + // Only platforms shipping updater assets (macOS, Windows) need the host version + static const std::optional systemVersion = +#if defined(Q_OS_MACOS) || defined(Q_OS_WIN) + getProductVersionMajor(); +#else + std::nullopt; +#endif + + if (!systemVersion) { + return std::nullopt; } - auto getSystemVersion = [] { +#if defined(Q_OS_MACOS) + static const bool isIntel = [] { // QSysInfo does not go through translation layers // We need to use sysctl to reliably detect the underlying architecture char arch[255]; size_t len = sizeof(arch); if (sysctlbyname("machdep.cpu.brand_string", arch, &len, nullptr, 0) == 0) { - // Intel mac is only supported on macOS 13 versions - if (QString::fromUtf8(arch).contains("Intel")) { - return 13; - } + return QString::fromUtf8(arch).contains("Intel"); } + return false; + }(); - return QSysInfo::productVersion().split(".")[0].toInt(); - }; - - // older(smaller) releases are compatible with a newer or the same system version - int sys_maj = getSystemVersion(); - int rel_maj = match.captured(1).toInt(); - return rel_maj == sys_maj; + // Apple (ARM) --> Cockatrice-3.0.0-macOS15.dmg + // Apple (x86) --> Cockatrice-3.0.0-macOS15_Intel.dmg + static const QRegularExpression macIntelRegex(R"(-macOS(\d+)_Intel\.[^.]+$)", + QRegularExpression::CaseInsensitiveOption); + static const QRegularExpression macRegex(R"(-macOS(\d+)\.[^.]+$)", QRegularExpression::CaseInsensitiveOption); + regex = isIntel ? &macIntelRegex : &macRegex; #elif defined(Q_OS_WIN) -#if Q_PROCESSOR_WORDSIZE == 4 - return fileName.contains("32bit"); -#elif Q_PROCESSOR_WORDSIZE == 8 - const QString &version = QSysInfo::productVersion(); - if (version.startsWith("7") || version.startsWith("8")) { - return fileName.contains("Win7"); - } else { - return fileName.contains("Win10"); - } -#else + // Windows (x86) --> Cockatrice-3.0.0-Windows10.exe + static const QRegularExpression winRegex(R"(-(?:Win|Windows)(\d+)\.[^.]+$)", + QRegularExpression::CaseInsensitiveOption); + regex = &winRegex; + +#else // if the OS doesn't fit one of the above #defines, then it will never match Q_UNUSED(fileName); - return false; + return std::nullopt; #endif -#else // If the OS doesn't fit one of the above #defines, then it will never match - Q_UNUSED(fileName); - return false; -#endif + // Any asset targeting an OS version up to the current host version works + auto match = regex->match(fileName); + if (!match.hasMatch()) { + return std::nullopt; + } + const int targetVersion = match.captured(1).toInt(); + if (targetVersion > *systemVersion) { + return std::nullopt; + } + return targetVersion; +} + +// Pick newest targeted version (highest number) amongst all compatible ones +QString ReleaseChannel::findBestDownloadUrl(const QVariantList &assets) +{ + QString bestUrl; + int bestVersion = -1; + for (const auto &rawAsset : assets) { + QVariantMap asset = rawAsset.toMap(); + QString name = asset["name"].toString(); + QString url = asset["browser_download_url"].toString(); + auto version = getTargetVersionForCurrentOS(name); + if (!version) { + continue; + } + if (*version > bestVersion) { + bestVersion = *version; + bestUrl = url; + } + } + if (!bestUrl.isEmpty()) { + qCInfo(ReleaseChannelLog) << "Best compatible asset=" << bestUrl; + } + return bestUrl; } QString StableReleaseChannel::getManualDownloadUrl() const @@ -138,16 +212,9 @@ void StableReleaseChannel::releaseListFinished() lastRelease->setPublishDate(resultMap["published_at"].toDate()); if (resultMap.contains("assets")) { - auto rawAssets = resultMap["assets"].toList(); - for (const auto &rawAsset : rawAssets) { - QVariantMap asset = rawAsset.toMap(); - QString name = asset["name"].toString(); - QString url = asset["browser_download_url"].toString(); - - if (downloadMatchesCurrentOS(name)) { - lastRelease->setDownloadUrl(url); - break; - } + auto url = findBestDownloadUrl(resultMap["assets"].toList()); + if (!url.isEmpty()) { + lastRelease->setDownloadUrl(url); } } @@ -289,21 +356,10 @@ void BetaReleaseChannel::fileListFinished() bool needToUpdate = (QString::compare(shortHash, myHash, Qt::CaseInsensitive) != 0); bool compatibleVersion = false; - QStringList resultUrlList{}; - for (QVariant file : resultList) { - QVariantMap map = file.toMap(); - resultUrlList << map["browser_download_url"].toString(); - } - - resultUrlList.sort(); - // iterate in reverse so the first item is the latest os version - for (auto url = resultUrlList.rbegin(); url < resultUrlList.rend(); ++url) { - if (downloadMatchesCurrentOS(*url)) { - compatibleVersion = true; - lastRelease->setDownloadUrl(*url); - qCInfo(ReleaseChannelLog) << "Found compatible version url=" << *url; - break; - } + QString downloadUrl = findBestDownloadUrl(resultList); + if (!downloadUrl.isEmpty()) { + compatibleVersion = true; + lastRelease->setDownloadUrl(downloadUrl); } emit finishedCheck(needToUpdate, compatibleVersion, lastRelease); diff --git a/cockatrice/src/client/network/update/client/release_channel.h b/cockatrice/src/client/network/update/client/release_channel.h index c56d0cfce..183a766ab 100644 --- a/cockatrice/src/client/network/update/client/release_channel.h +++ b/cockatrice/src/client/network/update/client/release_channel.h @@ -11,7 +11,9 @@ #include #include #include -#include +#include +#include +#include #include inline Q_LOGGING_CATEGORY(ReleaseChannelLog, "release_channel"); @@ -96,7 +98,8 @@ protected: Release *lastRelease; protected: - static bool downloadMatchesCurrentOS(const QString &fileName); + static std::optional getTargetVersionForCurrentOS(const QString &fileName); + static QString findBestDownloadUrl(const QVariantList &assets); [[nodiscard]] virtual QString getReleaseChannelUrl() const = 0; public: diff --git a/cockatrice/src/client/network/update/client/update_downloader.cpp b/cockatrice/src/client/network/update/client/update_downloader.cpp index a71bcf8f8..c86972d60 100644 --- a/cockatrice/src/client/network/update/client/update_downloader.cpp +++ b/cockatrice/src/client/network/update/client/update_downloader.cpp @@ -1,6 +1,14 @@ #include "update_downloader.h" +#include +#include +#include +#include +#include +#include +#include #include +#include UpdateDownloader::UpdateDownloader(QObject *parent) : QObject(parent), response(nullptr) { diff --git a/cockatrice/src/client/network/update/client/update_downloader.h b/cockatrice/src/client/network/update/client/update_downloader.h index 9a0caccbc..8aeefb22a 100644 --- a/cockatrice/src/client/network/update/client/update_downloader.h +++ b/cockatrice/src/client/network/update/client/update_downloader.h @@ -7,8 +7,14 @@ #ifndef COCKATRICE_UPDATEDOWNLOADER_H #define COCKATRICE_UPDATEDOWNLOADER_H +#include #include -#include +#include +#include +#include +#include + +class QNetworkAccessManager; class UpdateDownloader : public QObject { diff --git a/cockatrice/src/client/settings/cache_settings.cpp b/cockatrice/src/client/settings/cache_settings.cpp index eaa5d96ac..15a41e7e1 100644 --- a/cockatrice/src/client/settings/cache_settings.cpp +++ b/cockatrice/src/client/settings/cache_settings.cpp @@ -2,15 +2,20 @@ #include "../network/update/client/release_channel.h" #include "card_counter_settings.h" +#include "libcockatrice/interfaces/interface_card_database_path_provider.h" #include "shortcuts_settings.h" -#include "version_string.h" +#include #include #include #include #include +#include #include +#include #include +#include +#include #include #include #include @@ -110,6 +115,16 @@ SettingsCache::SettingsCache() settings = new QSettings(settingsPath + "global.ini", QSettings::IniFormat, this); + // Both migrations below overwrite per-domain files in place, so snapshot the whole settings + // folder first: global.ini is preserved as global.ini.old, but pre-existing per-domain files + // (and a re-migration after a downgrade) would otherwise have no way back. + if (SettingsMigration::isMigrationPending(settingsPath)) { + const QString backupPath = SettingsMigration::backupSettingsDirectory(settingsPath); + if (!backupPath.isEmpty()) { + qCInfo(SettingsCacheLog) << "[SettingsCache] Settings backed up before migration:" << backupPath; + } + } + // Migrate from legacy NativeFormat settings to per-class INI files (runs at most once) if (!isPortableBuild) { SettingsMigration::migrateLegacySettings(settingsPath); diff --git a/cockatrice/src/client/settings/cache_settings.h b/cockatrice/src/client/settings/cache_settings.h index 23cdb4dbf..5f699247d 100644 --- a/cockatrice/src/client/settings/cache_settings.h +++ b/cockatrice/src/client/settings/cache_settings.h @@ -7,11 +7,13 @@ #ifndef SETTINGSCACHE_H #define SETTINGSCACHE_H +#include #include +#include #include #include #include -#include +#include inline Q_LOGGING_CATEGORY(SettingsCacheLog, "settings_cache"); @@ -58,33 +60,33 @@ signals: void themeChanged(); private: - QSettings *settings; - ShortcutsSettings *shortcutsSettings; - CardDatabaseSettings *cardDatabaseSettings; - ServersSettings *serversSettings; - MessageSettings *messageSettings; - GameFiltersSettings *gameFiltersSettings; - LayoutsSettings *layoutsSettings; - DownloadSettings *downloadSettings; - RecentsSettings *recentsSettings; - CardOverrideSettings *cardOverrideSettings; - DebugSettings *debugSettings; - CardCounterSettings *cardCounterSettings; - TabsSettings *tabsSettings; - SoundSettings *soundSettings; - GameSettings *gameSettings; - ChatSettings *chatSettings; - CacheStorageSettings *cacheStorageSettings; - UpdatesSettings *updatesSettings; - PersonalSettings *personalSettings; - CardsDisplaySettings *cardsDisplaySettings; - InterfaceSettings *interfaceSettings; - DeckEditorSettings *deckEditorSettings; - PathsSettings *pathsSettings; - VisualDeckStorageSettings *visualDeckStorageSettings; - AppearanceSettings *appearanceSettings; - NetworkSettings *networkSettings; - CommanderBracketSettings *commanderBracketSettings; + QSettings *settings = nullptr; + ShortcutsSettings *shortcutsSettings = nullptr; + CardDatabaseSettings *cardDatabaseSettings = nullptr; + ServersSettings *serversSettings = nullptr; + MessageSettings *messageSettings = nullptr; + GameFiltersSettings *gameFiltersSettings = nullptr; + LayoutsSettings *layoutsSettings = nullptr; + DownloadSettings *downloadSettings = nullptr; + RecentsSettings *recentsSettings = nullptr; + CardOverrideSettings *cardOverrideSettings = nullptr; + DebugSettings *debugSettings = nullptr; + CardCounterSettings *cardCounterSettings = nullptr; + TabsSettings *tabsSettings = nullptr; + SoundSettings *soundSettings = nullptr; + GameSettings *gameSettings = nullptr; + ChatSettings *chatSettings = nullptr; + CacheStorageSettings *cacheStorageSettings = nullptr; + UpdatesSettings *updatesSettings = nullptr; + PersonalSettings *personalSettings = nullptr; + CardsDisplaySettings *cardsDisplaySettings = nullptr; + InterfaceSettings *interfaceSettings = nullptr; + DeckEditorSettings *deckEditorSettings = nullptr; + PathsSettings *pathsSettings = nullptr; + VisualDeckStorageSettings *visualDeckStorageSettings = nullptr; + AppearanceSettings *appearanceSettings = nullptr; + NetworkSettings *networkSettings = nullptr; + CommanderBracketSettings *commanderBracketSettings = nullptr; QString themeName; diff --git a/cockatrice/src/client/settings/card_counter_settings.cpp b/cockatrice/src/client/settings/card_counter_settings.cpp index d4030c174..4bdd581b5 100644 --- a/cockatrice/src/client/settings/card_counter_settings.cpp +++ b/cockatrice/src/client/settings/card_counter_settings.cpp @@ -1,8 +1,15 @@ #include "card_counter_settings.h" +#include "libcockatrice/settings/settings_manager.h" + +#include #include #include +#include #include +#include + +class QObject; CardCounterSettings::CardCounterSettings(const QString &settingsPath, QObject *parent) : SettingsManager(settingsPath + "card_counters.ini", "cards", "counters", parent) diff --git a/cockatrice/src/client/settings/card_counter_settings.h b/cockatrice/src/client/settings/card_counter_settings.h index 2ac658177..e5ca0a9bb 100644 --- a/cockatrice/src/client/settings/card_counter_settings.h +++ b/cockatrice/src/client/settings/card_counter_settings.h @@ -7,10 +7,12 @@ #ifndef CARD_COUNTER_SETTINGS_H #define CARD_COUNTER_SETTINGS_H +#include #include +#include -class QSettings; class QColor; +class QObject; class CardCounterSettings : public SettingsManager { diff --git a/cockatrice/src/client/settings/shortcut_treeview.cpp b/cockatrice/src/client/settings/shortcut_treeview.cpp index b909d47ac..4a5bafb01 100644 --- a/cockatrice/src/client/settings/shortcut_treeview.cpp +++ b/cockatrice/src/client/settings/shortcut_treeview.cpp @@ -3,7 +3,24 @@ #include "cache_settings.h" #include "shortcuts_settings.h" +#include +#include +#include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class QObject; +class QWidget; ShortcutFilterProxyModel::ShortcutFilterProxyModel(QObject *parent) : QSortFilterProxyModel(parent) { diff --git a/cockatrice/src/client/settings/shortcut_treeview.h b/cockatrice/src/client/settings/shortcut_treeview.h index afaf7e7ed..53f3fa0ef 100644 --- a/cockatrice/src/client/settings/shortcut_treeview.h +++ b/cockatrice/src/client/settings/shortcut_treeview.h @@ -8,8 +8,13 @@ #define SHORTCUT_TREEVIEW_H #include -#include +#include #include +#include + +class QObject; +class QStandardItemModel; +class QWidget; /** * Custom implementation of QSortFilterProxyModel that appends the source and parent strings together when filtering diff --git a/cockatrice/src/client/settings/shortcuts_settings.cpp b/cockatrice/src/client/settings/shortcuts_settings.cpp index 2ff65ab6f..ffb4afcac 100644 --- a/cockatrice/src/client/settings/shortcuts_settings.cpp +++ b/cockatrice/src/client/settings/shortcuts_settings.cpp @@ -2,9 +2,13 @@ #include #include +#include #include +#include +#include +#include #include -#include +#include ShortcutKey::ShortcutKey(const QString &_name, QList _sequence, ShortcutGroup::Groups _group) : QList(_sequence), name(_name), group(_group) diff --git a/cockatrice/src/client/settings/shortcuts_settings.h b/cockatrice/src/client/settings/shortcuts_settings.h index 95155b8d1..24790a407 100644 --- a/cockatrice/src/client/settings/shortcuts_settings.h +++ b/cockatrice/src/client/settings/shortcuts_settings.h @@ -8,9 +8,18 @@ #define SHORTCUTSSETTINGS_H #include +#include +#include +#include #include +#include #include -#include +#include +#include +#include +#include +#include +#include inline Q_LOGGING_CATEGORY(ShortcutsSettingsLog, "shortcuts_settings"); @@ -786,6 +795,10 @@ private: ShortcutGroup::Tabs)}, {"Tabs/aTabLogs", ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Logs"), parseSequenceString(""), ShortcutGroup::Tabs)}, + {"Tabs/aTabReport", + ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Report Queue"), parseSequenceString(""), ShortcutGroup::Tabs)}, + {"Tabs/aTabModeration", + ShortcutKey(QT_TRANSLATE_NOOP("shortcutsTab", "Moderation"), parseSequenceString(""), ShortcutGroup::Tabs)}, }; }; diff --git a/cockatrice/src/client/sound_engine.cpp b/cockatrice/src/client/sound_engine.cpp index 18de2264d..4ac142cf4 100644 --- a/cockatrice/src/client/sound_engine.cpp +++ b/cockatrice/src/client/sound_engine.cpp @@ -4,8 +4,15 @@ #include #include +#include #include +#include +#include #include +#include +#include +#include +#include #include #define DEFAULT_THEME_NAME "Default" @@ -94,7 +101,7 @@ QStringMap &SoundEngine::getAvailableThemes() QDir dir; availableThemes.clear(); - // load themes from user profile dir + // Load themes from user profile dir dir.setPath(SettingsCache::instance().getDataPath() + "/sounds"); @@ -104,7 +111,7 @@ QStringMap &SoundEngine::getAvailableThemes() } } - // load themes from cockatrice system dir + // Load themes from Cockatrice system dir dir.setPath(qApp->applicationDirPath() + #ifdef Q_OS_MAC "/../Resources/sounds" diff --git a/cockatrice/src/client/sound_engine.h b/cockatrice/src/client/sound_engine.h index 0c201523a..8167c71b5 100644 --- a/cockatrice/src/client/sound_engine.h +++ b/cockatrice/src/client/sound_engine.h @@ -7,17 +7,17 @@ #ifndef SOUNDENGINE_H #define SOUNDENGINE_H -#include #include #include -#include #include #include +#include + +class QAudioOutput; +class QMediaPlayer; inline Q_LOGGING_CATEGORY(SoundEngineLog, "sound_engine"); -class QBuffer; - typedef QMap QStringMap; class SoundEngine : public QObject diff --git a/cockatrice/src/database/interface/settings_card_preference_provider.h b/cockatrice/src/database/interface/settings_card_preference_provider.h index 64502e3a7..1feb2006b 100644 --- a/cockatrice/src/database/interface/settings_card_preference_provider.h +++ b/cockatrice/src/database/interface/settings_card_preference_provider.h @@ -13,11 +13,6 @@ public: { return SettingsCache::instance().cardOverrides().getCardPreferenceOverride(cardName); } - - [[nodiscard]] bool getIncludeRebalancedCards() const override - { - return SettingsCache::instance().cardsDisplay().getIncludeRebalancedCards(); - } }; #endif // COCKATRICE_SETTINGS_CARD_PREFERENCE_PROVIDER_H diff --git a/cockatrice/src/filters/deck_filter_string.cpp b/cockatrice/src/filters/deck_filter_string.cpp index dd873cfa5..616311750 100644 --- a/cockatrice/src/filters/deck_filter_string.cpp +++ b/cockatrice/src/filters/deck_filter_string.cpp @@ -1,9 +1,27 @@ #include "deck_filter_string.h" +#include "../interface/deck_loader/loaded_deck.h" +#include "libcockatrice/card/card_localization.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/deck_list/deck_list.h" +#include "libcockatrice/deck_list/tree/deck_list_card_node.h" + +#include +#include #include +#include +#include +#include +#include +#include #include #include #include +#include +#include +#include +#include +#include static peg::parser search(R"( Start <- QueryPartList @@ -43,6 +61,12 @@ NumericValue <- [0-9]+ static std::once_flag init; +// The peglib parser is a single permanent object, so the rule actions below cannot see +// per-instance state. The card language that the nested [[card name]] search matches +// against is passed through this thread-local context, which is live only while a +// DeckFilterString is being parsed, and copied into the nested FilterString closures. +thread_local CardSearchLanguage deckSearchLanguageContext; + static void setupParserRules() { // plumbing @@ -52,18 +76,14 @@ static void setupParserRules() search["Start"] = passthru; search["QueryPartList"] = [](const peg::SemanticValues &sv) -> DeckFilter { - return [=](const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &info) { - auto matchesFilter = [&deck, &info](const std::any &query) { - return std::any_cast(query)(deck, info); - }; + return [=](const DeckSearchData &data) { + auto matchesFilter = [&data](const std::any &query) { return std::any_cast(query)(data); }; return std::all_of(sv.begin(), sv.end(), matchesFilter); }; }; search["ComplexQueryPart"] = [](const peg::SemanticValues &sv) -> DeckFilter { - return [=](const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &info) { - auto matchesFilter = [&deck, &info](const std::any &query) { - return std::any_cast(query)(deck, info); - }; + return [=](const DeckSearchData &data) { + auto matchesFilter = [&data](const std::any &query) { return std::any_cast(query)(data); }; return std::any_of(sv.begin(), sv.end(), matchesFilter); }; }; @@ -71,9 +91,7 @@ static void setupParserRules() search["QueryPart"] = passthru; search["NotQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter { const auto dependent = std::any_cast(sv[0]); - return [=](const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &info) -> bool { - return !dependent(deck, info); - }; + return [=](const DeckSearchData &data) -> bool { return !dependent(data); }; }; search["String"] = [](const peg::SemanticValues &sv) -> QString { @@ -122,12 +140,12 @@ static void setupParserRules() // actual functionality search["DeckContentQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter { - auto cardFilter = FilterString(std::any_cast(sv[0])); + auto cardFilter = FilterString(std::any_cast(sv[0]), deckSearchLanguageContext); auto numberMatcher = sv.size() > 1 ? std::any_cast(sv[1]) : [](int count) { return count > 0; }; - return [=](const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &) -> bool { + return [=](const DeckSearchData &data) -> bool { int count = 0; - auto cardNodes = deck->deckLoader->getDeck().deckList.getCardNodes(); + auto cardNodes = data.deck->deckList.getCardNodes(); for (auto node : cardNodes) { auto cardInfoPtr = CardDatabaseManager::query()->getCardInfo(node->getName()); if (!cardInfoPtr.isNull() && cardFilter.check(cardInfoPtr)) { @@ -146,57 +164,53 @@ static void setupParserRules() search["DeckNameQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter { auto name = std::any_cast(sv[0]); - return [=](const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &) { - return deck->deckLoader->getDeck().deckList.getName().contains(name, Qt::CaseInsensitive); + return [=](const DeckSearchData &data) { + return data.deck->deckList.getName().contains(name, Qt::CaseInsensitive); }; }; search["FileNameQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter { auto name = std::any_cast(sv[0]); - return [=](const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &) { - auto filename = QFileInfo(deck->filePath).fileName(); + return [=](const DeckSearchData &data) { + auto filename = QFileInfo(data.filePath).fileName(); return filename.contains(name, Qt::CaseInsensitive); }; }; search["PathQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter { auto name = std::any_cast(sv[0]); - return [=](const DeckPreviewWidget *, const ExtraDeckSearchInfo &info) { - return info.relativeFilePath.contains(name, Qt::CaseInsensitive); - }; + return [=](const DeckSearchData &data) { return data.relativeFilePath.contains(name, Qt::CaseInsensitive); }; }; search["FormatQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter { auto format = std::any_cast(sv[0]); - return [=](const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &) { - auto gameFormat = deck->deckLoader->getDeck().deckList.getGameFormat(); + return [=](const DeckSearchData &data) { + auto gameFormat = data.deck->deckList.getGameFormat(); return QString::compare(format, gameFormat, Qt::CaseInsensitive) == 0; }; }; search["CommentQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter { auto value = std::any_cast(sv[0]); - return [=](const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &) { - auto comments = deck->deckLoader->getDeck().deckList.getComments(); + return [=](const DeckSearchData &data) { + auto comments = data.deck->deckList.getComments(); return comments.contains(value, Qt::CaseInsensitive); }; }; search["GenericQuery"] = [](const peg::SemanticValues &sv) -> DeckFilter { auto name = std::any_cast(sv[0]); - return [=](const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &) { - return deck->getDisplayName().contains(name, Qt::CaseInsensitive); - }; + return [=](const DeckSearchData &data) { return data.displayName.contains(name, Qt::CaseInsensitive); }; }; } DeckFilterString::DeckFilterString() { - filter = [](const DeckPreviewWidget *, const ExtraDeckSearchInfo &) { return false; }; + filter = [](const DeckSearchData &) { return false; }; _error = "Not initialized"; } -DeckFilterString::DeckFilterString(const QString &expr) +DeckFilterString::DeckFilterString(const QString &expr, const CardSearchLanguage &searchLanguage) { QByteArray ba = expr.simplified().toUtf8(); @@ -205,16 +219,18 @@ DeckFilterString::DeckFilterString(const QString &expr) _error = QString(); if (ba.isEmpty()) { - filter = [](const DeckPreviewWidget *, const ExtraDeckSearchInfo &) { return true; }; + filter = [](const DeckSearchData &) { return true; }; return; } + deckSearchLanguageContext = searchLanguage; + search.set_logger([&](size_t /*ln*/, size_t col, const std::string &msg) { _error = QString("Error at position %1: %2").arg(col).arg(QString::fromStdString(msg)); }); if (!search.parse(ba.data(), filter)) { qCInfo(DeckFilterStringLog).nospace() << "DeckFilterString error for " << expr << "; " << qPrintable(_error); - filter = [](const DeckPreviewWidget *, const ExtraDeckSearchInfo &) { return false; }; + filter = [](const DeckSearchData &) { return false; }; } -} \ No newline at end of file +} diff --git a/cockatrice/src/filters/deck_filter_string.h b/cockatrice/src/filters/deck_filter_string.h index 916b629ee..76b891b59 100644 --- a/cockatrice/src/filters/deck_filter_string.h +++ b/cockatrice/src/filters/deck_filter_string.h @@ -7,35 +7,40 @@ #ifndef DECK_FILTER_STRING_H #define DECK_FILTER_STRING_H -#include "../interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h" - #include #include #include +#include + +struct CardSearchLanguage; +struct LoadedDeck; inline Q_LOGGING_CATEGORY(DeckFilterStringLog, "deck_filter_string"); /** - * Extra info relevant to filtering that isn't present in the DeckPreviewWidget + * The data a deck search expression is evaluated against. + * + * This is a data view rather than a widget pointer, so the same filter + * expression can be evaluated against a model or a live widget. */ -struct ExtraDeckSearchInfo +struct DeckSearchData { - /** - * The relative filepath starting from the deck folder - */ - QString relativeFilePath; + const LoadedDeck *deck = nullptr; ///< The loaded deck. Must not be null. + QString filePath; ///< Absolute path of the deck file. + QString displayName; ///< Deck name, or the file name if the deck has no name. + QString relativeFilePath; ///< File path relative to the deck folder. }; -typedef std::function DeckFilter; +typedef std::function DeckFilter; class DeckFilterString { public: DeckFilterString(); - explicit DeckFilterString(const QString &expr); - bool check(const DeckPreviewWidget *deck, const ExtraDeckSearchInfo &info) const + explicit DeckFilterString(const QString &expr, const CardSearchLanguage &searchLanguage = {}); + bool check(const DeckSearchData &data) const { - return filter(deck, info); + return filter(data); } [[nodiscard]] bool valid() const diff --git a/cockatrice/src/filters/filter_builder.cpp b/cockatrice/src/filters/filter_builder.cpp index 785f753e7..6cc893224 100644 --- a/cockatrice/src/filters/filter_builder.cpp +++ b/cockatrice/src/filters/filter_builder.cpp @@ -1,11 +1,19 @@ #include "filter_builder.h" +#include "../interface/pixel_map_generator.h" #include "../interface/widgets/utility/custom_line_edit.h" #include #include +#include #include +#include +#include +#include +#include +#include #include +#include FilterBuilder::FilterBuilder(QWidget *parent) : QWidget(parent) { @@ -21,7 +29,7 @@ FilterBuilder::FilterBuilder(QWidget *parent) : QWidget(parent) typeCombo->addItem(CardFilter::typeName(static_cast(i)), QVariant(i)); } - QPushButton *ok = new QPushButton(QPixmap("theme:icons/increment"), QString()); + QPushButton *ok = new QPushButton(themePixmap(QStringLiteral("icons/increment")), QString()); ok->setObjectName("ok"); ok->setMaximumSize(20, 20); diff --git a/cockatrice/src/filters/filter_builder.h b/cockatrice/src/filters/filter_builder.h index 98fe3fd02..2aa38c992 100644 --- a/cockatrice/src/filters/filter_builder.h +++ b/cockatrice/src/filters/filter_builder.h @@ -8,8 +8,8 @@ #define FILTERBUILDER_H #include +#include -class QCheckBox; class QComboBox; class LineEditUnfocusable; class CardFilter; diff --git a/cockatrice/src/filters/filter_tree_model.cpp b/cockatrice/src/filters/filter_tree_model.cpp index 33b54530e..b3bf307f1 100644 --- a/cockatrice/src/filters/filter_tree_model.cpp +++ b/cockatrice/src/filters/filter_tree_model.cpp @@ -1,8 +1,15 @@ #include "filter_tree_model.h" +#include #include +#include +#include +#include #include #include +#include + +class QObject; FilterTreeModel::FilterTreeModel(QObject *parent) : QAbstractItemModel(parent) { diff --git a/cockatrice/src/filters/filter_tree_model.h b/cockatrice/src/filters/filter_tree_model.h index 7452f7a61..826fcc941 100644 --- a/cockatrice/src/filters/filter_tree_model.h +++ b/cockatrice/src/filters/filter_tree_model.h @@ -8,11 +8,15 @@ #define FILTERTREEMODEL_H #include +#include +#include #include +#include +#include class FilterTree; -class CardFilter; class FilterTreeNode; +class QObject; class FilterTreeModel : public QAbstractItemModel { diff --git a/cockatrice/src/filters/syntax_help.cpp b/cockatrice/src/filters/syntax_help.cpp index fa5338a41..baf5764d0 100644 --- a/cockatrice/src/filters/syntax_help.cpp +++ b/cockatrice/src/filters/syntax_help.cpp @@ -1,8 +1,21 @@ #include "syntax_help.h" +#include #include +#include +#include +#include +#include +#include #include +#include +#include +#include +#include +#include #include +#include +#include /** * Creates the card search syntax help window diff --git a/cockatrice/src/filters/syntax_help.h b/cockatrice/src/filters/syntax_help.h index d06fe03e5..52c878691 100644 --- a/cockatrice/src/filters/syntax_help.h +++ b/cockatrice/src/filters/syntax_help.h @@ -8,9 +8,10 @@ #ifndef SEARCH_SYNTAX_HELP_H #define SEARCH_SYNTAX_HELP_H -#include #include -#include + +class QLineEdit; +class QTextBrowser; inline Q_LOGGING_CATEGORY(SyntaxHelpLog, "syntax_help"); diff --git a/cockatrice/src/game/abstract_game.cpp b/cockatrice/src/game/abstract_game.cpp index 6aa2ab28f..efc575eff 100644 --- a/cockatrice/src/game/abstract_game.cpp +++ b/cockatrice/src/game/abstract_game.cpp @@ -1,7 +1,18 @@ #include "abstract_game.h" -#include "../interface/widgets/tabs/tab_game.h" +#include "game_event_handler.h" +#include "game_meta_info.h" +#include "game_state.h" +#include "libcockatrice/protocol/pb/game_replay.pb.h" +#include "player/player_info.h" #include "player/player_logic.h" +#include "player/player_manager.h" +#include "zones/card_zone_logic.h" + +#include +#include + +class AbstractClient; AbstractGame::AbstractGame(QObject *_parent) : QObject(_parent) { diff --git a/cockatrice/src/game/abstract_game.h b/cockatrice/src/game/abstract_game.h index fcf764492..326af8ac9 100644 --- a/cockatrice/src/game/abstract_game.h +++ b/cockatrice/src/game/abstract_game.h @@ -7,15 +7,18 @@ #ifndef COCKATRICE_ABSTRACT_GAME_H #define COCKATRICE_ABSTRACT_GAME_H -#include "game_event_handler.h" -#include "game_meta_info.h" -#include "game_state.h" -#include "player/player_manager.h" - #include -#include +#include +#include class CardItem; +class AbstractClient; +class GameEventHandler; +class GameMetaInfo; +class GameReplay; +class GameState; +class PlayerManager; + class AbstractGame : public QObject { Q_OBJECT diff --git a/cockatrice/src/game/arrow_registry.cpp b/cockatrice/src/game/arrow_registry.cpp index 286764b3b..c05134a75 100644 --- a/cockatrice/src/game/arrow_registry.cpp +++ b/cockatrice/src/game/arrow_registry.cpp @@ -1,6 +1,9 @@ #include "arrow_registry.h" #include "../game_graphics/board/arrow_item.h" +#include "board/arrow_data.h" + +#include void ArrowRegistry::insert(QSharedPointer data, ArrowItem *arrow) { diff --git a/cockatrice/src/game/arrow_registry.h b/cockatrice/src/game/arrow_registry.h index ef98229a2..ca3b04090 100644 --- a/cockatrice/src/game/arrow_registry.h +++ b/cockatrice/src/game/arrow_registry.h @@ -1,13 +1,13 @@ #ifndef COCKATRICE_ARROW_REGISTRY_H #define COCKATRICE_ARROW_REGISTRY_H -#include "board/arrow_data.h" - +#include #include #include -#include class ArrowItem; +struct ArrowData; +template class QSharedPointer; struct ArrowKey { diff --git a/cockatrice/src/game/board/arrow_data.cpp b/cockatrice/src/game/board/arrow_data.cpp index 9e89deed0..d5accd16e 100644 --- a/cockatrice/src/game/board/arrow_data.cpp +++ b/cockatrice/src/game/board/arrow_data.cpp @@ -1,5 +1,8 @@ #include "arrow_data.h" +#include "libcockatrice/protocol/pb/serverinfo_arrow.pb.h" +#include "libcockatrice/utility/color.h" + ArrowData ArrowData::fromProto(const ServerInfo_Arrow &arrow, int creatorId, bool isLocalCreator) { ArrowData data; diff --git a/cockatrice/src/game/board/arrow_data.h b/cockatrice/src/game/board/arrow_data.h index 2752f97e3..00217512f 100644 --- a/cockatrice/src/game/board/arrow_data.h +++ b/cockatrice/src/game/board/arrow_data.h @@ -3,8 +3,8 @@ #include #include -#include -#include + +class ServerInfo_Arrow; struct ArrowData { diff --git a/cockatrice/src/game/board/card_list.cpp b/cockatrice/src/game/board/card_list.cpp index 0080b5ae6..94bae67cd 100644 --- a/cockatrice/src/game/board/card_list.cpp +++ b/cockatrice/src/game/board/card_list.cpp @@ -1,9 +1,14 @@ #include "card_list.h" #include "../../game_graphics/board/card_item.h" +#include "libcockatrice/card/printing/exact_card.h" +#include #include +#include +#include #include +#include #include CardList::CardList(bool _contentsKnown) : QList(), contentsKnown(_contentsKnown) diff --git a/cockatrice/src/game/board/card_list.h b/cockatrice/src/game/board/card_list.h index 85a6848b7..b5427df0d 100644 --- a/cockatrice/src/game/board/card_list.h +++ b/cockatrice/src/game/board/card_list.h @@ -9,6 +9,8 @@ #include #include +#include +#include inline Q_LOGGING_CATEGORY(CardListLog, "card_list"); diff --git a/cockatrice/src/game/board/card_state.cpp b/cockatrice/src/game/board/card_state.cpp index 4319400d7..3f879a849 100644 --- a/cockatrice/src/game/board/card_state.cpp +++ b/cockatrice/src/game/board/card_state.cpp @@ -1,5 +1,7 @@ #include "card_state.h" +#include + void CardState::resetState(bool keepAnnotations) { attacking = false; diff --git a/cockatrice/src/game/board/card_state.h b/cockatrice/src/game/board/card_state.h index 0498b1aa2..ca7aaf927 100644 --- a/cockatrice/src/game/board/card_state.h +++ b/cockatrice/src/game/board/card_state.h @@ -3,9 +3,12 @@ #include #include +#include +#include class CardZoneLogic; class CardItem; + class CardState : public QObject { Q_OBJECT diff --git a/cockatrice/src/game/board/counter_state.cpp b/cockatrice/src/game/board/counter_state.cpp index 6da18b662..5d8c73d5b 100644 --- a/cockatrice/src/game/board/counter_state.cpp +++ b/cockatrice/src/game/board/counter_state.cpp @@ -1,5 +1,7 @@ #include "counter_state.h" +#include "libcockatrice/protocol/pb/serverinfo_counter.pb.h" + #include CounterState::CounterState(int id, const QString &name, const QColor &color, int radius, int value, QObject *parent) @@ -13,12 +15,12 @@ CounterState *CounterState::fromProto(const ServerInfo_Counter &counter, QObject convertColorToQColor(counter.counter_color()), counter.radius(), counter.count(), parent); } -void CounterState::setValue(int newValue) +void CounterState::setValue(int newValue, bool skipDamageAnimation) { if (newValue == value) { return; } int old = value; value = newValue; - emit valueChanged(old, newValue); + emit valueChanged(old, newValue, skipDamageAnimation); } \ No newline at end of file diff --git a/cockatrice/src/game/board/counter_state.h b/cockatrice/src/game/board/counter_state.h index 0f2f16b55..8badc6217 100644 --- a/cockatrice/src/game/board/counter_state.h +++ b/cockatrice/src/game/board/counter_state.h @@ -4,7 +4,9 @@ #include #include #include -#include +#include + +class ServerInfo_Counter; class CounterState : public QObject { @@ -35,10 +37,23 @@ public: return value; } - void setValue(int newValue); + /** + * @brief Set the counter value. + * @param newValue The new value. + * @param skipDamageAnimation When true, valueChanged is emitted with skipDamageAnimation=true, letting views + * suppress damage-related feedback (e.g. battlefield shimmer, life counter flash) for values set during replay + * rewinds. + */ + void setValue(int newValue, bool skipDamageAnimation = false); signals: - void valueChanged(int oldValue, int newValue); + /** + * @brief Emitted whenever the value changes. + * @param oldValue The previous value. + * @param newValue The new value. + * @param skipDamageAnimation True when the change should not trigger damage/life-change feedback in views. + */ + void valueChanged(int oldValue, int newValue, bool skipDamageAnimation); private: int id; diff --git a/cockatrice/src/game/game.cpp b/cockatrice/src/game/game.cpp index 4c8b109c2..7343d052a 100644 --- a/cockatrice/src/game/game.cpp +++ b/cockatrice/src/game/game.cpp @@ -1,9 +1,17 @@ #include "game.h" -#include "../interface/widgets/tabs/tab_game.h" +#include "game_meta_info.h" +#include "game_state.h" +#include "player/player_manager.h" +#include +#include +#include #include +class AbstractClient; +class QObject; + Game::Game(QObject *_parent, bool isLocalGame, QList &_clients, diff --git a/cockatrice/src/game/game.h b/cockatrice/src/game/game.h index 4f912664c..6a309cfb2 100644 --- a/cockatrice/src/game/game.h +++ b/cockatrice/src/game/game.h @@ -9,7 +9,14 @@ #include "abstract_game.h" -#include +#include + +class AbstractClient; +class Event_GameJoined; +class QObject; +class QString; +template class QMap; +template class QList; class Game : public AbstractGame { diff --git a/cockatrice/src/game/game_event_handler.cpp b/cockatrice/src/game/game_event_handler.cpp index 4a96eebdb..b86f661bb 100644 --- a/cockatrice/src/game/game_event_handler.cpp +++ b/cockatrice/src/game/game_event_handler.cpp @@ -1,9 +1,30 @@ #include "game_event_handler.h" -#include "../game_graphics/log/message_log_widget.h" -#include "../interface/widgets/tabs/tab_game.h" #include "abstract_game.h" +#include "game_meta_info.h" +#include "game_state.h" +#include "libcockatrice/protocol/pb/commands.pb.h" +#include "libcockatrice/protocol/pb/game_commands.pb.h" +#include "libcockatrice/protocol/pb/game_event.pb.h" +#include "libcockatrice/protocol/pb/game_event_context.pb.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_game.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_player.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_playerproperties.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_user.pb.h" +#include "player/player_event_handler.h" +#include "player/player_info.h" +#include "player/player_logic.h" +#include "player/player_manager.h" +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -13,7 +34,6 @@ #include #include #include -#include #include #include #include @@ -28,6 +48,8 @@ #include #include #include +#include +#include GameEventHandler::GameEventHandler(AbstractGame *_game) : QObject(_game), game(_game) { @@ -229,7 +251,11 @@ void GameEventHandler::handleArrowDeletion(int creatorId, int arrowId) void GameEventHandler::handleArrowDeletionFinished(const Response &response, int creatorId, int arrowId) { - if (response.response_code() == Response::RespNameNotFound) { + // The server confirms the arrow no longer exists whether it deleted it itself + // (RespOk, followed by an Event_DeleteArrow broadcast) or never had it + // (RespNameNotFound). In both cases the local copy has to go. deleteArrow is + // a no-op if the arrow was already removed by the event broadcast. + if (response.response_code() == Response::RespOk || response.response_code() == Response::RespNameNotFound) { emit arrowDeleted(creatorId, arrowId); } } @@ -281,12 +307,19 @@ void GameEventHandler::eventGameStateChanged(const Event_GameStateChanged &event emit playerJoined(prop); } player->processPlayerInfo(playerInfo); + // Extract playmat from player properties for opponent display + if (prop.has_playmat_params()) { + player->setPlaymatFromProperties(prop); + } if (player->getPlayerInfo()->getLocal()) { emit localPlayerDeckSelected(player, playerId, playerInfo); } else { if (!game->getGameMetaInfo()->proto().share_decklists_on_load()) { continue; } + if (!playerInfo.has_deck_list()) { + continue; + } opponentDecksToDisplay.append( qMakePair(playerId, qMakePair(playerName, QString::fromStdString(playerInfo.deck_list())))); @@ -344,6 +377,11 @@ void GameEventHandler::eventPlayerPropertiesChanged(const Event_PlayerProperties const ServerInfo_PlayerProperties &prop = event.player_properties(); emit playerPropertiesChanged(prop, eventPlayerId); + // Update playmat from player properties + if (prop.has_playmat_params()) { + player->setPlaymatFromProperties(prop); + } + const auto contextType = static_cast(getPbExtension(context)); switch (contextType) { case GameEventContext::READY_START: { @@ -414,12 +452,13 @@ void GameEventHandler::eventJoin(const Event_Join &event, int /*eventPlayerId*/, QString playerName = QString::fromStdString(playerInfo.user_info().name()); emit addPlayerToAutoCompleteList(playerName); - if (game->getPlayerManager()->getPlayers().contains(playerId)) { + PlayerManager *playerManager = game->getPlayerManager(); + if (playerManager->getPlayers().contains(playerId) || playerManager->getSpectators().contains(playerId)) { return; } if (playerInfo.spectator()) { - game->getPlayerManager()->addSpectator(playerId, playerInfo); + playerManager->addSpectator(playerId, playerInfo); emit logJoinSpectator(playerName); emit spectatorJoined(playerInfo); } else { diff --git a/cockatrice/src/game/game_event_handler.h b/cockatrice/src/game/game_event_handler.h index 99277abb7..30021d295 100644 --- a/cockatrice/src/game/game_event_handler.h +++ b/cockatrice/src/game/game_event_handler.h @@ -19,23 +19,29 @@ #include #include +#include +#include +#include #include +#include + +// IWYU pragma: keep +// GameEventContext, ServerInfo_Player and ServerInfo_User are passed by value +// to signals and slots, so the moc-generated code needs the complete types. +#include #include +#include class AbstractClient; class AbstractGame; -class CommandContainer; -class GameCommand; class GameEventContainer; class GameEventContext; class PendingCommand; class PlayerLogic; class Response; - class Event_GameStateChanged; class Event_PlayerPropertiesChanged; class Event_Join; -class Event_Leave; class Event_GameHostChanged; class Event_GameClosed; class Event_SetActivePlayer; @@ -44,6 +50,15 @@ class Event_GameSay; class Event_Kicked; class Event_ReverseTurn; class Event_Ping; +class ServerInfo_PlayerProperties; +namespace google +{ +namespace protobuf +{ +class Message; +} // namespace protobuf +} // namespace google +template class QList; inline Q_LOGGING_CATEGORY(GameEventHandlerLog, "game_event_handler"); diff --git a/cockatrice/src/game/game_meta_info.h b/cockatrice/src/game/game_meta_info.h index cdba1605f..ef8f0c08e 100644 --- a/cockatrice/src/game/game_meta_info.h +++ b/cockatrice/src/game/game_meta_info.h @@ -9,13 +9,16 @@ #include #include +#include #include +#include // Translation layer class to expose protobuf safely and hook it up to Qt Signals. // This class de-couples the domain object (i.e. the GameMetaInfo) from the network object. // If the network object changes, only this class needs to be adjusted. class AbstractGame; + class GameMetaInfo : public QObject { Q_OBJECT diff --git a/cockatrice/src/game/game_state.cpp b/cockatrice/src/game/game_state.cpp index 854878b92..e3bbbec00 100644 --- a/cockatrice/src/game/game_state.cpp +++ b/cockatrice/src/game/game_state.cpp @@ -2,6 +2,11 @@ #include "abstract_game.h" +#include +#include + +class AbstractClient; + GameState::GameState(AbstractGame *parent, int _secondsElapsed, int _hostId, @@ -29,13 +34,17 @@ void GameState::incrementGameTime() void GameState::setGameTime(int _secondsElapsed) { secondsElapsed = _secondsElapsed; + emit updateTimeElapsedLabel(formatElapsedTime(_secondsElapsed)); +} + +QString GameState::formatElapsedTime(int _secondsElapsed) +{ int seconds = _secondsElapsed; int minutes = seconds / 60; seconds -= minutes * 60; int hours = minutes / 60; minutes -= hours * 60; - emit updateTimeElapsedLabel(QString::number(hours).rightJustified(2, '0') + ":" + - QString::number(minutes).rightJustified(2, '0') + ":" + - QString::number(seconds).rightJustified(2, '0')); -} \ No newline at end of file + return QString::number(hours).rightJustified(2, '0') + ":" + QString::number(minutes).rightJustified(2, '0') + ":" + + QString::number(seconds).rightJustified(2, '0'); +} diff --git a/cockatrice/src/game/game_state.h b/cockatrice/src/game/game_state.h index 5a57d4321..55784fb91 100644 --- a/cockatrice/src/game/game_state.h +++ b/cockatrice/src/game/game_state.h @@ -7,12 +7,14 @@ #ifndef COCKATRICE_GAME_STATE_H #define COCKATRICE_GAME_STATE_H -#include -#include +#include +#include +#include +#include class AbstractGame; -class ServerInfo_PlayerProperties; -class ServerInfo_User; +class AbstractClient; +class QTimer; class GameState : public QObject { @@ -110,6 +112,11 @@ public: return hostId; } + int getSecondsElapsed() const + { + return secondsElapsed; + } + signals: void updateTimeElapsedLabel(QString newTime); void gameStarted(bool resuming); @@ -121,6 +128,9 @@ public slots: void incrementGameTime(); void setGameTime(int _secondsElapsed); +public: + static QString formatElapsedTime(int _secondsElapsed); + private: QTimer *gameTimer; int secondsElapsed; diff --git a/cockatrice/src/game/phase.cpp b/cockatrice/src/game/phase.cpp index 7fb1700a5..f56228279 100644 --- a/cockatrice/src/game/phase.cpp +++ b/cockatrice/src/game/phase.cpp @@ -1,5 +1,10 @@ #include "phase.h" +#include +#include +#include +#include + Phase::Phase(const QString &_name, const QString &_color, const QString &_soundFileName) : name(_name), color(_color), soundFileName(_soundFileName) { diff --git a/cockatrice/src/game/phase.h b/cockatrice/src/game/phase.h index a888ad43b..b4eaa6e46 100644 --- a/cockatrice/src/game/phase.h +++ b/cockatrice/src/game/phase.h @@ -7,8 +7,9 @@ #ifndef PHASE_H #define PHASE_H -#include +#include #include +#include class Phase { diff --git a/cockatrice/src/game/player/event_processing_options.h b/cockatrice/src/game/player/event_processing_options.h index 4c7663789..06238d77e 100644 --- a/cockatrice/src/game/player/event_processing_options.h +++ b/cockatrice/src/game/player/event_processing_options.h @@ -13,7 +13,8 @@ enum EventProcessingOption { SKIP_REVEAL_WINDOW = 0x0001, - SKIP_TAP_ANIMATION = 0x0002 + SKIP_TAP_ANIMATION = 0x0002, + SKIP_DAMAGE_ANIMATION = 0x0004 }; // Wrap it in a QFlags typedef diff --git a/cockatrice/src/game/player/player_actions.cpp b/cockatrice/src/game/player/player_actions.cpp index 67c3295d6..35682ede8 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/cockatrice/src/game/player/player_actions.cpp @@ -1,14 +1,54 @@ #include "player_actions.h" +#include "../../client/settings/cache_settings.h" +#include "../../game_graphics/board/card_item.h" +#include "../../game_graphics/dialogs/dlg_create_token.h" #include "../../game_graphics/dialogs/dlg_move_top_cards_until.h" -#include "../../game_graphics/dialogs/dlg_roll_dice.h" #include "../../game_graphics/player/card_menu_action_type.h" -#include "../../game_graphics/zones/hand_zone.h" #include "../../game_graphics/zones/table_zone.h" -#include "../../interface/widgets/tabs/tab_game.h" -#include "../../interface/widgets/utility/get_text_with_max.h" +#include "../../interface/deck_loader/loaded_deck.h" +#include "../abstract_game.h" +#include "../board/counter_state.h" +#include "../game_event_handler.h" +#include "../zones/card_zone_logic.h" +#include "../zones/hand_zone_logic.h" +#include "../zones/pile_zone_logic.h" +#include "../zones/table_zone_logic.h" #include "../zones/view_zone_logic.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/card/printing/printing_info.h" +#include "libcockatrice/card/relation/card_relation_type.h" +#include "libcockatrice/deck_list/deck_list.h" +#include "libcockatrice/filters/filter_string.h" +#include "libcockatrice/protocol/pb/card_attributes.pb.h" +#include "libcockatrice/protocol/pb/game_commands.pb.h" +#include "libcockatrice/settings/cards_display_settings.h" +#include "player_info.h" +#include "player_logic.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -26,13 +66,16 @@ #include #include #include -#include #include #include #include #include #include #include +#include +#include +#include +#include // milliseconds in between triggers of the move top cards until action static constexpr int MOVE_TOP_CARD_UNTIL_INTERVAL = 100; diff --git a/cockatrice/src/game/player/player_actions.h b/cockatrice/src/game/player/player_actions.h index fa4d54110..8fff81635 100644 --- a/cockatrice/src/game/player/player_actions.h +++ b/cockatrice/src/game/player/player_actions.h @@ -8,17 +8,25 @@ #ifndef COCKATRICE_PLAYER_ACTIONS_H #define COCKATRICE_PLAYER_ACTIONS_H -#include "../../game_graphics/board/card_item.h" +#include "../../game_graphics/board/card_item.h" // IWYU pragma: keep - moc instantiates the metatype for QList, which Qt 6.4 needs complete #include "../../game_graphics/dialogs/dlg_create_token.h" #include "../../game_graphics/dialogs/dlg_move_top_cards_until.h" #include "../../game_graphics/player/card_menu_action_type.h" -#include "event_processing_options.h" -#include "player_logic.h" +#include "../board/card_list.h" +#include "libcockatrice/card/card_info.h" -#include #include +#include +#include #include #include +#include + +class CardItem; +class CardRelation; +class QAction; +class QTimer; +template class QList; namespace google { @@ -29,9 +37,9 @@ class Message; } // namespace google class Command_MoveCard; -class GameEventContext; class PendingCommand; class PlayerLogic; + class PlayerActions : public QObject { Q_OBJECT diff --git a/cockatrice/src/game/player/player_event_handler.cpp b/cockatrice/src/game/player/player_event_handler.cpp index bc48298f7..18f6573df 100644 --- a/cockatrice/src/game/player/player_event_handler.cpp +++ b/cockatrice/src/game/player/player_event_handler.cpp @@ -1,15 +1,32 @@ #include "player_event_handler.h" -#include "../../game_graphics/board/arrow_item.h" #include "../../game_graphics/board/card_item.h" #include "../../game_graphics/zones/view_zone.h" -#include "../../interface/widgets/tabs/tab_game.h" +#include "../abstract_game.h" #include "../board/arrow_data.h" #include "../board/card_list.h" +#include "../board/counter_state.h" +#include "../zones/card_zone_logic.h" +#include "../zones/hand_zone_logic.h" +#include "../zones/pile_zone_logic.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/protocol/pb/card_attributes.pb.h" +#include "libcockatrice/protocol/pb/game_event.pb.h" +#include "libcockatrice/protocol/pb/game_event_context.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_card.pb.h" +#include "libcockatrice/utility/card_ref.h" #include "player_actions.h" +#include "player_info.h" #include "player_logic.h" +#include "player_manager.h" -#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -32,8 +49,9 @@ #include #include #include -#include #include +#include +#include PlayerEventHandler::PlayerEventHandler(PlayerLogic *_player) : QObject(_player), player(_player) { @@ -262,14 +280,15 @@ void PlayerEventHandler::eventCreateCounter(const Event_CreateCounter &event) player->addCounter(event.counter_info()); } -void PlayerEventHandler::eventSetCounter(const Event_SetCounter &event) +void PlayerEventHandler::eventSetCounter(const Event_SetCounter &event, EventProcessingOptions options) { CounterState *ctr = player->getCounters().value(event.counter_id(), nullptr); if (!ctr) { return; } int oldValue = ctr->getValue(); - ctr->setValue(event.value()); + const bool skipDamageAnimation = options.testFlag(SKIP_DAMAGE_ANIMATION); + ctr->setValue(event.value(), skipDamageAnimation); emit logSetCounter(player, ctr->getName(), event.value(), oldValue); } @@ -625,7 +644,7 @@ void PlayerEventHandler::processGameEvent(GameEvent::GameEventType type, eventCreateCounter(event.GetExtension(Event_CreateCounter::ext)); break; case GameEvent::SET_COUNTER: - eventSetCounter(event.GetExtension(Event_SetCounter::ext)); + eventSetCounter(event.GetExtension(Event_SetCounter::ext), options); break; case GameEvent::DEL_COUNTER: eventDelCounter(event.GetExtension(Event_DelCounter::ext)); diff --git a/cockatrice/src/game/player/player_event_handler.h b/cockatrice/src/game/player/player_event_handler.h index 48ad85e88..88254119b 100644 --- a/cockatrice/src/game/player/player_event_handler.h +++ b/cockatrice/src/game/player/player_event_handler.h @@ -22,14 +22,14 @@ #include "event_processing_options.h" #include -#include +#include #include -#include +#include +#include class CardItem; class CardZoneLogic; class PlayerLogic; - class Event_AttachCard; class Event_ChangeZoneProperties; class Event_CreateArrow; @@ -50,6 +50,9 @@ class Event_SetCardCounter; class Event_SetCounter; class Event_Shuffle; class Event_GameLogNotice; +class GameEventContext; +enum CardAttribute : int; +template class QList; /** * @class PlayerEventHandler @@ -153,7 +156,7 @@ public: void eventCreateCounter(const Event_CreateCounter &event); /// Set a player-level counter value. - void eventSetCounter(const Event_SetCounter &event); + void eventSetCounter(const Event_SetCounter &event, EventProcessingOptions options); /// Delete a player-level counter. void eventDelCounter(const Event_DelCounter &event); diff --git a/cockatrice/src/game/player/player_info.cpp b/cockatrice/src/game/player/player_info.cpp index c4debde0f..aef0d82aa 100644 --- a/cockatrice/src/game/player/player_info.cpp +++ b/cockatrice/src/game/player/player_info.cpp @@ -1,5 +1,7 @@ #include "player_info.h" +#include "libcockatrice/protocol/pb/serverinfo_user.pb.h" + PlayerInfo::PlayerInfo(const ServerInfo_User &info, int _id, bool _local, bool _judge) : id(_id), local(_local), judge(_judge) { diff --git a/cockatrice/src/game/player/player_info.h b/cockatrice/src/game/player/player_info.h index 4ec39edbd..58e877f8e 100644 --- a/cockatrice/src/game/player/player_info.h +++ b/cockatrice/src/game/player/player_info.h @@ -7,10 +7,10 @@ #ifndef COCKATRICE_PLAYER_INFO_H #define COCKATRICE_PLAYER_INFO_H -#include "../../game_graphics/player/player_target.h" - #include +#include #include +#include class PlayerInfo : public QObject { diff --git a/cockatrice/src/game/player/player_logic.cpp b/cockatrice/src/game/player/player_logic.cpp index 485e2fc5c..b545b7088 100644 --- a/cockatrice/src/game/player/player_logic.cpp +++ b/cockatrice/src/game/player/player_logic.cpp @@ -1,33 +1,37 @@ #include "player_logic.h" -#include "../../game_graphics/board/arrow_item.h" #include "../../game_graphics/board/card_item.h" -#include "../../game_graphics/board/counter_general.h" -#include "../../game_graphics/game_scene.h" -#include "../../game_graphics/player/player_target.h" -#include "../../game_graphics/zones/hand_zone.h" -#include "../../game_graphics/zones/pile_zone.h" -#include "../../game_graphics/zones/stack_zone.h" -#include "../../game_graphics/zones/table_zone.h" -#include "../../interface/theme_manager.h" -#include "../../interface/widgets/tabs/tab_game.h" -#include "../board/card_list.h" +#include "../abstract_game.h" +#include "../board/arrow_data.h" +#include "../board/counter_state.h" +#include "../game_meta_info.h" +#include "../zones/hand_zone_logic.h" +#include "../zones/pile_zone_logic.h" +#include "../zones/stack_zone_logic.h" +#include "../zones/table_zone_logic.h" +#include "libcockatrice/protocol/pb/serverinfo_card.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_counter.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_playerproperties.pb.h" +#include "libcockatrice/utility/zone_names.h" #include "player_actions.h" +#include "player_event_handler.h" +#include "player_info.h" +#include "player_manager.h" #include -#include -#include -#include -#include -#include -#include -#include -#include -#include +#include +#include +#include +#include +#include +#include #include #include #include +#include #include +#include +#include PlayerLogic::PlayerLogic(const ServerInfo_User &info, int _id, bool _local, bool _judge, AbstractGame *_parent) : QObject(_parent), game(_parent), playerInfo(new PlayerInfo(info, _id, _local, _judge)), @@ -175,7 +179,15 @@ void PlayerLogic::processPlayerInfo(const ServerInfo_Player &info) const ServerInfo_Card &cardInfo = zoneInfo.card_list(j); auto *card = new CardItem(this); card->processCardInfo(cardInfo); - zone->addCard(card, false, cardInfo.x(), cardInfo.y()); + // Zones without coordinates (hand, piles, stack) preserve the order + // they arrive in on the server in the positions of their cards list. + // The x coordinate of such cards is always 0, so inserting at it + // would reverse the list on reconnect. Append instead. + if (zoneInfo.with_coords()) { + zone->addCard(card, false, cardInfo.x(), cardInfo.y()); + } else { + zone->addCard(card, false, -1); + } } } if (zoneInfo.has_always_reveal_top_card()) { @@ -250,6 +262,22 @@ void PlayerLogic::setDeck(const DeckList &_deck) emit deckChanged(); } +void PlayerLogic::setPlaymatFromProperties(const ServerInfo_PlayerProperties &props) +{ + if (props.has_playmat_params() && !props.playmat_params().card_name().empty()) { + const auto &pp = props.playmat_params(); + remotePlaymatCard = {QString::fromStdString(pp.card_name()), QString::fromStdString(pp.card_provider_id())}; + remotePlaymatParams = {qBound(0.0, pp.margin_pct_l(), 0.95), qBound(0.0, pp.margin_pct_r(), 0.95), + qBound(0.0, pp.vertical_offset(), 1.0), qBound(0.1, pp.zoom(), 4.0)}; + hasRemotePlaymat = true; + } else { + remotePlaymatCard = CardRef{}; + remotePlaymatParams = PlaymatParams{}; + hasRemotePlaymat = false; + } + emit playmatChanged(); +} + CounterState *PlayerLogic::addCounter(const ServerInfo_Counter &counter) { return addCounter(counter.id(), QString::fromStdString(counter.name()), diff --git a/cockatrice/src/game/player/player_logic.h b/cockatrice/src/game/player/player_logic.h index a89cb6eed..59da7cc32 100644 --- a/cockatrice/src/game/player/player_logic.h +++ b/cockatrice/src/game/player/player_logic.h @@ -7,56 +7,48 @@ #ifndef PLAYER_H #define PLAYER_H -#include "../../game_graphics/player/player_area.h" -#include "../../interface/widgets/menus/tearoff_menu.h" -#include "../board/arrow_data.h" -#include "../interface/deck_loader/loaded_deck.h" +#include "../zones/card_zone_logic.h" #include "../zones/hand_zone_logic.h" #include "../zones/pile_zone_logic.h" #include "../zones/stack_zone_logic.h" #include "../zones/table_zone_logic.h" -#include "player_event_handler.h" -#include "player_info.h" +#include "libcockatrice/deck_list/deck_list.h" +#include "libcockatrice/utility/card_ref.h" +#include "libcockatrice/utility/playmat_params.h" +#include #include +#include #include #include -#include -#include -#include -#include +#include +#include #include +#include + +// IWYU pragma: keep +// ServerInfo_Card appears inside QList<...> in signal and slot +// signatures, which the moc-generated code needs to be complete. +#include + +class CardItem; +class CounterState; +class QWidget; +class ServerInfo_PlayerProperties; +struct ArrowData; +struct LoadedDeck; +template class QSharedPointer; inline Q_LOGGING_CATEGORY(PlayerLog, "player"); - -namespace google -{ -namespace protobuf -{ -class Message; -} -} // namespace google class AbstractCardItem; class AbstractGame; -class ArrowItem; -class ArrowTarget; -class CardDatabase; -class CardZone; -class CommandContainer; -class GameCommand; -class GameEvent; class PlayerInfo; class PlayerEventHandler; class PlayerActions; -class PlayerMenu; -class QAction; -class QMenu; -class ServerInfo_Arrow; class ServerInfo_Card; class ServerInfo_Counter; class ServerInfo_Player; class ServerInfo_User; -class TabGame; const int MAX_TOKENS_PER_DIALOG = 99; @@ -72,6 +64,8 @@ signals: const QList &cardList, bool withWritePermission); void deckChanged(); + /** @brief Emitted when the remote playmat (card/params) is updated from player properties. */ + void playmatChanged(); void newCardAdded(AbstractCardItem *card); void requestCardMenuUpdate(const CardItem *card); void counterAdded(CounterState *state); @@ -226,6 +220,20 @@ public: void setZoneId(int _zoneId); + void setPlaymatFromProperties(const ServerInfo_PlayerProperties &props); + const CardRef &getRemotePlaymatCard() const + { + return remotePlaymatCard; + } + const PlaymatParams &getRemotePlaymatParams() const + { + return remotePlaymatParams; + } + bool getHasRemotePlaymat() const + { + return hasRemotePlaymat; + } + private: AbstractGame *game; PlayerInfo *playerInfo; @@ -243,6 +251,11 @@ private: bool dialogSemaphore; QList cardsToDelete; + + // Playmat from player properties (for opponent display) + CardRef remotePlaymatCard; + PlaymatParams remotePlaymatParams; + bool hasRemotePlaymat = false; }; class AnnotationDialog : public QInputDialog diff --git a/cockatrice/src/game/player/player_manager.cpp b/cockatrice/src/game/player/player_manager.cpp index 6772d3ff1..4730805ad 100644 --- a/cockatrice/src/game/player/player_manager.cpp +++ b/cockatrice/src/game/player/player_manager.cpp @@ -1,8 +1,14 @@ #include "player_manager.h" #include "../abstract_game.h" +#include "../game_state.h" +#include "player_info.h" #include "player_logic.h" +#include +#include +#include + PlayerManager::PlayerManager(AbstractGame *_game, int _localPlayerId, bool _localPlayerIsJudge, @@ -75,6 +81,14 @@ PlayerLogic *PlayerManager::getPlayer(int playerId) const return player; } +void PlayerManager::clearSpectators() +{ + const QList spectatorIds = spectators.keys(); + for (int spectatorId : spectatorIds) { + removeSpectator(spectatorId); + } +} + void PlayerManager::onPlayerConceded(int playerId, bool conceded) { // Everything else cares about this diff --git a/cockatrice/src/game/player/player_manager.h b/cockatrice/src/game/player/player_manager.h index 2f8b87af8..7a9f7645b 100644 --- a/cockatrice/src/game/player/player_manager.h +++ b/cockatrice/src/game/player/player_manager.h @@ -7,12 +7,17 @@ #ifndef COCKATRICE_PLAYER_MANAGER_H #define COCKATRICE_PLAYER_MANAGER_H +#include "libcockatrice/protocol/pb/serverinfo_user.pb.h" + #include #include +#include #include +#include class AbstractGame; class PlayerLogic; + class PlayerManager : public QObject { Q_OBJECT @@ -100,6 +105,9 @@ public: emit spectatorRemoved(spectatorId, spectatorInfo); } + /** @brief Remove all spectators, emitting the removal signal for each. */ + void clearSpectators(); + [[nodiscard]] AbstractGame *getGame() const { return game; diff --git a/cockatrice/src/game/replay.cpp b/cockatrice/src/game/replay.cpp index dcf3e9b9b..4ab4a6321 100644 --- a/cockatrice/src/game/replay.cpp +++ b/cockatrice/src/game/replay.cpp @@ -1,6 +1,13 @@ #include "replay.h" -#include "../interface/widgets/tabs/tab_game.h" +#include "game_meta_info.h" +#include "game_state.h" +#include "player/player_manager.h" + +#include + +class GameReplay; +class QObject; Replay::Replay(QObject *_parent, const GameReplay *_replay, bool isLocalGame) : AbstractGame(_parent) { diff --git a/cockatrice/src/game/replay.h b/cockatrice/src/game/replay.h index 1c269b273..cc995dbac 100644 --- a/cockatrice/src/game/replay.h +++ b/cockatrice/src/game/replay.h @@ -10,6 +10,11 @@ #include "abstract_game.h" +#include + +class GameReplay; +class QObject; + class Replay : public AbstractGame { Q_OBJECT diff --git a/cockatrice/src/game/zones/card_zone_logic.cpp b/cockatrice/src/game/zones/card_zone_logic.cpp index 7e0585f4e..d997ab9ad 100644 --- a/cockatrice/src/game/zones/card_zone_logic.cpp +++ b/cockatrice/src/game/zones/card_zone_logic.cpp @@ -1,16 +1,25 @@ #include "card_zone_logic.h" +#include "../../client/translation.h" #include "../../game_graphics/board/card_item.h" +#include "../../game_graphics/zones/card_zone.h" #include "../../game_graphics/zones/view_zone.h" +#include "../board/card_list.h" #include "../player/player_actions.h" +#include "../player/player_info.h" #include "../player/player_logic.h" +#include "libcockatrice/card/database/card_database.h" #include "view_zone_logic.h" #include #include +#include +#include +#include #include #include #include +#include /** * @param _player the player that the zone belongs to diff --git a/cockatrice/src/game/zones/card_zone_logic.h b/cockatrice/src/game/zones/card_zone_logic.h index ae97719a9..2e232b258 100644 --- a/cockatrice/src/game/zones/card_zone_logic.h +++ b/cockatrice/src/game/zones/card_zone_logic.h @@ -10,17 +10,18 @@ #include "../../client/translation.h" #include "../board/card_list.h" +#include #include #include +#include +#include + +class CardItem; inline Q_LOGGING_CATEGORY(CardZoneLogicLog, "card_zone_logic"); class PlayerLogic; class ZoneViewZone; -class QMenu; -class QAction; -class QPainter; -class CardDragItem; class CardZoneLogic : public QObject { diff --git a/cockatrice/src/game/zones/hand_zone_logic.cpp b/cockatrice/src/game/zones/hand_zone_logic.cpp index 3bdd15902..cea17d031 100644 --- a/cockatrice/src/game/zones/hand_zone_logic.cpp +++ b/cockatrice/src/game/zones/hand_zone_logic.cpp @@ -3,6 +3,9 @@ #include "../../game_graphics/board/card_item.h" #include "card_zone_algorithms.h" +class PlayerLogic; +class QObject; + HandZoneLogic::HandZoneLogic(PlayerLogic *_player, const QString &_name, bool _hasCardAttr, diff --git a/cockatrice/src/game/zones/hand_zone_logic.h b/cockatrice/src/game/zones/hand_zone_logic.h index 400506248..c7d63bc0d 100644 --- a/cockatrice/src/game/zones/hand_zone_logic.h +++ b/cockatrice/src/game/zones/hand_zone_logic.h @@ -8,6 +8,12 @@ #define COCKATRICE_HAND_ZONE_LOGIC_H #include "card_zone_logic.h" +#include +#include + +class PlayerLogic; +class QObject; + class HandZoneLogic : public CardZoneLogic { Q_OBJECT diff --git a/cockatrice/src/game/zones/pile_zone_logic.cpp b/cockatrice/src/game/zones/pile_zone_logic.cpp index 0f374fb84..40af25cc5 100644 --- a/cockatrice/src/game/zones/pile_zone_logic.cpp +++ b/cockatrice/src/game/zones/pile_zone_logic.cpp @@ -1,6 +1,10 @@ #include "pile_zone_logic.h" #include "../../game_graphics/board/card_item.h" +#include "../board/card_list.h" + +class PlayerLogic; +class QObject; PileZoneLogic::PileZoneLogic(PlayerLogic *_player, const QString &_name, diff --git a/cockatrice/src/game/zones/pile_zone_logic.h b/cockatrice/src/game/zones/pile_zone_logic.h index 522d99b89..ed6841b75 100644 --- a/cockatrice/src/game/zones/pile_zone_logic.h +++ b/cockatrice/src/game/zones/pile_zone_logic.h @@ -8,6 +8,12 @@ #define COCKATRICE_PILE_ZONE_LOGIC_H #include "card_zone_logic.h" +#include +#include + +class PlayerLogic; +class QObject; + class PileZoneLogic : public CardZoneLogic { diff --git a/cockatrice/src/game/zones/stack_zone_logic.cpp b/cockatrice/src/game/zones/stack_zone_logic.cpp index 341d4b0e4..454274971 100644 --- a/cockatrice/src/game/zones/stack_zone_logic.cpp +++ b/cockatrice/src/game/zones/stack_zone_logic.cpp @@ -3,6 +3,9 @@ #include "../../game_graphics/board/card_item.h" #include "card_zone_algorithms.h" +class PlayerLogic; +class QObject; + StackZoneLogic::StackZoneLogic(PlayerLogic *_player, const QString &_name, bool _hasCardAttr, diff --git a/cockatrice/src/game/zones/stack_zone_logic.h b/cockatrice/src/game/zones/stack_zone_logic.h index cce4bd0fa..657d44ecf 100644 --- a/cockatrice/src/game/zones/stack_zone_logic.h +++ b/cockatrice/src/game/zones/stack_zone_logic.h @@ -8,6 +8,12 @@ #define COCKATRICE_STACK_ZONE_LOGIC_H #include "card_zone_logic.h" +#include +#include + +class PlayerLogic; +class QObject; + class StackZoneLogic : public CardZoneLogic { Q_OBJECT diff --git a/cockatrice/src/game/zones/table_zone_logic.cpp b/cockatrice/src/game/zones/table_zone_logic.cpp index a4f033819..07e4a759b 100644 --- a/cockatrice/src/game/zones/table_zone_logic.cpp +++ b/cockatrice/src/game/zones/table_zone_logic.cpp @@ -1,6 +1,13 @@ #include "table_zone_logic.h" #include "../../game_graphics/board/card_item.h" +#include "../board/card_list.h" +#include "libcockatrice/card/card_info.h" + +#include + +class PlayerLogic; +class QObject; TableZoneLogic::TableZoneLogic(PlayerLogic *_player, const QString &_name, diff --git a/cockatrice/src/game/zones/table_zone_logic.h b/cockatrice/src/game/zones/table_zone_logic.h index 6d8d64a20..8078bfd05 100644 --- a/cockatrice/src/game/zones/table_zone_logic.h +++ b/cockatrice/src/game/zones/table_zone_logic.h @@ -8,6 +8,12 @@ #define COCKATRICE_TABLE_ZONE_LOGIC_H #include "card_zone_logic.h" +#include +#include + +class PlayerLogic; +class QObject; + class TableZoneLogic : public CardZoneLogic { Q_OBJECT diff --git a/cockatrice/src/game/zones/view_zone_logic.cpp b/cockatrice/src/game/zones/view_zone_logic.cpp index 2ef04284a..eb3b7715f 100644 --- a/cockatrice/src/game/zones/view_zone_logic.cpp +++ b/cockatrice/src/game/zones/view_zone_logic.cpp @@ -2,8 +2,14 @@ #include "../../client/settings/cache_settings.h" #include "../../game_graphics/board/card_item.h" +#include "../board/card_list.h" +#include #include + +class PlayerLogic; +class QObject; + /** * @param _player the player that the cards are revealed to. * @param _origZone the zone the cards were revealed from. @@ -154,6 +160,14 @@ void ZoneViewZoneLogic::removeCard(int position, bool toNewZone) reorganizeCards(); } +void ZoneViewZoneLogic::clearCards() +{ + for (auto card : cards) { + card->deleteLater(); + } + cards.clear(); +} + void ZoneViewZoneLogic::setWriteableRevealZone(bool _writeableRevealZone) { diff --git a/cockatrice/src/game/zones/view_zone_logic.h b/cockatrice/src/game/zones/view_zone_logic.h index 04ed20e45..6fae301e4 100644 --- a/cockatrice/src/game/zones/view_zone_logic.h +++ b/cockatrice/src/game/zones/view_zone_logic.h @@ -8,6 +8,11 @@ #define COCKATRICE_VIEW_ZONE_LOGIC_H #include "card_zone_logic.h" +#include + +class PlayerLogic; +class QObject; + class ZoneViewZoneLogic : public CardZoneLogic { Q_OBJECT @@ -41,6 +46,10 @@ public: bool prepareAddCard(int x); void removeCard(int position, bool toNewZone); void updateCardIds(CardAction action); + + /** @brief Removes all cards from the view, without touching anything else. Used in replay rewind */ + void clearCards(); + int getNumberCards() const { return numberCards; diff --git a/cockatrice/src/game_graphics/animated_item.h b/cockatrice/src/game_graphics/animated_item.h new file mode 100644 index 000000000..700e0f62d --- /dev/null +++ b/cockatrice/src/game_graphics/animated_item.h @@ -0,0 +1,26 @@ +#ifndef ANIMATED_ITEM_H +#define ANIMATED_ITEM_H + +/** + * @file animated_item.h + * @ingroup GameGraphics + * @brief Interface for scene items driven by GameScene's shared animation timer. + * + * Items that want per-tick animation while a single QBasicTimer runs (instead of + * owning their own QTimer) implement this interface and register with the scene + * via GameScene::registerAnimationItem. + */ + +class IAnimatedItem +{ +public: + virtual ~IAnimatedItem() = default; + + /** + * @brief Advances the item's animation by one timer tick. + * @return true while the animation is still running, false once it has finished. + */ + virtual bool animationEvent() = 0; +}; + +#endif diff --git a/cockatrice/src/game_graphics/board/abstract_card_drag_item.cpp b/cockatrice/src/game_graphics/board/abstract_card_drag_item.cpp index 2f43591ac..2e18fca2d 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_drag_item.cpp +++ b/cockatrice/src/game_graphics/board/abstract_card_drag_item.cpp @@ -2,12 +2,20 @@ #include "../../client/settings/cache_settings.h" #include "../z_values.h" +#include "abstract_card_item.h" +#include +#include #include -#include #include #include +#include +#include +#include #include +#include + +class QPointF; const QColor GHOST_MASK = QColor(255, 255, 255, 50); diff --git a/cockatrice/src/game_graphics/board/abstract_card_drag_item.h b/cockatrice/src/game_graphics/board/abstract_card_drag_item.h index 1cbeb4fe7..18afdcbb8 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_drag_item.h +++ b/cockatrice/src/game_graphics/board/abstract_card_drag_item.h @@ -7,11 +7,17 @@ #ifndef ABSTRACTCARDDRAGITEM_H #define ABSTRACTCARDDRAGITEM_H -#include "abstract_card_item.h" +#include "../card_dimensions.h" +#include "graphics_item_type.h" -class QGraphicsScene; -class CardZone; -class CardInfo; +#include +#include +#include +#include +#include +#include + +class AbstractCardItem; class AbstractCardDragItem : public QObject, public QGraphicsItem { diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.cpp b/cockatrice/src/game_graphics/board/abstract_card_item.cpp index e0029ee2d..a79c5873e 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.cpp +++ b/cockatrice/src/game_graphics/board/abstract_card_item.cpp @@ -1,20 +1,44 @@ #include "abstract_card_item.h" #include "../../client/settings/cache_settings.h" +#include "../../interface/card_localization.h" #include "../../interface/card_picture_loader/card_picture_loader.h" +#include "../card_dimensions.h" #include "../game_scene.h" #include "../z_values.h" +#include "abstract_graphics_item.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/utility/card_ref.h" +#include +#include #include +#include +#include #include #include +#include +#include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include #include -#include #include #include #include #include +#include + +class QSizeF; AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef, PlayerLogic *_owner, int _id) : ArrowTarget(_owner, parent), id(_id), cardRef(cardRef), tapped(false), facedown(false), tapAngle(0), @@ -26,6 +50,8 @@ AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::displayCardNamesChanged, this, [this] { update(); }); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, + [this] { update(); }); refreshCardInfo(); connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::roundCardCornersChanged, this, @@ -171,7 +197,7 @@ void AbstractCardItem::paintPicture(QPainter *painter, const QSizeF &translatedS if (SettingsCache::instance().debug().getShowCardId()) { prefix = "#" + QString::number(id) + " "; } - nameStr = prefix + cardRef.name; + nameStr = prefix + CardLocalization::displayName(getCardInfo()); } painter->drawText(QRectF(3 * scaleFactor, 3 * scaleFactor, translatedSize.width() - 6 * scaleFactor, translatedSize.height() - 6 * scaleFactor), @@ -305,6 +331,11 @@ void AbstractCardItem::setTapped(bool _tapped, bool canAnimate) } } +bool AbstractCardItem::animationEvent() +{ + return false; +} + void AbstractCardItem::setFaceDown(bool _facedown) { facedown = _facedown; diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.h b/cockatrice/src/game_graphics/board/abstract_card_item.h index bdb5f7cf1..385f9c32f 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.h +++ b/cockatrice/src/game_graphics/board/abstract_card_item.h @@ -7,16 +7,25 @@ #ifndef ABSTRACTCARDITEM_H #define ABSTRACTCARDITEM_H -#include "../card_dimensions.h" +#include "../animated_item.h" #include "arrow_target.h" #include "graphics_item_type.h" +#include +#include +#include +#include +#include #include #include +#include class PlayerLogic; +class CardInfo; +class QPainter; +class QPoint; -class AbstractCardItem : public ArrowTarget +class AbstractCardItem : public ArrowTarget, public IAnimatedItem { Q_OBJECT protected: @@ -126,6 +135,9 @@ public: emit deleteCardInfoPopup(cardRef.name); } + /** @brief Default: no per-tick animation. Subclasses override to animate. */ + bool animationEvent() override; + protected: void transformPainter(QPainter *painter, const QSizeF &translatedSize, int angle); void mousePressEvent(QGraphicsSceneMouseEvent *event) override; diff --git a/cockatrice/src/game_graphics/board/abstract_counter.cpp b/cockatrice/src/game_graphics/board/abstract_counter.cpp index a20fb1b3c..8500b9c51 100644 --- a/cockatrice/src/game_graphics/board/abstract_counter.cpp +++ b/cockatrice/src/game_graphics/board/abstract_counter.cpp @@ -2,21 +2,33 @@ #include "../../client/settings/cache_settings.h" #include "../../client/settings/shortcuts_settings.h" +#include "../../game/board/counter_state.h" #include "../../game/player/player_actions.h" +#include "../../game/player/player_info.h" #include "../../game/player/player_logic.h" #include "../../game_graphics/board/translate_counter_name.h" -#include "../../interface/widgets/tabs/tab_game.h" +#include "../../interface/widgets/menus/tearoff_menu.h" #include #include +#include +#include +#include #include #include #include -#include +#include +#include +#include #include +#include +#include #include #include #include +#include + +class QWidget; AbstractCounter::AbstractCounter(CounterState *state, PlayerLogic *_player, @@ -29,8 +41,9 @@ AbstractCounter::AbstractCounter(CounterState *state, { setAcceptHoverEvents(true); - connect(state, &CounterState::valueChanged, this, [this](int, int newValue) { + connect(state, &CounterState::valueChanged, this, [this](int oldValue, int newValue, bool skipDamageAnimation) { value = newValue; + onValueChanged(oldValue, newValue, skipDamageAnimation); update(); }); @@ -228,3 +241,9 @@ void AbstractCounterDialog::changeValue(int diff) curValue += diff; setTextValue(QString::number(curValue)); } + +void AbstractCounter::onValueChanged(int /*oldValue*/, int /*newValue*/, bool /*skipDamageAnimation*/) +{ + // Default: no feedback. Subclasses such as PlayerCounter override this to + // flash the counter on meaningful changes (life gain/loss). +} diff --git a/cockatrice/src/game_graphics/board/abstract_counter.h b/cockatrice/src/game_graphics/board/abstract_counter.h index b319a722d..7ba686a93 100644 --- a/cockatrice/src/game_graphics/board/abstract_counter.h +++ b/cockatrice/src/game_graphics/board/abstract_counter.h @@ -7,18 +7,21 @@ #ifndef COUNTER_H #define COUNTER_H -#include "../../game/board/counter_state.h" #include "../../interface/widgets/menus/tearoff_menu.h" #include "../player/menu/abstract_player_component.h" +#include #include #include +#include +#include +#include class PlayerLogic; class QAction; -class QKeyEvent; class QMenu; -class QString; +class CounterState; +class QWidget; class AbstractCounter : public QObject, public QGraphicsItem, public AbstractPlayerComponent { @@ -35,6 +38,14 @@ protected: bool hovered = false; bool useNameForShortcut; + /** + * @brief Hook for subclasses that need per-value-change feedback (e.g. life-total flash). + * + * Called whenever the counter's value changes, before the item repaints. + * @param skipDamageAnimation True when damage-related feedback should be suppressed (replay rewinds). + */ + virtual void onValueChanged(int oldValue, int newValue, bool skipDamageAnimation); + void mousePressEvent(QGraphicsSceneMouseEvent *event) override; void hoverEnterEvent(QGraphicsSceneHoverEvent *event) override; void hoverLeaveEvent(QGraphicsSceneHoverEvent *event) override; diff --git a/cockatrice/src/game_graphics/board/abstract_graphics_item.cpp b/cockatrice/src/game_graphics/board/abstract_graphics_item.cpp index 8da8ab708..50e80d359 100644 --- a/cockatrice/src/game_graphics/board/abstract_graphics_item.cpp +++ b/cockatrice/src/game_graphics/board/abstract_graphics_item.cpp @@ -1,6 +1,14 @@ #include "abstract_graphics_item.h" +#include +#include +#include #include +#include +#include +#include +#include +#include void AbstractGraphicsItem::paintNumberEllipse(int number, int fontSize, diff --git a/cockatrice/src/game_graphics/board/abstract_graphics_item.h b/cockatrice/src/game_graphics/board/abstract_graphics_item.h index 287c2c0b3..ebc2a3761 100644 --- a/cockatrice/src/game_graphics/board/abstract_graphics_item.h +++ b/cockatrice/src/game_graphics/board/abstract_graphics_item.h @@ -7,7 +7,12 @@ #ifndef ABSTRACTGRAPHICSITEM_H #define ABSTRACTGRAPHICSITEM_H -#include +#include +#include +#include + +class QGraphicsItem; +class QPainter; /** * Parent class of all objects that appear in a game. diff --git a/cockatrice/src/game_graphics/board/arrow_item.cpp b/cockatrice/src/game_graphics/board/arrow_item.cpp index ce8967bb5..c5a9b9c5e 100644 --- a/cockatrice/src/game_graphics/board/arrow_item.cpp +++ b/cockatrice/src/game_graphics/board/arrow_item.cpp @@ -2,25 +2,58 @@ #include "arrow_item.h" #include "../../client/settings/cache_settings.h" +#include "../../game/board/arrow_data.h" #include "../../game/player/player_actions.h" +#include "../../game/player/player_info.h" #include "../../game/player/player_logic.h" +#include "../../game/zones/card_zone_logic.h" +#include "../game_scene.h" #include "../player/player_target.h" #include "../z_values.h" -#include "../zones/card_zone.h" #include "card_item.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/protocol/pb/color.pb.h" -#include +#include +#include #include #include +#include +#include #include +#include +#include +#include +#include +#include #include #include #include #include -#include +#include #include #include #include +#include +#include +#include +#include + +namespace +{ +constexpr qreal kMinStrokeDurationMs = 200.0; +constexpr qreal kMaxStrokeDurationMs = 450.0; +constexpr qreal kMsPerPixel = 0.8; +constexpr qreal kGlowFadeDurationMs = 120.0; +constexpr qreal kSheenHalfWidth = 14.0; + +/// @brief Ease-out cubic, for a natural "slow in / slow out" reveal. +qreal easeOutCubic(qreal t) +{ + const qreal inverse = 1.0 - t; + return 1.0 - inverse * inverse * inverse; +} +} // namespace ArrowItem::ArrowItem(QSharedPointer _data, ArrowTarget *_startItem, ArrowTarget *_targetItem) : data(std::move(_data)), startItem(_startItem), targetItem(_targetItem) @@ -47,8 +80,23 @@ ArrowItem::ArrowItem(QSharedPointer _data, ArrowTarget *_startI } } +ArrowItem::~ArrowItem() +{ + if (auto *scene = qobject_cast(this->scene())) { + scene->unregisterAnimationItem(this); + } +} + void ArrowItem::onTargetDestroyed() { + if (data->id == -1) { + // Drag and attach arrows are never inserted into the arrow registry and + // have no server-side counterpart, so no deletion event can clean them + // up. Delete them locally when either endpoint is destroyed. + delArrow(); + return; + } + emit requestDeletion(data->creatorId, data->id); } @@ -91,16 +139,21 @@ void ArrowItem::updatePath(const QPointF &endPoint) prepareGeometryChange(); if (lineLength < 30) { path = QPainterPath(); + bodyPath = QPainterPath(); + headPath = QPainterPath(); + shaftOutlinePath = QPainterPath(); + centerLine = QPainterPath(); + headBaseFraction = 1.0; } else { QPointF c(lineLength / 2, qTan(phi * M_PI / 180) * lineLength); - QPainterPath centerLine; + centerLine = QPainterPath(); centerLine.moveTo(0, 0); centerLine.quadTo(c, QPointF(lineLength, 0)); - double percentage = 1 - headLength / lineLength; - QPointF arrowBodyEndPoint = centerLine.pointAtPercent(percentage); - QLineF testLine(arrowBodyEndPoint, centerLine.pointAtPercent(percentage + 0.001)); + headBaseFraction = 1 - headLength / lineLength; + QPointF arrowBodyEndPoint = centerLine.pointAtPercent(headBaseFraction); + QLineF testLine(arrowBodyEndPoint, centerLine.pointAtPercent(headBaseFraction + 0.001)); qreal alpha = testLine.angle() - 90; QPointF endPoint1 = arrowBodyEndPoint + arrowWidth / 2 * QPointF(qCos(alpha * M_PI / 180), -qSin(alpha * M_PI / 180)); @@ -111,20 +164,89 @@ void ArrowItem::updatePath(const QPointF &endPoint) QPointF point2 = endPoint2 + (headWidth - arrowWidth) / 2 * QPointF(-qCos(alpha * M_PI / 180), qSin(alpha * M_PI / 180)); - path = QPainterPath(-arrowWidth / 2 * QPointF(qCos((phi - 90) * M_PI / 180), qSin((phi - 90) * M_PI / 180))); + QPointF start1 = -arrowWidth / 2 * QPointF(qCos((phi - 90) * M_PI / 180), qSin((phi - 90) * M_PI / 180)); + QPointF start2 = arrowWidth / 2 * QPointF(qCos((phi - 90) * M_PI / 180), qSin((phi - 90) * M_PI / 180)); + + path = QPainterPath(start1); path.quadTo(c, endPoint1); path.lineTo(point1); path.lineTo(QPointF(lineLength, 0)); path.lineTo(point2); path.lineTo(endPoint2); - path.quadTo(c, arrowWidth / 2 * QPointF(qCos((phi - 90) * M_PI / 180), qSin((phi - 90) * M_PI / 180))); - path.lineTo(-arrowWidth / 2 * QPointF(qCos((phi - 90) * M_PI / 180), qSin((phi - 90) * M_PI / 180))); + path.quadTo(c, start2); + path.lineTo(start1); + + bodyPath = QPainterPath(start1); + bodyPath.quadTo(c, endPoint1); + bodyPath.lineTo(endPoint2); + bodyPath.quadTo(c, start2); + bodyPath.lineTo(start1); + + headPath = QPainterPath(endPoint1); + headPath.lineTo(point1); + headPath.lineTo(QPointF(lineLength, 0)); + headPath.lineTo(point2); + headPath.lineTo(endPoint2); + + shaftOutlinePath = QPainterPath(start1); + shaftOutlinePath.quadTo(c, endPoint1); + shaftOutlinePath.moveTo(endPoint2); + shaftOutlinePath.quadTo(c, start2); + shaftOutlinePath.lineTo(start1); } setPos(startPoint); setTransform(QTransform().rotate(-line.angle())); } +void ArrowItem::startDrawAnimation() +{ + if (!SettingsCache::instance().cardsDisplay().getArrowDrawAnimation() || centerLine.isEmpty()) { + return; + } + + strokeDurationMs = qBound(kMinStrokeDurationMs, centerLine.length() * kMsPerPixel, kMaxStrokeDurationMs); + glowFadeDurationMs = kGlowFadeDurationMs; + // The clock is started on the first animationEvent() tick so that t=0 + // corresponds to the first rendered frame. Starting it here would count + // the time spent before the item's first paint (event-loop delays, bursts + // of arrows created together), making the arrow appear already partway + // drawn when it first shows up. + animationStarted = false; + drawProgress = 0.0; + glowAlpha = 1.0; + update(); + if (auto *scene = qobject_cast(this->scene())) { + scene->registerAnimationItem(this); + } +} + +bool ArrowItem::animationEvent() +{ + if (!animationStarted) { + animationClock.start(); + animationStarted = true; + } + + const qint64 elapsed = animationClock.elapsed(); + if (elapsed >= strokeDurationMs + glowFadeDurationMs) { + drawProgress = 1.0; + glowAlpha = 0.0; + update(); + return false; + } + + if (elapsed < strokeDurationMs) { + drawProgress = easeOutCubic(qBound(0.0, elapsed / strokeDurationMs, 1.0)); + glowAlpha = 1.0; + } else { + drawProgress = 1.0; + glowAlpha = 1.0 - (elapsed - strokeDurationMs) / glowFadeDurationMs; + } + update(); + return true; +} + void ArrowItem::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/) { QColor paintColor(data->color); @@ -133,8 +255,66 @@ void ArrowItem::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*opti } else { paintColor.setAlpha(150); } + + painter->save(); + const QPen outlinePen = painter->pen(); painter->setBrush(paintColor); - painter->drawPath(path); + + const auto drawShaft = [this, painter, &outlinePen, paintColor]() { + painter->setPen(Qt::NoPen); + painter->drawPath(bodyPath); + painter->setPen(outlinePen); + painter->setBrush(Qt::NoBrush); + painter->drawPath(shaftOutlinePath); + painter->setBrush(paintColor); + }; + + if (drawProgress >= 1.0 || path.isEmpty()) { + painter->drawPath(path); + } else if (drawProgress < headBaseFraction) { + // The reveal edge and the sheen share the same arc-length parameterization, + // so the stroke stays exactly in sync with the trailing sheen. + const qreal revealX = centerLine.pointAtPercent(drawProgress).x(); + QPainterPath clip; + clip.addRect(QRectF(-glowExtent, path.boundingRect().top() - glowExtent, revealX + glowExtent, + path.boundingRect().height() + 2 * glowExtent)); + painter->setClipPath(clip); + drawShaft(); + } else { + // Once the reveal reaches the head base, pop the whole head in with a fade + // instead of slicing the triangle into a growing stub. + drawShaft(); + const qreal headFadeIn = (drawProgress - headBaseFraction) / (1.0 - headBaseFraction); + painter->setOpacity(headFadeIn); + painter->setPen(Qt::NoPen); + painter->drawPath(headPath); + painter->setPen(outlinePen); + painter->setBrush(Qt::NoBrush); + painter->drawPath(headPath); + painter->setOpacity(1.0); + painter->setBrush(paintColor); + } + + if (glowAlpha > 0.0 && !centerLine.isEmpty()) { + // Sweep a bright band across the arrow. Clipping to the + // silhouette keeps it flat against the shaft so it reads as a light reflection. + const qreal anticipation = qMin(1.0, drawProgress / 0.08); + const QPointF sweep = centerLine.pointAtPercent(qMin(drawProgress, 1.0)); + QLinearGradient sheen(sweep.x() - kSheenHalfWidth, 0.0, sweep.x() + kSheenHalfWidth, 0.0); + sheen.setColorAt(0.0, QColor(paintColor.red(), paintColor.green(), paintColor.blue(), 0)); + sheen.setColorAt(0.5, QColor(255, 255, 255, 200)); + sheen.setColorAt(1.0, QColor(paintColor.red(), paintColor.green(), paintColor.blue(), 0)); + painter->save(); + painter->setPen(Qt::NoPen); + painter->setClipPath(path); + painter->setBrush(sheen); + painter->setOpacity(glowAlpha * anticipation); + painter->drawRect(QRectF(sweep.x() - kSheenHalfWidth - glowExtent, path.boundingRect().top() - glowExtent, + (kSheenHalfWidth + glowExtent) * 2.0, + path.boundingRect().height() + glowExtent * 2.0)); + painter->restore(); + } + painter->restore(); } void ArrowItem::mousePressEvent(QGraphicsSceneMouseEvent *event) @@ -226,6 +406,12 @@ void ArrowDragItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) { if (!startItem) { + // The source card was destroyed while the arrow was being drawn. + // Clean up the arrow and its children instead of leaking them. + delArrow(); + for (auto *child : childArrows) { + child->mouseReleaseEvent(event); + } return; } @@ -349,6 +535,12 @@ void ArrowAttachItem::mouseMoveEvent(QGraphicsSceneMouseEvent *event) void ArrowAttachItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) { if (!startItem) { + // The source card was destroyed while the arrow was being drawn. + // Clean up the arrow and its children instead of leaking them. + delArrow(); + for (auto *child : childArrows) { + child->mouseReleaseEvent(event); + } return; } diff --git a/cockatrice/src/game_graphics/board/arrow_item.h b/cockatrice/src/game_graphics/board/arrow_item.h index 1c306e065..2395165fc 100644 --- a/cockatrice/src/game_graphics/board/arrow_item.h +++ b/cockatrice/src/game_graphics/board/arrow_item.h @@ -2,17 +2,29 @@ #define ARROWITEM_H #include "../../game/board/arrow_data.h" +#include "../animated_item.h" #include "arrow_target.h" +#include "graphics_item_type.h" +#include +#include #include +#include +#include +#include +#include +#include #include +#include #include +#include +#include class CardItem; -class QGraphicsSceneMouseEvent; class PlayerLogic; +template class QSharedPointer; -class ArrowItem : public QObject, public QGraphicsItem +class ArrowItem : public QObject, public QGraphicsItem, public IAnimatedItem { Q_OBJECT Q_INTERFACES(QGraphicsItem) @@ -21,6 +33,19 @@ signals: private: QPainterPath path; + QPainterPath bodyPath; + QPainterPath headPath; + QPainterPath shaftOutlinePath; + QPainterPath centerLine; + qreal headBaseFraction = 1.0; + QElapsedTimer animationClock; + qreal strokeDurationMs = 0; + qreal glowFadeDurationMs = 0; + qreal drawProgress = 1.0; + qreal glowAlpha = 0.0; + bool animationStarted = false; + + static constexpr qreal glowExtent = 12.0; protected: QSharedPointer data; @@ -32,17 +57,28 @@ protected: void mousePressEvent(QGraphicsSceneMouseEvent *event) override; public: + enum + { + Type = typeArrow + }; + [[nodiscard]] int type() const override + { + return Type; + } ArrowItem(QSharedPointer _data, ArrowTarget *_startItem, ArrowTarget *_targetItem); + ~ArrowItem() override; void onTargetDestroyed(); void delArrow(); void updatePath(); void updatePath(const QPointF &endPoint); + void startDrawAnimation(); + bool animationEvent() override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; [[nodiscard]] QRectF boundingRect() const override { - return path.boundingRect(); + return path.boundingRect().adjusted(-glowExtent, -glowExtent, glowExtent, glowExtent); } [[nodiscard]] QPainterPath shape() const override { @@ -106,4 +142,4 @@ protected: void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override; }; -#endif \ No newline at end of file +#endif diff --git a/cockatrice/src/game_graphics/board/arrow_target.cpp b/cockatrice/src/game_graphics/board/arrow_target.cpp index 79b21d921..024479ee6 100644 --- a/cockatrice/src/game_graphics/board/arrow_target.cpp +++ b/cockatrice/src/game_graphics/board/arrow_target.cpp @@ -1,7 +1,8 @@ #include "arrow_target.h" -#include "../../game/player/player_logic.h" -#include "arrow_item.h" +#include + +class QGraphicsItem; ArrowTarget::ArrowTarget(PlayerLogic *_owner, QGraphicsItem *parent) : AbstractGraphicsItem(parent), owner(_owner) { diff --git a/cockatrice/src/game_graphics/board/arrow_target.h b/cockatrice/src/game_graphics/board/arrow_target.h index bf89c5456..5c55e0e5d 100644 --- a/cockatrice/src/game_graphics/board/arrow_target.h +++ b/cockatrice/src/game_graphics/board/arrow_target.h @@ -9,10 +9,10 @@ #include "abstract_graphics_item.h" -#include +#include class PlayerLogic; -class ArrowItem; +class QGraphicsItem; class ArrowTarget : public AbstractGraphicsItem { diff --git a/cockatrice/src/game_graphics/board/card_drag_item.cpp b/cockatrice/src/game_graphics/board/card_drag_item.cpp index 49467c5c9..265ea759c 100644 --- a/cockatrice/src/game_graphics/board/card_drag_item.cpp +++ b/cockatrice/src/game_graphics/board/card_drag_item.cpp @@ -6,9 +6,20 @@ #include "../zones/view_zone.h" #include "card_item.h" +#include +#include #include +#include +#include #include +#include +#include #include +#include +#include +#include + +class CardZoneLogic; CardDragItem::CardDragItem(CardItem *_item, int _id, diff --git a/cockatrice/src/game_graphics/board/card_drag_item.h b/cockatrice/src/game_graphics/board/card_drag_item.h index 74d25ad04..d5e9a9e83 100644 --- a/cockatrice/src/game_graphics/board/card_drag_item.h +++ b/cockatrice/src/game_graphics/board/card_drag_item.h @@ -9,7 +9,11 @@ #include "abstract_card_drag_item.h" +#include +#include + class CardItem; +class CardZone; class CardDragItem : public AbstractCardDragItem { diff --git a/cockatrice/src/game_graphics/board/card_item.cpp b/cockatrice/src/game_graphics/board/card_item.cpp index c40c8c214..a04d86c15 100644 --- a/cockatrice/src/game_graphics/board/card_item.cpp +++ b/cockatrice/src/game_graphics/board/card_item.cpp @@ -1,26 +1,52 @@ #include "card_item.h" #include "../../client/settings/cache_settings.h" -#include "../../client/settings/card_counter_settings.h" +#include "../../game/abstract_game.h" +#include "../../game/board/card_state.h" +#include "../../game/game_state.h" #include "../../game/phase.h" #include "../../game/player/player_actions.h" +#include "../../game/player/player_info.h" #include "../../game/player/player_logic.h" +#include "../../game/player/player_manager.h" +#include "../../game/zones/card_zone_logic.h" +#include "../../game/zones/table_zone_logic.h" #include "../../game/zones/view_zone_logic.h" -#include "../../interface/widgets/tabs/tab_game.h" +#include "../card_dimensions.h" #include "../game_scene.h" -#include "../zones/table_zone.h" -#include "../zones/view_zone.h" #include "arrow_item.h" #include "card_drag_item.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/protocol/pb/serverinfo_cardcounter.pb.h" #include <../../client/settings/card_counter_settings.h> #include +#include +#include +#include +#include +#include +#include +#include #include -#include +#include +#include +#include #include +#include +#include +#include +#include +#include +#include +#include #include #include #include +#include +#include + +struct CardRef; CardItem::CardItem(PlayerLogic *_owner, QGraphicsItem *parent, @@ -316,7 +342,7 @@ void CardItem::drawAttachArrow() for (const auto &item : scene()->selectedItems()) { CardItem *card = qgraphicsitem_cast(item); - if (card == nullptr) { + if (card == nullptr || card == this) { continue; } if (card->getZone() != state->getZone()) { diff --git a/cockatrice/src/game_graphics/board/card_item.h b/cockatrice/src/game_graphics/board/card_item.h index 37f3bab50..df79ef378 100644 --- a/cockatrice/src/game_graphics/board/card_item.h +++ b/cockatrice/src/game_graphics/board/card_item.h @@ -8,18 +8,24 @@ #define CARDITEM_H #include "../../game/board/card_state.h" -#include "../../game/zones/card_zone_logic.h" #include "abstract_card_item.h" +#include "graphics_item_type.h" -#include +#include +#include +#include +#include +#include +#include -class CardDatabase; class CardDragItem; -class CardZone; class ServerInfo_Card; class PlayerLogic; -class QAction; class QColor; +class CardZoneLogic; +class QGraphicsItem; +struct CardRef; +template class QMap; const int ROTATION_DEGREES_PER_FRAME = 10; @@ -137,7 +143,7 @@ public: void resetState(bool keepAnnotations = false); void processCardInfo(const ServerInfo_Card &_info); - bool animationEvent(); + bool animationEvent() override; CardDragItem *createDragItem(int _id, const QPointF &_pos, const QPointF &_scenePos, bool forceFaceDown); void deleteDragItem(); void drawArrow(const QColor &arrowColor); diff --git a/cockatrice/src/game_graphics/board/counter_general.cpp b/cockatrice/src/game_graphics/board/counter_general.cpp index 379c6f837..4a8bbd6a4 100644 --- a/cockatrice/src/game_graphics/board/counter_general.cpp +++ b/cockatrice/src/game_graphics/board/counter_general.cpp @@ -3,7 +3,20 @@ #include "../../interface/pixel_map_generator.h" #include "abstract_graphics_item.h" +#include #include +#include +#include +#include +#include +#include +#include +#include +#include + +class CounterState; +class PlayerLogic; +class QGraphicsItem; GeneralCounter::GeneralCounter(CounterState *state, PlayerLogic *player, bool useNameForShortcut, QGraphicsItem *parent) : AbstractCounter(state, player, true, useNameForShortcut, parent) diff --git a/cockatrice/src/game_graphics/board/counter_general.h b/cockatrice/src/game_graphics/board/counter_general.h index 0a2e882ce..2e49f11d7 100644 --- a/cockatrice/src/game_graphics/board/counter_general.h +++ b/cockatrice/src/game_graphics/board/counter_general.h @@ -9,6 +9,12 @@ #include "abstract_counter.h" +#include + +class CounterState; +class PlayerLogic; +class QGraphicsItem; + class GeneralCounter : public AbstractCounter { Q_OBJECT diff --git a/cockatrice/src/game_graphics/board/graphics_item_type.h b/cockatrice/src/game_graphics/board/graphics_item_type.h index 7eac132b0..afac7881f 100644 --- a/cockatrice/src/game_graphics/board/graphics_item_type.h +++ b/cockatrice/src/game_graphics/board/graphics_item_type.h @@ -16,7 +16,8 @@ enum GraphicsItemType typeZone = QGraphicsItem::UserType + 3, typePlayerTarget = QGraphicsItem::UserType + 4, typeDeckViewCardContainer = QGraphicsItem::UserType + 5, - typeOther = QGraphicsItem::UserType + 6 + typeOther = QGraphicsItem::UserType + 6, + typeArrow = QGraphicsItem::UserType + 7 }; #endif // COCKATRICE_GRAPHICS_ITEM_TYPE_H diff --git a/cockatrice/src/game_graphics/board/translate_counter_name.cpp b/cockatrice/src/game_graphics/board/translate_counter_name.cpp index 4f1c97657..3a2a5c7c8 100644 --- a/cockatrice/src/game_graphics/board/translate_counter_name.cpp +++ b/cockatrice/src/game_graphics/board/translate_counter_name.cpp @@ -1,5 +1,8 @@ #include "translate_counter_name.h" +#include +#include + const QMap TranslateCounterName::translated = { {"life", QT_TRANSLATE_NOOP("TranslateCounterName", "Life")}, {"w", QT_TRANSLATE_NOOP("TranslateCounterName", "White")}, diff --git a/cockatrice/src/game_graphics/board/translate_counter_name.h b/cockatrice/src/game_graphics/board/translate_counter_name.h index ba3a94fa5..e7e42e5fc 100644 --- a/cockatrice/src/game_graphics/board/translate_counter_name.h +++ b/cockatrice/src/game_graphics/board/translate_counter_name.h @@ -7,8 +7,10 @@ #ifndef TRANSLATECOUNTERNAME_H #define TRANSLATECOUNTERNAME_H +#include +#include +#include #include -#include class TranslateCounterName { diff --git a/cockatrice/src/game_graphics/deckview/deck_view.cpp b/cockatrice/src/game_graphics/deckview/deck_view.cpp index 1278737a0..c86bc9925 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view.cpp @@ -2,16 +2,47 @@ #include "../../client/settings/cache_settings.h" #include "../../interface/theme_manager.h" +#include "../board/abstract_card_drag_item.h" +#include "../card_dimensions.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/deck_list/tree/deck_list_card_node.h" +#include "libcockatrice/deck_list/tree/inner_deck_list_node.h" +#include "libcockatrice/protocol/pb/move_card_to_zone.pb.h" #include +#include +#include +#include +#include +#include +#include +#include #include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include #include #include +#include #include #include -#include #include +#include +#include +#include +#include + +class QObject; +class QRectF; +class QWidget; +struct CardRef; DeckViewCardDragItem::DeckViewCardDragItem(DeckViewCard *_item, const QPointF &_hotSpot, @@ -381,12 +412,10 @@ void DeckViewScene::rebuildTree() addItem(container); } - for (int j = 0; j < currentZone->size(); j++) { - auto *currentCard = dynamic_cast(currentZone->at(j)); - if (!currentCard) { - continue; - } - + // Cards in custom zones nested under a board are regular board cards in-game. + // They are collected recursively (like every other consumer) and reported with + // the top-level board zone as their origin, so that sideboard plans keep working. + for (auto *currentCard : deck->getCardNodes({currentZone->getName()})) { for (int k = 0; k < currentCard->getNumber(); ++k) { auto *newCard = new DeckViewCard(container, currentCard->toCardRef(), currentZone->getName()); container->addCard(newCard); diff --git a/cockatrice/src/game_graphics/deckview/deck_view.h b/cockatrice/src/game_graphics/deckview/deck_view.h index f996fd4da..3b5b4b6f4 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view.h +++ b/cockatrice/src/game_graphics/deckview/deck_view.h @@ -8,17 +8,30 @@ #define DECKVIEW_H #include "../board/abstract_card_drag_item.h" +#include "../board/abstract_card_item.h" +#include "../board/graphics_item_type.h" +#include +#include #include +#include #include +#include +#include +#include +#include +#include +#include +#include #include +#include class DeckList; -class InnerDecklistNode; -class CardInfo; class DeckViewCardContainer; class DeckViewCardDragItem; -class MoveCardToZone; +class QObject; +class QWidget; +struct CardRef; class DeckViewCard : public AbstractCardItem { diff --git a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp index 867f32c2c..89733d2ac 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp @@ -2,26 +2,51 @@ #include "../../client/settings/cache_settings.h" #include "../../client/settings/shortcuts_settings.h" +#include "../../game/abstract_game.h" +#include "../../game/game_event_handler.h" +#include "../../game/game_state.h" #include "../../interface/card_picture_loader/card_picture_loader.h" +#include "../../interface/deck_loader/deck_file_format.h" #include "../../interface/deck_loader/deck_loader.h" +#include "../../interface/deck_loader/loaded_deck.h" #include "../../interface/widgets/dialogs/dlg_load_deck.h" #include "../../interface/widgets/dialogs/dlg_load_deck_from_clipboard.h" #include "../../interface/widgets/dialogs/dlg_load_deck_from_website.h" #include "../../interface/widgets/dialogs/dlg_load_remote_deck.h" #include "../../interface/widgets/tabs/tab_game.h" +#include "../../interface/widgets/visual_deck_storage/visual_deck_storage_widget.h" +#include "../board/abstract_card_item.h" #include "deck_view.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/interfaces/interface_interface_settings_provider.h" +#include "libcockatrice/protocol/pb/move_card_to_zone.pb.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_playerproperties.pb.h" +#include "libcockatrice/utility/card_ref.h" +#include +#include #include -#include +#include +#include +#include +#include #include +#include #include #include +#include #include #include #include #include +#include #include #include +#include +#include +#include ToggleButton::ToggleButton(QWidget *parent) : QPushButton(parent), state(false) { @@ -100,6 +125,9 @@ DeckViewContainer::DeckViewContainer(int _playerId, TabGame *parent) connect(&SettingsCache::instance().visualDeckStorage(), &VisualDeckStorageSettings::visualDeckStorageInGameChanged, this, &DeckViewContainer::setVisualDeckStorageExists); + connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::playmatSettingsChanged, this, + &DeckViewContainer::onPlaymatSettingsChanged); + switchToDeckSelectView(); } @@ -294,6 +322,8 @@ void DeckViewContainer::loadDeckFromFile(const QString &filePath) void DeckViewContainer::loadDeckFromDeckList(const DeckList &deck) { + currentDeck = deck; + QString deckString = deck.writeToString_Native(); if (deckString.length() > MAX_FILE_LENGTH) { @@ -306,6 +336,52 @@ void DeckViewContainer::loadDeckFromDeckList(const DeckList &deck) PendingCommand *pend = parentGame->getGame()->getGameEventHandler()->prepareGameCommand(cmd); connect(pend, &PendingCommand::finished, this, &DeckViewContainer::deckSelectFinished); parentGame->getGame()->getGameEventHandler()->sendGameCommand(pend, playerId); + + resolveAndSendPlaymat(); +} + +void DeckViewContainer::resolveAndSendPlaymat() +{ + if (currentDeck.getCardRefList().isEmpty() && currentDeck.getPlaymat().card.isEmpty()) { + return; + } + + const auto &settings = SettingsCache::instance().userInterface(); + const auto fallbackBehavior = static_cast(settings.getPlaymatFallbackBehavior()); + + QList fallbackList = settings.getPlaymatFallbackList(); + + // In random mode with 2+ entries, remove the last-resolved mat to avoid repeats. + if (fallbackBehavior == PlaymatFallbackModeRandom && fallbackList.size() > 1) { + fallbackList.removeAll(lastResolvedPlaymat); + } + + const PlaymatInfo resolved = + resolvePlaymatForDeck(currentDeck, fallbackList, static_cast(settings.getPlaymatMode()), + fallbackBehavior, playmatRotationIndex); + + lastResolvedPlaymat = resolved; + + Command_SetPlaymat playmatCmd; + auto *pp = playmatCmd.mutable_playmat_params(); + pp->set_card_name(resolved.card.name.toStdString()); + pp->set_card_provider_id(resolved.card.providerId.toStdString()); + pp->set_margin_pct_l(resolved.params.marginPctL); + pp->set_margin_pct_r(resolved.params.marginPctR); + pp->set_vertical_offset(resolved.params.verticalOffset); + pp->set_zoom(resolved.params.zoom); + PendingCommand *playmatPend = parentGame->getGame()->getGameEventHandler()->prepareGameCommand(playmatCmd); + parentGame->getGame()->getGameEventHandler()->sendGameCommand(playmatPend, playerId); +} + +void DeckViewContainer::onPlaymatSettingsChanged() +{ + resolveAndSendPlaymat(); +} + +void DeckViewContainer::advancePlaymatRotation() +{ + playmatRotationIndex++; } void DeckViewContainer::loadRemoteDeck() @@ -396,6 +472,10 @@ void DeckViewContainer::sideboardPlanChanged() */ void DeckViewContainer::sendReadyStartCommand(bool ready) { + if (ready) { + resolveAndSendPlaymat(); + } + Command_ReadyStart cmd; cmd.set_ready(ready); parentGame->getGame()->getGameEventHandler()->sendGameCommand(cmd, playerId); @@ -433,6 +513,7 @@ void DeckViewContainer::setSideboardLocked(bool locked) void DeckViewContainer::setDeck(const DeckList &deck) { + currentDeck = deck; deckView->setDeck(deck); switchToDeckLoadedView(); } \ No newline at end of file diff --git a/cockatrice/src/game_graphics/deckview/deck_view_container.h b/cockatrice/src/game_graphics/deckview/deck_view_container.h index f25926e9d..77d16aa7f 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.h +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.h @@ -9,13 +9,17 @@ #include "../../interface/deck_loader/deck_loader.h" #include "../../interface/widgets/general/tutorial/tutorial_controller.h" +#include "libcockatrice/deck_list/deck_list.h" +#include "libcockatrice/utility/playmat_params.h" #include +#include +#include +#include class QVBoxLayout; class AbstractCardItem; class VisualDeckStorageWidget; -class DeckPreviewWidget; class Response; class TabGame; class DeckView; @@ -58,6 +62,9 @@ private: VisualDeckStorageWidget *visualDeckStorageWidget; TabGame *parentGame; int playerId; + int playmatRotationIndex = 0; ///< Per-match cursor for round-robin playmat mode. + DeckList currentDeck; ///< Cached deck for live settings re-resolution. + PlaymatInfo lastResolvedPlaymat; ///< Tracks last sent playmat to avoid repeats in random mode. void tryCreateVisualDeckStorageWidget(); void sendReadyStartCommand(bool ready); @@ -76,6 +83,7 @@ private slots: void sideboardLockButtonClicked(); void updateSideboardLockButtonText(); void refreshShortcuts(); + void onPlaymatSettingsChanged(); signals: void newCardAdded(AbstractCardItem *card); void notIdle(); @@ -89,6 +97,8 @@ public: void setSideboardLocked(bool locked); void setDeck(const DeckList &deck); void setVisualDeckStorageExists(bool exists); + void advancePlaymatRotation(); + void resolveAndSendPlaymat(); public slots: void loadDeckFromFile(const QString &filePath); diff --git a/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.cpp b/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.cpp index 7ab89fe5e..050d07352 100644 --- a/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.cpp +++ b/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.cpp @@ -1,7 +1,15 @@ #include "tabbed_deck_view_container.h" -#include "../../interface/widgets/tabs/tab_game.h" +#include "../board/abstract_card_item.h" #include "deck_view.h" +#include "deck_view_container.h" + +#include +#include +#include + +class DeckList; +class TabGame; TabbedDeckViewContainer::TabbedDeckViewContainer(int _playerId, TabGame *parent) : QTabWidget(nullptr), playerId(_playerId), parentGame(parent) diff --git a/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.h b/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.h index e5ded30de..63fedb5c2 100644 --- a/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.h +++ b/cockatrice/src/game_graphics/deckview/tabbed_deck_view_container.h @@ -9,7 +9,15 @@ #include "../../interface/widgets/general/tutorial/tutorial_controller.h" #include "deck_view_container.h" +#include +#include #include +#include + +class DeckList; +class DeckView; +class DeckViewContainer; +class TabGame; class TabbedDeckViewContainer : public QTabWidget { diff --git a/cockatrice/src/game_graphics/dialogs/dlg_create_token.cpp b/cockatrice/src/game_graphics/dialogs/dlg_create_token.cpp index b311d2ebd..5ce050380 100644 --- a/cockatrice/src/game_graphics/dialogs/dlg_create_token.cpp +++ b/cockatrice/src/game_graphics/dialogs/dlg_create_token.cpp @@ -2,28 +2,41 @@ #include "../../client/settings/cache_settings.h" #include "../../interface/widgets/cards/card_info_picture_widget.h" -#include "../../main.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include +#include #include #include #include #include #include #include -#include #include +#include #include #include +#include #include +#include +#include #include +#include +#include +#include #include -#include #include #include #include +#include #include #include #include +#include + +class QModelIndex; +class QWidget; DlgCreateToken::DlgCreateToken(const QStringList &_predefinedTokens, QWidget *parent) : QDialog(parent), predefinedTokens(_predefinedTokens) @@ -88,6 +101,17 @@ DlgCreateToken::DlgCreateToken(const QStringList &_predefinedTokens, QWidget *pa cardDatabaseDisplayModel = new TokenDisplayModel(this); cardDatabaseDisplayModel->setSourceModel(cardDatabaseModel); + const auto applyCardSearchLanguage = [this]() { + const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay(); + cardDatabaseDisplayModel->setSearchLanguage(CardSearchLanguage{ + cardsDisplay.getCardLang(), static_cast(cardsDisplay.getCardSearchLanguage())}); + }; + applyCardSearchLanguage(); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, + applyCardSearchLanguage); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardSearchLanguageChanged, this, + applyCardSearchLanguage); + chooseTokenFromAllRadioButton = new QRadioButton(tr("Show &all tokens")); connect(chooseTokenFromAllRadioButton, &QRadioButton::toggled, this, &DlgCreateToken::actChooseTokenFromAll); chooseTokenFromDeckRadioButton = new QRadioButton(tr("Show tokens from this &deck")); diff --git a/cockatrice/src/game_graphics/dialogs/dlg_create_token.h b/cockatrice/src/game_graphics/dialogs/dlg_create_token.h index 281e161fc..a03a1046b 100644 --- a/cockatrice/src/game_graphics/dialogs/dlg_create_token.h +++ b/cockatrice/src/game_graphics/dialogs/dlg_create_token.h @@ -8,19 +8,22 @@ #define DLG_CREATETOKEN_H #include +#include +#include +#include +#include class QLabel; class QLineEdit; class QComboBox; class QCheckBox; -class QPushButton; class QRadioButton; -class QCloseEvent; class QTreeView; -class DeckList; class CardDatabaseModel; class TokenDisplayModel; class CardInfoPictureWidget; +class QModelIndex; +class QWidget; struct TokenInfo { diff --git a/cockatrice/src/game_graphics/dialogs/dlg_move_top_cards_until.cpp b/cockatrice/src/game_graphics/dialogs/dlg_move_top_cards_until.cpp index 2b54452ac..b7cd73d79 100644 --- a/cockatrice/src/game_graphics/dialogs/dlg_move_top_cards_until.cpp +++ b/cockatrice/src/game_graphics/dialogs/dlg_move_top_cards_until.cpp @@ -1,16 +1,24 @@ #include "dlg_move_top_cards_until.h" +#include "libcockatrice/card/card_info.h" + +#include +#include +#include #include +#include #include -#include #include #include #include #include -#include +#include #include #include #include +#include + +class QWidget; DlgMoveTopCardsUntil::DlgMoveTopCardsUntil(QWidget *parent, const MoveTopCardsUntilOptions &options) : QDialog(parent) { @@ -19,6 +27,7 @@ DlgMoveTopCardsUntil::DlgMoveTopCardsUntil(QWidget *parent, const MoveTopCardsUn exprComboBox = new QComboBox(this); exprComboBox->setFocus(); exprComboBox->setEditable(true); + exprComboBox->completer()->setCaseSensitivity(Qt::CaseSensitive); exprComboBox->setInsertPolicy(QComboBox::InsertAtTop); exprComboBox->insertItems(0, options.exprs); exprLabel->setBuddy(exprComboBox); diff --git a/cockatrice/src/game_graphics/dialogs/dlg_move_top_cards_until.h b/cockatrice/src/game_graphics/dialogs/dlg_move_top_cards_until.h index ac9d41a94..b9080bcc1 100644 --- a/cockatrice/src/game_graphics/dialogs/dlg_move_top_cards_until.h +++ b/cockatrice/src/game_graphics/dialogs/dlg_move_top_cards_until.h @@ -7,14 +7,19 @@ #ifndef DLG_MOVE_TOP_CARDS_UNTIL_H #define DLG_MOVE_TOP_CARDS_UNTIL_H -#include -#include #include -#include -#include -#include +#include +#include +#include +#include class FilterString; +class QCheckBox; +class QComboBox; +class QDialogButtonBox; +class QLabel; +class QSpinBox; +class QWidget; struct MoveTopCardsUntilOptions { diff --git a/cockatrice/src/game_graphics/dialogs/dlg_roll_dice.cpp b/cockatrice/src/game_graphics/dialogs/dlg_roll_dice.cpp index 4f5d5b861..b17881211 100644 --- a/cockatrice/src/game_graphics/dialogs/dlg_roll_dice.cpp +++ b/cockatrice/src/game_graphics/dialogs/dlg_roll_dice.cpp @@ -1,12 +1,15 @@ #include "dlg_roll_dice.h" #include +#include #include #include +#include #include -#include #include +class QWidget; + DlgRollDice::DlgRollDice(QWidget *parent) : QDialog(parent) { numberOfSidesLabel = new QLabel(tr("Number of sides:")); diff --git a/cockatrice/src/game_graphics/dialogs/dlg_roll_dice.h b/cockatrice/src/game_graphics/dialogs/dlg_roll_dice.h index 15f7dc1ba..4c146a054 100644 --- a/cockatrice/src/game_graphics/dialogs/dlg_roll_dice.h +++ b/cockatrice/src/game_graphics/dialogs/dlg_roll_dice.h @@ -8,9 +8,13 @@ #define DLG_ROLL_DICE_H #include -#include -#include -#include +#include +#include + +class QDialogButtonBox; +class QLabel; +class QSpinBox; +class QWidget; class DlgRollDice : public QDialog { diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index db2088104..cc3f9f1d3 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -2,26 +2,50 @@ #include "../client/settings/cache_settings.h" #include "../game/abstract_game.h" +#include "../game/arrow_registry.h" +#include "../game/board/arrow_data.h" #include "../game/player/player_actions.h" +#include "../game/player/player_event_handler.h" +#include "../game/player/player_info.h" #include "../game/player/player_logic.h" -#include "../game_graphics/player/player_graphics_item.h" +#include "../game/zones/card_zone_logic.h" +#include "../game/zones/view_zone_logic.h" +#include "animated_item.h" +#include "board/abstract_card_item.h" +#include "board/arrow_item.h" #include "board/card_item.h" #include "phases_toolbar.h" #include "player/menu/player_menu.h" #include "player/player_graphics_item.h" +#include "player/player_target.h" +#include "zones/card_zone.h" #include "zones/select_zone.h" #include "zones/view_zone.h" #include "zones/view_zone_widget.h" #include #include +#include +#include #include #include +#include +#include +#include +#include +#include +#include +#include #include +#include #include +#include #include #include #include +#include + +class ArrowTarget; /** * @brief Constructs the GameScene. @@ -45,7 +69,19 @@ GameScene::GameScene(PhasesToolbar *_phasesToolbar, QObject *parent) GameScene::~GameScene() { + // Sever all destroyed->removeAnimatedItem connections before the members below + // are destroyed: the base QGraphicsScene destructor destroys the remaining items, + // and their destroyed() signals must not reach slots that reference members that + // no longer exist. The connection handle overload is used because the string-based + // disconnect(nullptr, nullptr, this, nullptr) is invalid (the sender must never be + // nullptr) and would otherwise fail to sever these pointer-to-member connections. + for (auto it = animationItemConnections.constBegin(); it != animationItemConnections.constEnd(); ++it) { + QObject::disconnect(*it); + } + animationItemConnections.clear(); + delete animationTimer; + animationTimer = nullptr; // Delete all ArrowItems before QGraphicsScene's base destructor runs. // QGraphicsScene::~QGraphicsScene() destroys items in arbitrary order. @@ -210,7 +246,12 @@ void GameScene::removePlayer(PlayerLogic *player) clearArrowsForPlayer(player->getPlayerInfo()->getId()); - for (ZoneViewWidget *zone : zoneViews) { + // Closing a view removes it from zoneViews synchronously, so iterate over a + // copy: otherwise a player with several open views (e.g. library and hand) + // only has the first one closed here and the remaining views are left + // pointing at a player that is about to be deleted. + const QList zoneViewCopy = zoneViews; + for (ZoneViewWidget *zone : zoneViewCopy) { if (zone->getPlayer() == player) { zone->close(); } @@ -246,17 +287,27 @@ void GameScene::adjustPlayerRotation(int rotationAdjustment) */ void GameScene::rearrange() { - int firstPlayerIndex = 0; - auto playersPlaying = collectActivePlayers(firstPlayerIndex); - playersPlaying = rotatePlayers(playersPlaying, firstPlayerIndex); + if (rearranging) { + needsReArrange = true; + return; + } + rearranging = true; + do { + needsReArrange = false; - int columns = determineColumnCount(playersPlaying.size()); - QSizeF sceneSize = computeSceneSizeAndPlayerLayout(playersPlaying, columns); + int firstPlayerIndex = 0; + auto playersPlaying = collectActivePlayers(firstPlayerIndex); + playersPlaying = rotatePlayers(playersPlaying, firstPlayerIndex); - phasesToolbar->setHeight(sceneSize.height()); - setSceneRect(0, 0, sceneSize.width(), sceneSize.height()); + int columns = determineColumnCount(playersPlaying.size()); + QSizeF sceneSize = computeSceneSizeAndPlayerLayout(playersPlaying, columns); - processViewSizeChange(viewSize); + phasesToolbar->setHeight(sceneSize.height()); + setSceneRect(0, 0, sceneSize.width(), sceneSize.height()); + + processViewSizeChange(viewSize); + } while (needsReArrange); + rearranging = false; } // ---------- View Size ---------- @@ -453,8 +504,14 @@ void GameScene::resizeColumnsAndPlayers(const QList &minWidthByColumn, qr qreal extraWidthPerColumn = (newWidth - minWidth) / playersByColumn.size(); qreal newx = phasesToolbar->getWidth(); - for (int col = 0; col < playersByColumn.size(); ++col) { - for (PlayerGraphicsItem *player : playersByColumn[col]) { + // Snapshot the columns: resizing a player's table can synchronously trigger + // GameScene::rearrange (table width -> sizeChanged -> updateBoundingRect -> + // sizeChanged -> rearrange), and rearrange rebuilds playersByColumn. Iterating + // the live container across that re-entrant call would use invalidated iterators. + const QList> columns = playersByColumn; + + for (int col = 0; col < columns.size(); ++col) { + for (PlayerGraphicsItem *player : columns[col]) { player->processSceneSizeChange(minWidthByColumn[col] + extraWidthPerColumn); player->setPos(newx, player->y()); } @@ -496,6 +553,7 @@ void GameScene::addArrow(QSharedPointer data) auto *arrow = new ArrowItem(data, startCard, targetItem); addItem(arrow); + arrow->startDrawAnimation(); arrowRegistry.insert(data, arrow); connect(arrow, &ArrowItem::requestDeletion, this, &GameScene::requestArrowDeletion); } @@ -636,7 +694,10 @@ CardItem *GameScene::findTopmostCardInZone(const QList &items, */ void GameScene::toggleZoneView(PlayerLogic *player, const QString &zoneName, int numberCards, bool isReversed) { - for (auto &view : zoneViews) { + // Closing a view removes it from zoneViews synchronously, so iterate over a + // copy to make sure every already-open matching view is closed. + const QList zoneViewCopy = zoneViews; + for (auto *view : zoneViewCopy) { ZoneViewZone *temp = view->getZone(); if (temp->getLogic()->getName() == zoneName && temp->getLogic()->getPlayer() == player && qobject_cast(temp->getLogic())->getNumberCards() == numberCards) { @@ -699,6 +760,16 @@ void GameScene::clearViews() } } +void GameScene::emptyActiveViews() +{ + for (auto zoneView : zoneViews) { + auto logic = qobject_cast(zoneView->getZone()->getLogic()); + if (!logic->getRevealZone()) { + logic->clearCards(); + } + } +} + /** * @brief Closes the most recently added zone view. */ @@ -736,30 +807,53 @@ bool GameScene::event(QEvent *event) void GameScene::timerEvent(QTimerEvent * /*event*/) { - QMutableSetIterator i(cardsToAnimate); + QMutableHashIterator i(animatedItems); while (i.hasNext()) { i.next(); if (!i.value()->animationEvent()) { i.remove(); } } - if (cardsToAnimate.isEmpty()) { + if (animatedItems.isEmpty()) { animationTimer->stop(); } } -void GameScene::registerAnimationItem(AbstractCardItem *card) +void GameScene::registerAnimationItem(IAnimatedItem *item) { - cardsToAnimate.insert(static_cast(card)); - if (!animationTimer->isActive()) { + auto *object = dynamic_cast(item); + if (!object) { + return; + } + // Guard against duplicate connections using the connection map, not + // animatedItems: the animation timer removes entries from animatedItems when an + // animation completes, but the destroyed->removeAnimatedItem connection must + // persist until the object is destroyed. Relying on animatedItems here would let + // a re-registered item (e.g. a life counter that flashes repeatedly) accumulate + // duplicate destroyed connections, the older ones of which would survive teardown. + if (!animationItemConnections.contains(object)) { + animationItemConnections.insert(object, + connect(object, &QObject::destroyed, this, &GameScene::removeAnimatedItem)); + } + animatedItems.insert(object, item); + if (animationTimer && !animationTimer->isActive()) { animationTimer->start(10, this); } } -void GameScene::unregisterAnimationItem(AbstractCardItem *card) +void GameScene::unregisterAnimationItem(IAnimatedItem *item) { - cardsToAnimate.remove(static_cast(card)); - if (cardsToAnimate.isEmpty()) { + animatedItems.remove(dynamic_cast(item)); + if (animationTimer && animatedItems.isEmpty()) { + animationTimer->stop(); + } +} + +void GameScene::removeAnimatedItem(QObject *item) +{ + animatedItems.remove(item); + animationItemConnections.remove(item); + if (animationTimer && animatedItems.isEmpty()) { animationTimer->stop(); } } diff --git a/cockatrice/src/game_graphics/game_scene.h b/cockatrice/src/game_graphics/game_scene.h index 74e979556..c7cae1e76 100644 --- a/cockatrice/src/game_graphics/game_scene.h +++ b/cockatrice/src/game_graphics/game_scene.h @@ -2,15 +2,33 @@ #define GAMESCENE_H #include "../game/arrow_registry.h" -#include "../game/board/arrow_data.h" -#include "../game/zones/card_zone_logic.h" -#include "board/arrow_item.h" #include +#include #include #include +#include +#include +#include #include -#include +#include +#include +#include +#include +#include +#include + +// IWYU pragma: keep +// ServerInfo_Card appears inside QList<...> in signal and slot +// signatures, which the moc-generated code needs to be complete. +#include + +class CardZoneLogic; +class IAnimatedItem; +class QGraphicsItem; +class QPoint; +struct ArrowData; +template class QSharedPointer; inline Q_LOGGING_CATEGORY(GameSceneLog, "game_scene"); inline Q_LOGGING_CATEGORY(GameScenePlayerAdditionRemovalLog, "game_scene.player_addition_removal"); @@ -24,6 +42,7 @@ class CardItem; class ServerInfo_Card; class PhasesToolbar; class QBasicTimer; +class QObject; /** * @class GameScene @@ -50,9 +69,13 @@ private: QList zoneViews; ///< Active zone view widgets QSize viewSize; ///< Current view size QPointer hoveredCard; ///< Currently hovered card - QBasicTimer *animationTimer; ///< Timer for card animations - QSet cardsToAnimate; ///< Cards currently animating - int playerRotation; ///< Rotation offset for player layout + QBasicTimer *animationTimer; ///< Timer for scene animations + QHash animatedItems; ///< Items currently animating + QHash + animationItemConnections; ///< destroyed->removeAnimatedItem handles per animated item + int playerRotation; ///< Rotation offset for player layout + bool rearranging = false; ///< Guard against re-entrant rearrange + bool needsReArrange = false; ///< Pending rearrange requested during a pass /** * @brief Updates which card is currently hovered based on scene coordinates. @@ -182,15 +205,24 @@ public: /** @brief Updates hovered card highlighting. */ void updateHoveredCard(CardItem *newCard); - /** @brief Registers a card for animation updates. */ - void registerAnimationItem(AbstractCardItem *card); + /** + * @brief Registers an item for animation updates with the shared scene timer. + * + * The item must inherit QObject; it is unregistered automatically when it is + * destroyed, so it may be deleted mid-animation without a dangling pointer. + */ + void registerAnimationItem(IAnimatedItem *item); - /** @brief Unregisters a card from animation updates. */ - void unregisterAnimationItem(AbstractCardItem *card); + /** @brief Unregisters an item from animation updates. */ + void unregisterAnimationItem(IAnimatedItem *item); void startRubberBand(const QPointF &selectionOrigin); void resizeRubberBand(const QPointF &cursorPoint, int selectedCount); void stopRubberBand(); +private slots: + /** @brief Removes a destroyed item from the animation set. */ + void removeAnimatedItem(QObject *item); + public slots: void onCardSelectionChanged(AbstractCardItem *card, bool selected); void onCardRightClicked(AbstractCardItem *card, QPoint screenPos); @@ -212,6 +244,9 @@ public slots: /** @brief Closes all zone views. */ void clearViews(); + /** @brief Empties the contents of all non-reveal zone views. Used for replay rewinding */ + void emptyActiveViews(); + /** @brief Closes the most recently added zone view. */ void closeMostRecentZoneView(); QTransform getViewTransform() const; diff --git a/cockatrice/src/game_graphics/game_view.cpp b/cockatrice/src/game_graphics/game_view.cpp index b768c8317..1c92d74ae 100644 --- a/cockatrice/src/game_graphics/game_view.cpp +++ b/cockatrice/src/game_graphics/game_view.cpp @@ -5,13 +5,24 @@ #include "game_scene.h" #include +#include +#include +#include +#include #include #include -#include +#include +#include +#include #include #include +#include +#include +#include +#include #include #include +#include // QRubberBand calls raise() in showEvent() and changeEvent() to stay on top of siblings. // This subclass disables that behavior so dragCountLabel can appear above it. @@ -114,6 +125,7 @@ void GameView::startRubberBand(const QPointF &_selectionOrigin) } selectionOrigin = _selectionOrigin; + previousBandRect = QRect(); rubberBand->setGeometry(QRect(mapFromScene(selectionOrigin), QSize(0, 0))); rubberBand->show(); } @@ -128,7 +140,17 @@ void GameView::resizeRubberBand(const QPointF &cursorPoint, int selectedCount) QPoint cursor = cursorPoint.toPoint(); QRect rect = QRect(mapFromScene(selectionOrigin), cursor).normalized(); + rubberBand->setGeometry(rect); + if (viewport()) { + // Repaint the union of the previous and current band rects: the vacated + // strip of a child widget is not reliably invalidated on all platforms + // (notably macOS), leaving stale pixels under the selection. + QRect dirty = previousBandRect.isNull() ? rect : previousBandRect.united(rect); + dirty.adjust(-1, -1, 1, 1); + viewport()->update(dirty); + previousBandRect = rect; + } if (!SettingsCache::instance().userInterface().getShowDragSelectionCount()) { dragCountLabel->hide(); @@ -171,7 +193,13 @@ void GameView::stopRubberBand() return; } + // Same rationale as resizeRubberBand: repaint the last known band area + // since hiding a child widget doesn't reliably invalidate its region. rubberBand->hide(); + if (viewport() && !previousBandRect.isNull()) { + viewport()->update(previousBandRect.adjusted(-1, -1, 1, 1)); + previousBandRect = QRect(); + } dragCountLabel->hide(); } diff --git a/cockatrice/src/game_graphics/game_view.h b/cockatrice/src/game_graphics/game_view.h index 3f6b60dbc..e57267734 100644 --- a/cockatrice/src/game_graphics/game_view.h +++ b/cockatrice/src/game_graphics/game_view.h @@ -10,11 +10,19 @@ #include "tally/tally.h" #include +#include +#include +#include +#include +#include +#include class GameScene; class QGridLayout; class QLabel; class QRubberBand; +class QAction; +class QWidget; class GameView : public QGraphicsView { @@ -27,6 +35,7 @@ private: QWidget *tallyContainer; QGridLayout *tallyLayout; QPointF selectionOrigin; + QRect previousBandRect; ///< Last rubber-band rect for targeted repaint QList cachedTallyRows; ///< Cached entries to avoid redundant rebuilds QSize rebuildTallyLabels(const QList &entries); diff --git a/cockatrice/src/game_graphics/hand_counter.cpp b/cockatrice/src/game_graphics/hand_counter.cpp index 35989ff38..9320c1295 100644 --- a/cockatrice/src/game_graphics/hand_counter.cpp +++ b/cockatrice/src/game_graphics/hand_counter.cpp @@ -1,10 +1,22 @@ #include "hand_counter.h" -#include "zones/card_zone.h" +#include "../game/board/card_list.h" +#include "../game/zones/card_zone_logic.h" +#include "../interface/pixel_map_generator.h" #include #include +#include #include +#include +#include +#include +#include +#include +#include +#include + +class QGraphicsItem; HandCounter::HandCounter(QGraphicsItem *parent) : AbstractGraphicsItem(parent), number(0) { @@ -32,7 +44,8 @@ void HandCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*op QSize translatedSize = painter->combinedTransform().mapRect(boundingRect()).size().toSize(); QPixmap cachedPixmap; if (!QPixmapCache::find("handCounter" + QString::number(translatedSize.width()), &cachedPixmap)) { - cachedPixmap = QPixmap("theme:hand").scaled(translatedSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); + cachedPixmap = + themePixmap(QStringLiteral("hand")).scaled(translatedSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); QPixmapCache::insert("handCounter" + QString::number(translatedSize.width()), cachedPixmap); } resetPainterTransform(painter); diff --git a/cockatrice/src/game_graphics/hand_counter.h b/cockatrice/src/game_graphics/hand_counter.h index 9aa65d514..c1f5d09d8 100644 --- a/cockatrice/src/game_graphics/hand_counter.h +++ b/cockatrice/src/game_graphics/hand_counter.h @@ -10,10 +10,10 @@ #include "board/abstract_graphics_item.h" #include "board/graphics_item_type.h" -#include +#include -class QPainter; -class QPixmap; +class QGraphicsItem; +class QPoint; class HandCounter : public AbstractGraphicsItem { diff --git a/cockatrice/src/game_graphics/log/message_log_widget.cpp b/cockatrice/src/game_graphics/log/message_log_widget.cpp index ccd903b04..4a9750fca 100644 --- a/cockatrice/src/game_graphics/log/message_log_widget.cpp +++ b/cockatrice/src/game_graphics/log/message_log_widget.cpp @@ -1,18 +1,41 @@ #include "message_log_widget.h" +#include "../../client/settings/cache_settings.h" #include "../../client/settings/card_counter_settings.h" #include "../../client/sound_engine.h" +#include "../../client/translation.h" +#include "../../game/abstract_game.h" +#include "../../game/board/card_list.h" +#include "../../game/game_state.h" #include "../../game/phase.h" +#include "../../game/player/player_event_handler.h" +#include "../../game/player/player_info.h" #include "../../game/player/player_logic.h" -#include "../../interface/widgets/tabs/tab_game.h" +#include "../../game/player/player_manager.h" +#include "../../game/zones/card_zone_logic.h" +#include "../../interface/card_localization.h" +#include "../../interface/widgets/server/chat_view/chat_view.h" #include "../board/card_item.h" #include "../board/translate_counter_name.h" +#include "libcockatrice/protocol/pb/game_event_context.pb.h" +#include +#include +#include +#include +#include #include #include +#include #include +#include +#include #include +class QWidget; +class ServerInfo_User; +class TabSupervisor; + static QString sanitizeHtml(QString dirty) { return dirty.replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """); @@ -20,7 +43,11 @@ static QString sanitizeHtml(QString dirty) static QString cardLink(const QString &cardName) { - return QString("%2").arg(cardName).arg(cardName); + // The href keeps the canonical name so the card popup can resolve it, while the label shows the name + // in the configured card language. + return QString("%2") + .arg(sanitizeHtml(cardName)) + .arg(sanitizeHtml(CardLocalization::displayNameFor(cardName))); } QPair @@ -626,13 +653,12 @@ void MessageLogWidget::logSetActivePhase(int phaseNumber) soundEngine->playSound(phase.soundFileName); - appendHtml("" + QDateTime::currentDateTime().toString("[hh:mm:ss] ") + - phase.getName() + ""); + appendHtml("" + getCurrentTime() + phase.getName() + ""); } void MessageLogWidget::logSetActivePlayer(PlayerLogic *player) { - appendHtml("
" + QDateTime::currentDateTime().toString("[hh:mm:ss] ") + + appendHtml("
" + getCurrentTime() + QString(tr("%1's turn.")).arg(player->getPlayerInfo()->getName()) + "
"); } @@ -801,10 +827,9 @@ void MessageLogWidget::logUndoDraw(PlayerLogic *player, QString cardName) if (cardName.isEmpty()) { appendHtmlServerMessage(tr("%1 undoes their last draw.").arg(sanitizeHtml(player->getPlayerInfo()->getName()))); } else { - appendHtmlServerMessage( - tr("%1 undoes their last draw (%2).") - .arg(sanitizeHtml(player->getPlayerInfo()->getName())) - .arg(QString("%2").arg(sanitizeHtml(cardName)).arg(sanitizeHtml(cardName)))); + appendHtmlServerMessage(tr("%1 undoes their last draw (%2).") + .arg(sanitizeHtml(player->getPlayerInfo()->getName())) + .arg(cardLink(std::move(cardName)))); } } @@ -826,6 +851,14 @@ void MessageLogWidget::appendHtmlServerMessage(const QString &html, bool optiona ChatView::appendHtmlServerMessage(messagePrefix + html + messageSuffix, optionalIsBold, optionalFontColor); } +QString MessageLogWidget::getCurrentTime() const +{ + if (SettingsCache::instance().chat().getUseGameTime()) { + return "[" + GameState::formatElapsedTime(game->getGameState()->getSecondsElapsed()) + "] "; + } + return ChatView::getCurrentTime(); +} + void MessageLogWidget::connectToPlayerEventHandler(PlayerEventHandler *playerEventHandler) { connect(playerEventHandler, &PlayerEventHandler::logSay, this, &MessageLogWidget::logSay); diff --git a/cockatrice/src/game_graphics/log/message_log_widget.h b/cockatrice/src/game_graphics/log/message_log_widget.h index a145d358d..6800ba8ed 100644 --- a/cockatrice/src/game_graphics/log/message_log_widget.h +++ b/cockatrice/src/game_graphics/log/message_log_widget.h @@ -7,14 +7,23 @@ #ifndef MESSAGELOGWIDGET_H #define MESSAGELOGWIDGET_H -#include "../../game/zones/card_zone_logic.h" #include "../../interface/widgets/server/chat_view/chat_view.h" +#include +#include +#include +#include + class AbstractGame; class CardItem; class GameEventContext; class PlayerLogic; class PlayerEventHandler; +class CardZoneLogic; +class QWidget; +class ServerInfo_User; +class TabSupervisor; +template class QList; class MessageLogWidget : public ChatView { @@ -104,6 +113,9 @@ public slots: void appendHtmlServerMessage(const QString &html, bool optionalIsBold = false, QString optionalFontColor = QString()) override; + +private: + [[nodiscard]] QString getCurrentTime() const override; }; #endif diff --git a/cockatrice/src/game_graphics/phases_toolbar.cpp b/cockatrice/src/game_graphics/phases_toolbar.cpp index 3361f9d55..d0a3f74fd 100644 --- a/cockatrice/src/game_graphics/phases_toolbar.cpp +++ b/cockatrice/src/game_graphics/phases_toolbar.cpp @@ -1,17 +1,26 @@ #include "phases_toolbar.h" #include "../interface/pixel_map_generator.h" +#include "board/abstract_graphics_item.h" +#include "libcockatrice/protocol/pb/card_attributes.pb.h" #include -#include +#include #include -#include +#include +#include +#include #include +#include +#include +#include #include #include #include #include #include +#include +#include PhaseButton::PhaseButton(const QString &_name, QGraphicsItem *parent, QAction *_doubleClickAction, bool _highlightable) : QObject(), QGraphicsItem(parent), name(_name), active(false), highlightable(_highlightable), diff --git a/cockatrice/src/game_graphics/phases_toolbar.h b/cockatrice/src/game_graphics/phases_toolbar.h index 39884ef75..376775284 100644 --- a/cockatrice/src/game_graphics/phases_toolbar.h +++ b/cockatrice/src/game_graphics/phases_toolbar.h @@ -8,11 +8,14 @@ #ifndef PHASESTOOLBAR_H #define PHASESTOOLBAR_H -#include "board/abstract_graphics_item.h" - -#include -#include +#include #include +#include +#include +#include + +class QAction; +class QTimer; namespace google { @@ -21,8 +24,6 @@ namespace protobuf class Message; } } // namespace google -class PlayerLogic; -class GameCommand; class PhaseButton : public QObject, public QGraphicsItem { diff --git a/cockatrice/src/game_graphics/player/menu/card_menu.cpp b/cockatrice/src/game_graphics/player/menu/card_menu.cpp index 75ca1b28b..137ec2ed1 100644 --- a/cockatrice/src/game_graphics/player/menu/card_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/card_menu.cpp @@ -1,21 +1,43 @@ #include "card_menu.h" +#include "../../../client/settings/cache_settings.h" #include "../../../client/settings/card_counter_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../../interface/widgets/tabs/tab_game.h" +#include "../../../game/abstract_game.h" +#include "../../../game/player/player_info.h" +#include "../../../game/player/player_manager.h" +#include "../../../game/zones/card_zone_logic.h" #include "../../board/card_item.h" #include "../../game/player/player_actions.h" #include "../../game/player/player_logic.h" #include "../../game/zones/view_zone_logic.h" +#include "../../game_scene.h" #include "../card_menu_action_type.h" #include "../player_graphics_item.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/card/printing/printing_info.h" +#include "libcockatrice/utility/card_ref.h" #include "move_menu.h" #include "pt_menu.h" +#include +#include +#include +#include +#include #include +#include +#include +#include +#include +#include #include #include #include +#include +#include /** * @brief Creates a circular icon filled with the specified color. diff --git a/cockatrice/src/game_graphics/player/menu/card_menu.h b/cockatrice/src/game_graphics/player/menu/card_menu.h index d67ef3876..337d77b3e 100644 --- a/cockatrice/src/game_graphics/player/menu/card_menu.h +++ b/cockatrice/src/game_graphics/player/menu/card_menu.h @@ -7,12 +7,18 @@ #ifndef COCKATRICE_CARD_MENU_H #define COCKATRICE_CARD_MENU_H +#include #include -#include +#include +#include +#include class CardItem; class PlayerGraphicsItem; class PlayerLogic; +class QAction; +struct CardRef; + class CardMenu : public QMenu { Q_OBJECT diff --git a/cockatrice/src/game_graphics/player/menu/custom_zone_menu.cpp b/cockatrice/src/game_graphics/player/menu/custom_zone_menu.cpp index 743746cc8..e16ebc112 100644 --- a/cockatrice/src/game_graphics/player/menu/custom_zone_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/custom_zone_menu.cpp @@ -1,8 +1,14 @@ #include "custom_zone_menu.h" +#include "../../../game/player/player_info.h" #include "../../game/player/player_logic.h" +#include "../../game_scene.h" #include "../player_graphics_item.h" +#include +#include +#include + CustomZoneMenu::CustomZoneMenu(PlayerGraphicsItem *_player) : player(_player) { menuAction()->setVisible(false); diff --git a/cockatrice/src/game_graphics/player/menu/custom_zone_menu.h b/cockatrice/src/game_graphics/player/menu/custom_zone_menu.h index 46dd58db6..07f5510ca 100644 --- a/cockatrice/src/game_graphics/player/menu/custom_zone_menu.h +++ b/cockatrice/src/game_graphics/player/menu/custom_zone_menu.h @@ -10,8 +10,11 @@ #include "abstract_player_component.h" #include +#include +#include class PlayerGraphicsItem; + class CustomZoneMenu : public QMenu, public AbstractPlayerComponent { Q_OBJECT diff --git a/cockatrice/src/game_graphics/player/menu/grave_menu.cpp b/cockatrice/src/game_graphics/player/menu/grave_menu.cpp index 6d9364579..580d2642e 100644 --- a/cockatrice/src/game_graphics/player/menu/grave_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/grave_menu.cpp @@ -1,15 +1,28 @@ #include "grave_menu.h" +#include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" +#include "../../../game/player/player_info.h" +#include "../../../game/player/player_manager.h" +#include "../../../game/zones/pile_zone_logic.h" +#include "../../../interface/widgets/menus/tearoff_menu.h" #include "../../game/abstract_game.h" #include "../../game/player/player_actions.h" #include "../../game/player/player_logic.h" #include "../player_graphics_item.h" #include +#include +#include +#include #include +#include +#include +#include #include +class QWidget; + GraveyardMenu::GraveyardMenu(PlayerGraphicsItem *_player, QWidget *parent) : TearOffMenu(parent), player(_player) { createMoveActions(); diff --git a/cockatrice/src/game_graphics/player/menu/grave_menu.h b/cockatrice/src/game_graphics/player/menu/grave_menu.h index 116261e9b..fa285409f 100644 --- a/cockatrice/src/game_graphics/player/menu/grave_menu.h +++ b/cockatrice/src/game_graphics/player/menu/grave_menu.h @@ -10,10 +10,13 @@ #include "../../../interface/widgets/menus/tearoff_menu.h" #include "abstract_player_component.h" -#include -#include +#include class PlayerGraphicsItem; +class QAction; +class QMenu; +class QWidget; + class GraveyardMenu : public TearOffMenu, public AbstractPlayerComponent { Q_OBJECT diff --git a/cockatrice/src/game_graphics/player/menu/hand_menu.cpp b/cockatrice/src/game_graphics/player/menu/hand_menu.cpp index ba0702f07..141542cb4 100644 --- a/cockatrice/src/game_graphics/player/menu/hand_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/hand_menu.cpp @@ -2,16 +2,28 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../../game_graphics/zones/hand_zone.h" +#include "../../../game/board/card_list.h" +#include "../../../game/player/player_info.h" +#include "../../../game/player/player_manager.h" +#include "../../../game/zones/hand_zone_logic.h" +#include "../../../interface/widgets/menus/tearoff_menu.h" #include "../../game/abstract_game.h" #include "../../game/player/player_actions.h" #include "../../game/player/player_logic.h" #include "../player_graphics_item.h" #include +#include +#include +#include #include +#include +#include +#include #include +class QWidget; + HandMenu::HandMenu(PlayerGraphicsItem *_player, QWidget *parent) : TearOffMenu(parent), player(_player) { auto *actions = player->getLogic()->getPlayerActions(); diff --git a/cockatrice/src/game_graphics/player/menu/hand_menu.h b/cockatrice/src/game_graphics/player/menu/hand_menu.h index d5204612b..de7726064 100644 --- a/cockatrice/src/game_graphics/player/menu/hand_menu.h +++ b/cockatrice/src/game_graphics/player/menu/hand_menu.h @@ -10,11 +10,12 @@ #include "../../../interface/widgets/menus/tearoff_menu.h" #include "abstract_player_component.h" -#include -#include +#include class PlayerGraphicsItem; -class PlayerActions; +class QAction; +class QMenu; +class QWidget; class HandMenu : public TearOffMenu, public AbstractPlayerComponent { diff --git a/cockatrice/src/game_graphics/player/menu/library_menu.cpp b/cockatrice/src/game_graphics/player/menu/library_menu.cpp index 4c15e09ec..aa389006e 100644 --- a/cockatrice/src/game_graphics/player/menu/library_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/library_menu.cpp @@ -2,15 +2,29 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../../interface/widgets/tabs/tab_game.h" +#include "../../../game/board/card_list.h" +#include "../../../game/player/player_info.h" +#include "../../../game/player/player_manager.h" +#include "../../../game/zones/pile_zone_logic.h" +#include "../../../interface/widgets/menus/tearoff_menu.h" #include "../../game/abstract_game.h" #include "../../game/player/player_actions.h" #include "../../game/player/player_logic.h" #include "../player_graphics_item.h" #include +#include #include +#include +#include +#include +#include #include +#include +#include +#include + +class QWidget; LibraryMenu::LibraryMenu(PlayerGraphicsItem *_player, QWidget *parent) : TearOffMenu(parent), player(_player) { diff --git a/cockatrice/src/game_graphics/player/menu/library_menu.h b/cockatrice/src/game_graphics/player/menu/library_menu.h index bc0e6fb8e..72e90d0ba 100644 --- a/cockatrice/src/game_graphics/player/menu/library_menu.h +++ b/cockatrice/src/game_graphics/player/menu/library_menu.h @@ -11,11 +11,11 @@ #include "abstract_player_component.h" #include -#include +#include class PlayerGraphicsItem; -class PlayerLogic; -class PlayerActions; +class QMenu; +class QWidget; class LibraryMenu : public TearOffMenu, public AbstractPlayerComponent { diff --git a/cockatrice/src/game_graphics/player/menu/move_menu.cpp b/cockatrice/src/game_graphics/player/menu/move_menu.cpp index 632e0ddda..2991100ec 100644 --- a/cockatrice/src/game_graphics/player/menu/move_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/move_menu.cpp @@ -1,11 +1,15 @@ #include "move_menu.h" +#include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" #include "../../game/player/player_actions.h" #include "../../game/player/player_logic.h" +#include "../../game_scene.h" #include "../card_menu_action_type.h" #include "../player_graphics_item.h" +#include + MoveMenu::MoveMenu(PlayerGraphicsItem *player) : QMenu(tr("Move to")) { aMoveToTopLibrary = new QAction(this); diff --git a/cockatrice/src/game_graphics/player/menu/move_menu.h b/cockatrice/src/game_graphics/player/menu/move_menu.h index 150bdbd3c..f1f239abd 100644 --- a/cockatrice/src/game_graphics/player/menu/move_menu.h +++ b/cockatrice/src/game_graphics/player/menu/move_menu.h @@ -7,8 +7,11 @@ #ifndef COCKATRICE_MOVE_MENU_H #define COCKATRICE_MOVE_MENU_H #include +#include class PlayerGraphicsItem; +class QAction; + class MoveMenu : public QMenu { Q_OBJECT diff --git a/cockatrice/src/game_graphics/player/menu/player_menu.cpp b/cockatrice/src/game_graphics/player/menu/player_menu.cpp index 7c90211df..44b184cfe 100644 --- a/cockatrice/src/game_graphics/player/menu/player_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/player_menu.cpp @@ -1,16 +1,31 @@ #include "player_menu.h" +#include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" +#include "../../../game/abstract_game.h" +#include "../../../game/player/player_info.h" +#include "../../../game/player/player_logic.h" +#include "../../../game/player/player_manager.h" #include "../../../game_graphics/zones/hand_zone.h" #include "../../../game_graphics/zones/pile_zone.h" #include "../../../game_graphics/zones/table_zone.h" -#include "../../../interface/widgets/tabs/tab_game.h" +#include "../../../interface/widgets/menus/tearoff_menu.h" #include "../../board/card_item.h" #include "../player_graphics_item.h" +#include "abstract_player_component.h" #include "card_menu.h" +#include "custom_zone_menu.h" +#include "grave_menu.h" #include "hand_menu.h" +#include "library_menu.h" +#include "rfg_menu.h" +#include "say_menu.h" +#include "sideboard_menu.h" +#include "tally_menu.h" +#include "utility_menu.h" -#include +#include +#include PlayerMenu::PlayerMenu(PlayerGraphicsItem *_player) : QObject(_player), player(_player) { diff --git a/cockatrice/src/game_graphics/player/menu/player_menu.h b/cockatrice/src/game_graphics/player/menu/player_menu.h index a6e89fab0..97dd2fc56 100644 --- a/cockatrice/src/game_graphics/player/menu/player_menu.h +++ b/cockatrice/src/game_graphics/player/menu/player_menu.h @@ -8,24 +8,28 @@ #define COCKATRICE_PLAYER_MENU_H #include "../../../interface/widgets/menus/tearoff_menu.h" -#include "custom_zone_menu.h" -#include "grave_menu.h" -#include "hand_menu.h" -#include "library_menu.h" -#include "rfg_menu.h" -#include "say_menu.h" -#include "sideboard_menu.h" -#include "tally_menu.h" -#include "utility_menu.h" #include -#include #include -#include +#include +#include class CardItem; class CardMenu; class PlayerGraphicsItem; +class AbstractPlayerComponent; +class CustomZoneMenu; +class GraveyardMenu; +class HandMenu; +class LibraryMenu; +class QMenu; +class RfgMenu; +class SayMenu; +class SideboardMenu; +class TallyMenu; +class UtilityMenu; +struct CardRef; + class PlayerMenu : public QObject { Q_OBJECT diff --git a/cockatrice/src/game_graphics/player/menu/pt_menu.cpp b/cockatrice/src/game_graphics/player/menu/pt_menu.cpp index 152461c18..b4f852b43 100644 --- a/cockatrice/src/game_graphics/player/menu/pt_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/pt_menu.cpp @@ -1,10 +1,14 @@ #include "pt_menu.h" +#include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" #include "../../game/player/player_actions.h" #include "../../game/player/player_logic.h" +#include "../../game_scene.h" #include "../player_graphics_item.h" +#include + PtMenu::PtMenu(PlayerGraphicsItem *player) : QMenu(tr("Power / toughness")) { PlayerActions *playerActions = player->getLogic()->getPlayerActions(); diff --git a/cockatrice/src/game_graphics/player/menu/pt_menu.h b/cockatrice/src/game_graphics/player/menu/pt_menu.h index 72f828801..aaf17cead 100644 --- a/cockatrice/src/game_graphics/player/menu/pt_menu.h +++ b/cockatrice/src/game_graphics/player/menu/pt_menu.h @@ -7,8 +7,11 @@ #ifndef COCKATRICE_PT_MENU_H #define COCKATRICE_PT_MENU_H #include +#include class PlayerGraphicsItem; +class QAction; + class PtMenu : public QMenu { diff --git a/cockatrice/src/game_graphics/player/menu/rfg_menu.cpp b/cockatrice/src/game_graphics/player/menu/rfg_menu.cpp index 45abadbf7..98514280f 100644 --- a/cockatrice/src/game_graphics/player/menu/rfg_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/rfg_menu.cpp @@ -1,11 +1,21 @@ #include "rfg_menu.h" +#include "../../../game/player/player_info.h" +#include "../../../game/zones/pile_zone_logic.h" +#include "../../../interface/widgets/menus/tearoff_menu.h" #include "../../game/player/player_actions.h" #include "../../game/player/player_logic.h" #include "../player_graphics_item.h" +#include +#include +#include +#include +#include #include +class QWidget; + RfgMenu::RfgMenu(PlayerGraphicsItem *_player, QWidget *parent) : TearOffMenu(parent), player(_player) { createMoveActions(); diff --git a/cockatrice/src/game_graphics/player/menu/rfg_menu.h b/cockatrice/src/game_graphics/player/menu/rfg_menu.h index f5dd888e4..8e1460316 100644 --- a/cockatrice/src/game_graphics/player/menu/rfg_menu.h +++ b/cockatrice/src/game_graphics/player/menu/rfg_menu.h @@ -10,10 +10,13 @@ #include "../../../interface/widgets/menus/tearoff_menu.h" #include "abstract_player_component.h" -#include -#include +#include class PlayerGraphicsItem; +class QAction; +class QMenu; +class QWidget; + class RfgMenu : public TearOffMenu, public AbstractPlayerComponent { Q_OBJECT diff --git a/cockatrice/src/game_graphics/player/menu/say_menu.cpp b/cockatrice/src/game_graphics/player/menu/say_menu.cpp index e7b0b3826..d467c6469 100644 --- a/cockatrice/src/game_graphics/player/menu/say_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/say_menu.cpp @@ -5,7 +5,12 @@ #include "../../game/player/player_logic.h" #include "../player_graphics_item.h" +#include +#include +#include +#include #include + SayMenu::SayMenu(PlayerGraphicsItem *_player) : player(_player) { connect(&SettingsCache::instance().messages(), &MessageSettings::messageMacrosChanged, this, &SayMenu::initSayMenu); diff --git a/cockatrice/src/game_graphics/player/menu/say_menu.h b/cockatrice/src/game_graphics/player/menu/say_menu.h index 3ff160d05..72169d18f 100644 --- a/cockatrice/src/game_graphics/player/menu/say_menu.h +++ b/cockatrice/src/game_graphics/player/menu/say_menu.h @@ -10,8 +10,10 @@ #include "abstract_player_component.h" #include +#include class PlayerGraphicsItem; + class SayMenu : public QMenu, public AbstractPlayerComponent { Q_OBJECT diff --git a/cockatrice/src/game_graphics/player/menu/sideboard_menu.cpp b/cockatrice/src/game_graphics/player/menu/sideboard_menu.cpp index 6753f743e..1eef3604f 100644 --- a/cockatrice/src/game_graphics/player/menu/sideboard_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/sideboard_menu.cpp @@ -1,10 +1,15 @@ #include "sideboard_menu.h" +#include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" +#include "../../../game/player/player_info.h" #include "../../game/player/player_actions.h" #include "../../game/player/player_logic.h" #include "../player_graphics_item.h" +#include +#include + SideboardMenu::SideboardMenu(PlayerGraphicsItem *player, QMenu *playerMenu) : QMenu(playerMenu) { aViewSideboard = new QAction(this); diff --git a/cockatrice/src/game_graphics/player/menu/sideboard_menu.h b/cockatrice/src/game_graphics/player/menu/sideboard_menu.h index b3b547291..3e6501c90 100644 --- a/cockatrice/src/game_graphics/player/menu/sideboard_menu.h +++ b/cockatrice/src/game_graphics/player/menu/sideboard_menu.h @@ -10,8 +10,11 @@ #include "abstract_player_component.h" #include +#include class PlayerGraphicsItem; +class QAction; + class SideboardMenu : public QMenu, public AbstractPlayerComponent { Q_OBJECT diff --git a/cockatrice/src/game_graphics/player/menu/tally_menu.cpp b/cockatrice/src/game_graphics/player/menu/tally_menu.cpp index 7eb3945b3..7ae5b36ce 100644 --- a/cockatrice/src/game_graphics/player/menu/tally_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/tally_menu.cpp @@ -1,7 +1,10 @@ #include "tally_menu.h" #include "../../../client/settings/cache_settings.h" +#include "../../tally/tally.h" +#include "libcockatrice/settings/interface_settings.h" +#include #include TallyMenu::TallyMenu() @@ -12,11 +15,13 @@ TallyMenu::TallyMenu() aTallyNone = createTallyAction(TallyType::None); aTallySubtypes = createTallyAction(TallyType::Subtypes); aTallyTotalPower = createTallyAction(TallyType::TotalPower); + aTallyTotalToughness = createTallyAction(TallyType::TotalToughness); addAction(aTallyNone); addSeparator(); addAction(aTallySubtypes); addAction(aTallyTotalPower); + addAction(aTallyTotalToughness); retranslateUi(); } @@ -54,4 +59,5 @@ void TallyMenu::retranslateUi() aTallyNone->setText(tr("None")); aTallySubtypes->setText(tr("Subtypes")); aTallyTotalPower->setText(tr("Total Power")); + aTallyTotalToughness->setText(tr("Total Toughness")); } diff --git a/cockatrice/src/game_graphics/player/menu/tally_menu.h b/cockatrice/src/game_graphics/player/menu/tally_menu.h index acd1daf67..75bd41ac0 100644 --- a/cockatrice/src/game_graphics/player/menu/tally_menu.h +++ b/cockatrice/src/game_graphics/player/menu/tally_menu.h @@ -2,10 +2,13 @@ #define COCKATRICE_TALLY_MENU_H #include "../../../interface/widgets/menus/tearoff_menu.h" -#include "../../tally/tally.h" #include "abstract_player_component.h" -#include +#include + +class QAction; +class QActionGroup; +enum class TallyType; class TallyMenu : public TearOffMenu, public AbstractPlayerComponent { @@ -24,6 +27,7 @@ private: QAction *aTallyNone = nullptr; QAction *aTallySubtypes = nullptr; QAction *aTallyTotalPower = nullptr; + QAction *aTallyTotalToughness = nullptr; QAction *createTallyAction(TallyType tallyType); }; diff --git a/cockatrice/src/game_graphics/player/menu/utility_menu.cpp b/cockatrice/src/game_graphics/player/menu/utility_menu.cpp index a7ea38d9a..7c18b3f4c 100644 --- a/cockatrice/src/game_graphics/player/menu/utility_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/utility_menu.cpp @@ -1,12 +1,17 @@ #include "utility_menu.h" +#include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../../../interface/deck_loader/deck_loader.h" +#include "../../../game/player/player_info.h" #include "../../game/player/player_actions.h" #include "../../game/player/player_logic.h" +#include "../../game_scene.h" #include "../player_graphics_item.h" -#include "player_menu.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/deck_list/deck_list.h" +#include +#include #include #include diff --git a/cockatrice/src/game_graphics/player/menu/utility_menu.h b/cockatrice/src/game_graphics/player/menu/utility_menu.h index bdc2a81a5..009e4aca3 100644 --- a/cockatrice/src/game_graphics/player/menu/utility_menu.h +++ b/cockatrice/src/game_graphics/player/menu/utility_menu.h @@ -9,10 +9,16 @@ #include "abstract_player_component.h" +#include +#include #include +#include +#include #include +#include class PlayerGraphicsItem; + class UtilityMenu : public QMenu, public AbstractPlayerComponent { Q_OBJECT diff --git a/cockatrice/src/game_graphics/player/player_area.cpp b/cockatrice/src/game_graphics/player/player_area.cpp index 86b8c2d1b..2af642eb5 100644 --- a/cockatrice/src/game_graphics/player/player_area.cpp +++ b/cockatrice/src/game_graphics/player/player_area.cpp @@ -2,6 +2,7 @@ #include "../../interface/theme_manager.h" +#include #include PlayerArea::PlayerArea(QGraphicsItem *parentItem) : QObject(), QGraphicsItem(parentItem) diff --git a/cockatrice/src/game_graphics/player/player_area.h b/cockatrice/src/game_graphics/player/player_area.h index d73547f81..7370a78be 100644 --- a/cockatrice/src/game_graphics/player/player_area.h +++ b/cockatrice/src/game_graphics/player/player_area.h @@ -8,7 +8,14 @@ #define COCKATRICE_PLAYER_AREA_H #include "../board/graphics_item_type.h" -#include "QGraphicsItem" + +#include +#include +#include +#include +#include + +class QGraphicsItem; /** * The entire graphical area belonging to a single player. diff --git a/cockatrice/src/game_graphics/player/player_dialogs.cpp b/cockatrice/src/game_graphics/player/player_dialogs.cpp index 95c225812..74f5de601 100644 --- a/cockatrice/src/game_graphics/player/player_dialogs.cpp +++ b/cockatrice/src/game_graphics/player/player_dialogs.cpp @@ -1,12 +1,21 @@ #include "player_dialogs.h" +#include "../../client/settings/cache_settings.h" #include "../../client/settings/card_counter_settings.h" +#include "../../game/player/player_actions.h" +#include "../../game/player/player_logic.h" #include "../../interface/widgets/utility/get_text_with_max.h" +#include "../board/abstract_counter.h" #include "../board/card_item.h" +#include "../dialogs/dlg_create_token.h" +#include "../dialogs/dlg_move_top_cards_until.h" #include "../dialogs/dlg_roll_dice.h" +#include "../game_scene.h" #include "../player/player_graphics_item.h" +#include #include +#include #include #include diff --git a/cockatrice/src/game_graphics/player/player_dialogs.h b/cockatrice/src/game_graphics/player/player_dialogs.h index f87704f2d..86d7282a2 100644 --- a/cockatrice/src/game_graphics/player/player_dialogs.h +++ b/cockatrice/src/game_graphics/player/player_dialogs.h @@ -1,12 +1,26 @@ #ifndef COCKATRICE_PLAYER_DIALOGS_H #define COCKATRICE_PLAYER_DIALOGS_H -#include "../../game/player/player_actions.h" #include "player_graphics_item.h" -#include -#include +// IWYU pragma: keep +// MoveTopCardsUntilOptions is passed by value to a slot, so the moc-generated +// code needs the complete type. +#include "../dialogs/dlg_move_top_cards_until.h" + +#include +#include +#include +#include +#include +#include +#include + +class CardItem; +class CardRelation; +class PlayerActions; +class QWidget; +struct MoveTopCardsUntilOptions; -class PlayerGraphicsItem; class PlayerDialogs : public QObject { diff --git a/cockatrice/src/game_graphics/player/player_graphics_item.cpp b/cockatrice/src/game_graphics/player/player_graphics_item.cpp index d443853ce..419816087 100644 --- a/cockatrice/src/game_graphics/player/player_graphics_item.cpp +++ b/cockatrice/src/game_graphics/player/player_graphics_item.cpp @@ -1,19 +1,53 @@ #include "player_graphics_item.h" +#include "../../client/settings/cache_settings.h" +#include "../../game/board/counter_state.h" #include "../../game/player/player_actions.h" -#include "../../interface/widgets/tabs/tab_game.h" -#include "../board/abstract_card_item.h" +#include "../../game/player/player_info.h" +#include "../../game/player/player_logic.h" +#include "../../game/zones/card_zone_logic.h" +#include "../../game/zones/hand_zone_logic.h" +#include "../../game/zones/pile_zone_logic.h" +#include "../../game/zones/stack_zone_logic.h" +#include "../../game/zones/table_zone_logic.h" +#include "../../interface/card_picture_loader/card_picture_loader.h" +#include "../../interface/widgets/cards/art_crop_attribution.h" +#include "../../interface/widgets/cards/card_art_utils.h" +#include "../../interface/widgets/playmat/playmat_utils.h" +#include "../board/abstract_counter.h" #include "../board/counter_general.h" +#include "../card_dimensions.h" #include "../hand_counter.h" #include "../zones/hand_zone.h" #include "../zones/pile_zone.h" #include "../zones/stack_zone.h" #include "../zones/table_zone.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/interfaces/interface_interface_settings_provider.h" +#include "libcockatrice/utility/card_ref.h" #include "menu/player_menu.h" +#include "player_area.h" #include "player_dialogs.h" +#include "player_target.h" -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include +#include +#include PlayerGraphicsItem::PlayerGraphicsItem(PlayerLogic *_player) : player(_player) { @@ -28,6 +62,10 @@ PlayerGraphicsItem::PlayerGraphicsItem(PlayerLogic *_player) : player(_player) connect(player, &PlayerLogic::counterAdded, this, &PlayerGraphicsItem::onCounterAdded); connect(player, &PlayerLogic::counterRemoved, this, &PlayerGraphicsItem::onCounterRemoved); + connect(player, &PlayerLogic::deckChanged, this, &PlayerGraphicsItem::updatePlaymat); + connect(player, &PlayerLogic::playmatChanged, this, &PlayerGraphicsItem::updatePlaymat); + connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::playmatVisibilityChanged, this, + [this](int) { updatePlaymat(); }); playerMenu = new PlayerMenu(this); @@ -67,6 +105,9 @@ PlayerGraphicsItem::PlayerGraphicsItem(PlayerLogic *_player) : player(_player) connect(tableZoneGraphicsItem, &TableZone::sizeChanged, this, &PlayerGraphicsItem::updateBoundingRect); + connect(this, &PlayerGraphicsItem::playmatChanged, tableZoneGraphicsItem, &TableZone::onPlaymatChanged); + connect(this, &PlayerGraphicsItem::playmatChanged, stackZoneGraphicsItem, &StackZone::onPlaymatChanged); + updateBoundingRect(); rearrangeZones(); @@ -112,7 +153,6 @@ void PlayerGraphicsItem::initializeZones() rfgZoneGraphicsItem->setPos(base + QPointF(0, 2 * h + h2 + 10)); tableZoneGraphicsItem = new TableZone(player->getTableZone(), mirrored, this); - connect(tableZoneGraphicsItem, &TableZone::sizeChanged, this, &PlayerGraphicsItem::updateBoundingRect); connect(this, &PlayerGraphicsItem::mirroredChanged, tableZoneGraphicsItem, &TableZone::setMirrored); stackZoneGraphicsItem = @@ -155,10 +195,61 @@ qreal PlayerGraphicsItem::getMinimumWidth() const return result; } -void PlayerGraphicsItem::paint(QPainter * /*painter*/, - const QStyleOptionGraphicsItem * /*option*/, - QWidget * /*widget*/) +void PlayerGraphicsItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) { + if (!hasPlaymat || playmatPixmap.isNull()) { + return; + } + + // Calculate the combined bounding rect of stack + table zones + QPointF stackPos = stackZoneGraphicsItem->pos(); + QPointF tablePos = tableZoneGraphicsItem->pos(); + QSizeF stackSize = stackZoneGraphicsItem->boundingRect().size(); + QSizeF tableSize = tableZoneGraphicsItem->boundingRect().size(); + + // Combined area: from stack left edge to table right edge + double combinedLeft = qMin(stackPos.x(), tablePos.x()); + double combinedTop = qMin(stackPos.y(), tablePos.y()); + double combinedRight = qMax(stackPos.x() + stackSize.width(), tablePos.x() + tableSize.width()); + double combinedBottom = qMax(stackPos.y() + stackSize.height(), tablePos.y() + tableSize.height()); + + QRectF combinedArea(combinedLeft, combinedTop, combinedRight - combinedLeft, combinedBottom - combinedTop); + + const QRectF srcRect = PlaymatUtils::computeArtSourceRect(playmatPixmap.size(), playmatParams); + const QRectF dstRect = PlaymatUtils::coverFitRect(combinedArea, srcRect.size()); + + painter->save(); + painter->setClipRect(combinedArea); + painter->setRenderHint(QPainter::SmoothPixmapTransform, true); + + // Render from a down-scaled copy of the art so the full-resolution source + // pixmap is never re-sampled at a tiny device size (also much cheaper than + // scaling it on every frame). + const QPixmap scaledPixmap = scaledPlaymatFor(srcRect, painter->worldTransform().mapRect(dstRect).size()); + painter->drawPixmap(dstRect, scaledPixmap, QRectF(scaledPixmap.rect())); + + painter->restore(); + + if (!playmatAttribution.isEmpty()) { + paintArtAttribution(*painter, combinedArea, playmatAttribution, Qt::AlignRight | Qt::AlignBottom, 0.8); + } +} + +QPixmap PlayerGraphicsItem::scaledPlaymatFor(const QRectF &srcRect, const QSizeF &deviceDstSize) +{ + // Bucket the render size so the source pixmap is re-scaled at most once per + // zoom step instead of once per frame. + constexpr int bucketSize = 32; + const QSize target = QSize(qMax(1, qRound(deviceDstSize.width() / bucketSize) * bucketSize), + qMax(1, qRound(deviceDstSize.height() / bucketSize) * bucketSize)) + .boundedTo(srcRect.toAlignedRect().size()); + + if (scaledPlaymatKey != target) { + const QPixmap crop = playmatPixmap.copy(srcRect.toAlignedRect()); + scaledPlaymatPixmap = crop.scaled(target, Qt::KeepAspectRatio, Qt::SmoothTransformation); + scaledPlaymatKey = target; + } + return scaledPlaymatPixmap; } void PlayerGraphicsItem::processSceneSizeChange(int newPlayerWidth) @@ -188,6 +279,11 @@ void PlayerGraphicsItem::onCounterAdded(CounterState *state) AbstractCounter *widget; if (state->getName() == "life") { widget = playerTarget->addCounter(state); + connect(state, &CounterState::valueChanged, this, [this](int oldValue, int newValue, bool skipDamageAnimation) { + if (newValue < oldValue && !skipDamageAnimation) { + tableZoneGraphicsItem->triggerDamageShimmer(); + } + }); } else { widget = new GeneralCounter(state, player, true, this); } @@ -298,3 +394,100 @@ void PlayerGraphicsItem::updateBoundingRect() emit sizeChanged(); } + +void PlayerGraphicsItem::updatePlaymat() +{ + int visibility = SettingsCache::instance().userInterface().getPlaymatVisibility(); + + // "Don't use playmats" — never show + if (visibility == PlaymatVisibilityNone) { + clearPlaymat(); + return; + } + + // "Show own playmat only" — hide playmats for remote players + if (visibility == PlaymatVisibilityOwnOnly && !player->getPlayerInfo()->getLocal()) { + clearPlaymat(); + return; + } + + CardRef playmatCard; + PlaymatParams params; + + if (player->getHasRemotePlaymat()) { + // Prefer the server-confirmed playmat (updated by Command_SetPlaymat). + playmatCard = player->getRemotePlaymatCard(); + params = player->getRemotePlaymatParams(); + } else if (player->getPlayerInfo()->getLocal()) { + // Local player without a server broadcast yet: apply the full + // settings-based resolution chain (mode, fallback list, behavior). + const auto &settings = SettingsCache::instance().userInterface(); + const PlaymatInfo resolved = resolvePlaymatForDeck( + player->getDeck(), settings.getPlaymatFallbackList(), static_cast(settings.getPlaymatMode()), + static_cast(settings.getPlaymatFallbackBehavior()), 0); + playmatCard = resolved.card; + params = resolved.params; + } else { + // Opponent without a server broadcast: use the deck-embedded playmat. + const DeckList &deck = player->getDeck(); + const PlaymatInfo &deckPlaymat = deck.getPlaymat(); + if (!deckPlaymat.card.isEmpty()) { + playmatCard = deckPlaymat.card; + params = deckPlaymat.params; + } + } + + if (playmatCard.isEmpty()) { + clearPlaymat(); + return; + } + + playmatParams = params; + scaledPlaymatKey = QSize(); // the art crop depends on the params, drop any cached scale + + ExactCard card = CardDatabaseManager::query()->getCard(playmatCard); + if (!card) { + clearPlaymat(); + return; + } + + playmatAttribution = buildArtAttribution(card); + + QPixmap fullRes; + CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040)); + + if (fullRes.isNull()) { + disconnect(playmatPixmapConnection); + CardInfo *cardInfo = card.getCardPtr().data(); + if (cardInfo) { + playmatPixmapConnection = + connect(cardInfo, &CardInfo::pixmapUpdated, this, &PlayerGraphicsItem::onPlaymatPixmapReady); + } + return; + } + + if (!hasPlaymat) { + hasPlaymat = true; + emit playmatChanged(true); + } + playmatPixmap = CardArtUtils::rotateSidewaysLayoutArt(fullRes, card); + update(); +} + +void PlayerGraphicsItem::clearPlaymat() +{ + disconnect(playmatPixmapConnection); + playmatAttribution.clear(); + if (hasPlaymat) { + hasPlaymat = false; + playmatPixmap = QPixmap(); + scaledPlaymatKey = QSize(); + emit playmatChanged(false); + update(); + } +} + +void PlayerGraphicsItem::onPlaymatPixmapReady() +{ + updatePlaymat(); +} diff --git a/cockatrice/src/game_graphics/player/player_graphics_item.h b/cockatrice/src/game_graphics/player/player_graphics_item.h index d02234ded..e1ca0a036 100644 --- a/cockatrice/src/game_graphics/player/player_graphics_item.h +++ b/cockatrice/src/game_graphics/player/player_graphics_item.h @@ -6,11 +6,20 @@ #ifndef COCKATRICE_PLAYER_GRAPHICS_ITEM_H #define COCKATRICE_PLAYER_GRAPHICS_ITEM_H -#include "../../game/player/player_logic.h" -#include "../board/abstract_counter.h" +#include "../board/graphics_item_type.h" #include "../game_scene.h" +#include "libcockatrice/utility/playmat_params.h" #include +#include +#include +#include +#include +#include +#include +#include +#include +#include class HandZone; class PileZone; @@ -19,7 +28,12 @@ class PlayerMenu; class PlayerTarget; class StackZone; class TableZone; -class ZoneViewZone; +class AbstractCounter; +class CardZone; +class CounterState; +class PlayerArea; +class PlayerLogic; +struct CardRef; class PlayerGraphicsItem : public QGraphicsObject { @@ -126,6 +140,7 @@ signals: void playerCountChanged(); void mirroredChanged(bool isMirrored); void cardInfoRequested(const CardRef &cardRef); + void playmatChanged(bool hasPlaymat); private: PlayerLogic *player; @@ -146,9 +161,23 @@ private: bool mirrored; bool handVisible = false; + QPixmap playmatPixmap; + QPixmap scaledPlaymatPixmap; // down-scaled copy of playmatPixmap for the current render size + QSize scaledPlaymatKey; // size bucket scaledPlaymatPixmap was rendered for + PlaymatParams playmatParams; + QString playmatAttribution; + bool hasPlaymat = false; + QMetaObject::Connection playmatPixmapConnection; + private slots: void updateBoundingRect(); void rearrangeZones(); + void clearPlaymat(); + void updatePlaymat(); + void onPlaymatPixmapReady(); + +private: + QPixmap scaledPlaymatFor(const QRectF &srcRect, const QSizeF &deviceDstSize); }; #endif // COCKATRICE_PLAYER_GRAPHICS_ITEM_H diff --git a/cockatrice/src/game_graphics/player/player_list_widget.cpp b/cockatrice/src/game_graphics/player/player_list_widget.cpp index 4268e1019..7b358010f 100644 --- a/cockatrice/src/game_graphics/player/player_list_widget.cpp +++ b/cockatrice/src/game_graphics/player/player_list_widget.cpp @@ -2,16 +2,30 @@ #include "../../interface/pixel_map_generator.h" #include "../../interface/widgets/server/user/user_context_menu.h" -#include "../../interface/widgets/server/user/user_list_manager.h" -#include "../../interface/widgets/server/user/user_list_widget.h" -#include "../../interface/widgets/tabs/tab_game.h" -#include "../../interface/widgets/tabs/tab_supervisor.h" +#include "libcockatrice/protocol/pb/serverinfo_user.pb.h" +#include "user_level.h" -#include +#include +#include +#include +#include +#include +#include +#include #include -#include +#include +#include +#include +#include +#include +#include #include -#include +#include + +class QModelIndex; +class QObject; +class QPoint; +class QWidget; PlayerListItemDelegate::PlayerListItemDelegate(QObject *const parent) : QStyledItemDelegate(parent) { @@ -53,13 +67,13 @@ PlayerListWidget::PlayerListWidget(TabSupervisor *_tabSupervisor, QWidget *parent) : QTreeWidget(parent), tabSupervisor(_tabSupervisor), client(_client), game(_game), gameStarted(false) { - readyIcon = QPixmap("theme:icons/ready_start"); - notReadyIcon = QPixmap("theme:icons/not_ready_start"); - concededIcon = QPixmap("theme:icons/conceded"); + 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 = QPixmap("theme:icons/lock"); + lockIcon = themePixmap(QStringLiteral("icons/lock")); if (tabSupervisor) { itemDelegate = new PlayerListItemDelegate(this); @@ -92,6 +106,11 @@ void PlayerListWidget::retranslateUi() void PlayerListWidget::addPlayer(const ServerInfo_PlayerProperties &player) { + if (players.contains(player.player_id())) { + updatePlayerProperties(player); + return; + } + QTreeWidgetItem *newPlayer = new PlayerListTWI; players.insert(player.player_id(), newPlayer); updatePlayerProperties(player); @@ -176,6 +195,17 @@ void PlayerListWidget::removePlayer(int playerId) delete takeTopLevelItem(indexOfTopLevelItem(player)); } +void PlayerListWidget::clearSpectators() +{ + const QList playerIds = players.keys(); + for (int playerId : playerIds) { + QTreeWidgetItem *player = players.value(playerId, 0); + if (player && !player->data(1, Qt::UserRole).toBool()) { + removePlayer(playerId); + } + } +} + void PlayerListWidget::setActivePlayer(int playerId) { QMapIterator i(players); diff --git a/cockatrice/src/game_graphics/player/player_list_widget.h b/cockatrice/src/game_graphics/player/player_list_widget.h index a53cfa989..b070fe312 100644 --- a/cockatrice/src/game_graphics/player/player_list_widget.h +++ b/cockatrice/src/game_graphics/player/player_list_widget.h @@ -7,18 +7,23 @@ #ifndef PLAYERLISTWIDGET_H #define PLAYERLISTWIDGET_H -#include "../../game/player/player_logic.h" - #include #include +#include #include #include +#include +#include class ServerInfo_PlayerProperties; class TabSupervisor; class AbstractClient; class AbstractGame; class UserContextMenu; +class QModelIndex; +class QObject; +class QPoint; +class QWidget; class PlayerListItemDelegate : public QStyledItemDelegate { @@ -66,6 +71,7 @@ public slots: void addPlayer(const ServerInfo_PlayerProperties &player); void removePlayer(int playerId); void updatePlayerProperties(const ServerInfo_PlayerProperties &prop, int playerId = -1); + void clearSpectators(); }; #endif diff --git a/cockatrice/src/game_graphics/player/player_target.cpp b/cockatrice/src/game_graphics/player/player_target.cpp index 567f3d44d..24734cd42 100644 --- a/cockatrice/src/game_graphics/player/player_target.cpp +++ b/cockatrice/src/game_graphics/player/player_target.cpp @@ -1,13 +1,43 @@ #include "player_target.h" +#include "../../client/settings/cache_settings.h" +#include "../../game/player/player_info.h" #include "../../game/player/player_logic.h" #include "../../interface/pixel_map_generator.h" +#include "../board/abstract_counter.h" +#include "../board/abstract_graphics_item.h" +#include "../board/arrow_target.h" +#include "../game_scene.h" +#include "libcockatrice/settings/interface_settings.h" +#include "user_level.h" -#include +#include +#include +#include +#include +#include +#include #include +#include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include #include #include +#include +#include +#include +#include + +class CounterState; +class QGraphicsItem; PlayerCounter::PlayerCounter(CounterState *state, PlayerLogic *player, QGraphicsItem *parent) : AbstractCounter(state, player, false, false, parent) @@ -21,17 +51,24 @@ QRectF PlayerCounter::boundingRect() const void PlayerCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/) { - const int radius = 8; - const qreal border = 1; - QPainterPath path(QPointF(50 - border / 2, border / 2)); - path.lineTo(radius, border / 2); - path.arcTo(border / 2, border / 2, 2 * radius, 2 * radius, 90, 90); - path.lineTo(border / 2, 30 - border / 2); - path.lineTo(50 - border / 2, 30 - border / 2); - path.closeSubpath(); + const int radius = 15; + const qreal border = 1.5; + // The box is drawn with a border-wide stroke straddling the path, so the + // visible outline spans [inset, inset + border]. Fills that must not cover + // the outline (e.g. the life-change flash) use a path inset by `border`. + const auto makePath = [](qreal inset) { + QPainterPath path(QPointF(50 - inset, inset)); + path.lineTo(radius, inset); + path.arcTo(inset, inset, 2 * radius, 2 * radius, 90, 90); + path.lineTo(inset, 30 - inset); + path.lineTo(50 - inset, 30 - inset); + path.closeSubpath(); + return path; + }; + QPainterPath path = makePath(border / 2); QPen pen(QColor(100, 100, 100)); - pen.setWidth(border); + pen.setWidthF(border); painter->setPen(pen); painter->setBrush(hovered ? QColor(50, 50, 50, 160) : QColor(0, 0, 0, 160)); @@ -45,6 +82,53 @@ void PlayerCounter::paint(QPainter *painter, const QStyleOptionGraphicsItem * /* painter->setFont(font); painter->setPen(Qt::white); painter->drawText(translatedRect, Qt::AlignCenter, QString::number(value)); + + // Life-change flash: emerald on gain, red on loss, decaying over a few ticks. + if (flashAlpha > 0) { + painter->save(); + QColor flashColor = flashDelta > 0 ? QColor(52, 224, 122) : QColor(239, 68, 68); + flashColor.setAlphaF(0.45 * flashAlpha); + painter->setPen(Qt::NoPen); + painter->setBrush(flashColor); + painter->setOpacity(0.85); + painter->drawPath(makePath(border)); + painter->restore(); + } +} + +void PlayerCounter::onValueChanged(int oldValue, int newValue, bool skipDamageAnimation) +{ + flashDelta = newValue - oldValue; + if (flashDelta == 0) { + return; + } + + if (!SettingsCache::instance().userInterface().getLifeCounterAnimationsEnabled()) { + flashAlpha = 0.0; + return; + } + + if (skipDamageAnimation) { + flashAlpha = 0.0; + return; + } + + flashAlpha = 1.0; + flashClock.start(); + if (scene()) { + static_cast(scene())->registerAnimationItem(this); + } +} + +bool PlayerCounter::animationEvent() +{ + flashAlpha = 1.0 - flashClock.elapsed() / flashDurationMs; + if (flashAlpha <= 0.0) { + flashAlpha = 0.0; + return false; + } + update(); + return true; } PlayerTarget::PlayerTarget(PlayerLogic *_owner, QGraphicsItem *parentItem) @@ -80,8 +164,18 @@ void PlayerTarget::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*o QRectF translatedRect = painter->combinedTransform().mapRect(avatarBoundingRect); QSize translatedSize = translatedRect.size().toSize(); QPixmap cachedPixmap; + // The key must cover everything the generated pawn depends on: the rendered + // size, the user level, and the pixmap being drawn. fullPixmap.cacheKey() is + // 0 for every null pixmap, so the default-pawn branch additionally needs the + // pawn's privlevel (lowercased, matching UserLevelPixmapGenerator) and colors + // in the key — otherwise two players without a custom avatar (and the same + // user level) would share one cached pawn. const QString cacheKey = "avatar" + QString::number(translatedSize.width()) + "_" + - QString::number(info->user_level()) + "_" + QString::number(fullPixmap.cacheKey()); + QString::number(translatedSize.height()) + "_" + QString::number(info->user_level()) + + "_" + QString::number(fullPixmap.cacheKey()) + "_" + + QString::fromStdString(info->privlevel()).toLower() + "_" + + QString::fromStdString(info->pawn_colors().left_side()) + "_" + + QString::fromStdString(info->pawn_colors().right_side()); if (!QPixmapCache::find(cacheKey, &cachedPixmap)) { cachedPixmap = QPixmap(translatedSize.width(), translatedSize.height()); diff --git a/cockatrice/src/game_graphics/player/player_target.h b/cockatrice/src/game_graphics/player/player_target.h index 67e155660..7780104d2 100644 --- a/cockatrice/src/game_graphics/player/player_target.h +++ b/cockatrice/src/game_graphics/player/player_target.h @@ -7,21 +7,38 @@ #ifndef PLAYERTARGET_H #define PLAYERTARGET_H +#include "../animated_item.h" #include "../board/abstract_counter.h" #include "../board/arrow_target.h" #include "../board/graphics_item_type.h" +#include #include +#include +#include class PlayerLogic; +class CounterState; +class QGraphicsItem; -class PlayerCounter : public AbstractCounter +class PlayerCounter : public AbstractCounter, public IAnimatedItem { Q_OBJECT +protected: + void onValueChanged(int oldValue, int newValue, bool skipDamageAnimation) override; + +private: + static constexpr qreal flashDurationMs = 450.0; + + QElapsedTimer flashClock; + qreal flashAlpha = 0.0; + int flashDelta = 0; + public: PlayerCounter(CounterState *state, PlayerLogic *player, QGraphicsItem *parent); QRectF boundingRect() const override; void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; + bool animationEvent() override; }; class PlayerTarget : public ArrowTarget diff --git a/cockatrice/src/game_graphics/tally/stats_tally.cpp b/cockatrice/src/game_graphics/tally/stats_tally.cpp index e7a6621fa..28e280c02 100644 --- a/cockatrice/src/game_graphics/tally/stats_tally.cpp +++ b/cockatrice/src/game_graphics/tally/stats_tally.cpp @@ -4,6 +4,11 @@ #include #include +#include +#include +#include +#include +#include #include static int sumPowers(const QList &cards) @@ -34,3 +39,31 @@ QList StatsTally::computeTotalPower(const QList &cards) QString name = QCoreApplication::translate("StatsTally", "Total Power"); return {TallyRow{name, QString::number(total)}}; } + +static int sumToughness(const QList &cards) +{ + int total = 0; + for (auto card : cards) { + QVariantList parsed = CardItem::parsePT(card->getPT()); + if (parsed.size() == 2) { + int toughness = parsed.at(1).toInt(); // toInt will default to 0 if it's not an int + total += qMax(toughness, 0); + } + } + return total; +} + +QList StatsTally::computeTotalToughness(const QList &cards) +{ + // don't bother if none of the cards have pt + bool hasPT = + std::any_of(cards.cbegin(), cards.cend(), [](const CardItem *card) { return !card->getPT().isEmpty(); }); + if (!hasPT) { + return {}; + } + + int total = sumToughness(cards); + + QString name = QCoreApplication::translate("StatsTally", "Total Toughness"); + return {TallyRow{name, QString::number(total)}}; +} diff --git a/cockatrice/src/game_graphics/tally/stats_tally.h b/cockatrice/src/game_graphics/tally/stats_tally.h index 4c3d93b56..cb0bdf81b 100644 --- a/cockatrice/src/game_graphics/tally/stats_tally.h +++ b/cockatrice/src/game_graphics/tally/stats_tally.h @@ -2,6 +2,10 @@ #define COCKATRICE_STATS_TALLY_H #include "tally.h" +#include + +class CardItem; + /** * @brief Extracts and tallies stats from selected cards. */ @@ -16,6 +20,14 @@ namespace StatsTally */ QList computeTotalPower(const QList &cards); +/** + * @brief Sums the toughness of all selected cards + * + * @param cards The list of selected card items to analyze. + * @return A single row containing the total, or an empty list if none of the cards have pt + */ +QList computeTotalToughness(const QList &cards); + } // namespace StatsTally #endif // COCKATRICE_STATS_TALLY_H diff --git a/cockatrice/src/game_graphics/tally/subtype_tally.cpp b/cockatrice/src/game_graphics/tally/subtype_tally.cpp index 2241eb3b2..ccd727405 100644 --- a/cockatrice/src/game_graphics/tally/subtype_tally.cpp +++ b/cockatrice/src/game_graphics/tally/subtype_tally.cpp @@ -1,9 +1,18 @@ #include "subtype_tally.h" #include "../board/card_item.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/printing/exact_card.h" #include +#include +#include +#include +#include #include +#include +#include +#include namespace { diff --git a/cockatrice/src/game_graphics/tally/subtype_tally.h b/cockatrice/src/game_graphics/tally/subtype_tally.h index 9926528e0..019a797d4 100644 --- a/cockatrice/src/game_graphics/tally/subtype_tally.h +++ b/cockatrice/src/game_graphics/tally/subtype_tally.h @@ -4,7 +4,6 @@ #include "tally.h" #include -#include class CardItem; diff --git a/cockatrice/src/game_graphics/tally/tally.cpp b/cockatrice/src/game_graphics/tally/tally.cpp index aa2cae024..21806ee84 100644 --- a/cockatrice/src/game_graphics/tally/tally.cpp +++ b/cockatrice/src/game_graphics/tally/tally.cpp @@ -21,6 +21,8 @@ QList Tally::compute(const QList &cards, const TallyType t return SubtypeTally::countSubtypes(cards); case TallyType::TotalPower: return StatsTally::computeTotalPower(cards); + case TallyType::TotalToughness: + return StatsTally::computeTotalToughness(cards); } return {}; } diff --git a/cockatrice/src/game_graphics/tally/tally.h b/cockatrice/src/game_graphics/tally/tally.h index 97406cddb..5699758e7 100644 --- a/cockatrice/src/game_graphics/tally/tally.h +++ b/cockatrice/src/game_graphics/tally/tally.h @@ -1,5 +1,6 @@ #ifndef COCKATRICE_TALLY_H #define COCKATRICE_TALLY_H +#include #include class CardItem; @@ -21,7 +22,8 @@ enum class TallyType None, Subtypes, TotalPower, - MaxValue = TotalPower // sentinel value + TotalToughness, + MaxValue = TotalToughness // sentinel value }; namespace Tally diff --git a/cockatrice/src/game_graphics/zones/card_zone.cpp b/cockatrice/src/game_graphics/zones/card_zone.cpp index 3457b681e..d09bb588c 100644 --- a/cockatrice/src/game_graphics/zones/card_zone.cpp +++ b/cockatrice/src/game_graphics/zones/card_zone.cpp @@ -1,10 +1,18 @@ #include "card_zone.h" +#include "../../game/board/card_list.h" +#include "../../game/zones/card_zone_logic.h" +#include "../board/abstract_graphics_item.h" #include "../board/card_item.h" -#include "view_zone.h" +#include #include #include +#include + +class QGraphicsItem; +class QPoint; +class QPointF; CardZone::CardZone(CardZoneLogic *_logic, QGraphicsItem *parent) : AbstractGraphicsItem(parent), menu(nullptr), doubleClickAction(0), logic(_logic) @@ -40,7 +48,7 @@ void CardZone::mouseDoubleClickEvent(QGraphicsSceneMouseEvent * /*event*/) bool CardZone::showContextMenu(const QPoint &screenPos) { if (menu) { - menu->exec(screenPos); + menu->popup(screenPos); return true; } return false; diff --git a/cockatrice/src/game_graphics/zones/card_zone.h b/cockatrice/src/game_graphics/zones/card_zone.h index 4cef6ca80..def3eee14 100644 --- a/cockatrice/src/game_graphics/zones/card_zone.h +++ b/cockatrice/src/game_graphics/zones/card_zone.h @@ -7,12 +7,21 @@ #ifndef CARDZONE_H #define CARDZONE_H -#include "../../game/zones/card_zone_logic.h" #include "../board/abstract_graphics_item.h" #include "../board/graphics_item_type.h" #include -#include +#include +#include + +class CardDragItem; +class CardItem; +class CardZoneLogic; +class QAction; +class QGraphicsItem; +class QMenu; +class QPoint; +template class QList; inline Q_LOGGING_CATEGORY(CardZoneLog, "card_zone"); diff --git a/cockatrice/src/game_graphics/zones/hand_zone.cpp b/cockatrice/src/game_graphics/zones/hand_zone.cpp index b52a4955a..3e527c081 100644 --- a/cockatrice/src/game_graphics/zones/hand_zone.cpp +++ b/cockatrice/src/game_graphics/zones/hand_zone.cpp @@ -2,14 +2,27 @@ #include "../../client/settings/cache_settings.h" #include "../../game/player/player_actions.h" +#include "../../game/player/player_info.h" #include "../../game/player/player_logic.h" +#include "../../game/zones/card_zone_logic.h" +#include "../../game/zones/hand_zone_logic.h" #include "../../interface/theme_manager.h" #include "../board/card_drag_item.h" #include "../board/card_item.h" +#include "../card_dimensions.h" +#include +#include #include +#include +#include +#include +#include #include #include +#include + +class QGraphicsItem; HandZone::HandZone(HandZoneLogic *_logic, int _zoneHeight, QGraphicsItem *parent) : SelectZone(_logic, parent), zoneHeight(_zoneHeight) @@ -41,7 +54,8 @@ void HandZone::handleDropEvent(const QList &dragItems, } } } else { - x = calcDropIndexFromY(dropPoint.y()); + bool sameZone = startZone == getLogic(); + x = calcDropIndexFromY(dropPoint.y(), !sameZone); } Command_MoveCard cmd; diff --git a/cockatrice/src/game_graphics/zones/hand_zone.h b/cockatrice/src/game_graphics/zones/hand_zone.h index c3f3f252b..5c91d684d 100644 --- a/cockatrice/src/game_graphics/zones/hand_zone.h +++ b/cockatrice/src/game_graphics/zones/hand_zone.h @@ -7,9 +7,16 @@ #ifndef HANDZONE_H #define HANDZONE_H -#include "../../game/zones/hand_zone_logic.h" +#include "../../game/board/card_list.h" #include "select_zone.h" +#include +#include + +class HandZoneLogic; +class QGraphicsItem; +template class QList; + class HandZone : public SelectZone { Q_OBJECT diff --git a/cockatrice/src/game_graphics/zones/pile_zone.cpp b/cockatrice/src/game_graphics/zones/pile_zone.cpp index abdf262f4..77a87a4af 100644 --- a/cockatrice/src/game_graphics/zones/pile_zone.cpp +++ b/cockatrice/src/game_graphics/zones/pile_zone.cpp @@ -1,18 +1,33 @@ #include "pile_zone.h" #include "../../client/settings/cache_settings.h" +#include "../../game/board/card_list.h" #include "../../game/player/player_actions.h" +#include "../../game/player/player_info.h" #include "../../game/player/player_logic.h" +#include "../../game/zones/card_zone_logic.h" #include "../../game/zones/pile_zone_logic.h" #include "../board/card_drag_item.h" #include "../board/card_item.h" -#include "view_zone.h" +#include "../card_dimensions.h" #include +#include +#include #include +#include #include +#include +#include +#include +#include +#include +#include +#include #include #include +#include +#include PileZone::PileZone(PileZoneLogic *_logic, QGraphicsItem *parent) : CardZone(_logic, parent) { diff --git a/cockatrice/src/game_graphics/zones/pile_zone.h b/cockatrice/src/game_graphics/zones/pile_zone.h index 099bc6f60..b2d8d0cd2 100644 --- a/cockatrice/src/game_graphics/zones/pile_zone.h +++ b/cockatrice/src/game_graphics/zones/pile_zone.h @@ -7,9 +7,13 @@ #ifndef PILEZONE_H #define PILEZONE_H -#include "../../game/zones/pile_zone_logic.h" #include "card_zone.h" +#include + +class PileZoneLogic; +class QGraphicsItem; + /** * A CardZone where the cards are in a single pile instead of being laid out. * Usually only top card is accessible by clicking. diff --git a/cockatrice/src/game_graphics/zones/select_zone.cpp b/cockatrice/src/game_graphics/zones/select_zone.cpp index c58c41b92..d69ec32f5 100644 --- a/cockatrice/src/game_graphics/zones/select_zone.cpp +++ b/cockatrice/src/game_graphics/zones/select_zone.cpp @@ -1,13 +1,25 @@ #include "select_zone.h" #include "../../client/settings/cache_settings.h" +#include "../../game/board/card_list.h" +#include "../../game/zones/card_zone_logic.h" #include "../board/card_item.h" #include "../game_scene.h" +#include +#include #include +#include #include +#include +#include +#include +#include +#include #include #include +#include +#include static qreal stackingOffset(qreal cardHeight) { @@ -83,7 +95,7 @@ SelectZone::StackLayoutParams SelectZone::buildStackParams(qreal minOffset) cons return {cardCount, boundingRect().height(), cardHeight, offset, minOffset}; } -int SelectZone::calcDropIndexFromY(qreal dropY, qreal minOffset) const +int SelectZone::calcDropIndexFromY(qreal dropY, bool allowCountExpand, qreal minOffset) const { const auto &cards = getLogic()->getCards(); if (cards.isEmpty()) { @@ -94,7 +106,8 @@ int SelectZone::calcDropIndexFromY(qreal dropY, qreal minOffset) const if (effectiveOffset <= 0.0) { return 0; } - return qBound(0, qRound((dropY - start) / effectiveOffset), params.cardCount - 1); + int max = allowCountExpand ? params.cardCount : params.cardCount - 1; + return qBound(0, qRound((dropY - start) / effectiveOffset), max); } void SelectZone::restoreStaleEscapedCards() diff --git a/cockatrice/src/game_graphics/zones/select_zone.h b/cockatrice/src/game_graphics/zones/select_zone.h index 7408f29b6..8a56949e3 100644 --- a/cockatrice/src/game_graphics/zones/select_zone.h +++ b/cockatrice/src/game_graphics/zones/select_zone.h @@ -9,10 +9,16 @@ #include "card_zone.h" +#include #include +#include #include +#include class QGraphicsRectItem; +class CardItem; +class CardZoneLogic; +class QGraphicsItem; /** * A CardZone where the cards are laid out, with each card directly interactable by clicking. @@ -104,8 +110,12 @@ protected: /** * @brief Computes the card index at a given y-coordinate within the zone's vertical layout. * Returns 0 if the zone has no cards or the offset is zero. + * + * @param dropY The y-coordinate that the card was dropped at + * @param allowCountExpand If false, clamps the index at the number of cards minus 1 + * @param minOffset Minimum offset to preserve */ - int calcDropIndexFromY(qreal dropY, qreal minOffset = 0.0) const; + int calcDropIndexFromY(qreal dropY, bool allowCountExpand, qreal minOffset = 0.0) const; /** * @brief Positions cards vertically with alternating left/right x-offsets. diff --git a/cockatrice/src/game_graphics/zones/stack_zone.cpp b/cockatrice/src/game_graphics/zones/stack_zone.cpp index 184f96d62..de1ff8ba9 100644 --- a/cockatrice/src/game_graphics/zones/stack_zone.cpp +++ b/cockatrice/src/game_graphics/zones/stack_zone.cpp @@ -1,15 +1,27 @@ #include "stack_zone.h" +#include "../../game/board/card_list.h" #include "../../game/player/player_actions.h" +#include "../../game/player/player_info.h" #include "../../game/player/player_logic.h" +#include "../../game/zones/card_zone_logic.h" #include "../../game/zones/stack_zone_logic.h" #include "../../interface/theme_manager.h" #include "../board/card_drag_item.h" #include "../board/card_item.h" #include "../card_dimensions.h" +#include +#include +#include +#include #include +#include +#include +#include #include +#include +#include StackZone::StackZone(StackZoneLogic *_logic, int _zoneHeight, QGraphicsItem *parent) : SelectZone(_logic, parent), zoneHeight(_zoneHeight) @@ -31,8 +43,22 @@ QRectF StackZone::boundingRect() const void StackZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/) { - QBrush brush = themeManager->getExtraBgBrush(ThemeManager::Stack, getLogic()->getPlayer()->getZoneId()); - painter->fillRect(boundingRect(), brush); + if (playmatActive) { + // Subtle overlay to distinguish stack zone from table zone (slightly darker) + painter->fillRect(boundingRect(), QColor(0, 0, 0, 80)); + } else { + QBrush brush = themeManager->getExtraBgBrush(ThemeManager::Stack, getLogic()->getPlayer()->getZoneId()); + painter->fillRect(boundingRect(), brush); + } +} + +void StackZone::onPlaymatChanged(bool active) +{ + playmatActive = active; + // See TableZone::onPlaymatChanged for the rationale. Translucent overlay + // over a dynamic playmat should not be held in the device cache. + setCacheMode(active ? QGraphicsItem::NoCache : QGraphicsItem::DeviceCoordinateCache); + update(); } void StackZone::handleDropEvent(const QList &dragItems, @@ -43,18 +69,14 @@ void StackZone::handleDropEvent(const QList &dragItems, return; } - const auto &cards = getLogic()->getCards(); - int index; - if (startZone == getLogic()) { - // Reordering within the zone: use drop position - index = calcDropIndexFromY(dropPoint.y(), MIN_CARD_VISIBLE); + bool sameZone = startZone == getLogic(); + int index = calcDropIndexFromY(dropPoint.y(), !sameZone, MIN_CARD_VISIBLE); + if (sameZone) { // Same-zone no-op: don't move a card onto itself + const auto &cards = getLogic()->getCards(); if (!cards.isEmpty() && cards.at(index)->getId() == dragItems.at(0)->getId()) { return; } - } else { - // Coming from another zone: append at end (top of stack, rendered on top) - index = static_cast(cards.size()); } Command_MoveCard cmd; diff --git a/cockatrice/src/game_graphics/zones/stack_zone.h b/cockatrice/src/game_graphics/zones/stack_zone.h index 147c3e2fc..5dea481b6 100644 --- a/cockatrice/src/game_graphics/zones/stack_zone.h +++ b/cockatrice/src/game_graphics/zones/stack_zone.h @@ -7,17 +7,26 @@ #ifndef STACKZONE_H #define STACKZONE_H -#include "../../game/zones/stack_zone_logic.h" #include "select_zone.h" +#include +#include + +class QGraphicsItem; +class StackZoneLogic; + class StackZone : public SelectZone { Q_OBJECT private: qreal zoneHeight; + bool playmatActive = false; private slots: void updateBg(); +public slots: + void onPlaymatChanged(bool active); + public: StackZone(StackZoneLogic *_logic, int _zoneHeight, QGraphicsItem *parent); /** @brief Resizes the stack zone height, e.g. when sharing vertical space with the command zone. */ diff --git a/cockatrice/src/game_graphics/zones/table_zone.cpp b/cockatrice/src/game_graphics/zones/table_zone.cpp index 4ef01853f..b704b79e0 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.cpp +++ b/cockatrice/src/game_graphics/zones/table_zone.cpp @@ -1,22 +1,50 @@ #include "table_zone.h" #include "../../client/settings/cache_settings.h" +#include "../../game/board/card_list.h" #include "../../game/player/player_actions.h" +#include "../../game/player/player_info.h" #include "../../game/player/player_logic.h" +#include "../../game/zones/card_zone_logic.h" #include "../../game/zones/table_zone_logic.h" #include "../../interface/theme_manager.h" -#include "../board/arrow_item.h" +#include "../board/abstract_card_item.h" #include "../board/card_drag_item.h" #include "../board/card_item.h" +#include "../game_scene.h" #include "../z_values.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/protocol/pb/card_attributes.pb.h" +#include +#include +#include +#include #include +#include +#include +#include #include +#include +#include +#include +#include +#include +#include #include #include #include #include #include +#include + +namespace google +{ +namespace protobuf +{ +class Message; +} // namespace protobuf +} // namespace google const QColor TableZone::BACKGROUND_COLOR = QColor(100, 100, 100); const QColor TableZone::FADE_MASK = QColor(0, 0, 0, 80); @@ -47,6 +75,31 @@ void TableZone::updateBg() update(); } +void TableZone::triggerDamageShimmer() +{ + if (!SettingsCache::instance().userInterface().getBattlefieldFlashEnabled()) { + damageShimmerAlpha = 0.0; + return; + } + + damageShimmerAlpha = 1.0; + shimmerClock.start(); + if (scene()) { + static_cast(scene())->registerAnimationItem(this); + } +} + +bool TableZone::animationEvent() +{ + damageShimmerAlpha = 1.0 - shimmerClock.elapsed() / shimmerDurationMs; + if (damageShimmerAlpha <= 0.0) { + damageShimmerAlpha = 0.0; + return false; + } + update(); + return true; +} + QRectF TableZone::boundingRect() const { return QRectF(0, 0, width, height); @@ -66,20 +119,43 @@ bool TableZone::isInverted() const void TableZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/) { - QBrush brush = themeManager->getExtraBgBrush(ThemeManager::Table, getLogic()->getPlayer()->getZoneId()); - painter->fillRect(boundingRect(), brush); + if (playmatActive) { + // Subtle overlay to distinguish table zone from stack zone + painter->fillRect(boundingRect(), QColor(0, 0, 0, 60)); + } else { + QBrush brush = themeManager->getExtraBgBrush(ThemeManager::Table, getLogic()->getPlayer()->getZoneId()); + painter->fillRect(boundingRect(), brush); + } if (active) { paintZoneOutline(painter); } else { // inactive player gets a darker table zone with a semi transparent black mask - // this means if the user provides a custom background it will fade + // this means if the user provides a custom background or playmat it will fade painter->fillRect(boundingRect(), FADE_MASK); } + // Decaying crimson wash from taking damage. + if (damageShimmerAlpha > 0.0) { + QColor shimmerColor(239, 68, 68); + shimmerColor.setAlphaF(0.22 * damageShimmerAlpha); + painter->fillRect(boundingRect(), shimmerColor); + } + paintLandDivider(painter); } +void TableZone::onPlaymatChanged(bool active) +{ + playmatActive = active; + // While a playmat is shown the zone paints a translucent overlay over the + // dynamic playmat behind it. Keep it out of the device cache so the cached + // pixels are never stale relative to the playmat (and to avoid compositing + // artifacts of cached translucent content on some platforms). + setCacheMode(active ? QGraphicsItem::NoCache : QGraphicsItem::DeviceCoordinateCache); + update(); +} + /** Render a soft outline around the edge of the TableZone. diff --git a/cockatrice/src/game_graphics/zones/table_zone.h b/cockatrice/src/game_graphics/zones/table_zone.h index 0d7e58206..ccd1d8e81 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.h +++ b/cockatrice/src/game_graphics/zones/table_zone.h @@ -7,17 +7,34 @@ #ifndef TABLEZONE_H #define TABLEZONE_H -#include "../../game/zones/table_zone_logic.h" -#include "../board/abstract_card_item.h" +#include "../animated_item.h" +#include "../card_dimensions.h" #include "select_zone.h" +#include +#include +#include +#include +#include +#include +#include + +class CardDragItem; +class CardItem; +class CardZoneLogic; +class QColor; +class QGraphicsItem; +class QPainter; +class TableZoneLogic; +template class QList; + /** * @brief TableZone is the grid based rect where CardItems may be placed. * * It is the main play zone and can be customized with background images. */ //! \todo Refactor methods to make more readable, extract logic to private methods (especially reorganizeCards()). -class TableZone : public SelectZone +class TableZone : public SelectZone, public IAnimatedItem { Q_OBJECT @@ -83,6 +100,7 @@ private: */ bool active = false; bool mirrored = false; + bool playmatActive = false; [[nodiscard]] bool isInverted() const; @@ -92,6 +110,9 @@ private slots: */ void updateBg(); +public slots: + void onPlaymatChanged(bool active); + public slots: /** Reorganizes CardItems in the TableZone @@ -121,6 +142,16 @@ public: */ void paint(QPainter *painter, const QStyleOptionGraphicsItem *option, QWidget *widget) override; + /** + Flashes the table surface after a player loses life. + + Wired up through the life counter so the battlefield glows when life drops. + */ + void triggerDamageShimmer(); + + /** @brief Decays the damage shimmer by one timer tick. */ + bool animationEvent() override; + /** Toggles the selected items as tapped. */ @@ -171,8 +202,17 @@ public: } void setWidth(qreal _width) { + // The width is stored as an int; truncate to match the previous implicit conversion. + const int newWidth = static_cast(_width); + if (width == newWidth) { + return; + } prepareGeometryChange(); - width = _width; + width = newWidth; + // The parent player item's boundingRect (which clips the playmat painting) is + // derived from this zone's size. Without this signal the playmat is cut off at + // the stale boundingRect edge whenever the scene is resized wider. + emit sizeChanged(); } [[nodiscard]] qreal getWidth() const { @@ -185,6 +225,11 @@ public: } private: + static constexpr qreal shimmerDurationMs = 450.0; + + QElapsedTimer shimmerClock; + qreal damageShimmerAlpha = 0.0; + void paintZoneOutline(QPainter *painter); void paintLandDivider(QPainter *painter); diff --git a/cockatrice/src/game_graphics/zones/view_zone.cpp b/cockatrice/src/game_graphics/zones/view_zone.cpp index baf7b8b30..a2aa124dc 100644 --- a/cockatrice/src/game_graphics/zones/view_zone.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone.cpp @@ -1,21 +1,40 @@ #include "view_zone.h" +#include "../../client/settings/cache_settings.h" #include "../../game/player/player_actions.h" +#include "../../game/player/player_info.h" #include "../../game/player/player_logic.h" +#include "../../game/zones/card_zone_logic.h" #include "../../game/zones/view_zone_logic.h" #include "../board/card_drag_item.h" #include "../board/card_item.h" +#include "../card_dimensions.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/filters/filter_string.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_zone.pb.h" +#include "libcockatrice/utility/card_ref.h" #include +#include #include -#include +#include +#include #include +#include +#include #include +#include +#include #include #include #include #include #include +#include +#include + +class QGraphicsItem; /** * @param parent the parent QGraphicsWidget containing the reveal zone @@ -253,7 +272,10 @@ ZoneViewZone::GridSize ZoneViewZone::positionCardsForDisplay(CardList &cards, Ca void ZoneViewZone::setFilterString(const QString &_filterString) { - filterString = FilterString(_filterString); + const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay(); + filterString = FilterString( + _filterString, CardSearchLanguage{cardsDisplay.getCardLang(), + static_cast(cardsDisplay.getCardSearchLanguage())}); reorganizeCards(); } diff --git a/cockatrice/src/game_graphics/zones/view_zone.h b/cockatrice/src/game_graphics/zones/view_zone.h index 9dfa00ce2..4032da760 100644 --- a/cockatrice/src/game_graphics/zones/view_zone.h +++ b/cockatrice/src/game_graphics/zones/view_zone.h @@ -7,17 +7,29 @@ #ifndef ZONEVIEWERZONE_H #define ZONEVIEWERZONE_H -#include "../../game/zones/view_zone_logic.h" +#include "../../game/board/card_list.h" #include "select_zone.h" #include +#include #include +#include +#include +#include #include -#include +#include +#include + +// IWYU pragma: keep +// ServerInfo_Card appears inside QList<...> in signal and slot +// signatures, which the moc-generated code needs to be complete. +#include + +class QGraphicsItem; +class ZoneViewZoneLogic; inline Q_LOGGING_CATEGORY(ViewZoneLog, "view_zone"); -class ZoneViewWidget; class Response; class ServerInfo_Card; class QGraphicsSceneWheelEvent; diff --git a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp index 17118e80d..49d9955e0 100644 --- a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp @@ -1,31 +1,63 @@ #include "view_zone_widget.h" #include "../../client/settings/cache_settings.h" +#include "../../client/translation.h" #include "../../filters/syntax_help.h" +#include "../../game/board/card_list.h" #include "../../game/player/player_actions.h" #include "../../game/player/player_logic.h" +#include "../../game/zones/card_zone_logic.h" +#include "../../game/zones/view_zone_logic.h" #include "../../interface/pixel_map_generator.h" -#include "../board/card_item.h" -#include "../game_scene.h" +#include "../card_dimensions.h" #include "../z_values.h" +#include "libcockatrice/utility/macros.h" #include "view_zone.h" +#include #include +#include +#include +#include +#include +#include +#include +#include #include #include +#include #include +#include #include -#include -#include +#include +#include +#include #include +#include +#include +#include +#include #include #include +#include +#include +#include +#include +#include #include +#include #include +#include namespace { -constexpr qreal kTitleBarHeight = 24.0; +qreal titleBarHeight(QWidget *styleWidget) +{ + if (styleWidget) { + return static_cast(styleWidget->style()->pixelMetric(QStyle::PM_TitleBarHeight, nullptr, styleWidget)); + } + return 24.0; +} constexpr qreal kMinVisibleWidth = 100.0; } // namespace @@ -62,7 +94,7 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player, 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(QPixmap("theme:icons/info"), QLineEdit::TrailingPosition); + auto help = searchEdit.addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); connect(help, &QAction::triggered, this, [this] { createSearchSyntaxHelpWindow(&searchEdit); }); @@ -168,6 +200,12 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player, } connect(&searchEdit, &QLineEdit::textChanged, zone, &ZoneViewZone::setFilterString); + + const auto applyCardSearchLanguage = [this] { zone->setFilterString(searchEdit.text()); }; + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, + applyCardSearchLanguage); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardSearchLanguageChanged, this, + applyCardSearchLanguage); } setLayout(vbox); @@ -283,7 +321,8 @@ QRectF ZoneViewWidget::closeButtonRect(QWidget *styleWidget) const // query the style for the close button position (handles macOS top-left placement) // Title bar rect MUST be local (0,0-based) for QStyle - const QRect titleBarRect(0, 0, static_cast(frameRectF.width()), static_cast(kTitleBarHeight)); + const qreal tbh = titleBarHeight(styleWidget); + const QRect titleBarRect(0, 0, static_cast(frameRectF.width()), static_cast(tbh)); if (styleWidget) { QStyleOptionTitleBar opt; @@ -312,7 +351,7 @@ QRectF ZoneViewWidget::closeButtonRect(QWidget *styleWidget) const } // Fallback: frame-relative top-right - return QRectF(frameRectF.right() - kTitleBarHeight, frameRectF.top(), kTitleBarHeight, kTitleBarHeight); + return QRectF(frameRectF.right() - tbh, frameRectF.top(), tbh, tbh); } QGraphicsView *ZoneViewWidget::findDragView(QWidget *eventWidget) const @@ -436,8 +475,9 @@ QVariant ZoneViewWidget::itemChange(GraphicsItemChange change, const QVariant &v const qreal minX = sceneRect.left(); const qreal maxX = qMax(minX, sceneRect.right() - kMinVisibleWidth); - const qreal minY = sceneRect.top() + kTitleBarHeight; - const qreal maxY = qMax(minY, sceneRect.bottom() - kTitleBarHeight); + const qreal tbh = titleBarHeight(nullptr); + const qreal minY = sceneRect.top() + tbh; + const qreal maxY = qMax(minY, sceneRect.bottom() - tbh); desiredPos.setX(qBound(minX, desiredPos.x(), maxX)); desiredPos.setY(qBound(minY, desiredPos.y(), maxY)); @@ -549,7 +589,7 @@ void ZoneViewWidget::initStyleOption(QStyleOption *option) const { QStyleOptionTitleBar *titleBar = qstyleoption_cast(option); if (titleBar) { - titleBar->icon = QPixmap("theme:cockatrice"); + titleBar->icon = themePixmap(QStringLiteral("cockatrice")); } } diff --git a/cockatrice/src/game_graphics/zones/view_zone_widget.h b/cockatrice/src/game_graphics/zones/view_zone_widget.h index de5ad28d5..9fe022b27 100644 --- a/cockatrice/src/game_graphics/zones/view_zone_widget.h +++ b/cockatrice/src/game_graphics/zones/view_zone_widget.h @@ -6,30 +6,34 @@ #ifndef ZONEVIEWWIDGET_H #define ZONEVIEWWIDGET_H -#include "../../game/zones/card_zone_logic.h" - #include #include #include #include #include +#include +#include +#include #include +#include +#include #include +#include + +// IWYU pragma: keep +// ServerInfo_Card appears inside QList<...> in signal and slot +// signatures, which the moc-generated code needs to be complete. +#include -class QLabel; -class QPushButton; -class CardZone; class ZoneViewZone; class PlayerLogic; -class CardDatabase; class QScrollBar; -class GameScene; class ServerInfo_Card; class QGraphicsSceneMouseEvent; class QGraphicsSceneWheelEvent; -class QStyleOption; class QGraphicsView; class QWidget; +class CardZoneLogic; class ScrollableGraphicsProxyWidget : public QGraphicsProxyWidget { diff --git a/cockatrice/src/interface/card_database_update/card_database_update_status_bar.cpp b/cockatrice/src/interface/card_database_update/card_database_update_status_bar.cpp new file mode 100644 index 000000000..721fdd6c8 --- /dev/null +++ b/cockatrice/src/interface/card_database_update/card_database_update_status_bar.cpp @@ -0,0 +1,117 @@ +#include "card_database_update_status_bar.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +/** + * @brief Translated name of one updater stage. + * @param fallback shown when the stage is unknown or not reported yet + */ +QString stageName(CardUpdateStage stage, const QString &fallback) +{ + switch (stage) { + case CardUpdateStage::Download: + return QCoreApplication::translate("CardDatabaseUpdateStatusBar", "Downloading"); + case CardUpdateStage::Scan: + return QCoreApplication::translate("CardDatabaseUpdateStatusBar", "Parsing"); + case CardUpdateStage::Import: + return QCoreApplication::translate("CardDatabaseUpdateStatusBar", "Importing"); + case CardUpdateStage::Unknown: + return fallback; + } + return fallback; +} +} // namespace + +CardDatabaseUpdateStatusBar::CardDatabaseUpdateStatusBar(QWidget *parent) : QWidget(parent) +{ + layout = new QHBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + + stageLabel = new QLabel(this); + progressBar = new QProgressBar(this); + progressBar->setMinimumWidth(120); + + layout->addWidget(stageLabel); + layout->addWidget(progressBar); + + setLayout(layout); + refresh(); + hide(); +} + +QString CardDatabaseUpdateStatusBar::statusText() const +{ + if (!active) { + return {}; + } + const QString name = stageName(progress.stage, tr("Updating the card database")); + if (progress.total <= 0) { + return name; + } + const int percent = static_cast((100.0 * progress.done) / progress.total); + return tr("%1 (%2%)").arg(name).arg(percent); +} + +void CardDatabaseUpdateStatusBar::updateStarted() +{ + active = true; + progress = {}; + refresh(); + show(); +} + +void CardDatabaseUpdateStatusBar::updateProgress(const CardUpdateProgress &progress) +{ + active = true; + this->progress = progress; + refresh(); +} + +void CardDatabaseUpdateStatusBar::updateFinished() +{ + active = false; + progress = {}; + refresh(); + hide(); +} + +void CardDatabaseUpdateStatusBar::changeEvent(QEvent *event) +{ + if (event->type() == QEvent::LanguageChange) { + refresh(); + } + QWidget::changeEvent(event); +} + +void CardDatabaseUpdateStatusBar::refresh() +{ + const QString name = stageName(progress.stage, tr("Updating the card database")); + stageLabel->setText(name); + + const qint64 cappedTotal = qMin(progress.total, INT_MAX); + const qint64 cappedDone = qMin(progress.done, INT_MAX); + const int minimum = 0; + const int maximum = static_cast(cappedTotal); + if (progressBar->minimum() != minimum || progressBar->maximum() != maximum) { + // A zero range is the busy indicator, used until the first report arrives. + progressBar->setRange(minimum, maximum); + } + progressBar->setValue(static_cast(cappedDone)); + + if (active && progress.total > 0) { + const int percent = static_cast((100.0 * progress.done) / progress.total); + setToolTip(tr("Card database update in progress: %1 (%2%)").arg(name).arg(percent)); + } else if (active) { + setToolTip(tr("Card database update in progress.")); + } else { + setToolTip({}); + } +} diff --git a/cockatrice/src/interface/card_database_update/card_database_update_status_bar.h b/cockatrice/src/interface/card_database_update/card_database_update_status_bar.h new file mode 100644 index 000000000..6db3bd225 --- /dev/null +++ b/cockatrice/src/interface/card_database_update/card_database_update_status_bar.h @@ -0,0 +1,68 @@ +#ifndef CARD_DATABASE_UPDATE_STATUS_BAR_H +#define CARD_DATABASE_UPDATE_STATUS_BAR_H + +#include "card_update_progress.h" + +#include +#include +#include + +class QEvent; +class QHBoxLayout; +class QLabel; +class QProgressBar; + +/** + * @class CardDatabaseUpdateStatusBar + * @brief Status bar widget showing what the card database updater (Oracle) is doing. + * + * The updater runs as a separate process, so this widget is the in-client view of + * its progress: it appears when an update starts, tracks the download/scan/import + * stages reported by the updater, and disappears when the update finishes. + */ +class CardDatabaseUpdateStatusBar : public QWidget +{ + Q_OBJECT +public: + /** + * @brief Constructs a hidden progress widget. + * @param parent Parent widget + */ + explicit CardDatabaseUpdateStatusBar(QWidget *parent = nullptr); + + /** + * @brief Human readable description of the running stage, e.g. "Downloading (42%)". + * @return the description, or an empty string when no update is running + */ + QString statusText() const; + +public slots: + /** + * @brief Shows the widget and resets it to the indeterminate state. + */ + void updateStarted(); + + /** + * @brief Shows the progress of one update stage. + */ + void updateProgress(const CardUpdateProgress &progress); + + /** + * @brief Hides the widget and clears the reported progress. + */ + void updateFinished(); + +protected: + void changeEvent(QEvent *event) override; + +private: + void refresh(); + + QHBoxLayout *layout; ///< Horizontal layout holding the stage label and the progress bar + QLabel *stageLabel; ///< Name of the stage currently running + QProgressBar *progressBar; ///< Progress within the current stage + CardUpdateProgress progress; ///< Last reported progress + bool active = false; ///< Whether an update is currently running +}; + +#endif // CARD_DATABASE_UPDATE_STATUS_BAR_H diff --git a/cockatrice/src/interface/card_database_update/card_update_progress.cpp b/cockatrice/src/interface/card_database_update/card_update_progress.cpp new file mode 100644 index 000000000..1f8e500d0 --- /dev/null +++ b/cockatrice/src/interface/card_database_update/card_update_progress.cpp @@ -0,0 +1,59 @@ +#include "card_update_progress.h" + +#include + +namespace +{ +CardUpdateStage parseStage(const QByteArray &token) +{ + if (token == "download") { + return CardUpdateStage::Download; + } + if (token == "scan") { + return CardUpdateStage::Scan; + } + if (token == "import") { + return CardUpdateStage::Import; + } + return CardUpdateStage::Unknown; +} +} // namespace + +std::optional CardUpdateProgress::fromProtocolLine(const QByteArray &line) +{ + const QByteArray trimmed = line.trimmed(); + if (!trimmed.startsWith("PROGRESS ")) { + return std::nullopt; + } + const QList parts = trimmed.split(' '); + if (parts.size() != 4) { + return std::nullopt; + } + bool doneOk = false; + bool totalOk = false; + const qint64 done = parts.at(2).toLongLong(&doneOk); + const qint64 total = parts.at(3).toLongLong(&totalOk); + if (!doneOk || !totalOk || done < 0 || total < 0) { + return std::nullopt; + } + CardUpdateProgress progress; + progress.stage = parseStage(parts.at(1)); + progress.done = done; + progress.total = total; + return progress; +} + +QString CardUpdateProgress::stageToken() const +{ + switch (stage) { + case CardUpdateStage::Download: + return QStringLiteral("download"); + case CardUpdateStage::Scan: + return QStringLiteral("scan"); + case CardUpdateStage::Import: + return QStringLiteral("import"); + case CardUpdateStage::Unknown: + return QStringLiteral("unknown"); + } + return QStringLiteral("unknown"); +} diff --git a/cockatrice/src/interface/card_database_update/card_update_progress.h b/cockatrice/src/interface/card_database_update/card_update_progress.h new file mode 100644 index 000000000..743f32209 --- /dev/null +++ b/cockatrice/src/interface/card_database_update/card_update_progress.h @@ -0,0 +1,42 @@ +#ifndef CARD_UPDATE_PROGRESS_H +#define CARD_UPDATE_PROGRESS_H + +#include +#include +#include +#include + +/** + * @brief One stage of the card database updater's progress protocol. + */ +enum class CardUpdateStage +{ + Download, ///< downloading the source data + Scan, ///< parsing the downloaded data + Import, ///< importing cards into the database + Unknown, ///< a stage the client does not recognize +}; + +/** + * @brief One progress line read from the card database updater's stdout. + */ +struct CardUpdateProgress +{ + /** + * @brief Parses one line of the updater stdout protocol: "PROGRESS ". + * @param line a single stdout line, with or without its trailing newline + * @return the parsed progress, or @c std::nullopt when the line is not a valid progress line + */ + static std::optional fromProtocolLine(const QByteArray &line); + + /** + * @brief Protocol token of the stage ("download", "scan", "import" or "unknown"), for logging. + */ + QString stageToken() const; + + CardUpdateStage stage = CardUpdateStage::Unknown; + qint64 done = 0; + qint64 total = 0; +}; + +#endif // CARD_UPDATE_PROGRESS_H diff --git a/cockatrice/src/interface/card_localization.h b/cockatrice/src/interface/card_localization.h new file mode 100644 index 000000000..39cbd7700 --- /dev/null +++ b/cockatrice/src/interface/card_localization.h @@ -0,0 +1,80 @@ +#ifndef COCKATRICE_CARD_LOCALIZATION_H +#define COCKATRICE_CARD_LOCALIZATION_H + +#include "../client/settings/cache_settings.h" + +#include +#include +#include +#include +#include + +namespace CardLocalization +{ +/** + * @brief The language code selected for localized card text and images. + */ +inline QString displayLang() +{ + return SettingsCache::instance().cardsDisplay().getCardLang(); +} + +/** + * @brief Card name in the configured display language, falling back to English. + * @param card The card to display. + * @return The localized name, or an empty string for a null card. + */ +inline QString displayName(const CardInfoPtr &card) +{ + return card.isNull() ? QString() : card->getLocalizedName(displayLang()); +} + +/** + * @brief Card rules text in the configured display language, falling back to English. + * @param card The card to display. + * @return The localized text, or an empty string for a null card. + */ +inline QString displayText(const CardInfoPtr &card) +{ + return card.isNull() ? QString() : card->getLocalizedText(displayLang()); +} + +/** + * @brief Card name in the configured display language, falling back to English. + * @param card The card to display. + */ +inline QString displayName(const CardInfo &card) +{ + return card.getLocalizedName(displayLang()); +} + +/** + * @brief Card rules text in the configured display language, falling back to English. + * @param card The card to display. + */ +inline QString displayText(const CardInfo &card) +{ + return card.getLocalizedText(displayLang()); +} + +/** + * @brief Looks a card up by its canonical name and returns its name in the configured display language. + * + * Use this where only the canonical name is known, such as the game log or [card] chat tags. Callers that + * already hold a resolved card and need to keep the canonical name around (as a link target, say) should + * use displayName() directly instead. + * + * @param cardName The canonical card name. + * @return The localized name, or cardName unchanged when the card is unknown to the database. + */ +inline QString displayNameFor(const QString &cardName) +{ + if (cardName.isEmpty()) { + return cardName; + } + const QString localizedName = displayName(CardDatabaseManager::query()->getCardInfo(cardName)); + return localizedName.isEmpty() ? cardName : localizedName; +} +} // namespace CardLocalization + +#endif // COCKATRICE_CARD_LOCALIZATION_H \ No newline at end of file diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp index 2f46e7941..4f77c7096 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp @@ -1,27 +1,48 @@ #include "card_picture_loader.h" #include "../../client/settings/cache_settings.h" +#include "../pixel_map_generator.h" #include "card_picture_loader_cache_method.h" #include "card_picture_loader_local_schemes.h" +#include "card_picture_loader_status_bar.h" +#include "card_picture_loader_worker.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/card/printing/printing_info.h" +#include "libcockatrice/card/set/card_set.h" #include -#include +#include +#include +#include #include -#include +#include +#include #include +#include +#include +#include #include -#include -#include -#include +#include +#include +#include +#include #include #include +#include +#include #include +#include +#include #include -#include +#include +#include +#include #include +#include #include #include -#include +#include // never cache more than 300 cards at once for a single deck #define CACHED_CARD_PER_DECK_MAX 300 @@ -36,9 +57,12 @@ CardPictureLoader::CardPictureLoader() : QObject(nullptr) &CardPictureLoader::picsPathChanged); connect(&SettingsCache::instance().downloads(), &DownloadSettings::picDownloadChanged, this, &CardPictureLoader::picDownloadChanged); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, + &CardPictureLoader::cardLangChanged); - qRegisterMetaType(); + qRegisterMetaType("ExactCard"); connect(worker, &CardPictureLoaderWorker::imageLoaded, this, &CardPictureLoader::imageLoaded); + connect(worker, &CardPictureLoaderWorker::networkCacheCleared, this, &CardPictureLoader::networkCacheCleared); statusBar = new CardPictureLoaderStatusBar(nullptr); QMainWindow *mainWindow = qobject_cast(QApplication::activeWindow()); @@ -54,7 +78,18 @@ CardPictureLoader::CardPictureLoader() : QObject(nullptr) CardPictureLoader::~CardPictureLoader() { - worker->deleteLater(); + if (worker) { + // Capture the thread first: shutdownThread() blocks until the worker has been freed by the + // finished() -> deleteLater chain, after which the worker pointer must not be dereferenced. + QThread *pictureLoaderThread = worker->workerThread(); + const bool stopped = worker->shutdownThread(); + worker = nullptr; + // Deleting a QThread that is still running is undefined behaviour, so only free it once the + // bounded wait in shutdownThread() confirmed that it stopped. + if (stopped) { + delete pictureLoaderThread; + } + } } void CardPictureLoader::getCardBackPixmap(QPixmap &pixmap, QSize size) @@ -62,7 +97,7 @@ void CardPictureLoader::getCardBackPixmap(QPixmap &pixmap, QSize size) QString backCacheKey = "_trice_card_back_" + QString::number(size.width()) + "x" + QString::number(size.height()); if (!QPixmapCache::find(backCacheKey, &pixmap)) { qCDebug(CardPictureLoaderLog) << "PictureLoader: cache miss for" << backCacheKey; - QPixmap tmpPixmap("theme:cardback"); + QPixmap tmpPixmap = themePixmap(QStringLiteral("cardback")); if (tmpPixmap.isNull()) { qCWarning(CardPictureLoaderLog) << "Failed to load 'theme:cardback'! Using fallback pixmap."; @@ -83,7 +118,7 @@ void CardPictureLoader::getCardBackLoadingInProgressPixmap(QPixmap &pixmap, QSiz "_trice_card_back_inprogress_" + QString::number(size.width()) + "x" + QString::number(size.height()); if (!QPixmapCache::find(backCacheKey, &pixmap)) { qCDebug(CardPictureLoaderCardBackCacheFailLog) << "PictureLoader: cache miss for" << backCacheKey; - QPixmap tmpPixmap("theme:cardback"); + QPixmap tmpPixmap = themePixmap(QStringLiteral("cardback")); if (tmpPixmap.isNull()) { qCWarning(CardPictureLoaderLog) << "Failed to load 'theme:cardback' for in-progress state! Using fallback."; @@ -105,7 +140,7 @@ void CardPictureLoader::getCardBackLoadingFailedPixmap(QPixmap &pixmap, QSize si "_trice_card_back_failed_" + QString::number(size.width()) + "x" + QString::number(size.height()); if (!QPixmapCache::find(backCacheKey, &pixmap)) { qCDebug(CardPictureLoaderCardBackCacheFailLog) << "PictureLoader: cache miss for" << backCacheKey; - QPixmap tmpPixmap("theme:cardback"); + QPixmap tmpPixmap = themePixmap(QStringLiteral("cardback")); if (tmpPixmap.isNull()) { qCWarning(CardPictureLoaderLog) << "Failed to load 'theme:cardback' for failed state! Using fallback."; @@ -138,7 +173,8 @@ void CardPictureLoader::getPixmap(QPixmap &pixmap, const ExactCard &card, QSize QPixmap bigPixmap; if (QPixmapCache::find(key, &bigPixmap)) { if (bigPixmap.isNull()) { - getCardBackLoadingFailedPixmap(pixmap, size); + // Leave the pixmap null so callers fall back to a solid color + // instead of showing the card back. QDateTime failedAtTime = getInstance().failedAt.value(key); if (!failedAtTime.isValid() || failedAtTime.addSecs(RETRY_FAILED_CARDS_SECS) < QDateTime::currentDateTime()) { @@ -204,7 +240,49 @@ void CardPictureLoader::imageLoaded(const ExactCard &card, const QImage &image) card.emitPixmapUpdated(); } -void CardPictureLoader::saveCardImageToLocalStorage(const ExactCard &card, const QPixmap &pixmap) +void CardPictureLoader::deleteAllLocalOverrides(const ExactCard &card) +{ + const QString picsRoot = SettingsCache::instance().paths().getPicsPath(); + if (picsRoot.isEmpty() || !card) { + return; + } + + QDir baseDir(picsRoot); + if (!baseDir.cd("downloadedPics")) { + return; + } + + const QString name = card.getInfo().getCorrectedName(); + + QString set, collector, uuid; + auto printing = card.getPrinting(); + if (printing.getSet()) { + set = printing.getSet()->getCorrectedShortName(); + collector = printing.getProperty("num"); + uuid = printing.getUuid(); + } + + for (const auto &scheme : CardPictureLoaderLocalSchemes::exportSchemes()) { + QString rel = CardPictureLoaderLocalSchemes::expandPattern(scheme.pattern, name, set, collector, uuid); + + if (rel.isEmpty()) { + continue; + } + + rel += ".png"; + rel = QDir::cleanPath(rel); + + QString fullPath = baseDir.filePath(rel); + + if (QFile::exists(fullPath)) { + QFile::remove(fullPath); + } + } +} + +void CardPictureLoader::saveCardImageToLocalStorage(const ExactCard &card, + const QPixmap &pixmap, + const bool allowOverwrite) { if (pixmap.isNull() || !card) { return; @@ -264,8 +342,9 @@ void CardPictureLoader::saveCardImageToLocalStorage(const ExactCard &card, const QFileInfo outInfo(baseDir.filePath(relativePath)); - // Do not overwrite existing files - if (outInfo.exists()) { + // Automatic cache writes (FILESYSTEM_CACHE) must never clobber an explicit user override. + // Only the explicit override paths pass allowOverwrite == true. + if (!allowOverwrite && outInfo.exists()) { return; } @@ -286,6 +365,122 @@ void CardPictureLoader::saveCardImageToLocalStorage(const ExactCard &card, const } } +void CardPictureLoader::installPrintingOverrideOnLoad(const ExactCard &originalCard, const ExactCard &overrideCard) +{ + // Overriding a card with itself is the reset case, not a real override: every code path below + // would re-enter itself through emitPixmapUpdated(). Reject it outright. + if (originalCard == overrideCard) { + return; + } + + CardInfoPtr cardPtr = overrideCard.getCardPtr(); + if (!cardPtr) { + return; + } + + // Heap-allocate so the lambda can capture it before the connection is made + auto *connectionHandle = new QMetaObject::Connection; + + *connectionHandle = + connect(cardPtr.data(), &CardInfo::pixmapUpdated, cardPtr.data(), + [originalCard, overrideCard, connectionHandle, this](const PrintingInfo &printing) { + // All printings share the same CardInfo, so ignore updates triggered by any + // other printing (e.g., the original card re-loading from disk). + if (printing != overrideCard.getPrinting()) { + return; + } + + QPixmap pixmap; + if (QPixmapCache::find(overrideCard.getPixmapCacheKey(), &pixmap) && !pixmap.isNull()) { + // The override art has resolved — persist it and reflect it immediately. + // Retire the connection before emitting so the refresh can't re-enter. + saveCardImageToLocalStorage(originalCard, pixmap, /*allowOverwrite=*/true); + + QObject::disconnect(*connectionHandle); + delete connectionHandle; + + QPixmapCache::clear(); + originalCard.emitPixmapUpdated(); + return; + } + + // The art could not be resolved. Keep the connection armed so a late resolution + // still lands, and surface a visible refusal instead of a silent no-op. An + // override already on disk is left untouched and simply re-displayed. + QPixmapCache::clear(); + if (!hasLocalOverrides(originalCard)) { + QPixmap refusedPixmap; + getCardBackLoadingFailedPixmap(refusedPixmap, QSize(480, 672)); + QPixmapCache::insert(originalCard.getPixmapCacheKey(), refusedPixmap); + } + originalCard.emitPixmapUpdated(); + }); + + // Now enqueue; if the image is already loading (deduplicated in the worker), + // the signal will still fire when it completes + CardPictureLoader::getInstance().worker->enqueueImageLoad(overrideCard); +} + +void CardPictureLoader::installPrintingOverride(const ExactCard &originalCard, const ExactCard &overrideCard) +{ + // Same guard as installPrintingOverrideOnLoad: self-override is the reset case. + if (originalCard == overrideCard) { + return; + } + + QPixmap pixmap; + const QString key = overrideCard.getPixmapCacheKey(); + + if (QPixmapCache::find(key, &pixmap) && !pixmap.isNull()) { + // Already cached — save immediately; the caller refreshes the card. + saveCardImageToLocalStorage(originalCard, pixmap, /*allowOverwrite=*/true); + return; + } + + // Cache miss or previously failed load — enqueue load and wait for the signal. + installPrintingOverrideOnLoad(originalCard, overrideCard); +} + +bool CardPictureLoader::hasLocalOverrides(const ExactCard &card) +{ + const QString picsRoot = SettingsCache::instance().paths().getPicsPath(); + if (picsRoot.isEmpty() || !card) { + return false; + } + + QDir baseDir(picsRoot); + if (!baseDir.cd("downloadedPics")) { + return false; + } + + const QString name = card.getInfo().getCorrectedName(); + + QString set, collector, uuid; + const PrintingInfo printing = card.getPrinting(); + if (printing.getSet()) { + set = printing.getSet()->getCorrectedShortName(); + collector = printing.getProperty("num"); + uuid = printing.getUuid(); + } + + for (const auto &scheme : CardPictureLoaderLocalSchemes::exportSchemes()) { + QString rel = CardPictureLoaderLocalSchemes::expandPattern(scheme.pattern, name, set, collector, uuid); + + if (rel.isEmpty()) { + continue; + } + + rel += ".png"; + rel = QDir::cleanPath(rel); + + if (QFile::exists(baseDir.filePath(rel))) { + return true; + } + } + + return false; +} + void CardPictureLoader::clearPixmapCache() { QPixmapCache::clear(); @@ -293,7 +488,17 @@ void CardPictureLoader::clearPixmapCache() void CardPictureLoader::clearNetworkCache() { - getInstance().worker->clearNetworkCache(); + // During teardown the worker is released before this singleton, so a queued clear may still + // arrive with no worker left to run it. + CardPictureLoaderWorker *worker = getInstance().worker; + if (!worker) { + return; + } + // The disk cache and redirect cache are owned by the worker thread, so the clear has to run + // there. Invoke it asynchronously to keep the GUI responsive while the worker may be walking + // the user's picture directories or recursively deleting the cache directory; callers that + // need to know when it is done can listen for networkCacheCleared(). + QMetaObject::invokeMethod(worker, &CardPictureLoaderWorker::clearNetworkCache, Qt::QueuedConnection); } void CardPictureLoader::cacheCardPixmaps(const QList &cards) @@ -325,31 +530,11 @@ void CardPictureLoader::picsPathChanged() QPixmapCache::clear(); } -bool CardPictureLoader::hasCustomArt() +void CardPictureLoader::cardLangChanged() { - auto picsPath = SettingsCache::instance().paths().getPicsPath(); - QDirIterator it(picsPath, QDir::Dirs | QDir::NoDotAndDotDot); - - // Check if there is at least one non-directory file in the pics path, other - // than in the "downloadedPics" subdirectory. - while (it.hasNext()) { -#if (QT_VERSION >= QT_VERSION_CHECK(6, 3, 0)) - QFileInfo dir(it.nextFileInfo()); -#else - // nextFileInfo() is only available in Qt 6.3+, for previous versions, we build - // the QFileInfo from a QString which requires more system calls. - QFileInfo dir(it.next()); -#endif - - if (it.fileName() == "downloadedPics") { - continue; - } - - QDirIterator subIt(it.filePath(), QDir::Files, QDirIterator::Subdirectories | QDirIterator::FollowSymlinks); - if (subIt.hasNext()) { - return true; - } - } - - return false; + // Localized images are fetched via a different URL, but the in-memory + // pixmap cache is keyed by card name/uuid, so drop everything cached + // (including failure timestamps) to force a reload in the new language. + QPixmapCache::clear(); + failedAt.clear(); } diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader.h index 5c3ac84a3..c6d91570b 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader.h @@ -1,12 +1,20 @@ #ifndef CARD_PICTURE_LOADER_H #define CARD_PICTURE_LOADER_H -#include "card_picture_loader_status_bar.h" -#include "card_picture_loader_worker.h" - -#include #include #include +#include +#include + +class CardPictureLoaderStatusBar; +class CardPictureLoaderWorker; +class ExactCard; +class QDateTime; +class QImage; +class QPixmap; +class QSize; +class QString; +template class QList; inline Q_LOGGING_CATEGORY(CardPictureLoaderLog, "card_picture_loader"); inline Q_LOGGING_CATEGORY(CardPictureLoaderCardBackCacheFailLog, "card_picture_loader.card_back_cache_fail"); @@ -97,10 +105,17 @@ public: static void cacheCardPixmaps(const QList &cards); /** - * @brief Check if the user has custom card art in the picsPath directory. - * @return True if any custom art exists. + * @brief Check if a local override image already exists for the card. + * @param card The card to check. + * @return True if the card has at least one locally stored override image. */ - static bool hasCustomArt(); + static bool hasLocalOverrides(const ExactCard &card); + + /** + * @brief Removes all locally stored override images for the card. + * @param card The card to remove the override images of. + */ + static void deleteAllLocalOverrides(const ExactCard &card); /** * @brief Clears the in-memory QPixmap cache for all cards. @@ -110,6 +125,9 @@ public: public slots: /** * @brief Clears the network disk cache of the worker. + * + * The clear runs on the worker thread, so this returns before it has completed; connect to + * networkCacheCleared() to act once it is done. */ static void clearNetworkCache(); @@ -120,7 +138,13 @@ public slots: * @param image Loaded QImage. */ void imageLoaded(const ExactCard &card, const QImage &image); - void saveCardImageToLocalStorage(const ExactCard &card, const QPixmap &pixmap); + void saveCardImageToLocalStorage(const ExactCard &card, const QPixmap &pixmap, bool allowOverwrite = false); + void installPrintingOverride(const ExactCard &originalCard, const ExactCard &overrideCard); + void installPrintingOverrideOnLoad(const ExactCard &originalCard, const ExactCard &overrideCard); + +signals: + /** @brief Emitted after the worker has finished clearing the network and redirect caches. */ + void networkCacheCleared(); private slots: /** @@ -134,6 +158,12 @@ private slots: * Clears the QPixmap cache to reload images. */ void picsPathChanged(); + + /** + * @brief Triggered when the card language setting changes. + * Clears the in-memory picture caches so images reload in the new language. + */ + void cardLangChanged(); }; #endif diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp index 39621839a..1437e23f4 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp @@ -2,11 +2,21 @@ #include "../../client/settings/cache_settings.h" #include "card_picture_loader_local_schemes.h" -#include "card_picture_to_load.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/card/printing/printing_info.h" +#include "libcockatrice/card/set/card_set.h" +#include +#include #include -#include -#include +#include +#include +#include +#include +#include +#include +#include #include static constexpr int REFRESH_INTERVAL_MS = 10 * 1000; @@ -94,10 +104,14 @@ QImage CardPictureLoaderLocal::tryLoadCardImageFromDisk(const QString &setName, candidatePaths << picsPath + "/downloadedPics/" + setName + "/" + nameVariant; } + // Non-set-folder export schemes (e.g., Name_Set_Collector) write straight into + // downloadedPics/; check there as a fallback so local overrides round-trip. + candidatePaths << picsPath + "/downloadedPics/" + nameVariant; + for (const QString &path : candidatePaths) { QFileInfo fileInfo(path); QDir dir = fileInfo.dir(); - QString baseName = fileInfo.fileName(); + QString baseName = fileInfo.completeBaseName(); if (!dir.exists()) { continue; @@ -105,7 +119,8 @@ QImage CardPictureLoaderLocal::tryLoadCardImageFromDisk(const QString &setName, QStringList files = dir.entryList(QDir::Files); for (const QString &file : files) { - if (!file.startsWith(baseName)) { + QFileInfo fi(file); + if (fi.completeBaseName() != baseName) { continue; } diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.h index 44163d1db..009897acc 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.h @@ -1,8 +1,15 @@ #ifndef PICTURE_LOADER_LOCAL_H #define PICTURE_LOADER_LOCAL_H -#include -#include +#include +#include +#include +#include +#include +#include + +class ExactCard; +class QTimer; inline Q_LOGGING_CATEGORY(CardPictureLoaderLocalLog, "card_picture_loader.local"); diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_request_status_display_widget.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader_request_status_display_widget.cpp index d1895af06..98364e3cb 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_request_status_display_widget.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_request_status_display_widget.cpp @@ -1,5 +1,11 @@ #include "card_picture_loader_request_status_display_widget.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/card/printing/printing_info.h" + +#include +#include + CardPictureLoaderRequestStatusDisplayWidget::CardPictureLoaderRequestStatusDisplayWidget(QWidget *parent, const QUrl &_url, const ExactCard &card, diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_request_status_display_widget.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader_request_status_display_widget.h index c9a272b68..58051cc1e 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_request_status_display_widget.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_request_status_display_widget.h @@ -1,11 +1,16 @@ #ifndef PICTURE_LOADER_REQUEST_STATUS_DISPLAY_WIDGET_H #define PICTURE_LOADER_REQUEST_STATUS_DISPLAY_WIDGET_H -#include +#include #include -#include +#include +#include #include -#include +#include + +class ExactCard; +class QHBoxLayout; +class QUrl; /** * @class CardPictureLoaderRequestStatusDisplayWidget diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_status_bar.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader_status_bar.cpp index 9e7e645bd..3b26c8033 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_status_bar.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_status_bar.cpp @@ -1,7 +1,19 @@ #include "card_picture_loader_status_bar.h" +#include "../widgets/quick_settings/settings_button_widget.h" +#include "../widgets/quick_settings/settings_popup_widget.h" #include "card_picture_loader_request_status_display_widget.h" +#include +#include +#include +#include +#include +#include +#include + +class ExactCard; + CardPictureLoaderStatusBar::CardPictureLoaderStatusBar(QWidget *parent) : QWidget(parent) { layout = new QHBoxLayout(this); diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_status_bar.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader_status_bar.h index 7f1f509db..2ba386c42 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_status_bar.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_status_bar.h @@ -1,12 +1,16 @@ #ifndef PICTURE_LOADER_STATUS_BAR_H #define PICTURE_LOADER_STATUS_BAR_H -#include "../../interface/widgets/quick_settings/settings_button_widget.h" -#include "card_picture_loader_worker_work.h" - -#include -#include +#include #include +#include + +class ExactCard; +class QHBoxLayout; +class QProgressBar; +class QTimer; +class QUrl; +class SettingsButtonWidget; /** * @class CardPictureLoaderStatusBar diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp index d288236d2..150b4c544 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp @@ -4,25 +4,47 @@ #include "card_picture_loader_cache_method.h" #include "card_picture_loader_local.h" #include "card_picture_loader_worker_work.h" +#include "card_picture_to_load.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/utility/server_rate_limiter.h" -#include -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include +#include +#include +#include #include +#include +#include +#include #include #include #include +#include #include #include -static constexpr int MAX_REQUESTS_PER_SEC = 10; -static constexpr int MIN_HOST_QUOTA = 1; ///< Floor for the per-host request allowance -static constexpr qint64 QUOTA_RECOVER_MS = 60000; ///< Idle time before a reduced quota starts recovering +static constexpr int MAX_REQUESTS_PER_SEC = DownloadSettings::DEFAULT_HOST_REQUEST_LIMIT; +static constexpr int MIN_HOST_QUOTA = DownloadSettings::MIN_HOST_REQUEST_LIMIT; +static constexpr qint64 QUOTA_RECOVER_MS = 60000; ///< Idle time before a reduced quota starts recovering +static constexpr int DISPATCH_INTERVAL_MS = 100; ///< Pacing between individual network requests +static constexpr qint64 QUOTA_RESET_INTERVAL_MS = 1000; ///< Interval at which the request quota resets +static constexpr int THREAD_SHUTDOWN_WAIT_MS = 5000; ///< Bounded wait for the worker thread to stop at exit CardPictureLoaderWorker::CardPictureLoaderWorker() : QObject(nullptr), picDownload(SettingsCache::instance().downloads().getPicDownload()), - requestQuota(MAX_REQUESTS_PER_SEC) + hostRequestLimits(SettingsCache::instance().downloads().getHostRequestLimits()) { networkManager = new QNetworkAccessManager(this); // We need a timeout to ensure requests don't hang indefinitely in case of @@ -57,20 +79,59 @@ CardPictureLoaderWorker::CardPictureLoaderWorker() localLoader = new CardPictureLoaderLocal(this); pictureLoaderThread = new QThread; + // The worker object frees itself once its thread finishes, so no event loop is left + // running and the QThread is never destroyed while still executing. + connect(pictureLoaderThread, &QThread::finished, this, &QObject::deleteLater); pictureLoaderThread->start(QThread::LowPriority); moveToThread(pictureLoaderThread); + // QTimer value members are not QObject children, so moveToThread on the worker doesn't move + // them. They must live in the worker's thread to be started from the slot code that runs there. + requestTimer.moveToThread(pictureLoaderThread); + dispatchTimer.moveToThread(pictureLoaderThread); + connect(this, &CardPictureLoaderWorker::imageLoadEnqueued, this, &CardPictureLoaderWorker::handleImageLoadEnqueued); connect(&requestTimer, &QTimer::timeout, this, &CardPictureLoaderWorker::resetRequestQuota); - requestTimer.setInterval(1000); - requestTimer.start(); + requestTimer.setInterval(static_cast(QUOTA_RESET_INTERVAL_MS)); + + connect(&dispatchTimer, &QTimer::timeout, this, &CardPictureLoaderWorker::dispatchQueuedRequest); + dispatchTimer.setInterval(DISPATCH_INTERVAL_MS); + + connect(&SettingsCache::instance().downloads(), &DownloadSettings::hostRequestLimitsChanged, this, + [this] { hostRequestLimits = SettingsCache::instance().downloads().getHostRequestLimits(); }); } CardPictureLoaderWorker::~CardPictureLoaderWorker() { saveRedirectCache(); - pictureLoaderThread->deleteLater(); +} + +bool CardPictureLoaderWorker::shutdownThread() +{ + // The finished() -> deleteLater chain (wired in the constructor) frees this worker as soon as + // its event loop exits, so nothing - not even a member read - may run once wait() returns. + // QThread::quit() and QThread::wait() are thread-safe and may be called from the owning thread. + QThread *thread = pictureLoaderThread; + if (!thread) { + return true; + } + thread->quit(); + // Only an unbounded wait() would guarantee the thread stops, but this runs from a function-local + // static destructor after main() has returned, with no UI left to interrupt a worker stuck in a + // slow slot or on a stalled filesystem. Bound the wait and leave such a thread to the OS rather + // than hanging the process forever. + if (!thread->wait(THREAD_SHUTDOWN_WAIT_MS)) { + qCWarning(CardPictureLoaderWorkerLog) << "Picture loader worker thread did not stop within" + << THREAD_SHUTDOWN_WAIT_MS << "ms; leaving it to be torn down by the OS"; + return false; + } + return true; +} + +QThread *CardPictureLoaderWorker::workerThread() const +{ + return pictureLoaderThread; } void CardPictureLoaderWorker::queueRequest(const QUrl &url, CardPictureLoaderWorkerWork *worker) @@ -84,8 +145,8 @@ void CardPictureLoaderWorker::queueRequest(const QUrl &url, CardPictureLoaderWor SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) == CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE && cache->metaData(url).isValid()) { - // If we hit a cached url, we get to make the request for free, since it won't contribute towards the - // rate-limit + // A request that will be served from the disk cache never touches the network and therefore + // doesn't use up any of the rate limit, so it gets to skip the queue. makeRequest(url, worker); return; } @@ -99,7 +160,17 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture // Check for cached redirects QUrl cachedRedirect = getCachedRedirect(url); if (!cachedRedirect.isEmpty()) { + // The status bar still needs to reclaim this URL's widget even when we hand the request back + // for a deferred retry instead of dispatching it onto the network. emit imageRequestSucceeded(url); + // The redirect target is a different host, which may itself be in 429 backoff; hand the + // entry back to its worker so it waits the backoff out instead of dispatching straight + // onto the backed-off host. + if (CardPictureLoaderWorkerWork::rateLimiter().isRateLimited(cachedRedirect.host(), + QDateTime::currentDateTime())) { + worker->scheduleDeferredRetry(cachedRedirect.host()); + return nullptr; + } return makeRequest(cachedRedirect, worker); } @@ -107,67 +178,261 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); req.setRawHeader("Accept", "image/avif,image/webp,image/apng,image/,/*;q=0.8"); - bool useNetworkCache = - !picDownload && static_cast( - SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) == - CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE; + // Cached entries are served straight from the disk cache even when picture downloads are + // enabled: re-fetching an already-cached image would burn the rate limit for nothing. Only a + // genuine cache miss goes to the network, and only when downloads are enabled. + bool useNetworkCache = !requestTouchesNetwork(url); req.setAttribute(QNetworkRequest::CacheLoadControlAttribute, useNetworkCache ? QNetworkRequest::AlwaysCache : QNetworkRequest::AlwaysNetwork); QNetworkReply *reply = networkManager->get(req); - // Connect reply handling - connect(reply, &QNetworkReply::finished, worker, [reply, worker] { worker->handleNetworkReply(reply); }); + // Track in-flight replies per host so the unlocked fast path can bound how many requests it + // issues at once, instead of creating replies that time out before Qt opens a connection. + const QString host = url.host(); + hostInFlight.insert(host, hostInFlight.value(host) + 1); + + // Release the in-flight slot when the reply is destroyed, not when it emits `finished`, and use + // the worker (not the work object) as the context object: a reply can go away without ever + // finishing (aborted, or a work object deleted while a reply is still pending), and a connection + // bound to that work object's lifetime would then never run, permanently shrinking the fast + // path's concurrency until it wedges. This way the slot is released exactly once. + connect(reply, &QObject::destroyed, this, + [this, host] { hostInFlight.insert(host, qMax(0, hostInFlight.value(host) - 1)); }); + + // Connect reply handling; the work object is the context so its handler dies with it. + connect(reply, &QNetworkReply::finished, worker, [worker, reply] { worker->handleNetworkReply(reply); }); return reply; } void CardPictureLoaderWorker::resetRequestQuota() { - requestQuota = MAX_REQUESTS_PER_SEC; - QDateTime now = QDateTime::currentDateTime(); - for (auto it = hostRequestQuota.begin(); it != hostRequestQuota.end(); ++it) { + for (auto it = hostRequestQuota.begin(); it != hostRequestQuota.end();) { if (!hostLast429.contains(it.key()) || now.msecsTo(hostLast429.value(it.key())) < -QUOTA_RECOVER_MS) { - it.value() = qMin(MAX_REQUESTS_PER_SEC, it.value() + 1); + if (hostAllowanceCeiling(it.key()) == DownloadSettings::UNLIMITED_HOST_QUOTA) { + // A developer-unlocked host that fell back after a 429 recovers towards the default + // allowance; once it gets there it becomes unlocked (fast-path) again. + if (it.value() + 1 >= DownloadSettings::DEFAULT_HOST_REQUEST_LIMIT) { + it = hostRequestQuota.erase(it); + continue; + } + it.value() += 1; + } else { + // Recover towards the host's effective allowance ceiling, which may be + // lowered by the user's per-host request limits. + it.value() = qMin(hostAllowanceCeiling(it.key()), it.value() + 1); + } } + ++it; } - for (const auto &request : requestLoadQueue) { - const QString host = request.first.host(); - hostQuotaRemaining.insert(host, hostRequestQuota.value(host, MAX_REQUESTS_PER_SEC)); - } + // Forget the per-second allowances; each host's allowance is re-seeded lazily from its + // reduced sustained quota the first time it is dispatched in the new second, so a host that + // enters the queue mid-second no longer falls through to a fresh full quota. + hostQuotaRemaining.clear(); - processQueuedRequests(); + updateTimerState(); } void CardPictureLoaderWorker::processQueuedRequests() { - while (requestQuota > 0 && processSingleRequest()) { - --requestQuota; + // QTimer must be started from the thread it lives in; if this public slot is ever reached from + // another thread, replay it on the worker's event loop instead of letting start() fail silently. + if (thread() != QThread::currentThread()) { + QMetaObject::invokeMethod(this, &CardPictureLoaderWorker::processQueuedRequests, Qt::QueuedConnection); + return; + } + updateTimerState(); +} + +void CardPictureLoaderWorker::dispatchQueuedRequest() +{ + if (requestLoadQueue.isEmpty()) { + // All queued requests have been dispatched; stop the pacing timers. + updateTimerState(); + return; + } + + QDateTime now = QDateTime::currentDateTime(); + bool dispatched = false; + // Set while an unlocked host still has queued work blocked only by the in-flight cap; the + // timer must keep running so it gets another try as soon as a slot frees. A host blocked by + // its 429 backoff instead waits for the next quota-reset tick to restart the dispatcher. + bool unlockedCapped = false; + + // Unlocked hosts (developer cap UNLIMITED_HOST_QUOTA) skip the pacing and the per-host + // allowance: dispatch their queued requests back-to-back, bounded by their 429 backoff and the + // per-host in-flight cap so a large burst can't queue replies that time out before Qt opens a + // connection for them. + for (int i = 0; i < requestLoadQueue.size();) { + const auto &request = requestLoadQueue.at(i); + // Dispatch decisions must key on the host the request will actually go to, not the URL that + // merely redirects to it: a redirect learned after this URL was queued would otherwise + // bypass the in-flight cap and drain the whole queue onto the target host unchecked. + const QUrl resolvedUrl = resolveCachedRedirect(request.first); + const QString host = resolvedUrl.host(); + if (isUnlockedHost(host)) { + if (CardPictureLoaderWorkerWork::rateLimiter().isRateLimited(host, now)) { + ++i; + continue; + } + if (hostInFlight.value(host) < MAX_IN_FLIGHT_PER_HOST) { + makeRequest(resolvedUrl, request.second); + requestLoadQueue.removeAt(i); + dispatched = true; + continue; + } + unlockedCapped = true; + } + ++i; + } + + if (requestLoadQueue.isEmpty()) { + dispatchTimer.stop(); + requestTimer.stop(); + return; + } + + if (processSingleRequest()) { + dispatched = true; + } + + // Keep the timer running while there is progress to make or unlocked work waiting on a free + // in-flight slot; otherwise no host has allowance left this second, so wait for the quota reset. + if (!dispatched && !unlockedCapped) { + dispatchTimer.stop(); + } +} + +void CardPictureLoaderWorker::updateTimerState() +{ + // Never restart an active timer: that would reset the pacing countdown and a burst of enqueues + // could keep starving the dispatcher, so only (re)start a timer that has actually stopped. + if (requestLoadQueue.isEmpty()) { + dispatchTimer.stop(); + // Forget per-second allowances once nothing is pending: a stale zero would otherwise delay + // the next single request by a full quota-reset interval. + hostQuotaRemaining.clear(); + } else if (!dispatchTimer.isActive()) { + dispatchTimer.start(); + } + + // The quota timer resets allowances every second and is also the only thing that heals a host + // after a 429 (see resetRequestQuota). It must keep ticking while work is queued or a host is + // still recovering below the ceiling, and only winds down once no host needs recovery anymore. + // Keeping it alive during such idle periods lets reduced quotas recover as intended. + bool hostRecovering = false; + for (auto it = hostRequestQuota.cbegin(); it != hostRequestQuota.cend(); ++it) { + if (it.value() < MAX_REQUESTS_PER_SEC) { + hostRecovering = true; + break; + } + } + + if (!requestLoadQueue.isEmpty() || hostRecovering) { + if (!requestTimer.isActive()) { + requestTimer.start(); + } + } else if (requestTimer.isActive()) { + requestTimer.stop(); } } bool CardPictureLoaderWorker::processSingleRequest() { + QDateTime now = QDateTime::currentDateTime(); for (int i = 0; i < requestLoadQueue.size(); ++i) { - const auto &request = requestLoadQueue.at(i); - QString host = request.first.host(); - int allowance = hostQuotaRemaining.value(host, MAX_REQUESTS_PER_SEC); + // Copy the entry: takeAt(i) below erases within the list this reference points into. + const auto request = requestLoadQueue.at(i); + // Resolve cached redirects so the rate-limit and allowance arithmetic keys on the host the + // request will actually hit (see resolveCachedRedirect). + const QUrl resolvedUrl = resolveCachedRedirect(request.first); + const QString host = resolvedUrl.host(); + // Don't dispatch requests to a host that is currently in its 429 backoff; hand the entry + // back to its worker so it can wait the backoff out or fall through to another source, + // instead of leaving it parked in the queue with no reply pending. Only applies to + // requests that will actually touch the network: one that will be served from the disk + // cache costs nothing and shouldn't wait out the 429. + if (requestTouchesNetwork(request.first) && + CardPictureLoaderWorkerWork::rateLimiter().isRateLimited(host, now)) { + // The queued URL is usually a cached-redirect target whose host differs from + // cardToDownload.getCurrentUrl(), so scheduleDeferredRetry() (which waits out the + // blocked host's deadline) is used instead of startNextPicDownload() looping on the + // original host. + auto entry = requestLoadQueue.takeAt(i); + if (host != entry.first.host()) { + // A cached redirect target is what is blocked, which the work object would not + // discover from its own URL; wait out that specific host (with jitter) instead. + entry.second->scheduleDeferredRetry(host); + } else { + entry.second->startNextPicDownload(); + } + return true; + } + // Unlocked hosts are handled by dispatchQueuedRequest's fast path, bounded by the in-flight + // cap; they must not fall through to the per-host allowance arithmetic below. + if (isUnlockedHost(host)) { + continue; + } + int ceiling = hostAllowanceCeiling(host); + if (ceiling == DownloadSettings::UNLIMITED_HOST_QUOTA) { + // A 429 dropped this unlocked host out of the fast path and installed a concrete + // allowance; pace it against that allowance until the recovery loop unlocks it again. + ceiling = hostRequestQuota.value(host, DownloadSettings::DEFAULT_HOST_REQUEST_LIMIT); + } + // Seed the allowance lazily so a host that enters the queue mid-second gets its reduced + // per-host allowance, clamped against the ceiling so a lowered user cap applies from this + // second onward. + if (!hostQuotaRemaining.contains(host)) { + hostQuotaRemaining.insert(host, qMin(ceiling, hostRequestQuota.value(host, ceiling))); + } + int allowance = hostQuotaRemaining.value(host); if (allowance > 0) { hostQuotaRemaining.insert(host, allowance - 1); - makeRequest(request.first, request.second); - requestLoadQueue.removeAt(i); + auto entry = requestLoadQueue.takeAt(i); + makeRequest(resolvedUrl, entry.second); return true; } } return false; } +bool CardPictureLoaderWorker::requestTouchesNetwork(const QUrl &url) const +{ + bool useNetworkCache = static_cast( + SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) == + CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE && + (cache->metaData(url).isValid() || !picDownload); + return !useNetworkCache; +} + +int CardPictureLoaderWorker::hostAllowanceCeiling(const QString &host) const +{ + const int devCap = DownloadSettings::getDeveloperHostCaps().value(host, MAX_REQUESTS_PER_SEC); + if (devCap == DownloadSettings::UNLIMITED_HOST_QUOTA && !hostRequestLimits.contains(host)) { + return DownloadSettings::UNLIMITED_HOST_QUOTA; + } + const int requested = hostRequestLimits.value(host, devCap); + return SettingsCache::instance().downloads().clampHostRequestLimit(host, requested); +} + +bool CardPictureLoaderWorker::isUnlockedHost(const QString &host) const +{ + return hostAllowanceCeiling(host) == DownloadSettings::UNLIMITED_HOST_QUOTA && !hostRequestQuota.contains(host); +} + void CardPictureLoaderWorker::onHostRateLimited(const QString &host) { - hostRequestQuota.insert(host, qMax(MIN_HOST_QUOTA, hostRequestQuota.value(host, MAX_REQUESTS_PER_SEC) / 2)); + const int ceiling = hostAllowanceCeiling(host); + // An unlocked host has no per-host allowance to halve. Install one instead so it drops out of + // the unlocked fast path and is paced like a throttled host; the recovery loop in + // resetRequestQuota() then walks it back up and unlocks it again. + const int base = + ceiling == DownloadSettings::UNLIMITED_HOST_QUOTA ? DownloadSettings::DEFAULT_HOST_REQUEST_LIMIT : ceiling; + hostRequestQuota.insert(host, qMax(MIN_HOST_QUOTA, hostRequestQuota.value(host, base) / 2)); hostLast429.insert(host, QDateTime::currentDateTime()); } @@ -225,6 +490,22 @@ QUrl CardPictureLoaderWorker::getCachedRedirect(const QUrl &originalUrl) const return {}; } +QUrl CardPictureLoaderWorker::resolveCachedRedirect(const QUrl &url) const +{ + // Follow the whole cached-redirect chain so dispatch keys on the host that is really hit. The + // depth bound keeps a corrupt or self-referencing cache entry from spinning us forever. + QUrl resolved = url; + int depth = 0; + while (depth++ < MAX_REDIRECT_CHAIN_DEPTH) { + QUrl target = getCachedRedirect(resolved); + if (target.isEmpty() || target == resolved) { + break; + } + resolved = target; + } + return resolved; +} + void CardPictureLoaderWorker::loadRedirectCache() { QSettings settings(cacheFilePath, QSettings::IniFormat); @@ -277,4 +558,5 @@ void CardPictureLoaderWorker::clearNetworkCache() { networkManager->cache()->clear(); redirectCache.clear(); + emit networkCacheCleared(); } diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.h index d1c519b7a..4b42c6e59 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.h @@ -1,21 +1,25 @@ #ifndef PICTURE_LOADER_WORKER_H #define PICTURE_LOADER_WORKER_H -#include "card_picture_loader_local.h" -#include "card_picture_loader_worker_work.h" -#include "card_picture_to_load.h" - -#include #include #include -#include -#include -#include #include +#include #include +#include +#include #include -#include -#include +#include +#include + +class CardPictureLoaderLocal; +class ExactCard; +class QDateTime; +class QImage; +class QNetworkAccessManager; +class QNetworkDiskCache; +class QNetworkReply; +class QThread; #define REDIRECT_HEADER_NAME "redirects" #define REDIRECT_ORIGINAL_URL "original" @@ -74,10 +78,37 @@ public: */ void onHostRateLimited(const QString &host); - /** @brief Clears the network cache and redirect cache. */ - void clearNetworkCache(); + /** + * @brief Stops the worker thread and reports whether it stopped. + * + * Called from the owning thread (CardPictureLoader) on its way out. QThread::quit() posts an + * exit request to the worker's event loop and QThread::wait() blocks (bounded) until the loop + * has returned and the thread finished. Only QThread members are touched here, so this method + * is safe to call from the owning thread. The worker object itself is freed by the finished() + * -> deleteLater chain (see the constructor); the QThread object is deleted afterwards by the + * owner (CardPictureLoader::~CardPictureLoader), not by this method. + * + * @return true if the thread stopped within the timeout, false if it is still running (in + * which case the owner must not delete the QThread). + */ + [[nodiscard]] bool shutdownThread(); + + /** + * @brief Returns the worker's QThread. + * @return The worker thread + * + * Only meaningful while the worker object is alive; capture it before calling shutdownThread(). + */ + QThread *workerThread() const; public slots: + /** + * @brief Clears the network cache and redirect cache. + * + * Runs on the worker thread; invoke it via a queued call when coming from another thread, + * since both caches are owned by the worker thread. + */ + void clearNetworkCache(); /** * @brief Makes a network request for the given URL using the specified worker. * @param url URL to load @@ -86,9 +117,12 @@ public slots: */ QNetworkReply *makeRequest(const QUrl &url, CardPictureLoaderWorkerWork *workThread); - /** @brief Processes all queued requests respecting the request quota. */ + /** @brief Ensures the pacing and quota-reset timers reflect the current queue and recovery state. */ void processQueuedRequests(); + /** @brief Chooses a request from the queue and starts it, respecting the quota and pacing. */ + void dispatchQueuedRequest(); + /** * @brief Processes a single queued request. * @return true if a request was processed, false if queue is empty. @@ -118,18 +152,56 @@ private: bool picDownload; ///< Whether downloading images from network is enabled QQueue> requestLoadQueue; ///< Queue of pending network requests - int requestQuota; ///< Remaining requests allowed per second QTimer requestTimer; ///< Timer to reset the request quota + QTimer dispatchTimer; ///< Timer pacing individual network requests QHash hostRequestQuota; ///< Sustained per-host request allowance + QHash hostRequestLimits; ///< User-set per-host request allowances QHash hostQuotaRemaining; ///< Per-host allowance left in the current second QHash hostLast429; ///< When each host was last rate limited + QHash hostInFlight; ///< Network replies currently in flight, per host + + /** @brief Maximum concurrent in-flight network replies per host. */ + static constexpr int MAX_IN_FLIGHT_PER_HOST = 6; + + /** @brief Bound on how many cached-redirect hops dispatch resolution will follow. */ + static constexpr int MAX_REDIRECT_CHAIN_DEPTH = 10; CardPictureLoaderLocal *localLoader; ///< Loader for local images QSet currentlyLoading; ///< Deduplication: contains pixmapCacheKey currently being loaded + /** + * @brief Effective per-host allowance ceiling for a host. + * @param host The host to look up + * @return The allowance ceiling in requests/second, or DownloadSettings::UNLIMITED_HOST_QUOTA + * when the developer unlocked the host and no user limit is set for it. + */ + [[nodiscard]] int hostAllowanceCeiling(const QString &host) const; + + /** + * @brief Whether a host may skip dispatch pacing and per-host allowance entirely. + * + * A host is unlocked while it has no user limit and no reduced allowance installed by a 429. + * A 429 drops it out of the fast path until resetRequestQuota() walks the allowance back up. + */ + [[nodiscard]] bool isUnlockedHost(const QString &host) const; + /** @brief Returns cached redirect URL for the given original URL, if available. */ [[nodiscard]] QUrl getCachedRedirect(const QUrl &originalUrl) const; + /** @brief Whether a request for this URL would actually touch the network, rather than being served from the disk + * cache. */ + [[nodiscard]] bool requestTouchesNetwork(const QUrl &url) const; + + /** + * @brief Follows the cached-redirect chain to the URL that will actually be requested. + * @param url The URL to resolve + * @return The final URL after chasing cached redirects, or @p url itself if none lead elsewhere + * + * Dispatch decisions (unlocked-host fast path, 429 backoff, in-flight cap) must key on the host + * a request really goes to, not the URL that merely redirects to it. + */ + [[nodiscard]] QUrl resolveCachedRedirect(const QUrl &url) const; + /** @brief Loads redirect cache from disk. */ void loadRedirectCache(); @@ -139,6 +211,9 @@ private: /** @brief Removes stale redirect entries older than TTL. */ void cleanStaleEntries(); + /** @brief Starts or stops the pacing and quota-reset timers to match the queue and recovery state. */ + void updateTimerState(); + private slots: /** @brief Resets the request quota for rate-limiting. */ void resetRequestQuota(); @@ -158,6 +233,9 @@ signals: /** @brief Emitted when a network request successfully completes. */ void imageRequestSucceeded(const QUrl &url); + + /** @brief Emitted after clearNetworkCache() has finished clearing both caches. */ + void networkCacheCleared(); }; #endif // PICTURE_LOADER_WORKER_H diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp index 66c56337c..92524384a 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp @@ -2,19 +2,35 @@ #include "../../client/settings/cache_settings.h" #include "card_picture_loader_worker.h" +#include "card_picture_to_load.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/utility/server_rate_limiter.h" #include -#include +#include +#include +#include +#include +#include +#include #include +#include #include #include +#include #include -#include -#include +#include +#include #include +#include +#include ServerRateLimiter CardPictureLoaderWorkerWork::s_rateLimiter; +#include #include +#include +#include // Card back returned by gatherer when card is not found static const QStringList MD5_BLACKLIST = { @@ -22,8 +38,13 @@ static const QStringList MD5_BLACKLIST = { "fbc7d763c08771c260b39e2115414eeb" // Current card back hash }; -CardPictureLoaderWorkerWork::CardPictureLoaderWorkerWork(const CardPictureLoaderWorker *worker, const ExactCard &toLoad) - : QObject(nullptr), cardToDownload(CardPictureToLoad(toLoad)), +const ServerRateLimiter &CardPictureLoaderWorkerWork::rateLimiter() +{ + return s_rateLimiter; +} + +CardPictureLoaderWorkerWork::CardPictureLoaderWorkerWork(CardPictureLoaderWorker *worker, const ExactCard &toLoad) + : QObject(worker), cardToDownload(CardPictureToLoad(toLoad)), picDownload(SettingsCache::instance().downloads().getPicDownload()) { // Hook up signals to the orchestrator @@ -168,7 +189,7 @@ void CardPictureLoaderWorkerWork::handleFailedReply(const QNetworkReply *reply) << "PictureLoader: [card: " << cardToDownload.getCard().getName() << " set: " << cardToDownload.getSetName() << "]: Too many requests from " << host << ", backing off until " << backoffUntil.toString(Qt::ISODate) << ", retrying the same url"; - scheduleDeferredRetry(); + scheduleDeferredRetry(host); } else { qCWarning(CardPictureLoaderWorkerWorkLog).nospace() << "PictureLoader: [card: " << cardToDownload.getCard().getName() @@ -273,14 +294,16 @@ QImage CardPictureLoaderWorkerWork::tryLoadImageFromReply(QNetworkReply *reply) return imgReader.read(); } -void CardPictureLoaderWorkerWork::scheduleDeferredRetry() +void CardPictureLoaderWorkerWork::scheduleDeferredRetry(const QString &preferredHost) { QDateTime now = QDateTime::currentDateTime(); - // Prefer waiting on the current URL's server so we retry the same source. - QString currentHost = QUrl(cardToDownload.getCurrentUrl()).host(); - QDateTime backoffUntil = s_rateLimiter.deadline(currentHost); - if (!s_rateLimiter.isRateLimited(currentHost, now)) { + // Prefer waiting on the server that is actually blocking the request: callers hand in the + // rate-limited host when it differs from the current URL (e.g. a cached redirect target still + // in backoff), otherwise fall back to the current URL's server so we retry the same source. + QString waitHost = preferredHost.isEmpty() ? QUrl(cardToDownload.getCurrentUrl()).host() : preferredHost; + QDateTime backoffUntil = s_rateLimiter.deadline(waitHost); + if (!s_rateLimiter.isRateLimited(waitHost, now)) { backoffUntil = s_rateLimiter.earliestDeadline(now); } diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.h b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.h index 1e56a4373..c3dbcc6e8 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.h @@ -1,18 +1,18 @@ #ifndef PICTURE_LOADER_WORKER_WORK_H #define PICTURE_LOADER_WORKER_WORK_H -#include "card_picture_loader_worker.h" #include "card_picture_to_load.h" -#include +#include #include -#include -#include #include -#include -#include -#include -#include +#include +#include + +class ExactCard; +class QNetworkReply; +class QUrl; +class ServerRateLimiter; inline Q_LOGGING_CATEGORY(CardPictureLoaderWorkerWorkLog, "card_picture_loader.worker"); @@ -36,13 +36,36 @@ class CardPictureLoaderWorkerWork : public QObject public: /** * @brief Constructs a worker for downloading a specific card image. - * @param worker The orchestrating CardPictureLoaderWorker + * @param worker The orchestrating CardPictureLoaderWorker; the work object becomes its child so + * it is destroyed with the worker even if it never reaches concludeImageLoad(). * @param toLoad The ExactCard to download */ - explicit CardPictureLoaderWorkerWork(const CardPictureLoaderWorker *worker, const ExactCard &toLoad); + explicit CardPictureLoaderWorkerWork(CardPictureLoaderWorker *worker, const ExactCard &toLoad); CardPictureToLoad cardToDownload; ///< The card and associated URLs to try downloading + /** @brief Shared per-server 429 backoff state. */ + static const ServerRateLimiter &rateLimiter(); + + /** + * @brief Starts downloading the next URL for this card. + * + * Skips URLs whose server is currently in 429 backoff, either waiting the + * backoff out or falling through to the other configured sources. + */ + void startNextPicDownload(); + + /** + * @brief Schedules a deferred retry after the relevant server backoff expires. + * @param preferredHost The server that is actually blocking the request, or an empty + * string to use the current URL's server + * + * Waits on the blocking server's backoff deadline, otherwise on the earliest active + * backoff. If no servers are in backoff, concludes with failure. Otherwise resets the + * CardPictureToLoad indices and retries after the backoff period. + */ + void scheduleDeferredRetry(const QString &preferredHost = {}); + public slots: /** * @brief Handles a finished network reply for the card image. @@ -55,9 +78,6 @@ private: static ServerRateLimiter s_rateLimiter; ///< Shared per-server 429 backoff state - /** @brief Starts downloading the next URL for this card. */ - void startNextPicDownload(); - /** @brief Called when all URLs have been exhausted or download failed. */ void picDownloadFailed(); @@ -82,16 +102,6 @@ private: */ void concludeImageLoad(const QImage &image); - /** - * @brief Schedules a deferred retry after the relevant server backoff expires. - * - * Waits on the current URL's server when it is the reason we are blocked, - * otherwise on the earliest active backoff. If no servers are in backoff, - * concludes with failure. Otherwise resets the CardPictureToLoad indices and - * retries after the backoff period. - */ - void scheduleDeferredRetry(); - private slots: /** @brief Updates the picDownload setting when it changes. */ void picDownloadChanged(); diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp b/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp index 5f4ff0bbd..e35c417c8 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp @@ -1,12 +1,22 @@ #include "card_picture_to_load.h" #include "../../client/settings/cache_settings.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/card/printing/printing_info.h" +#include "libcockatrice/card/set/card_set.h" -#include #include +#include +#include +#include #include +#include +#include +#include #include +#include #include +#include #include #include #include @@ -282,8 +292,15 @@ QString CardPictureToLoad::transformUrl(const QString &urlTemplate) const } // language setting - transformMap["!sflang!"] = QString(QCoreApplication::translate( - "PictureLoader", "en", "code for scryfall's language property, not available for all languages")); + const QString cardLang = SettingsCache::instance().cardsDisplay().getCardLang(); + transformMap["!sflang!"] = cardLang; + + // The localized printing's own id is unknown, so Scryfall must resolve it by + // its translated name (see populateSetUrls); expose that name for the + // `/cards/named` template. + if (cardLang != "en") { + transformMap["!localizedName!"] = card.getInfo().getLocalizedName(cardLang); + } QString transformedUrl = urlTemplate; for (const QString &prop : transformMap.keys()) { diff --git a/cockatrice/src/interface/card_picture_loader/card_picture_to_load.h b/cockatrice/src/interface/card_picture_loader/card_picture_to_load.h index 9e0e7449c..fe56dc4c6 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_to_load.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_to_load.h @@ -1,6 +1,12 @@ #ifndef PICTURE_TO_LOAD_H #define PICTURE_TO_LOAD_H +#include "libcockatrice/card/card_info.h" + +#include +#include +#include +#include #include inline Q_LOGGING_CATEGORY(CardPictureToLoadLog, "card_picture_loader.picture_to_load"); diff --git a/cockatrice/src/interface/deck_loader/card_node_function.cpp b/cockatrice/src/interface/deck_loader/card_node_function.cpp index 9441fe428..8d59a6c15 100644 --- a/cockatrice/src/interface/deck_loader/card_node_function.cpp +++ b/cockatrice/src/interface/deck_loader/card_node_function.cpp @@ -1,5 +1,12 @@ #include "card_node_function.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/printing_info.h" +#include "libcockatrice/card/set/card_set.h" + +#include +#include +#include #include #include #include @@ -8,13 +15,14 @@ void CardNodeFunction::SetProviderIdToPreferred::operator()(const InnerDecklistN { Q_UNUSED(node); PrintingInfo preferredPrinting = CardDatabaseManager::query()->getPreferredPrinting(card->getName()); - QString providerId = preferredPrinting.getUuid(); - QString setShortName = preferredPrinting.getSet()->getShortName(); - QString collectorNumber = preferredPrinting.getProperty("num"); + CardSetPtr preferredSet = preferredPrinting.getSet(); + if (!preferredSet) { + return; + } - card->setCardProviderId(providerId); - card->setCardCollectorNumber(collectorNumber); - card->setCardSetShortName(setShortName); + card->setCardProviderId(preferredPrinting.getUuid()); + card->setCardCollectorNumber(preferredPrinting.getProperty("num")); + card->setCardSetShortName(preferredSet->getShortName()); } void CardNodeFunction::ClearPrintingData::operator()(const InnerDecklistNode *node, DecklistCardNode *card) const diff --git a/cockatrice/src/interface/deck_loader/deck_file_format.cpp b/cockatrice/src/interface/deck_loader/deck_file_format.cpp index 3c88cde4e..ba5a44e4c 100644 --- a/cockatrice/src/interface/deck_loader/deck_file_format.cpp +++ b/cockatrice/src/interface/deck_loader/deck_file_format.cpp @@ -1,5 +1,7 @@ #include "deck_file_format.h" +#include + DeckFileFormat::Format DeckFileFormat::getFormatFromName(const QString &fileName) { if (fileName.endsWith(".cod", Qt::CaseInsensitive)) { diff --git a/cockatrice/src/interface/deck_loader/deck_file_format.h b/cockatrice/src/interface/deck_loader/deck_file_format.h index 995de32c0..3a25797ec 100644 --- a/cockatrice/src/interface/deck_loader/deck_file_format.h +++ b/cockatrice/src/interface/deck_loader/deck_file_format.h @@ -17,7 +17,7 @@ enum Format PlainText, /** - * This is cockatrice's native deck file format, and supports deck metadata such as banner cards and tags. + * This is Cockatrice's native deck file format, and supports deck metadata such as banner cards and tags. * Stored as .cod files. */ Cockatrice diff --git a/cockatrice/src/interface/deck_loader/deck_loader.cpp b/cockatrice/src/interface/deck_loader/deck_loader.cpp index 39a0c1071..d413a6c04 100644 --- a/cockatrice/src/interface/deck_loader/deck_loader.cpp +++ b/cockatrice/src/interface/deck_loader/deck_loader.cpp @@ -1,26 +1,51 @@ #include "deck_loader.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/deck_list/tree/abstract_deck_list_card_node.h" +#include "libcockatrice/deck_list/tree/abstract_deck_list_node.h" +#include "libcockatrice/deck_list/tree/inner_deck_list_node.h" + #include #include +#include #include #include #include +#include #include -#include +#include +#include +#include +#include +#include +#include +#include #include #include #include +#include +#include #include +#include +#include #include #include +#include #include #include +#include +#include +#include #include -#include +#include #include #include #include #include +#include + +class QTextLength; const QStringList DeckLoader::ACCEPTED_FILE_EXTENSIONS = {"*.cod", "*.dec", "*.dek", "*.txt", "*.mwDeck"}; @@ -50,7 +75,7 @@ DeckLoader::loadFromFile(const QString &fileName, DeckFileFormat::Format fmt, bo result = deckList.loadFromFile_Native(&file); if (!result) { qCInfo(DeckLoaderLog) << "Failed to load " << fileName - << "as cockatrice format; retrying as plain format"; + << "as Cockatrice format; retrying as plain format"; file.seek(0); result = deckList.loadFromFile_Plain(&file, CardNameNormalizer()); fmt = DeckFileFormat::PlainText; @@ -375,15 +400,32 @@ void DeckLoader::saveToStream_DeckHeader(QTextStream &out, const DeckList &deckL void DeckLoader::saveToStream_DeckZone(QTextStream &out, const InnerDecklistNode *zoneNode, bool addComments, - bool addSetNameAndNumber) + bool addSetNameAndNumber, + const QString &boardZoneName) { + // Nested sub-zones keep their owning board's identity: the top-level call + // passes no board, so the zone's own name is used; recursive calls carry the + // owning board down so the sideboard marker survives sub-zone nesting. + const QString owningBoardZoneName = boardZoneName.isEmpty() ? zoneNode->getName() : boardZoneName; + // group cards by card type and count the subtotals QMultiMap cardsByType; QMap cardTotalByType; int cardTotal = 0; + QList subZones; for (int j = 0; j < zoneNode->size(); j++) { auto *card = dynamic_cast(zoneNode->at(j)); + if (!card) { + // Cards collected in nested sub-zones are exported by recursion so + // they don't end up invisible in the plain text output. They are + // deferred until after this zone's own header and cards so they read + // as part of this zone's block. + if (auto *subZone = dynamic_cast(zoneNode->at(j))) { + subZones.append(subZone); + } + continue; + } CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(card->getName()); QString cardType = info ? info->getMainCardType() : "unknown"; @@ -411,25 +453,30 @@ void DeckLoader::saveToStream_DeckZone(QTextStream &out, QList cards = cardsByType.values(cardType); - saveToStream_DeckZoneCards(out, zoneNode, cards, addComments, addSetNameAndNumber); + saveToStream_DeckZoneCards(out, cards, addComments, addSetNameAndNumber, owningBoardZoneName); if (addComments) { out << "\n"; } } + + // Nested sub-zones come last, after the parent's own header and cards. + for (const auto *subZone : subZones) { + saveToStream_DeckZone(out, subZone, addComments, addSetNameAndNumber, owningBoardZoneName); + } } void DeckLoader::saveToStream_DeckZoneCards(QTextStream &out, - const InnerDecklistNode *zoneNode, QList cards, bool addComments, - bool addSetNameAndNumber) + bool addSetNameAndNumber, + const QString &boardZoneName) { // QMultiMap sorts values in reverse order for (int i = cards.size() - 1; i >= 0; --i) { DecklistCardNode *card = cards[i]; - if (zoneNode->getName() == DECK_ZONE_SIDE && addComments) { + if (boardZoneName == DECK_ZONE_SIDE && addComments) { out << "SB: "; } @@ -510,9 +557,26 @@ bool DeckLoader::convertToCockatriceFormat(LoadedDeck &deck) void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode *node) { + if (!node || node->isEmpty()) { + return; + } + const int totalColumns = 2; - if (node->height() == 1) { + // Dispatch children by type instead of trusting a whole-node height: a deck + // node may hold direct cards and nested zones side by side (custom zones), + // and an empty node would previously crash on at(0). + QVector cards; + QVector subZones; + for (int i = 0; i < node->size(); i++) { + if (auto *card = dynamic_cast(node->at(i))) { + cards.append(card); + } else if (auto *zone = dynamic_cast(node->at(i))) { + subZones.append(zone); + } + } + + if (!cards.isEmpty()) { QTextBlockFormat blockFormat; QTextCharFormat charFormat; charFormat.setFontPointSize(11); @@ -523,9 +587,9 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode tableFormat.setCellPadding(0); tableFormat.setCellSpacing(0); tableFormat.setBorder(0); - QTextTable *table = cursor->insertTable(node->size() + 1, totalColumns, tableFormat); - for (int i = 0; i < node->size(); i++) { - auto *card = dynamic_cast(node->at(i)); + QTextTable *table = cursor->insertTable(cards.size() + 1, totalColumns, tableFormat); + for (int i = 0; i < cards.size(); i++) { + const AbstractDecklistCardNode *card = cards[i]; QTextCharFormat cellCharFormat; cellCharFormat.setFontPointSize(9); @@ -540,7 +604,13 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode cellCursor = cell.firstCursorPosition(); cellCursor.insertText(card->getName()); } - } else if (node->height() == 2) { + } + + for (const InnerDecklistNode *subZone : subZones) { + if (subZone->isEmpty()) { + continue; + } + QTextBlockFormat blockFormat; QTextCharFormat charFormat; charFormat.setFontPointSize(14); @@ -559,10 +629,8 @@ void DeckLoader::printDeckListNode(QTextCursor *cursor, const InnerDecklistNode tableFormat.setColumnWidthConstraints(constraints); QTextTable *table = cursor->insertTable(1, totalColumns, tableFormat); - for (int i = 0; i < node->size(); i++) { - QTextCursor cellCursor = table->cellAt(0, (i * totalColumns) / node->size()).lastCursorPosition(); - printDeckListNode(&cellCursor, dynamic_cast(node->at(i))); - } + QTextCursor cellCursor = table->cellAt(0, 0).firstCursorPosition(); + printDeckListNode(&cellCursor, subZone); } cursor->movePosition(QTextCursor::End); diff --git a/cockatrice/src/interface/deck_loader/deck_loader.h b/cockatrice/src/interface/deck_loader/deck_loader.h index ac23e1ee0..16402b14b 100644 --- a/cockatrice/src/interface/deck_loader/deck_loader.h +++ b/cockatrice/src/interface/deck_loader/deck_loader.h @@ -7,13 +7,23 @@ #ifndef DECK_LOADER_H #define DECK_LOADER_H +#include "deck_file_format.h" #include "loaded_deck.h" #include -#include -#include -#include +#include +#include +#include #include +#include + +class DeckList; +class DecklistCardNode; +class InnerDecklistNode; +class QPrinter; +class QTextCursor; +class QTextStream; +template class QList; inline Q_LOGGING_CATEGORY(DeckLoaderLog, "deck_loader"); @@ -131,7 +141,7 @@ public: static void printDeckList(QPrinter *printer, const DeckList &deckList); /** - * Converts the given deck's file to the cockatrice file format. + * Converts the given deck's file to the Cockatrice file format. * Uses the lastLoadInfo in the LoadedDeck to determine the current name of the file and where to save to. * @param deck The deck to convert. Should have valid lastLoadInfo. Will update the lastLoadInfo. * @return Whether the conversion succeeded. @@ -159,12 +169,13 @@ private: static void saveToStream_DeckZone(QTextStream &out, const InnerDecklistNode *zoneNode, bool addComments = true, - bool addSetNameAndNumber = true); + bool addSetNameAndNumber = true, + const QString &boardZoneName = QString()); static void saveToStream_DeckZoneCards(QTextStream &out, - const InnerDecklistNode *zoneNode, QList cards, bool addComments = true, - bool addSetNameAndNumber = true); + bool addSetNameAndNumber = true, + const QString &boardZoneName = QString()); }; #endif diff --git a/cockatrice/src/interface/intents/contexts/context_join_game.h b/cockatrice/src/interface/intents/contexts/context_join_game.h index 102e2a520..2e5a88ea2 100644 --- a/cockatrice/src/interface/intents/contexts/context_join_game.h +++ b/cockatrice/src/interface/intents/contexts/context_join_game.h @@ -6,6 +6,7 @@ struct ContextJoinGame { ContextJoinRoom roomContext; int gameId; + bool asSpectator = false; }; #endif // COCKATRICE_CONTEXT_JOIN_GAME_H diff --git a/cockatrice/src/interface/intents/contexts/context_open_deck.h b/cockatrice/src/interface/intents/contexts/context_open_deck.h new file mode 100644 index 000000000..03dca088e --- /dev/null +++ b/cockatrice/src/interface/intents/contexts/context_open_deck.h @@ -0,0 +1,14 @@ +#ifndef COCKATRICE_CONTEXT_OPEN_DECK_H +#define COCKATRICE_CONTEXT_OPEN_DECK_H + +#include "context_connect_to_server.h" + +#include + +struct ContextOpenDeck +{ + ContextConnectToServer serverContext; + QString shareToken; +}; + +#endif // COCKATRICE_CONTEXT_OPEN_DECK_H diff --git a/cockatrice/src/interface/intents/intent.cpp b/cockatrice/src/interface/intents/intent.cpp index c02a89f35..db0d13b2c 100644 --- a/cockatrice/src/interface/intents/intent.cpp +++ b/cockatrice/src/interface/intents/intent.cpp @@ -2,10 +2,11 @@ Intent::Intent(QObject *parent) : QObject(parent) { - // An intent is done as soon as it reports success or failure. Deleting it - // also tears down its dependency chain and disconnects any signal wiring. + // An intent is done as soon as it reports success, failure, or cancellation. + // Deleting it also tears down its dependency chain and disconnects any signal wiring. connect(this, &Intent::finished, this, &QObject::deleteLater); connect(this, &Intent::failed, this, &QObject::deleteLater); + connect(this, &Intent::cancelled, this, &QObject::deleteLater); } Intent::~Intent() = default; @@ -27,6 +28,7 @@ void Intent::runDependency(Intent *dependency) this->execute(); }); connect(dependency, &Intent::failed, this, &Intent::failed); + connect(dependency, &Intent::cancelled, this, &Intent::cancelled); dependency->execute(); } @@ -46,3 +48,11 @@ void Intent::emitFailed(const QString &reason) emit failed(reason); } } + +void Intent::emitCancelled() +{ + if (!completed) { + completed = true; + emit cancelled(); + } +} diff --git a/cockatrice/src/interface/intents/intent.h b/cockatrice/src/interface/intents/intent.h index 125900ecd..ad8d4b5af 100644 --- a/cockatrice/src/interface/intents/intent.h +++ b/cockatrice/src/interface/intents/intent.h @@ -2,6 +2,8 @@ #define COCKATRICE_INTENT_H #include +#include +#include class Intent : public QObject { @@ -16,6 +18,7 @@ public: signals: void finished(); void failed(QString reason); + void cancelled(); protected: // --- Subclasses must implement these --- @@ -29,6 +32,7 @@ protected: // Emit the outcome exactly once; ignore late signals after the intent is done. void emitFinished(); void emitFailed(const QString &reason); + void emitCancelled(); private: bool completed = false; diff --git a/cockatrice/src/interface/intents/intent_connect_to_server.cpp b/cockatrice/src/interface/intents/intent_connect_to_server.cpp index 1cccc5a23..42ddc97cc 100644 --- a/cockatrice/src/interface/intents/intent_connect_to_server.cpp +++ b/cockatrice/src/interface/intents/intent_connect_to_server.cpp @@ -1,8 +1,14 @@ #include "intent_connect_to_server.h" +#include "contexts/context_connect_to_server.h" #include "intent_disconnect_from_server.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "remote_client.h" #include +#include + +template class QList; IntentConnectToServer::IntentConnectToServer(RemoteClient *_remoteClient, ContextConnectToServer *_context) : Intent(), remoteClient(_remoteClient), context(_context) diff --git a/cockatrice/src/interface/intents/intent_connect_to_server.h b/cockatrice/src/interface/intents/intent_connect_to_server.h index eab4d1a21..50d7f088b 100644 --- a/cockatrice/src/interface/intents/intent_connect_to_server.h +++ b/cockatrice/src/interface/intents/intent_connect_to_server.h @@ -1,9 +1,14 @@ #ifndef COCKATRICE_INTENT_CONNECT_TO_SERVER_H #define COCKATRICE_INTENT_CONNECT_TO_SERVER_H -#include "contexts/context_connect_to_server.h" +#include "abstract_client.h" #include "intent.h" -#include "remote_client.h" + +#include +#include + +class RemoteClient; +struct ContextConnectToServer; class IntentConnectToServer : public Intent { diff --git a/cockatrice/src/interface/intents/intent_disconnect_from_server.cpp b/cockatrice/src/interface/intents/intent_disconnect_from_server.cpp index cb39d7bab..e865c4a2b 100644 --- a/cockatrice/src/interface/intents/intent_disconnect_from_server.cpp +++ b/cockatrice/src/interface/intents/intent_disconnect_from_server.cpp @@ -1,5 +1,7 @@ #include "intent_disconnect_from_server.h" +#include "remote_client.h" + IntentDisconnectFromServer::IntentDisconnectFromServer(RemoteClient *_remoteClient) : Intent(), remoteClient(_remoteClient) { diff --git a/cockatrice/src/interface/intents/intent_disconnect_from_server.h b/cockatrice/src/interface/intents/intent_disconnect_from_server.h index 6e1dfd0c1..5425e973b 100644 --- a/cockatrice/src/interface/intents/intent_disconnect_from_server.h +++ b/cockatrice/src/interface/intents/intent_disconnect_from_server.h @@ -1,8 +1,12 @@ #ifndef COCKATRICE_INTENT_DISCONNECT_FROM_SERVER_H #define COCKATRICE_INTENT_DISCONNECT_FROM_SERVER_H +#include "abstract_client.h" #include "intent.h" -#include "remote_client.h" + +#include + +class RemoteClient; class IntentDisconnectFromServer : public Intent { diff --git a/cockatrice/src/interface/intents/intent_join_server_game.cpp b/cockatrice/src/interface/intents/intent_join_server_game.cpp index fb9c4d5ce..52e02b276 100644 --- a/cockatrice/src/interface/intents/intent_join_server_game.cpp +++ b/cockatrice/src/interface/intents/intent_join_server_game.cpp @@ -3,9 +3,17 @@ #include "../widgets/server/game_selector.h" #include "../widgets/tabs/tab_room.h" #include "../widgets/tabs/tab_supervisor.h" +#include "abstract_client.h" +#include "contexts/context_connect_to_server.h" +#include "contexts/context_join_room.h" #include "intent_join_server_room.h" +#include "remote_client.h" +#include +#include +#include #include +#include IntentJoinServerGame::IntentJoinServerGame(TabSupervisor *_tabSupervisor, RemoteClient *_remoteClient, @@ -19,13 +27,15 @@ bool IntentJoinServerGame::checkPrecondition() const if (remoteClient->getStatus() != ClientStatus::StatusLoggedIn) { return false; } - // peerPort() reflects the actual TCP peer, which may differ from the - // configured server port (e.g. when connecting through a proxy), so only - // the hostname is compared here. - if (remoteClient->peerName() != context->roomContext.serverContext.hostname) { + // serverName()/serverPort() reflect the server the client was configured + // to connect to, which may differ from the actual TCP peer (e.g. when + // connecting through a proxy), so compare those configured values. A link + // naming the same host on another port is a different server and must not + // reuse the session there. + if (remoteClient->serverName().compare(context->roomContext.serverContext.hostname, Qt::CaseInsensitive) != 0) { return false; } - if (QString::number(remoteClient->peerPort()) != context->roomContext.serverContext.port) { + if (QString::number(remoteClient->serverPort()) != context->roomContext.serverContext.port) { return false; } @@ -55,7 +65,7 @@ bool IntentJoinServerGame::tryJoinGame(TabRoom *room) return false; } - if (room->getGameSelector()->joinGameById(context->gameId)) { + if (room->getGameSelector()->joinGameById(context->gameId, context->asSpectator)) { emitFinished(); return true; } diff --git a/cockatrice/src/interface/intents/intent_join_server_game.h b/cockatrice/src/interface/intents/intent_join_server_game.h index 5e196df38..5abb31fc5 100644 --- a/cockatrice/src/interface/intents/intent_join_server_game.h +++ b/cockatrice/src/interface/intents/intent_join_server_game.h @@ -3,13 +3,14 @@ #include "contexts/context_join_game.h" #include "intent.h" -#include "remote_client.h" #include #include +#include class TabRoom; class TabSupervisor; +class RemoteClient; class IntentJoinServerGame : public Intent { diff --git a/cockatrice/src/interface/intents/intent_join_server_room.cpp b/cockatrice/src/interface/intents/intent_join_server_room.cpp index d25bc8d17..24349cadf 100644 --- a/cockatrice/src/interface/intents/intent_join_server_room.cpp +++ b/cockatrice/src/interface/intents/intent_join_server_room.cpp @@ -3,8 +3,15 @@ #include "../widgets/tabs/tab_room.h" #include "../widgets/tabs/tab_server.h" #include "../widgets/tabs/tab_supervisor.h" +#include "abstract_client.h" +#include "contexts/context_connect_to_server.h" +#include "contexts/context_join_room.h" #include "intent_connect_to_server.h" +#include "remote_client.h" +#include +#include +#include #include #include diff --git a/cockatrice/src/interface/intents/intent_join_server_room.h b/cockatrice/src/interface/intents/intent_join_server_room.h index 4a5599896..b4febae7b 100644 --- a/cockatrice/src/interface/intents/intent_join_server_room.h +++ b/cockatrice/src/interface/intents/intent_join_server_room.h @@ -1,11 +1,13 @@ #ifndef COCKATRICE_INTENT_JOIN_SERVER_ROOM_H #define COCKATRICE_INTENT_JOIN_SERVER_ROOM_H -#include "contexts/context_join_room.h" #include "intent.h" -#include "remote_client.h" + +#include class TabSupervisor; +class RemoteClient; +struct ContextJoinRoom; class IntentJoinServerRoom : public Intent { diff --git a/cockatrice/src/interface/intents/intent_login.cpp b/cockatrice/src/interface/intents/intent_login.cpp index ff871fd03..0d8390015 100644 --- a/cockatrice/src/interface/intents/intent_login.cpp +++ b/cockatrice/src/interface/intents/intent_login.cpp @@ -1,9 +1,18 @@ #include "intent_login.h" #include "../../client/settings/cache_settings.h" +#include "../widgets/dialogs/dlg_login_prompt.h" +#include "contexts/context_connect_to_server.h" #include "libcockatrice/settings/servers_settings.h" -IntentGetLoginCredentials::IntentGetLoginCredentials(ContextConnectToServer *_context) : Intent(), context(_context) +#include +#include +#include +#include + +IntentGetLoginCredentials::IntentGetLoginCredentials(ContextConnectToServer *_context, + bool _promptForMissingCredentials) + : Intent(), context(_context), promptForMissingCredentials(_promptForMissingCredentials) { } @@ -29,5 +38,46 @@ void IntentGetLoginCredentials::onPreconditionSatisfied() void IntentGetLoginCredentials::onPreconditionNotSatisfied() { - emitFailed(tr("No saved credentials for this server")); + // MainWindow::applyStartupDestination runs this intent on every launch for + // users whose startup tab is Server / Server Room; keep that path quiet, as + // it was before the link-driven sign-in dialog existed. + if (!promptForMissingCredentials) { + emitFailed(tr("No saved credentials for this server")); + return; + } + + // No credentials saved for the target server: ask the user for them. They + // opt into saving them so later links to the same server connect directly. + const QString serverText = context->hostname + ":" + context->port; + DlgLoginPrompt dialog(serverText); + // ApplicationModal: the dialog has no parent (the intent is not a widget), + // so WindowModal would not actually block any other window. + dialog.setWindowModality(Qt::ApplicationModal); + + if (dialog.exec() != QDialog::Accepted) { + emitCancelled(); + return; + } + + context->username = dialog.username(); + context->password = dialog.password(); + + if (dialog.savePassword() && !context->username.isEmpty()) { + ServersSettings &servers = SettingsCache::instance().servers(); + // The host may already be saved under a friendly name (e.g. a public-server + // list entry) with no credentials; reuse that name instead of overwriting + // it with the raw hostname when addNewServer updates the entry in place. + QString saveName = context->hostname; + const int existingIndex = servers.findServerIndex(context->hostname, context->port); + if (existingIndex >= 0) { + saveName = + servers.getValue(QString("saveName%1").arg(existingIndex), "server", "server_details").toString(); + if (saveName.isEmpty()) { + saveName = context->hostname; + } + } + servers.addNewServer(saveName, context->hostname, context->port, context->username, context->password, true); + } + + emitFinished(); } diff --git a/cockatrice/src/interface/intents/intent_login.h b/cockatrice/src/interface/intents/intent_login.h index c7fec92b7..a805f1d75 100644 --- a/cockatrice/src/interface/intents/intent_login.h +++ b/cockatrice/src/interface/intents/intent_login.h @@ -1,15 +1,21 @@ #ifndef COCKATRICE_INTENT_LOGIN_H #define COCKATRICE_INTENT_LOGIN_H -#include "contexts/context_connect_to_server.h" #include "intent.h" +#include + +struct ContextConnectToServer; + class IntentGetLoginCredentials : public Intent { Q_OBJECT public: - IntentGetLoginCredentials(ContextConnectToServer *_context); + // When promptForMissingCredentials is false (the default) a server without + // saved credentials fails silently; only intent chains from cockatrice:// + // links opt into the interactive sign-in dialog. + explicit IntentGetLoginCredentials(ContextConnectToServer *_context, bool _promptForMissingCredentials = false); protected: bool checkPrecondition() const override; @@ -18,6 +24,7 @@ protected: private: ContextConnectToServer *context; + bool promptForMissingCredentials; }; #endif // COCKATRICE_INTENT_LOGIN_H diff --git a/cockatrice/src/interface/intents/intent_open_local_deck.cpp b/cockatrice/src/interface/intents/intent_open_local_deck.cpp index 2457bec72..5b673d106 100644 --- a/cockatrice/src/interface/intents/intent_open_local_deck.cpp +++ b/cockatrice/src/interface/intents/intent_open_local_deck.cpp @@ -2,10 +2,14 @@ #include "../deck_loader/deck_file_format.h" #include "../deck_loader/deck_loader.h" +#include "../deck_loader/loaded_deck.h" #include "../widgets/tabs/tab_supervisor.h" #include "intent_wait_for_database_load.h" +#include "libcockatrice/card/database/card_database.h" +#include "libcockatrice/card/database/card_database_loader.h" #include +#include IntentOpenLocalDeck::IntentOpenLocalDeck(TabSupervisor *_tabSupervisor, const QString &_file) : Intent(), tabSupervisor(_tabSupervisor), file(_file) diff --git a/cockatrice/src/interface/intents/intent_open_local_deck.h b/cockatrice/src/interface/intents/intent_open_local_deck.h index 97f875e39..0338e9384 100644 --- a/cockatrice/src/interface/intents/intent_open_local_deck.h +++ b/cockatrice/src/interface/intents/intent_open_local_deck.h @@ -4,6 +4,7 @@ #include "intent.h" #include +#include class TabSupervisor; diff --git a/cockatrice/src/interface/intents/intent_open_server_room_by_name.cpp b/cockatrice/src/interface/intents/intent_open_server_room_by_name.cpp new file mode 100644 index 000000000..63aef004b --- /dev/null +++ b/cockatrice/src/interface/intents/intent_open_server_room_by_name.cpp @@ -0,0 +1,192 @@ +#include "intent_open_server_room_by_name.h" + +#include "../widgets/tabs/tab_room.h" +#include "../widgets/tabs/tab_supervisor.h" +#include "contexts/context_connect_to_server.h" +#include "intent_connect_to_server.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_room.pb.h" +#include "remote_client.h" + +#include +#include +#include +#include +#include +#include + +class CommandContainer; +class QVariant; + +IntentOpenServerRoomByName::IntentOpenServerRoomByName(TabSupervisor *_tabSupervisor, + RemoteClient *_remoteClient, + std::unique_ptr _context, + const QString &_roomName) + : Intent(), tabSupervisor(_tabSupervisor), remoteClient(_remoteClient), context(_context.release()), + roomName(_roomName) +{ + checkTimer.setInterval(250); + connect(&checkTimer, &QTimer::timeout, this, [this]() { + if (selectOpenRoom()) { + checkTimer.stop(); + } + }); +} + +bool IntentOpenServerRoomByName::checkPrecondition() const +{ + if (remoteClient->getStatus() != ClientStatus::StatusLoggedIn) { + return false; + } + // peerPort() reflects the actual TCP peer, which may differ from the + // configured server port (e.g. when connecting through a proxy), so only + // the hostname is compared here. + if (remoteClient->peerName() != context->serverContext.hostname) { + return false; + } + if (QString::number(remoteClient->peerPort()) != context->serverContext.port) { + return false; + } + + return true; +} + +void IntentOpenServerRoomByName::onPreconditionSatisfied() +{ + if (listening) { + return; + } + listening = true; + + if (selectOpenRoom()) { + return; + } + + // The room selector is the component that requests the room list, so the + // server tab must exist for the room to be resolved by name. + if (!tabSupervisor->getTabServer()) { + tabSupervisor->openTabServer(); + } + if (!tabSupervisor->getTabServer()) { + emitFailed(tr("No server tab available")); + return; + } + + connect(remoteClient, &RemoteClient::listRoomsEventReceived, this, &IntentOpenServerRoomByName::processListRooms); + connect(remoteClient, &RemoteClient::statusChanged, this, &IntentOpenServerRoomByName::onClientStatusChanged); + + // The room tab may be opened by our own join, by the room selector's auto-join, or by a + // join that was already in flight. Poll until it shows up. + checkTimer.start(); + + // While no join has been sent yet, keep the room list fresh: the list may have been + // requested before we subscribed to it, or a response may have been dropped during a + // busy login burst. A stale list would otherwise leave the room unresolved forever. + connect(&refreshTimer, &QTimer::timeout, this, [this]() { + if (!joinPending) { + remoteClient->sendCommand(remoteClient->prepareSessionCommand(Command_ListRooms())); + } + }); + refreshTimer.setInterval(5000); + refreshTimer.start(); + + // Last-resort failure for "the room genuinely is not in a fresh list". This must NOT + // fire while a join is in flight: a loaded server may take longer than that to answer + // during a login burst, and killing the intent early would leave the connection + // registered in the room with no tab to display it and every later join attempt + // would then be rejected with RespContextError. + QTimer::singleShot(20000, this, [this]() { + if (!joinPending) { + emitFailed(tr("Timed out while looking for the server room %1").arg(roomName)); + } + }); +} + +void IntentOpenServerRoomByName::onPreconditionNotSatisfied() +{ + runDependency(new IntentConnectToServer(remoteClient, &context->serverContext)); +} + +void IntentOpenServerRoomByName::onClientStatusChanged(ClientStatus status) +{ + if (status != ClientStatus::StatusLoggedIn) { + emitFailed(tr("Disconnected while looking for the server room %1").arg(roomName)); + } +} + +bool IntentOpenServerRoomByName::selectOpenRoom() +{ + const auto &roomTabs = tabSupervisor->getRoomTabs(); + for (auto i = roomTabs.cbegin(), end = roomTabs.cend(); i != end; ++i) { + TabRoom *room = i.value(); + if (room->getRoomName() == roomName) { + tabSupervisor->setCurrentWidget(room); + emitFinished(); + return true; + } + } + return false; +} + +void IntentOpenServerRoomByName::processListRooms(const Event_ListRooms &event) +{ + if (selectOpenRoom()) { + return; + } + + for (int i = 0; i < event.room_list_size(); ++i) { + const ServerInfo_Room &room = event.room_list(i); + if (room.has_name() && QString::fromStdString(room.name()) == roomName) { + openRoom(room); + return; + } + } +} + +void IntentOpenServerRoomByName::openRoom(const ServerInfo_Room &roomInfo) +{ + if (joinPending) { + return; + } + joinPending = true; + + // Rooms flagged auto_join are joined by the room selector automatically. Sending our own + // Command_JoinRoom on top of that would be answered with RespContextError. + if (roomInfo.has_auto_join() && roomInfo.auto_join()) { + return; + } + + Command_JoinRoom cmd; + cmd.set_room_id(roomInfo.room_id()); + PendingCommand *pend = remoteClient->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, + [this](const Response &r, const CommandContainer &, const QVariant &) { handleJoinResponse(r); }); + remoteClient->sendCommand(pend); +} + +void IntentOpenServerRoomByName::handleJoinResponse(const Response &response) +{ + switch (response.response_code()) { + case Response::RespOk: { + const Response_JoinRoom &resp = response.GetExtension(Response_JoinRoom::ext); + if (!tabSupervisor->getRoomTabs().contains(resp.room_info().room_id())) { + tabSupervisor->addRoomTab(resp.room_info(), true); + } + emitFinished(); + return; + } + case Response::RespNameNotFound: + emitFailed(tr("Failed to join the server room %1: it doesn't exist on the server.").arg(roomName)); + return; + case Response::RespUserLevelTooLow: + emitFailed(tr("You do not have the required permission to join the server room %1.").arg(roomName)); + return; + case Response::RespContextError: + // The room was already joined by someone else (e.g. the room selector's + // auto-join). It will show up in the room tabs shortly, so keep waiting. + return; + default: + emitFailed(tr("Failed to join the server room %1 due to an unknown error.").arg(roomName)); + return; + } +} diff --git a/cockatrice/src/interface/intents/intent_open_server_room_by_name.h b/cockatrice/src/interface/intents/intent_open_server_room_by_name.h new file mode 100644 index 000000000..e163796fc --- /dev/null +++ b/cockatrice/src/interface/intents/intent_open_server_room_by_name.h @@ -0,0 +1,63 @@ +#ifndef COCKATRICE_INTENT_OPEN_SERVER_ROOM_BY_NAME_H +#define COCKATRICE_INTENT_OPEN_SERVER_ROOM_BY_NAME_H + +#include "abstract_client.h" +#include "contexts/context_join_room.h" +#include "intent.h" + +#include +#include +#include +#include +#include + +class TabSupervisor; +class Event_ListRooms; +class ServerInfo_Room; +class RemoteClient; +class Response; + +/** + * @brief Connects to the configured server and opens a room identified by its name. + * + * Room ids are assigned by the server per session, so the room is resolved by name from the + * room list once the client is logged in. If the room is already open it is simply selected. + * + * The join itself is sent directly through the client instead of `TabServer::joinRoom`, so a + * failed join only fails the intent silently instead of popping a modal error box during + * startup. Success is routed to `TabSupervisor::addRoomTab`, the same tab-creation machinery + * the normal join flow uses. + */ +class IntentOpenServerRoomByName : public Intent +{ + Q_OBJECT + +public: + IntentOpenServerRoomByName(TabSupervisor *_tabSupervisor, + RemoteClient *_remoteClient, + std::unique_ptr _context, + const QString &_roomName); + +protected: + bool checkPrecondition() const override; + void onPreconditionSatisfied() override; + void onPreconditionNotSatisfied() override; + +private: + void processListRooms(const Event_ListRooms &event); + void openRoom(const ServerInfo_Room &roomInfo); + void handleJoinResponse(const Response &response); + void onClientStatusChanged(ClientStatus status); + bool selectOpenRoom(); + + TabSupervisor *tabSupervisor; + RemoteClient *remoteClient; + QScopedPointer context; + QString roomName; + bool listening = false; + bool joinPending = false; + QTimer checkTimer; + QTimer refreshTimer; +}; + +#endif // COCKATRICE_INTENT_OPEN_SERVER_ROOM_BY_NAME_H diff --git a/cockatrice/src/interface/intents/intent_open_shared_deck.cpp b/cockatrice/src/interface/intents/intent_open_shared_deck.cpp new file mode 100644 index 000000000..8abad3d88 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_open_shared_deck.cpp @@ -0,0 +1,219 @@ +#include "intent_open_shared_deck.h" + +#include "../deck_loader/deck_loader.h" +#include "../widgets/dialogs/dlg_shared_decks_preview.h" +#include "../widgets/tabs/tab_supervisor.h" +#include "abstract_client.h" +#include "contexts/context_connect_to_server.h" +#include "intent_connect_to_server.h" +#include "remote_client.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class CommandContainer; + +IntentOpenSharedDeck::IntentOpenSharedDeck(TabSupervisor *_tabSupervisor, + RemoteClient *_remoteClient, + const CardDatabaseQuerier *_querier, + std::unique_ptr _context) + : Intent(), tabSupervisor(_tabSupervisor), remoteClient(_remoteClient), querier(_querier), + context(_context.release()) +{ + downloadTimer = new QTimer(this); + downloadTimer->setSingleShot(true); + downloadTimer->setInterval(15000); + connect(downloadTimer, &QTimer::timeout, this, &IntentOpenSharedDeck::onDownloadTimeout); +} + +bool IntentOpenSharedDeck::checkPrecondition() const +{ + if (remoteClient->getStatus() != ClientStatus::StatusLoggedIn) { + return false; + } + // serverName()/serverPort() reflect the server the client was configured + // to connect to, which may differ from the actual TCP peer (e.g. when + // connecting through a proxy), so compare those configured values. The + // share token must be resolved against the host the link named — a link to + // the same host on another port is a different server. + if (remoteClient->serverName().compare(context->serverContext.hostname, Qt::CaseInsensitive) != 0) { + return false; + } + return QString::number(remoteClient->serverPort()) == context->serverContext.port; +} + +void IntentOpenSharedDeck::onPreconditionSatisfied() +{ + // Resolve the share token to its items first; a share can contain more than + // one deck, and each item is downloaded by id. Time the round trip like the + // downloads, so a silent server cannot hang the chain forever. + listPhase = true; + downloadTimer->start(); + + Command_DeckShareList cmd; + cmd.set_token(context->shareToken.toStdString()); + + PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &IntentOpenSharedDeck::listShareFinished); + remoteClient->sendCommand(pend); +} + +void IntentOpenSharedDeck::onPreconditionNotSatisfied() +{ + runDependency(new IntentConnectToServer(remoteClient, &context->serverContext)); +} + +void IntentOpenSharedDeck::listShareFinished(const Response &response, const CommandContainer & /* commandContainer */) +{ + downloadTimer->stop(); + listPhase = false; + + if (response.response_code() != Response::RespOk) { + emitFailed(tr("The shared deck could not be found or has expired")); + return; + } + + const Response_DeckShareList &resp = response.GetExtension(Response_DeckShareList::ext); + if (resp.items_size() == 0) { + emitFailed(tr("The shared deck is empty")); + return; + } + + QList items; + items.reserve(resp.items_size()); + for (const ServerInfo_DeckShareItem &item : resp.items()) { + items.append(item); + itemNames.insert(item.id(), QString::fromStdString(item.name())); + } + + const QString serverText = context->serverContext.hostname + ":" + context->serverContext.port; + + // Ask the user which decks to open before downloading anything. + previewDialog = new DlgSharedDecksPreview(tabSupervisor, querier, QString::fromStdString(resp.name()), + resp.expires_at(), serverText, items); + connect(previewDialog, &DlgSharedDecksPreview::openRequested, this, &IntentOpenSharedDeck::startDownloads); + connect(previewDialog, &DlgSharedDecksPreview::cancelled, this, &IntentOpenSharedDeck::emitCancelled); + connect(previewDialog, &DlgSharedDecksPreview::cancelled, previewDialog, &QWidget::deleteLater); + previewDialog->show(); + previewDialog->raise(); + previewDialog->activateWindow(); +} + +void IntentOpenSharedDeck::startDownloads(const QList &itemIds) +{ + pendingItemIds = itemIds; + totalItems = itemIds.size(); + completedItems = 0; + loadedDecks.clear(); + downloadNextItem(); +} + +void IntentOpenSharedDeck::downloadNextItem() +{ + if (pendingItemIds.isEmpty()) { + finishAll(); + return; + } + + currentItemId = pendingItemIds.takeFirst(); + downloadTimer->start(); + + Command_DeckShareDownload cmd; + cmd.set_token(context->shareToken.toStdString()); + cmd.set_item_id(currentItemId); + + PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &IntentOpenSharedDeck::downloadShareFinished); + remoteClient->sendCommand(pend); +} + +void IntentOpenSharedDeck::downloadShareFinished(const Response &response, + const CommandContainer & /* commandContainer */) +{ + downloadTimer->stop(); + + QString failureReason; + if (response.response_code() != Response::RespOk) { + failureReason = tr("Failed to download the shared deck"); + } else { + const Response_DeckShareDownload &resp = response.GetExtension(Response_DeckShareDownload::ext); + const QString deckString = QString::fromStdString(resp.deck()); + if (deckString.isEmpty()) { + failureReason = tr("The shared deck is empty"); + } else { + std::optional deckOpt = + DeckLoader::loadFromRemote(deckString, LoadedDeck::LoadInfo::NON_REMOTE_ID); + if (!deckOpt) { + failureReason = tr("The shared deck could not be loaded"); + } else { + loadedDecks.append(deckOpt.value()); + ++completedItems; + previewDialog->setDownloadProgress(completedItems, totalItems, + itemNames.value(currentItemId, tr("Unknown deck"))); + downloadNextItem(); + return; + } + } + } + + onItemFailure(failureReason); +} + +void IntentOpenSharedDeck::onItemFailure(const QString &reason) +{ + downloadTimer->stop(); + + if (loadedDecks.isEmpty()) { + previewDialog->deleteLater(); + emitFailed(reason); + return; + } + + const int downloadedCount = loadedDecks.size(); + const QMessageBox::StandardButton answer = QMessageBox::question( + previewDialog, tr("Open shared decks"), + tr("Could not download the deck \"%1\".\n\n%n deck(s) were already downloaded. Open them?", "", downloadedCount) + .arg(itemNames.value(currentItemId, tr("Unknown deck"))), + QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); + + if (answer == QMessageBox::Yes) { + finishAll(); + } else { + previewDialog->deleteLater(); + emitCancelled(); + } +} + +void IntentOpenSharedDeck::onDownloadTimeout() +{ + // The list phase has no preview dialog yet to report progress into; fail the + // whole intent instead of letting the shared deck hang in limbo. + if (listPhase) { + emitFailed(tr("Timed out while loading the shared deck")); + return; + } + onItemFailure(tr("Timed out while downloading the shared deck")); +} + +void IntentOpenSharedDeck::finishAll() +{ + previewDialog->deleteLater(); + for (const LoadedDeck &deck : loadedDecks) { + tabSupervisor->openDeckInNewTab(deck); + } + emitFinished(); +} diff --git a/cockatrice/src/interface/intents/intent_open_shared_deck.h b/cockatrice/src/interface/intents/intent_open_shared_deck.h new file mode 100644 index 000000000..afce2370f --- /dev/null +++ b/cockatrice/src/interface/intents/intent_open_shared_deck.h @@ -0,0 +1,64 @@ +#ifndef COCKATRICE_INTENT_OPEN_SHARED_DECK_H +#define COCKATRICE_INTENT_OPEN_SHARED_DECK_H + +#include "../deck_loader/loaded_deck.h" +#include "contexts/context_open_deck.h" +#include "intent.h" + +#include +#include +#include +#include +#include +#include + +class TabSupervisor; +class CardDatabaseQuerier; +class DlgSharedDecksPreview; +class QTimer; +class CommandContainer; +class RemoteClient; +class Response; + +class IntentOpenSharedDeck : public Intent +{ + Q_OBJECT + +public: + IntentOpenSharedDeck(TabSupervisor *_tabSupervisor, + RemoteClient *_remoteClient, + const CardDatabaseQuerier *_querier, + std::unique_ptr _context); + +protected: + bool checkPrecondition() const override; + void onPreconditionSatisfied() override; + void onPreconditionNotSatisfied() override; + +private slots: + void listShareFinished(const Response &response, const CommandContainer &commandContainer); + void downloadShareFinished(const Response &response, const CommandContainer &commandContainer); + void onDownloadTimeout(); + +private: + void startDownloads(const QList &itemIds); + void downloadNextItem(); + void onItemFailure(const QString &reason); + void finishAll(); + + TabSupervisor *tabSupervisor; + RemoteClient *remoteClient; + const CardDatabaseQuerier *querier; + QScopedPointer context; + DlgSharedDecksPreview *previewDialog = nullptr; + QTimer *downloadTimer; + QMap itemNames; + QList pendingItemIds; + QList loadedDecks; + bool listPhase = true; + int currentItemId = 0; + int totalItems = 0; + int completedItems = 0; +}; + +#endif // COCKATRICE_INTENT_OPEN_SHARED_DECK_H diff --git a/cockatrice/src/interface/intents/intent_wait_for_database_load.cpp b/cockatrice/src/interface/intents/intent_wait_for_database_load.cpp index c36378818..7a7da90a4 100644 --- a/cockatrice/src/interface/intents/intent_wait_for_database_load.cpp +++ b/cockatrice/src/interface/intents/intent_wait_for_database_load.cpp @@ -1,5 +1,8 @@ #include "intent_wait_for_database_load.h" +#include "libcockatrice/card/database/card_database.h" +#include "libcockatrice/card/database/card_database_loader.h" + #include bool IntentWaitForDatabaseLoad::checkPrecondition() const diff --git a/cockatrice/src/interface/intents/intent_wait_for_database_load.h b/cockatrice/src/interface/intents/intent_wait_for_database_load.h index 72f4a1ffc..715918f04 100644 --- a/cockatrice/src/interface/intents/intent_wait_for_database_load.h +++ b/cockatrice/src/interface/intents/intent_wait_for_database_load.h @@ -3,6 +3,8 @@ #include "intent.h" +#include + class IntentWaitForDatabaseLoad : public Intent { Q_OBJECT diff --git a/cockatrice/src/interface/intents/url_parser.cpp b/cockatrice/src/interface/intents/url_parser.cpp index 8b5309603..966fd1213 100644 --- a/cockatrice/src/interface/intents/url_parser.cpp +++ b/cockatrice/src/interface/intents/url_parser.cpp @@ -1,15 +1,39 @@ #include "url_parser.h" +#include "../../client/settings/cache_settings.h" +#include "../widgets/tabs/tab_room.h" +#include "../widgets/tabs/tab_supervisor.h" #include "../window_main.h" +#include "contexts/context_connect_to_server.h" #include "contexts/context_join_game.h" +#include "contexts/context_join_room.h" +#include "contexts/context_open_deck.h" +#include "intent.h" #include "intent_join_server_game.h" #include "intent_login.h" +#include "intent_open_shared_deck.h" +#include "remote_client.h" #include +#include +#include #include +#include +#include +#include #include #include +#include +#include +#include +#include +#include #include +#include +#include +#include + +inline Q_LOGGING_CATEGORY(UrlParserLog, "url_parser"); IntentUrlParser::IntentUrlParser(QObject *parent, MainWindow *_mainWindow) : QObject(parent), mainWindow(_mainWindow) { @@ -26,16 +50,33 @@ void IntentUrlParser::handle(const QString &urlStr) const QString action = url.host(); QUrlQuery query(url); + qCDebug(UrlParserLog) << "Parsing intent URL, action:" << action; + + PendingIntentChain chain; + Intent *firstIntent = nullptr; if (action == "joingame") { - handleJoinGame(query); + firstIntent = createJoinGameIntent(query, chain); } else if (action == "opendeck") { - // handleOpenDeck(query); + firstIntent = createOpenDeckIntent(query, chain); } else { qWarning() << "Unknown intent:" << action; } + + if (firstIntent == nullptr) { + // The link was invalid or the user declined the confirm: nothing runs. + // Report the idle state when no other chain is queued so that a startup + // launch (which skipped its own connection for this URL) falls back to it. + if (!chainRunning && pendingChains.isEmpty()) { + emit urlChainFinished(mainWindow->getRemoteClient()->getStatus() == StatusLoggedIn); + } + return; + } + + pendingChains.append(chain); + startNextChain(); } -void IntentUrlParser::handleJoinGame(const QUrlQuery &query) +Intent *IntentUrlParser::createJoinGameIntent(const QUrlQuery &query, PendingIntentChain &chain) { auto showError = [this](const QString &message) { QMessageBox::warning(mainWindow, tr("Open game"), message); }; @@ -46,21 +87,21 @@ void IntentUrlParser::handleJoinGame(const QUrlQuery &query) if (ctx->roomContext.serverContext.hostname.isEmpty()) { showError(tr("Missing or empty hostname in the game link")); - return; + return nullptr; } bool ok = false; ctx->roomContext.serverContext.port.toUShort(&ok); if (!ok) { showError(tr("Invalid or missing port in the game link")); - return; + return nullptr; } ctx->roomContext.roomId = query.queryItemValue("roomid").toInt(&ok); if (!ok) { showError(tr("Invalid or missing room id in the game link")); - return; + return nullptr; } ok = false; @@ -68,22 +109,342 @@ void IntentUrlParser::handleJoinGame(const QUrlQuery &query) if (!ok) { showError(tr("Invalid or missing game id in the game link")); - return; + return nullptr; } + const QString gameDescription = query.queryItemValue("game", QUrl::FullyDecoded); + const QString message = generateJoinGameMessage(*ctx, gameDescription); + + const QMessageBox::StandardButton answer = QMessageBox::question( + mainWindow, tr("Join game"), message, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); + if (answer != QMessageBox::Yes) { + return nullptr; + } + + RemoteClient *client = mainWindow->getRemoteClient(); + ContextConnectToServer *serverContext = &ctx->roomContext.serverContext; + // The join game intent owns the context and the credential lookup; once the // chain finishes (or fails) it deletes the whole tree. - ContextConnectToServer *serverContext = &ctx->roomContext.serverContext; - auto joinGameIntent = - new IntentJoinServerGame(mainWindow->getTabSupervisor(), mainWindow->getRemoteClient(), std::move(ctx)); + auto joinGameIntent = new IntentJoinServerGame(mainWindow->getTabSupervisor(), client, std::move(ctx)); joinGameIntent->setParent(this); - - auto getLoginCredentialsIntent = new IntentGetLoginCredentials(serverContext); - getLoginCredentialsIntent->setParent(joinGameIntent); - - connect(getLoginCredentialsIntent, &Intent::finished, joinGameIntent, &Intent::execute); - connect(getLoginCredentialsIntent, &Intent::failed, joinGameIntent, &Intent::failed); + chain.intents.append(joinGameIntent); connect(joinGameIntent, &Intent::failed, this, [showError](const QString &reason) { showError(reason); }); - getLoginCredentialsIntent->execute(); + Intent *firstIntent = joinGameIntent; + if (!isConnectedTo(serverContext->hostname, serverContext->port)) { + auto getLoginCredentialsIntent = + new IntentGetLoginCredentials(serverContext, /*promptForMissingCredentials=*/true); + getLoginCredentialsIntent->setParent(joinGameIntent); + chain.intents.insert(0, getLoginCredentialsIntent); + + connect(getLoginCredentialsIntent, &Intent::finished, joinGameIntent, &Intent::execute); + connect(getLoginCredentialsIntent, &Intent::failed, joinGameIntent, &Intent::failed); + connect(getLoginCredentialsIntent, &Intent::cancelled, joinGameIntent, &Intent::cancelled); + firstIntent = getLoginCredentialsIntent; + } + + return firstIntent; +} + +QString IntentUrlParser::generateJoinGameMessage(const ContextJoinGame &context, const QString &gameDescription) +{ + const QString hostname = context.roomContext.serverContext.hostname; + const QString port = context.roomContext.serverContext.port; + const int roomId = context.roomContext.roomId; + const int gameId = context.gameId; + const QString server = QStringLiteral("%1:%2").arg(hostname, port); + + // Prefer the room name over the raw numeric id: it means something to the + // user. The name is only known when we are already connected to the same + // server and sitting in that room — otherwise fall back to a plain prompt. + AbstractClient *client = mainWindow->getTabSupervisor()->getClient(); + const bool sameServer = client != nullptr && client->getStatus() == StatusLoggedIn && + hostname.compare(client->serverName(), Qt::CaseInsensitive) == 0 && + QString::number(client->serverPort()) == port; + TabRoom *roomTab = sameServer ? mainWindow->getTabSupervisor()->getRoomTabs().value(roomId) : nullptr; + + const QString gameIdStr = QString::number(gameId); + // Links built by newer clients embed the game description ("game" item); + // restate it in the confirm so it matches what the chat anchor showed. + // Unknown query items are ignored, so old links without it keep working. + // The multi-arg .arg() overloads replace in a single pass, so a description + // containing "%…" cannot corrupt later placeholders. + // FullyDecoded undoes every %XX escape and must match the chat anchor's + // decode mode, so a description containing "%" reads identically in both. + if (gameDescription.isEmpty()) { + return roomTab ? tr("Join game #%1 in \"%2\" on %3?").arg(gameIdStr, roomTab->getRoomName(), server) + : tr("Join game #%1 on %2?").arg(gameIdStr, server); + } + + return roomTab ? tr("Join game \"%1\" (#%2) in \"%3\" on %4?") + .arg(gameDescription, gameIdStr, roomTab->getRoomName(), server) + : tr("Join game \"%1\" (#%2) on %3?").arg(gameDescription, gameIdStr, server); +} + +Intent *IntentUrlParser::createOpenDeckIntent(const QUrlQuery &query, PendingIntentChain &chain) +{ + auto showError = [this](const QString &message) { + QMessageBox::warning(mainWindow, tr("Open shared deck"), message); + }; + + auto ctx = std::make_unique(); + + ctx->serverContext.hostname = query.queryItemValue("hostname"); + ctx->serverContext.port = query.queryItemValue("port"); + ctx->shareToken = query.queryItemValue("share"); + + qCDebug(UrlParserLog) << "Open-deck intent: host" << ctx->serverContext.hostname << "port" + << ctx->serverContext.port << "token length" << ctx->shareToken.length(); + + if (ctx->serverContext.hostname.isEmpty()) { + showError(tr("Missing or empty hostname in the share link")); + return nullptr; + } + + bool ok = false; + const quint16 port = ctx->serverContext.port.toUShort(&ok); + if (!ok || port == 0) { + showError(tr("Invalid or missing port in the share link")); + return nullptr; + } + + if (ctx->shareToken.isEmpty()) { + showError(tr("Missing or empty share value in the share link")); + return nullptr; + } + + RemoteClient *client = mainWindow->getRemoteClient(); + + // The open deck download needs a connection to the link's server. Ask before + // taking the session anywhere it isn't already, naming the host we would + // connect to. Remember the link's target when it moves us away from a live + // session so a failed or cancelled chain can restore the session it left. + // The hostname is link-supplied and percent-decoded, so escape it: QMessageBox + // renders AutoText, and markup in a hostname would otherwise flip the whole + // prompt to rich text and let a link pad the message the user is shown. + const bool alreadyConnected = isConnectedTo(ctx->serverContext.hostname, ctx->serverContext.port); + if (!alreadyConnected) { + const QString target = + QStringLiteral("%1:%2").arg(ctx->serverContext.hostname.toHtmlEscaped(), ctx->serverContext.port); + + if (client->getStatus() == StatusLoggedIn) { + const QString current = + QStringLiteral("%1:%2").arg(client->serverName(), QString::number(client->serverPort())); + const QMessageBox::StandardButton answer = QMessageBox::question( + mainWindow, tr("Open shared deck"), + tr("Opening this share link connects you to %1 instead of %2.\n\nContinue?").arg(target, current), + QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); + if (answer != QMessageBox::Yes) { + return nullptr; + } + chain.migrationTargetHost = ctx->serverContext.hostname; + chain.migrationTargetPort = ctx->serverContext.port; + chain.pendingRestore = true; + } else { + // Fresh connection is harmless to wander away from, but a server the + // client has never been configured for deserves a harder warning (no + // by default) so a stray link cannot silently steer the client there. + const bool knownHost = SettingsCache::instance().servers().findHostIndex(ctx->serverContext.hostname) >= 0; + const QMessageBox::StandardButton answer = + knownHost + ? QMessageBox::question(mainWindow, tr("Open shared deck"), + tr("Opening this share link connects you to %1.\n\nContinue?").arg(target)) + : QMessageBox::warning(mainWindow, tr("Open shared deck"), + tr("Opening this share link connects you to %1, a server you have " + "never connected to before.\n\nContinue?") + .arg(target), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + if (answer != QMessageBox::Yes) { + return nullptr; + } + } + } + + ContextConnectToServer *serverContext = &ctx->serverContext; + + // The open deck intent owns the context and the credential lookup; once + // the chain finishes (or fails) it deletes the whole tree. + auto openDeckIntent = + new IntentOpenSharedDeck(mainWindow->getTabSupervisor(), client, CardDatabaseManager::query(), std::move(ctx)); + openDeckIntent->setParent(this); + chain.intents.append(openDeckIntent); + connect(openDeckIntent, &Intent::failed, this, [showError](const QString &reason) { showError(reason); }); + + Intent *firstIntent = openDeckIntent; + if (!isConnectedTo(serverContext->hostname, serverContext->port)) { + auto getLoginCredentialsIntent = + new IntentGetLoginCredentials(serverContext, /*promptForMissingCredentials=*/true); + getLoginCredentialsIntent->setParent(openDeckIntent); + chain.intents.insert(0, getLoginCredentialsIntent); + + connect(getLoginCredentialsIntent, &Intent::finished, openDeckIntent, &Intent::execute); + connect(getLoginCredentialsIntent, &Intent::failed, openDeckIntent, &Intent::failed); + connect(getLoginCredentialsIntent, &Intent::cancelled, openDeckIntent, &Intent::cancelled); + firstIntent = getLoginCredentialsIntent; + } + + return firstIntent; +} + +bool IntentUrlParser::isConnectedTo(const QString &hostname, const QString &port) const +{ + // serverName() reflects the server the client was configured to connect to, + // which may differ from the actual TCP peer (e.g. when connecting through a + // proxy), so compare the configured host and port — exactly what a link + // names. A link to the same host on another port is a different server and + // must not silently reuse an existing session there. + RemoteClient *client = mainWindow->getRemoteClient(); + return client->getStatus() == StatusLoggedIn && client->serverName().compare(hostname, Qt::CaseInsensitive) == 0 && + QString::number(client->serverPort()) == port; +} + +void IntentUrlParser::startNextChain() +{ + if (chainRunning || pendingChains.isEmpty()) { + return; + } + chainRunning = true; + + PendingIntentChain &chain = pendingChains.first(); + if (chain.intents.isEmpty()) { + pendingChains.removeFirst(); + chainRunning = false; + startNextChain(); + return; + } + + // Snapshot the session this chain moves away from now that it actually + // runs. Chains are parsed while earlier ones are still queued, so a capture + // at parse time would follow whichever server the chain before it settled + // on, not the one the user is really on when this link is handled. + if (chain.pendingRestore) { + RemoteClient *client = mainWindow->getRemoteClient(); + chain.previousServerHost = client->serverName(); + chain.previousServerPort = QString::number(client->serverPort()); + } + + // Only the last intent completes the chain; its terminal signal ends the + // whole run. Cancellation of an intermediate intent (e.g. declined login + // prompt) is forwarded onto the last intent in the chain builders above. + Intent *finalIntent = chain.intents.last(); + connect(finalIntent, &Intent::finished, this, [this]() { chainEnded(true); }); + connect(finalIntent, &Intent::failed, this, [this]() { chainEnded(false); }); + connect(finalIntent, &Intent::cancelled, this, [this]() { chainEnded(false); }); + // Backstop: if the final intent is destroyed without emitting a terminal + // signal (e.g. a network error dropped it while running), end the chain so + // later links are not queued and dropped for the rest of the session. + chainBackstopConnection = connect(finalIntent, &QObject::destroyed, this, &IntentUrlParser::onChainIntentDestroyed); + + chain.intents.first()->execute(); +} + +void IntentUrlParser::chainEnded(bool chainSucceeded) +{ + chainRunning = false; + QObject::disconnect(chainBackstopConnection); + + const PendingIntentChain chain = pendingChains.takeFirst(); + + // Only a failed or cancelled chain restores the session the link migrated + // away from; a successful one leaves the user where they are. + if (chain.pendingRestore && !chainSucceeded) { + restorePreviousServer(chain); + } + + startNextChain(); + + // Only report the terminal state once the queue has fully drained, so a + // queued follow-up link keeps the startup fallback out of the picture. + if (!chainRunning && pendingChains.isEmpty()) { + emit urlChainFinished(mainWindow->getRemoteClient()->getStatus() == StatusLoggedIn); + } +} + +void IntentUrlParser::onChainIntentDestroyed() +{ + if (!chainRunning) { + return; + } + qCWarning(UrlParserLog) << "Share-link intent destroyed without a terminal signal; ending its chain"; + chainEnded(false); +} + +void IntentUrlParser::restorePreviousServer(const PendingIntentChain &chain) +{ + if (chain.previousServerHost.isEmpty()) { + return; + } + + RemoteClient *client = mainWindow->getRemoteClient(); + const ClientStatus status = client->getStatus(); + + // A failed/cancelled chain can fire while the client is still settling the + // in-flight connection attempt (wrong password, connect timeout). Only + // decide once the client has settled into logged-in or disconnected; + // deciding mid-connect would strand the user offline from their previous + // server. + if (status == StatusDisconnected || status == StatusLoggedIn) { + restoreToPreviousServer(chain); + return; + } + auto waitConnection = std::make_shared(); + *waitConnection = connect(client, &RemoteClient::statusChanged, this, [this, chain, client, waitConnection]() { + const ClientStatus settled = client->getStatus(); + if (settled == StatusDisconnected || settled == StatusLoggedIn) { + QObject::disconnect(*waitConnection); + restoreToPreviousServer(chain); + } + }); +} + +void IntentUrlParser::restoreToPreviousServer(const PendingIntentChain &chain) +{ + RemoteClient *client = mainWindow->getRemoteClient(); + + // Back on the previous server already → nothing to undo. + if (client->serverName().compare(chain.previousServerHost, Qt::CaseInsensitive) == 0 && + QString::number(client->serverPort()) == chain.previousServerPort) { + return; + } + + // When logged in somewhere, only intervene if that somewhere is the server + // the link moved us to; if the user went elsewhere on their own, leave them. + if (client->getStatus() == StatusLoggedIn) { + const bool onMigrationTarget = + client->serverName().compare(chain.migrationTargetHost, Qt::CaseInsensitive) == 0 && + QString::number(client->serverPort()) == chain.migrationTargetPort; + if (!onMigrationTarget) { + return; + } + + ServersSettings &servers = SettingsCache::instance().servers(); + const int index = servers.findServerIndex(chain.previousServerHost, chain.previousServerPort); + if (index >= 0 && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) { + const QString username = + servers.getValue(QString("username%1").arg(index), "server", "server_details").toString(); + const QString password = + servers.getValue(QString("password%1").arg(index), "server", "server_details").toString(); + client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), username, password); + return; + } + client->disconnectFromServer(); + return; + } + + if (client->getStatus() != StatusDisconnected) { + return; + } + + // The link's connection attempt failed: reconnect to the previous server + // when credentials are saved, otherwise stay offline. + ServersSettings &servers = SettingsCache::instance().servers(); + const int index = servers.findServerIndex(chain.previousServerHost, chain.previousServerPort); + if (index >= 0 && servers.hasLoginData(chain.previousServerHost, chain.previousServerPort)) { + const QString username = + servers.getValue(QString("username%1").arg(index), "server", "server_details").toString(); + const QString password = + servers.getValue(QString("password%1").arg(index), "server", "server_details").toString(); + client->connectToServer(chain.previousServerHost, chain.previousServerPort.toUInt(), username, password); + } } diff --git a/cockatrice/src/interface/intents/url_parser.h b/cockatrice/src/interface/intents/url_parser.h index bac0e3d25..9b8594275 100644 --- a/cockatrice/src/interface/intents/url_parser.h +++ b/cockatrice/src/interface/intents/url_parser.h @@ -1,9 +1,49 @@ #ifndef COCKATRICE_URL_PARSER_H #define COCKATRICE_URL_PARSER_H -#include -#include +#include +#include +#include +#include +#include +#include + +class Intent; class MainWindow; +struct ContextJoinGame; + +/** + * @brief One queued intent chain with the session-migration bookkeeping for it. + * + * The restore fields are per-chain on purpose: chains are parsed while earlier + * ones are still queued, so parser-wide state would let one chain's failure + * consume the restore data another chain recorded. + */ +struct PendingIntentChain +{ + QList intents; + + // Snapshot of the session in place when this chain started running, so a + // queued chain follows whichever server the chain before it settled on. + QString previousServerHost; + QString previousServerPort; + + // Recorded at parse time when the user confirmed migrating away from a live + // session to the host/port named by the link. + QString migrationTargetHost; + QString migrationTargetPort; + bool pendingRestore = false; +}; + +/** + * @brief Parses cockatrice:// links and runs them as serialized intent chains. + * + * Links are parsed by action (joingame/opendeck) and translated into an intent + * chain. Chains are queued and run one at a time: a document can hand multiple + * links to the window while an earlier chain still connects, and running two + * connect chains concurrently tears the connection down. urlChainFinished is + * emitted once the queue has fully drained. + */ class IntentUrlParser : public QObject { Q_OBJECT @@ -11,10 +51,28 @@ class IntentUrlParser : public QObject public: IntentUrlParser(QObject *parent, MainWindow *mainWindow); void handle(const QString &urlStr); - void handleJoinGame(const QUrlQuery &query); + +signals: + /** @brief Emitted when the last queued chain ended; carries whether the client is logged in. */ + void urlChainFinished(bool connected); private: + Intent *createJoinGameIntent(const QUrlQuery &query, PendingIntentChain &chain); + Intent *createOpenDeckIntent(const QUrlQuery &query, PendingIntentChain &chain); + QString generateJoinGameMessage(const ContextJoinGame &context, const QString &gameDescription); + [[nodiscard]] bool isConnectedTo(const QString &hostname, const QString &port) const; + void startNextChain(); + void chainEnded(bool chainSucceeded); + void onChainIntentDestroyed(); + void restorePreviousServer(const PendingIntentChain &chain); + void restoreToPreviousServer(const PendingIntentChain &chain); + MainWindow *mainWindow; + QList pendingChains; + bool chainRunning = false; + // Disconnects the destroyed-signal backstop once a chain ends, so an old + // intent's deferred deletion cannot end the chain that runs after it. + QMetaObject::Connection chainBackstopConnection; }; #endif // COCKATRICE_URL_PARSER_H diff --git a/cockatrice/src/interface/key_signals.cpp b/cockatrice/src/interface/key_signals.cpp index a78997cc2..4c3074139 100644 --- a/cockatrice/src/interface/key_signals.cpp +++ b/cockatrice/src/interface/key_signals.cpp @@ -1,6 +1,9 @@ #include "key_signals.h" +#include +#include #include +#include bool KeySignals::eventFilter(QObject * /*object*/, QEvent *event) { diff --git a/cockatrice/src/interface/key_signals.h b/cockatrice/src/interface/key_signals.h index 30dcee0ba..dbe147ad8 100644 --- a/cockatrice/src/interface/key_signals.h +++ b/cockatrice/src/interface/key_signals.h @@ -8,8 +8,8 @@ #ifndef KEYSIGNALS_H #define KEYSIGNALS_H -#include #include +#include class KeySignals : public QObject { diff --git a/cockatrice/src/interface/layouts/flow_layout.cpp b/cockatrice/src/interface/layouts/flow_layout.cpp index 0d03b7789..ba5e492e3 100644 --- a/cockatrice/src/interface/layouts/flow_layout.cpp +++ b/cockatrice/src/interface/layouts/flow_layout.cpp @@ -15,13 +15,18 @@ #include "flow_layout.h" -#include "../widgets/general/layout_containers/flow_widget.h" - #include #include -#include +#include +#include +#include +#include +#include +#include #include +#include #include +#include FlowLayout::FlowLayout(QWidget *parent, const Qt::Orientation flowDirection, diff --git a/cockatrice/src/interface/layouts/flow_layout.h b/cockatrice/src/interface/layouts/flow_layout.h index 9adcd8bb5..a6e015e3e 100644 --- a/cockatrice/src/interface/layouts/flow_layout.h +++ b/cockatrice/src/interface/layouts/flow_layout.h @@ -12,8 +12,14 @@ #include #include #include +#include #include -#include +#include +#include +#include + +class QLayoutItem; +class QWidget; inline Q_LOGGING_CATEGORY(FlowLayoutLog, "flow_layout", QtInfoMsg); diff --git a/cockatrice/src/interface/layouts/overlap_layout.cpp b/cockatrice/src/interface/layouts/overlap_layout.cpp index a4c13518a..2b4da9138 100644 --- a/cockatrice/src/interface/layouts/overlap_layout.cpp +++ b/cockatrice/src/interface/layouts/overlap_layout.cpp @@ -1,7 +1,15 @@ #include "overlap_layout.h" #include +#include +#include +#include +#include +#include +#include +#include #include +#include /** * @class OverlapLayout diff --git a/cockatrice/src/interface/layouts/overlap_layout.h b/cockatrice/src/interface/layouts/overlap_layout.h index c1b6a8e51..32c74c18a 100644 --- a/cockatrice/src/interface/layouts/overlap_layout.h +++ b/cockatrice/src/interface/layouts/overlap_layout.h @@ -10,7 +10,11 @@ #include #include #include -#include +#include +#include + +class QLayoutItem; +class QWidget; inline Q_LOGGING_CATEGORY(OverlapLayoutLog, "overlap_layout"); diff --git a/cockatrice/src/interface/logger.cpp b/cockatrice/src/interface/logger.cpp index 3e00a0b24..87fdcbd8c 100644 --- a/cockatrice/src/interface/logger.cpp +++ b/cockatrice/src/interface/logger.cpp @@ -5,9 +5,16 @@ #include #include #include +#include +#include #include +#include +#include +#include #include #include +#include +#include #define LOGGER_MAX_ENTRIES 128 #define LOGGER_FILENAME "qdebug.txt" diff --git a/cockatrice/src/interface/logger.h b/cockatrice/src/interface/logger.h index fc6dd70be..2287da804 100644 --- a/cockatrice/src/interface/logger.h +++ b/cockatrice/src/interface/logger.h @@ -8,10 +8,15 @@ #define LOGGER_H #include +#include #include +#include #include #include -#include +#include +#include +#include +#include #if defined(Q_PROCESSOR_X86_32) #define BUILD_ARCHITECTURE "32-bit" diff --git a/cockatrice/src/interface/palette_editor/color_button.cpp b/cockatrice/src/interface/palette_editor/color_button.cpp index e1a490d20..a7db23d31 100644 --- a/cockatrice/src/interface/palette_editor/color_button.cpp +++ b/cockatrice/src/interface/palette_editor/color_button.cpp @@ -1,7 +1,19 @@ #include "color_button.h" +#include #include +#include +#include #include +#include +#include +#include +#include +#include +#include +#include + +class QWidget; ColorButton::ColorButton(QWidget *parent) : QToolButton(parent) { diff --git a/cockatrice/src/interface/palette_editor/color_button.h b/cockatrice/src/interface/palette_editor/color_button.h index 4b139ef68..f9991ec6b 100644 --- a/cockatrice/src/interface/palette_editor/color_button.h +++ b/cockatrice/src/interface/palette_editor/color_button.h @@ -3,6 +3,9 @@ #include #include +#include + +class QWidget; class ColorButton : public QToolButton { diff --git a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp index adae6e152..20e16614a 100644 --- a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp +++ b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp @@ -1,6 +1,6 @@ #include "palette_editor_dialog.h" -#include "../../client/settings/cache_settings.h" +#include "../theme_config.h" #include "../theme_manager.h" #include "palette_generator.h" #include "palette_grid_widget.h" @@ -10,32 +10,22 @@ #include #include #include +#include #include -#include #include -#include +#include #include -#include +#include #include +#include #include -#include +#include +#include #include -#include - -// Probe whether a directory is truly writable by trying to create and remove a -// temporary file. QFileInfo::isWritable() on a directory is unreliable (notably -// on Windows where UAC VirtualStore can make a system dir appear writable). -static bool isDirReallyWritable(const QString &dirPath) -{ - const QString probe = QDir(dirPath).absoluteFilePath(".cockatrice_write_test"); - QFile f(probe); - if (!f.open(QIODevice::WriteOnly)) { - return false; - } - f.close(); - f.remove(); - return true; -} +#include +#include +#include +#include PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QString &_themeName, QWidget *parent) : QDialog(parent), themeDirPath(_themeDirPath), themeName(_themeName) @@ -46,14 +36,7 @@ PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QSt // Resolve a writable directory for saving. Built-in (Default / Fusion) and // other read-only theme directories must be customised in the user-writable // themes directory; otherwise the write would fail or be lost on upgrade. - if (!themeDirPath.isEmpty() && isDirReallyWritable(themeDirPath)) { - saveDir = themeDirPath; - } else { - saveDir = QDir(SettingsCache::instance().paths().getThemesPath()).absoluteFilePath(themeName); - if (!QDir().mkpath(saveDir)) { - qWarning() << "Failed to create palette save directory:" << saveDir; - } - } + saveDir = ThemeManager::writableThemeDir(themeName); // Load both scheme configs upfront so switching is instant loadSchemes(); @@ -214,7 +197,7 @@ void PaletteEditorDialog::retranslateUi() resetBtn->setToolTip(tr("Discard unsaved edits and restore the last saved palette")); saveBtn->setToolTip(tr("Write palette-%1.toml and reload the theme").arg(loadedScheme.toLower())); - if (saveDir.isEmpty() || !isDirReallyWritable(saveDir)) { + if (saveDir.isEmpty() || !ThemeManager::isDirReallyWritable(saveDir)) { saveBtn->setEnabled(false); saveBtn->setToolTip(tr("Cannot save: this theme has no writable directory")); } @@ -290,28 +273,42 @@ void PaletteEditorDialog::onSave() // Persist every scheme that changed, not just the one on screen. Each scheme // has its own file, so edits to the non-active scheme would otherwise be // silently discarded when the dialog closes. + // + // Save the loaded scheme last so commitPalette's global colour-scheme + // update (ThemeConfig::colorScheme) points at the active scheme. for (auto it = workingConfig.begin(); it != workingConfig.end(); ++it) { - const QString &scheme = it.key(); - if (it.value().colors == savedConfig.value(scheme).colors) { - continue; // unchanged — leave the on-disk file alone + if (it.key() == loadedScheme) { + continue; } - - if (!ThemeManager::savePaletteConfig(saveDir, scheme, it.value())) { + if (it.value() == savedConfig.value(it.key())) { + continue; + } + if (!ThemeManager::commitPalette(saveDir, it.key(), it.value())) { QMessageBox::warning(this, tr("Save failed"), - tr("Could not write %1 to:\n%2").arg(PaletteConfig::fileName(scheme), saveDir)); + tr("Could not write %1 to:\n%2").arg(PaletteConfig::fileName(it.key()), saveDir)); return; } } + // Commit the active scheme last so the global colour scheme matches. + if (workingConfig[loadedScheme] != savedConfig.value(loadedScheme)) { + if (!ThemeManager::commitPalette(saveDir, loadedScheme, workingConfig[loadedScheme])) { + QMessageBox::warning(this, tr("Save failed"), + tr("Could not write %1 to:\n%2").arg(PaletteConfig::fileName(loadedScheme), saveDir)); + return; + } + } else { + // No palette change but scheme may have switched -- still update global config. + ThemeConfig globalCfg = ThemeConfig::fromThemeDir(saveDir); + globalCfg.colorScheme = loadedScheme; + globalCfg.save(saveDir); + } + // Keep the saved snapshot in sync so Reset behaves correctly afterwards. for (auto it = workingConfig.begin(); it != workingConfig.end(); ++it) { savedConfig[it.key()] = it.value(); } - ThemeConfig globalCfg = ThemeConfig::fromThemeDir(saveDir); - globalCfg.colorScheme = loadedScheme; - globalCfg.save(saveDir); - themeManager->reloadCurrentTheme(); accept(); } diff --git a/cockatrice/src/interface/palette_editor/palette_editor_dialog.h b/cockatrice/src/interface/palette_editor/palette_editor_dialog.h index 953902c06..e29a70cb0 100644 --- a/cockatrice/src/interface/palette_editor/palette_editor_dialog.h +++ b/cockatrice/src/interface/palette_editor/palette_editor_dialog.h @@ -3,18 +3,21 @@ #include "../theme_config.h" +#include #include -#include #include +#include +#include class QTimer; - class QLabel; class QComboBox; class QDialogButtonBox; class QPushButton; class PaletteGridWidget; class QuickSetupPanel; +class QFrame; +class QWidget; class PaletteEditorDialog : public QDialog { diff --git a/cockatrice/src/interface/palette_editor/palette_generator.cpp b/cockatrice/src/interface/palette_editor/palette_generator.cpp index d30dd14f1..c98dcbe03 100644 --- a/cockatrice/src/interface/palette_editor/palette_generator.cpp +++ b/cockatrice/src/interface/palette_editor/palette_generator.cpp @@ -1,6 +1,13 @@ #include "palette_generator.h" +#include "../theme_config.h" + #include +#include +#include +#include +#include +#include // ════════════════════════════════════════════════════════════════════════════ // PaletteGenerator::fromAccent @@ -150,6 +157,17 @@ PaletteConfig fromAccent(const QColor &accent, int intensity, const QString &sch cfg.colors[CG::Disabled][CR::HighlightedText] = disText; cfg.colors[CG::Inactive][CR::HighlightedText] = dark ? Qt::white : Qt::black; + // Accent: same primary hue as Highlight, so palettes derived from a + // QuickSetup accent always carry a matching Accent role. +#if QT_VERSION >= QT_VERSION_CHECK(6, 6, 0) + set3(CR::Accent, hl, disText, hl); +#endif + + // Application role colors: Strong tracks the primary accent, while Soft is + // the lightened, desaturated companion used for button-gradient highlights. + cfg.appColors[AppColor::AccentStrong] = hl; + cfg.appColors[AppColor::AccentSoft] = hsl(accent.lightness() + 60, qRound(accent.hslSaturation() * 70 / 100.0)); + // BrightText QColor bright; if (achromatic) { diff --git a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp index 67294cd98..de069bc80 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp @@ -1,12 +1,25 @@ #include "palette_grid_widget.h" +#include "../theme_config.h" +#include "../theme_manager.h" +#include "color_button.h" + #include +#include +#include +#include +#include #include #include #include #include +#include +#include #include #include +#include +#include +#include static QList allRoles() { @@ -45,6 +58,11 @@ static const QMap ROLE_DESCRIPTIONS = { {QPalette::Shadow, QT_TR_NOOP("Very dark shadow colour")}, }; +static const QMap APP_ROLE_DESCRIPTIONS = { + {AppColor::AccentStrong, QT_TR_NOOP("Vivid primary accent (e.g. home-tab button gradient start)")}, + {AppColor::AccentSoft, QT_TR_NOOP("Lightened, desaturated accent (e.g. home-tab button gradient end)")}, +}; + PaletteGridWidget::PaletteGridWidget(QWidget *parent) : QWidget(parent) { scroll = new QScrollArea(this); @@ -122,6 +140,46 @@ void PaletteGridWidget::buildGrid(QWidget *host) grid->addWidget(btn, row + 1, col + 1, Qt::AlignHCenter | Qt::AlignVCenter); } } + + // Application color section: one ColorButton per role below the role grid. + // These are not tied to a color group, so a single button spans the row. + QMetaEnum appEnum = QMetaEnum::fromType(); + + const int appHeaderRow = roles.size() + 1; + + auto *appHeader = new QLabel(tr("App colors"), host); + appHeader->setToolTip(tr("Application-specific colors layered on top of the Qt palette")); + QFont appHeaderFont = appHeader->font(); + appHeaderFont.setBold(true); + appHeader->setFont(appHeaderFont); + appHeader->setAutoFillBackground(true); + appHeader->setContentsMargins(4, 4, 4, 4); + grid->addWidget(appHeader, appHeaderRow, 0, 1, 4); + headerLabels.append(appHeader); + + for (int i = 0; i < appEnum.keyCount(); ++i) { + auto role = static_cast(appEnum.value(i)); + const int row = appHeaderRow + 1 + i; + + if (i % 2 == 0) { + for (int col = 0; col < 4; ++col) { + auto *shade = new QWidget(host); + shade->setAutoFillBackground(true); + grid->addWidget(shade, row, col); + rowShadeWidgets.push_back(shade); + } + } + + auto *label = new QLabel(QString(appEnum.valueToKey(role)), host); + label->setToolTip(APP_ROLE_DESCRIPTIONS.value(role, {})); + label->setContentsMargins(4, 2, 8, 2); + grid->addWidget(label, row, 0); + + auto *btn = new ColorButton(host); + connect(btn, &ColorButton::colorChanged, this, [this] { emit paletteChanged(); }); + appColorButtons[role] = btn; + grid->addWidget(btn, row, 1, Qt::AlignHCenter | Qt::AlignVCenter); + } } void PaletteGridWidget::changeEvent(QEvent *e) @@ -166,6 +224,16 @@ void PaletteGridWidget::loadPalette(const PaletteConfig &cfg) colorButtons[group][role]->setColor(color); } } + + QMetaEnum appEnum = QMetaEnum::fromType(); + for (int i = 0; i < appEnum.keyCount(); ++i) { + auto role = static_cast(appEnum.value(i)); + QColor color = cfg.appColors.value(role); + if (!color.isValid()) { + color = themeManager->appColor(role); + } + appColorButtons[role]->setColor(color); + } } PaletteConfig PaletteGridWidget::currentPaletteConfig() const @@ -176,5 +244,12 @@ PaletteConfig PaletteGridWidget::currentPaletteConfig() const cfg.colors[group][role] = colorButtons[group][role]->getColor(); } } + + QMetaEnum appEnum = QMetaEnum::fromType(); + for (int i = 0; i < appEnum.keyCount(); ++i) { + auto role = static_cast(appEnum.value(i)); + cfg.appColors[role] = appColorButtons[role]->getColor(); + } + return cfg; } \ No newline at end of file diff --git a/cockatrice/src/interface/palette_editor/palette_grid_widget.h b/cockatrice/src/interface/palette_editor/palette_grid_widget.h index 1a665971a..f1ff41ec2 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.h +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.h @@ -2,15 +2,19 @@ #define COCKATRICE_PALETTE_GRID_WIDGET_H #include "../theme_config.h" -#include "color_button.h" +#include #include #include -#include +#include #include +#include class QLabel; class QScrollArea; +class ColorButton; +class QVBoxLayout; + // Scrollable grid of ColorButtons — one per (ColorGroup × ColorRole) cell. // Owns the load/read round-trip for PaletteConfig but has no file I/O itself. class PaletteGridWidget : public QWidget @@ -31,6 +35,7 @@ private: void refreshChromePalettes(); QMap> colorButtons; + QMap appColorButtons; QScrollArea *scroll; QWidget *gridHost; QVBoxLayout *layout; diff --git a/cockatrice/src/interface/palette_editor/quick_setup_panel.cpp b/cockatrice/src/interface/palette_editor/quick_setup_panel.cpp index 85098bde5..04f98dcc3 100644 --- a/cockatrice/src/interface/palette_editor/quick_setup_panel.cpp +++ b/cockatrice/src/interface/palette_editor/quick_setup_panel.cpp @@ -1,9 +1,15 @@ #include "quick_setup_panel.h" +#include "color_button.h" + #include +#include #include #include +#include #include +#include +#include QuickSetupPanel::QuickSetupPanel(QWidget *parent) : QWidget(parent) { diff --git a/cockatrice/src/interface/palette_editor/quick_setup_panel.h b/cockatrice/src/interface/palette_editor/quick_setup_panel.h index c83e27cf2..ea402086f 100644 --- a/cockatrice/src/interface/palette_editor/quick_setup_panel.h +++ b/cockatrice/src/interface/palette_editor/quick_setup_panel.h @@ -1,13 +1,14 @@ #ifndef COCKATRICE_QUICK_SETUP_PANEL_H #define COCKATRICE_QUICK_SETUP_PANEL_H -#include "color_button.h" - +#include #include +#include class QHBoxLayout; class QLabel; class QSlider; +class ColorButton; /** * @class QuickSetupPanel diff --git a/cockatrice/src/interface/pixel_map_generator.cpp b/cockatrice/src/interface/pixel_map_generator.cpp index d3b0252a6..2f5c4b6dd 100644 --- a/cockatrice/src/interface/pixel_map_generator.cpp +++ b/cockatrice/src/interface/pixel_map_generator.cpp @@ -1,12 +1,37 @@ #include "pixel_map_generator.h" +#include "libcockatrice/network/server/remote/user_level.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 +#include +#include +#include #include +#include +#include #include +#include #define DEFAULT_COLOR_UNREGISTERED "#32c8ec"; #define DEFAULT_COLOR_REGISTERED "#5ed900"; @@ -14,6 +39,32 @@ #define DEFAULT_COLOR_MODERATOR_RIGHT "#000000"; #define DEFAULT_COLOR_ADMIN "#ff2701"; +/** + * Clamps an svg render size so that rendering does not exceed a multiple of the requested size. + * + * Rendering at the full native size of an svg just to scale it down afterwards wastes memory, + * and canvases with extreme coordinates can exceed Qt's rasterizer coordinate limit which makes + * Qt silently drop shapes from the rendered image. + * + * @param renderSize The size the svg would be rendered at. + * @param requestedSize The size that was actually requested. + * + * @return A size with the aspect ratio of renderSize whose longest side is at most four times + * the longest side of requestedSize. + */ +static QSize capRenderSize(const QSize &renderSize, const QSize &requestedSize) +{ + const int longestRequestedSide = qMax(requestedSize.width(), requestedSize.height()); + if (longestRequestedSide <= 0) { + return renderSize; + } + + const int longestRenderSide = qMax(renderSize.width(), renderSize.height()); + const qreal scale = qMin(1.0, static_cast(longestRequestedSide * 4) / longestRenderSide); + return QSize(qMax(1, static_cast(renderSize.width() * scale)), + qMax(1, static_cast(renderSize.height() * scale))); +} + /** * Loads in an svg from file and scales it without affecting image quality. * @@ -35,6 +86,9 @@ static QPixmap loadSvg(const QString &svgPath, const QSize &size, bool expandOnl // If expandOnly, make sure the pixmap is at least as large as the svg, so that we don't lose any detail. // QIcon.pixmap(size) will automatically scale down the image, but it won't scale it up. QSize pixmapSize = expandOnly ? svgRenderer.defaultSize().expandedTo(size) : size; + if (expandOnly) { + pixmapSize = capRenderSize(pixmapSize, size); + } QPixmap pix(pixmapSize); pix.fill(Qt::transparent); @@ -52,7 +106,13 @@ static QPixmap loadSvg(const QString &svgPath, const QSize &size, bool expandOnl /** * Try to load path image from non-SVG formats, otherwise fall back to SVG. * This is to allow custom themes to support non-SVG format type overrides, since SVG requires custom loading. - * @param path The path to the file, with no file extension. File formats will be automatically detected. + * + * The path may already carry the resolved file extension (e.g. via + * ThemeManager::assetPath); such paths are loaded directly. Otherwise a + * format-agnostic lookup probes png, jpg and finally svg. + * + * @param path The path to the file, with no file extension unless the caller + * already resolved it. File formats will be automatically detected. * @param size The desired size of the pixmap. * @param expandOnly If true, then keep the size of the initial pixmap to at least the size (Only relevant if SVG). * @@ -60,6 +120,19 @@ static QPixmap loadSvg(const QString &svgPath, const QSize &size, bool expandOnl */ static QPixmap tryLoadImage(const QString &path, const QSize &size, bool expandOnly = false) { + if (path.endsWith(QLatin1String(".svg"), Qt::CaseInsensitive)) { + return loadSvg(path, size, expandOnly); + } + if (path.endsWith(QLatin1String(".png"), Qt::CaseInsensitive) || + path.endsWith(QLatin1String(".jpg"), Qt::CaseInsensitive) || + path.endsWith(QLatin1String(".jpeg"), Qt::CaseInsensitive)) { + QPixmap pix(path); + if (!pix.isNull()) { + return pix.scaled(size, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } + return {}; + } + const auto formats = {"png", "jpg"}; QPixmap returnPixmap; @@ -81,7 +154,8 @@ QPixmap PhasePixmapGenerator::generatePixmap(int height, QString name) return pmCache.value(key); } - QPixmap pixmap = tryLoadImage("theme:phases/" + name, QSize(height, height)); + QPixmap pixmap = tryLoadImage(QStringLiteral("theme:") + themeManager->assetPath(QStringLiteral("phases/") + name), + QSize(height, height)); pmCache.insert(key, pixmap); return pixmap; @@ -247,7 +321,9 @@ static QIcon loadAndColorSvg(const QString &iconPath, QSvgRenderer svgRenderer(doc.toByteArray()); - QPixmap pix(svgRenderer.defaultSize().expandedTo(QSize(minSize, minSize))); + const QSize pixmapSize = + capRenderSize(svgRenderer.defaultSize().expandedTo(QSize(minSize, minSize)), QSize(minSize, minSize)); + QPixmap pix(pixmapSize); pix.fill(Qt::transparent); QPainter pixPainter(&pix); @@ -307,6 +383,10 @@ static QString getIconType(const bool isBuddy, const UserLevelFlags &userLevelFl return "pawn_judge"; } + if (userLevelFlags.testFlag(ServerInfo_User::IsDeveloper)) { + return "pawn_dev"; + } + if (!privLevel.isEmpty() && privLevel.toLower() != "none") { return QString("pawn_%1").arg(privLevel.toLower()); } @@ -364,7 +444,8 @@ QPixmap LockPixmapGenerator::generatePixmap(int height) return pmCache.value(key); } - QPixmap pixmap = tryLoadImage("theme:icons/lock", QSize(height, height), true); + QPixmap pixmap = tryLoadImage(QStringLiteral("theme:") + themeManager->assetPath(QStringLiteral("icons/lock")), + QSize(height, height), true); pmCache.insert(key, pixmap); return pixmap; } @@ -379,7 +460,8 @@ QPixmap DropdownIconPixmapGenerator::generatePixmap(int height, bool expanded) } QString name = expanded ? "dropdown_expanded" : "dropdown_collapsed"; - QPixmap pixmap = tryLoadImage("theme:icons/" + name, QSize(height, height), true); + QPixmap pixmap = tryLoadImage(QStringLiteral("theme:") + themeManager->assetPath(QStringLiteral("icons/") + name), + QSize(height, height), true); pmCache.insert(key, pixmap); return pixmap; @@ -387,8 +469,66 @@ QPixmap DropdownIconPixmapGenerator::generatePixmap(int height, bool expanded) QMap DropdownIconPixmapGenerator::pmCache; +namespace +{ +/// Longest side mana symbols are rendered at before being scaled to their final size. +constexpr int MASTER_ICON_SIZE = 128; + +QString manaSymbolCacheKey(const QString &symbol, const QSize &size) +{ + return symbol + QLatin1Char('|') + QString::number(size.width()) + QLatin1Char('x') + + QString::number(size.height()); +} +} // namespace + +const QPixmap &ManaSymbolPixmapGenerator::masterIcon(const QString &symbol) +{ + auto it = masterCache.constFind(symbol); + if (it != masterCache.constEnd()) { + return it.value(); + } + + QImageReader reader("theme:icons/mana/" + symbol); + QSize sourceSize = reader.size(); + if (!sourceSize.isEmpty()) { + sourceSize.scale(QSize(MASTER_ICON_SIZE, MASTER_ICON_SIZE), Qt::KeepAspectRatio); + reader.setScaledSize(sourceSize); + } + const QPixmap rendered = QPixmap::fromImageReader(&reader); + + return masterCache.insert(symbol, rendered).value(); +} + +QPixmap ManaSymbolPixmapGenerator::generatePixmap(const QString &symbol, const QSize &size) +{ + const QString key = manaSymbolCacheKey(symbol, size); + auto it = scaledCache.constFind(key); + if (it != scaledCache.constEnd()) { + return it.value(); + } + + const QPixmap &icon = masterIcon(symbol); + if (icon.isNull()) { + return {}; + } + + QPixmap scaled = icon.scaled(size, Qt::KeepAspectRatio, Qt::SmoothTransformation); + scaledCache.insert(key, scaled); + return scaled; +} + +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(); @@ -399,3 +539,21 @@ QPixmap loadColorAdjustedPixmap(const QString &name) return QPixmap(name); } } + +QPixmap themePixmap(QStringView prefix) +{ + const QString resolved = themeManager->assetPath(prefix); + return QPixmap(QStringLiteral("theme:") + resolved); +} + +void clearPixmapGeneratorCaches() +{ + PhasePixmapGenerator::clear(); + CounterPixmapGenerator::clear(); + PingPixmapGenerator::clear(); + CountryPixmapGenerator::clear(); + UserLevelPixmapGenerator::clear(); + LockPixmapGenerator::clear(); + DropdownIconPixmapGenerator::clear(); + ManaSymbolPixmapGenerator::clear(); +} diff --git a/cockatrice/src/interface/pixel_map_generator.h b/cockatrice/src/interface/pixel_map_generator.h index 22f44d8db..7c01bb332 100644 --- a/cockatrice/src/interface/pixel_map_generator.h +++ b/cockatrice/src/interface/pixel_map_generator.h @@ -7,13 +7,20 @@ #ifndef PIXMAPGENERATOR_H #define PIXMAPGENERATOR_H +#include "libcockatrice/protocol/pb/serverinfo_user.pb.h" + +#include #include #include #include #include +#include +#include #include #include +class QSize; + inline Q_LOGGING_CATEGORY(PixelMapGeneratorLog, "pixel_map_generator"); class PhasePixmapGenerator @@ -125,6 +132,45 @@ public: } }; +class ManaSymbolPixmapGenerator +{ +private: + static QHash masterCache; + static QHash scaledCache; + + /** + * @brief Renders \a symbol once at a fixed moderate size, so repeated scalings never + * re-rasterize the source file (SVG sources can be very expensive to rasterize). + */ + static const QPixmap &masterIcon(const QString &symbol); + +public: + /** + * @brief Returns a smooth-scaled rendering of the given mana symbol icon. + * + * Results are shared between all callers via a process-wide cache keyed by symbol + * and size, so scaling work is done once per distinct combination instead of once + * per widget creation or resize. + */ + static QPixmap generatePixmap(const QString &symbol, const QSize &size); + static void clear() + { + masterCache.clear(); + scaledCache.clear(); + } +}; + QPixmap loadColorAdjustedPixmap(const QString &name); +// Loads a "theme:" asset (with no file extension in prefix), preferring the +// scheme-qualified variant (prefix-dark / prefix-light, resolved via +// ThemeManager::assetPath) and falling back to the plain asset. Callers load +// the returned path directly. Use for scheme-sensitive pixmaps like +// backgrounds, the card back, and the app logo. +QPixmap themePixmap(QStringView prefix); + +// Clears every PixmapGenerator's static cache so scheme variants are +// re-resolved when the active theme or color scheme changes. +void clearPixmapGeneratorCaches(); + #endif diff --git a/cockatrice/src/interface/theme_config.cpp b/cockatrice/src/interface/theme_config.cpp index 3c43c467d..652e322c8 100644 --- a/cockatrice/src/interface/theme_config.cpp +++ b/cockatrice/src/interface/theme_config.cpp @@ -1,9 +1,17 @@ #include "theme_config.h" +#include +#include #include #include +#include +#include +#include #include +#include #include +#include +#include bool ThemeConfig::isEmpty() const { @@ -16,7 +24,7 @@ QString ThemeConfig::toIni() const out += "[Appearance]\n"; out += QString("ColorScheme = %1\n").arg(colorScheme.isEmpty() ? "System" : colorScheme); out += "\n[Style]\n"; - out += QString("Name = %1\n").arg(styleName.isEmpty() ? "Default" : styleName); + out += QString("Name = %1\n").arg(styleName.isEmpty() ? "System" : styleName); return out; } @@ -96,7 +104,7 @@ bool ThemeConfig::save(const QString &themeDirPath) const bool PaletteConfig::hasPalette() const { - return !colors.isEmpty(); + return !colors.isEmpty() || !appColors.isEmpty(); } QString PaletteConfig::toToml() const @@ -133,6 +141,24 @@ QString PaletteConfig::toToml() const out += "\n"; } + if (!appColors.isEmpty()) { + QMetaEnum appEnum = QMetaEnum::fromType(); + + out += "[AppColors]\n"; + + for (auto it = appColors.cbegin(); it != appColors.cend(); ++it) { + const char *roleName = appEnum.valueToKey(it.key()); + + if (!roleName) { + continue; + } + + out += QString("%1 = %2\n").arg(QString(roleName), -20).arg(it.value().name(QColor::HexArgb)); + } + + out += "\n"; + } + return out; } @@ -152,6 +178,7 @@ PaletteConfig PaletteConfig::fromFile(const QString &filePath) } QMetaEnum roleEnum = QMetaEnum::fromType(); + QMetaEnum appEnum = QMetaEnum::fromType(); QString currentSection; QPalette::ColorGroup currentGroup = QPalette::Active; @@ -202,6 +229,26 @@ PaletteConfig PaletteConfig::fromFile(const QString &filePath) } } + QColor color(value); + + if (!color.isValid()) { + continue; + } + + if (currentSection.compare("AppColors", Qt::CaseInsensitive) == 0) { + if (key.startsWith("AppColor::")) { + key = key.mid(10); + } + + int appRoleInt = appEnum.keyToValue(key.toUtf8().constData()); + + if (appRoleInt >= 0) { + cfg.appColors[static_cast(appRoleInt)] = color; + } + + continue; + } + if (!currentSection.startsWith("Palette", Qt::CaseInsensitive)) { continue; } @@ -216,11 +263,7 @@ PaletteConfig PaletteConfig::fromFile(const QString &filePath) continue; } - QColor color(value); - - if (color.isValid()) { - cfg.colors[currentGroup][static_cast(roleInt)] = color; - } + cfg.colors[currentGroup][static_cast(roleInt)] = color; } return cfg; diff --git a/cockatrice/src/interface/theme_config.h b/cockatrice/src/interface/theme_config.h index 07bf55b7a..8b91613f5 100644 --- a/cockatrice/src/interface/theme_config.h +++ b/cockatrice/src/interface/theme_config.h @@ -4,7 +4,24 @@ #include #include #include +#include #include +#include + +// Application-specific color roles, layered on top of the fixed QPalette role +// set. Stored in the same palette-.toml under an [AppColors] section +// and editable from the palette editor, so theme authors can control colors +// beyond what Qt's palette can express. +namespace AppColor +{ +Q_NAMESPACE +enum Role +{ + AccentStrong, + AccentSoft, +}; +Q_ENUM_NS(Role) +} // namespace AppColor struct ThemeConfig { @@ -21,7 +38,16 @@ struct ThemeConfig struct PaletteConfig { QMap> colors; + QMap appColors; + bool operator==(const PaletteConfig &rhs) const + { + return colors == rhs.colors && appColors == rhs.appColors; + } + bool operator!=(const PaletteConfig &rhs) const + { + return !(*this == rhs); + } bool hasPalette() const; QString toToml() const; diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index 518a97bc6..9a96b3e45 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -1,25 +1,44 @@ #include "theme_manager.h" #include "../../client/settings/cache_settings.h" +#include "pixel_map_generator.h" +#include "theme_config.h" #include +#include #include #include -#include +#include +#include +#include +#include +#include +#include +#include +#include #include +#include #include #include +#include #include -#include +#include #include +#include +#include #include #include #include +#include #include #include +#include #include +#include +#include -#define NONE_THEME_NAME "Default" +#define SYSTEM_THEME_NAME "System" +#define LEGACY_SYSTEM_THEME_NAME "Default" #define FUSION_THEME_NAME "Fusion" #define STYLE_CSS_NAME "style.css" #define HANDZONE_BG_NAME "handzone" @@ -90,19 +109,33 @@ struct PaletteColorInfo } } +static QString usableDefaultStyle(const QString &style) +{ + // Only the OS/default ("System") style is sanitized: the Windows 11 and + // Windows Vista native styles are broken (dragging cards across zones can + // shrink the board to a tiny grey window that is unfixable without + // rejoining), so a "System" choice falls through to the built-in "windows" + // style (QWindowsStyle) instead. They remain selectable by hand, where the + // settings page warns about them. + if (style.compare("windows11", Qt::CaseInsensitive) == 0 || + style.compare("windowsvista", Qt::CaseInsensitive) == 0) { + return QStringLiteral("windows"); + } + return style; +} + ThemeManager::ThemeManager(QObject *parent) : QObject(parent) { - defaultStyleName = qApp->style()->objectName(); - //! \todo Workaround for windows11 style being broken. - if (defaultStyleName == "windows11") { - defaultStyleName = "windowsvista"; - } + defaultStyleName = usableDefaultStyle(qApp->style()->objectName()); // Capture the untouched application palette before any theme is applied. defaultPalette = qApp->palette(); ensureThemeDirectoryExists(); #if (QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)) connect(QGuiApplication::styleHints(), &QStyleHints::colorSchemeChanged, this, [this] { - defaultPalette = qApp->palette(); + // Reload so scheme-qualified assets and palettes follow the OS, but do + // NOT recapture defaultPalette: qApp->palette() already carries the + // currently-applied theme palette at this point, so recapturing it + // would contaminate the base for every later theme switch. themeChangedSlot(); }); #endif @@ -110,16 +143,94 @@ ThemeManager::ThemeManager(QObject *parent) : QObject(parent) themeChangedSlot(); } +// Copy every file of sourceDir into targetDir without ever overwriting an +// existing file, recursing into subdirectories (zones/, backgrounds/, ...). +// Returns false as soon as an entry could not be copied, leaving the source +// untouched so the caller can retry later. +static bool mergeDirWithoutOverwrite(const QString &sourceDir, const QString &targetDir) +{ + const QDir target(targetDir); + bool merged = true; + + const QDir::Filters filters = QDir::AllEntries | QDir::NoDotAndDotDot | QDir::Hidden; + for (const QFileInfo &entry : QDir(sourceDir).entryInfoList(filters)) { + const QString targetEntry = target.absoluteFilePath(entry.fileName()); + if (entry.isDir()) { + if (!QDir().mkpath(targetEntry) || !mergeDirWithoutOverwrite(entry.absoluteFilePath(), targetEntry)) { + merged = false; + } + } else if (!target.exists(entry.fileName()) && !QFile::copy(entry.absoluteFilePath(), targetEntry)) { + qCWarning(ThemeManagerLog) << "Could not migrate theme file:" << entry.absoluteFilePath(); + merged = false; + } + } + + return merged; +} + +void ThemeManager::migrateLegacyThemeDir(const QString &themesPath, const QString ¤tThemeName) +{ + const QString legacyPath = QDir(themesPath).absoluteFilePath(LEGACY_SYSTEM_THEME_NAME); + if (!QDir(legacyPath).exists()) { + return; + } + + // Only the renamed theme's own directory is migrated. A user sitting on a + // different theme may well have hand-made a "Default" folder of their own, + // and silently folding it into "System" would be rude. The check accepts + // both the pre-rename name and the post-rename one, since a user who + // already ran the rename keeps their settings on "System" while their + // customised directory is still called "Default". + if (currentThemeName != LEGACY_SYSTEM_THEME_NAME && currentThemeName != SYSTEM_THEME_NAME) { + return; + } + + const QString targetPath = QDir(themesPath).absoluteFilePath(SYSTEM_THEME_NAME); + if (!QDir(targetPath).exists() && QDir().rename(legacyPath, targetPath)) { + qCInfo(ThemeManagerLog) << "Migrated customised theme directory" << LEGACY_SYSTEM_THEME_NAME << "to" + << SYSTEM_THEME_NAME; + return; + } + + // The rename is only possible when the target does not exist yet. When it + // does (a partial earlier upgrade, or a user who re-customised the theme + // after the rename), fall back to merging so nothing is lost: existing + // "System" files win, legacy files only fill gaps. Because every legacy + // file either already exists at the target or is copied there, removing + // the legacy directory afterwards cannot drop user data. + if (!mergeDirWithoutOverwrite(legacyPath, targetPath)) { + qCWarning(ThemeManagerLog) << "Could not fully migrate theme directory" << legacyPath << "; keeping it for a" + << "later retry"; + return; + } + if (!QDir(legacyPath).removeRecursively()) { + qCWarning(ThemeManagerLog) << "Migrated theme directory but could not remove the legacy one:" << legacyPath; + return; + } + qCInfo(ThemeManagerLog) << "Merged customised theme directory" << LEGACY_SYSTEM_THEME_NAME << "into" + << SYSTEM_THEME_NAME; +} + void ThemeManager::ensureThemeDirectoryExists() { - if (SettingsCache::instance().getThemeName().isEmpty() || - !getAvailableThemes().contains(SettingsCache::instance().getThemeName())) { + auto &settings = SettingsCache::instance(); + + // Carry the user's theme customisations (zone graphics, stylesheets, + // palettes, theme.cfg) across before the stored name is rewritten. + migrateLegacyThemeDir(settings.paths().getThemesPath(), settings.getThemeName()); + + // Migrate the old "Default" theme name to "System" + if (settings.getThemeName() == LEGACY_SYSTEM_THEME_NAME) { + settings.setThemeName(SYSTEM_THEME_NAME); + } + + if (settings.getThemeName().isEmpty() || !getAvailableThemes().contains(settings.getThemeName())) { qCInfo(ThemeManagerLog) << "Theme name not set, setting default value"; - SettingsCache::instance().setThemeName(NONE_THEME_NAME); + settings.setThemeName(FUSION_THEME_NAME); } } -bool ThemeManager::isDarkMode(const QString &themeDirPath) +bool ThemeManager::isDarkMode(const QString &themeDirPath) const { ThemeConfig themeConfig = ThemeConfig::fromThemeDir(themeDirPath); if (themeConfig.colorScheme.compare("Dark", Qt::CaseInsensitive) == 0) { @@ -136,11 +247,74 @@ bool ThemeManager::isDarkMode(const QString &themeDirPath) } } -bool ThemeManager::isBuiltInTheme() +QString ThemeManager::schemeVariantPath(QStringView prefix) const { - const auto themeName = SettingsCache::instance().getThemeName(); + static const QStringList formats = {QStringLiteral(".png"), QStringLiteral(".jpg"), QStringLiteral(".jpeg"), + QStringLiteral(".svg")}; + const QString scheme = isDarkMode(currentThemePath) ? QStringLiteral("dark") : QStringLiteral("light"); + const QString variantStem = prefix.toString() + QLatin1Char('-') + scheme; - return themeName == NONE_THEME_NAME || themeName == FUSION_THEME_NAME; + for (const QString &format : formats) { + if (QFileInfo::exists(QStringLiteral("theme:") + variantStem + format)) { + return variantStem + format; + } + } + return QString(); +} + +QString ThemeManager::assetPath(QStringView prefix) const +{ + // Probe order mirrors tryLoadImage: a theme may override the default SVG + // with a raster of the same stem, so raster wins over SVG within a stem. + static const QStringList formats = {QStringLiteral(".png"), QStringLiteral(".jpg"), QStringLiteral(".jpeg"), + QStringLiteral(".svg")}; + + auto findExisting = [](const QString &stem) { + for (const QString &format : formats) { + if (QFileInfo::exists(QStringLiteral("theme:") + stem + format)) { + return stem + format; + } + } + return QString(); + }; + + // Prefer the scheme-qualified variant when it exists, else the plain + // asset as the super fallback. Both return the resolved path including + // its file extension so callers can load it directly. + const QString variant = schemeVariantPath(prefix); + if (!variant.isEmpty()) { + return variant; + } + const QString resolvedPlain = findExisting(prefix.toString()); + return resolvedPlain.isEmpty() ? prefix.toString() : resolvedPlain; +} + +// Probe whether a directory is truly writable by trying to create and remove a +// temporary file. QFileInfo::isWritable() on a directory is unreliable (notably +// on Windows where UAC VirtualStore can make a system dir appear writable). +bool ThemeManager::isDirReallyWritable(const QString &dirPath) +{ + const QString probe = QDir(dirPath).absoluteFilePath(".cockatrice_write_test"); + QFile f(probe); + if (!f.open(QIODevice::WriteOnly)) { + return false; + } + f.close(); + f.remove(); + return true; +} + +QString ThemeManager::writableThemeDir(const QString &themeName) +{ + // All theme writes go to the user themes directory regardless of whether + // the resolved (system) theme directory happens to be writable. Even when a + // write would succeed in-place, routing it to the user directory keeps the + // install intact and guarantees changes survive upgrades. + const QString dirPath = QDir(SettingsCache::instance().paths().getThemesPath()).absoluteFilePath(themeName); + if (!QDir().mkpath(dirPath)) { + qWarning() << "Failed to create theme save directory:" << dirPath; + } + return dirPath; } // System (read-only) themes location, relative to the application binary. @@ -165,9 +339,7 @@ QStringMap &ThemeManager::getAvailableThemes() // load themes from user profile dir dir.setPath(SettingsCache::instance().paths().getThemesPath()); - // add default value - availableThemes.insert(NONE_THEME_NAME, dir.absoluteFilePath("Default")); - + availableThemes.insert(SYSTEM_THEME_NAME, dir.absoluteFilePath("System")); availableThemes.insert(FUSION_THEME_NAME, dir.absoluteFilePath("Fusion")); for (QString themeName : dir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot, QDir::Name)) { @@ -176,7 +348,7 @@ QStringMap &ThemeManager::getAvailableThemes() } } - // load themes from cockatrice system dir + // Load themes from Cockatrice system dir dir.setPath(systemThemesBasePath()); for (QString themeName : dir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot, QDir::Name)) { @@ -191,7 +363,7 @@ QStringMap &ThemeManager::getAvailableThemes() QBrush ThemeManager::loadBrush(QString fileName, QColor fallbackColor) { QBrush brush; - QPixmap tmp = QPixmap("theme:zones/" + fileName); + QPixmap tmp = QPixmap("theme:" + assetPath(QStringLiteral("zones/") + fileName)); if (tmp.isNull()) { brush.setColor(fallbackColor); brush.setStyle(Qt::SolidPattern); @@ -205,7 +377,7 @@ QBrush ThemeManager::loadBrush(QString fileName, QColor fallbackColor) QBrush ThemeManager::loadExtraBrush(QString fileName, QBrush &fallbackBrush) { QBrush brush; - QPixmap tmp = QPixmap("theme:zones/" + fileName); + QPixmap tmp = QPixmap("theme:" + assetPath(QStringLiteral("zones/") + fileName)); if (tmp.isNull()) { brush = fallbackBrush; @@ -268,9 +440,22 @@ PaletteConfig ThemeManager::loadDefaultPaletteConfig(const QString &themeDirPath return cfg; } +bool ThemeManager::commitPalette(const QString &themeDirPath, const QString &colorScheme, const PaletteConfig &cfg) +{ + if (!savePaletteConfig(themeDirPath, colorScheme, cfg)) { + return false; + } + + ThemeConfig globalCfg = ThemeConfig::fromThemeDir(themeDirPath); + globalCfg.colorScheme = colorScheme; + globalCfg.save(themeDirPath); + + return true; +} + void ThemeManager::setColorScheme(const QString &scheme) { - const QString dirPath = getAvailableThemes().value(SettingsCache::instance().getThemeName()); + const QString dirPath = writableThemeDir(SettingsCache::instance().getThemeName()); ThemeConfig cfg = ThemeConfig::fromThemeDir(dirPath); cfg.colorScheme = scheme; @@ -281,7 +466,7 @@ void ThemeManager::setColorScheme(const QString &scheme) void ThemeManager::setStyleName(const QString &styleName) { - const QString dirPath = getAvailableThemes().value(SettingsCache::instance().getThemeName()); + const QString dirPath = writableThemeDir(SettingsCache::instance().getThemeName()); ThemeConfig cfg = ThemeConfig::fromThemeDir(dirPath); cfg.styleName = styleName; @@ -312,17 +497,20 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName, Q_UNUSED(activeScheme) #endif QString styleName = themeCfg.styleName; - if (styleName.isEmpty() || styleName.compare("Default", Qt::CaseInsensitive) == 0) { + if (styleName.isEmpty() || styleName.compare("System", Qt::CaseInsensitive) == 0) { if (themeName == FUSION_THEME_NAME) { styleName = "Fusion"; } else { - styleName = defaultStyleName; + // "System" resolves through the guard, which steers the broken + // Windows native styles to "windows". An explicitly chosen style + // name is honored as-is. + styleName = usableDefaultStyle(defaultStyleName); } } QStyle *style = QStyleFactory::create(styleName); if (!style) { - style = QStyleFactory::create(defaultStyleName); + style = QStyleFactory::create(usableDefaultStyle(defaultStyleName)); } // Base palette @@ -355,6 +543,8 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName, qApp->setPalette(base); qApp->setStyle(style); + currentAppColors = palCfg.appColors; + // Force every widget to re-polish and repaint immediately rather than // waiting for natural expose events, which produces a patchwork of old // and new colours during a live preview. @@ -367,6 +557,35 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName, style->polish(widget); widget->update(); } + + emit paletteChanged(); +} + +QColor ThemeManager::appColor(AppColor::Role role) const +{ + const auto it = currentAppColors.constFind(role); + if (it != currentAppColors.constEnd()) { + return it.value(); + } + + // QPalette::Accent was introduced in Qt 6.6 and several shipped palettes + // set it to a value barely distinguishable from Window, so it is not a + // reliable accent source. The selection highlight is the stable accent + // (Accent defaults to Highlight when unset), and deriving from it + // unconditionally keeps every Qt version rendering identically. + const QColor accent = qApp->palette().color(QPalette::Active, QPalette::Highlight); + + if (role == AppColor::AccentSoft) { + constexpr int SOFT_SATURATION_PERCENT = 70; + constexpr int SOFT_LIGHTNESS_OFFSET = 60; + + // Light end of the gradient: same hue, softened and lightened + return QColor::fromHsl(qMax(0, accent.hslHue()), + qBound(0, qRound(accent.hslSaturation() * SOFT_SATURATION_PERCENT / 100.0), 255), + qBound(0, accent.lightness() + SOFT_LIGHTNESS_OFFSET, 255)); + } + + return accent; } void ThemeManager::themeChangedSlot() @@ -376,9 +595,19 @@ void ThemeManager::themeChangedSlot() currentThemePath = dirPath; QDir dir(dirPath); - // CSS - if (!dirPath.isEmpty() && dir.exists(STYLE_CSS_NAME)) { - qApp->setStyleSheet("file:///" + dir.absoluteFilePath(STYLE_CSS_NAME)); + // CSS — prefer the scheme-qualified stylesheet (style-dark.css / + // style-light.css) when present, else the plain style.css as fallback. + if (!dirPath.isEmpty()) { + const QString scheme = isDarkMode(dirPath) ? QStringLiteral("dark") : QStringLiteral("light"); + const QString schemeCss = QFileInfo(QStringLiteral(STYLE_CSS_NAME)).completeBaseName() + QLatin1Char('-') + + scheme + QStringLiteral(".css"); + if (dir.exists(schemeCss)) { + qApp->setStyleSheet("file:///" + dir.absoluteFilePath(schemeCss)); + } else if (dir.exists(STYLE_CSS_NAME)) { + qApp->setStyleSheet("file:///" + dir.absoluteFilePath(STYLE_CSS_NAME)); + } else { + qApp->setStyleSheet(""); + } } else { qApp->setStyleSheet(""); } @@ -393,8 +622,19 @@ void ThemeManager::themeChangedSlot() // ── Load palette: custom first, then theme default ──────────────────── PaletteConfig palette = PaletteConfig::fromScheme(dirPath, activeScheme); - if (!palette.hasPalette()) { - palette = ThemeManager::loadDefaultPaletteConfig(dirPath, themeName, activeScheme); + const PaletteConfig themeDefault = ThemeManager::loadDefaultPaletteConfig(dirPath, themeName, activeScheme); + if (palette.hasPalette()) { + // A custom palette written before [AppColors] existed carries no app + // colors; merge the theme's shipped defaults so the identity colors + // survive (hasPalette() counts an app-colors-only file as a palette, + // so those are kept wholesale and never reach here empty). + for (auto it = themeDefault.appColors.cbegin(); it != themeDefault.appColors.cend(); ++it) { + if (!palette.appColors.contains(it.key())) { + palette.appColors.insert(it.key(), it.value()); + } + } + } else { + palette = themeDefault; } applyStyleAndPalette(themeName, themeCfg, palette, activeScheme); @@ -429,6 +669,7 @@ void ThemeManager::themeChangedSlot() } QPixmapCache::clear(); + clearPixmapGeneratorCaches(); emit themeChanged(); } diff --git a/cockatrice/src/interface/theme_manager.h b/cockatrice/src/interface/theme_manager.h index 861ab838b..c1ab65685 100644 --- a/cockatrice/src/interface/theme_manager.h +++ b/cockatrice/src/interface/theme_manager.h @@ -10,21 +10,21 @@ #include "theme_config.h" #include -#include +#include #include #include #include -#include +#include #include +#include #include +#include inline Q_LOGGING_CATEGORY(ThemeManagerLog, "theme_manager"); typedef QMap QStringMap; typedef QMap QBrushMap; -class QApplication; - class ThemeManager : public QObject { Q_OBJECT @@ -50,6 +50,7 @@ private: QString currentThemePath; std::array brushes; QStringMap availableThemes; + QMap currentAppColors; /* Internal cache for multiple backgrounds */ @@ -65,8 +66,32 @@ protected: const QString &activeScheme); public: - bool isBuiltInTheme(); - bool isDarkMode(const QString &themeDirPath); + // Resolves the directory to write theme changes to for the given theme + // name. The resolved theme dir (user or system) is used when writable; + // read-only system themes fall back to the user themes directory, creating + // it if needed, so customisations never get lost on upgrade. + static QString writableThemeDir(const QString &themeName); + // Carries a user profile's customised "Default" theme directory over to the + // "System" directory the theme was renamed to, so zone graphics, + // stylesheets, palettes and theme.cfg survive the rename. Renames the + // directory when "System" does not exist yet, otherwise merges file by + // file without overwriting what "System" already has. No-op unless the + // active theme is one of the two names, so a hand-made "Default" folder of + // a user on another theme is left alone. + static void migrateLegacyThemeDir(const QString &themesPath, const QString ¤tThemeName); + // Probe whether a directory is truly writable by trying to create and remove + // a temporary file. QFileInfo::isWritable() on a directory is unreliable + // (notably on Windows where UAC VirtualStore can make a system dir appear + // writable). + static bool isDirReallyWritable(const QString &dirPath); + // Explicit color scheme of the theme: theme.cfg's ColorScheme setting + // (Dark/Light), falling back to the OS color scheme when it is "System". + bool isDarkMode(const QString &themeDirPath) const; + // The resolved scheme of the currently active theme. + bool isDarkModeActive() const + { + return isDarkMode(currentThemePath); + } QStringMap &getAvailableThemes(); // Returns the path to the currently active theme directory (empty = default) QString getCurrentThemePath() const @@ -80,22 +105,45 @@ public: // Load/save per-scheme palette colors static PaletteConfig loadPaletteConfig(const QString &themeDirPath, const QString &colorScheme); static bool savePaletteConfig(const QString &themeDirPath, const QString &colorScheme, const PaletteConfig &cfg); + // Resolve prefix to a scheme-qualified "theme:" path. Existence is probed + // internally across the formats themes may ship (.png/.jpg/.svg), so + // callers load the returned path directly. Prefers "-" + // when a file exists at that stem, otherwise the plain "" as the + // super fallback. The resolved scheme covers explicit light/dark as well + // as OS-resolved "system". Returns the path with its file extension when a + // match is found; unqualified assets keep working unchanged. + QString assetPath(QStringView prefix) const; + // Like assetPath, but resolves only the scheme-qualified variant + // ("-.") and returns an empty string when no + // variant exists — it never falls back to the plain "" asset. + // Callers that must distinguish "no authored variant" (e.g. to keep a + // legacy runtime fallback alive) should use this instead of assetPath. + QString schemeVariantPath(QStringView prefix) const; // Load the theme's shipped default palette, falling back to the system // theme directory when it is absent from the resolved (user) directory. static PaletteConfig loadDefaultPaletteConfig(const QString &themeDirPath, const QString &themeName, const QString &colorScheme); + /** @brief Writes cfg to disk as the theme's palette-.toml and updates the + * theme's stored colour scheme to match. Shared by PaletteEditorDialog::onSave + * and FirstRunWizard's theme step so the two "generate + keep" paths can't drift. */ + static bool commitPalette(const QString &themeDirPath, const QString &colorScheme, const PaletteConfig &cfg); void setColorScheme(const QString &scheme); void setStyleName(const QString &styleName); void reloadCurrentTheme(); void previewPalette(const PaletteConfig &cfg, const QString &scheme); + // Resolves an application color role: the theme's stored [AppColors] value + // when present, otherwise a palette-accent-derived fallback. + QColor appColor(AppColor::Role role) const; + QBrush &getBgBrush(Role zone); QBrush getExtraBgBrush(Role zone, int zoneId = 0); protected slots: void themeChangedSlot(); signals: void themeChanged(); + void paletteChanged(); }; extern ThemeManager *themeManager; diff --git a/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp b/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp index 3f0f30a27..ef7fb0e0e 100644 --- a/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp @@ -1,15 +1,24 @@ #include "color_identity_widget.h" #include "../../../../client/settings/cache_settings.h" +#include "libcockatrice/card/card_info.h" #include "mana_symbol_widget.h" +#include #include -#include +#include +#include +#include #include +#include +#include #include +#include #include +#include #include #include +#include ColorIdentityWidget::ColorIdentityWidget(QWidget *parent, const QString &_colorIdentity) : QWidget(parent), colorIdentity(_colorIdentity) @@ -41,6 +50,11 @@ void ColorIdentityWidget::populateManaSymbolWidgets() // clear old layout QtUtils::clearLayoutRec(layout); + // The freshly created symbols haven't been sized yet, so force the next resize pass + // to apply the symbol size again. + lastIconSize = -1; + lastWidth = -1; + // populate mana symbols if (SettingsCache::instance().visualDeckStorage().getVisualDeckStorageDrawUnusedColorIdentities()) { for (const QString symbol : fullColorIdentity) { @@ -73,20 +87,37 @@ void ColorIdentityWidget::toggleUnusedVisibility() void ColorIdentityWidget::resizeEvent(QResizeEvent *event) { QWidget::resizeEvent(event); - QList manaSymbols = findChildren(); - if (!manaSymbols.isEmpty()) { - int totalWidth = event->size().width(); - int totalHeight = totalWidth / 6; // Set height to 1/4 of the width - setFixedHeight(totalHeight); + const int totalWidth = event->size().width(); + if (totalWidth == lastWidth && lastIconSize != -1) { + return; + } + lastWidth = totalWidth; - int spacing = layout->spacing(); - int count = manaSymbols.size(); - int availableWidth = totalWidth - (spacing * (count - 1)); - int iconSize = qMin(availableWidth / count, totalHeight); // Ensure icons fit within the new height + const int totalHeight = qMax(0, totalWidth / 6); // Set height to 1/4 of the width + setFixedHeight(totalHeight); - for (ManaSymbolWidget *manaSymbol : manaSymbols) { - manaSymbol->setFixedSize(iconSize, iconSize); + const int count = layout->count(); + if (count == 0) { + return; + } + + const int spacing = layout->spacing(); + const int availableWidth = totalWidth - (spacing * (count - 1)); + const int iconSize = qMin(availableWidth / count, totalHeight); // Ensure icons fit within the new height + + if (iconSize <= 0) { + lastIconSize = iconSize; + return; + } + if (iconSize == lastIconSize) { + return; + } + lastIconSize = iconSize; + + for (int i = 0; i < count; ++i) { + if (auto *w = qobject_cast(layout->itemAt(i)->widget())) { + w->setFixedSize(iconSize, iconSize); } } } diff --git a/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.h b/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.h index f776d4c77..3caf1a964 100644 --- a/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.h +++ b/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.h @@ -7,9 +7,13 @@ #ifndef COLOR_IDENTITY_WIDGET_H #define COLOR_IDENTITY_WIDGET_H -#include +#include +#include #include #include +#include + +class QHBoxLayout; class ColorIdentityWidget : public QWidget { @@ -30,6 +34,8 @@ public slots: private: QString colorIdentity; QHBoxLayout *layout; + int lastIconSize = -1; ///< The symbol size last applied, to skip redundant resize passes. + int lastWidth = -1; ///< The width last processed, to skip redundant resize passes. }; #endif // COLOR_IDENTITY_WIDGET_H diff --git a/cockatrice/src/interface/widgets/cards/additional_info/deck_color_identity.cpp b/cockatrice/src/interface/widgets/cards/additional_info/deck_color_identity.cpp new file mode 100644 index 000000000..13c185a79 --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/additional_info/deck_color_identity.cpp @@ -0,0 +1,42 @@ +#include "deck_color_identity.h" + +#include "libcockatrice/card/card_info.h" + +#include +#include +#include +#include +#include +#include +#include + +QString getDeckColorIdentity(const DeckList &deck, const CardDatabaseQuerier *db) +{ + const QStringList cardList = deck.getCardList({DECK_ZONE_MAIN, DECK_ZONE_SIDE}); + if (cardList.isEmpty()) { + return {}; + } + + QSet colorSet; // A set to collect unique color symbols (e.g., W, U, B, R, G) + + for (const QString &cardName : cardList) { + CardInfoPtr currentCard = db->getCardInfo(cardName); + if (currentCard) { + const QString colors = currentCard->getColors(); // returns something like "WUB" + for (const QChar &color : colors) { + colorSet.insert(color); + } + } + } + + // Ensure the color identity is in WUBRG order + QString colorIdentity; + const QString wubrgOrder = "WUBRG"; + for (const QChar &color : wubrgOrder) { + if (colorSet.contains(color)) { + colorIdentity.append(color); + } + } + + return colorIdentity; +} diff --git a/cockatrice/src/interface/widgets/cards/additional_info/deck_color_identity.h b/cockatrice/src/interface/widgets/cards/additional_info/deck_color_identity.h new file mode 100644 index 000000000..04294cb1c --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/additional_info/deck_color_identity.h @@ -0,0 +1,20 @@ +#ifndef COCKATRICE_DECK_COLOR_IDENTITY_H +#define COCKATRICE_DECK_COLOR_IDENTITY_H + +#include + +class CardDatabaseQuerier; +class DeckList; + +/** + * @brief Computes the color identity of a deck (e.g. "WUBRG") from the color + * symbols of all cards in the main deck and sideboard, ordered WUBRG. + * + * Shared as a free function so the deck storage previews and the deck share + * dialog compute identities identically. + * + * @param db Card database used to look up card color symbols. + */ +QString getDeckColorIdentity(const DeckList &deck, const CardDatabaseQuerier *db); + +#endif // COCKATRICE_DECK_COLOR_IDENTITY_H diff --git a/cockatrice/src/interface/widgets/cards/additional_info/mana_cost_widget.cpp b/cockatrice/src/interface/widgets/cards/additional_info/mana_cost_widget.cpp index fcbb69c2e..7da97f88c 100644 --- a/cockatrice/src/interface/widgets/cards/additional_info/mana_cost_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/additional_info/mana_cost_widget.cpp @@ -1,12 +1,16 @@ #include "mana_cost_widget.h" +#include "libcockatrice/card/card_info.h" #include "mana_symbol_widget.h" #include -#include +#include +#include +#include +#include #include #include -#include +#include ManaCostWidget::ManaCostWidget(QWidget *parent, CardInfoPtr _card) : QWidget(parent), card(_card) { diff --git a/cockatrice/src/interface/widgets/cards/additional_info/mana_cost_widget.h b/cockatrice/src/interface/widgets/cards/additional_info/mana_cost_widget.h index b2f6b62c0..a181f1148 100644 --- a/cockatrice/src/interface/widgets/cards/additional_info/mana_cost_widget.h +++ b/cockatrice/src/interface/widgets/cards/additional_info/mana_cost_widget.h @@ -7,9 +7,13 @@ #ifndef MANA_COST_WIDGET_H #define MANA_COST_WIDGET_H -#include +#include +#include #include #include +#include + +class QHBoxLayout; class ManaCostWidget : public QWidget { @@ -17,7 +21,7 @@ class ManaCostWidget : public QWidget public: explicit ManaCostWidget(QWidget *parent, CardInfoPtr card); - QStringList parseManaCost(const QString &manaString); + static QStringList parseManaCost(const QString &manaString); public slots: void resizeEvent(QResizeEvent *event) override; diff --git a/cockatrice/src/interface/widgets/cards/additional_info/mana_symbol_widget.cpp b/cockatrice/src/interface/widgets/cards/additional_info/mana_symbol_widget.cpp index 51247da7a..80dc11852 100644 --- a/cockatrice/src/interface/widgets/cards/additional_info/mana_symbol_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/additional_info/mana_symbol_widget.cpp @@ -1,15 +1,23 @@ #include "mana_symbol_widget.h" #include "../../../../client/settings/cache_settings.h" +#include "../../../pixel_map_generator.h" +#include +#include #include +#include +#include +#include #include +#include + +class QWidget; ManaSymbolWidget::ManaSymbolWidget(QWidget *parent, QString _symbol, bool _isActive, bool _mayBeToggled) - : QLabel(parent), symbol(_symbol), isActive(_isActive), mayBeToggled(_mayBeToggled) + : QLabel(parent), symbol(std::move(_symbol)), isActive(_isActive), mayBeToggled(_mayBeToggled) { - loadManaIcon(); - setPixmap(manaIcon.scaled(50, 50, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + setPixmap(ManaSymbolPixmapGenerator::generatePixmap(symbol, QSize(50, 50))); setMaximumWidth(50); // Initialize opacity effect @@ -64,16 +72,13 @@ void ManaSymbolWidget::mousePressEvent(QMouseEvent *event) void ManaSymbolWidget::resizeEvent(QResizeEvent *event) { QLabel::resizeEvent(event); - setPixmap(manaIcon.scaled(event->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation)); -} + const QSize newSize = event->size(); -void ManaSymbolWidget::loadManaIcon() -{ - QString filename = "theme:icons/mana/"; - - if (symbol == "W" || symbol == "U" || symbol == "B" || symbol == "R" || symbol == "G") { - filename += symbol; + // Skip the rescale when the size didn't actually change: layout passes resize these + // widgets repeatedly with identical sizes. + if (newSize.isEmpty() || pixmap().size() == newSize) { + return; } - manaIcon = QPixmap(filename); + setPixmap(ManaSymbolPixmapGenerator::generatePixmap(symbol, newSize)); } diff --git a/cockatrice/src/interface/widgets/cards/additional_info/mana_symbol_widget.h b/cockatrice/src/interface/widgets/cards/additional_info/mana_symbol_widget.h index 0f2d7acd1..8dad15785 100644 --- a/cockatrice/src/interface/widgets/cards/additional_info/mana_symbol_widget.h +++ b/cockatrice/src/interface/widgets/cards/additional_info/mana_symbol_widget.h @@ -7,8 +7,13 @@ #ifndef MANA_SYMBOL_WIDGET_H #define MANA_SYMBOL_WIDGET_H -#include +#include #include +#include +#include + +class QGraphicsOpacityEffect; +class QWidget; class ManaSymbolWidget : public QLabel { @@ -33,8 +38,6 @@ public: return symbol[0]; } - void loadManaIcon(); - public slots: void resizeEvent(QResizeEvent *event) override; void mousePressEvent(QMouseEvent *event) override; @@ -44,7 +47,6 @@ signals: private: QString symbol; - QPixmap manaIcon; bool isActive; bool mayBeToggled; QGraphicsOpacityEffect *opacityEffect; diff --git a/cockatrice/src/interface/widgets/cards/art_crop_attribution.cpp b/cockatrice/src/interface/widgets/cards/art_crop_attribution.cpp new file mode 100644 index 000000000..c4b347047 --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/art_crop_attribution.cpp @@ -0,0 +1,70 @@ +#include "art_crop_attribution.h" + +#include "libcockatrice/card/printing/printing_info.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +QString buildArtAttribution(const ExactCard &card) +{ + const QString artist = card.getPrinting().getArtist(); + if (artist.isEmpty()) { + return QString(); + } + return QObject::tr("Art: %1").arg(artist); +} + +QRectF paintArtAttribution(QPainter &painter, + const QRectF &rect, + const QString &attribution, + Qt::Alignment anchor, + qreal scale) +{ + if (attribution.isEmpty()) { + return QRectF(); + } + + painter.save(); + + QFont font = painter.font(); + font.setPointSizeF(qMax(6.0, font.pointSizeF() * scale)); + painter.setFont(font); + + const QFontMetrics fm(font); + const qreal maxTextWidth = rect.width() * 0.45; + const QString elided = fm.elidedText(attribution, Qt::ElideRight, qMax(qreal(80.0) * scale, maxTextWidth)); + + const qreal pad = 6.0 * scale; + QRectF captionRect(QPointF(0, 0), QSizeF(fm.horizontalAdvance(elided) + pad * 2.0, fm.height() + pad * 2.0)); + const qreal margin = 4.0 * scale; + if (anchor.testFlag(Qt::AlignLeft)) { + captionRect.moveLeft(rect.left() + margin); + } else { + captionRect.moveRight(rect.right() - margin); + } + if (anchor.testFlag(Qt::AlignTop)) { + captionRect.moveTop(rect.top() + margin); + } else { + captionRect.moveBottom(rect.bottom() - margin); + } + + painter.setPen(Qt::NoPen); + painter.setBrush(QColor(0, 0, 0, 120)); + painter.drawRoundedRect(captionRect, 4, 4); + + painter.setPen(QColor(255, 255, 255, 220)); + painter.drawText(captionRect, Qt::AlignCenter, elided); + + painter.restore(); + + return captionRect; +} diff --git a/cockatrice/src/interface/widgets/cards/art_crop_attribution.h b/cockatrice/src/interface/widgets/cards/art_crop_attribution.h new file mode 100644 index 000000000..b4acbef26 --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/art_crop_attribution.h @@ -0,0 +1,42 @@ +#ifndef COCKATRICE_ART_CROP_ATTRIBUTION_H +#define COCKATRICE_ART_CROP_ATTRIBUTION_H + +#include +#include + +class ExactCard; +class QPainter; +class QRectF; +class QString; + +/** + * @brief Builds an attribution caption for a cropped card art display. + * + * When a card image's art region is shown cropped (an "art crop"), the artist + * should be credited in the same interface. Returns an empty string when the + * database has no artist data for the card. + * + * @param card The card whose art is being displayed. + * @return Caption such as "Art: John Avon", or empty. + */ +QString buildArtAttribution(const ExactCard &card); + +/** + * @brief Paints an attribution caption in a corner of a rect. + * + * Draws a subtle semi-transparent pill containing the caption, elided to fit. + * + * @param painter Painter to draw with. + * @param rect The area (e.g. the cropped art region) the caption belongs to. + * @param attribution Caption text (see buildArtAttribution()). + * @param anchor Corner of @p rect to pin the pill to (default bottom-right). + * @param scale Size multiplier for the pill (e.g. 0.8 for a smaller pill). + * @return The rect the pill was drawn in, or an empty rect if @p attribution is empty. + */ +QRectF paintArtAttribution(QPainter &painter, + const QRectF &rect, + const QString &attribution, + Qt::Alignment anchor = Qt::AlignRight | Qt::AlignBottom, + qreal scale = 1.0); + +#endif // COCKATRICE_ART_CROP_ATTRIBUTION_H diff --git a/cockatrice/src/interface/widgets/cards/card_art_utils.cpp b/cockatrice/src/interface/widgets/cards/card_art_utils.cpp new file mode 100644 index 000000000..ebf79282e --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/card_art_utils.cpp @@ -0,0 +1,21 @@ +#include "card_art_utils.h" + +#include "libcockatrice/card/card_info.h" + +#include +#include +#include + +namespace CardArtUtils +{ +QPixmap rotateSidewaysLayoutArt(const QPixmap &art, const ExactCard &card) +{ + if (!card.getInfo().getUiAttributes().landscapeOrientation) { + return art; + } + + QTransform transform; + transform.rotate(90); + return art.transformed(transform, Qt::SmoothTransformation); +} +} // namespace CardArtUtils \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/cards/card_art_utils.h b/cockatrice/src/interface/widgets/cards/card_art_utils.h new file mode 100644 index 000000000..5c331a12c --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/card_art_utils.h @@ -0,0 +1,25 @@ +#ifndef CARD_ART_UTILS_H +#define CARD_ART_UTILS_H + +#include + +class ExactCard; + +namespace CardArtUtils +{ +/** + * @brief Rotates a card's art upright when its layout shows sideways. + * + * Sideways-layout cards (planes, sieges/battles, split cards) store their + * landscape artwork rotated 90° inside a portrait frame. Art-crop displays, + * playmat art, and the card-info picture must show such art upright before + * sampling or painting. Portrait cards are returned unchanged. + * + * @param art The card pixmap to orient. + * @param card The card describing the art orientation. + * @return @p art rotated 90° clockwise for sideways-layout cards, else @p art. + */ +QPixmap rotateSidewaysLayoutArt(const QPixmap &art, const ExactCard &card); +} // namespace CardArtUtils + +#endif // CARD_ART_UTILS_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp index 3f36e559c..b15ac5d23 100644 --- a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.cpp @@ -1,11 +1,33 @@ #include "card_group_display_widget.h" +#include "../../../../client/settings/cache_settings.h" +#include "../../general/display/banner_widget.h" +#include "../card_info_picture_widget.h" #include "../card_info_picture_with_text_overlay_widget.h" +#include "../card_size_widget.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/exact_card.h" -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include +#include +#include +#include CardGroupDisplayWidget::CardGroupDisplayWidget(QWidget *parent, DeckListModel *_deckListModel, @@ -30,6 +52,7 @@ CardGroupDisplayWidget::CardGroupDisplayWidget(QWidget *parent, layout->addWidget(banner); CardGroupDisplayWidget::updateCardDisplays(); + updateCardCount(); connect(deckListModel, &QAbstractItemModel::rowsInserted, this, &CardGroupDisplayWidget::onCardAddition); if (selectionModel) { @@ -38,6 +61,13 @@ CardGroupDisplayWidget::CardGroupDisplayWidget(QWidget *parent, } connect(deckListModel, &QAbstractItemModel::rowsRemoved, this, &CardGroupDisplayWidget::onCardRemoval); connect(deckListModel, &QAbstractItemModel::dataChanged, this, &CardGroupDisplayWidget::onDataChanged); + connect(deckListModel, &QAbstractItemModel::rowsInserted, this, &CardGroupDisplayWidget::updateCardCount); + connect(deckListModel, &QAbstractItemModel::rowsRemoved, this, &CardGroupDisplayWidget::updateCardCount); + connect(deckListModel, &QAbstractItemModel::dataChanged, this, &CardGroupDisplayWidget::updateCardCount); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::visualDeckEditorShowCardCountsChanged, + this, &CardGroupDisplayWidget::updateCardCount); + + cardSizeWidget->enableCtrlScrollResize(this); } // Just here so it can get overwritten in subclasses. @@ -174,16 +204,18 @@ void CardGroupDisplayWidget::updateCardDisplays() QModelIndex sourceIndex = proxy.mapToSource(proxyIndex); // 4. persist the source index - QPersistentModelIndex persistent(sourceIndex); + addCardWidgets(QPersistentModelIndex(sourceIndex)); + } +} - // Get the card amount - int cardAmount = - sourceIndex.sibling(sourceIndex.row(), DeckListModelColumns::CARD_AMOUNT).data(Qt::EditRole).toInt(); +void CardGroupDisplayWidget::addCardWidgets(const QPersistentModelIndex &persistent) +{ + // Get the card amount + int cardAmount = persistent.sibling(persistent.row(), DeckListModelColumns::CARD_AMOUNT).data(Qt::EditRole).toInt(); - // Create multiple widgets for the card count - for (int copy = 0; copy < cardAmount; ++copy) { - addToLayout(constructWidgetForIndex(persistent)); - } + // Create multiple widgets for the card count + for (int copy = 0; copy < cardAmount; ++copy) { + addToLayout(constructWidgetForIndex(persistent)); } } @@ -346,4 +378,22 @@ void CardGroupDisplayWidget::onActiveSortCriteriaChanged(QStringList _activeSort clearAllDisplayWidgets(); updateCardDisplays(); -} \ No newline at end of file +} + +void CardGroupDisplayWidget::updateCardCount() +{ + if (!banner || !deckListModel || !trackedIndex.isValid()) { + return; + } + + QString text = cardGroupCategory; + if (SettingsCache::instance().cardsDisplay().getVisualDeckEditorShowCardCounts()) { + int total = 0; + for (int i = 0; i < deckListModel->rowCount(trackedIndex); ++i) { + total += + deckListModel->index(i, DeckListModelColumns::CARD_AMOUNT, trackedIndex).data(Qt::EditRole).toInt(); + } + text += QStringLiteral(" (%1)").arg(total); + } + banner->setText(text); +} diff --git a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.h b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.h index 2308ccf8d..f4a555766 100644 --- a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/card_group_display_widget.h @@ -7,15 +7,24 @@ #ifndef CARD_GROUP_DISPLAY_WIDGET_H #define CARD_GROUP_DISPLAY_WIDGET_H -#include "../../general/display/banner_widget.h" -#include "../card_info_picture_with_text_overlay_widget.h" -#include "../card_size_widget.h" - -#include -#include +#include +#include +#include +#include +#include +#include #include +#include #include -#include +#include + +class BannerWidget; +class CardSizeWidget; +class DeckListModel; +class ExactCard; +class QItemSelection; +class QItemSelectionModel; +class QModelIndex; class CardGroupDisplayWidget : public QWidget { @@ -35,6 +44,7 @@ public: void onSelectionChanged(const QItemSelection &selected, const QItemSelection &deselected); void refreshSelectionForIndex(const QPersistentModelIndex &persistent); void clearAllDisplayWidgets(); + void addCardWidgets(const QPersistentModelIndex &persistent); DeckListModel *deckListModel; QItemSelectionModel *selectionModel; @@ -54,6 +64,7 @@ public slots: virtual void onCardRemoval(const QModelIndex &parent, int first, int last); void onDataChanged(const QModelIndex &topLeft, const QModelIndex &bottomRight, const QVector &roles); void onActiveSortCriteriaChanged(QStringList activeSortCriteria); + void updateCardCount(); void resizeEvent(QResizeEvent *event) override; signals: diff --git a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/flat_card_group_display_widget.cpp b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/flat_card_group_display_widget.cpp index a192d13d8..3256e0ca8 100644 --- a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/flat_card_group_display_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/flat_card_group_display_widget.cpp @@ -1,10 +1,20 @@ #include "flat_card_group_display_widget.h" -#include -#include +#include "../../general/display/banner_widget.h" +#include "../../general/layout_containers/flow_widget.h" + +#include +#include +#include +#include +#include #include +#include #include +class CardSizeWidget; +class QItemSelectionModel; + FlatCardGroupDisplayWidget::FlatCardGroupDisplayWidget(QWidget *parent, DeckListModel *_deckListModel, QItemSelectionModel *_selectionModel, diff --git a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/flat_card_group_display_widget.h b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/flat_card_group_display_widget.h index 0772c1ab4..d13dcddb5 100644 --- a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/flat_card_group_display_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/flat_card_group_display_widget.h @@ -10,6 +10,16 @@ #include "../../general/layout_containers/flow_widget.h" #include "card_group_display_widget.h" +#include +#include +#include +#include + +class CardSizeWidget; +class DeckListModel; +class QItemSelectionModel; +class QWidget; + class FlatCardGroupDisplayWidget : public CardGroupDisplayWidget { Q_OBJECT diff --git a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/overlapped_card_group_display_widget.cpp b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/overlapped_card_group_display_widget.cpp index dca8ee2a5..6e923cdbe 100644 --- a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/overlapped_card_group_display_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/overlapped_card_group_display_widget.cpp @@ -1,8 +1,20 @@ #include "overlapped_card_group_display_widget.h" +#include "../../general/display/banner_widget.h" +#include "../../general/layout_containers/overlap_widget.h" +#include "../card_size_widget.h" + +#include +#include +#include #include -#include +#include +#include +#include #include +#include + +class QItemSelectionModel; OverlappedCardGroupDisplayWidget::OverlappedCardGroupDisplayWidget(QWidget *parent, DeckListModel *_deckListModel, diff --git a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/overlapped_card_group_display_widget.h b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/overlapped_card_group_display_widget.h index 5b4b80d6b..76be3069f 100644 --- a/cockatrice/src/interface/widgets/cards/card_group_display_widgets/overlapped_card_group_display_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_group_display_widgets/overlapped_card_group_display_widget.h @@ -10,6 +10,16 @@ #include "../../general/layout_containers/overlap_widget.h" #include "card_group_display_widget.h" +#include +#include +#include +#include + +class CardSizeWidget; +class DeckListModel; +class QItemSelectionModel; +class QWidget; + class OverlappedCardGroupDisplayWidget : public CardGroupDisplayWidget { Q_OBJECT diff --git a/cockatrice/src/interface/widgets/cards/card_info_display_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_display_widget.cpp index 577dafe0a..7ec2ee38e 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_display_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_display_widget.cpp @@ -1,14 +1,25 @@ #include "card_info_display_widget.h" -#include "../../../game_graphics/board/card_item.h" +#include "../../../game_graphics/board/abstract_card_item.h" +#include "../../../game_graphics/card_dimensions.h" #include "card_info_picture_widget.h" #include "card_info_text_widget.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/utility/card_ref.h" -#include +#include +#include +#include #include +#include +#include #include #include +class QWidget; + CardInfoDisplayWidget::CardInfoDisplayWidget(const CardRef &cardRef, QWidget *parent, Qt::WindowFlags flags) : QFrame(parent, flags), aspectRatio(CardDimensions::HEIGHT_F / CardDimensions::WIDTH_F) { diff --git a/cockatrice/src/interface/widgets/cards/card_info_display_widget.h b/cockatrice/src/interface/widgets/cards/card_info_display_widget.h index f8f33f3d4..65965a63e 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_display_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_info_display_widget.h @@ -7,15 +7,17 @@ #ifndef CARDINFOWIDGET_H #define CARDINFOWIDGET_H -#include #include -#include +#include #include -#include +#include +#include class CardInfoPictureWidget; class CardInfoTextWidget; class AbstractCardItem; +class QWidget; +struct CardRef; class CardInfoDisplayWidget : public QFrame { diff --git a/cockatrice/src/interface/widgets/cards/card_info_frame_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_frame_widget.cpp index fcccf445a..0f66eb209 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_frame_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_frame_widget.cpp @@ -1,16 +1,27 @@ #include "card_info_frame_widget.h" #include "../../../client/settings/cache_settings.h" -#include "../../../game_graphics/board/card_item.h" -#include "card_info_display_widget.h" +#include "../../../game_graphics/board/abstract_card_item.h" #include "card_info_picture_widget.h" #include "card_info_text_widget.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/exact_card.h" +#include +#include +#include #include #include +#include +#include #include #include #include +#include +#include + +struct CardRef; CardInfoFrameWidget::CardInfoFrameWidget(QWidget *parent) : QTabWidget(parent), viewTransformationButton(nullptr), cardTextOnly(false) diff --git a/cockatrice/src/interface/widgets/cards/card_info_frame_widget.h b/cockatrice/src/interface/widgets/cards/card_info_frame_widget.h index e766fac7a..1f8143785 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_frame_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_info_frame_widget.h @@ -7,16 +7,19 @@ #ifndef CARDFRAME_H #define CARDFRAME_H -#include +#include #include #include -#include +#include class AbstractCardItem; class CardInfoPictureWidget; class CardInfoTextWidget; class QVBoxLayout; class QSplitter; +class QPushButton; +class QWidget; +struct CardRef; class CardInfoFrameWidget : public QTabWidget { diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_art_crop_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_picture_art_crop_widget.cpp index e5b0c4872..5ec4c8a25 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_art_crop_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_art_crop_widget.cpp @@ -1,6 +1,12 @@ #include "card_info_picture_art_crop_widget.h" #include "../../../interface/card_picture_loader/card_picture_loader.h" +#include "libcockatrice/card/printing/exact_card.h" + +#include +#include + +class QWidget; CardInfoPictureArtCropWidget::CardInfoPictureArtCropWidget(QWidget *parent) : CardInfoPictureWidget(parent, false, false) diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_art_crop_widget.h b/cockatrice/src/interface/widgets/cards/card_info_picture_art_crop_widget.h index ce22a1a44..540b0c2fa 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_art_crop_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_art_crop_widget.h @@ -9,6 +9,11 @@ #include "card_info_picture_widget.h" +#include +#include + +class QWidget; + class CardInfoPictureArtCropWidget : public CardInfoPictureWidget { Q_OBJECT diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_enlarged_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_picture_enlarged_widget.cpp index f442fe425..ba10d4a0f 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_enlarged_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_enlarged_widget.cpp @@ -2,10 +2,17 @@ #include "../../../client/settings/cache_settings.h" #include "../../../interface/card_picture_loader/card_picture_loader.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/printing/exact_card.h" #include +#include +#include +#include #include +#include #include +#include /** * @brief Constructs a CardPictureEnlargedWidget. @@ -52,12 +59,29 @@ void CardInfoPictureEnlargedWidget::loadPixmap(const QSize &size) * @param size The desired size for the pixmap. * * Sets the widget's pixmap to the card image and resizes the widget to match the specified size. Triggers a repaint. + * + * When the image is not yet cached, the pixmap is cleared (instead of showing a stale previous card) and the widget + * refreshes automatically once the card image finishes loading. */ void CardInfoPictureEnlargedWidget::setCardPixmap(const ExactCard &_card, const QSize size) { + if (card.getCardPtr()) { + disconnect(card.getCardPtr().data(), nullptr, this, nullptr); + } + card = _card; + + // Clear any previous card's art so we never paint a stale pixmap while the new image loads + enlargedPixmap = QPixmap(); loadPixmap(size); + if (card.getCardPtr()) { + connect(card.getCardPtr().data(), &CardInfo::pixmapUpdated, this, [this]() { + loadPixmap(this->size()); + update(); + }); + } + setFixedSize(size); // Set the widget size to the enlarged size update(); // Trigger a repaint diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_enlarged_widget.h b/cockatrice/src/interface/widgets/cards/card_info_picture_enlarged_widget.h index a158ac6cf..f5ead2a18 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_enlarged_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_enlarged_widget.h @@ -8,8 +8,12 @@ #ifndef CARD_PICTURE_ENLARGED_WIDGET_H #define CARD_PICTURE_ENLARGED_WIDGET_H +#include #include #include +#include + +class QSize; class CardInfoPictureEnlargedWidget final : public QWidget { diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp index 79ae087d7..3c1e1b77f 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp @@ -1,20 +1,47 @@ #include "card_info_picture_widget.h" #include "../../../client/settings/cache_settings.h" -#include "../../../game_graphics/board/card_item.h" #include "../../../interface/card_picture_loader/card_picture_loader.h" #include "../../../interface/widgets/tabs/tab_supervisor.h" #include "../../window_main.h" +#include "../tabs/abstract_tab_deck_editor.h" +#include "card_art_utils.h" +#include "card_info_picture_enlarged_widget.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/card/printing/printing_info.h" +#include "libcockatrice/deck_list/tree/inner_deck_list_node.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include +#include +#include +#include +#include #include +#include +#include +#include #include +#include #include +#include +#include #include #include #include -#include +#include +#include static constexpr qreal MTG_CARD_ASPECT_RATIO = 1.396; // static constexpr qreal YUGIOH_CARD_ASPECT_RATIO = 1.457; @@ -73,6 +100,8 @@ CardInfoPictureWidget::CardInfoPictureWidget(QWidget *parent, const bool _hoverT update(); }); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, + &CardInfoPictureWidget::updatePixmap); } /** @@ -193,12 +222,7 @@ void CardInfoPictureWidget::paintEvent(QPaintEvent *event) QPixmap transformedPixmap = resizedPixmap; // Default pixmap if (SettingsCache::instance().cardsDisplay().getAutoRotateSidewaysLayoutCards()) { - if (exactCard.getInfo().getUiAttributes().landscapeOrientation) { - // Rotate pixmap 90 degrees to the left - QTransform transform; - transform.rotate(90); - transformedPixmap = resizedPixmap.transformed(transform, Qt::SmoothTransformation); - } + transformedPixmap = CardArtUtils::rotateSidewaysLayoutArt(resizedPixmap, exactCard); } // Handle DPI scaling diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h index 4fe84ed0b..8d2d63736 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h @@ -7,15 +7,20 @@ #ifndef CARD_INFO_PICTURE_H #define CARD_INFO_PICTURE_H -#include "card_info_picture_enlarged_widget.h" - -#include +#include +#include +#include +#include #include #include +#include + +class CardInfoPictureEnlargedWidget; +class QPropertyAnimation; +class QTimer; inline Q_LOGGING_CATEGORY(CardInfoPictureWidgetLog, "card_info_picture_widget"); -class AbstractCardItem; class QMenu; class CardInfoPictureWidget : public QWidget diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp index c5cb59b3b..04fe9ab69 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.cpp @@ -1,8 +1,24 @@ #include "card_info_picture_with_text_overlay_widget.h" +#include +#include +#include #include +#include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include #include +#include + +class QWidget; /** * @brief Constructs a CardPictureWithTextOverlay widget. @@ -133,12 +149,14 @@ void CardInfoPictureWithTextOverlayWidget::paintEvent(QPaintEvent *event) path.addRoundedRect(glowRect, radius, radius); // Soft outer glow - QColor glowColor(0, 150, 255, 80); // subtle blu + QColor glowColor = palette().color(QPalette::Highlight); + glowColor.setAlpha(80); painter.setPen(QPen(glowColor, 6)); painter.drawPath(path); // Thin inner border for crispness - QColor borderColor(0, 150, 255, 200); + QColor borderColor = palette().color(QPalette::Highlight); + borderColor.setAlpha(200); painter.setPen(QPen(borderColor, 2)); painter.drawRoundedRect(pixmapRect, radius, radius); diff --git a/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.h b/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.h index ba978498d..6c547dc13 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_with_text_overlay_widget.h @@ -11,8 +11,14 @@ #include "card_info_picture_widget.h" #include -#include +#include #include +#include +#include + +class QPainter; +class QRect; +class QWidget; class CardInfoPictureWithTextOverlayWidget : public CardInfoPictureWidget { diff --git a/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp index c6af5320b..fa6cde311 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp @@ -1,16 +1,28 @@ #include "card_info_text_widget.h" -#include "../../../game_graphics/board/card_item.h" +#include "../../../client/settings/cache_settings.h" +#include "../../card_localization.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/printing/printing_info.h" +#include "libcockatrice/card/set/card_set.h" +#include "libcockatrice/settings/cards_display_settings.h" #include #include +#include #include #include +#include +#include +#include #include +#include #include #include +#include +#include -CardInfoTextWidget::CardInfoTextWidget(QWidget *parent) : QFrame(parent), info(nullptr) +CardInfoTextWidget::CardInfoTextWidget(QWidget *parent) : QFrame(parent) { propsLabel = new QLabel; propsLabel->setOpenExternalLinks(false); @@ -39,6 +51,12 @@ CardInfoTextWidget::CardInfoTextWidget(QWidget *parent) : QFrame(parent), info(n grid->setRowStretch(1, 1); retranslateUi(); + + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, [this] { + if (currentCard) { + setCard(currentCard); + } + }); } void CardInfoTextWidget::setTexts(const QString &propsText, const QString &textText) @@ -60,7 +78,7 @@ void CardInfoTextWidget::setCard(const ExactCard &exactCard) QString text = ""; text += QString("") - .arg(tr("Name:"), card->getName().toHtmlEscaped()); + .arg(tr("Name:"), CardLocalization::displayName(card).toHtmlEscaped()); if (!exactCard.getPrinting().isEmpty()) { QString setShort = exactCard.getPrinting().getSet()->getShortName().toHtmlEscaped(); @@ -94,7 +112,8 @@ void CardInfoTextWidget::setCard(const ExactCard &exactCard) } text += "
%1%2
"; - setTexts(text, card->getText()); + setTexts(text, CardLocalization::displayText(card)); + currentCard = exactCard; } void CardInfoTextWidget::setInvalidCardName(const QString &cardName) diff --git a/cockatrice/src/interface/widgets/cards/card_info_text_widget.h b/cockatrice/src/interface/widgets/cards/card_info_text_widget.h index a9c29da37..8223093fe 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_text_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_info_text_widget.h @@ -10,10 +10,13 @@ #include "libcockatrice/card/printing/exact_card.h" #include -#include +#include +#include + class QLabel; class QScrollArea; class QTextEdit; +class QWidget; class CardInfoTextWidget : public QFrame { @@ -23,7 +26,7 @@ private: QLabel *propsLabel; QScrollArea *propsScroll; QTextEdit *textLabel; - CardInfoPtr info; + ExactCard currentCard; ///< Last card set, re-rendered when the card language changes. void setTexts(const QString &propsText, const QString &textText); public: diff --git a/cockatrice/src/interface/widgets/cards/card_size_widget.cpp b/cockatrice/src/interface/widgets/cards/card_size_widget.cpp index 14e45743d..ab1840591 100644 --- a/cockatrice/src/interface/widgets/cards/card_size_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_size_widget.cpp @@ -1,7 +1,19 @@ #include "card_size_widget.h" -#include "../printing_selector/printing_selector.h" -#include "../visual_deck_storage/visual_deck_storage_widget.h" +#include "../general/layout_containers/flow_widget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include /** * @class CardSizeWidget @@ -58,3 +70,39 @@ QSlider *CardSizeWidget::getSlider() const { return cardSizeSlider; } + +void CardSizeWidget::enableCtrlScrollResize(QWidget *host) +{ + host->installEventFilter(this); + + const auto scrollAreas = host->findChildren(); + for (QScrollArea *scrollArea : scrollAreas) { + if (QWidget *content = scrollArea->widget()) { + content->installEventFilter(this); + } + } +} + +bool CardSizeWidget::eventFilter(QObject *watched, QEvent *event) +{ + if (event->type() == QEvent::Wheel && adjustSliderForWheel(static_cast(event))) { + return true; + } + return QWidget::eventFilter(watched, event); +} + +bool CardSizeWidget::adjustSliderForWheel(QWheelEvent *event) +{ + if (!(event->modifiers() & Qt::ControlModifier)) { + return false; + } + + const int angleDelta = event->angleDelta().y(); + if (angleDelta == 0) { + return false; + } + + const int wheelSteps = angleDelta > 0 ? qMax(1, angleDelta / 120) : qMin(-1, angleDelta / 120); + cardSizeSlider->setValue(cardSizeSlider->value() + wheelSteps * CARD_SIZE_WHEEL_STEP); + return true; +} diff --git a/cockatrice/src/interface/widgets/cards/card_size_widget.h b/cockatrice/src/interface/widgets/cards/card_size_widget.h index 9f4c165fd..7de97a5e9 100644 --- a/cockatrice/src/interface/widgets/cards/card_size_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_size_widget.h @@ -9,13 +9,15 @@ #ifndef CARD_SIZE_WIDGET_H #define CARD_SIZE_WIDGET_H -#include "../general/layout_containers/flow_widget.h" - -#include -#include -#include #include #include +#include + +class QWheelEvent; +class FlowWidget; +class QHBoxLayout; +class QLabel; +class QSlider; class CardSizeWidget : public QWidget { @@ -25,6 +27,18 @@ public: explicit CardSizeWidget(QWidget *parent, FlowWidget *flowWidget = nullptr, int defaultValue = 100); [[nodiscard]] QSlider *getSlider() const; + /** + * @brief Resizes the cards when the user Ctrl + scrolls over @p host or any of its descendants. + * + * Installs this widget as an event filter on the given host widget. If the host contains a + * scroll area the filter is also installed on the scroll area's content widget, so the resize + * intercepts the wheel event before the scroll area would scroll the view. + */ + void enableCtrlScrollResize(QWidget *host); + +protected: + bool eventFilter(QObject *watched, QEvent *event) override; + private slots: void updateCardSizeSetting(int newValue); @@ -44,6 +58,10 @@ private: QSlider *cardSizeSlider; QTimer debounceTimer; // Debounce timer int pendingValue; // Stores the latest slider value + + bool adjustSliderForWheel(QWheelEvent *event); + + static constexpr int CARD_SIZE_WHEEL_STEP = 10; ///< Slider step applied per Ctrl + scroll notch. }; #endif // CARD_SIZE_WIDGET_H diff --git a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp index eaf3a67b0..911e35285 100644 --- a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp @@ -1,11 +1,38 @@ #include "deck_card_zone_display_widget.h" +#include "../../../client/settings/cache_settings.h" +#include "../general/display/banner_widget.h" +#include "../visual_deck_editor/visual_deck_editor_widget.h" +#include "card_group_display_widgets/card_group_display_widget.h" #include "card_group_display_widgets/flat_card_group_display_widget.h" #include "card_group_display_widgets/overlapped_card_group_display_widget.h" +#include "libcockatrice/card/card_info.h" #include "libcockatrice/card/database/card_database_manager.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/deck_list/tree/deck_list_card_node.h" +#include "libcockatrice/settings/cards_display_settings.h" -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include +#include + +class CardSizeWidget; +class QMouseEvent; DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent, DeckListModel *_deckListModel, @@ -38,6 +65,7 @@ DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent, banner->setBuddy(cardGroupContainer); displayCards(); + updateZoneCardCount(); connect(deckListModel, &QAbstractItemModel::rowsInserted, this, &DeckCardZoneDisplayWidget::onCategoryAddition); if (selectionModel) { @@ -45,17 +73,17 @@ DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent, &DeckCardZoneDisplayWidget::onSelectionChanged); } connect(deckListModel, &QAbstractItemModel::rowsRemoved, this, &DeckCardZoneDisplayWidget::onCategoryRemoval); + connect(deckListModel, &QAbstractItemModel::rowsInserted, this, &DeckCardZoneDisplayWidget::updateZoneCardCount); + connect(deckListModel, &QAbstractItemModel::rowsRemoved, this, &DeckCardZoneDisplayWidget::updateZoneCardCount); + connect(deckListModel, &QAbstractItemModel::dataChanged, this, &DeckCardZoneDisplayWidget::updateZoneCardCount); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::visualDeckEditorShowCardCountsChanged, + this, &DeckCardZoneDisplayWidget::updateZoneCardCount); } // ===================================================================================================================== // User Interaction // ===================================================================================================================== -void DeckCardZoneDisplayWidget::onClick(QMouseEvent *event, const ExactCard &card) -{ - emit cardClicked(event, card, zoneName); -} - void DeckCardZoneDisplayWidget::onHover(const ExactCard &card) { emit cardHovered(card); @@ -95,12 +123,18 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex } auto categoryName = index.sibling(index.row(), DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); + // Cards in a custom zone belong to that zone, not the board zone, so that + // increment/decrement/swap actions target the custom zone. + const bool isCustomZone = index.data(DeckRoles::IsCustomZoneRole).toBool(); + const QString effectiveZoneName = isCustomZone ? categoryName : zoneName; + const auto routeCardClick = [this, effectiveZoneName](QMouseEvent *event, const ExactCard &card) { + emit cardClicked(event, card, effectiveZoneName); + }; if (displayType == DisplayType::Overlap) { auto *displayWidget = new OverlappedCardGroupDisplayWidget( - cardGroupContainer, deckListModel, selectionModel, index, zoneName, categoryName, activeGroupCriteria, - activeSortCriteria, subBannerOpacity, cardSizeWidget); - connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardClicked, this, - &DeckCardZoneDisplayWidget::onClick); + cardGroupContainer, deckListModel, selectionModel, index, effectiveZoneName, categoryName, + activeGroupCriteria, activeSortCriteria, subBannerOpacity, cardSizeWidget); + connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardClicked, this, routeCardClick); connect(displayWidget, &OverlappedCardGroupDisplayWidget::cardHovered, this, &DeckCardZoneDisplayWidget::onHover); connect(displayWidget, &CardGroupDisplayWidget::cleanupRequested, this, @@ -111,9 +145,9 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex indexToWidgetMap.insert(index, displayWidget); } else if (displayType == DisplayType::Flat) { auto *displayWidget = new FlatCardGroupDisplayWidget(cardGroupContainer, deckListModel, selectionModel, index, - zoneName, categoryName, activeGroupCriteria, + effectiveZoneName, categoryName, activeGroupCriteria, activeSortCriteria, subBannerOpacity, cardSizeWidget); - connect(displayWidget, &FlatCardGroupDisplayWidget::cardClicked, this, &DeckCardZoneDisplayWidget::onClick); + connect(displayWidget, &FlatCardGroupDisplayWidget::cardClicked, this, routeCardClick); connect(displayWidget, &FlatCardGroupDisplayWidget::cardHovered, this, &DeckCardZoneDisplayWidget::onHover); connect(displayWidget, &CardGroupDisplayWidget::cleanupRequested, this, &DeckCardZoneDisplayWidget::cleanupInvalidCardGroup); @@ -126,24 +160,18 @@ void DeckCardZoneDisplayWidget::constructAppropriateWidget(QPersistentModelIndex void DeckCardZoneDisplayWidget::displayCards() { - QSortFilterProxyModel proxy; - proxy.setSourceModel(deckListModel); - proxy.setSortRole(Qt::EditRole); - proxy.sort(DeckListModelColumns::CARD_NAME, Qt::AscendingOrder); + if (!trackedIndex.isValid()) { + return; + } - // 1. trackedIndex is a source index → map it to proxy space - QModelIndex proxyParent = proxy.mapFromSource(trackedIndex); - - // 2. iterate children under the proxy parent - for (int i = 0; i < proxy.rowCount(proxyParent); ++i) { - QModelIndex proxyIndex = proxy.index(i, 0, proxyParent); - - // 3. map back to source - QModelIndex sourceIndex = proxy.mapToSource(proxyIndex); - - // 4. persist the source index - QPersistentModelIndex persistent(sourceIndex); + // Iterate the direct children of the tracked zone, keeping the tree view's row + // order (criteria groups first, then custom zones, both in the model's sort order). + QList rows; + for (int i = 0; i < deckListModel->rowCount(trackedIndex); ++i) { + rows.append(QPersistentModelIndex(deckListModel->index(i, 0, trackedIndex))); + } + for (const QPersistentModelIndex &persistent : rows) { constructAppropriateWidget(persistent); } } @@ -249,3 +277,21 @@ QList DeckCardZoneDisplayWidget::getGroupCriteriaValueList() return groupCriteriaValues; } + +void DeckCardZoneDisplayWidget::updateZoneCardCount() +{ + if (!banner || !deckListModel) { + return; + } + + QString text = zoneName; + if (SettingsCache::instance().cardsDisplay().getVisualDeckEditorShowCardCounts()) { + int total = 0; + const auto cardNodes = deckListModel->getCardNodesForZone(zoneName); + for (const auto *node : cardNodes) { + total += node->getNumber(); + } + text += QStringLiteral(" (%1)").arg(total); + } + banner->setText(text); +} diff --git a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.h b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.h index b426fca30..b7dc3ed3e 100644 --- a/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.h +++ b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.h @@ -7,16 +7,27 @@ #ifndef DECK_CARD_ZONE_DISPLAY_WIDGET_H #define DECK_CARD_ZONE_DISPLAY_WIDGET_H -#include "../general/display/banner_widget.h" -#include "../general/layout_containers/overlap_widget.h" #include "../visual_deck_editor/visual_deck_editor_widget.h" -#include "card_group_display_widgets/card_group_display_widget.h" -#include "card_info_picture_with_text_overlay_widget.h" -#include "card_size_widget.h" -#include +#include +#include +#include +#include +#include +#include #include -#include +#include + +class BannerWidget; +class CardGroupDisplayWidget; +class CardSizeWidget; +class DeckListModel; +class ExactCard; +class OverlapWidget; +class QItemSelection; +class QItemSelectionModel; +class QModelIndex; +class QVBoxLayout; class DeckCardZoneDisplayWidget : public QWidget { @@ -42,7 +53,6 @@ public: void addCardsToOverlapWidget(); public slots: - void onClick(QMouseEvent *event, const ExactCard &card); void onHover(const ExactCard &card); void cleanupInvalidCardGroup(CardGroupDisplayWidget *displayWidget); void constructAppropriateWidget(QPersistentModelIndex index); @@ -53,6 +63,7 @@ public slots: QList getGroupCriteriaValueList(); void onCategoryAddition(const QModelIndex &parent, int first, int last); void onCategoryRemoval(const QModelIndex &parent, int first, int last); + void updateZoneCardCount(); signals: void cardClicked(QMouseEvent *event, const ExactCard &card, const QString &zoneName); diff --git a/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.cpp b/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.cpp index 1614836fc..186c55755 100644 --- a/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.cpp @@ -1,15 +1,15 @@ #include "deck_preview_card_picture_widget.h" #include "../../../client/settings/cache_settings.h" +#include "card_info_picture_widget.h" #include -#include -#include #include -#include -#include +#include #include +class QWidget; + /** * @brief Constructs a CardPictureWithTextOverlay widget. * @param parent The parent widget. @@ -27,18 +27,23 @@ DeckPreviewCardPictureWidget::DeckPreviewCardPictureWidget(QWidget *parent, const QColor &textColor, const QColor &outlineColor, const int fontSize, - const Qt::Alignment alignment) + const Qt::Alignment alignment, + const bool _emitClickImmediately) : CardInfoPictureWithTextOverlayWidget(parent, hoverToZoomEnabled, raiseOnEnter, textColor, outlineColor, fontSize, - alignment) + alignment), + emitClickImmediately(_emitClickImmediately) { singleClickTimer = new QTimer(this); singleClickTimer->setSingleShot(true); - connect(singleClickTimer, &QTimer::timeout, this, [this]() { emit imageClicked(lastMouseEvent, this); }); + connect(singleClickTimer, &QTimer::timeout, this, [this]() { + emit imageClicked(lastMouseEvent, this); + emit imageSingleClicked(); + }); connect(&SettingsCache::instance().visualDeckStorage(), &VisualDeckStorageSettings::visualDeckStorageSelectionAnimationChanged, this, &CardInfoPictureWidget::setRaiseOnEnterEnabled); @@ -47,8 +52,13 @@ DeckPreviewCardPictureWidget::DeckPreviewCardPictureWidget(QWidget *parent, void DeckPreviewCardPictureWidget::mousePressEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) { - lastMouseEvent = event; - singleClickTimer->start(QApplication::doubleClickInterval()); + if (emitClickImmediately) { + emit imageClicked(event, this); + emit imageSingleClicked(); + } else { + lastMouseEvent = event; + singleClickTimer->start(QApplication::doubleClickInterval()); + } } else { emit imageClicked(event, this); event->accept(); @@ -58,7 +68,14 @@ void DeckPreviewCardPictureWidget::mousePressEvent(QMouseEvent *event) void DeckPreviewCardPictureWidget::mouseDoubleClickEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) { - singleClickTimer->stop(); // Prevent single-click logic - emit imageDoubleClicked(lastMouseEvent, this); + if (emitClickImmediately) { + // Do not report a second single click for the second press of the + // double-click; the consumer maps the double-click to select+open. + lastMouseEvent = event; + emit imageDoubleClicked(event, this); + } else { + singleClickTimer->stop(); // Prevent single-click logic + emit imageDoubleClicked(lastMouseEvent, this); + } } } diff --git a/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.h b/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.h index 154e938aa..9643c7364 100644 --- a/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.h +++ b/cockatrice/src/interface/widgets/cards/deck_preview_card_picture_widget.h @@ -11,30 +11,50 @@ #include "card_info_picture_with_text_overlay_widget.h" #include -#include -#include -#include +#include +#include +#include + +class QTimer; +class QWidget; class DeckPreviewCardPictureWidget final : public CardInfoPictureWithTextOverlayWidget { Q_OBJECT public: + /** + * @brief Constructs a DeckPreviewCardPictureWidget. + * @param parent The parent widget. + * @param hoverToZoomEnabled If this widget will spawn a larger widget when hovered over. + * @param raiseOnEnter If the widget raises its border when the mouse enters. + * @param textColor The color of the overlay text. + * @param outlineColor The color of the outline around the text. + * @param fontSize The font size of the overlay text. + * @param alignment The alignment of the text within the overlay. + * @param emitClickImmediately If true, a left click is reported immediately on click + * instead of after the double-click interval. Use this for selection surfaces + * where reacting to a double-click (select-and-open) would needlessly delay the + * single-click feedback. The double-click signal is still emitted. + */ explicit DeckPreviewCardPictureWidget(QWidget *parent, bool hoverToZoomEnabled = false, bool raiseOnEnter = false, const QColor &textColor = Qt::white, const QColor &outlineColor = Qt::black, int fontSize = 12, - Qt::Alignment alignment = Qt::AlignCenter); + Qt::Alignment alignment = Qt::AlignCenter, + bool _emitClickImmediately = false); signals: void imageClicked(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); + void imageSingleClicked(); void imageDoubleClicked(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); private: QTimer *singleClickTimer; QMouseEvent *lastMouseEvent = nullptr; // Store the last mouse event + bool emitClickImmediately; protected: void mousePressEvent(QMouseEvent *event) override; 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 089abc5c8..730043d95 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,8 +1,16 @@ #include "abstract_analytics_panel_widget.h" +#include "../../pixel_map_generator.h" +#include "../general/display/banner_widget.h" #include "deck_list_statistics_analyzer.h" +#include +#include #include +#include +#include +#include +#include AbstractAnalyticsPanelWidget::AbstractAnalyticsPanelWidget(QWidget *parent, DeckListStatisticsAnalyzer *analyzer) : QWidget(parent), analyzer(analyzer) @@ -20,7 +28,7 @@ AbstractAnalyticsPanelWidget::AbstractAnalyticsPanelWidget(QWidget *parent, Deck // config button configureButton = new QPushButton(this); - configureButton->setIcon(QPixmap("theme:icons/cogwheel")); + configureButton->setIcon(themePixmap(QStringLiteral("icons/cogwheel"))); configureButton->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); connect(configureButton, &QPushButton::clicked, this, &AbstractAnalyticsPanelWidget::applyConfigFromDialog); bannerAndSettingsLayout->addWidget(configureButton, 0); diff --git a/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.h b/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.h index 23374a9e1..c1afb78d0 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.h +++ b/cockatrice/src/interface/widgets/deck_analytics/abstract_analytics_panel_widget.h @@ -3,12 +3,16 @@ #include "../general/display/banner_widget.h" -#include #include -#include +#include #include +#include class DeckListStatisticsAnalyzer; +class QDialog; +class QHBoxLayout; +class QPushButton; +class QVBoxLayout; class AbstractAnalyticsPanelWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/deck_analytics/add_analytics_panel_dialog.cpp b/cockatrice/src/interface/widgets/deck_analytics/add_analytics_panel_dialog.cpp index c83e6d982..f1ed5767c 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/add_analytics_panel_dialog.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/add_analytics_panel_dialog.cpp @@ -3,8 +3,11 @@ #include "analytics_panel_widget_factory.h" #include +#include #include +class QWidget; + AddAnalyticsPanelDialog::AddAnalyticsPanelDialog(QWidget *parent) : QDialog(parent) { setWindowTitle(tr("Add Analytics Panel")); diff --git a/cockatrice/src/interface/widgets/deck_analytics/add_analytics_panel_dialog.h b/cockatrice/src/interface/widgets/deck_analytics/add_analytics_panel_dialog.h index aa44734c2..f70c1d8f5 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/add_analytics_panel_dialog.h +++ b/cockatrice/src/interface/widgets/deck_analytics/add_analytics_panel_dialog.h @@ -2,12 +2,15 @@ #ifndef COCKATRICE_ADD_ANALYTICS_PANEL_DIALOG_H #define COCKATRICE_ADD_ANALYTICS_PANEL_DIALOG_H -#include "analytics_panel_widget_factory.h" - #include #include -#include -#include +#include +#include +#include + +class QDialogButtonBox; +class QVBoxLayout; +class QWidget; class AddAnalyticsPanelDialog : public QDialog { diff --git a/cockatrice/src/interface/widgets/deck_analytics/analytics_panel_widget_factory.cpp b/cockatrice/src/interface/widgets/deck_analytics/analytics_panel_widget_factory.cpp index e807e9d47..79dda527d 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analytics_panel_widget_factory.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/analytics_panel_widget_factory.cpp @@ -2,6 +2,8 @@ #include "abstract_analytics_panel_widget.h" +#include + AnalyticsPanelWidgetFactory &AnalyticsPanelWidgetFactory::instance() { static AnalyticsPanelWidgetFactory f; diff --git a/cockatrice/src/interface/widgets/deck_analytics/analytics_panel_widget_factory.h b/cockatrice/src/interface/widgets/deck_analytics/analytics_panel_widget_factory.h index 6c5856d70..598498980 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analytics_panel_widget_factory.h +++ b/cockatrice/src/interface/widgets/deck_analytics/analytics_panel_widget_factory.h @@ -1,9 +1,9 @@ #ifndef COCKATRICE_DECK_ANALYTICS_WIDGET_FACTORY_H #define COCKATRICE_DECK_ANALYTICS_WIDGET_FACTORY_H +#include #include #include -#include #include #include diff --git a/cockatrice/src/interface/widgets/deck_analytics/analytics_panel_widget_registrar.h b/cockatrice/src/interface/widgets/deck_analytics/analytics_panel_widget_registrar.h index 70d6df94f..99a6daf5d 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analytics_panel_widget_registrar.h +++ b/cockatrice/src/interface/widgets/deck_analytics/analytics_panel_widget_registrar.h @@ -3,6 +3,8 @@ #include "analytics_panel_widget_factory.h" +#include + class AnalyticsPanelWidgetRegistrar { public: diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/draw_probability/draw_probability_config.cpp b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/draw_probability/draw_probability_config.cpp index 51129a4b4..1c61116e3 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/draw_probability/draw_probability_config.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/draw_probability/draw_probability_config.cpp @@ -1,5 +1,8 @@ #include "draw_probability_config.h" +#include +#include + QJsonObject DrawProbabilityConfig::toJson() const { QJsonObject o; diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/draw_probability/draw_probability_config_dialog.cpp b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/draw_probability/draw_probability_config_dialog.cpp index 71f88c0fc..28fd4c841 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/draw_probability/draw_probability_config_dialog.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/draw_probability/draw_probability_config_dialog.cpp @@ -5,6 +5,10 @@ #include #include #include +#include +#include + +class QWidget; DrawProbabilityConfigDialog::DrawProbabilityConfigDialog(QWidget *parent) : QDialog(parent) { diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/draw_probability/draw_probability_config_dialog.h b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/draw_probability/draw_probability_config_dialog.h index 44b1f0eec..3553db75d 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/draw_probability/draw_probability_config_dialog.h +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/draw_probability/draw_probability_config_dialog.h @@ -3,11 +3,13 @@ #include "draw_probability_config.h" #include -#include +#include class QComboBox; class QSpinBox; class QLabel; +class QFormLayout; +class QWidget; class DrawProbabilityConfigDialog : public QDialog { diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/draw_probability/draw_probability_widget.cpp b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/draw_probability/draw_probability_widget.cpp index 0107294c7..90d9f58ef 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/draw_probability/draw_probability_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/draw_probability/draw_probability_widget.cpp @@ -1,21 +1,40 @@ #include "draw_probability_widget.h" +#include "../../../general/display/banner_widget.h" +#include "../../../general/layout_containers/flow_widget.h" +#include "../../abstract_analytics_panel_widget.h" +#include "../../deck_list_statistics_analyzer.h" +#include "deck_list_model.h" #include "draw_probability_config_dialog.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/deck_list/tree/deck_list_card_node.h" +#include +#include #include #include -#include -#include -#include #include +#include #include +#include #include +#include +#include +#include #include +#include +#include +#include #include -#include +#include +#include +#include #include #include #include +#include + +class QWidget; DrawProbabilityWidget::DrawProbabilityWidget(QWidget *parent, DeckListStatisticsAnalyzer *analyzer) : AbstractAnalyticsPanelWidget(parent, analyzer) diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/draw_probability/draw_probability_widget.h b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/draw_probability/draw_probability_widget.h index 9f7b971b1..7c7bdb50a 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/draw_probability/draw_probability_widget.h +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/draw_probability/draw_probability_widget.h @@ -1,16 +1,19 @@ #ifndef COCKATRICE_DRAW_PROBABILITY_WIDGET_H #define COCKATRICE_DRAW_PROBABILITY_WIDGET_H -#include "../../../../layouts/flow_layout.h" -#include "../../../general/layout_containers/flow_widget.h" #include "../../abstract_analytics_panel_widget.h" -#include "../../deck_list_statistics_analyzer.h" #include "draw_probability_config.h" -#include -#include -#include -#include +#include + +class DeckListStatisticsAnalyzer; +class FlowWidget; +class QComboBox; +class QLabel; +class QLineEdit; +class QSpinBox; +class QTableWidget; +class QWidget; class DrawProbabilityWidget : public AbstractAnalyticsPanelWidget { diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_config.cpp b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_config.cpp index 2f9f60752..41ebdec1e 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_config.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_config.cpp @@ -1,5 +1,9 @@ #include "mana_base_config.h" +#include +#include +#include + QJsonObject ManaBaseConfig::toJson() const { QJsonObject jsonObject; diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_config.h b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_config.h index d01f88b8b..5fd45af62 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_config.h +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_config.h @@ -2,8 +2,9 @@ #ifndef COCKATRICE_MANA_BASE_CONFIG_H #define COCKATRICE_MANA_BASE_CONFIG_H -#include #include +#include +#include #include struct ManaBaseConfig diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_config_dialog.cpp b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_config_dialog.cpp index 83e35d57d..32b54c485 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_config_dialog.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_config_dialog.cpp @@ -1,6 +1,19 @@ #include "mana_base_config_dialog.h" +#include "../../deck_list_statistics_analyzer.h" + +#include +#include +#include +#include +#include +#include +#include #include +#include +#include + +class QWidget; ManaBaseConfigDialog::ManaBaseConfigDialog(DeckListStatisticsAnalyzer *analyzer, ManaBaseConfig initial, diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_config_dialog.h b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_config_dialog.h index a816f154f..b0ae77076 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_config_dialog.h +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_config_dialog.h @@ -2,18 +2,20 @@ #ifndef COCKATRICE_MANA_BASE_ADD_DIALOG_H #define COCKATRICE_MANA_BASE_ADD_DIALOG_H -#include "../../deck_list_statistics_analyzer.h" #include "mana_base_config.h" -#include -#include #include -#include -#include -#include -#include -#include -#include +#include +#include +#include + +class DeckListStatisticsAnalyzer; +class QComboBox; +class QDialogButtonBox; +class QLabel; +class QListWidget; +class QVBoxLayout; +class QWidget; class ManaBaseConfigDialog : public QDialog { diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_widget.cpp b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_widget.cpp index 0093c360b..fc7914b94 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_widget.cpp @@ -3,12 +3,27 @@ #include "../../../general/display/charts/bars/bar_widget.h" #include "../../../general/display/charts/bars/color_bar.h" #include "../../../general/display/charts/pies/color_pie.h" +#include "../../abstract_analytics_panel_widget.h" #include "../../analytics_panel_widget_registrar.h" +#include "../../deck_list_statistics_analyzer.h" #include "mana_base_config_dialog.h" +#include #include -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include +#include +#include namespace { diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_widget.h b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_widget.h index 85b2a13b3..7ec5ecefe 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_widget.h +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_base/mana_base_widget.h @@ -7,16 +7,17 @@ #ifndef MANA_BASE_WIDGET_H #define MANA_BASE_WIDGET_H -#include "../../../general/display/banner_widget.h" #include "../../abstract_analytics_panel_widget.h" -#include "../../deck_list_statistics_analyzer.h" #include "mana_base_config.h" -#include -#include -#include -#include -#include +#include +#include +#include +#include + +class DeckListStatisticsAnalyzer; +class QHBoxLayout; +class QWidget; class ManaBaseWidget : public AbstractAnalyticsPanelWidget { diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_category_widget.cpp b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_category_widget.cpp index a0ccedc74..7e15058a4 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_category_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_category_widget.cpp @@ -1,9 +1,21 @@ #include "mana_curve_category_widget.h" +#include "../../../general/display/charts/bars/bar_chart_widget.h" #include "libcockatrice/utility/color.h" #include "libcockatrice/utility/qt_utils.h" #include "mana_curve_config.h" -#include "mana_curve_total_widget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include constexpr int MIN_ROW_HEIGHT = 100; // Minimum readable height per row diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_category_widget.h b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_category_widget.h index c6aa6f1f0..1794d5cab 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_category_widget.h +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_category_widget.h @@ -1,12 +1,14 @@ #ifndef COCKATRICE_MANA_CURVE_CATEGORY_WIDGET_H #define COCKATRICE_MANA_CURVE_CATEGORY_WIDGET_H -#include "../../../general/display/charts/bars/bar_chart_widget.h" -#include "mana_curve_config.h" - -#include -#include +#include #include +#include + +class QString; +class QVBoxLayout; +struct ManaCurveConfig; +template class QHash; class ManaCurveCategoryWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_config.cpp b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_config.cpp index 0dfef1b66..190642ad9 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_config.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_config.cpp @@ -1,5 +1,9 @@ #include "mana_curve_config.h" +#include +#include +#include + QJsonObject ManaCurveConfig::toJson() const { QJsonObject jsonObject; diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_config.h b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_config.h index 55222dd98..28224db03 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_config.h +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_config.h @@ -2,8 +2,9 @@ #ifndef COCKATRICE_MANA_CURVE_CONFIG_H #define COCKATRICE_MANA_CURVE_CONFIG_H -#include #include +#include +#include #include struct ManaCurveConfig diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_config_dialog.cpp b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_config_dialog.cpp index 9e9ae98a6..c5498ff3e 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_config_dialog.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_config_dialog.cpp @@ -1,12 +1,22 @@ #include "mana_curve_config_dialog.h" +#include "../../deck_list_statistics_analyzer.h" + +#include #include #include #include +#include #include +#include #include +#include +#include +#include #include +class QWidget; + ManaCurveConfigDialog::ManaCurveConfigDialog(DeckListStatisticsAnalyzer *analyzer, QWidget *parent) : QDialog(parent), analyzer(analyzer) { diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_config_dialog.h b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_config_dialog.h index 17e6776e5..47ce1b8c2 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_config_dialog.h +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_config_dialog.h @@ -1,16 +1,18 @@ #ifndef COCKATRICE_MANA_CURVE_ADD_DIALOG_H #define COCKATRICE_MANA_CURVE_ADD_DIALOG_H -#include "../../deck_list_statistics_analyzer.h" #include "mana_curve_config.h" #include -#include -#include +#include class QListWidget; class QCheckBox; class QComboBox; +class DeckListStatisticsAnalyzer; +class QDialogButtonBox; +class QLabel; +class QWidget; class ManaCurveConfigDialog : public QDialog { diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_total_widget.cpp b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_total_widget.cpp index c04767015..f1d9ea8c7 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_total_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_total_widget.cpp @@ -2,10 +2,18 @@ #include "../../../general/display/charts/bars/bar_chart_widget.h" #include "libcockatrice/utility/color.h" -#include "libcockatrice/utility/qt_utils.h" #include "mana_curve_config.h" #include +#include +#include +#include +#include +#include +#include +#include +#include +#include ManaCurveTotalWidget::ManaCurveTotalWidget(QWidget *parent) : QWidget(parent) { diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_total_widget.h b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_total_widget.h index 203f75345..0eaecec96 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_total_widget.h +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_total_widget.h @@ -1,11 +1,15 @@ #ifndef COCKATRICE_MANA_CURVE_TOTAL_WIDGET_H #define COCKATRICE_MANA_CURVE_TOTAL_WIDGET_H -#include "../../../general/display/charts/bars/bar_chart_widget.h" -#include "mana_curve_config.h" - -#include -#include +#include +#include #include +#include + +class BarChartWidget; +class QHBoxLayout; +class QLabel; +struct ManaCurveConfig; +template class QMap; class ManaCurveTotalWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_widget.cpp b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_widget.cpp index b182bf954..6d93bde54 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_widget.cpp @@ -1,19 +1,23 @@ #include "mana_curve_widget.h" -#include "../../../general/display/charts/bars/bar_chart_background_widget.h" -#include "../../../general/display/charts/bars/bar_chart_widget.h" -#include "../../../general/display/charts/bars/segmented_bar_widget.h" +#include "../../abstract_analytics_panel_widget.h" #include "../../analytics_panel_widget_registrar.h" #include "../../deck_list_statistics_analyzer.h" -#include "libcockatrice/utility/color.h" -#include "libcockatrice/utility/qt_utils.h" +#include "mana_curve_category_widget.h" #include "mana_curve_config_dialog.h" +#include "mana_curve_total_widget.h" -#include -#include -#include -#include -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class QWidget; namespace { diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_widget.h b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_widget.h index d3e56d44c..14c445717 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_widget.h +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_curve/mana_curve_widget.h @@ -8,14 +8,17 @@ #define MANA_CURVE_WIDGET_H #include "../../abstract_analytics_panel_widget.h" -#include "mana_curve_category_widget.h" #include "mana_curve_config.h" -#include "mana_curve_total_widget.h" -#include +#include +#include +#include +#include -class SegmentedBarWidget; class DeckListStatisticsAnalyzer; +class ManaCurveCategoryWidget; +class ManaCurveTotalWidget; +class QWidget; class ManaCurveWidget : public AbstractAnalyticsPanelWidget { diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_config.cpp b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_config.cpp index ee57cb1e2..7a6f0b51a 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_config.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_config.cpp @@ -1,5 +1,9 @@ #include "mana_devotion_config.h" +#include +#include +#include + QJsonObject ManaDevotionConfig::toJson() const { QJsonObject jsonObject; diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_config.h b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_config.h index 08be146e5..480b2151c 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_config.h +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_config.h @@ -1,8 +1,9 @@ #ifndef COCKATRICE_MANA_DEVOTION_CONFIG_H #define COCKATRICE_MANA_DEVOTION_CONFIG_H -#include #include +#include +#include #include struct ManaDevotionConfig diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_config_dialog.cpp b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_config_dialog.cpp index b01c33ba5..0d70fc24d 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_config_dialog.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_config_dialog.cpp @@ -1,5 +1,19 @@ #include "mana_devotion_config_dialog.h" +#include "../../deck_list_statistics_analyzer.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class QWidget; + ManaDevotionConfigDialog::ManaDevotionConfigDialog(DeckListStatisticsAnalyzer *analyzer, ManaDevotionConfig initial, QWidget *parent) diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_config_dialog.h b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_config_dialog.h index fd804e2af..9c37ec5ad 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_config_dialog.h +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_config_dialog.h @@ -2,16 +2,20 @@ #ifndef COCKATRICE_MANA_DEVOTION_ADD_DIALOG_H #define COCKATRICE_MANA_DEVOTION_ADD_DIALOG_H -#include "../../deck_list_statistics_analyzer.h" #include "mana_devotion_config.h" -#include -#include #include -#include -#include -#include -#include +#include +#include +#include + +class DeckListStatisticsAnalyzer; +class QComboBox; +class QDialogButtonBox; +class QLabel; +class QListWidget; +class QVBoxLayout; +class QWidget; class ManaDevotionConfigDialog : public QDialog { diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_widget.cpp b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_widget.cpp index 709577ff2..7e1f0c211 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_widget.cpp @@ -3,12 +3,26 @@ #include "../../../general/display/charts/bars/bar_widget.h" #include "../../../general/display/charts/bars/color_bar.h" #include "../../../general/display/charts/pies/color_pie.h" +#include "../../abstract_analytics_panel_widget.h" #include "../../analytics_panel_widget_registrar.h" #include "../../deck_list_statistics_analyzer.h" #include "mana_devotion_config_dialog.h" +#include +#include +#include +#include #include -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include namespace { diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_widget.h b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_widget.h index af0215305..2b0182c1e 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_widget.h +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_devotion/mana_devotion_widget.h @@ -6,11 +6,17 @@ #ifndef MANA_DEVOTION_WIDGET_H #define MANA_DEVOTION_WIDGET_H -#include "../../../general/display/banner_widget.h" #include "../../abstract_analytics_panel_widget.h" #include "mana_devotion_config.h" -#include +#include +#include +#include +#include + +class DeckListStatisticsAnalyzer; +class QHBoxLayout; +class QWidget; class ManaDevotionWidget : public AbstractAnalyticsPanelWidget { diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_distribution/mana_distribution_config.cpp b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_distribution/mana_distribution_config.cpp index 01af9329c..ab321661e 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_distribution/mana_distribution_config.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_distribution/mana_distribution_config.cpp @@ -1,5 +1,9 @@ #include "mana_distribution_config.h" +#include +#include +#include + QJsonObject ManaDistributionConfig::toJson() const { QJsonObject o; diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_distribution/mana_distribution_config.h b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_distribution/mana_distribution_config.h index d1aa0f48e..012b0f4a7 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_distribution/mana_distribution_config.h +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_distribution/mana_distribution_config.h @@ -1,8 +1,8 @@ #ifndef COCKATRICE_MANA_DISTRIBUTION_CONFIG_H #define COCKATRICE_MANA_DISTRIBUTION_CONFIG_H -#include #include +#include #include #include diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_distribution/mana_distribution_config_dialog.cpp b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_distribution/mana_distribution_config_dialog.cpp index 325c70028..24b71bae0 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_distribution/mana_distribution_config_dialog.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_distribution/mana_distribution_config_dialog.cpp @@ -1,12 +1,19 @@ #include "mana_distribution_config_dialog.h" +#include #include #include #include #include +#include #include +#include +#include +#include #include +class QWidget; + static const QStringList kColors = {"W", "U", "B", "R", "G", "C"}; ManaDistributionConfigDialog::ManaDistributionConfigDialog(DeckListStatisticsAnalyzer *analyzer, QWidget *parent) diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_distribution/mana_distribution_config_dialog.h b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_distribution/mana_distribution_config_dialog.h index 57c88e29d..3d21b6bee 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_distribution/mana_distribution_config_dialog.h +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_distribution/mana_distribution_config_dialog.h @@ -4,14 +4,15 @@ #include "mana_distribution_config.h" #include -#include -#include -#include +#include class QComboBox; class QListWidget; class QCheckBox; class DeckListStatisticsAnalyzer; +class QDialogButtonBox; +class QLabel; +class QWidget; class ManaDistributionConfigDialog : public QDialog { diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_distribution/mana_distribution_single_display_widget.cpp b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_distribution/mana_distribution_single_display_widget.cpp index 8a5e14858..4faa105e6 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_distribution/mana_distribution_single_display_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_distribution/mana_distribution_single_display_widget.cpp @@ -2,7 +2,10 @@ #include "../../../cards/additional_info/mana_symbol_widget.h" +#include +#include #include +#include ManaDistributionSingleDisplayWidget::ManaDistributionSingleDisplayWidget(const QString &colorSymbol, QWidget *parent) : QWidget(parent) diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_distribution/mana_distribution_single_display_widget.h b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_distribution/mana_distribution_single_display_widget.h index e33d0cec1..2fd3efb2d 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_distribution/mana_distribution_single_display_widget.h +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_distribution/mana_distribution_single_display_widget.h @@ -1,10 +1,12 @@ #ifndef COCKATRICE_MANA_DISTRIBUTION_SINGLE_DISPLAY_WIDGET_H #define COCKATRICE_MANA_DISTRIBUTION_SINGLE_DISPLAY_WIDGET_H -#include -#include -#include +#include #include +#include + +class QLabel; +class QProgressBar; class ManaDistributionSingleDisplayWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_distribution/mana_distribution_widget.cpp b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_distribution/mana_distribution_widget.cpp index 8eaea1426..5543c64df 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_distribution/mana_distribution_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_distribution/mana_distribution_widget.cpp @@ -1,12 +1,21 @@ #include "mana_distribution_widget.h" +#include "../../../general/display/charts/bars/color_bar.h" +#include "../../../general/display/charts/pies/color_pie.h" +#include "../../abstract_analytics_panel_widget.h" #include "../../analytics_panel_widget_registrar.h" +#include "../../deck_list_statistics_analyzer.h" #include "mana_distribution_config_dialog.h" +#include "mana_distribution_single_display_widget.h" -#include -#include -#include +#include +#include +#include +#include +#include +#include #include +#include namespace { diff --git a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_distribution/mana_distribution_widget.h b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_distribution/mana_distribution_widget.h index 2d834f3af..6230e0201 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_distribution/mana_distribution_widget.h +++ b/cockatrice/src/interface/widgets/deck_analytics/analyzer_modules/mana_distribution/mana_distribution_widget.h @@ -1,17 +1,21 @@ #ifndef COCKATRICE_MANA_DISTRIBUTION_WIDGET_H #define COCKATRICE_MANA_DISTRIBUTION_WIDGET_H -#include "../../../general/display/charts/bars/color_bar.h" -#include "../../../general/display/charts/pies/color_pie.h" #include "../../abstract_analytics_panel_widget.h" -#include "../../deck_list_statistics_analyzer.h" #include "mana_distribution_config.h" -#include "mana_distribution_single_display_widget.h" -#include +#include #include -#include -#include +#include +#include + +class ColorBar; +class ColorPie; +class DeckListStatisticsAnalyzer; +class ManaDistributionSingleDisplayWidget; +class QHBoxLayout; +class QVBoxLayout; +class QWidget; class ManaDistributionWidget : public AbstractAnalyticsPanelWidget { diff --git a/cockatrice/src/interface/widgets/deck_analytics/deck_analytics_widget.cpp b/cockatrice/src/interface/widgets/deck_analytics/deck_analytics_widget.cpp index 9db1aee19..7734fcfc8 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/deck_analytics_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/deck_analytics_widget.cpp @@ -1,5 +1,6 @@ #include "deck_analytics_widget.h" +#include "../general/layout_containers/flow_widget.h" #include "../general/tutorial/tutorial_controller.h" #include "../tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.h" #include "abstract_analytics_panel_widget.h" @@ -12,13 +13,19 @@ #include "libcockatrice/utility/qt_utils.h" #include "resizable_panel.h" +#include +#include #include +#include #include #include +#include #include #include #include #include +#include +#include DeckAnalyticsWidget::DeckAnalyticsWidget(QWidget *parent, DeckListStatisticsAnalyzer *_statsAnalyzer) : QWidget(parent), statsAnalyzer(_statsAnalyzer) diff --git a/cockatrice/src/interface/widgets/deck_analytics/deck_analytics_widget.h b/cockatrice/src/interface/widgets/deck_analytics/deck_analytics_widget.h index 9e5753232..6abf3db56 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/deck_analytics_widget.h +++ b/cockatrice/src/interface/widgets/deck_analytics/deck_analytics_widget.h @@ -15,12 +15,21 @@ #include #include -#include -#include +#include +#include #include #include +#include class LayoutInspector; +class AbstractAnalyticsPanelWidget; +class DeckListStatisticsAnalyzer; +class FlowWidget; +class QCheckBox; +class QPushButton; +class QScrollArea; +class QVBoxLayout; +class ResizablePanel; class DeckAnalyticsWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/deck_analytics/deck_list_statistics_analyzer.cpp b/cockatrice/src/interface/widgets/deck_analytics/deck_list_statistics_analyzer.cpp index add13ff21..c2f70759e 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/deck_list_statistics_analyzer.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/deck_list_statistics_analyzer.cpp @@ -1,11 +1,19 @@ #include "deck_list_statistics_analyzer.h" #include "deck_list_model.h" -#include "deck_list_statistics_analyzer.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/deck_list/tree/deck_list_card_node.h" +#include "libcockatrice/deck_list/tree/inner_deck_list_node.h" +#include #include +#include +#include +#include +#include #include -#include +#include DeckListStatisticsAnalyzer::DeckListStatisticsAnalyzer(QObject *parent, DeckListModel *_model, diff --git a/cockatrice/src/interface/widgets/deck_analytics/deck_list_statistics_analyzer.h b/cockatrice/src/interface/widgets/deck_analytics/deck_list_statistics_analyzer.h index 52ae751bf..c3643d65f 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/deck_list_statistics_analyzer.h +++ b/cockatrice/src/interface/widgets/deck_analytics/deck_list_statistics_analyzer.h @@ -1,10 +1,12 @@ #ifndef COCKATRICE_DECK_LIST_STATISTICS_ANALYZER_H #define COCKATRICE_DECK_LIST_STATISTICS_ANALYZER_H -#include "deck_list_model.h" - #include +#include #include +#include +#include +#include #include class DeckListModel; diff --git a/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp b/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp index 250fe1ad3..f46679406 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp @@ -1,11 +1,34 @@ #include "resizable_panel.h" +#include "abstract_analytics_panel_widget.h" #include "libcockatrice/utility/qt_utils.h" +#include +#include #include +#include +#include +#include +#include +#include +#include +#include #include +#include +#include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include +#include ResizablePanel::ResizablePanel(const QString &_typeId, AbstractAnalyticsPanelWidget *analyticsPanel, QWidget *parent) : QWidget(parent), panel(analyticsPanel), typeId(_typeId) diff --git a/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.h b/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.h index d958ec64d..809703847 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.h +++ b/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.h @@ -1,19 +1,15 @@ #ifndef COCKATRICE_RESIZABLE_PANEL_H #define COCKATRICE_RESIZABLE_PANEL_H -#include "abstract_analytics_panel_widget.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include +#include +#include #include +#include + +class AbstractAnalyticsPanelWidget; +class QFrame; +class QPushButton; +class QTimer; class ResizablePanel : public QWidget { diff --git a/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp b/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp index 7c782b074..e8f6398f7 100644 --- a/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp @@ -1,17 +1,35 @@ #include "card_database_view.h" #include "../../../client/settings/cache_settings.h" +#include "../../key_signals.h" #include "card_database_display_model.h" #include "card_database_model.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include +#include #include +#include #include +#include #include +#include +#include #include +#include +#include +#include +#include +#include #include #include #include #include +#include +#include + +class QWidget; static bool canBeCommander(const CardInfo &cardInfo) { @@ -90,6 +108,13 @@ void CardDatabaseView::decrementCard(const QString &zoneName) emit cardDecremented(currentCardName(), zoneName); } +void CardDatabaseView::setZoneMenuProvider(const std::function>()> &provider, + const std::function &newZoneHandler) +{ + zoneMenuProvider = provider; + this->newZoneHandler = newZoneHandler; +} + void CardDatabaseView::updateCard(const QModelIndex ¤t, const QModelIndex & /*previous*/) { if (!current.isValid()) { @@ -142,6 +167,50 @@ void CardDatabaseView::openCustomMenu(QPoint point) [this, card] { emit cardAdded(card->getName(), DECK_ZONE_SIDE); }); connect(selectPrinting, &QAction::triggered, this, &CardDatabaseView::selectPrintingClicked); + if (zoneMenuProvider) { + QMenu *addToZoneMenu = menu.addMenu(tr("Add to Zone")); + const auto zoneBoards = zoneMenuProvider(); + for (const QString &boardName : InnerDecklistNode::boardZoneNames()) { + // Boards with zones nest their children so no two menu entries + // share a visible name: "Maindeck ▸ { Maindeck (whole board), … }". + const QStringList customZones = [&zoneBoards, boardName] { + for (const auto &zoneBoard : zoneBoards) { + if (zoneBoard.first == boardName) { + return zoneBoard.second; + } + } + return QStringList(); + }(); + if (customZones.isEmpty()) { + QAction *action = addToZoneMenu->addAction(InnerDecklistNode::visibleNameFromName(boardName)); + connect(action, &QAction::triggered, this, + [this, card, boardName] { emit cardAdded(card->getName(), boardName); }); + } else { + QMenu *boardSubmenu = addToZoneMenu->addMenu(InnerDecklistNode::visibleNameFromName(boardName)); + QAction *wholeBoardAction = boardSubmenu->addAction(InnerDecklistNode::visibleNameFromName(boardName)); + connect(wholeBoardAction, &QAction::triggered, this, + [this, card, boardName] { emit cardAdded(card->getName(), boardName); }); + for (const QString &zoneName : customZones) { + QAction *action = boardSubmenu->addAction(zoneName); + connect(action, &QAction::triggered, this, + [this, card, zoneName] { emit cardAdded(card->getName(), zoneName); }); + } + } + } + + if (newZoneHandler) { + addToZoneMenu->addSeparator(); + + QAction *newZoneAction = addToZoneMenu->addAction(tr("Create &new zone...")); + connect(newZoneAction, &QAction::triggered, this, [this, card] { + const QString zoneName = newZoneHandler(); + if (!zoneName.isEmpty()) { + emit cardAdded(card->getName(), zoneName); + } + }); + } + } + if (canBeCommander(*card)) { QAction *edhRecCommander = menu.addAction(tr("Show on EDHRec (Commander)")); connect(edhRecCommander, &QAction::triggered, this, [this, card] { emit edhrecClicked(card, true); }); diff --git a/cockatrice/src/interface/widgets/deck_editor/card_database_view.h b/cockatrice/src/interface/widgets/deck_editor/card_database_view.h index 175ec12b9..c095c544d 100644 --- a/cockatrice/src/interface/widgets/deck_editor/card_database_view.h +++ b/cockatrice/src/interface/widgets/deck_editor/card_database_view.h @@ -3,11 +3,19 @@ #include "../../key_signals.h" +#include +#include +#include #include +#include #include +#include -class CardDatabaseModel; class CardDatabaseDisplayModel; +class QModelIndex; +class QPoint; +class QWidget; +template class QList; /** * @brief The card database table. @@ -19,6 +27,13 @@ class CardDatabaseView : public QTreeView KeySignals searchKeySignals; CardDatabaseDisplayModel *databaseDisplayModel; + /// Provides the custom zones available in the current deck, grouped by board zone. + /// The list contains (board zone name, custom zone names) pairs for every board. + std::function>()> zoneMenuProvider; + /// Handler invoked when the user picks "New zone..." from the add-to-zone menu. + /// Returns the name of the created zone, or an empty string if creation was cancelled. + std::function newZoneHandler; + public: explicit CardDatabaseView(QWidget *parent, CardDatabaseDisplayModel *model); @@ -33,6 +48,17 @@ public: return &searchKeySignals; } + /** + * @brief Sets the provider used to populate the "Add to zone" submenu of the context menu. + * If no provider is set, the submenu is not shown. + * + * @param provider Returns the custom zones of the current deck, grouped by board zone + * @param newZoneHandler Creates a new custom zone and returns its name, or an empty string + * if creation was cancelled. The menu entry is hidden when not provided. + */ + void setZoneMenuProvider(const std::function>()> &provider, + const std::function &newZoneHandler); + signals: void cardChanged(const QString &cardName); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_database_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_database_dock_widget.cpp index 2a491de4f..57ae6eadd 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_database_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_database_dock_widget.cpp @@ -1,5 +1,25 @@ #include "deck_editor_card_database_dock_widget.h" +#include "../../../interface/widgets/tabs/abstract_tab_deck_editor.h" +#include "card_database_view.h" +#include "deck_editor_database_display_widget.h" +#include "deck_list_model.h" +#include "deck_state_manager.h" +#include "deck_zone_dialog.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/deck_list/tree/inner_deck_list_node.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + DeckEditorCardDatabaseDockWidget::DeckEditorCardDatabaseDockWidget(AbstractTabDeckEditor *parent) : QDockWidget(parent) { setObjectName("databaseDisplayDock"); @@ -15,6 +35,27 @@ void DeckEditorCardDatabaseDockWidget::createDatabaseDisplayDock(AbstractTabDeck { databaseDisplayWidget = new DeckEditorDatabaseDisplayWidget(this, deckEditor->databaseModel); + databaseDisplayWidget->getDatabaseView()->setZoneMenuProvider( + [deckEditor]() -> QList> { + QList> result; + auto *deckListModel = deckEditor->deckStateManager->getModel(); + for (const QString &boardName : InnerDecklistNode::boardZoneNames()) { + result.append({boardName, deckListModel->getCustomZoneNames(boardName)}); + } + return result; + }, + [this, deckEditor]() -> QString { + QString boardName; + const QString zoneName = + DeckZoneDialog::promptForNewZone(this, {}, &boardName, [deckEditor](const QString &candidate) { + return deckEditor->deckStateManager->validateNewZoneName(candidate); + }); + if (!zoneName.isEmpty()) { + deckEditor->deckStateManager->createCustomZone(boardName, zoneName); + } + return zoneName; + }); + auto *frame = new QVBoxLayout; frame->setObjectName("databaseDisplayFrame"); frame->addWidget(databaseDisplayWidget); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_database_dock_widget.h b/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_database_dock_widget.h index 6af2e4432..b73843bc7 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_database_dock_widget.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_database_dock_widget.h @@ -1,12 +1,10 @@ #ifndef COCKATRICE_DECK_EDITOR_CARD_DATABASE_DOCK_WIDGET_H #define COCKATRICE_DECK_EDITOR_CARD_DATABASE_DOCK_WIDGET_H -#include "../../../interface/widgets/tabs/abstract_tab_deck_editor.h" - #include +#include class AbstractTabDeckEditor; -class CardDatabase; class DeckEditorDatabaseDisplayWidget; class FilterTree; diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_info_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_info_dock_widget.cpp index a78a9c9c5..1b920ea1d 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_info_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_info_dock_widget.cpp @@ -1,8 +1,14 @@ #include "deck_editor_card_info_dock_widget.h" #include "../cards/card_info_frame_widget.h" +#include "../tabs/abstract_tab_deck_editor.h" +#include #include +#include +#include + +class ExactCard; DeckEditorCardInfoDockWidget::DeckEditorCardInfoDockWidget(AbstractTabDeckEditor *parent) : QDockWidget(parent), deckEditor(parent) diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_info_dock_widget.h b/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_info_dock_widget.h index 086ca2ca4..2f204306d 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_info_dock_widget.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_card_info_dock_widget.h @@ -8,12 +8,13 @@ #ifndef DECK_EDITOR_CARD_INFO_DOCK_WIDGET_H #define DECK_EDITOR_CARD_INFO_DOCK_WIDGET_H -#include "../../../interface/widgets/tabs/abstract_tab_deck_editor.h" -#include "../cards/card_info_frame_widget.h" - #include +#include class AbstractTabDeckEditor; +class CardInfoFrameWidget; +class ExactCard; + class DeckEditorCardInfoDockWidget : public QDockWidget { Q_OBJECT 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 9da821813..7ed398535 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 @@ -2,17 +2,30 @@ #include "../../../client/settings/cache_settings.h" #include "../../../filters/syntax_help.h" -#include "../../../interface/widgets/tabs/abstract_tab_deck_editor.h" -#include "../../../interface/widgets/tabs/tab_supervisor.h" +#include "../../key_signals.h" #include "../../pixel_map_generator.h" +#include "../utility/custom_line_edit.h" +#include "card_database_display_model.h" +#include "card_database_model.h" #include "card_database_view.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/deck_list/tree/inner_deck_list_node.h" -#include -#include +#include +#include +#include +#include +#include #include -#include +#include +#include #include -#include +#include +#include +#include + +class FilterTree; DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent, CardDatabaseModel *databaseModel) : QWidget(parent) @@ -28,7 +41,7 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent 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(QPixmap("theme:icons/info"), QLineEdit::TrailingPosition); + auto help = searchEdit->addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); setFocusProxy(searchEdit); setFocusPolicy(Qt::ClickFocus); @@ -40,6 +53,17 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent databaseDisplayModel->setSourceModel(databaseModel); databaseDisplayModel->setFilterKeyColumn(0); + const auto applyCardSearchLanguage = [this]() { + const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay(); + databaseDisplayModel->setSearchLanguage(CardSearchLanguage{ + cardsDisplay.getCardLang(), static_cast(cardsDisplay.getCardSearchLanguage())}); + }; + applyCardSearchLanguage(); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, + applyCardSearchLanguage); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardSearchLanguageChanged, this, + applyCardSearchLanguage); + databaseView = new CardDatabaseView(this, databaseDisplayModel); databaseView->setObjectName("databaseView"); databaseView->setFocusProxy(searchEdit); @@ -59,13 +83,13 @@ DeckEditorDatabaseDisplayWidget::DeckEditorDatabaseDisplayWidget(QWidget *parent &DeckEditorDatabaseDisplayWidget::onRelatedCardClicked); aAddCard = new QAction(QString(), this); - aAddCard->setIcon(QPixmap("theme:icons/arrow_right_green")); + aAddCard->setIcon(themePixmap(QStringLiteral("icons/arrow_right_green"))); connect(aAddCard, &QAction::triggered, this, &DeckEditorDatabaseDisplayWidget::actAddCardToMainDeck); auto *tbAddCard = new QToolButton(this); tbAddCard->setDefaultAction(aAddCard); aAddCardToSideboard = new QAction(QString(), this); - aAddCardToSideboard->setIcon(QPixmap("theme:icons/arrow_right_blue")); + aAddCardToSideboard->setIcon(themePixmap(QStringLiteral("icons/arrow_right_blue"))); 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_database_display_widget.h b/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.h index 5de4d211d..e9edec125 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.h @@ -8,16 +8,21 @@ #ifndef DECK_EDITOR_DATABASE_DISPLAY_WIDGET_H #define DECK_EDITOR_DATABASE_DISPLAY_WIDGET_H -#include "../../../interface/widgets/tabs/abstract_tab_deck_editor.h" -#include "../utility/custom_line_edit.h" +#include "libcockatrice/card/card_info.h" -#include +#include #include -#include -#include +#include class CardDatabaseView; -class AbstractTabDeckEditor; +class CardDatabaseDisplayModel; +class CardDatabaseModel; +class ExactCard; +class FilterTree; +class QAction; +class QHBoxLayout; +class QVBoxLayout; +class SearchLineEdit; class DeckEditorDatabaseDisplayWidget : public QWidget { 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 fc04fb83d..3d467d376 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 @@ -2,22 +2,77 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" +#include "../../key_signals.h" +#include "../../pixel_map_generator.h" #include "../general/tutorial/tutorial_controller.h" +#include "../playmat/playmat_settings_dialog.h" +#include "../quick_settings/settings_button_widget.h" #include "../settings_page/user_interface_settings_page.h" +#include "../tabs/abstract_tab_deck_editor.h" #include "../tabs/api/commander_spellbook/commander_bracket_widget.h" +#include "../utility/custom_line_edit.h" +#include "../visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.h" +#include "deck_list_history_manager_widget.h" +#include "deck_list_model.h" #include "deck_list_style_proxy.h" #include "deck_state_manager.h" +#include "deck_zone_dialog.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/database/card_database.h" +#include "libcockatrice/card/database/card_database_loader.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/printing_info.h" +#include "libcockatrice/deck_list/deck_list.h" +#include "libcockatrice/deck_list/tree/abstract_deck_list_node.h" +#include "libcockatrice/deck_list/tree/inner_deck_list_node.h" +#include "libcockatrice/utility/card_ref.h" +#include "libcockatrice/utility/playmat_params.h" +#include +#include +#include +#include #include +#include +#include #include +#include +#include #include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include +#include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include +#include #include #include #include +#include +#include static int findRestoreIndex(const CardRef &wanted, const QComboBox *combo) { @@ -187,25 +242,25 @@ void DeckEditorDeckDockWidget::createDeckDock() &DeckEditorDeckDockWidget::applyActiveGroupCriteria); aIncrement = new QAction(QString(), this); - aIncrement->setIcon(QPixmap("theme:icons/increment")); + aIncrement->setIcon(themePixmap(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(QPixmap("theme:icons/decrement")); + aDecrement->setIcon(themePixmap(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(QPixmap("theme:icons/remove_row")); + aRemoveCard->setIcon(themePixmap(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(QPixmap("theme:icons/swap")); + aSwapCard->setIcon(themePixmap(QStringLiteral("icons/swap"))); connect(aSwapCard, &QAction::triggered, this, &DeckEditorDeckDockWidget::actSwapSelection); auto *tbSwapCard = new QToolButton(this); tbSwapCard->setDefaultAction(aSwapCard); @@ -229,10 +284,18 @@ void DeckEditorDeckDockWidget::createDeckDock() upperLayout->addWidget(bannerCardLabel, 4, 0); upperLayout->addWidget(bannerCardComboBox, 4, 1); - upperLayout->addWidget(deckTagsDisplayWidget, 5, 1); + playmatLabel = new QLabel(); + playmatLabel->setObjectName("playmatLabel"); + playmatLabel->setText(tr("Playmat")); + playmatSettingsButton = new QPushButton(tr("Edit Playmat...")); + connect(playmatSettingsButton, &QPushButton::clicked, this, &DeckEditorDeckDockWidget::openPlaymatSettings); + upperLayout->addWidget(playmatLabel, 5, 0); + upperLayout->addWidget(playmatSettingsButton, 5, 1); - upperLayout->addWidget(activeGroupCriteriaLabel, 6, 0); - upperLayout->addWidget(activeGroupCriteriaComboBox, 6, 1); + upperLayout->addWidget(deckTagsDisplayWidget, 6, 1); + + upperLayout->addWidget(activeGroupCriteriaLabel, 7, 0); + upperLayout->addWidget(activeGroupCriteriaComboBox, 7, 1); hashLabel1 = new QLabel(); hashLabel1->setObjectName("hashLabel1"); @@ -347,7 +410,9 @@ ExactCard DeckEditorDeckDockWidget::getCurrentCard() if (!current.isValid()) { return {}; } - const QString cardName = current.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString(); + // The display role holds the localized card name; the edit role always carries the + // canonical English name needed to look the card up in the database. + const QString cardName = current.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); const QString cardProviderID = current.siblingAtColumn(DeckListModelColumns::CARD_PROVIDER_ID).data().toString(); const QModelIndex gparent = current.parent().parent(); @@ -457,6 +522,35 @@ void DeckEditorDeckDockWidget::writeBannerCard(int index) deckStateManager->setBannerCard(bannerCard); } +void DeckEditorDeckDockWidget::openPlaymatSettings() +{ + PlaymatInfo current = deckStateManager->getMetadata().playmat; + + PlaymatSettingsDialog dialog(current.card, current.params, this); + if (dialog.exec() == QDialog::Accepted) { + CardRef newCard = dialog.card(); + PlaymatParams newParams = dialog.params(); + + if (newCard.isEmpty()) { + deckStateManager->setPlaymat(PlaymatInfo{}); + } else { + deckStateManager->setPlaymat({newCard, newParams}); + } + + updatePlaymatLabel(); + } +} + +void DeckEditorDeckDockWidget::updatePlaymatLabel() +{ + CardRef playmat = deckStateManager->getMetadata().playmat.card; + if (playmat.isEmpty()) { + playmatSettingsButton->setText(tr("Edit Playmat...")); + } else { + playmatSettingsButton->setText(tr("Edit Playmat (%1)").arg(playmat.name)); + } +} + void DeckEditorDeckDockWidget::applyActiveGroupCriteria() { getModel()->setActiveGroupCriteria( @@ -491,8 +585,11 @@ void DeckEditorDeckDockWidget::syncBannerCardComboBoxSelectionWithDeck() void DeckEditorDeckDockWidget::setSelectedIndex(const QModelIndex &newCardIndex, bool preserveWidgetFocus) { + const QModelIndex proxyIndex = proxy->mapFromSource(newCardIndex); + deckView->clearSelection(); - deckView->setCurrentIndex(newCardIndex); + deckView->setCurrentIndex(proxyIndex); + deckView->scrollTo(proxyIndex); recursiveExpand(newCardIndex); if (!preserveWidgetFocus) { @@ -514,6 +611,7 @@ void DeckEditorDeckDockWidget::syncDisplayWidgetsToModel() syncBannerCardComboBoxSelectionWithDeck(); updateBannerCardComboBox(); bannerCardComboBox->blockSignals(false); + updatePlaymatLabel(); updateHash(); formatComboBox->blockSignals(true); @@ -748,14 +846,213 @@ void DeckEditorDeckDockWidget::offsetCountAtIndex(const QModelIndex &idx, bool i void DeckEditorDeckDockWidget::decklistCustomMenu(QPoint point) { + const QModelIndex sourceIndex = proxy->mapToSource(deckView->indexAt(point)); + QMenu menu; + const bool isCustomZoneRow = sourceIndex.isValid() && sourceIndex.data(DeckRoles::IsCustomZoneRole).toBool(); + const bool isBoardZoneRow = sourceIndex.isValid() && !isCustomZoneRow && !sourceIndex.parent().isValid(); + const bool isCardRow = + sourceIndex.isValid() && !isCustomZoneRow && !isBoardZoneRow && !getModel()->hasChildren(sourceIndex); + + // Walk the row up to its top-level node to find the hosting board. Cards in + // the tokens board cannot be moved (moveCardToZone bails for it), so the + // move menu is skipped for them. + QString currentBoardName; + QModelIndex board = sourceIndex.parent(); + while (board.isValid() && board.parent().isValid()) { + board = board.parent(); + } + if (board.isValid()) { + currentBoardName = board.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); + } + + if (isCardRow) { + if (currentBoardName != DECK_ZONE_TOKENS) { + addMoveToZoneMenu(&menu, sourceIndex, currentBoardName); + menu.addSeparator(); + } + } else if (isCustomZoneRow) { + const QString zoneName = + sourceIndex.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); + + QAction *renameAction = menu.addAction(tr("&Rename zone...")); + connect(renameAction, &QAction::triggered, this, [this, zoneName] { + // The unchanged name must not validate as a duplicate. + const QString newName = + DeckZoneDialog::promptForRename(this, zoneName, [this, zoneName](const QString &candidate) { + return candidate == zoneName ? QString() : deckStateManager->validateNewZoneName(candidate); + }); + if (!newName.isEmpty() && newName != zoneName) { + deckStateManager->renameCustomZone(zoneName, newName); + } + }); + + QMenu *boardMenu = menu.addMenu(tr("Change &board")); + addChangeBoardMenu(boardMenu, zoneName); + + QAction *deleteAction = menu.addAction(tr("&Delete zone")); + const bool zoneHasCards = getModel()->hasChildren(sourceIndex); + deleteAction->setEnabled(!zoneHasCards); + if (zoneHasCards) { + deleteAction->setToolTip(tr("Move or remove all cards first.")); + menu.setToolTipsVisible(true); + } + connect(deleteAction, &QAction::triggered, this, [this, zoneName] { + const auto result = + QMessageBox::warning(this, tr("Delete zone"), tr("Delete the zone \"%1\"?").arg(zoneName), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + if (result == QMessageBox::Yes) { + deckStateManager->removeCustomZone(zoneName); + } + }); + menu.addSeparator(); + } else if (isBoardZoneRow) { + const QString boardName = + sourceIndex.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); + // Tokens cannot host custom zones, so only offer the action on real boards. + const bool canHostCustomZones = + boardName == DECK_ZONE_MAIN || boardName == DECK_ZONE_SIDE || boardName == DECK_ZONE_MAYBEBOARD; + if (canHostCustomZones) { + addNewZoneAction(&menu, boardName); + menu.addSeparator(); + } + } else if (!sourceIndex.isValid()) { + addNewZoneAction(&menu); + menu.addSeparator(); + } + QAction *selectPrinting = menu.addAction(tr("Select Printing")); connect(selectPrinting, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::showPrintingSelector); menu.exec(deckView->mapToGlobal(point)); } +void DeckEditorDeckDockWidget::addMoveToZoneMenu(QMenu *menu, + const QModelIndex &sourceCardIndex, + const QString ¤tBoardName) +{ + // The card's current *zone*, derived with the same ancestor walk as + // DeckStateManager::moveCardToZone (nearest custom-zone ancestor, else the + // top-level board/zone): a card inside "Removal" under the maindeck lives in + // "Removal", not "main". Comparing against that instead of the board keeps + // the enabled state and the same-zone no-op consistent with the move logic. + QString currentZoneName; + for (QModelIndex ancestor = sourceCardIndex.parent(); ancestor.isValid(); ancestor = ancestor.parent()) { + if (ancestor.data(DeckRoles::IsCustomZoneRole).toBool() || !ancestor.parent().isValid()) { + currentZoneName = ancestor.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); + break; + } + } + + const auto addMoveAction = [this, sourceCardIndex](QMenu *targetMenu, const QString &targetZoneName, + const QString &label, bool enabled) { + QAction *action = targetMenu->addAction(label); + action->setEnabled(enabled); + if (enabled) { + connect(action, &QAction::triggered, this, [this, sourceCardIndex, targetZoneName] { + deckStateManager->moveCardToZone(sourceCardIndex, targetZoneName); + }); + } + }; + + const auto tree = deckStateManager->getDeckListShared()->getTree(); + + QMenu *moveMenu = menu->addMenu(tr("Move to &zone")); + + for (const QString &boardName : InnerDecklistNode::boardZoneNames()) { + const QString boardLabel = InnerDecklistNode::visibleNameFromName(boardName); + const auto customZones = tree->getCustomZones(boardName); + + // Boards with zones nest their children so no two menu entries share a + // visible name: "Maindeck ▸ { Maindeck (whole board), Removal, … }". + // The board the card already lives on is marked instead of offered. + if (!customZones.isEmpty()) { + QMenu *boardSubmenu = moveMenu->addMenu(boardLabel); + addMoveAction(boardSubmenu, boardName, boardLabel, boardName != currentZoneName); + for (const auto *customZone : customZones) { + addMoveAction(boardSubmenu, customZone->getName(), customZone->getName(), + customZone->getName() != currentZoneName); + } + } else { + addMoveAction(moveMenu, boardName, boardLabel, boardName != currentZoneName); + } + } + + moveMenu->addSeparator(); + + QAction *newZoneAction = moveMenu->addAction(tr("Create new zone and move &here...")); + connect(newZoneAction, &QAction::triggered, this, [this, sourceCardIndex, currentBoardName, currentZoneName] { + // Resolve the card's identity before creating the zone: + // createNewCustomZone rebuilds the model tree, so sourceCardIndex's + // internal pointer is freed by the time it would be used. + const QString cardName = + sourceCardIndex.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); + const QString providerId = + sourceCardIndex.siblingAtColumn(DeckListModelColumns::CARD_PROVIDER_ID).data(Qt::DisplayRole).toString(); + const QString collectorNumber = sourceCardIndex.siblingAtColumn(DeckListModelColumns::CARD_COLLECTOR_NUMBER) + .data(Qt::DisplayRole) + .toString(); + + const QString zoneName = createNewCustomZone(currentBoardName); + if (!zoneName.isEmpty()) { + // Re-find the card: the old index is no longer safe since rows were + // rebuilt. Mirror DeckStateManager::decrementCard's re-find pattern. + const QModelIndex refreshed = getModel()->findCard(cardName, currentZoneName, providerId, collectorNumber); + if (refreshed.isValid()) { + deckStateManager->moveCardToZone(refreshed, zoneName); + } + } + }); +} + +void DeckEditorDeckDockWidget::addChangeBoardMenu(QMenu *menu, const QString &zoneName) +{ + const auto tree = deckStateManager->getDeckListShared()->getTree(); + for (const QString &boardName : InnerDecklistNode::boardZoneNames()) { + QAction *action = menu->addAction(InnerDecklistNode::visibleNameFromName(boardName)); + + // The board currently holding the zone is marked instead of offered. + // Duplicate names cannot come up through the editor, so this doubles as + // the uniqueness guard for imported decks. + bool holdsTheZone = false; + for (const auto *customZone : tree->getCustomZones(boardName)) { + if (customZone->getName() == zoneName) { + holdsTheZone = true; + break; + } + } + if (holdsTheZone) { + action->setCheckable(true); + action->setChecked(true); + continue; + } + + connect(action, &QAction::triggered, this, + [this, zoneName, boardName] { deckStateManager->moveCustomZone(zoneName, boardName); }); + } +} + +void DeckEditorDeckDockWidget::addNewZoneAction(QMenu *menu, const QString &initialBoardName) +{ + QAction *newZoneAction = menu->addAction(tr("Create &new zone...")); + connect(newZoneAction, &QAction::triggered, this, + [this, initialBoardName] { createNewCustomZone(initialBoardName); }); +} + +QString DeckEditorDeckDockWidget::createNewCustomZone(const QString &initialBoardName) +{ + QString boardName; + const QString zoneName = + DeckZoneDialog::promptForNewZone(this, initialBoardName, &boardName, [this](const QString &candidate) { + return deckStateManager->validateNewZoneName(candidate); + }); + if (!zoneName.isEmpty()) { + deckStateManager->createCustomZone(boardName, zoneName); + } + return zoneName; +} + void DeckEditorDeckDockWidget::refreshShortcuts() { ShortcutsSettings &shortcuts = SettingsCache::instance().shortcuts(); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h index 79d2c62f9..c9b7e0713 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.h @@ -8,23 +8,41 @@ #ifndef DECK_EDITOR_DECK_DOCK_WIDGET_H #define DECK_EDITOR_DECK_DOCK_WIDGET_H -#include "../../../interface/widgets/tabs/abstract_tab_deck_editor.h" #include "../../key_signals.h" #include "../general/tutorial/tutorial_controller.h" #include "../utility/custom_line_edit.h" #include "../visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.h" #include "deck_list_history_manager_widget.h" #include "deck_list_style_proxy.h" +#include "libcockatrice/card/printing/exact_card.h" #include -#include -#include +#include +#include #include -#include +#include class CommanderBracketWidget; class DeckListModel; class AbstractTabDeckEditor; +class DeckListHistoryManagerWidget; +class DeckListStyleProxy; +class DeckPreviewDeckTagsDisplayWidget; +class DeckStateManager; +class LineEditUnfocusable; +class QAction; +class QCheckBox; +class QComboBox; +class QItemSelectionModel; +class QLabel; +class QMenu; +class QModelIndex; +class QPoint; +class QPushButton; +class QTextEdit; +class QTimer; +class SettingsButtonWidget; + class DeckEditorDeckDockWidget : public QDockWidget { Q_OBJECT @@ -34,6 +52,8 @@ public: DeckListStyleProxy *proxy; QTreeView *deckView; QComboBox *bannerCardComboBox; + QLabel *playmatLabel; + QPushButton *playmatSettingsButton; void createDeckDock(); ExactCard getCurrentCard(); void retranslateUi(); @@ -99,12 +119,19 @@ private: [[nodiscard]] QModelIndexList getSelectedCardNodeSourceIndices() const; void offsetCountAtIndex(const QModelIndex &idx, bool isIncrement); + void addMoveToZoneMenu(QMenu *menu, const QModelIndex &sourceCardIndex, const QString ¤tBoardName); + void addChangeBoardMenu(QMenu *menu, const QString &zoneName); + QString createNewCustomZone(const QString &initialBoardName = {}); + void addNewZoneAction(QMenu *menu, const QString &initialBoardName = {}); + private slots: void decklistCustomMenu(QPoint point); void updateCard(QModelIndex, const QModelIndex ¤t); void writeName(); void writeComments(); void writeBannerCard(int); + void openPlaymatSettings(); + void updatePlaymatLabel(); void applyActiveGroupCriteria(); void setSelectedIndex(const QModelIndex &newCardIndex, bool preserveWidgetFocus); void updateHash(); 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 2d4fb60e8..0a47333fa 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 @@ -4,10 +4,31 @@ #include "../../../client/settings/shortcuts_settings.h" #include "../../../filters/filter_builder.h" #include "../../../filters/filter_tree_model.h" +#include "../../key_signals.h" +#include "../../pixel_map_generator.h" +#include "../tabs/abstract_tab_deck_editor.h" +#include "libcockatrice/filters/filter_card.h" +#include +#include +#include +#include #include +#include +#include #include +#include +#include +#include +#include +#include +#include #include +#include +#include +#include +#include +#include DeckEditorFilterDockWidget::DeckEditorFilterDockWidget(AbstractTabDeckEditor *parent) : QDockWidget(parent), deckEditor(parent) @@ -42,11 +63,11 @@ void DeckEditorFilterDockWidget::createFiltersDock() connect(filterBuilder, &FilterBuilder::add, filterModel, &FilterTreeModel::addFilter); aClearFilterOne = new QAction(QString(), this); - aClearFilterOne->setIcon(QPixmap("theme:icons/decrement")); + aClearFilterOne->setIcon(themePixmap(QStringLiteral("icons/decrement"))); connect(aClearFilterOne, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterOne); aClearFilterAll = new QAction(QString(), this); - aClearFilterAll->setIcon(QPixmap("theme:icons/clearsearch")); + aClearFilterAll->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); connect(aClearFilterAll, &QAction::triggered, this, &DeckEditorFilterDockWidget::actClearFilterAll); auto *filterDelOne = new QToolButton(); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.h b/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.h index b61fc72b5..7bb9c0045 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_filter_dock_widget.h @@ -8,13 +8,18 @@ #ifndef DECK_EDITOR_FILTER_DOCK_WIDGET_H #define DECK_EDITOR_FILTER_DOCK_WIDGET_H -#include "../../../interface/widgets/tabs/abstract_tab_deck_editor.h" #include "../../key_signals.h" #include +#include class FilterTreeModel; class AbstractTabDeckEditor; +class QAction; +class QPoint; +class QTreeView; +class QWidget; + class DeckEditorFilterDockWidget : public QDockWidget { Q_OBJECT diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_printing_selector_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_printing_selector_dock_widget.cpp index 06d8d1de7..d45e951ba 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_printing_selector_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_printing_selector_dock_widget.cpp @@ -2,10 +2,15 @@ #include "../../../client/settings/cache_settings.h" #include "../../../interface/widgets/tabs/abstract_tab_deck_editor.h" +#include "../printing_selector/printing_selector.h" +#include "deck_editor_deck_dock_widget.h" #include "printing_disabled_info_widget.h" +#include #include +#include #include +#include DeckEditorPrintingSelectorDockWidget::DeckEditorPrintingSelectorDockWidget(AbstractTabDeckEditor *parent) : QDockWidget(parent), deckEditor(parent) diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_editor_printing_selector_dock_widget.h b/cockatrice/src/interface/widgets/deck_editor/deck_editor_printing_selector_dock_widget.h index 1037b55fb..e20ae932d 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_printing_selector_dock_widget.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_printing_selector_dock_widget.h @@ -7,13 +7,13 @@ #ifndef DECK_EDITOR_PRINTING_SELECTOR_DOCK_WIDGET_H #define DECK_EDITOR_PRINTING_SELECTOR_DOCK_WIDGET_H -#include "../../../interface/widgets/tabs/abstract_tab_deck_editor.h" -#include "../printing_selector/printing_selector.h" - #include +#include class PrintingDisabledInfoWidget; -class TabDeckEditor; +class AbstractTabDeckEditor; +class PrintingSelector; +class QWidget; class DeckEditorPrintingSelectorDockWidget : public QDockWidget { 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 cef459752..209dc7273 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,28 @@ #include "deck_list_history_manager_widget.h" +#include "../../pixel_map_generator.h" +#include "../quick_settings/settings_button_widget.h" #include "deck_state_manager.h" +#include "libcockatrice/deck_list/deck_list_history_manager.h" +#include "libcockatrice/deck_list/deck_list_memento.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class DeckListStyleProxy; DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_deckStateManager, DeckListStyleProxy *_styleProxy, @@ -10,7 +32,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de layout = new QHBoxLayout(this); aUndo = new QAction(QString(), this); - aUndo->setIcon(QPixmap("theme:icons/arrow_undo")); + aUndo->setIcon(themePixmap(QStringLiteral("icons/arrow_undo"))); aUndo->setShortcut(QKeySequence::Undo); aUndo->setShortcutContext(Qt::ApplicationShortcut); connect(aUndo, &QAction::triggered, this, &DeckListHistoryManagerWidget::doUndo); @@ -19,7 +41,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de undoButton->setDefaultAction(aUndo); aRedo = new QAction(QString(), this); - aRedo->setIcon(QPixmap("theme:icons/arrow_redo")); + aRedo->setIcon(themePixmap(QStringLiteral("icons/arrow_redo"))); aRedo->setShortcut(QKeySequence::Redo); aRedo->setShortcutContext(Qt::ApplicationShortcut); connect(aRedo, &QAction::triggered, this, &DeckListHistoryManagerWidget::doRedo); @@ -31,7 +53,7 @@ DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_de layout->addWidget(redoButton); historyButton = new SettingsButtonWidget(this); - historyButton->setButtonIcon(QPixmap("theme:icons/arrow_history")); + historyButton->setButtonIcon(themePixmap(QStringLiteral("icons/arrow_history"))); historyLabel = new QLabel(this); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.h b/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.h index ab53912e2..5ed44b0fd 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_list_history_manager_widget.h @@ -1,18 +1,20 @@ #ifndef COCKATRICE_DECK_EDITOR_DECK_LIST_HISTORY_MANAGER_WIDGET_H #define COCKATRICE_DECK_EDITOR_DECK_LIST_HISTORY_MANAGER_WIDGET_H +#include + +class DeckListStyleProxy; +class QAction; +class QHBoxLayout; +class QLabel; +class QListWidget; +class QListWidgetItem; +class QToolButton; +class SettingsButtonWidget; #ifndef COCKATRICE_DECK_UNDO_WIDGET_H #define COCKATRICE_DECK_UNDO_WIDGET_H -#include "../quick_settings/settings_button_widget.h" -#include "deck_list_style_proxy.h" - -#include -#include -#include #include -#include -#include class DeckStateManager; diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.cpp index e41529b22..1e1aefe58 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.cpp @@ -3,7 +3,10 @@ #include #include #include +#include +#include #include +#include QVariant DeckListStyleProxy::data(const QModelIndex &index, int role) const { diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.h b/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.h index 52b7eea10..e4d1de4f4 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_list_style_proxy.h @@ -2,6 +2,7 @@ #define COCKATRICE_DECK_LIST_STYLE_PROXY_H #include +#include class DeckListStyleProxy : public QIdentityProxyModel { diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp index f8fb450ce..d4715c8d5 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp @@ -1,12 +1,37 @@ #include "deck_state_manager.h" +#include "../../../client/settings/cache_settings.h" +#include "../../deck_loader/loaded_deck.h" +#include "deck_list_model.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/card/printing/printing_info.h" +#include "libcockatrice/card/set/card_set.h" +#include "libcockatrice/deck_list/deck_list.h" +#include "libcockatrice/deck_list/deck_list_memento.h" +#include "libcockatrice/deck_list/deck_list_node_tree.h" +#include "libcockatrice/utility/card_ref.h" +#include "libcockatrice/utility/playmat_params.h" + +#include +#include #include #include +#include +#include +#include + +class QModelIndex; DeckStateManager::DeckStateManager(QObject *parent) : QObject(parent), deckList(QSharedPointer(new DeckList)), deckListModel(new DeckListModel(this, deckList)), historyManager(new DeckListHistoryManager(this)) { + deckListModel->setDisplayLanguage(SettingsCache::instance().cardsDisplay().getCardLang()); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, deckListModel, + [this](const QString &lang) { deckListModel->setDisplayLanguage(lang); }); + connect(historyManager, &DeckListHistoryManager::undoRedoStateChanged, this, [this] { setModified(true); emit historyChanged(); @@ -142,6 +167,19 @@ void DeckStateManager::setBannerCard(const CardRef &bannerCard) doMetadataModified(); } +void DeckStateManager::setPlaymat(const PlaymatInfo &playmat) +{ + PlaymatInfo previous = deckList->getPlaymat(); + if (previous == playmat) { + return; + } + + requestHistorySave(tr("Set playmat to %1").arg(playmat.card.name)); + deckList->setPlaymat(playmat); + + doMetadataModified(); +} + void DeckStateManager::setTags(const QStringList &tags) { QStringList previous = deckList->getTags(); @@ -246,7 +284,10 @@ bool DeckStateManager::swapCardAtIndex(const QModelIndex &idx) return false; } - QString cardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString(); + // The display role holds the localized card name; the edit role always carries the + // canonical English name needed to look the card up in the database. + QString displayCardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString(); + QString cardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); QString providerId = idx.siblingAtColumn(DeckListModelColumns::CARD_PROVIDER_ID).data().toString(); QModelIndex gparent = idx.parent().parent(); @@ -263,7 +304,7 @@ bool DeckStateManager::swapCardAtIndex(const QModelIndex &idx) QString reason = tr("Moved to %1 1 × \"%2\" (%3)") // .arg(otherZoneName) - .arg(cardName) + .arg(displayCardName) .arg(providerId); return modifyDeck(reason, [&idx, &cardName, &providerId, &otherZoneName](auto model) { @@ -277,9 +318,8 @@ bool DeckStateManager::removeCardAtIndex(const QModelIndex &idx) return false; } - QString cardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString(); - - QString reason = tr("Removed \"%1\" (all copies)").arg(cardName); + QString reason = + tr("Removed \"%1\" (all copies)").arg(idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data().toString()); return modifyDeck(reason, [&idx](auto model) { return model->removeRow(idx.row(), idx.parent()); }); } @@ -294,6 +334,170 @@ bool DeckStateManager::decrementCountAtIndex(const QModelIndex &idx) return offsetCountAtIndex(idx, -1); } +bool DeckStateManager::moveCardToZone(const QModelIndex &idx, const QString &targetZoneName) +{ + if (!idx.isValid()) { + return false; + } + + // Only actual card rows can be moved. Group or zone rows report an + // aggregate amount and must never be deleted by this operation. + if (!idx.data(DeckRoles::IsCardRole).toBool()) { + return false; + } + + QString cardName = idx.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); + QString providerId = idx.siblingAtColumn(DeckListModelColumns::CARD_PROVIDER_ID).data(Qt::DisplayRole).toString(); + int copies = idx.siblingAtColumn(DeckListModelColumns::CARD_AMOUNT).data(Qt::EditRole).toInt(); + + if (copies <= 0) { + return false; + } + + // Tokens only live in the tokens zone and cannot be moved into decks. + CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(cardName); + if (info && info->getIsToken()) { + return false; + } + + // Determine the zone the card currently lives in: the enclosing custom + // zone, or the nearest top-level zone (board zone or legacy zone). + QString currentZoneName; + for (QModelIndex ancestor = idx.parent(); ancestor.isValid(); ancestor = ancestor.parent()) { + bool isCustomZone = ancestor.data(DeckRoles::IsCustomZoneRole).toBool(); + if (isCustomZone || !ancestor.parent().isValid()) { + currentZoneName = ancestor.siblingAtColumn(DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); + break; + } + } + + if (currentZoneName == targetZoneName) { + return false; + } + + QString reason = tr("Moved %1 × \"%2\" (%3) to %4") + .arg(copies) + .arg(cardName) + .arg(providerId) + .arg(InnerDecklistNode::visibleNameFromName(targetZoneName)); + + return modifyDeck(reason, [&idx, &cardName, &providerId, &targetZoneName, copies](auto model) { + if (!model->removeRow(idx.row(), idx.parent())) { + return false; + } + + if (ExactCard card = CardDatabaseManager::query()->getCard({cardName, providerId})) { + for (int i = 0; i < copies; ++i) { + model->addCard(card, targetZoneName); + } + } else { + for (int i = 0; i < copies; ++i) { + model->addPreferredPrintingCard(cardName, targetZoneName, true); + } + } + + return true; + }); +} + +bool DeckStateManager::createCustomZone(const QString &boardZoneName, const QString &zoneName) +{ + const QString trimmedZoneName = zoneName.trimmed(); + if (trimmedZoneName.isEmpty()) { + return false; + } + + QString reason = + tr("Created zone \"%1\" in %2").arg(trimmedZoneName, InnerDecklistNode::visibleNameFromName(boardZoneName)); + + return modifyTree(reason, [&boardZoneName, &trimmedZoneName](DecklistNodeTree *tree) { + return tree->addCustomZone(boardZoneName, trimmedZoneName) != nullptr; + }); +} + +bool DeckStateManager::renameCustomZone(const QString &oldZoneName, const QString &newZoneName) +{ + const QString trimmedNewZoneName = newZoneName.trimmed(); + if (trimmedNewZoneName.isEmpty() || oldZoneName == trimmedNewZoneName) { + return false; + } + + QString reason = tr("Renamed zone \"%1\" to \"%2\"").arg(oldZoneName, trimmedNewZoneName); + + return modifyTree(reason, [&oldZoneName, &trimmedNewZoneName](DecklistNodeTree *tree) { + return tree->renameCustomZone(oldZoneName, trimmedNewZoneName); + }); +} + +bool DeckStateManager::moveCustomZone(const QString &zoneName, const QString &newBoardZoneName) +{ + const auto *tree = deckList->getTree(); + + // Locate the zone through the tree's own lookup, which walks every top-level + // zone (not just the standard boards) and covers the same-board no-op below. + const auto *zone = tree->findCustomZoneByName(zoneName); + if (!zone) { + return false; + } + + // Same-board moves are no-ops and must not pollute the history. + const QString currentBoardName = zone->getParent() ? zone->getParent()->getName() : QString(); + if (currentBoardName == newBoardZoneName) { + return true; + } + + // Zone names are deck-unique among zones created through this manager, so a + // same-named zone on the target board can only come from an imported deck. + // Refuse the move instead of silently stacking same-named zones. + for (const auto *targetZone : tree->getCustomZones(newBoardZoneName)) { + if (targetZone->getName() == zoneName) { + return false; + } + } + + QString reason = + tr("Moved zone \"%1\" to %2").arg(zoneName, InnerDecklistNode::visibleNameFromName(newBoardZoneName)); + + return modifyTree(reason, [&zoneName, &newBoardZoneName](DecklistNodeTree *tree) { + return tree->moveCustomZone(zoneName, newBoardZoneName); + }); +} + +bool DeckStateManager::removeCustomZone(const QString &zoneName) +{ + QString reason = tr("Deleted zone \"%1\"").arg(zoneName); + + return modifyTree(reason, [&zoneName](DecklistNodeTree *tree) { return tree->removeCustomZone(zoneName); }); +} + +QString DeckStateManager::validateNewZoneName(const QString &zoneName) const +{ + if (zoneName.trimmed().isEmpty()) { + return tr("Enter a zone name."); + } + + const QString trimmedZoneName = zoneName.trimmed(); + + // The standard zone names are reserved even before they exist. + if (trimmedZoneName == DECK_ZONE_MAIN || trimmedZoneName == DECK_ZONE_SIDE || + trimmedZoneName == DECK_ZONE_MAYBEBOARD || trimmedZoneName == DECK_ZONE_TOKENS) { + return tr("This name is reserved."); + } + + const auto *tree = deckList->getTree(); + + // Reuse the tree's own uniqueness contract: any top-level zone and any + // custom zone on *every* board claims the name (hasZoneName also reserves + // the standard board names, which we already rejected with a dedicated + // message above). Scanning only the standard boards here would miss a + // custom zone an imported deck carries under `tokens`. + if (tree->hasZoneName(trimmedZoneName)) { + return tr("A zone with this name already exists."); + } + + return {}; +} + bool DeckStateManager::offsetCountAtIndex(const QModelIndex &idx, int offset) { if (!idx.isValid()) { @@ -354,6 +558,25 @@ void DeckStateManager::requestHistorySave(const QString &reason) historyManager->save(deckList->createMemento(reason)); } +bool DeckStateManager::modifyTree(const QString &reason, const std::function &operation) +{ + DeckListMemento memento = deckList->createMemento(reason); + bool success = operation(deckList->getTree()); + + if (success) { + historyManager->save(memento); + deckListModel->rebuildTree(); + deckList->refreshDeckHash(); + emit deckListModel->deckHashChanged(); + // removeCustomZone can drop whole card sets the model never notified + // about (rebuildTree emits no cardNodesChanged), so tell the consumers. + emit deckListModel->cardNodesChanged(); + doCardModified(); + } + + return success; +} + /** * @brief Handles updating state and emitting signals whenever the cards are modified */ diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h index 6fce6be57..98876947b 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h @@ -2,12 +2,22 @@ #define COCKATRICE_DECK_STATE_MANAGER_H #include "../../deck_loader/loaded_deck.h" -#include "deck_list_model.h" +#include +#include #include +#include +#include +#include #include +#include class DeckListHistoryManager; +class DeckListModel; +class DecklistNodeTree; +class ExactCard; +struct CardRef; +struct PlaymatInfo; /** * @brief This class centralizes the management of the state of the deck in the deck editor tab. @@ -171,6 +181,7 @@ public: void setName(const QString &name); void setComments(const QString &comments); void setBannerCard(const CardRef &bannerCard); + void setPlaymat(const PlaymatInfo &playmat); void setTags(const QStringList &tags); void setFormat(const QString &format); ///@} @@ -235,6 +246,68 @@ public: */ bool decrementCountAtIndex(const QModelIndex &idx); + /** + * @brief Moves all copies of the card at the given index to the given zone. + * No-ops if the index is invalid, not a card node, the card is a token, or the + * card is already in the target zone. + * Saves the operation to history if successful. + * + * @param idx The model index of the card to move + * @param targetZoneName The zone to move the card to (board zone or custom zone name) + * @return Whether the operation was successfully performed + */ + bool moveCardToZone(const QModelIndex &idx, const QString &targetZoneName); + + /** + * @brief Creates a new custom zone nested under a board zone. + * Saves the operation to history if successful. + * + * @param boardZoneName The board zone to nest the custom zone under + * @param zoneName The name of the new custom zone. Gets trimmed and must be + * unique across the deck. + * @return Whether the zone was created + */ + bool createCustomZone(const QString &boardZoneName, const QString &zoneName); + + /** + * @brief Renames a custom zone. + * Saves the operation to history if successful. + * + * @param oldZoneName The current name of the custom zone + * @param newZoneName The new name. Gets trimmed and must be unique across the deck. + * @return Whether the rename succeeded + */ + bool renameCustomZone(const QString &oldZoneName, const QString &newZoneName); + + /** + * @brief Moves a custom zone (and its cards) to a different board zone. + * Same-board moves succeed without creating a history entry. + * Saves the operation to history if successful. + * + * @param zoneName The custom zone to move + * @param newBoardZoneName The board zone to move the custom zone under + * @return Whether the move succeeded + */ + bool moveCustomZone(const QString &zoneName, const QString &newBoardZoneName); + + /** + * @brief Removes a custom zone and all its cards. + * Saves the operation to history if successful. + * + * @param zoneName The custom zone to remove + * @return Whether the zone was removed + */ + bool removeCustomZone(const QString &zoneName); + + /** + * @brief Checks whether a candidate name is usable for a new custom zone. + * + * @param zoneName The candidate name + * @return An empty string when the name is usable, otherwise a user-facing + * error message describing the problem + */ + [[nodiscard]] QString validateNewZoneName(const QString &zoneName) const; + /** * Undoes n steps of the history, setting the decklist state and updating the current step in the historyManager. * @param steps Number of steps to undo. @@ -256,6 +329,7 @@ public slots: private: bool offsetCountAtIndex(const QModelIndex &idx, int offset); + bool modifyTree(const QString &reason, const std::function &operation); void doCardModified(); void doMetadataModified(); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.cpp new file mode 100644 index 000000000..cb9667603 --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.cpp @@ -0,0 +1,149 @@ +#include "deck_zone_dialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +DeckZoneDialog::DeckZoneDialog(QWidget *parent, + const QString &initialBoardName, + const std::function &_nameValidator, + bool _allowBoardSelection) + : QDialog(parent), nameValidator(_nameValidator), allowBoardSelection(_allowBoardSelection) +{ + nameLabel = new QLabel(this); + nameEdit = new QLineEdit(this); + nameEdit->setMaxLength(MAX_NAME_LENGTH); + + errorLabel = new QLabel(this); + errorLabel->hide(); + + boardLabel = new QLabel(this); + boardCombo = new QComboBox(this); + for (const QString &boardName : InnerDecklistNode::boardZoneNames()) { + // Use the icon overload explicitly so `boardName` lands in the user data role + // (visible text is applied below in retranslateUi). The two-argument form + // addItem({}, boardName) would be ambiguous and resolve to the icon overload + // with empty user data, yielding empty entries and an empty getBoardName(). + boardCombo->addItem({}, {}, boardName); + } + if (!initialBoardName.isEmpty()) { + int idx = boardCombo->findData(initialBoardName); + if (idx != -1) { + boardCombo->setCurrentIndex(idx); + } + } + + buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false); + connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + + auto *layout = new QVBoxLayout(this); + layout->addWidget(nameLabel); + layout->addWidget(nameEdit); + layout->addWidget(errorLabel); + if (allowBoardSelection) { + layout->addWidget(boardLabel); + layout->addWidget(boardCombo); + } else { + boardLabel->hide(); + boardCombo->hide(); + } + layout->addWidget(buttonBox); + + retranslateUi(); + + connect(nameEdit, &QLineEdit::textChanged, this, [this] { validateName(); }); + validateName(); + + nameEdit->setFocus(); +} + +QString DeckZoneDialog::getZoneName() const +{ + return nameEdit->text().trimmed(); +} + +QString DeckZoneDialog::getBoardName() const +{ + return boardCombo->currentData().toString(); +} + +void DeckZoneDialog::setZoneName(const QString &zoneName) +{ + nameEdit->setText(zoneName); + nameEdit->selectAll(); +} + +void DeckZoneDialog::changeEvent(QEvent *event) +{ + QDialog::changeEvent(event); + + if (event->type() == QEvent::LanguageChange) { + retranslateUi(); + } +} + +void DeckZoneDialog::retranslateUi() +{ + setWindowTitle(allowBoardSelection ? tr("New zone") : tr("Rename zone")); + + nameLabel->setText(tr("Zone &name:")); + nameLabel->setBuddy(nameEdit); + + boardLabel->setText(tr("&Parent zone:")); + boardLabel->setBuddy(boardCombo); + + for (int i = 0; i < boardCombo->count(); i++) { + boardCombo->setItemText(i, InnerDecklistNode::visibleNameFromName(boardCombo->itemData(i).toString())); + } +} + +void DeckZoneDialog::validateName() +{ + const QString zoneName = nameEdit->text().trimmed(); + QString error; + if (zoneName.isEmpty()) { + error = tr("Enter a zone name."); + } else if (nameValidator) { + error = nameValidator(zoneName); + } + + errorLabel->setText(error); + errorLabel->setVisible(!error.isEmpty()); + buttonBox->button(QDialogButtonBox::Ok)->setEnabled(error.isEmpty()); +} + +QString DeckZoneDialog::promptForNewZone(QWidget *parent, + const QString &initialBoardName, + QString *chosenBoardName, + const std::function &nameValidator) +{ + DeckZoneDialog dialog(parent, initialBoardName, nameValidator); + if (dialog.exec() != QDialog::Accepted) { + return {}; + } + + if (chosenBoardName) { + *chosenBoardName = dialog.getBoardName(); + } + return dialog.getZoneName(); +} + +QString DeckZoneDialog::promptForRename(QWidget *parent, + const QString ¤tZoneName, + const std::function &nameValidator) +{ + DeckZoneDialog dialog(parent, {}, nameValidator, false); + dialog.setZoneName(currentZoneName); + return dialog.exec() == QDialog::Accepted ? dialog.getZoneName() : QString(); +} diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.h b/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.h new file mode 100644 index 000000000..f4c9c0f9d --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.h @@ -0,0 +1,123 @@ +/** + * @file deck_zone_dialog.h + * @ingroup DeckEditorWidgets + * @brief Shared dialog for creating custom deck zones. + */ + +#ifndef DECK_ZONE_DIALOG_H +#define DECK_ZONE_DIALOG_H + +#include +#include +#include +#include + +class QComboBox; +class QDialogButtonBox; +class QLabel; +class QLineEdit; +class QWidget; + +/** + * @brief Modal dialog asking for the name and parent zone of a new custom deck zone. + * + * Menus construct the dialog transiently around exec(), so validation state only + * ever reflects the name currently typed. + */ +class DeckZoneDialog : public QDialog +{ + Q_OBJECT + +public: + /** + * @brief Constructs the dialog and runs the initial validation pass. + * + * @param parent The parent widget for the dialog + * @param initialBoardName The board zone to preselect in the combo. Unknown names + * fall back to main. + * @param _nameValidator Given the trimmed candidate name, returns an empty string + * when it is usable, otherwise a user-facing error message. May be empty. + * @param _allowBoardSelection When false the parent-zone combo is hidden and the + * dialog acts as a rename prompt for an existing zone. + */ + explicit DeckZoneDialog(QWidget *parent = nullptr, + const QString &initialBoardName = {}, + const std::function &_nameValidator = {}, + bool _allowBoardSelection = true); + + /** + * @brief The trimmed zone name entered by the user. + */ + [[nodiscard]] QString getZoneName() const; + + /** + * @brief The internal name of the board zone selected in the combo. + */ + [[nodiscard]] QString getBoardName() const; + + /** + * @brief Prefills the name field, e.g. with the current name when renaming. + * + * @param zoneName The text to put into the name field, selected for quick editing + */ + void setZoneName(const QString &zoneName); + + /** + * @brief Prompts the user for a new custom zone name and the board zone to nest it under. + * + * Convenience wrapper that runs DeckZoneDialog modally. + * + * @param parent The parent widget for the dialog + * @param initialBoardName The board zone to preselect in the dialog. Unknown names fall + * back to main. + * @param chosenBoardName (out) The internal name of the board zone the user chose + * @param nameValidator Optional validator forwarded to the dialog + * @return The trimmed zone name, or an empty string if the user cancelled + */ + static QString promptForNewZone(QWidget *parent, + const QString &initialBoardName, + QString *chosenBoardName, + const std::function &nameValidator = {}); + + /** + * @brief Prompts the user for a new name for an existing custom zone. + * + * Same inline validation as promptForNewZone, but without a parent-zone picker. + * + * @param parent The parent widget for the dialog + * @param currentZoneName The current name, prefilled for editing + * @param nameValidator Validator deciding whether a candidate name is usable. It sees + * the current name too, so callers wanting to allow unchanged names must + * special-case that themselves. + * @return The trimmed new name, or an empty string if the user cancelled + */ + static QString promptForRename(QWidget *parent, + const QString ¤tZoneName, + const std::function &nameValidator = {}); + +protected: + void changeEvent(QEvent *event) override; + +private: + /** + * @brief Sets every user-visible string. Runs on construction and on runtime + * language changes. + */ + void retranslateUi(); + + /** + * @brief Validates the current input, toggling Ok and the inline error label. + */ + void validateName(); + + QLabel *nameLabel; + QLineEdit *nameEdit; + QLabel *errorLabel; + QLabel *boardLabel; + QComboBox *boardCombo; + QDialogButtonBox *buttonBox; + std::function nameValidator; + bool allowBoardSelection; +}; + +#endif // DECK_ZONE_DIALOG_H diff --git a/cockatrice/src/interface/widgets/deck_editor/printing_disabled_info_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/printing_disabled_info_widget.cpp index 23670b1b3..b2c4dd303 100644 --- a/cockatrice/src/interface/widgets/deck_editor/printing_disabled_info_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/printing_disabled_info_widget.cpp @@ -3,10 +3,13 @@ #include "../dialogs/override_printing_warning.h" #include +#include #include #include +#include #include -#include +#include +#include PrintingDisabledInfoWidget::PrintingDisabledInfoWidget(QWidget *parent) : QWidget(parent) { diff --git a/cockatrice/src/interface/widgets/deck_editor/printing_disabled_info_widget.h b/cockatrice/src/interface/widgets/deck_editor/printing_disabled_info_widget.h index 0851ccfad..c14c758ff 100644 --- a/cockatrice/src/interface/widgets/deck_editor/printing_disabled_info_widget.h +++ b/cockatrice/src/interface/widgets/deck_editor/printing_disabled_info_widget.h @@ -1,6 +1,7 @@ #ifndef COCKATRICE_PRINTING_DISABLED_INFO_WIDGET_H #define COCKATRICE_PRINTING_DISABLED_INFO_WIDGET_H #include +#include class QPushButton; class QLabel; diff --git a/cockatrice/src/interface/widgets/deck_share/deck_share_utils.cpp b/cockatrice/src/interface/widgets/deck_share/deck_share_utils.cpp new file mode 100644 index 000000000..0ebdc0647 --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_share/deck_share_utils.cpp @@ -0,0 +1,61 @@ +#include "deck_share_utils.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace DeckShareUtils +{ + +QString buildShareLink(const AbstractClient *client, const QString &token) +{ + QUrl url; + url.setScheme(QStringLiteral("cockatrice")); + url.setHost(QStringLiteral("opendeck")); + + QUrlQuery query; + query.addQueryItem(QStringLiteral("share"), token); + query.addQueryItem(QStringLiteral("hostname"), client->serverName()); + query.addQueryItem(QStringLiteral("port"), QString::number(client->serverPort())); + url.setQuery(query); + + return url.toString(QUrl::FullyEncoded); +} + +QString copyShareLinkToClipboard(const QString &link) +{ + QGuiApplication::clipboard()->setText(link); + return link; +} + +QString formatShareExpiry(const QDateTime &expiry) +{ + return QLocale().toString(expiry.toLocalTime(), QLocale::ShortFormat); +} + +ShareResponse handleShareResponse(const AbstractClient *client, const Response &response) +{ + const Response_DeckShareCreate &resp = response.GetExtension(Response_DeckShareCreate::ext); + const QString token = QString::fromStdString(resp.token()); + + const QString link = buildShareLink(client, token); + copyShareLinkToClipboard(link); + +#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0) + const QDateTime expiry = QDateTime::fromSecsSinceEpoch(resp.expires_at(), QTimeZone::UTC); +#else + const QDateTime expiry = QDateTime::fromSecsSinceEpoch(resp.expires_at(), Qt::UTC); +#endif + + return {link, expiry}; +} + +} // namespace DeckShareUtils diff --git a/cockatrice/src/interface/widgets/deck_share/deck_share_utils.h b/cockatrice/src/interface/widgets/deck_share/deck_share_utils.h new file mode 100644 index 000000000..c9f7afa8b --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_share/deck_share_utils.h @@ -0,0 +1,59 @@ +/** + * @file deck_share_utils.h + * @ingroup DeckShareWidgets + */ +//! \todo Document this file. + +#ifndef DECK_SHARE_UTILS_H +#define DECK_SHARE_UTILS_H + +#include +#include + +class AbstractClient; +class Response; + +/** + * @brief Shared helpers for creating temporary deck shares. + */ +namespace DeckShareUtils +{ + +/** + * @brief The outcome of a successful share-create response. + */ +struct ShareResponse +{ + QString link; ///< The share link that was copied to the clipboard. + QDateTime expiry; ///< When the share expires (UTC). +}; + +/** + * @brief Builds the cockatrice:// link for a freshly created deck share. + * @param client Used to embed the target server's hostname and port. + * @param token The share token from Response_DeckShareCreate. + */ +QString buildShareLink(const AbstractClient *client, const QString &token); + +/** + * @brief Copies the share link to the clipboard. + * @return The link that was copied. + */ +QString copyShareLinkToClipboard(const QString &link); + +/** + * @brief Formats the expiration timestamp for a share. + */ +QString formatShareExpiry(const QDateTime &expiry); + +/** + * @brief Handles a successful Response_DeckShareCreate: builds the share link, + * copies it to the clipboard, and derives the share expiry. + * @param client Used to embed the target server's hostname and port. + * @param response The successful response carrying the share token and expiry. + */ +ShareResponse handleShareResponse(const AbstractClient *client, const Response &response); + +} // namespace DeckShareUtils + +#endif // DECK_SHARE_UTILS_H diff --git a/cockatrice/src/interface/widgets/deck_share/share_bar_widget.cpp b/cockatrice/src/interface/widgets/deck_share/share_bar_widget.cpp new file mode 100644 index 000000000..5aca343ca --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_share/share_bar_widget.cpp @@ -0,0 +1,77 @@ +#include "share_bar_widget.h" + +#include +#include +#include +#include + +ShareBarWidget::ShareBarWidget(QWidget *parent) : QWidget(parent) +{ + auto *layout = new QHBoxLayout(this); + layout->setContentsMargins(12, 10, 12, 10); + layout->setSpacing(8); + + hintLabel = new QLabel(this); + hintLabel->setWordWrap(true); + + nameEdit = new QLineEdit(this); + nameEdit->setMaximumWidth(260); + + countLabel = new QLabel(this); + + cancelButton = new QPushButton(this); + connect(cancelButton, &QPushButton::clicked, this, &ShareBarWidget::cancelRequested); + + createButton = new QPushButton(this); + createButton->setDefault(true); + connect(createButton, &QPushButton::clicked, this, &ShareBarWidget::createRequested); + + layout->addWidget(hintLabel, 1); + layout->addWidget(nameEdit); + layout->addWidget(countLabel); + layout->addStretch(); + layout->addWidget(cancelButton); + layout->addWidget(createButton); + + setLayout(layout); + + retranslateUi(); +} + +void ShareBarWidget::retranslateUi() +{ + nameEdit->setPlaceholderText(tr("Share name")); + cancelButton->setText(tr("Cancel")); + createButton->setText(tr("Create share link")); +} + +QString ShareBarWidget::name() const +{ + return nameEdit->text().trimmed(); +} + +void ShareBarWidget::setName(const QString &value) +{ + nameEdit->setText(value); +} + +void ShareBarWidget::setCountText(const QString &text) +{ + countLabel->setText(text); +} + +void ShareBarWidget::setHintText(const QString &text, bool visible) +{ + hintLabel->setText(text); + hintLabel->setVisible(visible); +} + +void ShareBarWidget::setCreateEnabled(bool enabled) +{ + createButton->setEnabled(enabled); +} + +void ShareBarWidget::focusName() +{ + nameEdit->setFocus(); +} diff --git a/cockatrice/src/interface/widgets/deck_share/share_bar_widget.h b/cockatrice/src/interface/widgets/deck_share/share_bar_widget.h new file mode 100644 index 000000000..f97cc6788 --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_share/share_bar_widget.h @@ -0,0 +1,65 @@ +/** + * @file share_bar_widget.h + * @ingroup DeckShareWidgets + */ +//! \todo Document this file. + +#ifndef SHARE_BAR_WIDGET_H +#define SHARE_BAR_WIDGET_H + +#include +#include +#include + +class QLabel; +class QLineEdit; +class QPushButton; + +/** + * @brief The activated toolbar used to create a temporary deck share. + * + * A single reusable component shared by the local visual deck storage and the + * remote server deck storage tabs, so the share workflow renders identically in + * both places. It owns its own widgets, strings, and layout; the owning tab only + * sets the count/hint text and reacts to the create/cancel signals. + */ +class ShareBarWidget final : public QWidget +{ + Q_OBJECT + +public: + explicit ShareBarWidget(QWidget *parent = nullptr); + + void retranslateUi(); + + /** @return The trimmed name entered by the user. */ + [[nodiscard]] QString name() const; + + /** @brief Resets the name field to the given default. */ + void setName(const QString &name); + + /** @brief Sets the selected-count summary label text. */ + void setCountText(const QString &text); + + /** @brief Sets the explainer hint text, showing it when @p visible is true. */ + void setHintText(const QString &text, bool visible); + + /** @brief Enables or disables the create-share-link button (guards double submission). */ + void setCreateEnabled(bool enabled); + + /** @brief Moves keyboard focus to the name field. */ + void focusName(); + +signals: + void createRequested(); + void cancelRequested(); + +private: + QLabel *hintLabel; + QLineEdit *nameEdit; + QLabel *countLabel; + QPushButton *cancelButton; + QPushButton *createButton; +}; + +#endif // SHARE_BAR_WIDGET_H diff --git a/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.cpp b/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.cpp new file mode 100644 index 000000000..3bb829089 --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.cpp @@ -0,0 +1,146 @@ +#include "shared_deck_preview_widget.h" + +#include "../cards/additional_info/color_identity_widget.h" +#include "../cards/deck_preview_card_picture_widget.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/utility/card_ref.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +SharedDeckPreviewWidget::SharedDeckPreviewWidget(QWidget *parent, + const CardDatabaseQuerier *querier, + const QString &deckName, + const QString &bannerCardName, + const QString &colorIdentity, + const QString &gameFormat, + const QString &deckToolTip) + : QWidget(parent) +{ + bannerCardDisplayWidget = + new DeckPreviewCardPictureWidget(this, false, false, Qt::white, Qt::black, 12, Qt::AlignCenter, true); + bannerCardDisplayWidget->setScaleFactor(100); + const ExactCard bannerCard = bannerCardName.isEmpty() ? ExactCard() : querier->getCard(CardRef{bannerCardName, {}}); + bannerCardDisplayWidget->setCard(bannerCard); + bannerCardDisplayWidget->setOverlayText(deckName); + setToolTip(deckToolTip.isEmpty() ? deckName : deckToolTip); + setFocusPolicy(Qt::StrongFocus); + setBaseAccessibleName(deckName); + + colorIdentityWidget = new ColorIdentityWidget(this, colorIdentity); + colorIdentityWidget->setVisible(!colorIdentity.isEmpty()); + + // gameFormat is server-supplied and the QLabel renders AutoText, so escape it. + gameFormatLabel = new QLabel(gameFormat.toHtmlEscaped(), this); + gameFormatLabel->setAlignment(Qt::AlignCenter); + gameFormatLabel->setVisible(!gameFormat.isEmpty()); + + selectionCheckBox = new QCheckBox(this); + selectionCheckBox->setToolTip(tr("Select this deck")); + // The tile itself is focusable (Space/Enter toggles); keep the checkbox + // from creating a second tab stop per tile. + selectionCheckBox->setFocusPolicy(Qt::NoFocus); + + // Selection frame reused from the deck-preview selection covenant: a + // palette(highlight) border around the banner card, shown while selected. + selectionFrame = new QFrame(bannerCardDisplayWidget); + selectionFrame->setAttribute(Qt::WA_TransparentForMouseEvents); + selectionFrame->setStyleSheet(QStringLiteral( + "QFrame { border: 2px solid palette(highlight); border-radius: 4px; background: transparent; }")); + selectionFrame->setVisible(false); + + auto *selectionRow = new QHBoxLayout; + selectionRow->addWidget(selectionCheckBox); + selectionRow->addStretch(1); + + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addLayout(selectionRow); + layout->addWidget(bannerCardDisplayWidget, 0, Qt::AlignHCenter); + layout->addWidget(colorIdentityWidget, 0, Qt::AlignHCenter); + layout->addWidget(gameFormatLabel, 0, Qt::AlignHCenter); + setLayout(layout); + + connect(selectionCheckBox, &QCheckBox::toggled, this, [this](bool checked) { + updateSelectionVisual(checked); + emit selectionToggled(checked); + }); + connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageClicked, this, + &SharedDeckPreviewWidget::toggleSelection); + connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageDoubleClicked, this, + &SharedDeckPreviewWidget::activate); +} + +bool SharedDeckPreviewWidget::isSelected() const +{ + return selectionCheckBox->isChecked(); +} + +void SharedDeckPreviewWidget::setSelected(bool selected) +{ + if (isSelected() == selected) { + return; + } + selectionCheckBox->setChecked(selected); +} + +void SharedDeckPreviewWidget::updateSelectionVisual(bool selected) +{ + selectionFrame->setVisible(selected); + selectionFrame->raise(); + if (selected) { + setAccessibleName(baseAccessibleName + tr(" (selected)")); + } else { + setAccessibleName(baseAccessibleName); + } +} + +void SharedDeckPreviewWidget::setBaseAccessibleName(const QString &name) +{ + baseAccessibleName = name; + setAccessibleName(name); +} + +void SharedDeckPreviewWidget::toggleSelection() +{ + setSelected(!isSelected()); +} + +void SharedDeckPreviewWidget::activate() +{ + setSelected(true); + emit activated(); +} + +void SharedDeckPreviewWidget::resizeEvent(QResizeEvent *event) +{ + QWidget::resizeEvent(event); + updateSelectionFrameGeometry(); +} + +void SharedDeckPreviewWidget::updateSelectionFrameGeometry() +{ + if (selectionFrame == nullptr || bannerCardDisplayWidget == nullptr) { + return; + } + selectionFrame->setGeometry(bannerCardDisplayWidget->rect().adjusted(1, 1, -1, -1)); +} + +void SharedDeckPreviewWidget::keyPressEvent(QKeyEvent *event) +{ + if (event->key() == Qt::Key_Space || event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter) { + toggleSelection(); + event->accept(); + return; + } + QWidget::keyPressEvent(event); +} diff --git a/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.h b/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.h new file mode 100644 index 000000000..bff7de743 --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_share/shared_deck_preview_widget.h @@ -0,0 +1,77 @@ +/** + * @file shared_deck_preview_widget.h + * @ingroup DeckShareWidgets + */ +//! \todo Document this file. + +#ifndef SHARED_DECK_PREVIEW_WIDGET_H +#define SHARED_DECK_PREVIEW_WIDGET_H + +#include +#include +#include + +class ColorIdentityWidget; +class DeckPreviewCardPictureWidget; +class QCheckBox; +class QFrame; +class QLabel; +class CardDatabaseQuerier; + +/** + * @brief A selectable preview tile for a deck that has no local file. + * + * Renders a banner card picture (looked up by name in the card database), the + * deck name, color identity and game format. Used to preview decks shared via a + * cockatrice:// link (metadata from Command_DeckShareList) and the deck + * currently open in the deck editor. + * + * Selection follows the deck-preview covenant: the tile reports its click + * immediately (no double-click interval delay), a palette(highlight) frame + * marks the selected tile, and Space/Enter toggles selection from the keyboard. + * A double click selects the tile and emits activated() so the caller can open + * just that deck. + */ +class SharedDeckPreviewWidget : public QWidget +{ + Q_OBJECT + +public: + explicit SharedDeckPreviewWidget(QWidget *parent, + const CardDatabaseQuerier *querier, + const QString &deckName, + const QString &bannerCardName, + const QString &colorIdentity, + const QString &gameFormat = QString(), + const QString &deckToolTip = QString()); + + [[nodiscard]] bool isSelected() const; + void setSelected(bool selected); + + void setBaseAccessibleName(const QString &name); + +signals: + void selectionToggled(bool selected); + void activated(); + +protected: + void resizeEvent(QResizeEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; + +private slots: + void toggleSelection(); + void activate(); + +private: + void updateSelectionVisual(bool selected); + void updateSelectionFrameGeometry(); + + DeckPreviewCardPictureWidget *bannerCardDisplayWidget; + ColorIdentityWidget *colorIdentityWidget; + QLabel *gameFormatLabel; + QCheckBox *selectionCheckBox; + QFrame *selectionFrame; + QString baseAccessibleName; +}; + +#endif // SHARED_DECK_PREVIEW_WIDGET_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp index aa8a916f8..7a7e31a58 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp @@ -1,19 +1,35 @@ #include "dlg_connect.h" #include "../../../client/settings/cache_settings.h" +#include "../../pixel_map_generator.h" +#include "../server/handle_public_servers.h" +#include "../server/user/user_info_connection.h" +#include "libcockatrice/utility/macros.h" #include #include +#include #include #include #include -#include +#include +#include #include +#include #include #include #include +#include +#include +#include +#include +#include +#include #include #include +#include + +class QWidget; DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) { @@ -21,7 +37,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) previousHosts = new QComboBox(this); btnDeleteServer = new QPushButton(this); - btnDeleteServer->setIcon(QPixmap("theme:icons/remove_row")); + btnDeleteServer->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); btnDeleteServer->setToolTip(tr("Delete the currently selected saved server")); btnDeleteServer->setFixedWidth(30); @@ -29,7 +45,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) hps = new HandlePublicServers(this); btnRefreshServers = new QPushButton(this); - btnRefreshServers->setIcon(QPixmap("theme:icons/sync")); + btnRefreshServers->setIcon(themePixmap(QStringLiteral("icons/sync"))); btnRefreshServers->setToolTip(tr("Refresh the server list with known public servers")); btnRefreshServers->setFixedWidth(30); @@ -99,7 +115,7 @@ DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) updateDisplayInfo(previousHosts->currentText()); btnForgotPassword = new QPushButton(this); - btnForgotPassword->setIcon(QPixmap("theme:icons/forgot_password")); + btnForgotPassword->setIcon(themePixmap(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_connect.h b/cockatrice/src/interface/widgets/dialogs/dlg_connect.h index 083dad0ad..10cb5366a 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.h @@ -7,12 +7,13 @@ #ifndef DLG_CONNECT_H #define DLG_CONNECT_H -#include "../interface/widgets/server/handle_public_servers.h" -#include "../interface/widgets/server/user/user_info_connection.h" - #include #include +#include +#include #include +#include +#include class QCheckBox; class QComboBox; @@ -23,6 +24,9 @@ class QLabel; class QPushButton; class QRadioButton; class QVBoxLayout; +class HandlePublicServers; +class QWidget; +class UserConnection_Information; class DlgConnect : public QDialog { diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp index 198fa259b..f2dd8c1e4 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.cpp @@ -1,9 +1,18 @@ #include "dlg_convert_deck_to_cod_format.h" +#include "../../../client/settings/cache_settings.h" +#include "../../deck_loader/deck_file_format.h" + #include #include +#include +#include +#include #include +#include +#include #include +#include DialogConvertDeckToCodFormat::DialogConvertDeckToCodFormat(QWidget *parent) : QDialog(parent) { @@ -38,3 +47,71 @@ bool DialogConvertDeckToCodFormat::dontAskAgain() const { return dontAskAgainCheckbox->isChecked(); } + +namespace +{ + +bool confirmOverwriteIfExists(QWidget *parent, const QString &filePath) +{ + QFileInfo fileInfo(filePath); + QString newFileName = QDir::toNativeSeparators(fileInfo.path() + "/" + fileInfo.completeBaseName() + ".cod"); + + if (QFile::exists(newFileName)) { + QMessageBox::StandardButton reply = + QMessageBox::question(parent, QObject::tr("Overwrite Existing File?"), + QObject::tr("A .cod version of this deck already exists. Overwrite it?"), + QMessageBox::Yes | QMessageBox::No); + return reply == QMessageBox::Yes; + } + return true; // Safe to proceed +} + +} // namespace + +bool DialogConvertDeckToCodFormat::promptIfRequired(QWidget *parent, + const QString &filePath, + const std::function &convert) +{ + if (DeckFileFormat::getFormatFromName(filePath) == DeckFileFormat::Cockatrice) { + return true; + } + + // Retrieve saved preference if the prompt is disabled + if (!SettingsCache::instance().visualDeckStorage().getVisualDeckStoragePromptForConversion()) { + if (!SettingsCache::instance().visualDeckStorage().getVisualDeckStorageAlwaysConvert()) { + return false; + } + + if (!confirmOverwriteIfExists(parent, filePath)) { + return false; + } + + return convert(); + } + + // Show the dialog to the user + DialogConvertDeckToCodFormat conversionDialog(parent); + if (conversionDialog.exec() != QDialog::Accepted) { + SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion( + !conversionDialog.dontAskAgain()); + SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(false); + + return false; + } + + // Try to convert file + if (!confirmOverwriteIfExists(parent, filePath)) { + return false; + } + + if (!convert()) { + return false; + } + + if (conversionDialog.dontAskAgain()) { + SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion(false); + SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(true); + } + + return true; +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.h b/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.h index 6642ad8c6..de2fb9a63 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_convert_deck_to_cod_format.h @@ -8,11 +8,17 @@ #ifndef DIALOG_CONVERT_DECK_TO_COD_FORMAT_H #define DIALOG_CONVERT_DECK_TO_COD_FORMAT_H -#include #include -#include -#include -#include +#include +#include +#include +#include + +class QWidget; +class QCheckBox; +class QDialogButtonBox; +class QLabel; +class QVBoxLayout; class DialogConvertDeckToCodFormat : public QDialog { @@ -24,6 +30,21 @@ public: [[nodiscard]] bool dontAskAgain() const; + /** + * @brief Checks whether the deck file at \a filePath can store tags. + * + * If the file is not a .cod deck, prompts the user for conversion to the + * Cockatrice format, honoring the saved "always convert / don't ask again" + * preference. On acceptance \a convert is called to perform the conversion. + * + * @param parent The widget to parent the prompt to. + * @param filePath The path of the deck file to check. + * @param convert Called to convert the deck once the user agrees. + * @return true if tags can be stored (no conversion needed, or the conversion + * was performed), false if the user declined to convert. + */ + static bool promptIfRequired(QWidget *parent, const QString &filePath, const std::function &convert); + private: QVBoxLayout *layout; QLabel *label; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp index 2b1e9e8c2..104b695e6 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp @@ -2,23 +2,35 @@ #include "../../../client/settings/cache_settings.h" #include "../interface/widgets/tabs/tab_room.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/room_commands.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_user.pb.h" +#include "libcockatrice/utility/macros.h" -#include #include +#include #include #include #include #include #include +#include #include #include #include #include +#include +#include #include +#include #include #include #include #include +#include +#include + +class QWidget; void DlgCreateGame::sharedCtor() { @@ -214,6 +226,7 @@ DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMapsetChecked(gameInfo.spectators_need_password()); spectatorsCanTalkCheckBox->setChecked(gameInfo.spectators_can_chat()); spectatorsSeeEverythingCheckBox->setChecked(gameInfo.spectators_omniscient()); + shareDecklistsOnLoadCheckBox->setChecked(gameInfo.share_decklists_on_load()); QSet types; for (int i = 0; i < gameInfo.game_types_size(); ++i) { diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h index 61925286d..71b09997c 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.h @@ -9,7 +9,9 @@ #include #include +#include #include +#include class QCheckBox; class QDialogButtonBox; @@ -22,6 +24,7 @@ class QSpinBox; class Response; class ServerInfo_Game; class TabRoom; +class QWidget; class DlgCreateGame : public QDialog { diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_default_tags_editor.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_default_tags_editor.cpp index e9c73b025..9eb064beb 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_default_tags_editor.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_default_tags_editor.cpp @@ -2,9 +2,20 @@ #include "../../../client/settings/cache_settings.h" +#include +#include +#include +#include +#include +#include #include #include +#include +#include +#include +#include #include +#include #include DlgDefaultTagsEditor::DlgDefaultTagsEditor(QWidget *parent) : QDialog(parent) diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_default_tags_editor.h b/cockatrice/src/interface/widgets/dialogs/dlg_default_tags_editor.h index e584d0731..081701485 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_default_tags_editor.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_default_tags_editor.h @@ -9,9 +9,15 @@ #define DLG_DEFAULT_TAGS_EDITOR_H #include -#include -#include -#include +#include + +class QHBoxLayout; +class QLineEdit; +class QListWidget; +class QListWidgetItem; +class QPushButton; +class QVBoxLayout; +class QWidget; class DlgDefaultTagsEditor : public QDialog { diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_avatar.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_edit_avatar.cpp index b5710b208..520ece007 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_avatar.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_avatar.cpp @@ -3,12 +3,22 @@ #include #include #include +#include #include +#include +#include #include #include +#include +#include #include +#include +#include #include #include +#include + +class QWidget; DlgEditAvatar::DlgEditAvatar(QWidget *parent) : QDialog(parent), image() { @@ -19,6 +29,9 @@ DlgEditAvatar::DlgEditAvatar(QWidget *parent) : QDialog(parent), image() textLabel = new QLabel(tr("To change your avatar, choose a new image.\nTo remove your current avatar, confirm " "without choosing a new image.")); + hintLabel = new QLabel( + tr("Images are automatically downscaled to at most %1x%1 pixels.").arg(QString::number(MAX_AVATAR_DIMENSION))); + browseButton = new QPushButton(tr("Browse...")); connect(browseButton, &QPushButton::clicked, this, &DlgEditAvatar::actBrowse); @@ -26,6 +39,7 @@ DlgEditAvatar::DlgEditAvatar(QWidget *parent) : QDialog(parent), image() grid->addWidget(imageLabel, 0, 0, 1, 2); grid->addWidget(textLabel, 1, 0); grid->addWidget(browseButton, 1, 1); + grid->addWidget(hintLabel, 2, 0, 1, 2); auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgEditAvatar::actOk); @@ -72,6 +86,10 @@ QByteArray DlgEditAvatar::getImage() return QByteArray(); } + if (image.width() > MAX_AVATAR_DIMENSION || image.height() > MAX_AVATAR_DIMENSION) { + image = image.scaled(MAX_AVATAR_DIMENSION, MAX_AVATAR_DIMENSION, Qt::KeepAspectRatio, Qt::SmoothTransformation); + } + for (;;) { QByteArray ba; QBuffer buffer(&ba); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_avatar.h b/cockatrice/src/interface/widgets/dialogs/dlg_edit_avatar.h index d635ac2a8..4df45711c 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_avatar.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_avatar.h @@ -7,16 +7,18 @@ #ifndef DLG_EDITAVATAR_H #define DLG_EDITAVATAR_H -#include +#include #include -#include +#include #include +#include + +class QWidget; inline Q_LOGGING_CATEGORY(DlgEditAvatarLog, "dlg_edit_avatar"); class QLabel; class QPushButton; -class QCheckBox; class DlgEditAvatar : public QDialog { @@ -29,8 +31,9 @@ private slots: void actBrowse(); private: + static constexpr int MAX_AVATAR_DIMENSION = 1024; QImage image; - QLabel *textLabel, *imageLabel; + QLabel *textLabel, *imageLabel, *hintLabel; QPushButton *browseButton; }; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_password.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_edit_password.cpp index 4310c03fc..59f80c79c 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_password.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_password.cpp @@ -4,12 +4,17 @@ #include #include -#include #include #include +#include +#include +#include #include #include +class QVBoxLayout; +class QWidget; + DlgEditPassword::DlgEditPassword(QWidget *parent) : QDialog(parent) { oldPasswordLabel = new QLabel(tr("Old password:")); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_password.h b/cockatrice/src/interface/widgets/dialogs/dlg_edit_password.h index 85c72233d..e80ede3f3 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_password.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_password.h @@ -9,10 +9,11 @@ #include #include +#include +#include class QLabel; -class QPushButton; -class QCheckBox; +class QWidget; class DlgEditPassword : public QDialog { diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp index f249976c2..cbf61a394 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp @@ -1,25 +1,42 @@ #include "dlg_edit_tokens.h" +#include "../../pixel_map_generator.h" #include "../interface/widgets/utility/get_text_with_max.h" +#include "card_database_display_model.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/printing_info.h" +#include "libcockatrice/card/set/card_set.h" #include +#include #include #include #include #include #include +#include #include -#include +#include #include #include #include +#include +#include +#include #include #include +#include +#include +#include #include #include #include #include #include +#include + +class QWidget; DlgEditTokens::DlgEditTokens(QWidget *parent) : QDialog(parent), currentCard(nullptr) { @@ -90,10 +107,10 @@ DlgEditTokens::DlgEditTokens(QWidget *parent) : QDialog(parent), currentCard(nul &DlgEditTokens::tokenSelectionChanged); QAction *aAddToken = new QAction(tr("Add token"), this); - aAddToken->setIcon(QPixmap("theme:icons/increment")); + aAddToken->setIcon(themePixmap(QStringLiteral("icons/increment"))); connect(aAddToken, &QAction::triggered, this, &DlgEditTokens::actAddToken); QAction *aRemoveToken = new QAction(tr("Remove token"), this); - aRemoveToken->setIcon(QPixmap("theme:icons/decrement")); + aRemoveToken->setIcon(themePixmap(QStringLiteral("icons/decrement"))); connect(aRemoveToken, &QAction::triggered, this, &DlgEditTokens::actRemoveToken); auto *databaseToolBar = new QToolBar; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.h b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.h index 202212e98..cde89d22b 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.h @@ -8,7 +8,11 @@ #define DLG_EDIT_TOKENS_H #include +#include +#include +#include #include +#include class QModelIndex; class CardDatabaseModel; @@ -17,6 +21,7 @@ class QLabel; class QComboBox; class QLineEdit; class QTreeView; +class QWidget; class DlgEditTokens : public QDialog { diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_user.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_edit_user.cpp index 85b95f335..ffe8391a0 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_user.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_user.cpp @@ -4,10 +4,18 @@ #include #include -#include #include +#include +#include +#include +#include +#include +#include #include +class QVBoxLayout; +class QWidget; + DlgEditUser::DlgEditUser(QWidget *parent, QString email, QString country, QString realName) : QDialog(parent) { emailLabel = new QLabel(tr("Email:")); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_user.h b/cockatrice/src/interface/widgets/dialogs/dlg_edit_user.h index 35a8bfb46..0385c2bbe 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_user.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_user.h @@ -10,10 +10,11 @@ #include #include #include +#include +#include class QLabel; -class QPushButton; -class QCheckBox; +class QWidget; class DlgEditUser : public QDialog { diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_filter_games.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_filter_games.cpp index 8f498cc2c..cd3c3893e 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_filter_games.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_filter_games.cpp @@ -1,15 +1,25 @@ #include "dlg_filter_games.h" +#include "../server/games_model.h" + #include #include -#include #include #include #include +#include #include #include -#include +#include +#include +#include +#include #include +#include +#include +#include + +class QWidget; DlgFilterGames::DlgFilterGames(const QMap &_allGameTypes, const GamesProxyModel *_gamesProxyModel, @@ -30,7 +40,7 @@ DlgFilterGames::DlgFilterGames(const QMap &_allGameTypes, hideFullGames = new QCheckBox(tr("Hide full games")); hideFullGames->setChecked(filters.hideFullGames); - hideGamesThatStarted = new QCheckBox(tr("Hide games that have started")); + hideGamesThatStarted = new QCheckBox(tr("Hide started games")); hideGamesThatStarted->setChecked(filters.hideGamesThatStarted); hidePasswordProtectedGames = new QCheckBox(tr("Hide password protected games")); @@ -57,16 +67,16 @@ DlgFilterGames::DlgFilterGames(const QMap &_allGameTypes, gameNameFilterEdit->setText(filters.gameNameFilter); auto *gameNameFilterLabel = new QLabel(tr("Game &description:")); gameNameFilterLabel->setBuddy(gameNameFilterEdit); - creatorNameFilterEdit = new QLineEdit; - creatorNameFilterEdit->setText(filters.creatorNameFilters.join(", ")); - auto *creatorNameFilterLabel = new QLabel(tr("&Creator name:")); - creatorNameFilterLabel->setBuddy(creatorNameFilterEdit); + hostNameFilterEdit = new QLineEdit; + hostNameFilterEdit->setText(filters.hostNameFilters.join(", ")); + auto *hostNameFilterLabel = new QLabel(tr("&Host name:")); + hostNameFilterLabel->setBuddy(hostNameFilterEdit); auto *generalGrid = new QGridLayout; generalGrid->addWidget(gameNameFilterLabel, 0, 0); generalGrid->addWidget(gameNameFilterEdit, 0, 1); - generalGrid->addWidget(creatorNameFilterLabel, 1, 0); - generalGrid->addWidget(creatorNameFilterEdit, 1, 1); + generalGrid->addWidget(hostNameFilterLabel, 1, 0); + generalGrid->addWidget(hostNameFilterEdit, 1, 1); generalGrid->addWidget(maxGameAgeLabel, 2, 0); generalGrid->addWidget(maxGameAgeComboBox, 2, 1); generalGroupBox = new QGroupBox(tr("General")); @@ -193,7 +203,7 @@ GameFilterConfigs DlgFilterGames::getFilters() const hideNotBuddyCreatedGames->isChecked(), hideOpenDecklistGames->isChecked(), gameNameFilterEdit->text(), - getCreatorNameFilters(), + getHostNameFilters(), getGameTypeFilter(), maxPlayersFilterMinSpinBox->value(), maxPlayersFilterMaxSpinBox->value(), @@ -216,9 +226,9 @@ void DlgFilterGames::toggleSpectatorCheckboxEnabledness(bool spectatorsEnabled) showOnlyIfSpectatorsCanSeeHands->setDisabled(!spectatorsEnabled); } -QStringList DlgFilterGames::getCreatorNameFilters() const +QStringList DlgFilterGames::getHostNameFilters() const { - return creatorNameFilterEdit->text().split(",", Qt::SkipEmptyParts); + return hostNameFilterEdit->text().split(",", Qt::SkipEmptyParts); } QSet DlgFilterGames::getGameTypeFilter() const diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_filter_games.h b/cockatrice/src/interface/widgets/dialogs/dlg_filter_games.h index 447f9b16c..d7c11d0f1 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_filter_games.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_filter_games.h @@ -7,20 +7,23 @@ #ifndef DLG_FILTER_GAMES_H #define DLG_FILTER_GAMES_H -#include "../interface/widgets/server/games_model.h" +#include "../server/game_filter_configs.h" -#include -#include #include #include #include +#include +#include #include +#include class QCheckBox; class QComboBox; class QGroupBox; class QLineEdit; class QSpinBox; +class GamesProxyModel; +class QWidget; class DlgFilterGames : public QDialog { @@ -35,7 +38,7 @@ private: QCheckBox *hideNotBuddyCreatedGames; QCheckBox *hideOpenDecklistGames; QLineEdit *gameNameFilterEdit; - QLineEdit *creatorNameFilterEdit; + QLineEdit *hostNameFilterEdit; QMap gameTypeFilterCheckBoxes; QSpinBox *maxPlayersFilterMinSpinBox; QSpinBox *maxPlayersFilterMaxSpinBox; @@ -50,7 +53,7 @@ private: const GamesProxyModel *gamesProxyModel; const QMap gameAgeMap; - [[nodiscard]] QStringList getCreatorNameFilters() const; + [[nodiscard]] QStringList getHostNameFilters() const; [[nodiscard]] QSet getGameTypeFilter() const; [[nodiscard]] QTime getMaxGameAge() const; [[nodiscard]] bool getShowSpectatorPasswordProtected() const; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_challenge.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_challenge.cpp index c21955373..3ee74faab 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_challenge.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_challenge.cpp @@ -4,12 +4,16 @@ #include #include -#include +#include #include #include +#include +#include #include #include +class QWidget; + DlgForgotPasswordChallenge::DlgForgotPasswordChallenge(QWidget *parent) : QDialog(parent) { QString lastfphost; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_challenge.h b/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_challenge.h index d32e2fc3e..f5678af17 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_challenge.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_challenge.h @@ -7,13 +7,13 @@ #ifndef DLG_FORGOTPASSWORDCHALLENGE_H #define DLG_FORGOTPASSWORDCHALLENGE_H -#include #include #include +#include +#include class QLabel; -class QPushButton; -class QCheckBox; +class QWidget; class DlgForgotPasswordChallenge : public QDialog { diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_request.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_request.cpp index 4ee0c9c88..0cb1773b6 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_request.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_request.cpp @@ -4,12 +4,16 @@ #include #include -#include +#include #include #include +#include +#include #include #include +class QWidget; + DlgForgotPasswordRequest::DlgForgotPasswordRequest(QWidget *parent) : QDialog(parent) { QString lastfphost; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_request.h b/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_request.h index 2cf352e0d..ff255c748 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_request.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_request.h @@ -7,13 +7,13 @@ #ifndef DLG_FORGOTPASSWORDREQUEST_H #define DLG_FORGOTPASSWORDREQUEST_H -#include #include #include +#include +#include class QLabel; -class QPushButton; -class QCheckBox; +class QWidget; class DlgForgotPasswordRequest : public QDialog { diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_reset.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_reset.cpp index 2800d5c0c..6c1b95b43 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_reset.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_reset.cpp @@ -4,12 +4,18 @@ #include #include -#include +#include #include #include +#include +#include +#include #include #include +class QVBoxLayout; +class QWidget; + DlgForgotPasswordReset::DlgForgotPasswordReset(QWidget *parent) : QDialog(parent) { QString lastfphost; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_reset.h b/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_reset.h index 8a443b906..995d67359 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_reset.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_reset.h @@ -7,13 +7,13 @@ #ifndef DLG_FORGOTPASSWORDRESET_H #define DLG_FORGOTPASSWORDRESET_H -#include #include #include +#include +#include class QLabel; -class QPushButton; -class QCheckBox; +class QWidget; class DlgForgotPasswordReset : public QDialog { diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_invite_to_game.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_invite_to_game.cpp new file mode 100644 index 000000000..cf503856f --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_invite_to_game.cpp @@ -0,0 +1,117 @@ +#include "dlg_invite_to_game.h" + +#include "../server/user/user_list_manager.h" +#include "../server/user/user_list_widget.h" +#include "../tabs/tab_supervisor.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class QWidget; + +DlgInviteToGame::DlgInviteToGame(TabSupervisor *_tabSupervisor, + const QString &_inviteUrl, + bool _onlyBuddies, + const QStringList &_excludeUserNames, + QWidget *parent) + : QDialog(parent), tabSupervisor(_tabSupervisor), inviteUrl(_inviteUrl), onlyBuddies(_onlyBuddies), + excludeUserNames(_excludeUserNames) +{ + setModal(true); + + searchEdit = new QLineEdit(this); + searchEdit->setClearButtonEnabled(true); + connect(searchEdit, &QLineEdit::textChanged, this, &DlgInviteToGame::searchTextChanged); + + // The embedded list is the real room user list without the hover popup: + // same manager, same delegate/painter, same sections, live via manager + // signals while the modal loop runs. + UserListManager *manager = tabSupervisor->getUserListManager(); + userList = new UserListWidget(tabSupervisor, tabSupervisor->getClient(), UserListWidget::RoomList, this, + /*hasUserInfoPopup=*/false); + userList->setUserFilter([this, manager](const QString &name, bool online) { + return !excludeUserNames.contains(name) && online && !manager->isUserIgnored(name); + }); + if (onlyBuddies) { + userList->setSectioned({UserListWidget::Section::Buddy}); + } else { + userList->setSectioned({UserListWidget::Section::Buddy, UserListWidget::Section::Online}); + } + userList->bind(manager); + userList->rebuild(); + + connect(userList, &UserListWidget::userActivated, this, &DlgInviteToGame::inviteCurrentUser); + connect(userList, &UserListWidget::currentUserChanged, this, [this](const QString &userName) { + currentUserName = userName; + inviteButton->setEnabled(!userName.isEmpty()); + }); + + inviteButton = new QPushButton(this); + inviteButton->setEnabled(false); + inviteButton->setDefault(true); + connect(inviteButton, &QPushButton::clicked, this, [this] { inviteCurrentUser(currentUserName); }); + + cancelButton = new QPushButton(this); + connect(cancelButton, &QPushButton::clicked, this, &QDialog::reject); + + auto *buttonRow = new QHBoxLayout; + buttonRow->addStretch(); + buttonRow->addWidget(inviteButton); + buttonRow->addWidget(cancelButton); + + auto *layout = new QVBoxLayout(this); + layout->addWidget(searchEdit); + layout->addWidget(userList, 1); + layout->addLayout(buttonRow); + + retranslateUi(); + + // Default to a comfortably tall dialog so the list has room to breathe, + // capped by the available screen. No minimum is enforced: small screens + // and manual resizing can go shorter than this. + const QRect availableScreen = QGuiApplication::primaryScreen()->availableGeometry(); + resize(sizeHint().width(), qMin(sizeHint().height() * 3, availableScreen.height() * 4 / 5)); +} + +void DlgInviteToGame::searchTextChanged(const QString &text) +{ + userList->setFilterText(text); +} + +void DlgInviteToGame::inviteCurrentUser(const QString &userName) +{ + if (userName.isEmpty()) { + return; + } + // The invite link carries the game's id and, when the game has one, its + // description (makeGameJoinLink embeds both). Read them back so the prefix + // names the game by description first, then its id — identical to the + // context-menu invite so recipients see one consistent message style. + const QUrl inviteUrlObj(inviteUrl); + const QUrlQuery inviteQuery(inviteUrlObj); + const int gameId = inviteQuery.queryItemValue("gameid").toInt(); + const QString gameDescription = inviteQuery.queryItemValue("game"); + const QString prefix = gameDescription.isEmpty() + ? tr("Join my game (#%1):").arg(gameId) + : tr("Join my game \"%1\" (#%2):").arg(gameDescription).arg(gameId); + tabSupervisor->sendInviteToUser(userName, prefix + " " + inviteUrl); + accept(); +} + +void DlgInviteToGame::retranslateUi() +{ + setWindowTitle(tr("Invite to Game")); + searchEdit->setPlaceholderText(tr("Search users...")); + inviteButton->setText(tr("Invite")); + cancelButton->setText(tr("Cancel")); +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_invite_to_game.h b/cockatrice/src/interface/widgets/dialogs/dlg_invite_to_game.h new file mode 100644 index 000000000..cfcc48733 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_invite_to_game.h @@ -0,0 +1,50 @@ +/** + * @file dlg_invite_to_game.h + * @ingroup RoomDialogs + */ +//! \todo Document this file. + +#ifndef DLG_INVITE_TO_GAME_H +#define DLG_INVITE_TO_GAME_H + +#include +#include +#include +#include +#include + +class QLineEdit; +class QPushButton; +class TabSupervisor; +class UserListWidget; +class QWidget; + +class DlgInviteToGame : public QDialog +{ + Q_OBJECT +public: + DlgInviteToGame(TabSupervisor *_tabSupervisor, + const QString &_inviteUrl, + bool _onlyBuddies, + const QStringList &_excludeUserNames, + QWidget *parent = nullptr); + +private slots: + void searchTextChanged(const QString &text); + void inviteCurrentUser(const QString &userName); + +private: + TabSupervisor *tabSupervisor; + QString inviteUrl; + bool onlyBuddies; + QStringList excludeUserNames; + QString currentUserName; + QLineEdit *searchEdit; + UserListWidget *userList; + QPushButton *inviteButton; + QPushButton *cancelButton; + + void retranslateUi(); +}; + +#endif diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck.cpp index 626ebed50..cbf24db87 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck.cpp @@ -3,8 +3,13 @@ #include "../../../client/settings/cache_settings.h" #include "../../deck_loader/deck_loader.h" +#include +#include #include #include + +class QWidget; + DlgLoadDeck::DlgLoadDeck(QWidget *parent) : QFileDialog(parent, tr("Load Deck")) { QString startingDir = SettingsCache::instance().recents().getLatestDeckDirPath(); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck.h b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck.h index 27d658902..5ba07c01d 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck.h @@ -9,6 +9,9 @@ #define DLG_LOAD_DECK_H #include +#include + +class QWidget; /** * The file dialog for "Load Deck" operations. diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp index a3242c797..2c0fd3f6f 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.cpp @@ -9,12 +9,19 @@ #include #include #include +#include +#include +#include #include #include #include #include #include +#include #include +#include + +class QWidget; /** * Creates the main layout and connects the signals that are common to all versions of this window diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.h b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.h index dce9d712c..f3cbb7b62 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_clipboard.h @@ -8,13 +8,16 @@ #ifndef DLG_LOAD_DECK_FROM_CLIPBOARD_H #define DLG_LOAD_DECK_FROM_CLIPBOARD_H -#include "../../deck_loader/loaded_deck.h" +#include "libcockatrice/deck_list/deck_list.h" -#include #include +#include +#include class QPlainTextEdit; class QPushButton; +class QCheckBox; +class QWidget; /** * Base class for dialog windows for actions that involve loading decks from text input. diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp index ba00873d9..88b7e2bb3 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_website.cpp @@ -1,16 +1,37 @@ #include "dlg_load_deck_from_website.h" +#include "../../deck_loader/card_node_function.h" +#include "parsers/deck_link_to_api_transformer.h" +#include "parsers/interface_json_deck_parser.h" + #include +#include #include +#include #include #include #include #include +#include +#include +#include #include +#include +#include +#include #include +#include +#include +#include +#include +#include +#include #include +#include #include +class QWidget; + DlgLoadDeckFromWebsite::DlgLoadDeckFromWebsite(QWidget *parent) : QDialog(parent) { nam = new QNetworkAccessManager(this); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_website.h b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_website.h index 2890148b9..3f6e759f2 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_website.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck_from_website.h @@ -8,15 +8,21 @@ #ifndef DLG_LOAD_DECK_FROM_WEBSITE_H #define DLG_LOAD_DECK_FROM_WEBSITE_H -#include "../client/network/parsers/deck_link_to_api_transformer.h" #include "../client/network/parsers/interface_json_deck_parser.h" +#include "libcockatrice/deck_list/deck_list.h" #include -#include -#include #include -#include -#include +#include +#include + +class QLabel; +class QLineEdit; +class QNetworkAccessManager; +class QVBoxLayout; +class QWidget; +enum class DeckProvider; +template class QSharedPointer; inline Q_LOGGING_CATEGORY(DlgLoadDeckFromWebsiteLog, "dlg_load_deck_from_website"); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_load_remote_deck.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_load_remote_deck.cpp index ffe08bec9..a56009fe4 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_load_remote_deck.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_load_remote_deck.cpp @@ -3,9 +3,13 @@ #include "../interface/widgets/server/remote/remote_decklist_tree_widget.h" #include +#include #include +#include #include +class QWidget; + DlgLoadRemoteDeck::DlgLoadRemoteDeck(AbstractClient *_client, QWidget *parent) : QDialog(parent), client(_client) { dirView = new RemoteDeckList_TreeWidget(client); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_load_remote_deck.h b/cockatrice/src/interface/widgets/dialogs/dlg_load_remote_deck.h index 9c81e9ae3..cfec16d90 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_load_remote_deck.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_load_remote_deck.h @@ -9,12 +9,13 @@ #define DLG_STARTGAME_H #include +#include class RemoteDeckList_TreeWidget; class QModelIndex; class AbstractClient; -class QPushButton; class QDialogButtonBox; +class QWidget; class DlgLoadRemoteDeck : public QDialog { diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_local_game_options.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_local_game_options.cpp index eca0ec658..858deb751 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_local_game_options.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_local_game_options.cpp @@ -7,10 +7,13 @@ #include #include #include +#include #include #include #include +class QWidget; + DlgLocalGameOptions::DlgLocalGameOptions(QWidget *parent) : QDialog(parent) { numberPlayersLabel = new QLabel(tr("Players:"), this); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_local_game_options.h b/cockatrice/src/interface/widgets/dialogs/dlg_local_game_options.h index 4307581a4..0725a08db 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_local_game_options.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_local_game_options.h @@ -11,6 +11,9 @@ #define DLG_LOCAL_GAME_OPTIONS_H #include +#include + +class QWidget; struct LocalGameOptions { diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.cpp new file mode 100644 index 000000000..0a18a759f --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.cpp @@ -0,0 +1,52 @@ +#include "dlg_login_prompt.h" + +#include +#include +#include +#include +#include +#include + +class QWidget; + +DlgLoginPrompt::DlgLoginPrompt(const QString &serverText, QWidget *parent) : QDialog(parent) +{ + setWindowTitle(tr("Sign in")); + + auto *mainLayout = new QVBoxLayout(this); + mainLayout->addWidget( + new QLabel(tr("This link requires you to be signed in.\nSign in to %1:").arg(serverText), this)); + + auto *formLayout = new QFormLayout; + usernameEdit = new QLineEdit(this); + passwordEdit = new QLineEdit(this); + passwordEdit->setEchoMode(QLineEdit::Password); + formLayout->addRow(tr("Username:"), usernameEdit); + formLayout->addRow(tr("Password:"), passwordEdit); + mainLayout->addLayout(formLayout); + + savePasswordCheckBox = new QCheckBox(tr("Save password for this server"), this); + mainLayout->addWidget(savePasswordCheckBox); + + auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + connect(buttonBox, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + mainLayout->addWidget(buttonBox); + + usernameEdit->setFocus(); +} + +QString DlgLoginPrompt::username() const +{ + return usernameEdit->text().trimmed(); +} + +QString DlgLoginPrompt::password() const +{ + return passwordEdit->text(); +} + +bool DlgLoginPrompt::savePassword() const +{ + return savePasswordCheckBox->isChecked(); +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.h b/cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.h new file mode 100644 index 000000000..47bf3bcf4 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_login_prompt.h @@ -0,0 +1,43 @@ +/** + * @file dlg_login_prompt.h + * @ingroup ConnectionDialogs + */ +//! \todo Document this file. + +#ifndef DLG_LOGIN_PROMPT_H +#define DLG_LOGIN_PROMPT_H + +#include +#include +#include + +class QCheckBox; +class QLineEdit; +class QWidget; + +/** + * @brief Small sign-in dialog used when a cockatrice:// link needs credentials + * that are not saved for the target server. + * + * The entered name and password are handed to the intent chain; when the user + * opts to save them, they are stored in the server settings so that later links + * to the same server connect seamlessly. + */ +class DlgLoginPrompt : public QDialog +{ + Q_OBJECT + +public: + explicit DlgLoginPrompt(const QString &serverText, QWidget *parent = nullptr); + + [[nodiscard]] QString username() const; + [[nodiscard]] QString password() const; + [[nodiscard]] bool savePassword() const; + +private: + QLineEdit *usernameEdit; + QLineEdit *passwordEdit; + QCheckBox *savePasswordCheckBox; +}; + +#endif // DLG_LOGIN_PROMPT_H diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp index 7c107eb2f..dbfe38e26 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp @@ -1,23 +1,45 @@ #include "dlg_manage_sets.h" #include "../../../client/settings/cache_settings.h" +#include "../../pixel_map_generator.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" +#include +#include +#include #include +#include #include +#include #include #include #include #include #include +#include #include +#include #include +#include +#include +#include #include +#include +#include +#include +#include +#include +#include #include #include +#include #include +#include #include +#include +#include #include #include #include @@ -35,28 +57,28 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent) setsEditToolBar->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); aTop = new QAction(QString(), this); - aTop->setIcon(QPixmap("theme:icons/arrow_top_green")); + aTop->setIcon(themePixmap(QStringLiteral("icons/arrow_top_green"))); 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(QPixmap("theme:icons/arrow_up_green")); + aUp->setIcon(themePixmap(QStringLiteral("icons/arrow_up_green"))); 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(QPixmap("theme:icons/arrow_down_green")); + aDown->setIcon(themePixmap(QStringLiteral("icons/arrow_down_green"))); 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(QPixmap("theme:icons/arrow_bottom_green")); + aBottom->setIcon(themePixmap(QStringLiteral("icons/arrow_bottom_green"))); aBottom->setToolTip(tr("Move selected set to the bottom")); aBottom->setEnabled(false); connect(aBottom, &QAction::triggered, this, &WndSets::actBottom); @@ -66,7 +88,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(QPixmap("theme:icons/search"), LineEditUnfocusable::LeadingPosition); + searchField->addAction(themePixmap(QStringLiteral("icons/search")), LineEditUnfocusable::LeadingPosition); searchField->setClearButtonEnabled(true); setFocusProxy(searchField); @@ -156,12 +178,6 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent) sortWarning->setLayout(sortWarningLayout); sortWarning->setVisible(false); - includeRebalancedCards = SettingsCache::instance().cardsDisplay().getIncludeRebalancedCards(); - QCheckBox *includeRebalancedCardsCheckBox = - new QCheckBox(tr("Include cards rebalanced for Alchemy [requires restart]")); - includeRebalancedCardsCheckBox->setChecked(includeRebalancedCards); - connect(includeRebalancedCardsCheckBox, &QAbstractButton::toggled, this, &WndSets::includeRebalancedCardsChanged); - buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); connect(buttonBox, &QDialogButtonBox::accepted, this, &WndSets::actSave); connect(buttonBox, &QDialogButtonBox::rejected, this, &WndSets::actRestore); @@ -175,9 +191,8 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent) mainLayout->addWidget(enableSomeButton, 2, 1); mainLayout->addWidget(disableSomeButton, 2, 2); mainLayout->addWidget(sortWarning, 3, 1, 1, 2); - mainLayout->addWidget(includeRebalancedCardsCheckBox, 4, 1, 1, 2); - mainLayout->addWidget(hintsGroupBox, 5, 1, 1, 2); - mainLayout->addWidget(buttonBox, 6, 1, 1, 2); + mainLayout->addWidget(hintsGroupBox, 4, 1, 1, 2); + mainLayout->addWidget(buttonBox, 5, 1, 1, 2); mainLayout->setColumnStretch(1, 1); mainLayout->setColumnStretch(2, 1); @@ -248,15 +263,9 @@ void WndSets::resetSort() sortWarning->setVisible(false); } -void WndSets::includeRebalancedCardsChanged(bool _includeRebalancedCards) -{ - includeRebalancedCards = _includeRebalancedCards; -} - void WndSets::actSave() { model->save(CardDatabaseManager::getInstance()); - SettingsCache::instance().cardsDisplay().setIncludeRebalancedCards(includeRebalancedCards); CardPictureLoader::clearPixmapCache(); const auto reloadOk1 = QtConcurrent::run([] { CardDatabaseManager::getInstance()->reloadCardDatabasesAndNotify(); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.h b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.h index 2ed5eca0f..67b4c90b1 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.h @@ -7,12 +7,11 @@ #ifndef DLG_MANAGE_SETS_H #define DLG_MANAGE_SETS_H -#include -#include -#include #include +#include +#include +#include -class CardDatabase; class LineEditUnfocusable; class QGroupBox; class QItemSelection; @@ -20,7 +19,14 @@ class QPushButton; class QTreeView; class SetsDisplayModel; class SetsModel; -class SetsProxyModel; +class QAction; +class QDialogButtonBox; +class QGridLayout; +class QHBoxLayout; +class QLabel; +class QToolBar; +class QWidget; +template class QSet; class WndSets : public QMainWindow { @@ -46,7 +52,6 @@ private: int sortIndex; Qt::SortOrder sortOrder; bool setOrderIsSorted; - bool includeRebalancedCards; enum { NO_SETS_SELECTED, @@ -81,7 +86,6 @@ private slots: void actDisableResetButton(const QString &filterText); void actSort(int index); void actIgnoreWarning(); - void includeRebalancedCardsChanged(bool _includeRebalancedCardsChanged); }; #endif diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp new file mode 100644 index 000000000..52c9eaef0 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp @@ -0,0 +1,307 @@ +#include "dlg_my_reports.h" + +#include "../utility/report_utils.h" +#include "abstract_client.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_report.pb.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class QWidget; + +namespace +{ +constexpr int COL_ID = 0; +constexpr int COL_TIME = 1; +constexpr int COL_REPORTED = 2; +constexpr int COL_CATEGORY = 3; +constexpr int COL_GAMEID = 4; +constexpr int COL_STATUS = 5; +constexpr int COL_ASSIGNED = 6; +constexpr int COL_COUNT = 7; +} // namespace + +DlgMyReports::DlgMyReports(AbstractClient *_client, QWidget *parent) + : QDialog(parent), client(_client), selectedReportId(-1) +{ + setWindowTitle(tr("My Reports")); + setMinimumSize(800, 500); + + table = new QTableWidget(0, COL_COUNT); + table->setHorizontalHeaderLabels( + {tr("#"), tr("Time"), tr("Reported User"), tr("Category"), tr("Game ID"), tr("Status"), tr("Assigned To")}); + table->setSelectionBehavior(QAbstractItemView::SelectRows); + table->setSelectionMode(QAbstractItemView::SingleSelection); + table->setEditTriggers(QAbstractItemView::NoEditTriggers); + table->setSortingEnabled(true); + table->verticalHeader()->setVisible(false); + table->setAlternatingRowColors(true); + table->horizontalHeader()->setSectionResizeMode(COL_TIME, QHeaderView::ResizeToContents); + table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch); + connect(table, &QTableWidget::itemSelectionChanged, this, &DlgMyReports::onSelectionChanged); + + auto *detailsGroup = new QGroupBox(tr("Report Details")); + descriptionEdit = new QTextEdit; + descriptionEdit->setReadOnly(true); + descriptionEdit->setFixedHeight(80); + + auto *chatGroup = new QGroupBox(tr("Chat Log Context")); + chatLogEdit = new QTextEdit; + chatLogEdit->setReadOnly(true); + QFont monoFont("monospace"); + monoFont.setStyleHint(QFont::Monospace); + const int systemPointSize = QFontDatabase::systemFont(QFontDatabase::GeneralFont).pointSize(); + if (systemPointSize > 0) { + monoFont.setPointSize(systemPointSize); + } + chatLogEdit->setFont(monoFont); + auto *chatLayout = new QVBoxLayout(chatGroup); + chatLayout->setContentsMargins(4, 4, 4, 4); + chatLayout->addWidget(chatLogEdit); + + auto *commentsLabel = new QLabel(tr("Comments:")); + commentsEdit = new QTextEdit; + commentsEdit->setReadOnly(true); + commentsEdit->setFixedHeight(120); + + auto *addCommentLabel = new QLabel(tr("Add a comment:")); + commentInput = new QLineEdit; + commentInput->setPlaceholderText(tr("Type your comment here...")); + commentButton = new QPushButton(tr("Send")); + commentButton->setEnabled(false); + connect(commentButton, &QPushButton::clicked, this, &DlgMyReports::addComment); + connect(commentInput, &QLineEdit::returnPressed, this, &DlgMyReports::addComment); + + auto *detailsLayout = new QVBoxLayout(detailsGroup); + detailsLayout->setContentsMargins(4, 4, 4, 4); + detailsLayout->addWidget(descriptionEdit); + detailsLayout->addWidget(chatGroup); + detailsLayout->addWidget(commentsLabel); + detailsLayout->addWidget(commentsEdit); + detailsLayout->addWidget(addCommentLabel); + auto *commentRow = new QHBoxLayout; + commentRow->addWidget(commentInput); + commentRow->addWidget(commentButton); + detailsLayout->addLayout(commentRow); + + closeButton = new QPushButton(tr("Close")); + connect(closeButton, &QPushButton::clicked, this, &QDialog::accept); + + refreshButton = new QPushButton(tr("Refresh")); + connect(refreshButton, &QPushButton::clicked, this, &DlgMyReports::refreshList); + + statusLabel = new QLabel; + + auto *bottomBar = new QHBoxLayout; + bottomBar->addWidget(statusLabel); + bottomBar->addStretch(); + bottomBar->addWidget(refreshButton); + bottomBar->addWidget(closeButton); + + auto *layout = new QVBoxLayout(this); + layout->addWidget(table, 1); + layout->addWidget(detailsGroup); + layout->addLayout(bottomBar); + + setActionsEnabled(false); + refreshList(); +} + +void DlgMyReports::refreshList() +{ + selectedReportIdBeforeRefresh = selectedReportId; + commentDraftBeforeRefresh = commentInput->text(); + statusLabel->setText(tr("Loading...")); + refreshButton->setEnabled(false); + table->setRowCount(0); + currentReports.clear(); + descriptionEdit->clear(); + chatLogEdit->clear(); + commentsEdit->clear(); + commentInput->clear(); + setActionsEnabled(false); + selectedReportId = -1; + + Command_ReportMyList cmd; + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &DlgMyReports::reportListResponse); + client->sendCommand(pend); +} + +void DlgMyReports::reportListResponse(const Response &response) +{ + refreshButton->setEnabled(true); + + if (response.response_code() != Response::RespOk) { + statusLabel->setText(tr("Failed to load reports.")); + return; + } + + const Response_ReportMyList &resp = response.GetExtension(Response_ReportMyList::ext); + currentReports.clear(); + for (int i = 0; i < resp.reports_size(); ++i) { + currentReports.append(resp.reports(i)); + } + + table->setSortingEnabled(false); + table->setRowCount(currentReports.size()); + + for (int row = 0; row < currentReports.size(); ++row) { + const ServerInfo_Report &r = currentReports[row]; + + report_utils::fillReportTableRow(table, row, r, COL_ID, COL_TIME, COL_REPORTED, COL_CATEGORY, COL_GAMEID, + COL_STATUS, COL_ASSIGNED); + } + + table->setSortingEnabled(true); + table->resizeColumnsToContents(); + table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch); + + if (selectedReportIdBeforeRefresh >= 0) { + for (int row = 0; row < table->rowCount(); ++row) { + if (table->item(row, COL_ID) && + table->item(row, COL_ID)->data(Qt::UserRole).toInt() == selectedReportIdBeforeRefresh) { + table->setCurrentCell(row, 0); + break; + } + } + } + + if (commentInput->text().isEmpty()) { + commentInput->setText(commentDraftBeforeRefresh); + } + + statusLabel->setText(tr("%1 report(s)").arg(currentReports.size())); +} + +void DlgMyReports::onSelectionChanged() +{ + const int row = table->currentRow(); + if (row < 0 || !table->item(row, COL_ID)) { + descriptionEdit->clear(); + chatLogEdit->clear(); + commentsEdit->clear(); + commentInput->clear(); + commentButton->setEnabled(false); + selectedReportId = -1; + return; + } + + const int reportId = table->item(row, COL_ID)->data(Qt::UserRole).toInt(); + selectedReportId = reportId; + + for (const ServerInfo_Report &r : currentReports) { + if (r.report_id() == reportId) { + descriptionEdit->setPlainText(QString::fromStdString(r.description())); + break; + } + } + + chatLogEdit->setPlainText(tr("Loading...")); + commentsEdit->setPlainText(tr("Loading...")); + + Command_ReportDetails cmd; + cmd.set_report_id(reportId); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &DlgMyReports::reportDetailsResponse); + client->sendCommand(pend); + + QString status = table->item(row, COL_STATUS)->text(); + bool canComment = (status == "open" || status == "assigned"); + commentButton->setEnabled(canComment); + commentInput->setEnabled(canComment); + if (!canComment) { + commentInput->setPlaceholderText(tr("This report is closed.")); + } else { + commentInput->setPlaceholderText(tr("Type your comment here...")); + } +} + +void DlgMyReports::reportDetailsResponse(const Response &response) +{ + if (response.response_code() != Response::RespOk) { + if (selectedReportId == -1) { + return; + } + chatLogEdit->clear(); + commentsEdit->setPlainText(tr("Failed to load report details.")); + return; + } + + const Response_ReportDetails &resp = response.GetExtension(Response_ReportDetails::ext); + const ServerInfo_Report &r = resp.report(); + + if (selectedReportId != r.report_id()) { + return; + } + + loadReportDetails(r); +} + +void DlgMyReports::loadReportDetails(const ServerInfo_Report &report) +{ + report_utils::renderReportDetails(chatLogEdit, commentsEdit, report, tr("No comments yet."), tr("[Moderator]"), + tr("[You]")); +} + +void DlgMyReports::addComment() +{ + if (selectedReportId < 0) { + return; + } + + QString text = commentInput->text().trimmed(); + if (text.isEmpty()) { + return; + } + + commentButton->setEnabled(false); + + Command_ReportAddComment cmd; + cmd.set_report_id(selectedReportId); + cmd.set_comment(text.toStdString()); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &DlgMyReports::addCommentResponse); + client->sendCommand(pend); +} + +void DlgMyReports::addCommentResponse(const Response &response) +{ + if (response.response_code() == Response::RespOk) { + commentInput->clear(); + refreshList(); + } else { + commentButton->setEnabled(true); + } +} + +void DlgMyReports::setActionsEnabled(bool enabled) +{ + commentButton->setEnabled(enabled); + commentInput->setEnabled(enabled); +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.h b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.h new file mode 100644 index 000000000..dc9f90d2d --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.h @@ -0,0 +1,55 @@ +#ifndef COCKATRICE_DLG_MY_REPORTS_H +#define COCKATRICE_DLG_MY_REPORTS_H + +#include +#include +#include +#include +#include + +class AbstractClient; +class QTableWidget; +class QTextEdit; +class QLineEdit; +class QPushButton; +class QLabel; +class QWidget; +class Response; + +class DlgMyReports : public QDialog +{ + Q_OBJECT +public: + explicit DlgMyReports(AbstractClient *_client, QWidget *parent = nullptr); + +private slots: + void refreshList(); + void reportListResponse(const Response &response); + void onSelectionChanged(); + void reportDetailsResponse(const Response &response); + void addComment(); + void addCommentResponse(const Response &response); + +private: + void loadReportDetails(const ServerInfo_Report &report); + void setActionsEnabled(bool enabled); + + AbstractClient *client; + + QTableWidget *table; + QTextEdit *descriptionEdit; + QTextEdit *chatLogEdit; + QTextEdit *commentsEdit; + QLineEdit *commentInput; + QPushButton *commentButton; + QPushButton *refreshButton; + QPushButton *closeButton; + QLabel *statusLabel; + + QList currentReports; + int selectedReportId; + int selectedReportIdBeforeRefresh = -1; + QString commentDraftBeforeRefresh; +}; + +#endif // COCKATRICE_DLG_MY_REPORTS_H diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp index fce99a1a7..9e32e87d1 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp @@ -1,18 +1,72 @@ #include "dlg_register.h" #include "../../../client/settings/cache_settings.h" +#include "../../pixel_map_generator.h" +#include "../server/handle_public_servers.h" +#include "../server/user/user_info_connection.h" -#include +#include #include #include +#include #include +#include #include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include #include #include +class QWidget; + DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent) { + // ── Server picker ────────────────────────────────────────────────── + previousHostButton = new QRadioButton(tr("Known Hosts"), this); + previousHosts = new QComboBox(this); + + btnDeleteServer = new QPushButton(this); + btnDeleteServer->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); + btnDeleteServer->setToolTip(tr("Delete the currently selected saved server")); + btnDeleteServer->setFixedWidth(30); + + connect(btnDeleteServer, &QPushButton::clicked, this, &DlgRegister::actRemoveSavedServer); + + hps = new HandlePublicServers(this); + btnRefreshServers = new QPushButton(this); + btnRefreshServers->setIcon(themePixmap(QStringLiteral("icons/sync"))); + btnRefreshServers->setToolTip(tr("Refresh the server list with known public servers")); + btnRefreshServers->setFixedWidth(30); + + connect(hps, &HandlePublicServers::sigPublicServersDownloadedSuccessfully, this, [this] { rebuildComboBoxList(); }); + connect(hps, &HandlePublicServers::sigPublicServersDownloadedUnsuccessfully, this, + &DlgRegister::rebuildComboBoxList); + connect(btnRefreshServers, &QPushButton::released, this, &DlgRegister::downloadThePublicServers); + + newHostButton = new QRadioButton(tr("New Host"), this); + + auto *serverPickerRow = new QHBoxLayout; + serverPickerRow->addWidget(previousHosts); + serverPickerRow->addWidget(btnDeleteServer); + serverPickerRow->addWidget(btnRefreshServers); + + auto *serverGroupLayout = new QVBoxLayout; + serverGroupLayout->addWidget(previousHostButton); + serverGroupLayout->addLayout(serverPickerRow); + serverGroupLayout->addWidget(newHostButton); + + auto *serverGroupBox = new QGroupBox(tr("Server")); + serverGroupBox->setLayout(serverGroupLayout); + + // ── Registration fields ──────────────────────────────────────────── ServersSettings &servers = SettingsCache::instance().servers(); infoLabel = new QLabel(tr("Enter your information and the information of the server you'd like to register to.\n" "Your email will be used to verify your account.")); @@ -321,26 +375,28 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent) realnameEdit->setMaxLength(MAX_NAME_LENGTH); realnameLabel->setBuddy(realnameEdit); + // ── Layout ───────────────────────────────────────────────────────── auto *grid = new QGridLayout; - grid->addWidget(infoLabel, 0, 0, 1, 2); - grid->addWidget(hostLabel, 1, 0); - grid->addWidget(hostEdit, 1, 1); - grid->addWidget(portLabel, 2, 0); - grid->addWidget(portEdit, 2, 1); - grid->addWidget(playernameLabel, 3, 0); - grid->addWidget(playernameEdit, 3, 1); - grid->addWidget(passwordLabel, 4, 0); - grid->addWidget(passwordEdit, 4, 1); - grid->addWidget(passwordConfirmationLabel, 5, 0); - grid->addWidget(passwordConfirmationEdit, 5, 1); - grid->addWidget(emailLabel, 6, 0); - grid->addWidget(emailEdit, 6, 1); - grid->addWidget(emailConfirmationLabel, 7, 0); - grid->addWidget(emailConfirmationEdit, 7, 1); - grid->addWidget(countryLabel, 9, 0); - grid->addWidget(countryEdit, 9, 1); - grid->addWidget(realnameLabel, 10, 0); - grid->addWidget(realnameEdit, 10, 1); + grid->addWidget(serverGroupBox, 0, 0, 1, 2); + grid->addWidget(infoLabel, 1, 0, 1, 2); + grid->addWidget(hostLabel, 2, 0); + grid->addWidget(hostEdit, 2, 1); + grid->addWidget(portLabel, 3, 0); + grid->addWidget(portEdit, 3, 1); + grid->addWidget(playernameLabel, 4, 0); + grid->addWidget(playernameEdit, 4, 1); + grid->addWidget(passwordLabel, 5, 0); + grid->addWidget(passwordEdit, 5, 1); + grid->addWidget(passwordConfirmationLabel, 6, 0); + grid->addWidget(passwordConfirmationEdit, 6, 1); + grid->addWidget(emailLabel, 7, 0); + grid->addWidget(emailEdit, 7, 1); + grid->addWidget(emailConfirmationLabel, 8, 0); + grid->addWidget(emailConfirmationEdit, 8, 1); + grid->addWidget(countryLabel, 10, 0); + grid->addWidget(countryEdit, 10, 1); + grid->addWidget(realnameLabel, 11, 0); + grid->addWidget(realnameEdit, 11, 1); auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgRegister::actOk); @@ -352,13 +408,115 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent) setLayout(mainLayout); setWindowTitle(tr("Register to server")); - setFixedHeight(sizeHint().height()); - setMinimumWidth(300); + setMinimumWidth(360); + + connect(previousHostButton, &QRadioButton::toggled, this, &DlgRegister::previousHostSelected); + connect(newHostButton, &QRadioButton::toggled, this, &DlgRegister::newHostSelected); + connect(previousHosts, &QComboBox::currentTextChanged, this, &DlgRegister::updateDisplayInfo); + + previousHostButton->setChecked(true); + + preRebuildComboBoxList(); +} + +DlgRegister::~DlgRegister() = default; + +void DlgRegister::downloadThePublicServers() +{ + btnRefreshServers->setDisabled(true); + previousHosts->clear(); + previousHosts->addItem(placeHolderText); + hps->downloadPublicServers(); +} + +void DlgRegister::preRebuildComboBoxList() +{ + UserConnection_Information uci; + savedHostList = uci.getServerInfo(); + + if (savedHostList.size() == 1) { + downloadThePublicServers(); + } else { + rebuildComboBoxList(); + } +} + +void DlgRegister::rebuildComboBoxList(int failure) +{ + Q_UNUSED(failure); + + previousHosts->clear(); + + UserConnection_Information uci; + savedHostList = uci.getServerInfo(); + + auto &servers = SettingsCache::instance().servers(); + QString previousHostName = servers.getPrevioushostName(); + + for (const auto &pair : savedHostList) { + const auto &tmp = pair.second; + QString saveName = tmp.getSaveName(); + if (saveName.size()) { + previousHosts->addItem(saveName); + if (saveName.compare(previousHostName) == 0) { + previousHosts->setCurrentIndex(previousHosts->count() - 1); + } + } + } + + btnRefreshServers->setDisabled(false); +} + +void DlgRegister::previousHostSelected(bool state) +{ + if (state) { + previousHosts->setDisabled(false); + btnRefreshServers->setDisabled(false); + hostEdit->setDisabled(true); + portEdit->setDisabled(true); + } +} + +void DlgRegister::newHostSelected(bool state) +{ + if (state) { + previousHosts->setDisabled(true); + btnRefreshServers->setDisabled(true); + hostEdit->setDisabled(false); + hostEdit->clear(); + hostEdit->setPlaceholderText(tr("Server URL")); + portEdit->setDisabled(false); + portEdit->clear(); + portEdit->setPlaceholderText(tr("Communication Port")); + playernameEdit->setDisabled(false); + playernameEdit->clear(); + } else { + // Rebuild the list so the previously selected host's details are + // repopulated (mirrors DlgConnect::newHostSelected). + preRebuildComboBoxList(); + } +} + +void DlgRegister::updateDisplayInfo(const QString &saveName) +{ + if (saveName.isEmpty() || saveName == placeHolderText) { + return; + } + + UserConnection_Information uci; + QStringList _data = uci.getServerInfo(saveName); + + if (_data.size() < 7) { + return; + } + + hostEdit->setText(_data.at(1)); + portEdit->setText(_data.at(2)); + playernameEdit->setText(_data.at(3)); } void DlgRegister::actOk() { - //! \todo This stuff should be using QValidators. if (passwordEdit->text().length() < 8) { QMessageBox::critical(this, tr("Registration Warning"), tr("Your password is too short.")); return; @@ -375,5 +533,29 @@ void DlgRegister::actOk() return; } + ServersSettings &servers = SettingsCache::instance().servers(); + + if (newHostButton->isChecked()) { + // Persist the new host so it shows up in the Connect dialog later. + // The password is never stored: the account is not verified yet. + const QString host = hostEdit->text().trimmed(); + if (!host.isEmpty()) { + servers.addNewServer(host, host, portEdit->text().trimmed(), playernameEdit->text().trimmed(), QString(), + false); + servers.setPrevioushostName(host); + } + } else { + const QString saveName = previousHosts->currentText(); + if (!saveName.isEmpty() && saveName != placeHolderText) { + servers.setPrevioushostName(saveName); + } + } + accept(); } + +void DlgRegister::actRemoveSavedServer() +{ + SettingsCache::instance().servers().removeServer(hostEdit->text()); + previousHosts->removeItem(previousHosts->currentIndex()); +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_register.h b/cockatrice/src/interface/widgets/dialogs/dlg_register.h index abed9ff51..10ea98543 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_register.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_register.h @@ -1,25 +1,28 @@ -/** - * @file dlg_register.h - * @ingroup AccountDialogs - */ -//! \todo Document this file. - #ifndef DLG_REGISTER_H #define DLG_REGISTER_H #include #include #include +#include +#include +#include +#include +class HandlePublicServers; class QLabel; class QPushButton; -class QCheckBox; +class QRadioButton; +class UserConnection_Information; +class QWidget; class DlgRegister : public QDialog { Q_OBJECT public: explicit DlgRegister(QWidget *parent = nullptr); + ~DlgRegister() override; + [[nodiscard]] QString getHost() const { return hostEdit->text(); @@ -48,15 +51,35 @@ public: { return realnameEdit->text(); } + +public slots: + void downloadThePublicServers(); + private slots: void actOk(); + void previousHostSelected(bool state); + void newHostSelected(bool state); + void updateDisplayInfo(const QString &saveName); + void preRebuildComboBoxList(); + void rebuildComboBoxList(int failure = -1); + void actRemoveSavedServer(); private: + QRadioButton *newHostButton; + QRadioButton *previousHostButton; + QComboBox *previousHosts; + QPushButton *btnDeleteServer; + QPushButton *btnRefreshServers; + HandlePublicServers *hps; + QLabel *infoLabel, *hostLabel, *portLabel, *playernameLabel, *passwordLabel, *passwordConfirmationLabel, *emailLabel, *emailConfirmationLabel, *countryLabel, *realnameLabel; QLineEdit *hostEdit, *portEdit, *playernameEdit, *passwordEdit, *passwordConfirmationEdit, *emailEdit, *emailConfirmationEdit, *realnameEdit; QComboBox *countryEdit; + + QMap> savedHostList; + const QString placeHolderText = tr("Downloading..."); }; -#endif +#endif // DLG_REGISTER_H diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp new file mode 100644 index 000000000..b1ae479fe --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp @@ -0,0 +1,246 @@ +#include "dlg_report_user.h" + +#include "abstract_client.h" +#include "libcockatrice/protocol/pb/response.pb.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class QWidget; + +DlgReportUser::DlgReportUser(AbstractClient *_client, + const QString &_reportedUser, + int _gameId, + const QString &_autoChatLog, + QWidget *parent) + : QDialog(parent), client(_client), reportedUser(_reportedUser), gameId(_gameId) +{ + setWindowTitle(tr("Report User")); + setMinimumWidth(500); + + auto *infoLabel = + new QLabel(tr("Reports are reviewed by moderators. False reports may result in account penalties.")); + infoLabel->setWordWrap(true); + infoLabel->setStyleSheet("color: palette(placeholderText); padding: 5px;"); + + auto *reportGroup = new QGroupBox(tr("Report Details")); + auto *reportGrid = new QGridLayout(reportGroup); + + reportGrid->addWidget(new QLabel(tr("Reported User:")), 0, 0); + reportedUserLabel = new QLabel(reportedUser); + reportedUserLabel->setStyleSheet("font-weight: bold;"); + reportGrid->addWidget(reportedUserLabel, 0, 1); + + reportGrid->addWidget(new QLabel(tr("Game ID:")), 1, 0); + if (gameId >= 0) { + gameIdLabel = new QLabel(QString::number(gameId)); + gameIdLabel->setStyleSheet("font-weight: bold;"); + reportGrid->addWidget(gameIdLabel, 1, 1); + } else { + gameIdEdit = new QLineEdit; + gameIdEdit->setPlaceholderText(tr("(Optional) Enter game ID if available")); + gameIdEdit->setToolTip(tr("If the report is related to a specific game, enter its ID.")); + reportGrid->addWidget(gameIdEdit, 1, 1); + gameIdLabel = nullptr; + } + + auto *categoryGroup = new QGroupBox(tr("Category")); + auto *categoryGrid = new QGridLayout(categoryGroup); + + categoryBox = new QComboBox; + for (const QString &key : ReportCategories::keys()) { + const QString label = categoryLabel(key); + if (label.isEmpty()) { + continue; // skip keys without a dialog label + } + categoryBox->addItem(label, key); + categoryBox->setItemData(categoryBox->count() - 1, categoryToolTip(key), Qt::ToolTipRole); + } + + categoryGrid->addWidget(new QLabel(tr("Category:")), 0, 0); + categoryGrid->addWidget(categoryBox, 0, 1); + + auto *descGroup = new QGroupBox(tr("Description")); + auto *descLayout = new QVBoxLayout(descGroup); + + descriptionEdit = new QTextEdit; + descriptionEdit->setPlaceholderText( + tr("Please describe what happened. Include dates, game details, or any evidence if available.")); + descriptionEdit->setFixedHeight(120); + descLayout->addWidget(descriptionEdit); + + auto *chatGroup = new QGroupBox(tr("Chat Log Context")); + auto *chatLayout = new QVBoxLayout(chatGroup); + chatLogEdit = new QTextEdit; + chatLogEdit->setReadOnly(true); + QFont monoFont("monospace"); + monoFont.setStyleHint(QFont::Monospace); + const int systemPointSize = QFontDatabase::systemFont(QFontDatabase::GeneralFont).pointSize(); + if (systemPointSize > 0) { + monoFont.setPointSize(systemPointSize); + } + chatLogEdit->setFont(monoFont); + if (!_autoChatLog.isEmpty()) { + chatLogEdit->setPlainText(_autoChatLog); + } else { + chatLogEdit->setPlaceholderText(tr("No chat context available (not triggered from chat).")); + } + chatLogEdit->setFixedHeight(100); + chatLayout->addWidget(chatLogEdit); + + auto *chatNote = new QLabel( + tr("This chat log is captured from your local chat window and may not reflect the full conversation.")); + chatNote->setWordWrap(true); + chatNote->setStyleSheet("color: palette(placeholderText);"); + chatLayout->addWidget(chatNote); + + buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Submit Report")); + connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgReportUser::actSubmit); + connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + + auto *layout = new QVBoxLayout(this); + layout->addWidget(infoLabel); + layout->addWidget(reportGroup); + layout->addWidget(categoryGroup); + layout->addWidget(descGroup); + layout->addWidget(chatGroup); + layout->addWidget(buttonBox); +} + +void DlgReportUser::actSubmit() +{ + const QString description = descriptionEdit->toPlainText().trimmed(); + if (description.isEmpty()) { + QMessageBox::warning(this, tr("Missing description"), tr("Please describe what happened before submitting.")); + return; + } + + QMessageBox::StandardButton reply = + QMessageBox::question(this, tr("Confirm Report"), + tr("Submit report against %1 for %2?").arg(reportedUser, categoryBox->currentText()), + QMessageBox::Yes | QMessageBox::No); + + if (reply != QMessageBox::Yes) { + return; + } + + buttonBox->setEnabled(false); + buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Submitting...")); + + Command_Report cmd; + cmd.set_reported_user(reportedUser.toStdString()); + cmd.set_category(categoryBox->currentData().toString().toStdString()); + cmd.set_description(description.toStdString()); + + if (gameId >= 0) { + cmd.set_game_id(gameId); + } else if (gameIdEdit && !gameIdEdit->text().trimmed().isEmpty()) { + bool ok; + int manualGameId = gameIdEdit->text().trimmed().toInt(&ok); + if (ok && manualGameId > 0) { + cmd.set_game_id(manualGameId); + } + } + + const QString chatLog = chatLogEdit->toPlainText().trimmed(); + if (!chatLog.isEmpty()) { + cmd.set_chat_log(chatLog.toStdString()); + } + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &DlgReportUser::reportResponse); + client->sendCommand(pend); +} + +void DlgReportUser::reportResponse(const Response &response) +{ + buttonBox->setEnabled(true); + buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Submit Report")); + + if (response.response_code() == Response::RespOk) { + QMessageBox::information(this, tr("Report Submitted"), + tr("Your report has been submitted and will be reviewed by a moderator. Thank you.")); + accept(); + } else if (response.response_code() == Response::RespTooManyRequests) { + QMessageBox::warning(this, tr("Submission Failed"), + tr("You have reached the daily report limit. Please try again later.")); + } else if (response.response_code() == Response::RespNameNotFound) { + QMessageBox::warning( + this, tr("Submission Failed"), + tr("The reported user could not be found. Guests (unregistered users) cannot be reported.")); + } else { + QMessageBox::warning(this, tr("Submission Failed"), tr("Failed to submit report. Please try again.")); + } +} + +QString DlgReportUser::categoryLabel(const QString &key) +{ + if (key == QLatin1String("cheating")) { + return tr("Cheating / Unsporting behavior"); + } + if (key == QLatin1String("bug_abuse")) { + return tr("Bug Abuse"); + } + if (key == QLatin1String("harassment")) { + return tr("Harassment / Abuse"); + } + if (key == QLatin1String("verbal_abuse")) { + return tr("Verbal Abuse"); + } + if (key == QLatin1String("hate_speech")) { + return tr("Hate speech"); + } + if (key == QLatin1String("spam")) { + return tr("Spam"); + } + if (key == QLatin1String("other")) { + return tr("Other"); + } + return QString(); +} + +QString DlgReportUser::categoryToolTip(const QString &key) +{ + if (key == QLatin1String("cheating")) { + return tr("Using external tools, cheat programs, or exploiting game bugs"); + } + if (key == QLatin1String("bug_abuse")) { + return tr("Exploiting a bug or glitch to gain an unfair advantage"); + } + if (key == QLatin1String("harassment")) { + return tr("Threatening, bullying, or persistent unwanted contact"); + } + if (key == QLatin1String("verbal_abuse")) { + return tr("Abusive or offensive language directed at another player"); + } + if (key == QLatin1String("hate_speech")) { + return tr("Discriminatory language targeting race, gender, religion, etc."); + } + if (key == QLatin1String("spam")) { + return tr("Repeated unwanted messages or advertisements"); + } + if (key == QLatin1String("other")) { + return tr("Any behavior not covered by the above categories"); + } + return QString(); +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_report_user.h b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.h new file mode 100644 index 000000000..c7fce055d --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.h @@ -0,0 +1,48 @@ +#ifndef COCKATRICE_DLG_REPORT_USER_H +#define COCKATRICE_DLG_REPORT_USER_H + +#include +#include +#include + +class AbstractClient; +class QComboBox; +class QDialogButtonBox; +class QLineEdit; +class QTextEdit; +class QLabel; +class QWidget; +class Response; + +class DlgReportUser : public QDialog +{ + Q_OBJECT +public: + DlgReportUser(AbstractClient *_client, + const QString &_reportedUser, + int _gameId = -1, + const QString &_autoChatLog = QString(), + QWidget *parent = nullptr); + +private slots: + void actSubmit(); + void reportResponse(const Response &response); + +private: + static QString categoryLabel(const QString &key); + static QString categoryToolTip(const QString &key); + + AbstractClient *client; + QString reportedUser; + int gameId; + + QLabel *reportedUserLabel; + QLabel *gameIdLabel = nullptr; + QLineEdit *gameIdEdit = nullptr; + QComboBox *categoryBox; + QTextEdit *descriptionEdit; + QTextEdit *chatLogEdit; + QDialogButtonBox *buttonBox; +}; + +#endif // COCKATRICE_DLG_REPORT_USER_H diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp index 8eed083e1..55c6569f7 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.cpp @@ -1,26 +1,59 @@ #include "dlg_select_set_for_cards.h" #include "../../deck_loader/card_node_function.h" -#include "../../deck_loader/deck_loader.h" #include "../deck_editor/deck_state_manager.h" #include "../interface/widgets/cards/card_info_picture_widget.h" #include "../interface/widgets/general/layout_containers/flow_widget.h" +#include "deck_list_model.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/database/card_database.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/card/printing/printing_info.h" +#include "libcockatrice/card/set/card_set.h" +#include "libcockatrice/deck_list/tree/inner_deck_list_node.h" +#include +#include #include +#include #include +#include +#include +#include +#include +#include +#include #include -#include +#include #include +#include +#include +#include #include +#include +#include +#include +#include #include +#include +#include #include +#include +#include +#include #include #include #include +#include +#include #include +#include #include -#include -#include +#include +#include +#include +#include DlgSelectSetForCards::DlgSelectSetForCards(QWidget *parent, DeckStateManager *deckStateManger) : QDialog(parent), deckStateManager(deckStateManger) diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.h b/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.h index 4528552d0..89cc43662 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_select_set_for_cards.h @@ -7,19 +7,23 @@ #ifndef DLG_SELECT_SET_FOR_CARDS_H #define DLG_SELECT_SET_FOR_CARDS_H -#include "../interface/widgets/general/layout_containers/flow_widget.h" - -#include #include -#include -#include +#include #include -#include -#include -#include +#include +#include +#include +#include class DeckStateManager; class SetEntryWidget; // Forward declaration +class FlowWidget; +class QCheckBox; +class QLabel; +class QListWidget; +class QPushButton; +class QScrollArea; +class QVBoxLayout; class DlgSelectSetForCards : public QDialog { diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp index 883cfcd03..d41c948eb 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp @@ -6,7 +6,9 @@ #include "dlg_settings.h" #include "../../../client/settings/cache_settings.h" +#include "../../pixel_map_generator.h" #include "../main.h" +#include "../settings_page/abstract_settings_page.h" #include "../settings_page/appearance_settings_page.h" #include "../settings_page/deck_editor_settings_page.h" #include "../settings_page/general_settings_page.h" @@ -17,33 +19,59 @@ #include "../settings_page/sound_settings_page.h" #include "../settings_page/storage_settings_page.h" #include "../settings_page/user_interface_settings_page.h" +#include "libcockatrice/card/database/card_database.h" #include "libcockatrice/card/database/card_database_loader.h" #include "libcockatrice/card/database/card_database_manager.h" +#include #include +#include +#include +#include #include +#include +#include #include +#include +#include +#include #include #include #include #include +#include #include +#include #include #include #include +#include +#include +#include +#include +#include #include #include +#include #include #include #include #include #include +#include +#include #include #include +#include #include #include +#include +#include #include #include +#include + +class QModelIndex; /** * @brief Wraps a widget in a scroll area for long settings pages @@ -96,7 +124,7 @@ void DlgSettings::setupUi() // Search bar searchEdit = new QLineEdit; searchEdit->setClearButtonEnabled(true); - searchEdit->addAction(QPixmap("theme:icons/search"), QLineEdit::LeadingPosition); + searchEdit->addAction(themePixmap(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); searchEdit->installEventFilter(this); connect(searchEdit, &QLineEdit::textChanged, this, &DlgSettings::onSearchTextChanged); @@ -132,7 +160,7 @@ void DlgSettings::setupUi() pagesWidget->addWidget(makeScrollable(userInterfacePage)); pagesWidget->addWidget(makeScrollable(deckEditorPage)); pagesWidget->addWidget(makeScrollable(storagePage)); - pagesWidget->addWidget(messagesPage); + pagesWidget->addWidget(makeScrollable(messagesPage)); pagesWidget->addWidget(soundPage); pagesWidget->addWidget(shortcutsPage); @@ -415,6 +443,11 @@ void DlgSettings::setTab(int index) } } +AbstractSettingsPage *DlgSettings::page(SettingsPage which) const +{ + return pages.value(static_cast(which)); +} + void DlgSettings::updateLanguage() { qApp->removeTranslator(translator); // NOLINT(cppcoreguidelines-pro-type-static-cast-downcast) @@ -477,13 +510,13 @@ void DlgSettings::closeEvent(QCloseEvent *event) case Invalid: loadErrorMessage = tr("Your card database is invalid.\n\n" "Cockatrice may not function correctly with an invalid database\n\n" - "You may need to rerun oracle to update your card database.\n\n" + "You may need to rerun Oracle to update your card database.\n\n" "Would you like to change your database location setting?"); break; case VersionTooOld: loadErrorMessage = tr("Your card database version is too old.\n\n" "This can cause problems loading card information or images\n\n" - "Usually this can be fixed by rerunning oracle to to update your card database.\n\n" + "Usually this can be fixed by rerunning Oracle to to update your card database.\n\n" "Would you like to change your database location setting?"); break; case NotLoaded: diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_settings.h b/cockatrice/src/interface/widgets/dialogs/dlg_settings.h index b700f7af9..1f15e76d5 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.h @@ -7,9 +7,15 @@ #define DLG_SETTINGS_H #include +#include #include +#include +#include +#include class QPushButton; +class QModelIndex; +class QWidget; inline Q_LOGGING_CATEGORY(DlgSettingsLog, "dlg_settings"); @@ -17,7 +23,6 @@ class QStackedWidget; class QToolButton; class QListView; class QLineEdit; - class AbstractSettingsPage; class SettingsSearchModel; class SettingsSearchDelegate; @@ -54,6 +59,7 @@ public: explicit DlgSettings(QWidget *parent = nullptr); void setTab(int index); + AbstractSettingsPage *page(SettingsPage which) const; private slots: void onTabClicked(int index); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.cpp new file mode 100644 index 000000000..eec80a7e8 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.cpp @@ -0,0 +1,101 @@ +#include "dlg_share_deck.h" + +#include "../../../client/settings/cache_settings.h" +#include "../cards/additional_info/deck_color_identity.h" +#include "../deck_share/deck_share_utils.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class QWidget; +template class QSharedPointer; + +DlgShareDeck::DlgShareDeck(AbstractClient *_client, const QSharedPointer &_deck, QWidget *_parent) + : QDialog(_parent), client(_client), deck(_deck), shareTimeoutTimer(new QTimer(this)) +{ + setWindowTitle(tr("Share deck")); + + auto *layout = new QVBoxLayout(this); + + nameEdit = new QLineEdit(this); + nameEdit->setText(tr("Shared deck")); + + auto *form = new QFormLayout; + form->addRow(tr("Share name:"), nameEdit); + layout->addLayout(form); + + auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); + buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Create share link")); + buttonBox->button(QDialogButtonBox::Cancel)->setText(tr("Cancel")); + connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgShareDeck::actShare); + connect(buttonBox, &QDialogButtonBox::rejected, this, &DlgShareDeck::reject); + this->buttonBox = buttonBox; + layout->addWidget(buttonBox); + + shareTimeoutTimer->setSingleShot(true); + shareTimeoutTimer->setInterval( + static_cast((static_cast(SettingsCache::instance().network().getTimeOut()) + 1) * + SettingsCache::instance().network().getKeepAlive() * 1000)); + connect(shareTimeoutTimer, &QTimer::timeout, this, &DlgShareDeck::onShareTimeout); +} + +void DlgShareDeck::actShare() +{ + buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false); + + Command_DeckShareCreate cmd; + cmd.set_name(nameEdit->text().trimmed().toStdString()); + if (cmd.name().empty()) { + cmd.set_name(tr("Shared deck").toStdString()); + } + + DeckShareItem *item = cmd.add_items(); + item->set_deck_list(deck->writeToString_Native().toStdString()); + item->set_color_identity(getDeckColorIdentity(*deck, CardDatabaseManager::query()).toStdString()); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &DlgShareDeck::shareFinished); + client->sendCommand(pend); + shareTimeoutTimer->start(); +} + +void DlgShareDeck::shareFinished(const Response &response, const CommandContainer & /*commandContainer*/) +{ + shareTimeoutTimer->stop(); + if (response.response_code() != Response::RespOk) { + buttonBox->button(QDialogButtonBox::Ok)->setEnabled(true); + QMessageBox::critical(this, tr("Share deck"), + tr("Failed to create the share link (server response code %1).") + .arg(QString::number(static_cast(response.response_code())))); + return; + } + + const DeckShareUtils::ShareResponse share = DeckShareUtils::handleShareResponse(client, response); + + QMessageBox::information(this, tr("Share deck"), + tr("Share link created and copied to the clipboard:\n\n%1\n\n" + "The share expires on %2.") + .arg(share.link, DeckShareUtils::formatShareExpiry(share.expiry))); + accept(); +} + +void DlgShareDeck::onShareTimeout() +{ + buttonBox->button(QDialogButtonBox::Ok)->setEnabled(true); + QMessageBox::warning(this, tr("Share deck"), tr("The server did not respond in time. Try again.")); +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.h b/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.h new file mode 100644 index 000000000..108a8c38b --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_share_deck.h @@ -0,0 +1,49 @@ +/** + * @file dlg_share_deck.h + * @ingroup Dialogs + */ +//! \todo Document this file. + +#ifndef DLG_SHARE_DECK_H +#define DLG_SHARE_DECK_H + +#include +#include +#include + +class AbstractClient; +class CommandContainer; +class DeckList; +class QDialogButtonBox; +class QLineEdit; +class QTimer; +class Response; +class QWidget; +template class QSharedPointer; + +/** + * @brief Slim dialog to create a temporary share for the deck open in the editor. + * + * Asks for a share name, sends Command_DeckShareCreate for the single inline + * deck, and copies the resulting link to the clipboard. + */ +class DlgShareDeck : public QDialog +{ + Q_OBJECT +public: + DlgShareDeck(AbstractClient *_client, const QSharedPointer &_deck, QWidget *parent = nullptr); + +private slots: + void actShare(); + void shareFinished(const Response &response, const CommandContainer &commandContainer); + void onShareTimeout(); + +private: + AbstractClient *client; + QSharedPointer deck; + QLineEdit *nameEdit; + QDialogButtonBox *buttonBox; + QTimer *shareTimeoutTimer; +}; + +#endif // DLG_SHARE_DECK_H diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp new file mode 100644 index 000000000..8ad91dcaf --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.cpp @@ -0,0 +1,186 @@ +#include "dlg_shared_decks_preview.h" + +#include "../deck_share/shared_deck_preview_widget.h" +#include "../general/layout_containers/flow_widget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class QWidget; + +DlgSharedDecksPreview::DlgSharedDecksPreview(QWidget *parent, + const CardDatabaseQuerier *querier, + const QString &shareName, + qint64 expiresAt, + const QString &serverText, + const QList &items) + : QDialog(parent) +{ + setWindowTitle(tr("Open shared decks")); + resize(700, 500); + + auto *mainLayout = new QVBoxLayout(this); + + // shareName and serverText come from the share server, so escape them: the + // QLabels render AutoText and markup would otherwise be shown as rich text. + auto *titleLabel = + new QLabel(tr("Share: %1").arg((shareName.isEmpty() ? tr("Untitled") : shareName).toHtmlEscaped()), this); + QFont titleFont = titleLabel->font(); + titleFont.setBold(true); + titleFont.setPointSize(titleFont.pointSize() + 2); + titleLabel->setFont(titleFont); + mainLayout->addWidget(titleLabel); + + if (!serverText.isEmpty()) { + mainLayout->addWidget(new QLabel(tr("From %1").arg(serverText.toHtmlEscaped()), this)); + } + + if (expiresAt > 0) { + const QString expiryText = QDateTime::fromSecsSinceEpoch(expiresAt).toLocalTime().toString(Qt::TextDate); + mainLayout->addWidget(new QLabel(tr("This share link expires on %1").arg(expiryText), this)); + } + + downloadStatusLabel = new QLabel(this); + downloadStatusLabel->setVisible(false); + mainLayout->addWidget(downloadStatusLabel); + + flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); + mainLayout->addWidget(flowWidget, 1); + + for (const ServerInfo_DeckShareItem &item : items) { + QStringList tags; + for (const auto &tag : item.tags()) { + tags.append(QString::fromStdString(tag)); + } + + auto *tile = new SharedDeckPreviewWidget( + this, querier, QString::fromStdString(item.name()), QString::fromStdString(item.banner_card()), + QString::fromStdString(item.color_identity()), QString::fromStdString(item.game_format()), tags.join(", ")); + flowWidget->addNavigableWidget(tile); + tiles.append(tile); + itemIds.append(item.id()); + } + + if (tiles.size() == 1) { + tiles.first()->setSelected(true); + } + + auto *buttonBox = new QDialogButtonBox(this); + openSelectedButton = buttonBox->addButton(tr("Open selected"), QDialogButtonBox::AcceptRole); + openAllButton = buttonBox->addButton(tr("Open all"), QDialogButtonBox::ActionRole); + buttonBox->addButton(tr("Cancel"), QDialogButtonBox::RejectRole); + mainLayout->addWidget(buttonBox); + + connect(buttonBox, &QDialogButtonBox::rejected, this, [this]() { + onCancel(); + close(); + }); + + // Esc calls QDialog::reject() directly (which hides the dialog without a + // close event), so route it through the same guarded cancel as the button. + connect(this, &QDialog::rejected, this, [this]() { + onCancel(); + close(); + }); + + connect(openSelectedButton, &QPushButton::clicked, this, &DlgSharedDecksPreview::openSelected); + connect(buttonBox, &QDialogButtonBox::clicked, this, [this, buttonBox](QAbstractButton *button) { + if (buttonBox->buttonRole(button) == QDialogButtonBox::ActionRole) { + openAll(); + } + }); + + for (SharedDeckPreviewWidget *tile : tiles) { + connect(tile, &SharedDeckPreviewWidget::selectionToggled, this, + &DlgSharedDecksPreview::updateOpenSelectedEnabled); + } + for (int i = 0; i < tiles.size(); ++i) { + const int itemId = itemIds.at(i); + // Double-clicking a tile selects it and opens just that deck. + connect(tiles.at(i), &SharedDeckPreviewWidget::activated, this, [this, itemId]() { + resultEmitted = true; + setDownloading(true); + emit openRequested(QList{itemId}); + }); + } + updateOpenSelectedEnabled(); +} + +QList DlgSharedDecksPreview::selectedItemIds() const +{ + QList selectedIds; + for (int i = 0; i < tiles.size(); ++i) { + if (tiles.at(i)->isSelected()) { + selectedIds.append(itemIds.at(i)); + } + } + return selectedIds; +} + +void DlgSharedDecksPreview::openSelected() +{ + const QList selectedIds = selectedItemIds(); + if (selectedIds.isEmpty()) { + return; + } + resultEmitted = true; + setDownloading(true); + emit openRequested(selectedIds); +} + +void DlgSharedDecksPreview::openAll() +{ + resultEmitted = true; + setDownloading(true); + emit openRequested(itemIds); +} + +void DlgSharedDecksPreview::setDownloading(bool downloading) +{ + if (downloadInProgress == downloading) { + return; + } + downloadInProgress = downloading; + downloadStatusLabel->setVisible(downloading); + for (SharedDeckPreviewWidget *tile : tiles) { + tile->setEnabled(!downloading); + } + openSelectedButton->setEnabled(!downloading); + openAllButton->setEnabled(!downloading); +} + +void DlgSharedDecksPreview::setDownloadProgress(int done, int total, const QString ¤tDeckName) +{ + if (!downloadInProgress) { + return; + } + downloadStatusLabel->setText(tr("Downloading deck %1 of %2: %3").arg(done).arg(total).arg(currentDeckName)); +} + +void DlgSharedDecksPreview::updateOpenSelectedEnabled() +{ + openSelectedButton->setEnabled(!selectedItemIds().isEmpty()); +} + +void DlgSharedDecksPreview::onCancel() +{ + if (!resultEmitted || downloadInProgress) { + resultEmitted = true; + emit cancelled(); + } +} + +void DlgSharedDecksPreview::closeEvent(QCloseEvent *event) +{ + onCancel(); + QDialog::closeEvent(event); +} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.h b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.h new file mode 100644 index 000000000..fab7ba6bf --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_shared_decks_preview.h @@ -0,0 +1,71 @@ +#ifndef COCKATRICE_DLG_SHARED_DECKS_PREVIEW_H +#define COCKATRICE_DLG_SHARED_DECKS_PREVIEW_H + +#include +#include +#include +#include +#include + +class FlowWidget; +class QLabel; +class QPushButton; +class ServerInfo_DeckShareItem; +class SharedDeckPreviewWidget; +class CardDatabaseQuerier; +class QWidget; + +/** + * @brief Non-modal preview of the decks contained in a shared-deck link. + * + * Lets the user pick which of the shared decks to open before anything is + * downloaded. Emits openRequested with the ids of the chosen decks, or + * cancelled when the user closes the dialog without choosing. Once the user + * picks, the dialog switches into a "downloading" state: the tiles and open + * buttons are disabled, a progress label shows the current download and Cancel + * stays functional so the download can be aborted. + */ +class DlgSharedDecksPreview : public QDialog +{ + Q_OBJECT + +public: + explicit DlgSharedDecksPreview(QWidget *parent, + const CardDatabaseQuerier *querier, + const QString &shareName, + qint64 expiresAt, + const QString &serverText, + const QList &items); + + void setDownloadProgress(int done, int total, const QString ¤tDeckName); + +public slots: + void setDownloading(bool downloading); + +signals: + void openRequested(const QList &itemIds); + void cancelled(); + +protected: + void closeEvent(QCloseEvent *event) override; + +private slots: + void openSelected(); + void openAll(); + void updateOpenSelectedEnabled(); + void onCancel(); + +private: + QList selectedItemIds() const; + + FlowWidget *flowWidget; + QList tiles; + QList itemIds; + QPushButton *openSelectedButton; + QPushButton *openAllButton; + QLabel *downloadStatusLabel; + bool resultEmitted = false; + bool downloadInProgress = false; +}; + +#endif // COCKATRICE_DLG_SHARED_DECKS_PREVIEW_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_startup_card_check.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_startup_card_check.cpp index 4322beb8c..3d50480cc 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_startup_card_check.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_startup_card_check.cpp @@ -2,9 +2,17 @@ #include "../../../client/settings/cache_settings.h" +#include #include +#include +#include +#include +#include +#include #include +class QWidget; + DlgStartupCardCheck::DlgStartupCardCheck(QWidget *parent) : QDialog(parent) { setWindowTitle(tr("Card Update Check")); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_startup_card_check.h b/cockatrice/src/interface/widgets/dialogs/dlg_startup_card_check.h index d26610983..0d0fb6670 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_startup_card_check.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_startup_card_check.h @@ -7,12 +7,15 @@ #ifndef DLG_STARTUP_CARD_CHECK_H #define DLG_STARTUP_CARD_CHECK_H -#include #include -#include -#include -#include -#include +#include + +class QButtonGroup; +class QDialogButtonBox; +class QLabel; +class QRadioButton; +class QVBoxLayout; +class QWidget; class DlgStartupCardCheck : public QDialog { diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp index 3d3619bf0..511ff1c7d 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_tip_of_the_day.cpp @@ -7,9 +7,19 @@ #include #include #include +#include #include +#include +#include +#include #include +#include +#include +#include +#include +#include #include +#include #define MIN_TIP_IMAGE_HEIGHT 200 #define MIN_TIP_IMAGE_WIDTH 200 diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_tip_of_the_day.h b/cockatrice/src/interface/widgets/dialogs/dlg_tip_of_the_day.h index e493481d0..689814115 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_tip_of_the_day.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_tip_of_the_day.h @@ -7,12 +7,15 @@ #ifndef DLG_TIPOFDAY_H #define DLG_TIPOFDAY_H -#include #include -#include -#include #include -#include +#include + +class QDialogButtonBox; +class QHBoxLayout; +class QPixmap; +class QVBoxLayout; +class QWidget; inline Q_LOGGING_CATEGORY(DlgTipOfTheDayLog, "dlg_tip_of_the_day"); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp index 7cf58d3e0..76bb13248 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp @@ -4,16 +4,41 @@ #include "../client/network/update/client/client_update_checker.h" #include "../client/network/update/client/release_channel.h" #include "../interface/window_main.h" +#include "update/client/update_downloader.h" +#include +#include +#include +#include +#include #include +#include +#include +#include #include +#include +#include #include +#include +#include +#include #include #include +#include +#include +#include +#include #include -#include +#include +#include #include +// Executable that, when it sits next to the downloaded update installer, is installed instead of +// it. A packager shipping a custom build - or someone testing one - can drop the file there and +// have Cockatrice run it rather than the official installer, without the release channel having to +// host an installer for that build. It is a drop-in and is run with the same arguments. +static const QString UPDATE_INSTALLER_OVERRIDE = "Cockatrice-Update-Override.exe"; + DlgUpdate::DlgUpdate(QWidget *parent) : QDialog(parent) { @@ -203,6 +228,24 @@ void DlgUpdate::setLabel(const QString &newText) statusLabel->setText(newText); } +void DlgUpdate::warnInstallerIsWaiting() +{ + // Modeless on purpose: the installer asks the application to close exactly once (see + // CloseMatchingApps in NSIS.template.in) and only polls afterwards, while Qt drops spontaneous + // close events - a WM_CLOSE from the installer - for windows that are blocked by a modal + // widget (QGuiApplicationPrivate::processCloseEvent). An application modal message box here + // would therefore swallow the installer's one and only request for the rest of its wait. + auto *notification = new QMessageBox(QMessageBox::Warning, tr("Update"), + tr("The update installer is already running and waits about a " + "minute for Cockatrice to close. Cockatrice is still busy, so " + "save your work and close it before then. Otherwise the " + "installer gives up and the update is cancelled."), + QMessageBox::Ok, parentWidget()); + notification->setWindowModality(Qt::NonModal); + notification->setAttribute(Qt::WA_DeleteOnClose); + notification->show(); +} + void DlgUpdate::updateCheckError(const QString &errorString) { setLabel(tr("Error")); @@ -224,6 +267,12 @@ void DlgUpdate::downloadSuccessful(const QUrl &filepath) QString installerPath = filepath.toLocalFile(); + const QString overridePath = QDir(QFileInfo(installerPath).absolutePath()).filePath(UPDATE_INSTALLER_OVERRIDE); + if (QFileInfo::exists(overridePath)) { + qCInfo(DlgUpdateLog) << "Installing the update installer override instead of the download:" << overridePath; + installerPath = overridePath; + } + QString appDir = QDir::toNativeSeparators(QCoreApplication::applicationDirPath()); QProcess process; process.setProgram(installerPath); @@ -231,8 +280,16 @@ void DlgUpdate::downloadSuccessful(const QUrl &filepath) // NSIS needs the /D= argument to be an UNQUOTED string, even if it contains spaces. Qt likes to quote arguments if // they contain spaces, so we use the windows exclusive QProcess::setNativeArguments in the only case where this is // relevant, which preserves the argument unquoted. + // + // /PID= tells the installer which process to wait for before it replaces anything. We launch the installer first + // and only quit afterwards, so it starts while this process is still alive and still has every Qt runtime DLL + // mapped, and a mapped DLL cannot be deleted. Without the handle the installer can only poll for an image name and + // guess, and a wrong guess is what leaves the update writing new executables next to runtime files of the previous + // version, which fails on the next start with "The procedure entry point ... could not be located in the dynamic + // link library + // ...Qt6QuickWidgets.dll". /D= has to stay the last parameter, which is why it is last here too. #ifdef Q_OS_WIN - process.setNativeArguments(QString("/R /D=%1").arg(appDir)); + process.setNativeArguments(QString("/R /PID=%1 /D=%2").arg(QCoreApplication::applicationPid()).arg(appDir)); #else // Linux/macOS: normal argument passing (not relevant since they update differently.) process.setArguments({"/R", QString("/D=%1").arg(appDir)}); @@ -240,8 +297,31 @@ void DlgUpdate::downloadSuccessful(const QUrl &filepath) // Try to open the installer. If it opens, quit Cockatrice if (process.startDetached()) { - QMetaObject::invokeMethod(static_cast(parent()), "close", Qt::QueuedConnection); - qCInfo(DlgUpdateLog) << "Opened downloaded update file successfully - closing Cockatrice"; + qCInfo(DlgUpdateLog) << "Opened update installer successfully - closing Cockatrice"; + // Close the main window synchronously so file locks are released before the NSIS installer + // (already launched) starts replacing files. This also flushes settings and shuts down the + // tabs, but only when the close is actually accepted: MainWindow may veto it for a running + // card DB update, an open game, or an unsaved deck, and closeForUpdate() also reports a + // close already in progress (reached from a nested event loop while a shutdown prompt is + // up). Only quit when the shutdown really ran - otherwise keep running so the user can + // resolve the blocker, and warn them that the installer only waits about a minute + // before it gives up and cancels the update. + if (auto *window = qobject_cast(parent())) { + if (window->closeForUpdate()) { + QTimer::singleShot(0, qApp, [] { QCoreApplication::exit(0); }); + } else { + warnInstallerIsWaiting(); + } + } else { + // Not a MainWindow, so no faithful close can be requested - but the parent still gets + // its close() call, which is what the code before this change did and lets it flush + // settings and shut down its tabs. Leaving is then unconditional: the installer is + // already running against a process that still holds locks on the files it replaces. + if (auto *widget = parentWidget()) { + widget->close(); + } + QTimer::singleShot(0, qApp, [] { QCoreApplication::exit(0); }); + } close(); } else { setLabel(tr("Error")); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_update.h b/cockatrice/src/interface/widgets/dialogs/dlg_update.h index 7bd0020d5..026a54a8c 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_update.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_update.h @@ -7,17 +7,24 @@ #ifndef DLG_UPDATE_H #define DLG_UPDATE_H -#include "../client/network/update/client/update_downloader.h" +#include "../../../client/network/update/client/release_channel.h" -#include +#include #include -#include -#include +#include +#include +#include +#include + +class QDialogButtonBox; +class QLabel; +class QProgressBar; +class QPushButton; +class QWidget; +class UpdateDownloader; inline Q_LOGGING_CATEGORY(DlgUpdateLog, "dlg_update"); -class Release; - class DlgUpdate : public QDialog { Q_OBJECT @@ -42,6 +49,7 @@ private: void addStopDownloadAndRemoveOthers(bool enable); void beginUpdateCheck(); void setLabel(const QString &text); + void warnInstallerIsWaiting(); QLabel *statusLabel, *descriptionLabel; QProgressBar *progress; QPushButton *manualDownload, *gotoDownload, *ok, *stopDownload; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_view_log.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_view_log.cpp index 957e039c0..d55e800a1 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_view_log.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_view_log.cpp @@ -4,13 +4,18 @@ #include "../../logger.h" #include +#include #include +#include +#include #include #include #include #include #include +class QWidget; + DlgViewLog::DlgViewLog(QWidget *parent) : QDialog(parent) { logArea = new QPlainTextEdit; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_view_log.h b/cockatrice/src/interface/widgets/dialogs/dlg_view_log.h index 5c7315b50..d63ff5ffc 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_view_log.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_view_log.h @@ -7,11 +7,14 @@ #ifndef DLG_VIEWLOG_H #define DLG_VIEWLOG_H -#include #include +#include +#include class QPlainTextEdit; -class QCloseEvent; +class QCheckBox; +class QPushButton; +class QWidget; class DlgViewLog : public QDialog { diff --git a/cockatrice/src/interface/widgets/dialogs/override_printing_warning.cpp b/cockatrice/src/interface/widgets/dialogs/override_printing_warning.cpp index 33d102f5e..57f7ff7f5 100644 --- a/cockatrice/src/interface/widgets/dialogs/override_printing_warning.cpp +++ b/cockatrice/src/interface/widgets/dialogs/override_printing_warning.cpp @@ -3,7 +3,13 @@ #include "../../card_picture_loader/card_picture_loader.h" #include "../../client/settings/cache_settings.h" +#include +#include +#include #include + +class QWidget; + bool OverridePrintingWarning::execMessageBox(QWidget *parent, bool enable) { QString message; diff --git a/cockatrice/src/interface/widgets/dialogs/override_printing_warning.h b/cockatrice/src/interface/widgets/dialogs/override_printing_warning.h index f2c926fc3..6c5891373 100644 --- a/cockatrice/src/interface/widgets/dialogs/override_printing_warning.h +++ b/cockatrice/src/interface/widgets/dialogs/override_printing_warning.h @@ -1,6 +1,7 @@ #ifndef COCKATRICE_OVERRIDE_PRINTING_WARN_H #define COCKATRICE_OVERRIDE_PRINTING_WARN_H -#include + +class QWidget; namespace OverridePrintingWarning { diff --git a/cockatrice/src/interface/widgets/dialogs/tip_of_the_day.cpp b/cockatrice/src/interface/widgets/dialogs/tip_of_the_day.cpp index 66170792a..3463fe6df 100644 --- a/cockatrice/src/interface/widgets/dialogs/tip_of_the_day.cpp +++ b/cockatrice/src/interface/widgets/dialogs/tip_of_the_day.cpp @@ -2,11 +2,20 @@ #include #include -#include +#include +#include +#include +#include #include +#include #include +#include +#include +#include #include +class QObject; + #define TIPDDBMODEL_COLUMNS 3 TipOfTheDay::TipOfTheDay(QString _title, QString _content, QString _imagePath, QDate _date) diff --git a/cockatrice/src/interface/widgets/dialogs/tip_of_the_day.h b/cockatrice/src/interface/widgets/dialogs/tip_of_the_day.h index 9cda07e2b..4e6e23567 100644 --- a/cockatrice/src/interface/widgets/dialogs/tip_of_the_day.h +++ b/cockatrice/src/interface/widgets/dialogs/tip_of_the_day.h @@ -9,6 +9,12 @@ #include #include +#include +#include +#include + +class QObject; +template class QList; class TipOfTheDay { diff --git a/cockatrice/src/interface/widgets/general/background_sources.h b/cockatrice/src/interface/widgets/general/background_sources.h index 423111bb6..775d55895 100644 --- a/cockatrice/src/interface/widgets/general/background_sources.h +++ b/cockatrice/src/interface/widgets/general/background_sources.h @@ -9,7 +9,10 @@ #include #include +#include #include +#include +#include class BackgroundSources { diff --git a/cockatrice/src/interface/widgets/general/display/background_plate_widget.cpp b/cockatrice/src/interface/widgets/general/display/background_plate_widget.cpp index aad259900..fd9ad67a8 100644 --- a/cockatrice/src/interface/widgets/general/display/background_plate_widget.cpp +++ b/cockatrice/src/interface/widgets/general/display/background_plate_widget.cpp @@ -1,9 +1,9 @@ #include "background_plate_widget.h" -#include #include #include -#include +#include +#include BackgroundPlateWidget::BackgroundPlateWidget(QWidget *parent) : QWidget(parent) { diff --git a/cockatrice/src/interface/widgets/general/display/background_plate_widget.h b/cockatrice/src/interface/widgets/general/display/background_plate_widget.h index 1fc1bc3ba..6634069c4 100644 --- a/cockatrice/src/interface/widgets/general/display/background_plate_widget.h +++ b/cockatrice/src/interface/widgets/general/display/background_plate_widget.h @@ -2,6 +2,7 @@ #define COCKATRICE_BACKGROUND_PLATE_WIDGET_H #include +#include class BackgroundPlateWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/general/display/banner_widget.cpp b/cockatrice/src/interface/widgets/general/display/banner_widget.cpp index 5de5457ea..baf46bbba 100644 --- a/cockatrice/src/interface/widgets/general/display/banner_widget.cpp +++ b/cockatrice/src/interface/widgets/general/display/banner_widget.cpp @@ -2,10 +2,15 @@ #include "../../../pixel_map_generator.h" +#include +#include +#include #include -#include #include -#include +#include +#include +#include +#include BannerWidget::BannerWidget(QWidget *parent, const QString &text, Qt::Orientation orientation, int transparency_) : QWidget(parent), gradientOrientation(orientation), transparency(qBound(0, transparency_, 100)) @@ -32,6 +37,7 @@ BannerWidget::BannerWidget(QWidget *parent, const QString &text, Qt::Orientation // Set minimum height for the widget setMinimumHeight(50); + setMaximumHeight(100); connect(this, &BannerWidget::buddyVisibilityChanged, this, &BannerWidget::toggleBuddyVisibility); updateDropdownIconState(); diff --git a/cockatrice/src/interface/widgets/general/display/banner_widget.h b/cockatrice/src/interface/widgets/general/display/banner_widget.h index 77a05abb6..06556089b 100644 --- a/cockatrice/src/interface/widgets/general/display/banner_widget.h +++ b/cockatrice/src/interface/widgets/general/display/banner_widget.h @@ -10,8 +10,10 @@ #define BANNER_WIDGET_H #include -#include +#include #include +#include +#include class BannerWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_background_widget.cpp b/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_background_widget.cpp index f7ca669f3..3ea2df6bd 100644 --- a/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_background_widget.cpp +++ b/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_background_widget.cpp @@ -1,5 +1,17 @@ #include "bar_chart_background_widget.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + BarChartBackgroundWidget::BarChartBackgroundWidget(QWidget *parent) : QWidget(parent) { setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); diff --git a/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_background_widget.h b/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_background_widget.h index 06a17c7c6..1fc8a3bde 100644 --- a/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_background_widget.h +++ b/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_background_widget.h @@ -1,8 +1,8 @@ #ifndef COCKATRICE_BAR_CHART_BACKGROUND_WIDGET_H #define COCKATRICE_BAR_CHART_BACKGROUND_WIDGET_H -#include #include +#include class BarChartBackgroundWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.cpp b/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.cpp index 04315d0ce..484ac861d 100644 --- a/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.cpp +++ b/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.cpp @@ -1,9 +1,20 @@ #include "bar_chart_widget.h" +#include +#include +#include +#include #include #include #include +#include +#include +#include +#include +#include #include +#include +#include BarChartWidget::BarChartWidget(QWidget *parent) : QWidget(parent) { diff --git a/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.h b/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.h index e80a3f8e8..a596c094a 100644 --- a/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.h +++ b/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.h @@ -2,9 +2,12 @@ #define COCKATRICE_BAR_CHART_WIDGET_H #include +#include #include +#include #include #include +#include struct BarSegment { diff --git a/cockatrice/src/interface/widgets/general/display/charts/bars/bar_widget.cpp b/cockatrice/src/interface/widgets/general/display/charts/bars/bar_widget.cpp index 2194b6b03..82d15d5be 100644 --- a/cockatrice/src/interface/widgets/general/display/charts/bars/bar_widget.cpp +++ b/cockatrice/src/interface/widgets/general/display/charts/bars/bar_widget.cpp @@ -2,6 +2,10 @@ #include #include +#include +#include +#include +#include BarWidget::BarWidget(QString label, int value, int total, QColor barColor, QWidget *parent) : QWidget(parent), label(std::move(label)), value(value), total(total), barColor(barColor) diff --git a/cockatrice/src/interface/widgets/general/display/charts/bars/bar_widget.h b/cockatrice/src/interface/widgets/general/display/charts/bars/bar_widget.h index 05814f15b..18da1f939 100644 --- a/cockatrice/src/interface/widgets/general/display/charts/bars/bar_widget.h +++ b/cockatrice/src/interface/widgets/general/display/charts/bars/bar_widget.h @@ -8,7 +8,11 @@ #ifndef BAR_WIDGET_H #define BAR_WIDGET_H +#include +#include #include +#include +#include class BarWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/general/display/charts/bars/color_bar.cpp b/cockatrice/src/interface/widgets/general/display/charts/bars/color_bar.cpp index 1087c621e..28118cc9c 100644 --- a/cockatrice/src/interface/widgets/general/display/charts/bars/color_bar.cpp +++ b/cockatrice/src/interface/widgets/general/display/charts/bars/color_bar.cpp @@ -2,10 +2,21 @@ #include "libcockatrice/utility/color.h" +#include #include +#include #include #include +#include +#include +#include +#include +#include +#include #include +#include +#include +#include ColorBar::ColorBar(const QMap &_colors, QWidget *parent) : QWidget(parent), colors(GameSpecificColors::MTG::sortManaMapWUBRGCFirst(_colors)) diff --git a/cockatrice/src/interface/widgets/general/display/charts/bars/color_bar.h b/cockatrice/src/interface/widgets/general/display/charts/bars/color_bar.h index 1d435cec1..3acb7f97f 100644 --- a/cockatrice/src/interface/widgets/general/display/charts/bars/color_bar.h +++ b/cockatrice/src/interface/widgets/general/display/charts/bars/color_bar.h @@ -2,9 +2,13 @@ #define COCKATRICE_COLOR_BAR_H #include -#include +#include +#include #include #include +#include + +template class QMap; /** * @class ColorBar diff --git a/cockatrice/src/interface/widgets/general/display/charts/bars/percent_bar_widget.cpp b/cockatrice/src/interface/widgets/general/display/charts/bars/percent_bar_widget.cpp index 3726dd061..33195a518 100644 --- a/cockatrice/src/interface/widgets/general/display/charts/bars/percent_bar_widget.cpp +++ b/cockatrice/src/interface/widgets/general/display/charts/bars/percent_bar_widget.cpp @@ -1,5 +1,13 @@ #include "percent_bar_widget.h" +#include +#include +#include +#include +#include +#include +#include + PercentBarWidget::PercentBarWidget(QWidget *parent, double initialValue) : QWidget(parent), valueToDisplay(initialValue) { setMinimumSize(50, 10); diff --git a/cockatrice/src/interface/widgets/general/display/charts/bars/percent_bar_widget.h b/cockatrice/src/interface/widgets/general/display/charts/bars/percent_bar_widget.h index 9cd6039c8..f12cb8ae7 100644 --- a/cockatrice/src/interface/widgets/general/display/charts/bars/percent_bar_widget.h +++ b/cockatrice/src/interface/widgets/general/display/charts/bars/percent_bar_widget.h @@ -8,8 +8,9 @@ #ifndef PERCENT_BAR_WIDGET_H #define PERCENT_BAR_WIDGET_H -#include #include +#include +#include class PercentBarWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp b/cockatrice/src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp index 03c69373a..5a3130964 100644 --- a/cockatrice/src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp +++ b/cockatrice/src/interface/widgets/general/display/charts/bars/segmented_bar_widget.cpp @@ -1,9 +1,19 @@ #include "segmented_bar_widget.h" +#include +#include +#include #include #include #include +#include +#include +#include +#include +#include #include +#include +#include SegmentedBarWidget::SegmentedBarWidget(QString label, QVector segments, int total, QWidget *parent) : QWidget(parent), label(std::move(label)), segments(std::move(segments)), total(total) diff --git a/cockatrice/src/interface/widgets/general/display/charts/bars/segmented_bar_widget.h b/cockatrice/src/interface/widgets/general/display/charts/bars/segmented_bar_widget.h index 4df54e42e..c8c7dc2b9 100644 --- a/cockatrice/src/interface/widgets/general/display/charts/bars/segmented_bar_widget.h +++ b/cockatrice/src/interface/widgets/general/display/charts/bars/segmented_bar_widget.h @@ -2,8 +2,12 @@ #define COCKATRICE_SEGMENTED_BAR_WIDGET_H #include +#include +#include +#include #include #include +#include class SegmentedBarWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/general/display/charts/pies/color_pie.cpp b/cockatrice/src/interface/widgets/general/display/charts/pies/color_pie.cpp index bd4641981..82542a663 100644 --- a/cockatrice/src/interface/widgets/general/display/charts/pies/color_pie.cpp +++ b/cockatrice/src/interface/widgets/general/display/charts/pies/color_pie.cpp @@ -2,10 +2,22 @@ #include "libcockatrice/utility/color.h" +#include +#include #include #include +#include +#include +#include +#include +#include +#include #include +#include #include +#include +#include +#include ColorPie::ColorPie(const QMap &_colors, QWidget *parent) : QWidget(parent), colors(GameSpecificColors::MTG::sortManaMapWUBRGCFirst(_colors)) diff --git a/cockatrice/src/interface/widgets/general/display/charts/pies/color_pie.h b/cockatrice/src/interface/widgets/general/display/charts/pies/color_pie.h index 60d8e18f2..0fe023f63 100644 --- a/cockatrice/src/interface/widgets/general/display/charts/pies/color_pie.h +++ b/cockatrice/src/interface/widgets/general/display/charts/pies/color_pie.h @@ -1,6 +1,12 @@ #ifndef COCKATRICE_COLOR_PIE_H #define COCKATRICE_COLOR_PIE_H +#include +#include +#include +#include + +class QPoint; #ifndef COLOR_PIE_H #define COLOR_PIE_H diff --git a/cockatrice/src/interface/widgets/general/display/dynamic_font_size_label.cpp b/cockatrice/src/interface/widgets/general/display/dynamic_font_size_label.cpp index f485e0040..0609eb16a 100644 --- a/cockatrice/src/interface/widgets/general/display/dynamic_font_size_label.cpp +++ b/cockatrice/src/interface/widgets/general/display/dynamic_font_size_label.cpp @@ -1,7 +1,16 @@ #include "dynamic_font_size_label.h" -#define FONT_PRECISION (0.5) -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define FONT_PRECISION (0.5) DynamicFontSizeLabel::DynamicFontSizeLabel(QWidget *parent, Qt::WindowFlags f) : QLabel(parent, f) { diff --git a/cockatrice/src/interface/widgets/general/display/dynamic_font_size_label.h b/cockatrice/src/interface/widgets/general/display/dynamic_font_size_label.h index c9b159b02..cd029902a 100644 --- a/cockatrice/src/interface/widgets/general/display/dynamic_font_size_label.h +++ b/cockatrice/src/interface/widgets/general/display/dynamic_font_size_label.h @@ -9,6 +9,12 @@ #include #include +#include +#include +#include +#include + +class QWidget; class DynamicFontSizeLabel : public QLabel { diff --git a/cockatrice/src/interface/widgets/general/display/dynamic_font_size_push_button.cpp b/cockatrice/src/interface/widgets/general/display/dynamic_font_size_push_button.cpp index 653b2cd5c..25e8ca48f 100644 --- a/cockatrice/src/interface/widgets/general/display/dynamic_font_size_push_button.cpp +++ b/cockatrice/src/interface/widgets/general/display/dynamic_font_size_push_button.cpp @@ -2,7 +2,14 @@ #include "dynamic_font_size_label.h" +#include +#include +#include #include +#include +#include +#include +#include DynamicFontSizePushButton::DynamicFontSizePushButton(QWidget *parent) : QPushButton(parent) { diff --git a/cockatrice/src/interface/widgets/general/display/dynamic_font_size_push_button.h b/cockatrice/src/interface/widgets/general/display/dynamic_font_size_push_button.h index 540abfe6b..dfbb03d20 100644 --- a/cockatrice/src/interface/widgets/general/display/dynamic_font_size_push_button.h +++ b/cockatrice/src/interface/widgets/general/display/dynamic_font_size_push_button.h @@ -7,9 +7,12 @@ #ifndef DYNAMICFONTSIZEPUSHBUTTON_H #define DYNAMICFONTSIZEPUSHBUTTON_H -#include +#include #include -#include +#include +#include + +class QWidget; class DynamicFontSizePushButton : public QPushButton { diff --git a/cockatrice/src/interface/widgets/general/display/labeled_input.cpp b/cockatrice/src/interface/widgets/general/display/labeled_input.cpp index ee26c78d9..40ee91bcc 100644 --- a/cockatrice/src/interface/widgets/general/display/labeled_input.cpp +++ b/cockatrice/src/interface/widgets/general/display/labeled_input.cpp @@ -1,5 +1,12 @@ #include "labeled_input.h" +#include +#include +#include +#include +#include +#include + LabeledInput::LabeledInput(QWidget *parent, const QString &labelText) : QWidget(parent) { label = new QLabel(labelText, this); diff --git a/cockatrice/src/interface/widgets/general/display/labeled_input.h b/cockatrice/src/interface/widgets/general/display/labeled_input.h index 838dfb280..a07d50dbb 100644 --- a/cockatrice/src/interface/widgets/general/display/labeled_input.h +++ b/cockatrice/src/interface/widgets/general/display/labeled_input.h @@ -7,11 +7,15 @@ #ifndef LABELED_INPUT_H #define LABELED_INPUT_H -#include -#include -#include -#include +#include +#include #include +#include + +class QComboBox; +class QHBoxLayout; +class QLabel; +class QSpinBox; class LabeledInput final : public QWidget { diff --git a/cockatrice/src/interface/widgets/general/display/shadow_background_label.cpp b/cockatrice/src/interface/widgets/general/display/shadow_background_label.cpp index 7db18c57d..e1218a6fb 100644 --- a/cockatrice/src/interface/widgets/general/display/shadow_background_label.cpp +++ b/cockatrice/src/interface/widgets/general/display/shadow_background_label.cpp @@ -1,7 +1,13 @@ #include "shadow_background_label.h" -#include +#include +#include #include +#include +#include +#include + +class QWidget; /** * @class ShadowBackgroundLabel diff --git a/cockatrice/src/interface/widgets/general/display/shadow_background_label.h b/cockatrice/src/interface/widgets/general/display/shadow_background_label.h index 69232691e..e64b9ca5b 100644 --- a/cockatrice/src/interface/widgets/general/display/shadow_background_label.h +++ b/cockatrice/src/interface/widgets/general/display/shadow_background_label.h @@ -8,6 +8,10 @@ #define STYLEDLABEL_H #include +#include +#include + +class QWidget; class ShadowBackgroundLabel : public QLabel { diff --git a/cockatrice/src/interface/widgets/general/home_styled_button.cpp b/cockatrice/src/interface/widgets/general/home_styled_button.cpp index c7bdd1e5e..7f23827bc 100644 --- a/cockatrice/src/interface/widgets/general/home_styled_button.cpp +++ b/cockatrice/src/interface/widgets/general/home_styled_button.cpp @@ -1,9 +1,19 @@ #include "home_styled_button.h" +#include +#include #include #include -#include -#include +#include +#include +#include +#include +#include +#include +#include +#include + +class QWidget; HomeStyledButton::HomeStyledButton(const QString &text, QPair _gradientColors, QWidget *parent) : QPushButton(text, parent), gradientColors(_gradientColors) diff --git a/cockatrice/src/interface/widgets/general/home_styled_button.h b/cockatrice/src/interface/widgets/general/home_styled_button.h index f8d97e9aa..55ac5550f 100644 --- a/cockatrice/src/interface/widgets/general/home_styled_button.h +++ b/cockatrice/src/interface/widgets/general/home_styled_button.h @@ -6,7 +6,13 @@ #ifndef HOME_STYLED_BUTTON_H #define HOME_STYLED_BUTTON_H +#include +#include #include +#include +#include + +class QWidget; class HomeStyledButton : public QPushButton { diff --git a/cockatrice/src/interface/widgets/general/home_tab_button_color.h b/cockatrice/src/interface/widgets/general/home_tab_button_color.h new file mode 100644 index 000000000..b45bd7a92 --- /dev/null +++ b/cockatrice/src/interface/widgets/general/home_tab_button_color.h @@ -0,0 +1,49 @@ +#ifndef COCKATRICE_HOME_TAB_BUTTON_COLOR_H +#define COCKATRICE_HOME_TAB_BUTTON_COLOR_H + +#include + +namespace HomeTabButtonColor +{ + +/** + * @brief Where to get the colors for the home tab buttons from + */ +enum Source +{ + FromThemeColors, ///< Use the theme's identity accent colors + FromBackground, ///< Extract colour from the background image +}; + +struct Entry +{ + Source source; + const char *trKey; ///< key for translation +}; + +inline QList all() +{ + static QList entries = {{FromThemeColors, QT_TR_NOOP("From theme colors")}, + {FromBackground, QT_TR_NOOP("Extract from background")}}; + + return entries; +} + +/** + * Safely converts an int into the corresponding Source. + * + * @param value The int value + * @return The Source. Returns Source::FromThemeColors if the value is not within range + */ +inline Source intToSource(int value) +{ + if (value > FromBackground) { + return FromThemeColors; // default + } + + return static_cast(value); +} + +} // namespace HomeTabButtonColor + +#endif // COCKATRICE_HOME_TAB_BUTTON_COLOR_H diff --git a/cockatrice/src/interface/widgets/general/home_widget.cpp b/cockatrice/src/interface/widgets/general/home_widget.cpp index aed4ff955..968a9a991 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.cpp +++ b/cockatrice/src/interface/widgets/general/home_widget.cpp @@ -2,31 +2,79 @@ #include "../../../client/settings/cache_settings.h" #include "../../../interface/widgets/tabs/tab_supervisor.h" +#include "../../deck_loader/deck_file_format.h" +#include "../../deck_loader/deck_loader.h" +#include "../../deck_loader/loaded_deck.h" +#include "../../pixel_map_generator.h" +#include "../../theme_config.h" #include "../../theme_manager.h" #include "../../window_main.h" +#include "../cards/art_crop_attribution.h" +#include "../cards/card_info_picture_art_crop_widget.h" +#include "abstract_client.h" #include "background_sources.h" #include "home_styled_button.h" +#include "home_tab_button_color.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/database/card_database.h" +#include "libcockatrice/card/database/card_database_loader.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/card/printing/printing_info.h" +#include "libcockatrice/card/set/card_set.h" +#include "libcockatrice/utility/card_ref.h" #include "tutorial/tutorial_controller.h" +#include +#include +#include +#include +#include +#include #include +#include +#include +#include +#include +#include +#include #include #include +#include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include +#include +#include +#include #include #include #include #include #include +#include +#include +#include +#include HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor) - : QWidget(parent), tabSupervisor(_tabSupervisor), background("theme:backgrounds/home"), overlay("theme:cockatrice") + : QWidget(parent), tabSupervisor(_tabSupervisor), background(themePixmap(QStringLiteral("backgrounds/home"))) { layout = new QGridLayout(this); backgroundSourceCard = new CardInfoPictureArtCropWidget(this); - gradientColors = extractDominantColors(background); + gradientColors = determineButtonColor(); layout->addWidget(createButtons(), 1, 1, Qt::AlignVCenter | Qt::AlignHCenter); @@ -79,10 +127,19 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor) // Lambda is cleaner to read than overloading this connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabDisplayCardNameChanged, this, [this] { repaint(); }); + connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabBackgroundDimChanged, this, + [this] { repaint(); }); connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, &HomeWidget::initializeBackgroundFromSource); connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, &HomeWidget::updateButtonsToBackgroundColor); + // Scheme flips (light/dark/system with an OS switch) fire on themeManager, + // not on SettingsCache::themeChanged, so re-resolve the variant background. + connect(themeManager, &ThemeManager::themeChanged, this, &HomeWidget::initializeBackgroundFromSource); + connect(themeManager, &ThemeManager::paletteChanged, this, &HomeWidget::updateButtonsToBackgroundColor); + connect(themeManager, &ThemeManager::paletteChanged, this, &HomeWidget::updateLogoOverlay); + connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabButtonColorChanged, this, + &HomeWidget::updateButtonsToBackgroundColor); } void HomeWidget::showEvent(QShowEvent *event) @@ -97,6 +154,10 @@ void HomeWidget::showEvent(QShowEvent *event) void HomeWidget::initializeBackgroundFromSource() { + // The featured logo is theme/scheme-derived too; reload it alongside the + // background so a theme or appearance switch doesn't leave it stale. + updateLogoOverlay(); + if (CardDatabaseManager::getInstance()->getLoadStatus() != LoadStatus::Ok) { connect(CardDatabaseManager::getInstance(), &CardDatabase::cardDatabaseLoadingFinished, this, &HomeWidget::initializeBackgroundFromSource); @@ -109,7 +170,7 @@ void HomeWidget::initializeBackgroundFromSource() switch (backgroundSourceType) { case BackgroundSources::Theme: cardChangeTimer->stop(); - background = QPixmap("theme:backgrounds/home"); + background = themePixmap(QStringLiteral("backgrounds/home")); backgroundSourceDeck = DeckList(); backgroundSourceCard->setCard(ExactCard()); updateButtonsToBackgroundColor(); @@ -135,6 +196,26 @@ void HomeWidget::loadBackgroundSourceDeck() backgroundSourceDeck = deckOpt.has_value() ? deckOpt.value().deckList : DeckList(); } +static QPair paletteDerivedButtonColors() +{ + return {themeManager->appColor(AppColor::AccentStrong), themeManager->appColor(AppColor::AccentSoft)}; +} + +QPair HomeWidget::determineButtonColor() const +{ + auto colorSource = + HomeTabButtonColor::intToSource(SettingsCache::instance().appearance().getHomeTabButtonColorSourceIndex()); + + switch (colorSource) { + case HomeTabButtonColor::FromThemeColors: + return paletteDerivedButtonColors(); + case HomeTabButtonColor::FromBackground: + return extractDominantColors(background); + } + + return paletteDerivedButtonColors(); +} + void HomeWidget::setRandomCard(ExactCard &newCard) { static constexpr int ATTEMPTS = 10; @@ -209,7 +290,7 @@ void HomeWidget::updateBackgroundProperties() void HomeWidget::updateButtonsToBackgroundColor() { - gradientColors = extractDominantColors(background); + gradientColors = determineButtonColor(); for (HomeStyledButton *button : findChildren()) { button->updateStylesheet(gradientColors); button->update(); @@ -236,10 +317,10 @@ QGroupBox *HomeWidget::createButtons() QVBoxLayout *boxLayout = new QVBoxLayout; boxLayout->setAlignment(Qt::AlignHCenter); - QLabel *logoLabel = new QLabel; - logoLabel->setPixmap(overlay.scaledToWidth(200, Qt::SmoothTransformation)); + logoLabel = new QLabel; logoLabel->setAlignment(Qt::AlignCenter); boxLayout->addWidget(logoLabel); + updateLogoOverlay(); boxLayout->addSpacing(25); connectButton = new HomeStyledButton("Connect/Play", gradientColors); @@ -304,11 +385,6 @@ void HomeWidget::updateConnectButton(const ClientStatus status) QPair HomeWidget::extractDominantColors(const QPixmap &pixmap) { - if (themeManager->isBuiltInTheme() && SettingsCache::instance().appearance().getHomeTabBackgroundSource() == - BackgroundSources::toId(BackgroundSources::Theme)) { - return QPair(QColor::fromRgb(20, 140, 60), QColor::fromRgb(120, 200, 80)); - } - // Step 1: Downscale image for performance QImage image = pixmap.toImage() .scaled(64, 64, Qt::KeepAspectRatio, Qt::SmoothTransformation) @@ -372,16 +448,19 @@ void HomeWidget::paintEvent(QPaintEvent *event) painter.drawPixmap(topLeft, toDraw); } - // Draw translucent black overlay with rounded corners - QRectF overlayRect(5, 5, width() - 10, height() - 10); - QPainterPath roundedRectPath; - roundedRectPath.addRoundedRect(overlayRect, 20, 20); + if (SettingsCache::instance().appearance().getHomeTabBackgroundDim()) { + // Draw translucent black overlay with rounded corners + QRectF overlayRect(5, 5, width() - 10, height() - 10); + QPainterPath roundedRectPath; + roundedRectPath.addRoundedRect(overlayRect, 20, 20); - QColor semiTransparentBlack(0, 0, 0, static_cast(255 * 0.33)); - painter.fillPath(roundedRectPath, semiTransparentBlack); + QColor semiTransparentBlack(0, 0, 0, static_cast(255 * 0.33)); + painter.fillPath(roundedRectPath, semiTransparentBlack); + } - // Card name overlay (bottom-right) + // Card name overlay (above the attribution, bottom-right) QString cardName; + QString attribution; ExactCard card = backgroundSourceCard->getCard(); if (card) { cardName = card.getCardPtr()->getName(); @@ -389,8 +468,27 @@ void HomeWidget::paintEvent(QPaintEvent *event) cardName += " (" + card.getPrinting().getSet()->getCorrectedShortName() + ") " + card.getPrinting().getProperty("num"); } + attribution = buildArtAttribution(card); } + // Scryfall requires artist attribution wherever card art is shown cropped. + // Pin it to the bottom-right corner, using the same font as the card name pill, + // and align its right edge with the card name pill's right edge. + constexpr int margin = 15; + constexpr qreal attributionMargin = 4.0; + + QFont attributionFont = painter.font(); + attributionFont.setPointSize(14); + attributionFont.setBold(true); + painter.setFont(attributionFont); + + // paintArtAttribution insets the pill 4px from the given rect's right edge, + // so nudge the rect's right edge to land exactly on the pill's right edge. + QRectF attributionArea = rect(); + attributionArea.setRight(width() - margin + attributionMargin); + const QRectF attributionRect = paintArtAttribution(painter, attributionArea, attribution); + + // Card name bubble above the attribution (when enabled). if (!cardName.isEmpty() && SettingsCache::instance().appearance().getHomeTabDisplayCardName()) { QFont font = painter.font(); font.setPointSize(14); @@ -399,24 +497,80 @@ void HomeWidget::paintEvent(QPaintEvent *event) QFontMetrics fm(font); constexpr int padding = 10; - constexpr int margin = 15; QRect textRect = fm.boundingRect(cardName); - QRect bgRect(width() - textRect.width() - padding * 2 - margin, - height() - textRect.height() - padding * 2 - margin, textRect.width() + padding * 2, - textRect.height() + padding * 2); + int bubbleBottom = height() - margin; + if (!attributionRect.isEmpty()) { + bubbleBottom = attributionRect.top() - 6; + } + const QRect nameBubbleRect(width() - textRect.width() - padding * 2 - margin, + bubbleBottom - textRect.height() - padding * 2, textRect.width() + padding * 2, + textRect.height() + padding * 2); // Background bubble painter.setPen(Qt::NoPen); painter.setBrush(QColor(0, 0, 0, 160)); - painter.drawRoundedRect(bgRect, 8, 8); + painter.drawRoundedRect(nameBubbleRect, 8, 8); // Text painter.setPen(Qt::white); - painter.drawText(bgRect.adjusted(padding, padding, -padding, -padding), Qt::AlignRight | Qt::AlignVCenter, - cardName); + painter.drawText(nameBubbleRect.adjusted(padding, padding, -padding, -padding), + Qt::AlignRight | Qt::AlignVCenter, cardName); } QWidget::paintEvent(event); } + +void HomeWidget::updateLogoOverlay() +{ + // Emulate cockatrice.svg in Qt rather than rendering the baked-in SVG. + // The SVG has no separate plate: the gradient fills the bird's silhouette + // paths (light #c9fd62/AccentSoft at the top-left, dark #139740/AccentStrong + // toward the bottom-right — the SVG's linearGradient4265-7-8 stops along + // its userSpaceOnUse axis), and the white highlight path + // (cockatrice-logo-white) sits on top. So we paint that gradient clipped to + // the full logo silhouette (the full-color logo's alpha), then overlay the + // white mark. Colours stay fully theme-driven and independent of the static + // greens baked into the SVG. + const QColor strong = themeManager->appColor(AppColor::AccentStrong); + const QColor soft = themeManager->appColor(AppColor::AccentSoft); + + const QPixmap silhouette = themePixmap(QStringLiteral("cockatrice")).scaledToWidth(200, Qt::SmoothTransformation); + const QPixmap whiteMark = + themePixmap(QStringLiteral("cockatrice-logo-white")).scaledToWidth(200, Qt::SmoothTransformation); + if (silhouette.isNull() || whiteMark.isNull()) { + return; + } + + QPixmap composite(silhouette.size()); + composite.fill(Qt::transparent); + + { + QPainter painter(&composite); + painter.setRenderHint(QPainter::Antialiasing); + painter.setRenderHint(QPainter::SmoothPixmapTransform); + + // Recreate cockatrice.svg's own gradient geometry (linearGradient + // 4265-7-8, userSpaceOnUse): light AccentSoft at S=(-8.097,-97.746), + // dark AccentStrong at E=(162.455,295.208), on the SVG's 300x300 + // canvas. Scale those coordinates to this composite's size. + const qreal scale = composite.width() / 300.0; + QLinearGradient gradient(QPointF(-8.097, -97.746) * scale, QPointF(162.455, 295.208) * scale); + gradient.setColorAt(0.0, soft); + gradient.setColorAt(1.0, strong); + painter.fillRect(composite.rect(), gradient); + + // Clip the gradient to the full logo silhouette exactly as the SVG's + // gradient paths are confined to the bird. + painter.setCompositionMode(QPainter::CompositionMode_DestinationIn); + painter.drawPixmap(0, 0, silhouette); + + painter.setCompositionMode(QPainter::CompositionMode_SourceOver); + painter.drawPixmap(0, 0, whiteMark); + } + + if (logoLabel) { + logoLabel->setPixmap(composite); + } +} diff --git a/cockatrice/src/interface/widgets/general/home_widget.h b/cockatrice/src/interface/widgets/general/home_widget.h index 6dbb49757..0d3dd625a 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.h +++ b/cockatrice/src/interface/widgets/general/home_widget.h @@ -7,13 +7,23 @@ #ifndef HOME_WIDGET_H #define HOME_WIDGET_H -#include "../../../interface/widgets/tabs/tab_supervisor.h" -#include "../cards/card_info_picture_art_crop_widget.h" -#include "home_styled_button.h" +#include "libcockatrice/deck_list/deck_list.h" -#include +#include +#include +#include #include #include +#include + +class QGridLayout; +class QLabel; +class CardInfoPictureArtCropWidget; +class ExactCard; +class HomeStyledButton; +class QGroupBox; +class QTimer; +class TabSupervisor; class TutorialController; @@ -25,7 +35,7 @@ class HomeWidget : public QWidget public: HomeWidget(QWidget *parent, TabSupervisor *tabSupervisor); void updateRandomCard(); - QPair extractDominantColors(const QPixmap &pixmap); + static QPair extractDominantColors(const QPixmap &pixmap); public slots: void paintEvent(QPaintEvent *event) override; @@ -46,7 +56,7 @@ private: bool tutorialStarted = false; CardInfoPictureArtCropWidget *backgroundSourceCard = nullptr; DeckList backgroundSourceDeck; - QPixmap overlay; + QLabel *logoLabel = nullptr; QPair gradientColors; HomeStyledButton *connectButton; @@ -60,6 +70,8 @@ private: void setRandomCard(ExactCard &newCard); void loadBackgroundSourceDeck(); + QPair determineButtonColor() const; + void updateLogoOverlay(); }; #endif // HOME_WIDGET_H diff --git a/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp b/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp index 025f457bd..6a6025ec2 100644 --- a/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp +++ b/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.cpp @@ -6,9 +6,17 @@ #include "flow_widget.h" +#include "../../../layouts/flow_layout.h" + +#include #include +#include +#include +#include +#include #include #include +#include #include #include @@ -80,13 +88,35 @@ FlowWidget::FlowWidget(QWidget *parent, /** * @brief Adds a widget to the flow layout within the FlowWidget. * + * Plain widgets are not filtered for arrow keys: intercepting them would steal + * Up/Down/Left/Right from controls that use them (combo boxes, spin boxes + * etc.). Widgets that want keyboard navigation between flow items must be + * added via addNavigableWidget instead. + * * @param widget_to_add The widget to add to the flow layout. */ -void FlowWidget::addWidget(QWidget *widget_to_add) const +void FlowWidget::addWidget(QWidget *widget_to_add) { flowLayout->addWidget(widget_to_add); } +/** + * @brief Adds a widget and routes its arrow keys to FlowWidget focus navigation. + * + * The widget is filtered for arrow-key events so keyboard navigation between + * the flow items keeps working even when the flow sits inside a QScrollArea, + * which swallows arrow keys before they can reach FlowWidget::keyPressEvent. + * Only widgets added through this method are affected; anything that needs its + * own arrow keys should use plain addWidget. + * + * @param widget_to_add The widget to add to the flow layout. + */ +void FlowWidget::addNavigableWidget(QWidget *widget_to_add) +{ + widget_to_add->installEventFilter(this); + flowLayout->addWidget(widget_to_add); +} + void FlowWidget::insertWidgetAtIndex(QWidget *toInsert, int index) { flowLayout->insertWidgetAtIndex(toInsert, index); @@ -177,6 +207,66 @@ QLayoutItem *FlowWidget::itemAt(int index) const return flowLayout->itemAt(index); } +void FlowWidget::keyPressEvent(QKeyEvent *event) +{ + if (moveFocus(event)) { + event->accept(); + return; + } + QWidget::keyPressEvent(event); +} + +bool FlowWidget::eventFilter(QObject *watched, QEvent *event) +{ + if (event->type() == QEvent::KeyPress && moveFocus(static_cast(event))) { + return true; + } + return QWidget::eventFilter(watched, event); +} + +bool FlowWidget::moveFocus(QKeyEvent *event) +{ + // Keyboard navigation between the flow items: arrow keys move focus just + // like clicking the sibling tiles would. Only items that can take keyboard + // focus (e.g. the deck-preview tiles in shared-deck links) are visited. + const bool moveForward = event->key() == Qt::Key_Right || event->key() == Qt::Key_Down; + const bool moveBackward = event->key() == Qt::Key_Left || event->key() == Qt::Key_Up; + if (!moveForward && !moveBackward) { + return false; + } + + QList focusableItems; + for (int i = 0; i < flowLayout->count(); ++i) { + QWidget *item = flowLayout->itemAt(i)->widget(); + if (item != nullptr && (item->focusPolicy() & Qt::TabFocus)) { + focusableItems.append(item); + } + } + + if (focusableItems.isEmpty()) { + return false; + } + + int currentIndex = -1; + for (int i = 0; i < focusableItems.size(); ++i) { + if (focusableItems.at(i)->hasFocus()) { + currentIndex = i; + break; + } + } + + const int delta = moveForward ? 1 : -1; + int nextIndex; + if (currentIndex < 0) { + nextIndex = moveForward ? 0 : focusableItems.size() - 1; + } else { + nextIndex = (currentIndex + delta + focusableItems.size()) % focusableItems.size(); + } + focusableItems.value(nextIndex)->setFocus(); + event->accept(); + return true; +} + int FlowWidget::count() const { return flowLayout->count(); diff --git a/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.h b/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.h index a232336d8..dbf33d07f 100644 --- a/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.h +++ b/cockatrice/src/interface/widgets/general/layout_containers/flow_widget.h @@ -8,12 +8,17 @@ #ifndef FLOW_WIDGET_H #define FLOW_WIDGET_H -#include "../../../layouts/flow_layout.h" - -#include #include -#include #include +#include +#include +#include + +class FlowLayout; +class QHBoxLayout; +class QKeyEvent; +class QLayoutItem; +class QScrollArea; inline Q_LOGGING_CATEGORY(FlowWidgetLog, "flow_widget", QtInfoMsg); inline Q_LOGGING_CATEGORY(FlowWidgetSizeLog, "flow_widget.size", QtInfoMsg); @@ -28,7 +33,8 @@ public: Qt::ScrollBarPolicy horizontalPolicy, Qt::ScrollBarPolicy verticalPolicy); - void addWidget(QWidget *widget_to_add) const; + void addWidget(QWidget *widget_to_add); + void addNavigableWidget(QWidget *widget_to_add); void insertWidgetAtIndex(QWidget *toInsert, int index); void removeWidget(QWidget *widgetToRemove) const; void clearLayout(); @@ -43,9 +49,15 @@ public slots: void setSpacing(int hSpacing, int vSpacing); protected: + bool eventFilter(QObject *watched, QEvent *event) override; void resizeEvent(QResizeEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; private: + /// @brief Moves keyboard focus to an adjacent flow item for an arrow-key event. + /// @return True when the event was an arrow key and was handled. + bool moveFocus(QKeyEvent *event); + Qt::Orientation flowDirection; QHBoxLayout *mainLayout; FlowLayout *flowLayout; diff --git a/cockatrice/src/interface/widgets/general/layout_containers/overlap_control_widget.cpp b/cockatrice/src/interface/widgets/general/layout_containers/overlap_control_widget.cpp index 97068978d..8cfe190e2 100644 --- a/cockatrice/src/interface/widgets/general/layout_containers/overlap_control_widget.cpp +++ b/cockatrice/src/interface/widgets/general/layout_containers/overlap_control_widget.cpp @@ -1,7 +1,11 @@ #include "overlap_control_widget.h" +#include "../display/labeled_input.h" #include "overlap_widget.h" +#include +#include + OverlapControlWidget::OverlapControlWidget(int overlapPercentage, int maxColumns, int maxRows, diff --git a/cockatrice/src/interface/widgets/general/layout_containers/overlap_control_widget.h b/cockatrice/src/interface/widgets/general/layout_containers/overlap_control_widget.h index 104a97da9..c5639110f 100644 --- a/cockatrice/src/interface/widgets/general/layout_containers/overlap_control_widget.h +++ b/cockatrice/src/interface/widgets/general/layout_containers/overlap_control_widget.h @@ -6,12 +6,14 @@ #ifndef OVERLAP_CONTROL_WIDGET_H #define OVERLAP_CONTROL_WIDGET_H -#include "../display/labeled_input.h" -#include "overlap_widget.h" - -#include -#include #include +#include +#include + +class LabeledInput; +class OverlapWidget; +class QHBoxLayout; +class QSlider; class OverlapControlWidget final : public QWidget { diff --git a/cockatrice/src/interface/widgets/general/layout_containers/overlap_widget.cpp b/cockatrice/src/interface/widgets/general/layout_containers/overlap_widget.cpp index 1c4ee30ab..03240211c 100644 --- a/cockatrice/src/interface/widgets/general/layout_containers/overlap_widget.cpp +++ b/cockatrice/src/interface/widgets/general/layout_containers/overlap_widget.cpp @@ -1,9 +1,9 @@ #include "overlap_widget.h" -#include "../../../layouts/flow_layout.h" +#include "../../../layouts/overlap_layout.h" +#include #include -#include /** * @class OverlapWidget diff --git a/cockatrice/src/interface/widgets/general/layout_containers/overlap_widget.h b/cockatrice/src/interface/widgets/general/layout_containers/overlap_widget.h index 62c620201..c103c5f80 100644 --- a/cockatrice/src/interface/widgets/general/layout_containers/overlap_widget.h +++ b/cockatrice/src/interface/widgets/general/layout_containers/overlap_widget.h @@ -7,9 +7,12 @@ #ifndef OVERLAP_WIDGET_H #define OVERLAP_WIDGET_H -#include "../../../layouts/overlap_layout.h" - +#include #include +#include +#include + +class OverlapLayout; class OverlapWidget final : public QWidget { diff --git a/cockatrice/src/interface/widgets/menus/deck_editor_menu.cpp b/cockatrice/src/interface/widgets/menus/deck_editor_menu.cpp index f057680be..b0503ec22 100644 --- a/cockatrice/src/interface/widgets/menus/deck_editor_menu.cpp +++ b/cockatrice/src/interface/widgets/menus/deck_editor_menu.cpp @@ -2,9 +2,14 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" +#include "../deck_editor/deck_editor_filter_dock_widget.h" #include "../tabs/abstract_tab_deck_editor.h" +#include +#include +#include #include + DeckEditorMenu::DeckEditorMenu(AbstractTabDeckEditor *parent) : QMenu(parent), deckEditor(parent) { aNewDeck = new QAction(QString(), this); @@ -28,6 +33,9 @@ DeckEditorMenu::DeckEditorMenu(AbstractTabDeckEditor *parent) : QMenu(parent), d aSaveDeckAs = new QAction(QString(), this); connect(aSaveDeckAs, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::actSaveDeckAs); + aShareDeck = new QAction(QString(), this); + connect(aShareDeck, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::actShareDeck); + aLoadDeckFromClipboard = new QAction(QString(), this); connect(aLoadDeckFromClipboard, &QAction::triggered, deckEditor, &AbstractTabDeckEditor::actLoadDeckFromClipboard); @@ -96,6 +104,7 @@ DeckEditorMenu::DeckEditorMenu(AbstractTabDeckEditor *parent) : QMenu(parent), d addMenu(loadRecentDeckMenu); addAction(aSaveDeck); addAction(aSaveDeckAs); + addAction(aShareDeck); addSeparator(); addAction(aLoadDeckFromClipboard); addMenu(editDeckInClipboardMenu); @@ -120,6 +129,7 @@ void DeckEditorMenu::setSaveStatus(bool newStatus) { aSaveDeck->setEnabled(newStatus); aSaveDeckAs->setEnabled(newStatus); + aShareDeck->setEnabled(newStatus); aSaveDeckToClipboard->setEnabled(newStatus); aSaveDeckToClipboardNoSetInfo->setEnabled(newStatus); aSaveDeckToClipboardRaw->setEnabled(newStatus); @@ -157,6 +167,7 @@ void DeckEditorMenu::retranslateUi() aClearRecents->setText(tr("Clear")); aSaveDeck->setText(tr("&Save deck")); aSaveDeckAs->setText(tr("Save deck &as...")); + aShareDeck->setText(tr("Share deck...")); aLoadDeckFromClipboard->setText(tr("Load deck from cl&ipboard...")); diff --git a/cockatrice/src/interface/widgets/menus/deck_editor_menu.h b/cockatrice/src/interface/widgets/menus/deck_editor_menu.h index eff9257bb..904fbb6f4 100644 --- a/cockatrice/src/interface/widgets/menus/deck_editor_menu.h +++ b/cockatrice/src/interface/widgets/menus/deck_editor_menu.h @@ -8,8 +8,11 @@ #define DECK_EDITOR_MENU_H #include +#include class AbstractTabDeckEditor; +class QAction; + class DeckEditorMenu : public QMenu { Q_OBJECT @@ -21,7 +24,8 @@ public: QAction *aNewDeck, *aLoadDeck, *aClearRecents, *aSaveDeck, *aSaveDeckAs, *aLoadDeckFromClipboard, *aEditDeckInClipboard, *aEditDeckInClipboardRaw, *aSaveDeckToClipboard, *aSaveDeckToClipboardNoSetInfo, *aSaveDeckToClipboardRaw, *aSaveDeckToClipboardRawNoSetInfo, *aPrintDeck, *aLoadDeckFromWebsite, - *aExportDeckDecklist, *aExportDeckDecklistXyz, *aAnalyzeDeckDeckstats, *aAnalyzeDeckTappedout, *aClose; + *aExportDeckDecklist, *aExportDeckDecklistXyz, *aAnalyzeDeckDeckstats, *aAnalyzeDeckTappedout, *aShareDeck, + *aClose; QMenu *loadRecentDeckMenu, *analyzeDeckMenu, *editDeckInClipboardMenu, *saveDeckToClipboardMenu; void setSaveStatus(bool newStatus); diff --git a/cockatrice/src/interface/widgets/onboarding/banner_shader_config.h b/cockatrice/src/interface/widgets/onboarding/banner_shader_config.h new file mode 100644 index 000000000..6008044ff --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/banner_shader_config.h @@ -0,0 +1,313 @@ +#ifndef BANNER_SHADER_CONFIG_H +#define BANNER_SHADER_CONFIG_H + +#include +#include + +/** + * Uniform values fed to brand_banner.frag, exposed to QML as the + * "bannerConfig" context property. + * + * Two independent "banks" (A/B) each carry their own mode/speed/seed so + * BrandBanner.qml can render both simultaneously and crossfade between + * them via opacity -- see frontIsA. The shared palette (colorA/colorB/ + * accent) and clock (time/aspect) apply to both banks identically, since + * only the foreground motif changes between onboarding pages, never the + * brand palette. + * + * Deliberately plain `property` (not `required property`) on the QML side + * -- a required-property shadowing bug bit the home-screen particle + * background before, and there's no reason to reintroduce that risk here. + */ +class BannerShaderConfig : public QObject +{ + Q_OBJECT + Q_PROPERTY(qreal time READ time WRITE setTime NOTIFY timeChanged) + Q_PROPERTY(qreal aspect READ aspect WRITE setAspect NOTIFY aspectChanged) + + Q_PROPERTY(qreal modeA READ modeA WRITE setModeA NOTIFY modeAChanged) + Q_PROPERTY(qreal speedA READ speedA WRITE setSpeedA NOTIFY speedAChanged) + Q_PROPERTY(qreal seedA READ seedA WRITE setSeedA NOTIFY seedAChanged) + + Q_PROPERTY(qreal modeB READ modeB WRITE setModeB NOTIFY modeBChanged) + Q_PROPERTY(qreal speedB READ speedB WRITE setSpeedB NOTIFY speedBChanged) + Q_PROPERTY(qreal seedB READ seedB WRITE setSeedB NOTIFY seedBChanged) + + Q_PROPERTY(bool frontIsA READ frontIsA WRITE setFrontIsA NOTIFY frontIsAChanged) + + Q_PROPERTY(QColor colorA READ colorA WRITE setColorA NOTIFY colorAChanged) + Q_PROPERTY(QColor colorB READ colorB WRITE setColorB NOTIFY colorBChanged) + Q_PROPERTY(QColor accent READ accent WRITE setAccent NOTIFY accentChanged) + Q_PROPERTY(QColor glowColor READ glowColor WRITE setGlowColor NOTIFY glowColorChanged) + Q_PROPERTY(QColor brandStrong READ brandStrong WRITE setBrandStrong NOTIFY brandStrongChanged) + Q_PROPERTY(QColor brandSoft READ brandSoft WRITE setBrandSoft NOTIFY brandSoftChanged) + Q_PROPERTY(qreal vignetteMin READ vignetteMin WRITE setVignetteMin NOTIFY vignetteMinChanged) + + Q_PROPERTY(bool logoVisible READ logoVisible WRITE setLogoVisible NOTIFY logoVisibleChanged) + Q_PROPERTY(qreal logoGlow READ logoGlow WRITE setLogoGlow NOTIFY logoGlowChanged) + +public: + explicit BannerShaderConfig(QObject *parent = nullptr) : QObject(parent) + { + } + + qreal time() const + { + return m_time; + } + void setTime(qreal v) + { + if (v != m_time) { + m_time = v; + emit timeChanged(); + } + } + + qreal aspect() const + { + return m_aspect; + } + void setAspect(qreal v) + { + if (v != m_aspect) { + m_aspect = v; + emit aspectChanged(); + } + } + + qreal modeA() const + { + return m_modeA; + } + void setModeA(qreal v) + { + if (v != m_modeA) { + m_modeA = v; + emit modeAChanged(); + } + } + qreal speedA() const + { + return m_speedA; + } + void setSpeedA(qreal v) + { + if (v != m_speedA) { + m_speedA = v; + emit speedAChanged(); + } + } + qreal seedA() const + { + return m_seedA; + } + void setSeedA(qreal v) + { + if (v != m_seedA) { + m_seedA = v; + emit seedAChanged(); + } + } + + qreal modeB() const + { + return m_modeB; + } + void setModeB(qreal v) + { + if (v != m_modeB) { + m_modeB = v; + emit modeBChanged(); + } + } + qreal speedB() const + { + return m_speedB; + } + void setSpeedB(qreal v) + { + if (v != m_speedB) { + m_speedB = v; + emit speedBChanged(); + } + } + qreal seedB() const + { + return m_seedB; + } + void setSeedB(qreal v) + { + if (v != m_seedB) { + m_seedB = v; + emit seedBChanged(); + } + } + + bool frontIsA() const + { + return m_frontIsA; + } + void setFrontIsA(bool v) + { + if (v != m_frontIsA) { + m_frontIsA = v; + emit frontIsAChanged(); + } + } + + QColor colorA() const + { + return m_colorA; + } + void setColorA(const QColor &c) + { + if (c != m_colorA) { + m_colorA = c; + emit colorAChanged(); + } + } + QColor colorB() const + { + return m_colorB; + } + void setColorB(const QColor &c) + { + if (c != m_colorB) { + m_colorB = c; + emit colorBChanged(); + } + } + QColor accent() const + { + return m_accent; + } + void setAccent(const QColor &c) + { + if (c != m_accent) { + m_accent = c; + emit accentChanged(); + } + } + + QColor glowColor() const + { + return m_glowColor; + } + void setGlowColor(const QColor &c) + { + if (c != m_glowColor) { + m_glowColor = c; + emit glowColorChanged(); + } + } + + QColor brandStrong() const + { + return m_brandStrong; + } + void setBrandStrong(const QColor &c) + { + if (c != m_brandStrong) { + m_brandStrong = c; + emit brandStrongChanged(); + } + } + + QColor brandSoft() const + { + return m_brandSoft; + } + void setBrandSoft(const QColor &c) + { + if (c != m_brandSoft) { + m_brandSoft = c; + emit brandSoftChanged(); + } + } + + qreal vignetteMin() const + { + return m_vignetteMin; + } + void setVignetteMin(qreal v) + { + if (v != m_vignetteMin) { + m_vignetteMin = v; + emit vignetteMinChanged(); + } + } + + bool logoVisible() const + { + return m_logoVisible; + } + void setLogoVisible(bool v) + { + if (v != m_logoVisible) { + m_logoVisible = v; + emit logoVisibleChanged(); + } + } + + qreal logoGlow() const + { + return m_logoGlow; + } + void setLogoGlow(qreal v) + { + if (v != m_logoGlow) { + m_logoGlow = v; + emit logoGlowChanged(); + } + } + +signals: + void timeChanged(); + void aspectChanged(); + void modeAChanged(); + void speedAChanged(); + void seedAChanged(); + void modeBChanged(); + void speedBChanged(); + void seedBChanged(); + void frontIsAChanged(); + void colorAChanged(); + void colorBChanged(); + void accentChanged(); + void glowColorChanged(); + void brandStrongChanged(); + void brandSoftChanged(); + void vignetteMinChanged(); + void logoVisibleChanged(); + void logoGlowChanged(); + +private: + qreal m_time = 0.0; + qreal m_aspect = 16.0 / 9.0; + + qreal m_modeA = 0.0; + qreal m_speedA = 1.0; + qreal m_seedA = 0.0; + + qreal m_modeB = 0.0; + qreal m_speedB = 1.0; + qreal m_seedB = 0.0; + + bool m_frontIsA = true; + + // Curated fallback seed values -- BannerHost overwrites these with + // palette-derived colours (see shader_banner_widget.cpp) before the first + // paint, so they only matter as a safe pre-first-apply default. + QColor m_colorA{0x1A, 0x1A, 0x20}; + QColor m_colorB{0x0E, 0x0E, 0x12}; + QColor m_accent{0x8B, 0xDD, 0x6B}; + QColor m_glowColor{Qt::white}; + QColor m_brandStrong{0x13, 0x97, 0x40}; + QColor m_brandSoft{0xC9, 0xFD, 0x62}; + qreal m_vignetteMin = 0.62; + + bool m_logoVisible = false; + qreal m_logoGlow = 1.0; +}; + +#endif // BANNER_SHADER_CONFIG_H diff --git a/cockatrice/src/interface/widgets/onboarding/brand_colors.h b/cockatrice/src/interface/widgets/onboarding/brand_colors.h new file mode 100644 index 000000000..bf173270b --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/brand_colors.h @@ -0,0 +1,15 @@ +#ifndef BRAND_COLORS_H +#define BRAND_COLORS_H + +#include + +/** @brief Cockatrice brand green. + * + * Single source of truth for the onboarding brand accent: it backs the + * banner's shader-accent uniform as the curated fallback when the active + * palette resolves no usable Highlight, and it preseads the wizard's + * QuickSetupPanel so a freshly generated palette keeps the brand identity + * until the user picks their own look. */ +inline const QColor kCockatriceBrandGreen(0x8B, 0xDD, 0x6B); + +#endif // BRAND_COLORS_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp new file mode 100644 index 000000000..426811ec2 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp @@ -0,0 +1,228 @@ +#include "first_run_wizard.h" + +#include "first_run_wizard_page.h" +#include "pages/account_setup_page.h" +#include "pages/card_database_setup_page.h" +#include "pages/finish_page.h" +#include "pages/preferences_setup_page.h" +#include "pages/theme_setup_page.h" +#include "pages/welcome_page.h" +#include "shader_banner_widget.h" +#include "step_indicator_widget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class QWidget; + +FirstRunWizard::FirstRunWizard(QWidget *parent) : QDialog(parent) +{ + setWindowFlag(Qt::WindowContextHelpButtonHint, false); + setMinimumSize(640, 490); + resize(720, 550); + + bannerHost = new BannerHost(this); + + titleLabel = new QLabel(this); + QFont titleFont = titleLabel->font(); + titleFont.setPointSizeF(titleFont.pointSizeF() * 1.4); + titleFont.setBold(true); + titleLabel->setFont(titleFont); + + subtitleLabel = new QLabel(this); + subtitleLabel->setWordWrap(true); + + stack = new QStackedWidget(this); + stepIndicator = new StepIndicatorWidget(this); + + backButton = new QPushButton(this); + skipButton = new QPushButton(this); + nextButton = new QPushButton(this); + nextButton->setDefault(true); + + connect(backButton, &QPushButton::clicked, this, &FirstRunWizard::goBack); + connect(skipButton, &QPushButton::clicked, this, &FirstRunWizard::skip); + connect(nextButton, &QPushButton::clicked, this, &FirstRunWizard::goNext); + + auto *headerLayout = new QVBoxLayout; + headerLayout->setContentsMargins(0, 0, 0, 0); + headerLayout->addWidget(bannerHost); + headerLayout->addSpacing(12); + headerLayout->addWidget(titleLabel); + headerLayout->addWidget(subtitleLabel); + + auto *navLayout = new QHBoxLayout; + navLayout->addWidget(backButton); + navLayout->addWidget(skipButton); + navLayout->addStretch(); + navLayout->addWidget(stepIndicator); + navLayout->addStretch(); + navLayout->addWidget(nextButton); + + auto *root = new QVBoxLayout(this); + root->addLayout(headerLayout); + root->addSpacing(8); + root->addWidget(stack, 1); + root->addSpacing(8); + root->addLayout(navLayout); + + auto *welcome = new WelcomePage(this); + auto *cardDb = new CardDatabaseSetupPage(this); + auto *theme = new ThemeSetupPage(this); + auto *account = new AccountSetupPage(this); + auto *prefs = new PreferencesSetupPage(this); + auto *finishPg = new FinishPage(this); + + cardDatabasePage = cardDb; + + connect(cardDb, &CardDatabaseSetupPage::updateRequested, this, &FirstRunWizard::cardDatabaseUpdateRequested); + connect(cardDb, &CardDatabaseSetupPage::manualSetupRequested, this, + &FirstRunWizard::manualCardDatabaseSetupRequested); + connect(account, &AccountSetupPage::registerRequested, this, &FirstRunWizard::registerRequested); + connect(account, &AccountSetupPage::connectRequested, this, &FirstRunWizard::connectRequested); + + connect(cardDb, &CardDatabaseSetupPage::advanceRequested, this, [this] { + if (stack->currentWidget() == cardDatabasePage) { + showPage(currentIndex + 1); + } + }); + + addPage(welcome); + addPage(cardDb); + addPage(theme); + addPage(account); + addPage(prefs); + addPage(finishPg); + + stepIndicator->setStepCount(pages.count()); + retranslateUi(); + showPage(0); +} + +void FirstRunWizard::addPage(FirstRunWizardPage *page) +{ + pages.append(page); + stack->addWidget(page); + connect(page, &FirstRunWizardPage::completeChanged, this, &FirstRunWizard::updateChrome); +} + +void FirstRunWizard::showPage(int index) +{ + if (index < 0 || index >= pages.count()) { + return; + } + currentIndex = index; + stack->setCurrentIndex(index); + pages[index]->initializePage(); + stepIndicator->setCurrentStep(index); + static const QList motifs = { + BannerHost::Motif::Welcome, BannerHost::Motif::CardDatabase, BannerHost::Motif::Theming, + BannerHost::Motif::Account, BannerHost::Motif::Preferences, BannerHost::Motif::Finish, + }; + if (index < motifs.size()) { + bannerHost->setMotif(motifs[index]); + } + titleLabel->setText(pages[index]->stepTitle()); + subtitleLabel->setText(pages[index]->stepSubtitle()); + subtitleLabel->setVisible(!pages[index]->stepSubtitle().isEmpty()); + updateChrome(); +} + +void FirstRunWizard::updateChrome() +{ + if (currentIndex < 0) { + return; + } + FirstRunWizardPage *page = pages[currentIndex]; + const bool isLast = (currentIndex == pages.count() - 1); + + backButton->setVisible(currentIndex > 0); + skipButton->setVisible(page->isSkippable()); + nextButton->setEnabled(page->isComplete()); + + QString customText = page->nextButtonText(); + if (!customText.isEmpty()) { + nextButton->setText(customText); + } else { + nextButton->setText(isLast ? tr("Finish") : tr("Next")); + } +} + +void FirstRunWizard::goNext() +{ + FirstRunWizardPage *page = pages[currentIndex]; + if (!page->validatePage() || !page->handleNextClick()) { + return; + } + if (currentIndex == pages.count() - 1) { + finish(); + return; + } + showPage(currentIndex + 1); +} + +void FirstRunWizard::goBack() +{ + showPage(currentIndex - 1); +} + +void FirstRunWizard::skip() +{ + showPage(currentIndex + 1); +} + +void FirstRunWizard::onCardDatabaseUpdateFinished(bool success) +{ + if (cardDatabasePage) { + cardDatabasePage->onUpdateFinished(success); + } +} + +void FirstRunWizard::onCardDatabaseUpdateProgress(const CardUpdateProgress &progress) +{ + if (cardDatabasePage) { + cardDatabasePage->onUpdateProgress(progress); + } +} + +void FirstRunWizard::finish() +{ + accept(); +} + +void FirstRunWizard::closeEvent(QCloseEvent *event) +{ + // Every step persists its own choice as it's made, so closing early + // isn't destructive -- treat it exactly like reaching the end. + QDialog::closeEvent(event); +} + +void FirstRunWizard::changeEvent(QEvent *event) +{ + if (event->type() == QEvent::LanguageChange) { + retranslateUi(); + } + QDialog::changeEvent(event); +} + +void FirstRunWizard::retranslateUi() +{ + setWindowTitle(tr("Welcome to Cockatrice")); + backButton->setText(tr("Back")); + skipButton->setText(tr("Skip")); + for (FirstRunWizardPage *page : std::as_const(pages)) { + page->retranslateUi(); + } + if (currentIndex >= 0) { + titleLabel->setText(pages[currentIndex]->stepTitle()); + subtitleLabel->setText(pages[currentIndex]->stepSubtitle()); + } + updateChrome(); +} diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h new file mode 100644 index 000000000..812985a9f --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h @@ -0,0 +1,80 @@ +#ifndef FIRST_RUN_WIZARD_H +#define FIRST_RUN_WIZARD_H + +#include "../../card_database_update/card_update_progress.h" + +#include +#include +#include +#include +#include + +class BannerHost; +class FirstRunWizardPage; +class StepIndicatorWidget; +class CardDatabaseSetupPage; +class QLabel; +class QPushButton; +class QStackedWidget; +class QWidget; + +/** @brief Polished first-run onboarding flow: card database setup, theme + * selection, server account setup, and a handful of key preferences. + * + * Deliberately ignorant of network/registration/download internals -- + * pages that need them emit request signals for MainWindow to fulfill. + * Every choice is written to SettingsCache as it's made (via the pages + * themselves, same as AppearanceSettingsPage does), so "Skip" or closing + * the window never discards anything already confirmed. */ +class FirstRunWizard : public QDialog +{ + Q_OBJECT + +public: + explicit FirstRunWizard(QWidget *parent = nullptr); + +signals: + void registerRequested(); + void connectRequested(); + void cardDatabaseUpdateRequested(); + void manualCardDatabaseSetupRequested(); + +public slots: + /** @brief Forwarded from MainWindow once the background card database update process exits. */ + void onCardDatabaseUpdateFinished(bool success); + + /** @brief Forwarded from MainWindow while the background card database update process runs. */ + void onCardDatabaseUpdateProgress(const CardUpdateProgress &progress); + +protected: + void closeEvent(QCloseEvent *event) override; + void changeEvent(QEvent *event) override; + +private slots: + void goNext(); + void goBack(); + void skip(); + void updateChrome(); + +private: + void addPage(FirstRunWizardPage *page); + void showPage(int index); + void retranslateUi(); + void finish(); + + QStackedWidget *stack; + StepIndicatorWidget *stepIndicator; + BannerHost *bannerHost; + QLabel *titleLabel; + QLabel *subtitleLabel; + QPushButton *backButton; + QPushButton *skipButton; + QPushButton *nextButton; + + CardDatabaseSetupPage *cardDatabasePage = nullptr; + + QList pages; + int currentIndex = -1; +}; + +#endif // FIRST_RUN_WIZARD_H diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard_page.cpp b/cockatrice/src/interface/widgets/onboarding/first_run_wizard_page.cpp new file mode 100644 index 000000000..6da8958f2 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard_page.cpp @@ -0,0 +1 @@ +#include "first_run_wizard_page.h" diff --git a/cockatrice/src/interface/widgets/onboarding/first_run_wizard_page.h b/cockatrice/src/interface/widgets/onboarding/first_run_wizard_page.h new file mode 100644 index 000000000..e7b818810 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard_page.h @@ -0,0 +1,77 @@ +#ifndef FIRST_RUN_WIZARD_PAGE_H +#define FIRST_RUN_WIZARD_PAGE_H + +#include +#include +#include + +/** @brief Base class for a single step of FirstRunWizard. + * + * QWidget-based rather than QWizardPage-based: FirstRunWizard is a + * QDialog + QStackedWidget shell (not a QWizard) so it can own the + * banner/step-dot chrome that QWizard's native styles don't give us + * consistent control over. Naming mirrors OracleWizardPage for + * familiarity only -- the two hierarchies are unrelated. */ +class FirstRunWizardPage : public QWidget +{ + Q_OBJECT + +public: + explicit FirstRunWizardPage(QWidget *parent = nullptr) : QWidget(parent) + { + } + + /** @brief Called every time the page becomes visible, including navigating back to it. */ + virtual void initializePage() + { + } + + /** @brief Called before advancing past this page. Return false to block navigation; + the page itself is responsible for telling the user why. */ + virtual bool validatePage() + { + return true; + } + + /** @brief Whether Next/Finish should currently be enabled. Pages doing async work + can flip this mid-step; emit completeChanged() when they do. */ + virtual bool isComplete() const + { + return true; + } + + /** @brief Whether the wizard's "Skip" button should be offered on this page. */ + virtual bool isSkippable() const + { + return false; + } + + virtual QString stepTitle() const = 0; + virtual QString stepSubtitle() const + { + return {}; + } + + /** @brief Override to replace the "Next"/"Finish" button text on this page. + Return an empty string to use the default label. */ + virtual QString nextButtonText() const + { + return {}; + } + + /** @brief Called when the user presses the Next button. Return true to allow + advancing to the next page, false to stay on this page (e.g. to + trigger an async action first). */ + virtual bool handleNextClick() + { + return true; + } + + virtual void retranslateUi() = 0; + +signals: + void completeChanged(); + void advanceRequested(); +}; + +#endif // FIRST_RUN_WIZARD_PAGE_H diff --git a/cockatrice/src/interface/widgets/onboarding/pages/account_setup_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/account_setup_page.cpp new file mode 100644 index 000000000..16a1d2254 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/account_setup_page.cpp @@ -0,0 +1,62 @@ +#include "account_setup_page.h" + +#include "../first_run_wizard_page.h" + +#include +#include +#include +#include +#include + +class QWidget; + +AccountSetupPage::AccountSetupPage(QWidget *parent) : FirstRunWizardPage(parent) +{ + bodyLabel = new QLabel(this); + bodyLabel->setWordWrap(true); + bodyLabel->setAlignment(Qt::AlignCenter); + + registerButton = new QPushButton(this); + connectButton = new QPushButton(this); + skipHintLabel = new QLabel(this); + skipHintLabel->setWordWrap(true); + skipHintLabel->setAlignment(Qt::AlignCenter); + + connect(registerButton, &QPushButton::clicked, this, &AccountSetupPage::registerRequested); + connect(connectButton, &QPushButton::clicked, this, &AccountSetupPage::connectRequested); + + auto *layout = new QVBoxLayout(this); + layout->addStretch(); + layout->addWidget(bodyLabel); + layout->addSpacing(16); + layout->addWidget(registerButton, 0, Qt::AlignHCenter); + layout->addWidget(connectButton, 0, Qt::AlignHCenter); + layout->addSpacing(16); + layout->addWidget(skipHintLabel); + layout->addStretch(); + + retranslateUi(); +} + +bool AccountSetupPage::isSkippable() const +{ + return true; +} + +QString AccountSetupPage::stepTitle() const +{ + return tr("Join a Server"); +} + +QString AccountSetupPage::stepSubtitle() const +{ + return tr("Optional — you can always do this later from the menu."); +} + +void AccountSetupPage::retranslateUi() +{ + bodyLabel->setText(tr("Playing online needs a server account.")); + registerButton->setText(tr("Register a new account…")); + connectButton->setText(tr("I already have one — Connect…")); + skipHintLabel->setText(tr("Just want to play locally? Skip this and connect whenever you're ready.")); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/onboarding/pages/account_setup_page.h b/cockatrice/src/interface/widgets/onboarding/pages/account_setup_page.h new file mode 100644 index 000000000..344b4e41a --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/account_setup_page.h @@ -0,0 +1,41 @@ +#ifndef ACCOUNT_SETUP_PAGE_H +#define ACCOUNT_SETUP_PAGE_H + +#include "../first_run_wizard_page.h" + +#include + +class QLabel; +class QPushButton; +class QWidget; + +/** @brief First-run account step. Does NOT embed DlgRegister's fields: they exist + * to be handed to ConnectionController's network registration flow, which + * this wizard has no visibility into. Reimplementing the fields here + * without that wiring would look functional and silently do nothing -- + * worse than reuse. So: a friendly landing spot that opens the *existing* + * DlgRegister / connect flow via signals FirstRunWizard forwards. */ +class AccountSetupPage : public FirstRunWizardPage +{ + Q_OBJECT + +public: + explicit AccountSetupPage(QWidget *parent = nullptr); + + bool isSkippable() const override; + QString stepTitle() const override; + QString stepSubtitle() const override; + void retranslateUi() override; + +signals: + void registerRequested(); + void connectRequested(); + +private: + QLabel *bodyLabel; + QPushButton *registerButton; + QPushButton *connectButton; + QLabel *skipHintLabel; +}; + +#endif // ACCOUNT_SETUP_PAGE_H diff --git a/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.cpp new file mode 100644 index 000000000..cb53a25f3 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.cpp @@ -0,0 +1,350 @@ +#include "card_database_setup_page.h" + +#include "../../client/settings/cache_settings.h" +#include "../first_run_wizard_page.h" +#include "libcockatrice/card/database/card_database.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +CardDatabaseSetupPage::CardDatabaseSetupPage(QWidget *parent) : FirstRunWizardPage(parent) +{ + statusLabel = new QLabel(this); + statusLabel->setWordWrap(true); + statusLabel->setAlignment(Qt::AlignCenter); + + progressBar = new QProgressBar(this); + progressBar->setRange(0, 0); + progressBar->setTextVisible(false); + progressBar->setFixedWidth(280); + + retryButton = new QPushButton(this); + manualButton = new QPushButton(this); + + connect(retryButton, &QPushButton::clicked, this, [this] { + setState(State::Running); + emit updateRequested(); + }); + connect(manualButton, &QPushButton::clicked, this, &CardDatabaseSetupPage::manualSetupRequested); + + // ── Advanced: custom download source ─────────────────────────────── + advancedToggleButton = new QPushButton(this); + advancedToggleButton->setCheckable(true); + advancedToggleButton->setChecked(false); + advancedToggleButton->setFlat(true); + advancedToggleButton->setStyleSheet("QPushButton { text-align: left; padding: 5px 12px; font-weight: bold; }" + "QPushButton:checked { }"); + + advancedPanel = new QWidget(this); + advancedPanel->setVisible(false); + + urlLineEdit = new QLineEdit(advancedPanel); + urlHintLabel = new QLabel(advancedPanel); + urlHintLabel->setWordWrap(true); + + restoreDefaultUrlButton = new QPushButton(advancedPanel); + applyAndRetryButton = new QPushButton(advancedPanel); + + connect(advancedToggleButton, &QPushButton::toggled, this, &CardDatabaseSetupPage::onToggleAdvanced); + connect(restoreDefaultUrlButton, &QPushButton::clicked, this, &CardDatabaseSetupPage::onRestoreDefaultUrl); + connect(applyAndRetryButton, &QPushButton::clicked, this, &CardDatabaseSetupPage::onApplyCustomUrl); + + auto *advancedButtonRow = new QHBoxLayout; + advancedButtonRow->addWidget(restoreDefaultUrlButton); + advancedButtonRow->addStretch(); + advancedButtonRow->addWidget(applyAndRetryButton); + + auto *advancedLayout = new QVBoxLayout(advancedPanel); + advancedLayout->setContentsMargins(12, 4, 12, 4); + advancedLayout->addWidget(urlLineEdit); + advancedLayout->addWidget(urlHintLabel); + advancedLayout->addLayout(advancedButtonRow); + + // ── Startup card update check ─────────────────────────────────────── + auto &upd = SettingsCache::instance().updates(); + + const auto updateBehavior = [this] { + auto &u = SettingsCache::instance().updates(); + int idx = startupBehaviorCombo->currentIndex(); + u.setStartupCardUpdateCheckPromptForUpdate(idx == 1); + u.setStartupCardUpdateCheckAlwaysUpdate(idx == 2); + }; + + startupBehaviorLabel = new QLabel(this); + startupBehaviorCombo = new QComboBox(this); + startupBehaviorCombo->addItem(QString()); // placeholder, filled in retranslateUi + startupBehaviorCombo->addItem(QString()); + startupBehaviorCombo->addItem(QString()); + if (upd.getStartupCardUpdateCheckPromptForUpdate()) { + startupBehaviorCombo->setCurrentIndex(1); + } else if (upd.getStartupCardUpdateCheckAlwaysUpdate()) { + startupBehaviorCombo->setCurrentIndex(2); + } else { + startupBehaviorCombo->setCurrentIndex(0); + } + connect(startupBehaviorCombo, QOverload::of(&QComboBox::currentIndexChanged), this, updateBehavior); + + checkIntervalLabel = new QLabel(this); + checkIntervalSpinBox = new QSpinBox(this); + checkIntervalSpinBox->setMinimum(1); + checkIntervalSpinBox->setMaximum(30); + checkIntervalSpinBox->setValue(upd.getCardUpdateCheckInterval()); + connect(checkIntervalSpinBox, QOverload::of(&QSpinBox::valueChanged), &upd, + &UpdatesSettings::setCardUpdateCheckInterval); + + auto *checkGrid = new QGridLayout; + checkGrid->addWidget(startupBehaviorLabel, 0, 0); + checkGrid->addWidget(startupBehaviorCombo, 0, 1); + checkGrid->addWidget(checkIntervalLabel, 1, 0); + checkGrid->addWidget(checkIntervalSpinBox, 1, 1); + + auto *layout = new QVBoxLayout(this); + layout->addStretch(); + layout->addWidget(statusLabel); + layout->addSpacing(12); + layout->addWidget(progressBar, 0, Qt::AlignHCenter); + layout->addSpacing(12); + layout->addWidget(retryButton, 0, Qt::AlignHCenter); + layout->addWidget(manualButton, 0, Qt::AlignHCenter); + layout->addSpacing(16); + layout->addWidget(advancedToggleButton); + layout->addWidget(advancedPanel); + layout->addSpacing(8); + layout->addLayout(checkGrid); + layout->addStretch(); + + retranslateUi(); +} + +bool CardDatabaseSetupPage::alreadyHaveDatabase() const +{ + return CardDatabaseManager::getInstance()->getCardList().count() > 0; +} + +QString CardDatabaseSetupPage::oracleSettingsFilePath() const +{ + return SettingsCache::instance().getSettingsPath() + "oracle.ini"; +} + +QString CardDatabaseSetupPage::readCustomUrl() const +{ + QSettings oracleSettings(oracleSettingsFilePath(), QSettings::IniFormat); + return oracleSettings.value("allsetsurl").toString(); +} + +void CardDatabaseSetupPage::writeCustomUrl(const QString &url) +{ + QSettings oracleSettings(oracleSettingsFilePath(), QSettings::IniFormat); + if (url.isEmpty()) { + oracleSettings.remove("allsetsurl"); + } else { + oracleSettings.setValue("allsetsurl", url); + } +} + +void CardDatabaseSetupPage::initializePage() +{ + urlLineEdit->setText(readCustomUrl()); + + if (state != State::NotStarted) { + return; + } + + if (alreadyHaveDatabase()) { + setState(State::Succeeded); + return; + } + + // Don't auto-download — wait for the user to press "Download". + setState(State::NotStarted); + statusLabel->setText(tr("Press Download to fetch the card database, or Skip to do it later.")); +} + +void CardDatabaseSetupPage::onUpdateFinished(bool success) +{ + setState(success ? State::Succeeded : State::Failed); + if (success) { + emit advanceRequested(); + } +} + +void CardDatabaseSetupPage::onUpdateProgress(const CardUpdateProgress &progress) +{ + if (state != State::Running) { + return; + } + const qint64 total = progress.total; + const qint64 done = progress.done; + progressBar->setRange(0, total > 0 ? static_cast(qMin(total, INT_MAX)) : 0); + progressBar->setValue(static_cast(qMin(done, INT_MAX))); + if (total > 0) { + const int percent = static_cast((100.0 * done) / total); + switch (progress.stage) { + case CardUpdateStage::Download: + statusLabel->setText(tr("Downloading the card database (%1%)…").arg(percent)); + break; + case CardUpdateStage::Scan: + statusLabel->setText(tr("Parsing the card database (%1%)…").arg(percent)); + break; + case CardUpdateStage::Import: + statusLabel->setText(tr("Importing cards (%1%)…").arg(percent)); + break; + case CardUpdateStage::Unknown: + break; + } + } +} + +QString CardDatabaseSetupPage::nextButtonText() const +{ + return state == State::NotStarted ? tr("Download") : QString(); +} + +bool CardDatabaseSetupPage::handleNextClick() +{ + if (state == State::NotStarted) { + setState(State::Running); + emit updateRequested(); + return false; + } + return true; +} + +void CardDatabaseSetupPage::onToggleAdvanced(bool open) +{ + advancedToggleButton->setText(open ? tr("▼ Advanced: custom download source") + : tr("▶ Advanced: custom download source")); + advancedPanel->setVisible(open); + + QWidget *wizardWindow = window(); + if (!wizardWindow) { + return; + } + + if (open) { + windowSizeBeforeExpansion = wizardWindow->size(); + QTimer::singleShot(0, this, [wizardWindow] { + wizardWindow->resize(wizardWindow->size().expandedTo(wizardWindow->sizeHint())); + }); + } else { + QTimer::singleShot(0, this, [this, wizardWindow] { + wizardWindow->resize(wizardWindow->size().boundedTo(windowSizeBeforeExpansion)); + }); + } +} + +void CardDatabaseSetupPage::onApplyCustomUrl() +{ + const QString text = urlLineEdit->text().trimmed(); + + if (!text.isEmpty()) { + const QUrl url = QUrl::fromUserInput(text); + if (!url.isValid()) { + QMessageBox::warning(this, tr("Invalid URL"), + tr("That doesn't look like a valid URL. Double-check it and try again, " + "or clear the field to use the default source.")); + return; + } + } + + writeCustomUrl(text); + setState(State::Running); + emit updateRequested(); +} + +void CardDatabaseSetupPage::onRestoreDefaultUrl() +{ + urlLineEdit->clear(); + writeCustomUrl(QString()); +} + +void CardDatabaseSetupPage::setState(State newState) +{ + state = newState; + + progressBar->setVisible(state == State::Running); + retryButton->setVisible(state == State::Failed); + manualButton->setVisible(state == State::Failed); + applyAndRetryButton->setEnabled(state != State::Running); + + switch (state) { + case State::NotStarted: + statusLabel->setText(tr("Press Download to fetch the card database, or Skip to do it later.")); + break; + case State::Running: + statusLabel->setText(tr("Downloading the latest card database…")); + break; + case State::Succeeded: + statusLabel->setText(tr("Card database ready ✓")); + break; + case State::Failed: + statusLabel->setText( + tr("Couldn't download the card database automatically. Check your connection and retry, " + "set it up manually, or skip this for now — you can do it later from the Card Database menu.")); + break; + } + + emit completeChanged(); +} + +bool CardDatabaseSetupPage::isComplete() const +{ + return state != State::Running; +} + +bool CardDatabaseSetupPage::isSkippable() const +{ + return state != State::Succeeded; +} + +QString CardDatabaseSetupPage::stepTitle() const +{ + return tr("Card Database"); +} + +QString CardDatabaseSetupPage::stepSubtitle() const +{ + return tr("Cockatrice needs card data to know what you're playing with."); +} + +void CardDatabaseSetupPage::retranslateUi() +{ + retryButton->setText(tr("Retry")); + manualButton->setText(tr("Set up manually…")); + + onToggleAdvanced(advancedToggleButton->isChecked()); + urlLineEdit->setPlaceholderText(tr("Leave blank to use the default source")); + urlHintLabel->setText(tr("Only change this if you know you need a mirror or a custom card data source.")); + restoreDefaultUrlButton->setText(tr("Restore default")); + applyAndRetryButton->setText(tr("Apply && retry")); + + startupBehaviorLabel->setText(tr("Check for card database updates on startup")); + startupBehaviorCombo->setItemText(0, tr("Don't check")); + startupBehaviorCombo->setItemText(1, tr("Prompt for update")); + startupBehaviorCombo->setItemText(2, tr("Always update in the background")); + + checkIntervalLabel->setText(tr("Check for card database updates every")); + checkIntervalSpinBox->setSuffix(tr(" days")); + + setState(state); +} diff --git a/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.h b/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.h new file mode 100644 index 000000000..a84223497 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.h @@ -0,0 +1,84 @@ +#ifndef CARD_DATABASE_SETUP_PAGE_H +#define CARD_DATABASE_SETUP_PAGE_H + +#include "../../../card_database_update/card_update_progress.h" +#include "../first_run_wizard_page.h" + +#include +#include +#include +#include + +class QComboBox; +class QLabel; +class QLineEdit; +class QProgressBar; +class QPushButton; +class QSpinBox; +class QWidget; + +class CardDatabaseSetupPage : public FirstRunWizardPage +{ + Q_OBJECT + +public: + explicit CardDatabaseSetupPage(QWidget *parent = nullptr); + + void initializePage() override; + bool isComplete() const override; + bool isSkippable() const override; + QString stepTitle() const override; + QString stepSubtitle() const override; + QString nextButtonText() const override; + bool handleNextClick() override; + void retranslateUi() override; + + void onUpdateFinished(bool success); + void onUpdateProgress(const CardUpdateProgress &progress); + +signals: + void updateRequested(); + void manualSetupRequested(); + +private: + enum class State + { + NotStarted, + Running, + Succeeded, + Failed, + }; + + void setState(State newState); + bool alreadyHaveDatabase() const; + + QString oracleSettingsFilePath() const; + QString readCustomUrl() const; + void writeCustomUrl(const QString &url); + + void onToggleAdvanced(bool open); + void onApplyCustomUrl(); + void onRestoreDefaultUrl(); + + QLabel *statusLabel; + QProgressBar *progressBar; + QPushButton *retryButton; + QPushButton *manualButton; + + QPushButton *advancedToggleButton; + QWidget *advancedPanel; + QLineEdit *urlLineEdit; + QLabel *urlHintLabel; + QPushButton *restoreDefaultUrlButton; + QPushButton *applyAndRetryButton; + + QLabel *startupBehaviorLabel; + QComboBox *startupBehaviorCombo; + QLabel *checkIntervalLabel; + QSpinBox *checkIntervalSpinBox; + + State state = State::NotStarted; + QSize windowSizeBeforeExpansion; +}; + +#endif // CARD_DATABASE_SETUP_PAGE_H diff --git a/cockatrice/src/interface/widgets/onboarding/pages/finish_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/finish_page.cpp new file mode 100644 index 000000000..1adf35282 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/finish_page.cpp @@ -0,0 +1,36 @@ +#include "finish_page.h" + +#include "../first_run_wizard_page.h" + +#include +#include +#include +#include + +class QWidget; + +FinishPage::FinishPage(QWidget *parent) : FirstRunWizardPage(parent) +{ + bodyLabel = new QLabel(this); + bodyLabel->setWordWrap(true); + bodyLabel->setAlignment(Qt::AlignCenter); + + auto *layout = new QVBoxLayout(this); + layout->addStretch(); + layout->addWidget(bodyLabel); + layout->addStretch(); + + retranslateUi(); +} + +QString FinishPage::stepTitle() const +{ + return tr("You're All Set"); +} + +void FinishPage::retranslateUi() +{ + bodyLabel->setText( + tr("That's everything for now. Jump into Settings any time to change your mind about any of this.\n\n" + "Have fun!")); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/onboarding/pages/finish_page.h b/cockatrice/src/interface/widgets/onboarding/pages/finish_page.h new file mode 100644 index 000000000..b78e40ec5 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/finish_page.h @@ -0,0 +1,25 @@ +#ifndef FINISH_PAGE_H +#define FINISH_PAGE_H + +#include "../first_run_wizard_page.h" + +#include + +class QLabel; +class QWidget; + +class FinishPage : public FirstRunWizardPage +{ + Q_OBJECT + +public: + explicit FinishPage(QWidget *parent = nullptr); + + QString stepTitle() const override; + void retranslateUi() override; + +private: + QLabel *bodyLabel; +}; + +#endif // FINISH_PAGE_H diff --git a/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.cpp new file mode 100644 index 000000000..da08e3a12 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.cpp @@ -0,0 +1,180 @@ +#include "preferences_setup_page.h" + +#include "../../client/settings/cache_settings.h" +#include "../../client/sound_engine.h" +#include "../first_run_wizard_page.h" +#include "libcockatrice/settings/interface_settings.h" +#include "libcockatrice/settings/sound_settings.h" +#include "libcockatrice/settings/tabs_settings.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +// The server destinations are omitted: during first run their tabs are not +// open yet, and the wizard offers no way to fill in the server/room details. +QList wizardStartupTabOrder() +{ + return {StartupTabHome, StartupTabVisualDeckStorage, StartupTabDeckStorage, + StartupTabReplays, StartupTabDeckEditor, StartupTabVisualDeckEditor}; +} +} // namespace + +PreferencesSetupPage::PreferencesSetupPage(QWidget *parent) : FirstRunWizardPage(parent) +{ + auto *content = new QWidget; + auto *contentLayout = new QVBoxLayout(content); + + gameplayGroup = new QGroupBox(content); + auto *gameplayLayout = new QVBoxLayout(gameplayGroup); + contentLayout->addWidget(gameplayGroup); + doubleClickToPlayCheckBox = new QCheckBox(gameplayGroup); + horizontalHandCheckBox = new QCheckBox(gameplayGroup); + playToStackCheckBox = new QCheckBox(gameplayGroup); + gameplayLayout->addWidget(doubleClickToPlayCheckBox); + gameplayLayout->addWidget(horizontalHandCheckBox); + gameplayLayout->addWidget(playToStackCheckBox); + + notificationsGroup = new QGroupBox(content); + auto *notificationsLayout = new QVBoxLayout(notificationsGroup); + contentLayout->addWidget(notificationsGroup); + notificationsEnabledCheckBox = new QCheckBox(notificationsGroup); + soundEnabledCheckBox = new QCheckBox(notificationsGroup); + notificationsLayout->addWidget(notificationsEnabledCheckBox); + notificationsLayout->addWidget(soundEnabledCheckBox); + + startupGroup = new QGroupBox(content); + auto *startupForm = new QFormLayout(startupGroup); + contentLayout->addWidget(startupGroup); + startupTabLabel = new QLabel(startupGroup); + startupTabSelector = new QComboBox(startupGroup); + startupTabSelector->setSizeAdjustPolicy(QComboBox::AdjustToContents); + for (StartupTab tab : wizardStartupTabOrder()) { + startupTabSelector->addItem(QString(), tab); // texts set in retranslateUi + } + startupForm->addRow(startupTabLabel, startupTabSelector); + + contentLayout->addStretch(); + + auto *scrollArea = new QScrollArea(this); + scrollArea->setWidget(content); + scrollArea->setWidgetResizable(true); + scrollArea->setFrameShape(QFrame::NoFrame); + + auto *layout = new QVBoxLayout(this); + layout->addWidget(scrollArea); + + SettingsCache &settings = SettingsCache::instance(); + + connect(doubleClickToPlayCheckBox, &QCheckBox::toggled, &settings.userInterface(), + &InterfaceSettings::setDoubleClickToPlay); + connect(horizontalHandCheckBox, &QCheckBox::toggled, &settings.userInterface(), + &InterfaceSettings::setHorizontalHand); + connect(playToStackCheckBox, &QCheckBox::toggled, &settings.userInterface(), &InterfaceSettings::setPlayToStack); + + connect(notificationsEnabledCheckBox, &QCheckBox::toggled, &settings.userInterface(), + &InterfaceSettings::setNotificationsEnabled); + connect(soundEnabledCheckBox, &QCheckBox::toggled, &settings.sound(), &SoundSettings::setSoundEnabled); + connect(soundEnabledCheckBox, &QCheckBox::toggled, soundEngine, &SoundEngine::testSound); + + connect(startupTabSelector, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int index) { + if (index < 0) { + return; + } + SettingsCache::instance().tabs().setStartupTabIndex(startupTabSelector->itemData(index).toInt()); + }); + + retranslateUi(); +} + +void PreferencesSetupPage::initializePage() +{ + SettingsCache &settings = SettingsCache::instance(); + + doubleClickToPlayCheckBox->setChecked(settings.userInterface().getDoubleClickToPlay()); + horizontalHandCheckBox->setChecked(settings.userInterface().getHorizontalHand()); + playToStackCheckBox->setChecked(settings.userInterface().getPlayToStack()); + + notificationsEnabledCheckBox->setChecked(settings.userInterface().getNotificationsEnabled()); + soundEnabledCheckBox->setChecked(settings.sound().getSoundEnabled()); + + startupTabSelector->setCurrentIndex(startupTabSelector->findData(settings.tabs().getStartupTabIndex())); +} + +bool PreferencesSetupPage::isSkippable() const +{ + return true; +} + +QString PreferencesSetupPage::stepTitle() const +{ + return tr("A Few Preferences"); +} + +QString PreferencesSetupPage::stepSubtitle() const +{ + return tr("Defaults are fine — tweak these now or from Settings anytime."); +} + +void PreferencesSetupPage::retranslateUi() +{ + gameplayGroup->setTitle(tr("Gameplay")); + doubleClickToPlayCheckBox->setText(tr("Double-click cards to play them")); + doubleClickToPlayCheckBox->setToolTip(tr("When disabled, a single click plays the selected card onto the table.")); + horizontalHandCheckBox->setText(tr("Display hand horizontally")); + horizontalHandCheckBox->setToolTip( + tr("Shows your hand as a row along the bottom of the table instead of a column beside it.")); + playToStackCheckBox->setText(tr("Play all nonlands onto the stack by default")); + playToStackCheckBox->setToolTip( + tr("Cards you play appear on the stack so other players can respond to them, as in a tabletop game.")); + + notificationsGroup->setTitle(tr("Notifications && Sound")); + notificationsEnabledCheckBox->setText(tr("Show desktop notifications")); + soundEnabledCheckBox->setText(tr("Play sound effects")); + + startupGroup->setTitle(tr("Startup")); + startupTabLabel->setText(tr("Startup tab:")); + const QList tabs = wizardStartupTabOrder(); + for (int i = 0; i < tabs.size(); ++i) { + QString name; + switch (tabs[i]) { + case StartupTabHome: + name = tr("Home"); + break; + case StartupTabVisualDeckStorage: + name = tr("Visual Deck Storage"); + break; + case StartupTabDeckStorage: + name = tr("Deck Storage"); + break; + case StartupTabReplays: + name = tr("Game Replays"); + break; + case StartupTabDeckEditor: + name = tr("Deck Editor"); + break; + case StartupTabVisualDeckEditor: + name = tr("Visual Deck Editor"); + break; + case StartupTabServer: + name = tr("Server"); + break; + case StartupTabServerRoom: + name = tr("Server Room"); + break; + } + startupTabSelector->setItemText(i, name); + } +} diff --git a/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.h b/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.h new file mode 100644 index 000000000..18d6c0e8f --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.h @@ -0,0 +1,44 @@ +#ifndef PREFERENCES_SETUP_PAGE_H +#define PREFERENCES_SETUP_PAGE_H + +#include "../first_run_wizard_page.h" + +#include + +class QCheckBox; +class QComboBox; +class QGroupBox; +class QLabel; +class QWidget; + +/** @brief A curated subset of settings for the user to adjust. + **/ +class PreferencesSetupPage : public FirstRunWizardPage +{ + Q_OBJECT + +public: + explicit PreferencesSetupPage(QWidget *parent = nullptr); + + void initializePage() override; + bool isSkippable() const override; + QString stepTitle() const override; + QString stepSubtitle() const override; + void retranslateUi() override; + +private: + QGroupBox *gameplayGroup; + QCheckBox *doubleClickToPlayCheckBox; + QCheckBox *horizontalHandCheckBox; + QCheckBox *playToStackCheckBox; + + QGroupBox *notificationsGroup; + QCheckBox *notificationsEnabledCheckBox; + QCheckBox *soundEnabledCheckBox; + + QGroupBox *startupGroup; + QLabel *startupTabLabel; + QComboBox *startupTabSelector; +}; + +#endif // PREFERENCES_SETUP_PAGE_H diff --git a/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.cpp new file mode 100644 index 000000000..66554d85c --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.cpp @@ -0,0 +1,287 @@ +#include "theme_setup_page.h" + +#include "../../../theme_config.h" +#include "../../client/settings/cache_settings.h" +#include "../../interface/palette_editor/palette_generator.h" +#include "../../interface/palette_editor/quick_setup_panel.h" +#include "../../interface/theme_manager.h" +#include "../../interface/widgets/general/background_sources.h" +#include "../first_run_wizard_page.h" +#include "libcockatrice/settings/appearance_settings.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class QWidget; + +namespace +{ +/** @brief A theme's shipped identity accent, immune to any user- or auto- + * generated palette that may currently be masking appColor(). */ +QColor themeIdentityAccent(const QString &themeDirPath, const QString &themeName) +{ + for (const QString &scheme : {QStringLiteral("Light"), QStringLiteral("Dark")}) { + const PaletteConfig cfg = ThemeManager::loadDefaultPaletteConfig(themeDirPath, themeName, scheme); + if (cfg.appColors.contains(AppColor::AccentStrong)) { + return cfg.appColors.value(AppColor::AccentStrong); + } + const QColor highlight = cfg.colors.value(QPalette::Active).value(QPalette::Highlight); + if (highlight.isValid()) { + return highlight; + } + } + return {}; +} +} // namespace + +ThemeSetupPage::ThemeSetupPage(QWidget *parent) : FirstRunWizardPage(parent) +{ + themeCombo = new QComboBox(this); + schemeCombo = new QComboBox(this); + schemeCombo->addItem(tr("Light"), QStringLiteral("Light")); + schemeCombo->addItem(tr("Dark"), QStringLiteral("Dark")); +#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) + schemeCombo->addItem(tr("Match system"), QStringLiteral("System")); +#endif + + quickSetupPanel = new QuickSetupPanel(this); + + // Seed the picker from the current theme's own identity accent rather than + // a hardcoded brand green: Plasma seeds violet, Fusion green, etc., and it + // is immune to stale generated palettes that may mask appColor(). This was + // initially a brand-green workaround from before Fusion became the default. + // setAccentColor blocks signals, so this never triggers a generation. + lastSeededTheme = SettingsCache::instance().getThemeName(); + quickSetupPanel->setAccentColor( + themeIdentityAccent(themeManager->getAvailableThemes().value(lastSeededTheme), lastSeededTheme)); + + connect(themeCombo, QOverload::of(&QComboBox::currentIndexChanged), this, &ThemeSetupPage::onThemeChanged); + connect(schemeCombo, QOverload::of(&QComboBox::currentIndexChanged), this, &ThemeSetupPage::onSchemeChanged); + connect(quickSetupPanel, &QuickSetupPanel::valueChanged, this, &ThemeSetupPage::onGenerateFromAccent); + + homeTabBackgroundCombo = new QComboBox(this); + for (const auto &entry : BackgroundSources::all()) { + homeTabBackgroundCombo->addItem(QObject::tr(entry.trKey), QVariant::fromValue(entry.type)); + } + connect(homeTabBackgroundCombo, QOverload::of(&QComboBox::currentIndexChanged), this, + &ThemeSetupPage::onHomeTabBackgroundChanged); + + // Keep the scheme combo honest when the *theme* changes underneath it + // (switching theme reloads that theme's own stored colorScheme), and + // opportunistically seed a palette for themes that ship none at all. + // Mirrors AppearanceSettingsPage's identical listener for the combo-sync + // half of this. + connect(themeManager, &ThemeManager::themeChanged, this, [this] { + const QString newTheme = SettingsCache::instance().getThemeName(); + const QString newDir = themeManager->getAvailableThemes().value(newTheme); + const ThemeConfig cfg = ThemeConfig::fromThemeDir(newDir); + const QString current = cfg.colorScheme; + + schemeCombo->blockSignals(true); + const int idx = schemeCombo->findData(current); + schemeCombo->setCurrentIndex(idx >= 0 ? idx : 0); + schemeCombo->blockSignals(false); + + // Keep the picker's accent in step with the theme's own identity; the + // swatch seeded at construction would otherwise stay stale (e.g. green + // from a previous theme) when the user toggles themes. + if (newTheme != lastSeededTheme) { + lastSeededTheme = newTheme; + quickSetupPanel->setAccentColor(themeIdentityAccent(newDir, newTheme)); + } + + maybeAutoGeneratePalette(); + }); + + auto *form = new QFormLayout; + form->addRow(tr("Theme:"), themeCombo); + form->addRow(tr("Appearance:"), schemeCombo); + form->addRow(tr("Home screen background:"), homeTabBackgroundCombo); + + accentGroup = new QGroupBox(this); + auto *accentLayout = new QVBoxLayout(accentGroup); + accentLayout->addWidget(quickSetupPanel); + + auto *layout = new QVBoxLayout(this); + layout->addLayout(form); + layout->addWidget(accentGroup); + layout->addStretch(); + + retranslateUi(); +} + +void ThemeSetupPage::initializePage() +{ + themeCombo->blockSignals(true); + themeCombo->clear(); + const QString currentTheme = SettingsCache::instance().getThemeName(); + for (const QString &name : themeManager->getAvailableThemes().keys()) { + themeCombo->addItem(name); + } + const int idx = themeCombo->findText(currentTheme); + themeCombo->setCurrentIndex(idx >= 0 ? idx : 0); + themeCombo->blockSignals(false); + + const QString dirPath = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName()); + const ThemeConfig cfg = ThemeConfig::fromThemeDir(dirPath); + schemeCombo->blockSignals(true); + const int schemeIdx = schemeCombo->findData(cfg.colorScheme); + schemeCombo->setCurrentIndex(schemeIdx >= 0 ? schemeIdx : 0); + schemeCombo->blockSignals(false); + + homeTabBackgroundCombo->blockSignals(true); + QString homeTabSource = SettingsCache::instance().appearance().getHomeTabBackgroundSource(); + int homeTabIdx = homeTabBackgroundCombo->findData(BackgroundSources::fromId(homeTabSource)); + homeTabBackgroundCombo->setCurrentIndex(homeTabIdx >= 0 ? homeTabIdx : 0); + homeTabBackgroundCombo->blockSignals(false); + + // Opening the page must not touch the running application's palette: + // previews and auto-generation only happen in response to the user + // actually changing a control, never on mere page visibility. + paletteDirty = false; +} + +QString ThemeSetupPage::currentScheme() const +{ + return schemeCombo->currentData().toString(); +} + +QString ThemeSetupPage::resolvedScheme() const +{ + const QString scheme = currentScheme(); + if (scheme.isEmpty() || scheme == QStringLiteral("System")) { + return themeManager->isDarkMode(themeManager->getCurrentThemePath()) ? "Dark" : "Light"; + } + return scheme; +} + +void ThemeSetupPage::onThemeChanged(int index) +{ + if (index < 0) { + return; + } + paletteDirty = false; + SettingsCache::instance().setThemeName(themeCombo->itemText(index)); + // Scheme-combo sync and auto-generation both happen via the + // ThemeManager::themeChanged listener above, triggered by setThemeName. +} + +void ThemeSetupPage::onSchemeChanged() +{ + themeManager->setColorScheme(currentScheme()); +} + +void ThemeSetupPage::onHomeTabBackgroundChanged(int index) +{ + if (index < 0) { + return; + } + auto type = homeTabBackgroundCombo->currentData().value(); + SettingsCache::instance().appearance().setHomeTabBackgroundSource(BackgroundSources::toId(type)); +} + +void ThemeSetupPage::onGenerateFromAccent(const QColor &accent, int intensity) +{ + PaletteConfig cfg = PaletteGenerator::fromAccent(accent, intensity, resolvedScheme()); + themeManager->previewPalette(cfg, resolvedScheme()); + paletteDirty = true; +} + +void ThemeSetupPage::maybeAutoGeneratePalette() +{ + const QString dirPath = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName()); + const QString scheme = resolvedScheme(); + + // The theme dir may resolve to the user profile even for built-in themes + // (getAvailableThemes gives the user copy precedence), so consult the + // shipped palette too -- both via loadDefaultPaletteConfig's system fallback. + // Without it, scheme flips regenerate a fresh palette from the picker accent + // and clobber the curated colours the theme explicitly ships. + if (PaletteConfig::fromScheme(dirPath, scheme).hasPalette() || + ThemeManager::loadDefaultPaletteConfig(dirPath, SettingsCache::instance().getThemeName(), scheme) + .hasPalette()) { + return; // theme already has something real to show -- leave it alone + } + + // The theme+scheme combination has nothing saved and nothing shipped, and + // the user just switched to it. Rather than leaving a flat, unstyled look, + // seed one from whatever accent QuickSetupPanel currently holds and mark + // it dirty so it's written to disk if the user moves on. Only ever reached + // through user interaction (theme/scheme change, accent drag) -- never on + // page open. + PaletteConfig generated = + PaletteGenerator::fromAccent(quickSetupPanel->accentColor(), quickSetupPanel->intensity(), scheme); + themeManager->previewPalette(generated, scheme); + paletteDirty = true; +} + +bool ThemeSetupPage::validatePage() +{ + if (paletteDirty) { + const QString scheme = resolvedScheme(); + PaletteConfig cfg = + PaletteGenerator::fromAccent(quickSetupPanel->accentColor(), quickSetupPanel->intensity(), scheme); + if (!ThemeManager::commitPalette(writableThemeDir(), scheme, cfg)) { + QMessageBox::warning(this, tr("Save failed"), + tr("Could not write the theme palette to:\n%1").arg(writableThemeDir())); + return false; + } + themeManager->reloadCurrentTheme(); + } + return true; +} + +QString ThemeSetupPage::writableThemeDir() const +{ + // Built-in themes resolve to the read-only system themes directory; + // palette edits must go to the user themes directory instead, exactly + // as PaletteEditorDialog does. + const QString dirPath = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName()); + if (!dirPath.isEmpty()) { + const QString probe = QDir(dirPath).absoluteFilePath(".cockatrice_write_test"); + QFile f(probe); + if (f.open(QIODevice::WriteOnly)) { + f.close(); + f.remove(); + return dirPath; + } + } + return QDir(SettingsCache::instance().paths().getThemesPath()) + .absoluteFilePath(SettingsCache::instance().getThemeName()); +} + +bool ThemeSetupPage::isSkippable() const +{ + return true; +} + +QString ThemeSetupPage::stepTitle() const +{ + return tr("Pick a Look"); +} + +QString ThemeSetupPage::stepSubtitle() const +{ + return tr("You can fine-tune every colour later from Settings → Appearance."); +} + +void ThemeSetupPage::retranslateUi() +{ + accentGroup->setTitle(tr("Accent colour (optional)")); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.h b/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.h new file mode 100644 index 000000000..b0d717043 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.h @@ -0,0 +1,66 @@ +#ifndef THEME_SETUP_PAGE_H +#define THEME_SETUP_PAGE_H + +#include "../first_run_wizard_page.h" + +#include +#include +#include + +class QComboBox; +class QGroupBox; +class QuickSetupPanel; +class QWidget; + +/** @brief First-run theme step. Reuses the same building blocks as Appearance + * settings and the Palette Editor (ThemeManager, PaletteConfig, + * PaletteGenerator, and the QuickSetupPanel widget itself) rather than + * reimplementing palette generation or preview here. + * + * Behavior specific to this page (deliberately not pushed down into + * ThemeManager, to avoid changing app-wide behaviour for existing installs): + * - Opening the page never changes the running palette; previews and + * auto-generation only happen when the user actually changes a control. + * - If a theme+scheme the user selects has no saved palette and no shipped + * default, one is generated from the QuickSetupPanel's current accent so + * the preview doesn't fall back to a flat, unstyled look. */ +class ThemeSetupPage : public FirstRunWizardPage +{ + Q_OBJECT + +public: + explicit ThemeSetupPage(QWidget *parent = nullptr); + + void initializePage() override; + bool validatePage() override; + bool isSkippable() const override; + QString stepTitle() const override; + QString stepSubtitle() const override; + void retranslateUi() override; + +private slots: + void onThemeChanged(int index); + void onSchemeChanged(); + void onGenerateFromAccent(const QColor &accent, int intensity); + void onHomeTabBackgroundChanged(int index); + +private: + QString currentScheme() const; + QString resolvedScheme() const; // "System" -> actual Light/Dark + void maybeAutoGeneratePalette(); + QString writableThemeDir() const; + + QComboBox *themeCombo; + QComboBox *schemeCombo; + QGroupBox *accentGroup; + QuickSetupPanel *quickSetupPanel; + + QComboBox *homeTabBackgroundCombo; + + bool paletteDirty = false; + + /// Theme whose identity accent currently seeds the picker. + QString lastSeededTheme; +}; + +#endif // THEME_SETUP_PAGE_H diff --git a/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.cpp b/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.cpp new file mode 100644 index 000000000..71d2fa409 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.cpp @@ -0,0 +1,87 @@ +#include "welcome_page.h" + +#include "../../../../main.h" +#include "../../client/settings/cache_settings.h" +#include "../../settings_page/general_settings_page.h" +#include "../first_run_wizard_page.h" +#include "libcockatrice/settings/personal_settings.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class QWidget; + +WelcomePage::WelcomePage(QWidget *parent) : FirstRunWizardPage(parent) +{ + bodyLabel = new QLabel(this); + bodyLabel->setWordWrap(true); + bodyLabel->setAlignment(Qt::AlignCenter); + + languageLabel = new QLabel(this); + langCombo = new QComboBox(this); + for (const QString &code : GeneralSettingsPage::findQmFiles()) { + langCombo->addItem(GeneralSettingsPage::languageName(code), code); + } + + QString current = SettingsCache::instance().personal().getLang(); + if (current.isEmpty()) { + current = QLocale::system().name(); + } + int index = langCombo->findData(current); + if (index < 0) { + index = langCombo->findData(current.section('_', 0, 0)); + } + if (index >= 0) { + langCombo->setCurrentIndex(index); + } + + connect(langCombo, QOverload::of(&QComboBox::currentIndexChanged), this, &WelcomePage::languageChanged); + + auto *languageRow = new QHBoxLayout; + languageRow->addStretch(); + languageRow->addWidget(languageLabel); + languageRow->addWidget(langCombo); + languageRow->addStretch(); + + auto *layout = new QVBoxLayout(this); + layout->addStretch(); + layout->addWidget(bodyLabel); + layout->addStretch(); + layout->addLayout(languageRow); + + retranslateUi(); +} + +void WelcomePage::languageChanged(int index) +{ + if (index < 0) { + return; + } + SettingsCache::instance().personal().setLang(langCombo->itemData(index).toString()); + qApp->removeTranslator(translator); // NOLINT(cppcoreguidelines-pro-type-static-cast-downcast) + installNewTranslator(); +} + +QString WelcomePage::stepTitle() const +{ + return tr("Welcome!"); +} + +void WelcomePage::retranslateUi() +{ + bodyLabel->setText(tr("Let's get you set up. This will only take a minute — " + "we'll grab the card database, pick a look you like, " + "and get you ready to connect to a server.\n\n" + "You can change any of this later from Settings.")); + languageLabel->setText(tr("Language:")); +} diff --git a/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.h b/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.h new file mode 100644 index 000000000..289034ab1 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.h @@ -0,0 +1,31 @@ +#ifndef WELCOME_PAGE_H +#define WELCOME_PAGE_H + +#include "../first_run_wizard_page.h" + +#include + +class QComboBox; +class QLabel; +class QWidget; + +class WelcomePage : public FirstRunWizardPage +{ + Q_OBJECT + +public: + explicit WelcomePage(QWidget *parent = nullptr); + + QString stepTitle() const override; + void retranslateUi() override; + +private slots: + void languageChanged(int index); + +private: + QLabel *bodyLabel; + QLabel *languageLabel; + QComboBox *langCombo; +}; + +#endif // WELCOME_PAGE_H diff --git a/cockatrice/src/interface/widgets/onboarding/qml/BrandBanner.qml b/cockatrice/src/interface/widgets/onboarding/qml/BrandBanner.qml new file mode 100644 index 000000000..d94e15280 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/qml/BrandBanner.qml @@ -0,0 +1,96 @@ +import QtQuick + +Item { + id: root + + ShaderEffect { + id: effectA + anchors.fill: parent + opacity: bannerConfig.frontIsA ? 1.0 : 0.0 + Behavior on opacity { NumberAnimation { duration: 450; easing.type: Easing.InOutCubic } } + property real iTime: bannerConfig.time + property real uAspect: bannerConfig.aspect + property real uMode: bannerConfig.modeA + property real uSpeed: bannerConfig.speedA + property real uSeed: bannerConfig.seedA + property vector4d uColorA: Qt.vector4d(bannerConfig.colorA.r, bannerConfig.colorA.g, bannerConfig.colorA.b, 1.0) + property vector4d uColorB: Qt.vector4d(bannerConfig.colorB.r, bannerConfig.colorB.g, bannerConfig.colorB.b, 1.0) + property vector4d uAccent: Qt.vector4d(bannerConfig.accent.r, bannerConfig.accent.g, bannerConfig.accent.b, 1.0) + property vector4d uGlowColor: Qt.vector4d(bannerConfig.glowColor.r, bannerConfig.glowColor.g, bannerConfig.glowColor.b, 1.0) + property real uVignetteMin: bannerConfig.vignetteMin + property real uLogoGlow: bannerConfig.logoGlow + fragmentShader: "qrc:/onboarding/shaders/brand_banner.frag.qsb" + } + + ShaderEffect { + id: effectB + anchors.fill: parent + opacity: bannerConfig.frontIsA ? 0.0 : 1.0 + Behavior on opacity { NumberAnimation { duration: 450; easing.type: Easing.InOutCubic } } + property real iTime: bannerConfig.time + property real uAspect: bannerConfig.aspect + property real uMode: bannerConfig.modeB + property real uSpeed: bannerConfig.speedB + property real uSeed: bannerConfig.seedB + property vector4d uColorA: Qt.vector4d(bannerConfig.colorA.r, bannerConfig.colorA.g, bannerConfig.colorA.b, 1.0) + property vector4d uColorB: Qt.vector4d(bannerConfig.colorB.r, bannerConfig.colorB.g, bannerConfig.colorB.b, 1.0) + property vector4d uAccent: Qt.vector4d(bannerConfig.accent.r, bannerConfig.accent.g, bannerConfig.accent.b, 1.0) + property vector4d uGlowColor: Qt.vector4d(bannerConfig.glowColor.r, bannerConfig.glowColor.g, bannerConfig.glowColor.b, 1.0) + property real uVignetteMin: bannerConfig.vignetteMin + property real uLogoGlow: bannerConfig.logoGlow + fragmentShader: "qrc:/onboarding/shaders/brand_banner.frag.qsb" + } + + // The white logo sits at full opacity on top of the static gradient plate — + // no glow, no breathing. The plate matches home_widget's QPainter composite. + Item { + id: logoHost + visible: bannerConfig.logoVisible + anchors.centerIn: parent + width: root.height * 0.6 + height: width + + // The full-color logo renders beneath the white mark and is consumed as + // a texture (layer.enabled) by the plate shader's silhouette mask, so + // the gradient is clipped to the bird exactly as the SVG's gradient + // paths are. It is never drawn to the screen itself. + Image { + id: silhouetteMask + anchors.fill: parent + source: "qrc:/resources/cockatrice.svg" + sourceSize: Qt.size(256, 256) + fillMode: Image.PreserveAspectFit + smooth: true + visible: false + layer.enabled: true + layer.smooth: true + } + + // The logo's gradient plate, drawn behind the mark: a linear + // AccentSoft (light) -> AccentStrong (dark) sheet along the same + // top-left -> bottom-right userSpaceOnUse axis the baked-in SVG used, + // clipped to the bird silhouette via uSilhouette. The white highlight + // path above is theme independent. Sized to the logo itself — no + // rounded badge, matching home_widget's QPainter composite. Static. + // Small ShaderEffect, Qt 6.4-safe. + ShaderEffect { + id: brandPlate + anchors.fill: parent + property vector4d uStrong: Qt.vector4d(bannerConfig.brandStrong.r, bannerConfig.brandStrong.g, + bannerConfig.brandStrong.b, 1.0) + property vector4d uSoft: Qt.vector4d(bannerConfig.brandSoft.r, bannerConfig.brandSoft.g, + bannerConfig.brandSoft.b, 1.0) + property var uSilhouette: silhouetteMask + fragmentShader: "qrc:/onboarding/shaders/brand_plate.frag.qsb" + } + + Image { + id: logoImage + anchors.fill: parent + source: "qrc:/resources/cockatrice-logo-white.svg" + sourceSize: Qt.size(256, 256) + fillMode: Image.PreserveAspectFit + smooth: true + } + } +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp new file mode 100644 index 000000000..c17399957 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp @@ -0,0 +1,337 @@ +#include "shader_banner_widget.h" + +#include "../../theme_config.h" +#include "../../theme_manager.h" +#include "banner_shader_config.h" +#include "brand_colors.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +// Curated near-black stage -- used only when the active palette resolves no +// usable window colour. Matches the banner's original design (dark and quiet +// so the accent stands out) and satisfies design-plans §2.1's "identity +// survives a bare palette". +constexpr QRgb kFallbackColorA = 0x1A1A20; +constexpr QRgb kFallbackColorB = 0x0E0E12; + +struct SuggestedColors +{ + QColor colorA; + QColor colorB; + QColor accent; + QColor glowColor; + QColor brandStrong; + QColor brandSoft; + qreal vignetteMin = 0.62; +}; + +SuggestedColors suggestedBannerColors() +{ + const QPalette &pal = qApp->palette(); + const QColor window = pal.color(QPalette::Active, QPalette::Window); + // Identity accent: the theme's [AppColors] AccentStrong, which appColor() + // resolves to QPalette::Highlight when a theme doesn't pin AccentStrong. + // Reading bare Highlight ignored curated accent tokens (Plasma's violet + // vs Default's green) whenever a palette didn't set the role itself. + const QColor accentStrong = themeManager->appColor(AppColor::AccentStrong); + if (!window.isValid() || !accentStrong.isValid()) { + return {QColor(kFallbackColorA), + QColor(kFallbackColorB), + kCockatriceBrandGreen, + QColor(Qt::white), + kCockatriceBrandGreen, + QColor(0xC9, 0xFD, 0x62), + 0.62}; + } + + // The theme's brand pair: AccentStrong is the deep green, AccentSoft the + // lime. These two appColors form the logo's "surrounding gradient" (deep + // core grading out to the soft, brand-toned glow) on both the banner and + // the home screen. + const QColor brandStrong = accentStrong; + const QColor brandSoft = themeManager->appColor(AppColor::AccentSoft); + + // Dress the stage for the scheme so the banner never fights the + // surrounding window in either mode. Dark palettes keep the original + // quiet near-black stage (lightness 29 → 16) with the theme's window + // hue; light palettes get a pastel "frosted accent" treatment built from + // the accent hue instead of a plain near-white copy: a coloured wash that + // clearly belongs to the theme. + const qreal luma = 0.299 * window.red() + 0.587 * window.green() + 0.114 * window.blue(); + const bool lightStage = luma > 115.0; + if (lightStage) { + const int hue = accentStrong.hslHue(); + // Achromatic accents (grey) get a neutral near-white stage instead. + const int stageSat = hue < 0 ? 0 : 64; + const int hueSafe = hue < 0 ? 0 : hue; + // Depth is what stops a light stage reading as a washed-out near-white + // copy of the page behind the banner: deepen the lower pastel band and + // raise saturation so the hue is clearly present while staying frosted. + auto pastel = [hueSafe, stageSat](int lightness) { return QColor::fromHsl(hueSafe, stageSat, lightness); }; + auto pastelLower = [hueSafe](int lightness) { return QColor::fromHsl(hueSafe, 76, lightness); }; + // Brightness-lifted accent for additive glows: the raw accent on a + // light stage must be mid-bright to read instead of washing out, so + // lift lightness and saturation together. + const int accentLightness = qBound(158, accentStrong.lightness() + 82, 198); + const int accentSaturation = hue < 0 ? 0 : qMax(accentStrong.hslSaturation(), 180); + const QColor liftedAccent = + hue < 0 ? accentStrong : QColor::fromHsl(hueSafe, accentSaturation, accentLightness); + // The centre glow (and logo tint in QML) uses the deep accent itself: + // a coloured halo/fill behind the logo instead of a white or black one. + return {pastel(214), pastelLower(186), liftedAccent, accentStrong, brandStrong, brandSoft, 0.80}; + } + + // Dark stage: force the window hue down to the banner's curated darkness, + // scaling saturation away so chromatic palettes tint it without going + // muddy. The accent is the bright, brand-driven tone (hue from the accent + // itself, never the -- often grey -- window), and it drives both the + // embers/fog and the logo glow so the mark tints like the light stage. + auto stage = [&window](int lightness) { + const int hue = window.hslHue(); + const int saturation = hue < 0 ? 0 : qBound(0, qRound(window.hslSaturation() * (lightness / 40.0)), 255); + return QColor::fromHsl(hue, saturation, lightness); + }; + const int accentHue = accentStrong.hslHue(); + const int accentHueSafe = accentHue < 0 ? 0 : accentHue; + const int accentLightness = qBound(150, accentStrong.lightness() + 70, 185); + const int accentSaturation = accentHue < 0 ? 0 : qMax(accentStrong.hslSaturation(), 160); + const QColor accent = + accentHue < 0 ? accentStrong : QColor::fromHsl(accentHueSafe, accentSaturation, accentLightness); + return {stage(29), stage(16), accent, accent, brandStrong, brandSoft, 0.62}; +} +} // namespace + +class GradientFallbackWidget : public QWidget +{ +public: + using QWidget::QWidget; + + void setColors(const QColor &a, const QColor &b) + { + if (a != colorA || b != colorB) { + colorA = a; + colorB = b; + } + } + +protected: + void paintEvent(QPaintEvent *) override + { + QPainter painter(this); + QLinearGradient gradient(0, 0, width(), height()); + gradient.setColorAt(0.0, colorA); + gradient.setColorAt(1.0, colorB); + painter.fillRect(rect(), gradient); + } + +private: + QColor colorA{QColor(kFallbackColorA)}; + QColor colorB{QColor(kFallbackColorB)}; +}; + +BannerHost::BannerHost(QWidget *parent) : QWidget(parent) +{ + setFixedHeight(150); + + stack = new QStackedLayout(this); + stack->setContentsMargins(0, 0, 0, 0); + + fallback = new GradientFallbackWidget(this); + stack->addWidget(fallback); + + quickWidget = new QQuickWidget(this); + quickWidget->setResizeMode(QQuickWidget::SizeRootObjectToView); + + config = new BannerShaderConfig(quickWidget->engine()); + quickWidget->rootContext()->setContextProperty("bannerConfig", config); + quickWidget->setSource(QUrl("qrc:/onboarding/qml/BrandBanner.qml")); + + if (quickWidget->status() == QQuickWidget::Error) { + activateFallback(); + } else { + connect(quickWidget, &QQuickWidget::sceneGraphError, this, &BannerHost::onSceneGraphFailed); + stack->addWidget(quickWidget); + stack->setCurrentWidget(quickWidget); + } + + connect(&clock, &QTimer::timeout, this, &BannerHost::tick); + clock.setInterval(16); // ~60fps; the shader itself is cheap, this is just a wall clock + + connect(themeManager, &ThemeManager::themeChanged, this, &BannerHost::applyThemeColors); + applyThemeColors(); + + applyMotifPreset(currentMotif); + updateAspect(); +} + +void BannerHost::activateFallback() +{ + if (usingFallback) { + return; + } + usingFallback = true; + clock.stop(); + stack->setCurrentWidget(fallback); + + if (quickWidget) { + quickWidget->deleteLater(); // takes BannerShaderConfig (parented to its engine) with it + quickWidget = nullptr; + config = nullptr; + } +} + +void BannerHost::onSceneGraphFailed() +{ + activateFallback(); +} + +void BannerHost::setMotif(Motif motif) +{ + currentMotif = motif; + applyMotifPreset(motif); +} + +BannerHost::Preset BannerHost::presetFor(Motif motif) +{ + // speed/seed tuned per motif so e.g. the network "pulse" (Account) reads + // at a deliberately calmer cadence than the data "scan" lines + // (Preferences), even though both come from the same shader. + switch (motif) { + case Motif::Welcome: + return {0.0, 0.6, 0.15}; + case Motif::CardDatabase: + return {1.0, 1.3, 0.42}; + case Motif::Theming: + return {2.0, 1.2, 0.73}; + case Motif::Account: + return {3.0, 0.8, 0.28}; + case Motif::Preferences: + return {4.0, 1.0, 0.61}; + case Motif::Finish: + return {5.0, 1.0, 0.91}; + } + return {0.0, 0.6, 0.15}; +} + +void BannerHost::applyMotifPreset(Motif motif) +{ + if (usingFallback || !config) { + return; + } + + const Preset p = presetFor(motif); + + config->setColorA(bannerColorA); + config->setColorB(bannerColorB); + config->setAccent(bannerAccent); + config->setLogoVisible(motif == Motif::Welcome); + + if (isFirstApply) { + // Nothing on screen yet -- write straight into the front bank, no + // crossfade needed for the very first paint. + config->setModeA(p.mode); + config->setSpeedA(p.speed); + config->setSeedA(p.seed); + config->setFrontIsA(true); + isFirstApply = false; + return; + } + + // Write the new preset into whichever bank is currently hidden, then + // flip which one is front. QML's opacity Behavior does the actual + // crossfade -- BannerHost never animates anything itself. + if (config->frontIsA()) { + config->setModeB(p.mode); + config->setSpeedB(p.speed); + config->setSeedB(p.seed); + config->setFrontIsA(false); + } else { + config->setModeA(p.mode); + config->setSpeedA(p.speed); + config->setSeedA(p.seed); + config->setFrontIsA(true); + } +} + +void BannerHost::updateAspect() +{ + if (config && height() > 0) { + config->setAspect(qreal(width()) / qreal(height())); + } +} + +void BannerHost::resizeEvent(QResizeEvent *event) +{ + QWidget::resizeEvent(event); + updateAspect(); +} + +void BannerHost::showEvent(QShowEvent *event) +{ + QWidget::showEvent(event); + if (!usingFallback) { + elapsed.restart(); + clock.start(); + } +} + +void BannerHost::hideEvent(QHideEvent *event) +{ + QWidget::hideEvent(event); + clock.stop(); +} + +void BannerHost::applyThemeColors() +{ + const SuggestedColors colors = suggestedBannerColors(); + bannerColorA = colors.colorA; + bannerColorB = colors.colorB; + bannerAccent = colors.accent; + + if (usingFallback) { + fallback->setColors(bannerColorA, bannerColorB); + fallback->update(); + } else if (config) { + config->setColorA(bannerColorA); + config->setColorB(bannerColorB); + config->setAccent(bannerAccent); + config->setGlowColor(colors.glowColor); + config->setBrandStrong(colors.brandStrong); + config->setBrandSoft(colors.brandSoft); + config->setVignetteMin(colors.vignetteMin); + } +} + +void BannerHost::tick() +{ + // Palette previews (e.g. accent drags in the wizard's QuickSetupPanel) + // apply qApp->palette() without firing themeChanged, so re-derive here; + // BannerShaderConfig's setters are equality-guarded, so this is a no-op + // unless the colours actually changed. + applyThemeColors(); + if (config) { + qreal t = elapsed.elapsed() / 1000.0; + config->setTime(t); + // Visible breathing for the logo: oscillates between 0.0 and 1.0 + qreal glow = 0.5 + 0.5 * qSin(t * 0.4); + config->setLogoGlow(glow); + } +} diff --git a/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.h b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.h new file mode 100644 index 000000000..6fa0c703f --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.h @@ -0,0 +1,93 @@ +#ifndef SHADER_BANNER_WIDGET_H +#define SHADER_BANNER_WIDGET_H + +#include +#include +#include +#include +#include +#include + +class BannerShaderConfig; +class QQuickWidget; +class GradientFallbackWidget; +class QStackedLayout; + +/** @brief Onboarding banner: a subtle, looping brand-shader animation, one of six + * per-page "motifs" driving the same prebaked fragment shader + * (onboarding/shaders/brand_banner.frag) with different uniform values, so + * every page feels distinct but unmistakably part of the same family. + * + * Motif switches crossfade smoothly (see BrandBanner.qml's two stacked + * ShaderEffect layers + Behavior on opacity) rather than cutting instantly + * -- BannerHost just writes the new preset into whichever layer is + * currently hidden and flips BannerShaderConfig::frontIsA; QML handles the + * actual animation declaratively. + * + * Falls back to a static two-stop gradient (no shader, no QQuickWidget) if + * the platform's Qt Quick scenegraph can't initialize -- e.g. software + * rendering only, or a CI/VM environment with no GPU -- so onboarding + * never blocks or blanks out over a graphics driver problem. The fallback + * is permanent for the lifetime of this widget once triggered. */ +class BannerHost : public QWidget +{ + Q_OBJECT + +public: + enum class Motif + { + Welcome, + CardDatabase, + Theming, + Account, + Preferences, + Finish, + }; + + explicit BannerHost(QWidget *parent = nullptr); + + void setMotif(Motif motif); + +protected: + void showEvent(QShowEvent *event) override; + void hideEvent(QHideEvent *event) override; + void resizeEvent(QResizeEvent *event) override; + +private slots: + void tick(); + void onSceneGraphFailed(); + void applyThemeColors(); + +private: + struct Preset + { + qreal mode; + qreal speed; + qreal seed; + }; + + static Preset presetFor(Motif motif); + + void applyMotifPreset(Motif motif); + void updateAspect(); + void activateFallback(); + + QStackedLayout *stack; + QQuickWidget *quickWidget = nullptr; + BannerShaderConfig *config = nullptr; + GradientFallbackWidget *fallback = nullptr; + + // Palette-derived banner colours -- the theme's window hue forced down to + // the banner's curated darkness, plus the theme's Highlight as accent. + QColor bannerColorA; + QColor bannerColorB; + QColor bannerAccent; + + QTimer clock; + QElapsedTimer elapsed; + Motif currentMotif = Motif::Welcome; + bool usingFallback = false; + bool isFirstApply = true; +}; + +#endif // SHADER_BANNER_WIDGET_H diff --git a/cockatrice/src/interface/widgets/onboarding/shaders/brand_banner.frag b/cockatrice/src/interface/widgets/onboarding/shaders/brand_banner.frag new file mode 100644 index 000000000..2bf6d0abf --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/shaders/brand_banner.frag @@ -0,0 +1,468 @@ +#version 440 + +// ════════════════════════════════════════════════════════════════════════ +// brand_banner.frag +// +// One shader, six motifs (uMode 0..5). All motifs composite over a shared +// backgroundField() whose colour is flow-noise-modulated blend of uColorA +// and uColorB. SDFs operate in aspect-corrected space (ac.x = uv.x * +// uAspect) to preserve shape proportions on the wide banner. +// +// IMPORTANT: the uniform block below must list custom uniforms in EXACTLY +// the order they're declared as properties on each ShaderEffect instance in +// BrandBanner.qml (after the two Qt-supplied members, qt_Matrix/qt_Opacity). +// ════════════════════════════════════════════════════════════════════════ + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(std140, binding = 0) uniform buf +{ + mat4 qt_Matrix; + float qt_Opacity; + float iTime; + float uAspect; + float uMode; + float uSpeed; + float uSeed; + vec4 uColorA; + vec4 uColorB; + vec4 uAccent; + vec4 uGlowColor; + float uVignetteMin; + float uLogoGlow; +}; + +// ── Primitives ────────────────────────────────────────────────────────── + +float hash21(vec2 p) +{ + p = fract(p * vec2(123.34, 456.21)); + p += dot(p, p + 45.32); + return fract(p.x * p.y); +} + +float valueNoise(vec2 p) +{ + vec2 i = floor(p); + vec2 f = fract(p); + float a = hash21(i); + float b = hash21(i + vec2(1.0, 0.0)); + float c = hash21(i + vec2(0.0, 1.0)); + float d = hash21(i + vec2(1.0, 1.0)); + vec2 u = f * f * (3.0 - 2.0 * f); + return mix(mix(a, b, u.x), mix(c, d, u.x), u.y); +} + +float fbm(vec2 p) +{ + float v = 0.0; + float amp = 0.5; + for (int i = 0; i < 3; i++) { + v += amp * valueNoise(p); + p *= 2.03; + amp *= 0.5; + } + return v; +} + +float flowNoise(vec2 p, float t) +{ + vec2 warp1 = vec2(fbm(p + vec2(0.0, 0.0)), fbm(p + vec2(5.2, 1.3))); + vec2 warp2 = vec2(fbm(p + 4.0 * warp1 + vec2(1.7, 9.2) + t * 0.6), + fbm(p + 4.0 * warp1 + vec2(8.3, 2.8) - t * 0.5)); + return fbm(p + 4.0 * warp2 + t * 0.15); +} + +float bloom(float d, float coreRadius, float haloRadius) +{ + float core = exp(-(d * d) / (coreRadius * coreRadius)); + float halo = exp(-d / haloRadius) * 0.35; + return core + halo; +} + +float roundedBoxSDF(vec2 p, vec2 halfSize, float radius) +{ + vec2 d = abs(p) - halfSize + radius; + return length(max(d, 0.0)) - radius + min(max(d.x, d.y), 0.0); +} + +// Rotated box SDF -- applies 2D rotation to p before evaluating roundedBoxSDF. +float rotatedBoxSDF(vec2 p, vec2 halfSize, float radius, float angle) +{ + float c = cos(angle); + float s = sin(angle); + vec2 rp = vec2(p.x * c - p.y * s, p.x * s + p.y * c); + return roundedBoxSDF(rp, halfSize, radius); +} + +float vignette(vec2 uv) +{ + vec2 c = uv - 0.5; + c.x *= max(uAspect, 0.0001); + return smoothstep(1.0, 0.25, length(c)); +} + +// ── Shared background ─────────────────────────────────────────────────── + +vec3 backgroundField(vec2 uv, float time) +{ + // Diagonal luminance gradient from (0,0) to (1,1) used as blend factor + // between uColorA and uColorB; modulated by flowNoise. + float baseD = smoothstep(0.0, 1.0, uv.y * 0.5 + uv.x * 0.2); + float painted = flowNoise(uv * 1.5, time * 0.04) - 0.5; + baseD = clamp(baseD + painted * 0.12, 0.0, 1.0); + + vec3 col = mix(uColorA.rgb, uColorB.rgb, baseD); + + // Low-frequency fBM noise pushes local colour toward uColorB for depth + float deep = fbm(uv * 1.0 + vec2(37.1, 12.4) + time * 0.015); + col = mix(col, uColorB.rgb, (deep - 0.5) * 0.08); + + // Accent-coloured fog layer: flowNoise peaks above 0.6 contribute accent + float fog = flowNoise(uv * 0.8 + vec2(100.0, 50.0), time * 0.02); + col += uAccent.rgb * max(fog - 0.6, 0.0) * 0.14; + + return col; +} + +// ── Motifs ────────────────────────────────────────────────────────────── + +// Centre bloom, flow-noise shimmer gated to centre, and 48 orbiting ember +// particles that deflect into a tight ring near the centre. +vec3 motifWelcome(vec2 uv, vec3 bg, float t) +{ + vec3 col = bg; + float asp = max(uAspect, 0.001); + vec2 ac = vec2(uv.x * asp, uv.y); + vec2 center = vec2(asp * 0.5, 0.5); + float cDist = length(ac - center); + + // Centre bloom at logo position; intensity scales with uLogoGlow. The + // QML brandGlow halo now supplies the primary logo surround (the two + // brand appColors), so this shader bloom is deliberately kept as a subtle + // ambience rather than a competing glow. + float centreLight = bloom(cDist, 0.08 * asp, 0.40 * asp); + col += uGlowColor.rgb * centreLight * 0.20 * uLogoGlow; + + // Flow-noise shimmer gated by Gaussian mask at centre + float shimmer = flowNoise(ac * 0.8 + vec2(55.0, 33.0), t * 0.05) * 0.5 + 0.5; + float shimmerMask = exp(-(cDist * cDist) / (0.18 * asp * 0.18 * asp)); + col += uGlowColor.rgb * shimmer * shimmerMask * 0.04 * uLogoGlow; + + // 48 ember particles: hash-seeded position, speed, size, brightness. + // Embers within a distance threshold of centre are deflected into an + // orbital ring via tangent displacement perpendicular to the centre vector. + const int EMBERS = 48; + for (int i = 0; i < EMBERS; i++) { + float fi = float(i); + + float baseX = hash21(vec2(fi * 7.31 + uSeed, fi * 3.17)); + float baseY = hash21(vec2(fi * 11.9 + uSeed * 1.4, fi * 5.53)); + + float riseSpeed = 0.025 + hash21(vec2(fi * 1.7, uSeed * 2.1)) * 0.035; + float driftAmp = 0.04 + hash21(vec2(fi * 9.3, uSeed)) * 0.06; + float driftFreq = 0.3 + hash21(vec2(fi * 4.1, uSeed * 3.3)) * 0.5; + + float pX = baseX * asp + sin(t * driftFreq + fi * 1.7) * driftAmp * asp; + float pY = fract(baseY + t * riseSpeed); + + float size = 0.010 + hash21(vec2(fi * 2.9, uSeed * 4.7)) * 0.014; + float bright = 0.18 + hash21(vec2(fi * 6.1, uSeed * 0.9)) * 0.32; + + // Fade out near top/bottom edges + float edgeFade = smoothstep(0.0, 0.12, pY) * smoothstep(1.0, 0.88, pY); + float twinkle = 0.6 + 0.4 * sin(t * (1.2 + fi * 0.37) + fi * 2.9); + + vec2 ePos = vec2(pX, pY); + + // Embers near centre: deflect into orbital ring via tangent displacement + vec2 toCenter = ePos - center; + float distToCenter = length(toCenter); + float ringWeight = smoothstep(0.38 * asp, 0.06 * asp, distToCenter); + + float orbitPhase = t * (0.15 + fi * 0.020) + fi * 2.3; + float orbitAmount = 0.020 + hash21(vec2(fi * 12.3, uSeed * 2.7)) * 0.020; + vec2 tangent = vec2(-toCenter.y, toCenter.x); + vec2 deflected = ePos + tangent * ringWeight * orbitAmount * asp * sin(orbitPhase); + + float pushOut = ringWeight * (0.008 + hash21(vec2(fi * 6.7, uSeed * 1.1)) * 0.012) * asp; + deflected += normalize(toCenter + 0.001) * pushOut; + + float dist = length(ac - deflected); + float intensity = bright * edgeFade * twinkle; + col += uAccent.rgb * bloom(dist, size, size * 4.0) * intensity; + } + + return col; +} + +// 25 card-shaped box SDFs at parallax depths drifting horizontally across +// the banner; each card has a semi-transparent fill, accent outline, and +// card-back diamond pattern. +vec3 motifCardDatabase(vec2 uv, vec3 bg, float t) +{ + vec3 col = bg; + float asp = max(uAspect, 0.001); + vec2 ac = vec2(uv.x * asp, uv.y); + + const int CARDS = 25; + for (int i = 0; i < CARDS; i++) { + float fi = float(i); + + // Parallax depth via hash; used to scale size, speed, brightness + float depth = hash21(vec2(fi * 1.37 + uSeed, fi * 0.91)); + + // Card dimensions in corrected space (portrait: height > width) + float cardH = mix(0.055, 0.15, depth); + cardH *= 0.85 + 0.30 * hash21(vec2(fi * 3.14, uSeed * 2.71)); + float cardW = cardH * 0.71; // 5:7 ratio + + // Horizontal drift; nearer cards (higher depth) move faster + float speed = mix(0.06, 0.18, depth); + float xPhase = hash21(vec2(fi * 7.13, uSeed * 4.37)); + xPhase = fract(xPhase + t * speed); + float x = mix(-1.5, asp + 1.5, xPhase); + + // Vertical position: hash distribution with sinusoidal oscillation + float yBase = hash21(vec2(fi * 2.91, uSeed * 1.63)); + float y = yBase + sin(t * 0.6 + fi * 1.9) * 0.035; + y = clamp(y, cardH + 0.02, 1.0 - cardH - 0.02); + + // Random rotation angle ±4 degrees + float tilt = (hash21(vec2(fi * 5.71, uSeed * 8.29)) - 0.5) * 0.14; + + vec2 p = ac - vec2(x, y); + float d = rotatedBoxSDF(p, vec2(cardW, cardH), cardW * 0.14, tilt); + + // Semi-transparent dark fill + float fill = smoothstep(0.015, -0.005, d); + col = mix(col, uColorB.rgb * 0.60, fill * 0.62); + + // Accent outline + float edge = smoothstep(0.035, 0.0, abs(d)); + col += uAccent.rgb * edge * mix(0.18, 0.50, 1.0 - depth); + + // Card-back diamond: smaller rotated box inset from card edges + float innerD = rotatedBoxSDF(p, vec2(cardW * 0.45, cardH * 0.55), cardW * 0.08, tilt); + float innerEdge = smoothstep(0.012, 0.0, abs(innerD)); + col += uAccent.rgb * innerEdge * fill * 0.12 * (1.0 - depth); + + // Centre dot + float dotDist = length(p); + col += uAccent.rgb * bloom(dotDist, 0.008, 0.02) * fill * 0.15 * (1.0 - depth); + } + return col; +} + +// 4 horizontal bands with multi-frequency sinusoidal warp and pulsing width. +vec3 motifTheming(vec2 uv, vec3 bg, float t) +{ + vec3 col = bg; + + const int BANDS = 4; + for (int i = 0; i < BANDS; i++) { + float fi = float(i); + float yCenter = 0.18 + fi * 0.22; + + // Three summed sinusoids for horizontal undulation + float wave = sin(uv.x * 3.2 + t * 0.5 + fi * 2.1) * 0.08; + wave += sin(uv.x * 7.0 - t * 0.3 + fi * 1.3) * 0.035; + wave += sin(uv.x * 1.6 + t * 0.18 + fi * 3.7) * 0.05; + + float bandDist = abs(uv.y - yCenter - wave); + float bandWidth = 0.04 + sin(t * 0.2 + fi * 0.8) * 0.012; + float band = smoothstep(bandWidth, 0.0, bandDist); + + // Upper bands have higher intensity + float intensity = mix(0.15, 0.38, 1.0 - fi / float(BANDS)); + col += uAccent.rgb * band * intensity; + } + + return col; +} + +// 14 nodes at pseudo-random positions with sinusoidal pulse; edges drawn +// between nodes within a threshold distance; central glow + periodic ring. +vec3 motifAccount(vec2 uv, vec3 bg, float t) +{ + vec3 col = bg; + float asp = max(uAspect, 0.001); + vec2 ac = vec2(uv.x * asp, uv.y); + vec2 center = vec2(asp * 0.5, 0.5); + + const int NODES = 14; + vec2 nodePos[14]; + float nodePulse[14]; + + for (int i = 0; i < NODES; i++) { + float fi = float(i); + + // Hash-seeded position with gentle sinusoidal drift + float nx = hash21(vec2(fi * 3.17 + uSeed, fi * 1.93)) * asp; + float ny = hash21(vec2(fi * 5.41 + uSeed * 1.7, fi * 2.79)); + + float dx = sin(t * 0.12 + fi * 1.7) * 0.08; + float dy = cos(t * 0.09 + fi * 2.3) * 0.04; + vec2 pos = vec2(nx + dx, ny + dy); + nodePos[i] = pos; + + // Per-node pulse phase, normalised to [0, 1] + float pulsePhase = hash21(vec2(fi * 4.31, uSeed * 6.17)); + float pulse = sin(t * 0.8 + pulsePhase * 6.283) * 0.5 + 0.5; + nodePulse[i] = pulse; + + // Node glow via bloom; intensity modulated by pulse + float dist = length(ac - pos); + col += uAccent.rgb * bloom(dist, 0.018, 0.08) * mix(0.30, 0.55, pulse); + } + + // Edges: connect nodes within a radius threshold + float connectDist = asp * 0.22; + for (int i = 0; i < NODES; i++) { + for (int j = i + 1; j < NODES; j++) { + float pairDist = length(nodePos[i] - nodePos[j]); + if (pairDist < connectDist) { + float strength = 1.0 - pairDist / connectDist; + vec2 pa = ac - nodePos[i]; + vec2 ba = nodePos[j] - nodePos[i]; + float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0); + float lineDist = length(pa - ba * h); + col += uAccent.rgb * smoothstep(0.010, 0.0, lineDist) * strength * 0.14; + } + } + } + + // Central bloom at banner centre + float cDist = length(ac - center); + col += uAccent.rgb * bloom(cDist, 0.04, 0.25) * 0.20; + + // Periodic expanding ring from centre + float ripplePhase = t * 0.4; + float rippleDist = abs(cDist - fract(ripplePhase) * asp * 0.7); + col += uAccent.rgb * smoothstep(0.02, 0.0, rippleDist) * 0.14; + + return col; +} + +// 18x5 toggle-grid of rounded boxes with hash-driven on/off per cell; +// a scanning highlight sweeps L-to-R, brightening cells near the scan line. +vec3 motifPreferences(vec2 uv, vec3 bg, float t) +{ + vec3 col = bg; + + float cols = 18.0; + float rows = 5.0; + vec2 gridUV = uv * vec2(cols, rows); + vec2 cell = fract(gridUV) - 0.5; + vec2 cellId = floor(gridUV); + + // On/off state per cell, hash-seeded for pseudo-randomness + float on = step(0.55, hash21(cellId + uSeed * 10.0)); + + float d = roundedBoxSDF(cell, vec2(0.28, 0.32), 0.06); + + // Filled "on" cells + float cellFill = smoothstep(0.04, -0.02, d); + col += uAccent.rgb * cellFill * on * 0.18; + + // Cell borders (drawn on all cells) + float border = smoothstep(0.025, 0.0, abs(d)); + col += uAccent.rgb * border * 0.06; + + // Scanning highlight: thin line + soft glow sweeping L-to-R + float scanX = fract(t * 0.15); + float scanDist = abs(uv.x - scanX); + float scanLine = smoothstep(0.015, 0.0, scanDist); + col += uAccent.rgb * scanLine * 0.40; + + float scanGlow = smoothstep(0.08, 0.0, scanDist); + col += uAccent.rgb * scanGlow * 0.08; + + // "On" cells near the scan line get extra brightness + float scanProximity = smoothstep(0.12, 0.0, scanDist); + col += uAccent.rgb * cellFill * on * scanProximity * 0.15; + + return col; +} + +// Centre radial bloom with sinusoidal pulse, 4 expanding ring halos with +// outer glow falloff, and 35 rising particles. +vec3 motifFinish(vec2 uv, vec3 bg, float t) +{ + vec3 col = bg; + float asp = max(uAspect, 0.001); + vec2 ac = vec2(uv.x * asp, uv.y); + vec2 center = vec2(asp * 0.5, 0.5); + float cDist = length(ac - center); + + // Centre bloom with sinusoidal pulse modulation + float pulse = 0.65 + 0.35 * sin(t * 0.4); + col += uAccent.rgb * bloom(cDist, 0.12, 0.55) * 0.10 * pulse; + + // 4 expanding rings: radius increases via phase; ring width grows with + // expansion; combined with exponential outer glow falloff + for (int i = 0; i < 4; i++) { + float fi = float(i); + float phase = fract(t * 0.06 + fi * 0.25); + float ringRadius = phase * asp * 0.7; + float ringDist = abs(cDist - ringRadius); + float ringWidth = 0.025 + phase * 0.025; + float ring = smoothstep(ringWidth, 0.0, ringDist); + float outerGlow = exp(-ringDist / (0.03 + phase * 0.02)) * 0.3; + float combined = ring + outerGlow; + float fade = 1.0 - phase * 0.5; + col += uAccent.rgb * combined * fade * 0.15; + } + + // 35 particles rising vertically with sinusoidal horizontal drift; + // each particle uses bloom with edge fade and twinkle animation + const int PARTICLES = 35; + for (int i = 0; i < PARTICLES; i++) { + float fi = float(i); + float baseX = hash21(vec2(fi * 13.7 + uSeed, fi * 7.31)); + float baseY = hash21(vec2(fi * 23.1 + uSeed * 1.9, fi * 11.3)); + + float riseSpeed = 0.04 + hash21(vec2(fi * 3.1, uSeed * 2.7)) * 0.06; + float driftAmp = 0.03 + hash21(vec2(fi * 8.9, uSeed)) * 0.05; + float driftFreq = 0.4 + hash21(vec2(fi * 5.3, uSeed * 4.1)) * 0.6; + + float pX = baseX * asp + sin(t * driftFreq + fi * 2.3) * driftAmp * asp; + float pY = fract(baseY + t * riseSpeed); + + float size = 0.005 + hash21(vec2(fi * 4.7, uSeed * 3.9)) * 0.010; + float bright = 0.12 + hash21(vec2(fi * 7.1, uSeed * 1.3)) * 0.25; + + float edgeFade = smoothstep(0.0, 0.1, pY) * smoothstep(1.0, 0.9, pY); + float twinkle = 0.5 + 0.5 * sin(t * (1.8 + fi * 0.43) + fi * 3.1); + + vec2 pPos = vec2(pX, pY); + float dist = length(ac - pPos); + col += uAccent.rgb * bloom(dist, size, size * 3.5) * bright * edgeFade * twinkle; + } + + return col; +} + +// ── Main ──────────────────────────────────────────────────────────────── + +void main() +{ + vec2 uv = qt_TexCoord0; + float t = iTime * uSpeed; + + vec3 bg = backgroundField(uv, iTime); + + vec3 col; + if (uMode < 0.5) col = motifWelcome(uv, bg, t); + else if (uMode < 1.5) col = motifCardDatabase(uv, bg, t); + else if (uMode < 2.5) col = motifTheming(uv, bg, t); + else if (uMode < 3.5) col = motifAccount(uv, bg, t); + else if (uMode < 4.5) col = motifPreferences(uv, bg, t); + else col = motifFinish(uv, bg, t); + + // Corner vignette; uVignetteMin is scheme-driven (0.62 on dark stages, + // gentler on light ones so near-white corners don't go muddy grey). + col *= mix(uVignetteMin, 1.0, vignette(uv)); + fragColor = vec4(col, 1.0) * qt_Opacity; +} diff --git a/cockatrice/src/interface/widgets/onboarding/shaders/brand_plate.frag b/cockatrice/src/interface/widgets/onboarding/shaders/brand_plate.frag new file mode 100644 index 000000000..43b798c9f --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/shaders/brand_plate.frag @@ -0,0 +1,42 @@ +#version 440 + +// The logo's gradient plate, drawn by us rather than the baked-in SVG: a +// linear blend between the two brand appColors (light AccentSoft at the +// top-left grading to dark AccentStrong at the bottom-right, mirroring +// cockatrice.svg's linearGradient4265-7-8 userSpaceOnUse axis), clipped to +// the bird's full silhouette via the full-color logo's alpha (uSilhouette). +// The white highlight path (cockatrice-logo-white) is overlaid in QML on top, +// exactly as the SVG stacks its white path over the gradient paths. Fully +// static: no glow, no breathing — the plate just sits there like the home +// widget's QPainter composite. + +layout(location = 0) in vec2 qt_TexCoord0; +layout(location = 0) out vec4 fragColor; + +layout(std140, binding = 0) uniform buf +{ + mat4 qt_Matrix; + float qt_Opacity; + vec4 uStrong; + vec4 uSoft; +}; + +// The full-color logo's alpha channel acts as the silhouette mask: the +// gradient only appears inside the bird, exactly like the SVG's gradient paths. +layout(binding = 1) uniform sampler2D uSilhouette; + +void main() +{ + // Recreate cockatrice.svg's own gradient geometry (linearGradient4265-7-8, + // userSpaceOnUse): light AccentSoft at the start point S=(-8.097,-97.746), + // dark AccentStrong at the end E=(162.455,295.208), on the SVG's 300x300 + // canvas. Normalized to UV space, V=E-S=(0.5685,1.3098), so + // t = dot(uv - S_norm, V)/|V|^2 with S_norm=(-0.0270,-0.3258). + float t = clamp(dot(qt_TexCoord0 - vec2(-0.02699, -0.32582), vec2(0.56851, 1.30985)) / 2.03891, 0.0, 1.0); + vec3 color = mix(uSoft.rgb, uStrong.rgb, t); + + // Anti-aliased silhouette clip from the full-color logo's alpha. + float alpha = texture(uSilhouette, qt_TexCoord0).a; + + fragColor = vec4(color * alpha, alpha) * qt_Opacity; +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/onboarding/step_indicator_widget.cpp b/cockatrice/src/interface/widgets/onboarding/step_indicator_widget.cpp new file mode 100644 index 000000000..004bdcc11 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/step_indicator_widget.cpp @@ -0,0 +1,90 @@ +#include "step_indicator_widget.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +StepIndicatorWidget::StepIndicatorWidget(QWidget *parent) : QWidget(parent) +{ + setFixedHeight(kDotDiameter + 2 * kVerticalMargin); +} + +void StepIndicatorWidget::setStepCount(int count) +{ + stepCount = qMax(0, count); + currentStep = qBound(0, currentStep, qMax(0, stepCount - 1)); + updateGeometry(); + update(); +} + +void StepIndicatorWidget::setCurrentStep(int index) +{ + if (stepCount == 0) { + return; + } + currentStep = qBound(0, index, stepCount - 1); + update(); +} + +QSize StepIndicatorWidget::sizeHint() const +{ + return minimumSizeHint(); +} + +QSize StepIndicatorWidget::minimumSizeHint() const +{ + if (stepCount == 0) { + return QSize(0, height()); + } + int width = kActiveDotWidth + (stepCount - 1) * kDotDiameter + (stepCount - 1) * kDotSpacing; + return QSize(width, height()); +} + +void StepIndicatorWidget::paintEvent(QPaintEvent * /*event*/) +{ + if (stepCount == 0) { + return; + } + + QPainter painter(this); + painter.setRenderHint(QPainter::Antialiasing); + + const QColor activeColor = palette().color(QPalette::Highlight); + + // QPalette::Mid alpha-blended against a dark Window background reads as + // near-invisible (Mid is itself a dark grey in dark palettes -- see + // PaletteGenerator's satShadeLo/Dark roles). WindowText is guaranteed to + // contrast against Window in any theme by definition, so alpha-blending + // *that* instead keeps the dots visibly dim-but-present in both light and + // dark schemes. Same trick PaletteGenerator uses for placeholder text. + QColor inactiveColor = palette().color(QPalette::WindowText); + inactiveColor.setAlpha(100); + + int totalWidth = 0; + for (int i = 0; i < stepCount; ++i) { + totalWidth += (i == currentStep) ? kActiveDotWidth : kDotDiameter; + if (i > 0) { + totalWidth += kDotSpacing; + } + } + + int x = (width() - totalWidth) / 2; + const int y = height() / 2; + + for (int i = 0; i < stepCount; ++i) { + const bool active = (i == currentStep); + const int dotWidth = active ? kActiveDotWidth : kDotDiameter; + + QPainterPath path; + QRectF rect(x, y - kDotDiameter / 2.0, dotWidth, kDotDiameter); + path.addRoundedRect(rect, kDotDiameter / 2.0, kDotDiameter / 2.0); + painter.fillPath(path, active ? activeColor : inactiveColor); + + x += dotWidth + kDotSpacing; + } +} diff --git a/cockatrice/src/interface/widgets/onboarding/step_indicator_widget.h b/cockatrice/src/interface/widgets/onboarding/step_indicator_widget.h new file mode 100644 index 000000000..d2b5dfcb5 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/step_indicator_widget.h @@ -0,0 +1,35 @@ +#ifndef STEP_INDICATOR_WIDGET_H +#define STEP_INDICATOR_WIDGET_H + +#include +#include + +/** @brief Row of dots showing progress through a fixed-length sequence of steps, + * in the style of a mobile/OS setup flow. Purely presentational. */ +class StepIndicatorWidget : public QWidget +{ + Q_OBJECT + +public: + explicit StepIndicatorWidget(QWidget *parent = nullptr); + + void setStepCount(int count); + void setCurrentStep(int index); + + QSize sizeHint() const override; + QSize minimumSizeHint() const override; + +protected: + void paintEvent(QPaintEvent *event) override; + +private: + int stepCount = 0; + int currentStep = 0; + + static constexpr int kDotDiameter = 8; + static constexpr int kActiveDotWidth = 22; + static constexpr int kDotSpacing = 10; + static constexpr int kVerticalMargin = 6; +}; + +#endif // STEP_INDICATOR_WIDGET_H diff --git a/cockatrice/src/interface/widgets/playmat/playmat_collection_dialog.cpp b/cockatrice/src/interface/widgets/playmat/playmat_collection_dialog.cpp new file mode 100644 index 000000000..f77aefb33 --- /dev/null +++ b/cockatrice/src/interface/widgets/playmat/playmat_collection_dialog.cpp @@ -0,0 +1,196 @@ +#include "playmat_collection_dialog.h" + +#include "../../../client/settings/cache_settings.h" +#include "libcockatrice/interfaces/interface_interface_settings_provider.h" +#include "libcockatrice/utility/card_ref.h" +#include "libcockatrice/utility/playmat_params.h" +#include "playmat_settings_dialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class QWidget; + +PlaymatCollectionDialog::PlaymatCollectionDialog(QWidget *parent) : QDialog(parent) +{ + setMinimumWidth(420); + setupUi(); + retranslateUi(); +} + +void PlaymatCollectionDialog::accept() +{ + auto &interfaceSettings = SettingsCache::instance().userInterface(); + interfaceSettings.setPlaymatFallbackList(playmats); + interfaceSettings.setPlaymatFallbackBehavior(modeCombo->currentData().toInt()); + QDialog::accept(); +} + +int PlaymatCollectionDialog::currentRow() const +{ + return playmatList->currentRow(); +} + +void PlaymatCollectionDialog::setupUi() +{ + auto &interfaceSettings = SettingsCache::instance().userInterface(); + playmats = interfaceSettings.getPlaymatFallbackList(); + + playmatList = new QListWidget; + for (const PlaymatInfo &entry : playmats) { + playmatList->addItem(entry.card.name); + } + connect(playmatList, &QListWidget::itemSelectionChanged, this, &PlaymatCollectionDialog::selectionChanged); + connect(playmatList, &QListWidget::itemDoubleClicked, this, [this](QListWidgetItem *) { editPlaymat(); }); + + addButton = new QPushButton; + editButton = new QPushButton; + removeButton = new QPushButton; + moveUpButton = new QPushButton; + moveDownButton = new QPushButton; + + connect(addButton, &QPushButton::clicked, this, &PlaymatCollectionDialog::addPlaymat); + connect(editButton, &QPushButton::clicked, this, &PlaymatCollectionDialog::editPlaymat); + connect(removeButton, &QPushButton::clicked, this, &PlaymatCollectionDialog::removePlaymat); + connect(moveUpButton, &QPushButton::clicked, this, &PlaymatCollectionDialog::movePlaymatUp); + connect(moveDownButton, &QPushButton::clicked, this, &PlaymatCollectionDialog::movePlaymatDown); + + auto *listButtons = new QVBoxLayout; + listButtons->addWidget(addButton); + listButtons->addWidget(editButton); + listButtons->addWidget(removeButton); + listButtons->addWidget(moveUpButton); + listButtons->addWidget(moveDownButton); + listButtons->addStretch(); + + auto *listRow = new QHBoxLayout; + listRow->addWidget(playmatList, 1); + listRow->addLayout(listButtons); + + modeCombo = new QComboBox; + modeCombo->addItem(QString(), PlaymatFallbackModeFixed); + modeCombo->addItem(QString(), PlaymatFallbackModeRoundRobin); + modeCombo->addItem(QString(), PlaymatFallbackModeRandom); + const int modeIndex = modeCombo->findData(interfaceSettings.getPlaymatFallbackBehavior()); + if (modeIndex >= 0) { + modeCombo->setCurrentIndex(modeIndex); + } + + auto *modeRow = new QHBoxLayout; + modeLabel = new QLabel; + modeLabel->setBuddy(modeCombo); + modeRow->addWidget(modeLabel); + modeRow->addWidget(modeCombo, 1); + + auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + connect(buttonBox, &QDialogButtonBox::accepted, this, &PlaymatCollectionDialog::accept); + connect(buttonBox, &QDialogButtonBox::rejected, this, &QDialog::reject); + + auto *root = new QVBoxLayout; + root->addLayout(listRow); + root->addLayout(modeRow); + root->addWidget(buttonBox); + setLayout(root); + + selectionChanged(); +} + +void PlaymatCollectionDialog::selectionChanged() +{ + const bool hasSelection = playmatList->currentRow() >= 0; + editButton->setEnabled(hasSelection); + removeButton->setEnabled(hasSelection); + moveUpButton->setEnabled(hasSelection && playmatList->currentRow() > 0); + moveDownButton->setEnabled(hasSelection && playmatList->currentRow() < playmatList->count() - 1); +} + +void PlaymatCollectionDialog::addPlaymat() +{ + PlaymatSettingsDialog dialog(CardRef{}, PlaymatParams{}, this); + if (dialog.exec() == QDialog::Accepted) { + const CardRef card = dialog.card(); + if (!card.isEmpty()) { + PlaymatInfo res = {card, dialog.params()}; + playmats.append(res); + playmatList->addItem(res.card.name); + playmatList->setCurrentRow(playmatList->count() - 1); + } + } +} + +void PlaymatCollectionDialog::editPlaymat() +{ + const int row = currentRow(); + if (row < 0) { + return; + } + + const PlaymatInfo ¤t = playmats.at(row); + PlaymatSettingsDialog dialog(current.card, current.params, this); + if (dialog.exec() == QDialog::Accepted) { + const CardRef card = dialog.card(); + if (card.isEmpty()) { + return; // Removal is handled by the Remove button + } + playmats[row] = {card, dialog.params()}; + playmatList->item(row)->setText(card.name); + } +} + +void PlaymatCollectionDialog::removePlaymat() +{ + const int row = currentRow(); + if (row < 0) { + return; + } + playmats.removeAt(row); + delete playmatList->takeItem(row); + selectionChanged(); +} + +void PlaymatCollectionDialog::movePlaymatUp() +{ + const int row = currentRow(); + if (row <= 0) { + return; + } + playmats.swapItemsAt(row, row - 1); + playmatList->insertItem(row - 1, playmatList->takeItem(row)); + playmatList->setCurrentRow(row - 1); + selectionChanged(); +} + +void PlaymatCollectionDialog::movePlaymatDown() +{ + const int row = currentRow(); + if (row < 0 || row >= playmats.size() - 1) { + return; + } + playmats.swapItemsAt(row, row + 1); + playmatList->insertItem(row + 1, playmatList->takeItem(row)); + playmatList->setCurrentRow(row + 1); + selectionChanged(); +} + +void PlaymatCollectionDialog::retranslateUi() +{ + setWindowTitle(tr("Default Playmats")); + addButton->setText(tr("Add...")); + editButton->setText(tr("Edit...")); + removeButton->setText(tr("Remove")); + moveUpButton->setText(tr("Move Up")); + moveDownButton->setText(tr("Move Down")); + modeLabel->setText(tr("List mode:")); + modeCombo->setItemText(0, tr("Fixed (always the first entry)")); + modeCombo->setItemText(1, tr("Round-robin (cycle through entries)")); + modeCombo->setItemText(2, tr("Random (pick one per game)")); +} diff --git a/cockatrice/src/interface/widgets/playmat/playmat_collection_dialog.h b/cockatrice/src/interface/widgets/playmat/playmat_collection_dialog.h new file mode 100644 index 000000000..a04cfdf54 --- /dev/null +++ b/cockatrice/src/interface/widgets/playmat/playmat_collection_dialog.h @@ -0,0 +1,56 @@ +#ifndef COCKATRICE_PLAYMAT_COLLECTION_DIALOG_H +#define COCKATRICE_PLAYMAT_COLLECTION_DIALOG_H + +#include +#include +#include +#include + +class QComboBox; +class QLabel; +class QListWidget; +class QPushButton; +class QWidget; + +/** + * @brief Dialog for editing the user-level playmat collection. + * + * The collection is the fallback used when a deck has no playmat of its own. + * It supports multiple entries and a pick mode (always first / round-robin / + * random). The dialog edits a working copy and writes it to the settings only + * when accepted. + */ +class PlaymatCollectionDialog : public QDialog +{ + Q_OBJECT + +public: + explicit PlaymatCollectionDialog(QWidget *parent = nullptr); + + void accept() override; + +private slots: + void addPlaymat(); + void editPlaymat(); + void removePlaymat(); + void movePlaymatUp(); + void movePlaymatDown(); + void selectionChanged(); + +private: + void setupUi(); + void retranslateUi(); + int currentRow() const; + + QList playmats; ///< Working copy edited by the dialog. + QListWidget *playmatList; + QComboBox *modeCombo; + QLabel *modeLabel; + QPushButton *addButton; + QPushButton *editButton; + QPushButton *removeButton; + QPushButton *moveUpButton; + QPushButton *moveDownButton; +}; + +#endif // COCKATRICE_PLAYMAT_COLLECTION_DIALOG_H diff --git a/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.cpp b/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.cpp new file mode 100644 index 000000000..881866292 --- /dev/null +++ b/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.cpp @@ -0,0 +1,390 @@ +#include "playmat_preview_widget.h" + +#include "../cards/art_crop_attribution.h" +#include "playmat_utils.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +// Mirrors the dialog/proto clamps so gestures can never produce an +// out of range parameter. The zoom FLOOR is dynamic: see +// playmatClampedZoom(), zooming out stops where the sampling window would +// exceed the card itself, so there is no dead range at the bottom end. +constexpr qreal MAX_MARGIN = 0.95; +// Range of in game stack+table aspect ratios worth designing for, derived +// from PlayerGraphicsItem::paint()'s combinedArea = stack ∪ table: +// height = 10 + 30 + 3*102 + 2*30 = 406 (TableZone rows) +// width = 1.5*72 + (20 + 5*72 + 15) = 503 (StackZone + TableZone +// at MIN_WIDTH) +// The area's shape depends on GAME CONTENT (played card columns widen the +// table by ~107 px each), not on the window size. Fresh board ≈ 503/406, +// a table grown to roughly double its minimum width ≈ 2.2. +constexpr qreal MIN_TABLE_ASPECT = 503.0 / 406.0; // fresh board: most generous framing +constexpr qreal MAX_TABLE_ASPECT = 2.2; // well developed, wide table +// Keyboard nudge steps (viewport convention: Down looks further down). +constexpr qreal KEY_PAN_MARGIN_STEP = 0.005; +constexpr qreal KEY_PAN_OFFSET_STEP = 0.01; +constexpr qreal KEY_ZOOM_STEP = 1.05; +constexpr qreal WHEEL_ZOOM_BASE = 1.15; // zoom factor per wheel notch +} // namespace + +PlaymatPreviewWidget::PlaymatPreviewWidget(QWidget *parent) : QWidget(parent) +{ + // The crop is square and drawn contain fit, so the height decides its + // on screen size, keep it generous but let the dialog compress on small + // or high DPI screens + setMinimumSize(400, 180); + QSizePolicy sp(QSizePolicy::Expanding, QSizePolicy::Expanding); + setSizePolicy(sp); + setFocusPolicy(Qt::StrongFocus); + setCursor(Qt::OpenHandCursor); + setAccessibleName(tr("Playmat crop")); + setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2))); +} + +void PlaymatPreviewWidget::setPixmap(const QPixmap &pixmap) +{ + sourcePixmap = pixmap; + setCursor(sourcePixmap.isNull() ? Qt::ArrowCursor : Qt::OpenHandCursor); + update(); +} + +void PlaymatPreviewWidget::setParams(const PlaymatParams &p) +{ + params = p; + setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2))); + update(); +} + +void PlaymatPreviewWidget::setAttribution(const QString &attribution) +{ + attributionText = attribution; + update(); +} + +QRectF PlaymatPreviewWidget::activePlayArea() const +{ + // The viewport is a frame shaped like a fresh board's stack+table area + // (kMinTableAspect): the most generous framing the game will produce. + // Dimmed strips mark where a wider, developed table crops further. + const QRectF cardRect = QRectF(rect()).adjusted(3, 2, -3, -2); + return PlaymatUtils::aspectFitRect(cardRect.adjusted(6, 4, -4, -4), MIN_TABLE_ASPECT); +} + +qreal PlaymatPreviewWidget::samplingWindowSide() const +{ + if (sourcePixmap.isNull()) { + return 0.0; + } + // Same clamped window the render path uses, gestures and painting must + // never disagree about geometry. + return PlaymatUtils::playmatWindowSide(sourcePixmap.size(), params); +} + +qreal PlaymatPreviewWidget::widgetToSourceScale() const +{ + const qreal cropSide = samplingWindowSide(); + const QRectF area = activePlayArea(); + if (cropSide <= 0.0 || area.isEmpty()) { + return 0.0; + } + // Mirror coverFitRect(): the square crop into the (wider) viewport fills + // its width. + return area.width() / cropSide; +} + +void PlaymatPreviewWidget::applyCropDelta(qreal dMarginL, qreal dMarginR, qreal dOffset, qreal zoomFactor) +{ + PlaymatParams next = params; + if (dMarginL + dMarginR == 0.0) { + // Pure horizontal pan rebalances the margins along their + // sum constant segment. Individual bounds must not break that + // invariant, otherwise repeated corner drags let one margin grow + // without end, collapsing the viewing window and desynchronizing the + // visual zoom from the readout. + const qreal sum = params.marginPctL + params.marginPctR; + const qreal lo = qMax(0.0, sum - MAX_MARGIN); + const qreal hi = qMin(sum, MAX_MARGIN); + next.marginPctL = qBound(lo, params.marginPctL + dMarginL, hi); + next.marginPctR = sum - next.marginPctL; + } else { + next.marginPctL = qBound(0.0, params.marginPctL + dMarginL, MAX_MARGIN); + next.marginPctR = qBound(0.0, params.marginPctR + dMarginR, MAX_MARGIN); + } + next.verticalOffset = qBound(0.0, params.verticalOffset + dOffset, 1.0); + // Clamp through the shared helper so the floor tracks the new margins: + // zooming out stops exactly where the window reaches the card bounds. + next.zoom = params.zoom * zoomFactor; + if (!sourcePixmap.isNull()) { + next.zoom = PlaymatUtils::playmatClampedZoom(sourcePixmap.size(), next); + } + + if (sameCrop(next, params)) { + return; + } + + params = next; + setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2))); + update(); + emit paramsEdited(params); +} + +bool PlaymatPreviewWidget::sameCrop(const PlaymatParams &a, const PlaymatParams &b) const +{ + // Exact comparison on purpose: clamped assignments yield identical bits, + // while qFuzzyCompare based equality misbehaves around zero + return a.marginPctL == b.marginPctL && a.marginPctR == b.marginPctR && a.verticalOffset == b.verticalOffset && + a.zoom == b.zoom; +} + +void PlaymatPreviewWidget::restoreSnapshot() +{ + // The snapshot only ever holds values that passed the gesture clamps, + // so it is safe to restore verbatim + if (sameCrop(paramsAtFocusIn, params)) { + return; + } + params = paramsAtFocusIn; + setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2))); + update(); + emit paramsEdited(params); +} + +void PlaymatPreviewWidget::paintEvent(QPaintEvent *) +{ + QPainter painter(this); + painter.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform | QPainter::TextAntialiasing); + + const QRect rect = this->rect(); + const QColor accentColor(100, 116, 139); + + // Background + const QRectF cardRect = QRectF(rect).adjusted(3, 2, -3, -2); + QLinearGradient bg(cardRect.topLeft(), cardRect.topRight()); + bg.setColorAt(0, accentColor.darker(320)); + bg.setColorAt(1, QColor(18, 22, 30)); + painter.setPen(Qt::NoPen); + painter.setBrush(bg); + painter.drawRoundedRect(cardRect, 6, 6); + painter.setBrush(accentColor); + painter.drawRoundedRect(QRectF(cardRect.left(), cardRect.top(), 3, cardRect.height()), 2, 2); + + if (sourcePixmap.isNull()) { + painter.setPen(QColor(150, 150, 150)); + painter.drawText(rect, Qt::AlignCenter, tr("No card selected")); + return; + } + + const QRectF playArea = activePlayArea(); + + // Exactly the game's pipeline (player_graphics_item): cover fit the crop + // into the table shaped viewport, centered, so the frame shows precisely + // what a minimum aspect window shows, and dragging moves the art behind + // the fixed frame. + const QRectF srcRect = PlaymatUtils::computeArtSourceRect(sourcePixmap.size(), params); + const QRectF dstRect = PlaymatUtils::coverFitRect(playArea, srcRect.size()); + + painter.setClipRect(playArea.toRect()); + painter.drawPixmap(dstRect, sourcePixmap, srcRect); + + // Wider (developed) tables crop further: mark where a kMaxTableAspect + // board stops. Palette driven so theme authors can recolor the markers. + const qreal wideBandHeight = playArea.height() * (MIN_TABLE_ASPECT / MAX_TABLE_ASPECT); + const qreal stripHeight = (playArea.height() - wideBandHeight) / 2.0; + QColor stripColor = palette().color(QPalette::Window); + stripColor.setAlpha(150); + painter.fillRect(QRectF(playArea.left(), playArea.top(), playArea.width(), stripHeight), stripColor); + painter.fillRect(QRectF(playArea.left(), playArea.bottom() - stripHeight, playArea.width(), stripHeight), + stripColor); + QColor hairlineColor = palette().color(QPalette::Highlight); + hairlineColor.setAlpha(110); + painter.setPen(QPen(hairlineColor, 1)); + painter.drawLine(QPointF(playArea.left(), playArea.top() + stripHeight), + QPointF(playArea.right(), playArea.top() + stripHeight)); + painter.drawLine(QPointF(playArea.left(), playArea.bottom() - stripHeight), + QPointF(playArea.right(), playArea.bottom() - stripHeight)); + + // Draw zone divider: stack is roughly the left portion + const double stackWidthRatio = 0.18; // Stack is about 18% of total play area + const double stackDividerX = playArea.left() + playArea.width() * stackWidthRatio; + + // Subtle semi transparent overlays to distinguish zones + // Stack zone overlay (slightly darker) + QRectF stackOverlay(playArea.left(), playArea.top(), playArea.width() * stackWidthRatio, playArea.height()); + painter.fillRect(stackOverlay, QColor(0, 0, 0, 40)); + + // Table zone overlay (very subtle) + QRectF tableOverlay(stackDividerX, playArea.top(), playArea.width() * (1.0 - stackWidthRatio), playArea.height()); + painter.fillRect(tableOverlay, QColor(0, 0, 0, 20)); + + // Zone divider line + painter.setPen(QPen(QColor(255, 255, 255, 50), 1)); + painter.drawLine(QPointF(stackDividerX, playArea.top()), QPointF(stackDividerX, playArea.bottom())); + + // Land divider line (about 60% down the table area) + const double landDividerY = playArea.top() + playArea.height() * 0.65; + painter.setPen(QPen(QColor(255, 255, 255, 30), 1)); + painter.drawLine(QPointF(stackDividerX, landDividerY), QPointF(playArea.right(), landDividerY)); + painter.setClipping(false); + + // Border around the viewport = boundary of every plausible framing. + painter.setPen(QPen(QColor(70, 80, 95, 120), 1)); + painter.setBrush(Qt::NoBrush); + painter.drawRoundedRect(playArea.adjusted(0, 0, -1, -1), 3, 3); + + // Visible keyboard focus per the focus cursor contract, Tab must show + // where the keys land + if (hasFocus()) { + QPen focusPen(palette().color(QPalette::Highlight), 2); + painter.setPen(focusPen); + painter.drawRoundedRect(playArea.adjusted(-1, -1, 1, 1), 3, 3); + } + + paintArtAttribution(painter, playArea, attributionText, Qt::AlignRight | Qt::AlignBottom, 0.8); + + // Zoom readout so the gesture has a visible, stable counterpart. + QColor ink = palette().color(QPalette::WindowText); + ink.setAlpha(160); + painter.setPen(ink); + painter.drawText(QPointF(playArea.left() + 8, playArea.bottom() - 8), + tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2))); +} + +void PlaymatPreviewWidget::mousePressEvent(QMouseEvent *event) +{ + if (event->button() != Qt::LeftButton || sourcePixmap.isNull() || samplingWindowSide() <= 0.0) { + QWidget::mousePressEvent(event); + return; + } + lastDragPos = event->pos(); + setCursor(Qt::ClosedHandCursor); + event->accept(); +} + +void PlaymatPreviewWidget::mouseMoveEvent(QMouseEvent *event) +{ + if (!(event->buttons() & Qt::LeftButton) || sourcePixmap.isNull()) { + QWidget::mouseMoveEvent(event); + return; + } + + const QPointF delta = QPointF(event->pos() - lastDragPos); + lastDragPos = event->pos(); + + const qreal scale = widgetToSourceScale(); + // Vertical travel of the SAMPLING window: verticalOffset moves its top + // edge by exactly this much per unit, identical to the render path. + // Windows taller than the art (square/landscape sources zoomed out) + // leave no travel, vertical drags are then boundary no ops. + const qreal travel = static_cast(sourcePixmap.height()) - samplingWindowSide(); + if (scale <= 0.0) { + event->accept(); + return; + } + + // Dragging moves the ART with the cursor, so the viewing window slides the + // other way. Horizontal panning rebalances the margins (their sum, hence + // the window width, stays constant), vertical panning moves the window's + // top edge within its available travel. + const qreal sourceW = sourcePixmap.width(); + const qreal dMargin = -(delta.x() / scale) / sourceW; + const qreal dOffset = travel > 0.5 ? -(delta.y() / scale) / travel : 0.0; + + applyCropDelta(dMargin, -dMargin, dOffset, 1.0); + event->accept(); +} + +void PlaymatPreviewWidget::mouseReleaseEvent(QMouseEvent *event) +{ + if (event->button() == Qt::LeftButton) { + setCursor(sourcePixmap.isNull() ? Qt::ArrowCursor : Qt::OpenHandCursor); + event->accept(); + return; + } + QWidget::mouseReleaseEvent(event); +} + +void PlaymatPreviewWidget::wheelEvent(QWheelEvent *event) +{ + if (sourcePixmap.isNull() || samplingWindowSide() <= 0.0) { + QWidget::wheelEvent(event); + return; + } + const qreal notches = static_cast(event->angleDelta().y()) / 120.0; + if (notches == 0.0) { + event->accept(); + return; + } + applyCropDelta(0.0, 0.0, 0.0, std::pow(WHEEL_ZOOM_BASE, notches)); + event->accept(); +} + +void PlaymatPreviewWidget::keyPressEvent(QKeyEvent *event) +{ + if (sourcePixmap.isNull()) { + QWidget::keyPressEvent(event); + return; + } + + switch (event->key()) { + case Qt::Key_Escape: + if (sameCrop(params, paramsAtFocusIn)) { + // Nothing to undo on this surface, let the event reach the + // dialog so Esc keeps its close meaning there + QWidget::keyPressEvent(event); + return; + } + restoreSnapshot(); + break; + case Qt::Key_Backspace: + restoreSnapshot(); + break; + case Qt::Key_Left: + applyCropDelta(-KEY_PAN_MARGIN_STEP, KEY_PAN_MARGIN_STEP, 0.0, 1.0); + break; + case Qt::Key_Right: + applyCropDelta(KEY_PAN_MARGIN_STEP, -KEY_PAN_MARGIN_STEP, 0.0, 1.0); + break; + case Qt::Key_Up: + applyCropDelta(0.0, 0.0, -KEY_PAN_OFFSET_STEP, 1.0); + break; + case Qt::Key_Down: + applyCropDelta(0.0, 0.0, KEY_PAN_OFFSET_STEP, 1.0); + break; + case Qt::Key_Plus: + case Qt::Key_Equal: + applyCropDelta(0.0, 0.0, 0.0, KEY_ZOOM_STEP); + break; + case Qt::Key_Minus: + applyCropDelta(0.0, 0.0, 0.0, 1.0 / KEY_ZOOM_STEP); + break; + default: + QWidget::keyPressEvent(event); + return; + } + event->accept(); +} + +void PlaymatPreviewWidget::focusInEvent(QFocusEvent *event) +{ + // Snapshot for the Esc or Backspace reset, restoring whatever the user + // had when the surface took focus + paramsAtFocusIn = params; + QWidget::focusInEvent(event); +} diff --git a/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.h b/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.h new file mode 100644 index 000000000..5e54e96eb --- /dev/null +++ b/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.h @@ -0,0 +1,66 @@ +#ifndef COCKATRICE_PLAYMAT_PREVIEW_WIDGET_H +#define COCKATRICE_PLAYMAT_PREVIEW_WIDGET_H + +#include "libcockatrice/utility/playmat_params.h" + +#include +#include +#include +#include +#include +#include +#include + +/** + * @brief Interactive crop surface showing how a playmat card art will appear + * across the combined table + stack play area. + * + * Renders a fixed frame shaped like a fresh board's stack+table area (the + * most generous framing the game produces): it shows the tallest slice of + * the square crop in normal play, with dimmed strips marking where a wider, + * developed table crops further, exactly the game's own render pipeline. + * The widget doubles as the editor's primary crop control: dragging pans the + * art behind the frame, the wheel zooms, and arrow keys nudge, mirroring + * the stored parameters (margins pan horizontally, verticalOffset + * vertically, zoom scales) so no separate numeric controls are needed. + */ +class PlaymatPreviewWidget : public QWidget +{ + Q_OBJECT + +public: + explicit PlaymatPreviewWidget(QWidget *parent = nullptr); + + void setPixmap(const QPixmap &pixmap); + void setParams(const PlaymatParams ¶ms); + void setAttribution(const QString &attribution); + +signals: + /** @brief Emitted whenever direct manipulation (drag, wheel, keys) changes the crop parameters. */ + void paramsEdited(const PlaymatParams ¶ms); + +protected: + void paintEvent(QPaintEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + void wheelEvent(QWheelEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; + void focusInEvent(QFocusEvent *event) override; + +private: + QRectF activePlayArea() const; ///< destination rect used for rendering AND gesture math + qreal samplingWindowSide() const; ///< clamped square window side, shared with the render path + qreal widgetToSourceScale() const; + void applyCropDelta(qreal dMarginL, qreal dMarginR, qreal dOffset, qreal zoomFactor); + bool sameCrop(const PlaymatParams &a, const PlaymatParams &b) const; + void restoreSnapshot(); + + QPixmap sourcePixmap; + PlaymatParams params; + PlaymatParams paramsAtFocusIn; ///< crop as of the latest focus gain, restored by Esc or Backspace + QString attributionText; + QPoint lastDragPos; ///< widget space position of the previous mouse move while panning +}; + +#endif // COCKATRICE_PLAYMAT_PREVIEW_WIDGET_H diff --git a/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.cpp b/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.cpp new file mode 100644 index 000000000..5a4457d34 --- /dev/null +++ b/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.cpp @@ -0,0 +1,334 @@ +#include "playmat_settings_dialog.h" + +#include "../../card_picture_loader/card_picture_loader.h" +#include "../cards/art_crop_attribution.h" +#include "../cards/card_art_utils.h" +#include "../utility/completer_utils.h" +#include "card_database_display_model.h" +#include "card_database_model.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/card/printing/printing_info.h" +#include "libcockatrice/card/set/card_set.h" +#include "playmat_preview_widget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +PlaymatSettingsDialog::PlaymatSettingsDialog(const CardRef &initialCard, + const PlaymatParams &initialParams, + QWidget *parent) + : QDialog(parent), currentCard(initialCard), currentParams(initialParams) +{ + setMinimumWidth(500); + setupUi(); + + // Seed UI from initial values + if (!initialCard.name.isEmpty()) { + searchBar->setText(initialCard.name); + onCardNameChanged(initialCard.name); + + // onCardNameChanged leaves the printing combo on the first printing in + // the database, which would silently change the deck's stored playmat + // card on accept. Restore the stored printing when it resolves locally. + const int storedPrintingIndex = providerComboBox->findData(initialCard.providerId); + if (storedPrintingIndex != -1) { + providerComboBox->setCurrentIndex(storedPrintingIndex); + } else { + // Stored printing not in the local database: keep it rather than + // silently substituting the first printing. + currentCard.providerId = initialCard.providerId; + reloadPreview(); + } + } + + retranslateUi(); +} + +CardRef PlaymatSettingsDialog::card() const +{ + return currentCard; +} + +PlaymatParams PlaymatSettingsDialog::params() const +{ + return currentParams; +} + +QDoubleSpinBox *PlaymatSettingsDialog::makeSpinBox(double min, double max, double value, double step) +{ + auto *spin = new QDoubleSpinBox; + spin->setRange(min, max); + spin->setSingleStep(step); + spin->setDecimals(3); + spin->setValue(value); + return spin; +} + +void PlaymatSettingsDialog::initializeSearchBar() +{ + searchBar = new QLineEdit; + + cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this); + cardDatabaseDisplayModel = new CardDatabaseDisplayModel(this); + cardDatabaseDisplayModel->setSourceModel(cardDatabaseModel); + + const CardCompleterSetup cardSetup = createCardCompleter(cardDatabaseDisplayModel, this, 15); + searchModel = cardSetup.searchModel; + proxyModel = cardSetup.proxyModel; + completer = cardSetup.completer; + searchBar->setCompleter(completer); + + connectCardCompleterSearch(searchBar, cardSetup); + + connect(completer, static_cast(&QCompleter::activated), this, + [this](const QString &completion) { + if (searchBar->text() != completion) { + searchBar->setText(completion); + searchBar->setCursorPosition(searchBar->text().length()); + } + onCardNameChanged(completion); + }); + + connect(searchBar, &QLineEdit::returnPressed, this, [this]() { onCardNameChanged(searchBar->text()); }); +} + +void PlaymatSettingsDialog::setupUi() +{ + initializeSearchBar(); + + providerComboBox = new QComboBox; + connect(providerComboBox, &QComboBox::currentIndexChanged, this, [this]() { + currentCard.providerId = providerComboBox->currentData().toString(); + reloadPreview(); + }); + + auto *form = new QFormLayout; + controlsForm = form; + cardNameLabel = new QLabel; + printingLabel = new QLabel; + form->addRow(cardNameLabel, searchBar); + form->addRow(printingLabel, providerComboBox); + + // Numerical editors expose the raw PlaymatParams for precise input. They + // share the same form as the rows above so every field lines up on one + // label column. They stay hidden until requested since the crop surface + // is the primary control. + marginLSpin = makeSpinBox(0.0, 0.95, currentParams.marginPctL, 0.01); + marginRSpin = makeSpinBox(0.0, 0.95, currentParams.marginPctR, 0.01); + verticalOffsetSpin = makeSpinBox(0.0, 1.0, currentParams.verticalOffset, 0.01); + zoomSpin = makeSpinBox(0.1, 4.0, currentParams.zoom, 0.05); + + leftMarginLabel = new QLabel; + rightMarginLabel = new QLabel; + verticalOffsetLabel = new QLabel; + zoomLabel = new QLabel; + + showNumericEditorsCheck = new QCheckBox; + + form->addRow(showNumericEditorsCheck); + form->addRow(leftMarginLabel, marginLSpin); + form->addRow(rightMarginLabel, marginRSpin); + form->addRow(verticalOffsetLabel, verticalOffsetSpin); + form->addRow(zoomLabel, zoomSpin); + + controlsGroup = new QGroupBox; + controlsGroup->setLayout(form); + + preview = new PlaymatPreviewWidget; + preview->setParams(currentParams); + + auto *previewLayout = new QVBoxLayout; + previewLayout->addWidget(preview); + previewCaptionLabel = new QLabel; + previewCaptionLabel->setAlignment(Qt::AlignCenter); + previewCaptionLabel->setWordWrap(true); + previewLayout->addWidget(previewCaptionLabel); + previewGroup = new QGroupBox; + previewGroup->setLayout(previewLayout); + + auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + removeButton = new QPushButton; + buttons->addButton(removeButton, QDialogButtonBox::ResetRole); + + connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); + connect(removeButton, &QPushButton::clicked, this, [this]() { + currentCard = CardRef{}; // empty signals removal + accept(); + }); + + // The crop surface is the primary control: dragging pans, wheel/keys zoom, + // editing exactly the same stored parameters the numeric fields do. + connect(preview, &PlaymatPreviewWidget::paramsEdited, this, [this](const PlaymatParams &edited) { + currentParams = edited; + + QSignalBlocker blockMarginL(marginLSpin); + QSignalBlocker blockMarginR(marginRSpin); + QSignalBlocker blockOffset(verticalOffsetSpin); + QSignalBlocker blockZoom(zoomSpin); + marginLSpin->setValue(edited.marginPctL); + marginRSpin->setValue(edited.marginPctR); + verticalOffsetSpin->setValue(edited.verticalOffset); + zoomSpin->setValue(edited.zoom); + }); + + connect(showNumericEditorsCheck, &QCheckBox::toggled, this, &PlaymatSettingsDialog::setNumericEditorsVisible); + setNumericEditorsVisible(false); + + connect(marginLSpin, &QDoubleSpinBox::valueChanged, this, &PlaymatSettingsDialog::onParamChanged); + connect(marginRSpin, &QDoubleSpinBox::valueChanged, this, &PlaymatSettingsDialog::onParamChanged); + connect(verticalOffsetSpin, &QDoubleSpinBox::valueChanged, this, &PlaymatSettingsDialog::onParamChanged); + connect(zoomSpin, &QDoubleSpinBox::valueChanged, this, &PlaymatSettingsDialog::onParamChanged); + + // The crop surface leads visually, card selection supports it below. + auto *root = new QVBoxLayout; + root->addWidget(previewGroup); + root->addWidget(controlsGroup); + root->addWidget(buttons); + setLayout(root); +} + +void PlaymatSettingsDialog::populateProviderCombo(const QString &cardName) +{ + providerComboBox->clear(); + + auto card = CardDatabaseManager::query()->getCard({cardName}); + + const auto &sets = card.getInfo().getSets(); + + for (const auto &printings : sets) { + for (const auto &p : printings) { + QString setName = p.getSet()->getLongName(); + QString collector = p.getProperty("num"); + QString uuid = p.getUuid(); + + QString label = setName; + if (!collector.isEmpty()) { + label += " #" + collector; + } + + providerComboBox->addItem(label, uuid); + } + } +} + +void PlaymatSettingsDialog::onCardNameChanged(const QString &name) +{ + if (name.isEmpty()) { + currentPixmap = QPixmap(); + preview->setPixmap(currentPixmap); + return; + } + + const ExactCard card = CardDatabaseManager::query()->getCard({name}); + if (!card) { + currentPixmap = QPixmap(); + preview->setPixmap(currentPixmap); + providerComboBox->clear(); + return; + } + + currentCard.name = name; + + populateProviderCombo(name); + + if (providerComboBox->count() == 0) { + currentPixmap = QPixmap(); + preview->setPixmap(currentPixmap); + currentCard.providerId.clear(); + return; + } + + currentCard.providerId = providerComboBox->currentData().toString(); + reloadPreview(); +} + +void PlaymatSettingsDialog::reloadPreview() +{ + if (currentCard.name.isEmpty()) { + return; + } + + ExactCard card = CardDatabaseManager::query()->getCard({currentCard.name, currentCard.providerId}); + if (!card) { + return; + } + + disconnect(pixmapUpdatedConnection); + + QPixmap fullRes; + CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040)); + + if (fullRes.isNull()) { + CardInfo *cardInfo = card.getCardPtr().data(); + if (cardInfo) { + pixmapUpdatedConnection = connect(cardInfo, &CardInfo::pixmapUpdated, this, [this]() { reloadPreview(); }); + } + return; + } + + currentPixmap = CardArtUtils::rotateSidewaysLayoutArt(fullRes, card); + preview->setPixmap(currentPixmap); + preview->setParams(currentParams); + preview->setAttribution(buildArtAttribution(card)); +} + +void PlaymatSettingsDialog::onParamChanged() +{ + currentParams.marginPctL = marginLSpin->value(); + currentParams.marginPctR = marginRSpin->value(); + currentParams.verticalOffset = verticalOffsetSpin->value(); + currentParams.zoom = zoomSpin->value(); + preview->setParams(currentParams); +} + +void PlaymatSettingsDialog::setNumericEditorsVisible(bool visible) +{ + controlsForm->setRowVisible(leftMarginLabel, visible); + controlsForm->setRowVisible(rightMarginLabel, visible); + controlsForm->setRowVisible(verticalOffsetLabel, visible); + controlsForm->setRowVisible(zoomLabel, visible); + + // A QDialog never resizes itself when its content requirements change, + // so revealing the editors would squeeze the crop group until the info + // caption ran into the preview. Re-fit the dialog to the new size hint. + adjustSize(); +} + +void PlaymatSettingsDialog::retranslateUi() +{ + setWindowTitle(tr("Playmat Settings")); + searchBar->setPlaceholderText(tr("Type a card name...")); + cardNameLabel->setText(tr("Card name:")); + printingLabel->setText(tr("Printing:")); + showNumericEditorsCheck->setText(tr("Show numerical editors")); + leftMarginLabel->setText(tr("Left margin (%):")); + rightMarginLabel->setText(tr("Right margin (%):")); + verticalOffsetLabel->setText(tr("Vertical offset:")); + zoomLabel->setText(tr("Zoom:")); + controlsGroup->setTitle(tr("Card")); + previewGroup->setTitle(tr("Crop")); + previewCaptionLabel->setText( + tr("Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores. " + "Dimmed strips mark where a wider table crops further.")); + removeButton->setText(tr("Remove Playmat")); +} diff --git a/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.h b/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.h new file mode 100644 index 000000000..07833af25 --- /dev/null +++ b/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.h @@ -0,0 +1,99 @@ +#ifndef COCKATRICE_PLAYMAT_SETTINGS_DIALOG_H +#define COCKATRICE_PLAYMAT_SETTINGS_DIALOG_H + +#include "libcockatrice/utility/card_ref.h" +#include "libcockatrice/utility/playmat_params.h" + +#include +#include +#include +#include +#include + +class QCheckBox; +class QComboBox; +class QCompleter; +class QDoubleSpinBox; +class QFormLayout; +class QGroupBox; +class QLabel; +class QLineEdit; +class QPushButton; +class QWidget; +class CardDatabaseModel; +class CardDatabaseDisplayModel; +class CardSearchModel; +class CardCompleterProxyModel; +class PlaymatPreviewWidget; + +/** + * @brief Dialog for configuring the playmat card art for a deck. + * + * The crop surface is the primary control: drag to pan the visible art, + * scroll (or +/- keys) to zoom, arrow keys to nudge. Card name and printing + * are selected below. A checkbox reveals optional numerical editors for the + * raw PlaymatParams. These controls edit the same stored PlaymatParams that + * ship in deck files and player properties. + */ +class PlaymatSettingsDialog : public QDialog +{ + Q_OBJECT + +public: + explicit PlaymatSettingsDialog(const CardRef &initialCard = {}, + const PlaymatParams &initialParams = {}, + QWidget *parent = nullptr); + + CardRef card() const; + PlaymatParams params() const; + +private slots: + void onCardNameChanged(const QString &name); + void onParamChanged(); + void reloadPreview(); + +private: + void setupUi(); + void populateProviderCombo(const QString &cardName); + void initializeSearchBar(); + void retranslateUi(); + void setNumericEditorsVisible(bool visible); + QDoubleSpinBox *makeSpinBox(double min, double max, double value, double step); + + QLineEdit *searchBar; + QCompleter *completer; + CardDatabaseModel *cardDatabaseModel; + CardDatabaseDisplayModel *cardDatabaseDisplayModel; + CardSearchModel *searchModel; + CardCompleterProxyModel *proxyModel; + + QComboBox *providerComboBox; + + QMetaObject::Connection pixmapUpdatedConnection; + + QLabel *cardNameLabel; + QLabel *printingLabel; + QLabel *previewCaptionLabel; + QCheckBox *showNumericEditorsCheck; + QFormLayout *controlsForm; + QLabel *leftMarginLabel; + QLabel *rightMarginLabel; + QLabel *verticalOffsetLabel; + QLabel *zoomLabel; + QGroupBox *controlsGroup; + QGroupBox *previewGroup; + QPushButton *removeButton; + + QDoubleSpinBox *marginLSpin; + QDoubleSpinBox *marginRSpin; + QDoubleSpinBox *verticalOffsetSpin; + QDoubleSpinBox *zoomSpin; + + PlaymatPreviewWidget *preview; + + QPixmap currentPixmap; + CardRef currentCard; + PlaymatParams currentParams; +}; + +#endif // COCKATRICE_PLAYMAT_SETTINGS_DIALOG_H diff --git a/cockatrice/src/interface/widgets/playmat/playmat_utils.h b/cockatrice/src/interface/widgets/playmat/playmat_utils.h new file mode 100644 index 000000000..0691a9637 --- /dev/null +++ b/cockatrice/src/interface/widgets/playmat/playmat_utils.h @@ -0,0 +1,140 @@ +#ifndef COCKATRICE_PLAYMAT_UTILS_H +#define COCKATRICE_PLAYMAT_UTILS_H + +#include +#include +#include +#include + +namespace PlaymatUtils +{ + +/** @brief Upper bound for zooming into the playmat art. */ +constexpr qreal MAX_ZOOM = 4.0; + +/** + * @brief Width of the outer viewing window: full card width trimmed by the + * horizontal margins. Guarded against margins summing to >= 1. + */ +inline qreal playmatVisibleWidth(const QSize &fullCardSize, const PlaymatParams ¶ms) +{ + const qreal srcW = fullCardSize.width(); + const qreal marginL = params.marginPctL * srcW; + const qreal marginR = params.marginPctR * srcW; + return qMax(0.0, srcW - marginL - marginR); +} + +/** + * @brief Zoom clamped to the range where every step renders differently. + * + * The square sampling window is visibleWidth / zoom, zooming out past + * visibleWidth / min(card width, height) would sample beyond the card itself, + * which both looks broken and makes whole ranges of the parameter dead. The + * floor is therefore derived from the actual image instead of a static value, + * and is shared verbatim by the render path and the editor's gesture math so + * the two can never disagree. + */ +inline qreal playmatClampedZoom(const QSize &fullCardSize, const PlaymatParams ¶ms) +{ + const qreal minDim = qMin(fullCardSize.width(), fullCardSize.height()); + const qreal visibleW = playmatVisibleWidth(fullCardSize, params); + const qreal zoomOutFloor = (minDim > 0.0 && visibleW > 0.0) ? visibleW / minDim : 1.0; + // The floor deliberately bypasses MAX_ZOOM: when the art is much wider + // than tall, keeping the square window inside it requires more than 4x + // zoom-out, and honoring that larger floor keeps side within + // min(card width, height). Zooming IN is still capped at MAX_ZOOM. + return qMin(MAX_ZOOM, qMax(params.zoom, zoomOutFloor)); +} + +/** + * @brief Side of the square sampling window actually rendered for these + * parameters. Never exceeds either card dimension, so the source rect + * always lies within the image (vertical travel remains for panning + * whenever the art is taller than it is wide). + */ +inline qreal playmatWindowSide(const QSize &fullCardSize, const PlaymatParams ¶ms) +{ + const qreal visibleW = playmatVisibleWidth(fullCardSize, params); + if (visibleW <= 0.0) { + return 0.0; + } + return visibleW / playmatClampedZoom(fullCardSize, params); +} + +/** + * @brief Computes the source region of the full resolution card image to use as a playmat. + * + * Parameters are relative to the full card image. horizontal margins trim the + * card borders (shifting them pans the window), verticalOffset places the top + * edge of the sampling window within its available travel, and zoom scales + * into the trimmed span. The result always lies within the card image bounds. + * + * @param fullCardSize Size of the full card image. + * @param params Positioning parameters. + * @return Source rectangle in full card image pixel coordinates. + */ +inline QRectF computeArtSourceRect(const QSize &fullCardSize, const PlaymatParams ¶ms) +{ + const qreal srcW = fullCardSize.width(); + const qreal srcH = fullCardSize.height(); + + // Square sampling window, keeps art unskewed, never exceeds the card on + // either axis thanks to the zoom floor in playmatWindowSide(). + const qreal side = playmatWindowSide(fullCardSize, params); + + // verticalOffset places the TOP edge of the sampling window itself within + // its travel, so the full [0, 1] parameter range is live at every zoom and + // the window can always reach the very top (0.0) and bottom (1.0) of the + // art. + const qreal offset = qBound(0.0, params.verticalOffset, 1.0); + const qreal y = offset * qMax(0.0, srcH - side); + + // Horizontally the sampling window sits centered inside the trimmed span + // (margins pan it), zooming out can make it wider than that span, so it + // is then kept within the image, an edge stop, never an invalid rect. + const qreal outerW = playmatVisibleWidth(fullCardSize, params); + const qreal x = qBound(0.0, params.marginPctL * srcW + (outerW - side) / 2.0, qMax(0.0, srcW - side)); + + return QRectF(x, y, side, side); +} + +/** + * @brief Returns the destination rectangle that fits a source of the given aspect + * ratio into dstArea using "cover" semantics (no distortion, overflows cropped). + * + * @param dstArea Area to fill. + * @param srcSize Size of the source, only its aspect ratio matters. + * @return Destination rectangle centered in dstArea. + */ +inline QRectF coverFitRect(const QRectF &dstArea, const QSizeF &srcSize) +{ + const qreal srcAspect = srcSize.width() / srcSize.height(); + const qreal dstAspect = dstArea.width() / dstArea.height(); + + if (srcAspect > dstAspect) { + const qreal dstW = dstArea.height() * srcAspect; + return QRectF(dstArea.left() + (dstArea.width() - dstW) / 2.0, dstArea.top(), dstW, dstArea.height()); + } + + const qreal dstH = dstArea.width() / srcAspect; + return QRectF(dstArea.left(), dstArea.top() + (dstArea.height() - dstH) / 2.0, dstArea.width(), dstH); +} + +/** + * @brief Fits a rectangle of the given aspect ratio into dstArea, centered, + * touching the constraining dimension ("aspect fit" of the FRAME + * itself, not of a source image). + */ +inline QRectF aspectFitRect(const QRectF &dstArea, qreal aspect) +{ + if (aspect <= 0.0) { + return dstArea; + } + qreal w = qMin(dstArea.width(), dstArea.height() * aspect); + qreal h = w / aspect; + return QRectF(dstArea.left() + (dstArea.width() - w) / 2.0, dstArea.top() + (dstArea.height() - h) / 2.0, w, h); +} + +} // namespace PlaymatUtils + +#endif // COCKATRICE_PLAYMAT_UTILS_H diff --git a/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.cpp index 579106540..bad88ddbb 100644 --- a/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.cpp @@ -1,8 +1,21 @@ #include "all_zones_card_amount_widget.h" #include "../general/display/shadow_background_label.h" +#include "card_amount_widget.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/deck_list/tree/inner_deck_list_node.h" +#include +#include +#include +#include #include +#include +#include +#include + +class DeckStateManager; /** * @brief Constructor for the AllZonesCardAmountWidget class. diff --git a/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.h b/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.h index 325dd1c1b..cefabe293 100644 --- a/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.h +++ b/cockatrice/src/interface/widgets/printing_selector/all_zones_card_amount_widget.h @@ -7,11 +7,15 @@ #ifndef ALL_ZONES_CARD_AMOUNT_WIDGET_H #define ALL_ZONES_CARD_AMOUNT_WIDGET_H -#include "../../deck_loader/deck_loader.h" -#include "card_amount_widget.h" - #include -#include +#include + +class CardAmountWidget; +class DeckStateManager; +class ExactCard; +class QLabel; +class QSlider; +class QVBoxLayout; class AllZonesCardAmountWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.cpp index ff47e7b9c..4b5040741 100644 --- a/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.cpp @@ -1,9 +1,30 @@ #include "card_amount_widget.h" #include "../deck_editor/deck_state_manager.h" +#include "../general/display/dynamic_font_size_push_button.h" +#include "deck_list_model.h" +#include "libcockatrice/card/printing/printing_info.h" +#include "libcockatrice/card/set/card_set.h" +#include "libcockatrice/deck_list/tree/inner_deck_list_node.h" +#include +#include +#include +#include +#include +#include #include -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class QModelIndex; /** * @brief Constructs a widget for displaying and controlling the card count in a specific zone. diff --git a/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.h b/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.h index 2780e3ad2..6f5889fc7 100644 --- a/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.h +++ b/cockatrice/src/interface/widgets/printing_selector/card_amount_widget.h @@ -8,15 +8,17 @@ #ifndef CARD_AMOUNT_WIDGET_H #define CARD_AMOUNT_WIDGET_H -#include "../../../interface/widgets/tabs/abstract_tab_deck_editor.h" -#include "../general/display/dynamic_font_size_push_button.h" +#include "libcockatrice/card/printing/exact_card.h" -#include -#include -#include +#include #include -#include -#include +#include + +class DeckStateManager; +class DynamicFontSizePushButton; +class QHBoxLayout; +class QLabel; +class QSlider; class CardAmountWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp index 0c3bbbe00..27ae61b94 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp @@ -1,19 +1,38 @@ #include "printing_selector.h" #include "../../../client/settings/cache_settings.h" -#include "../../../interface/card_picture_loader/card_picture_loader.h" -#include "../../../interface/widgets/dialogs/dlg_select_set_for_cards.h" +#include "../cards/card_size_widget.h" #include "../deck_editor/deck_state_manager.h" +#include "../general/layout_containers/flow_widget.h" +#include "../quick_settings/settings_button_widget.h" +#include "../tabs/abstract_tab_deck_editor.h" +#include "deck_list_model.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/card/printing/printing_info.h" +#include "libcockatrice/deck_list/tree/deck_list_card_node.h" +#include "libcockatrice/deck_list/tree/inner_deck_list_node.h" #include "printing_selector_card_display_widget.h" #include "printing_selector_card_search_widget.h" #include "printing_selector_card_selection_widget.h" #include "printing_selector_card_sorting_widget.h" #include "printing_selector_placeholder_widget.h" -#include +#include +#include +#include +#include +#include +#include #include +#include +#include +#include +#include +#include #include #include +#include /** * @brief Constructs a PrintingSelector widget to display and manage card printings. @@ -58,6 +77,7 @@ PrintingSelector::PrintingSelector(QWidget *parent, AbstractTabDeckEditor *_deck SettingsCache::instance().cardsDisplay().getPrintingSelectorCardSize()); connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::setPrintingSelectorCardSize); + cardSizeWidget->enableCtrlScrollResize(flowWidget); displayOptionsWidget->addSettingsWidget(sortToolBar); displayOptionsWidget->addSettingsWidget(navigationCheckBox); diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector.h b/cockatrice/src/interface/widgets/printing_selector/printing_selector.h index 14d73f836..b9cb27ecd 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector.h +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector.h @@ -7,18 +7,20 @@ #ifndef PRINTING_SELECTOR_H #define PRINTING_SELECTOR_H -#include "../cards/card_size_widget.h" -#include "../general/layout_containers/flow_widget.h" -#include "../quick_settings/settings_button_widget.h" -#include "printing_selector_placeholder_widget.h" - -#include -#include -#include -#include #include #include -#include +#include + +class CardSizeWidget; +class FlowWidget; +class PrintingSelectorPlaceholderWidget; +class QCheckBox; +class QHBoxLayout; +class QString; +class QTimer; +class QVBoxLayout; +class SettingsButtonWidget; +template class QMap; #define BATCH_SIZE 10 @@ -33,8 +35,8 @@ class DeckStateManager; class PrintingSelectorCardSearchWidget; class PrintingSelectorCardSelectionWidget; class PrintingSelectorCardSortingWidget; -class PrintingSelectorViewOptionsWidget; class AbstractTabDeckEditor; + class PrintingSelector : public QWidget { Q_OBJECT diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_display_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_display_widget.cpp index edeba86d1..1c3cb635d 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_display_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_display_widget.cpp @@ -1,11 +1,22 @@ #include "printing_selector_card_display_widget.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/printing/printing_info.h" +#include "libcockatrice/card/set/card_set.h" +#include "printing_selector.h" #include "printing_selector_card_overlay_widget.h" #include "set_name_and_collectors_number_display_widget.h" -#include +#include +#include +#include +#include #include -#include +#include + +class AbstractTabDeckEditor; +class DeckStateManager; +class QSlider; /** * @brief Constructs a PrintingSelectorCardDisplayWidget to display card information. diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_display_widget.h b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_display_widget.h index 4de561f4f..f0a378fd3 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_display_widget.h +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_display_widget.h @@ -7,12 +7,25 @@ #ifndef PRINTING_SELECTOR_CARD_DISPLAY_WIDGET_H #define PRINTING_SELECTOR_CARD_DISPLAY_WIDGET_H -#include "../../../interface/widgets/tabs/abstract_tab_deck_editor.h" -#include "printing_selector_card_overlay_widget.h" -#include "set_name_and_collectors_number_display_widget.h" +#include "libcockatrice/card/printing/exact_card.h" + +// IWYU pragma: keep +// ZoneCounts appears inside a QMap in a slot signature, so the moc-generated +// code needs the complete type. +#include "printing_selector.h" #include -#include +#include + +class AbstractTabDeckEditor; +class DeckStateManager; +class PrintingSelectorCardOverlayWidget; +class QSlider; +class QString; +class QVBoxLayout; +class SetNameAndCollectorsNumberDisplayWidget; +struct ZoneCounts; +template class QMap; class PrintingSelectorCardDisplayWidget : public QWidget { 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 2b9201e6c..bec9100a6 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 @@ -1,18 +1,91 @@ #include "printing_selector_card_overlay_widget.h" #include "../../../client/settings/cache_settings.h" -#include "printing_selector_card_display_widget.h" +#include "../../card_picture_loader/card_picture_loader.h" +#include "../cards/card_info_picture_widget.h" +#include "../tabs/abstract_tab_deck_editor.h" +#include "all_zones_card_amount_widget.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/printing_info.h" +#include "libcockatrice/card/set/card_set.h" +#include +#include +#include +#include +#include +#include #include #include +#include #include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include +#include +#include #include #include #include #include -#include +#include + +class DeckStateManager; + +namespace +{ +/** + * @brief Places the preview beside the highlighted action inside the given screen. + * + * Side-aware: hugs the side of the action that has room, aligned with its row, then + * clamps every edge so the preview always lands fully on-screen on first show. + */ +QPoint +previewPositionNear(const QRect &actionRect, const QSize &labelSize, const QRect &screenGeometry, int previewOffset) +{ + const bool rightFits = actionRect.right() + previewOffset + labelSize.width() <= screenGeometry.right(); + const bool leftFits = actionRect.left() - previewOffset - labelSize.width() >= screenGeometry.left(); + + int x; + if (rightFits) { + x = actionRect.right() + previewOffset; + } else if (leftFits) { + x = actionRect.left() - previewOffset - labelSize.width(); + } else { + x = actionRect.left(); + } + x = qMax(screenGeometry.left(), x); + x = qMin(screenGeometry.right() - labelSize.width() + 1, x); + + const bool belowFits = actionRect.bottom() + previewOffset + labelSize.height() <= screenGeometry.bottom(); + const bool aboveFits = actionRect.top() - previewOffset - labelSize.height() >= screenGeometry.top(); + + int y; + if (belowFits) { + y = actionRect.bottom() + previewOffset; + } else if (aboveFits) { + y = actionRect.top() - previewOffset - labelSize.height(); + } else { + y = actionRect.top(); + } + y = qMax(screenGeometry.top(), y); + y = qMin(screenGeometry.bottom() - labelSize.height() + 1, y); + + return {x, y}; +} +} // namespace /** * @brief Constructs a PrintingSelectorCardOverlayWidget for displaying a card overlay. @@ -49,6 +122,31 @@ PrintingSelectorCardOverlayWidget::PrintingSelectorCardOverlayWidget(QWidget *pa initializePinBadge(); + // 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). + cardOverridePreviewLabel = new QLabel(this, Qt::ToolTip); + cardOverridePreviewLabel->setWindowFlag(Qt::FramelessWindowHint); + cardOverridePreviewLabel->setAttribute(Qt::WA_ShowWithoutActivating); + cardOverridePreviewLabel->setScaledContents(true); + cardOverridePreviewLabel->hide(); + + // While the preview is visible, keep it honest: when the hovered printing's art + // resolves (all alternate printings share the root card's CardInfo), redraw it in place. + if (rootCard.getCardPtr()) { + connect(rootCard.getCardPtr().data(), &CardInfo::pixmapUpdated, this, [this] { + if (cardOverridePreviewLabel->isVisible()) { + refreshPreview(); + } + }); + } + + // Alt-Tab / app-inactive must not strand the floating preview. + connect(qApp, &QGuiApplication::applicationStateChanged, this, [this](Qt::ApplicationState state) { + if (state != Qt::ApplicationActive) { + hidePreview(); + } + }); + // Update when this overlay emits cardPreferenceChanged or when size/scale changes connect(this, &PrintingSelectorCardOverlayWidget::cardPreferenceChanged, this, &PrintingSelectorCardOverlayWidget::updatePinBadgeVisibility); @@ -184,19 +282,23 @@ void PrintingSelectorCardOverlayWidget::leaveEvent(QEvent *event) } /** - * @brief Creates and shows a custom context menu when the right mouse button is clicked. + * @brief Creates and shows the card-overlay context menu. * - * The context menu includes an option to show related cards, which displays a submenu with actions - * for each related card. When an action is triggered, the card information is updated, and the - * printing selector is shown. + * The menu includes the card art preference (Pin/Unpin Printing), the Image Overrides + * submenu (Load Custom Image, Clear Custom Image, and one entry per alternate printing with a + * live preview), and the Show Related cards submenu. * - * @param point The position of the mouse when the right-click occurred. + * @param point The local position the menu should pop at. */ void PrintingSelectorCardOverlayWidget::customMenu(QPoint point) { QMenu menu; - auto *preferenceMenu = new QMenu(tr("Preference")); + hidePreview(); // Clear any preview state left over from a previous menu run. + + // Submenus are owned by the stack-allocated top-level menu (addMenu() does not + // transfer ownership). + auto *preferenceMenu = new QMenu(tr("Preference"), &menu); menu.addMenu(preferenceMenu); const auto &preferredProviderId = @@ -218,8 +320,66 @@ void PrintingSelectorCardOverlayWidget::customMenu(QPoint point) }); } + menu.addSeparator(); + + auto *overrideMenu = new QMenu(tr("Image Overrides"), &menu); + + auto *loadCustomAction = overrideMenu->addAction(tr("Load Custom Image...")); + auto *clearOverrideAction = overrideMenu->addAction(tr("Clear Custom Image")); + + // Nothing to clear on a card that has no local override yet. + clearOverrideAction->setEnabled(CardPictureLoader::hasLocalOverrides(rootCard)); + + overrideMenu->addSeparator(); + + const auto &allSets = rootCard.getInfo().getSets(); + + for (const auto &set : allSets) { + for (const auto &printing : set) { + if (printing == rootCard.getPrinting()) { + continue; + } + + // The submenu is already scoped to this card, so the rows lead with set + + // collector; only printings with a distinct display name add their own name. + const CardSetPtr cardSet = printing.getSet(); + if (!cardSet) { + continue; + } + + QString label = tr("%1 %2").arg(cardSet->getCorrectedShortName(), printing.getProperty("num")); + + auto *action = overrideMenu->addAction(label); + + ExactCard overrideCard(rootCard.getCardPtr(), printing); + action->setData(QVariant::fromValue(overrideCard)); + + connect(action, &QAction::triggered, this, [this, overrideCard]() { + CardPictureLoader::getInstance().installPrintingOverride(rootCard, overrideCard); + QPixmapCache::clear(); + rootCard.emitPixmapUpdated(); // refresh the overlay art in place, like the other paths + }); + } + } + + connect(clearOverrideAction, &QAction::triggered, this, [this]() { + CardPictureLoader::deleteAllLocalOverrides(rootCard); + QPixmapCache::clear(); + rootCard.emitPixmapUpdated(); // force UI refresh + }); + + connect(loadCustomAction, &QAction::triggered, this, &PrintingSelectorCardOverlayWidget::loadCustomImage); + + connect(overrideMenu, &QMenu::hovered, this, &PrintingSelectorCardOverlayWidget::showPreviewForAction); + connect(overrideMenu, &QMenu::aboutToHide, this, &PrintingSelectorCardOverlayWidget::hidePreview); + connect(overrideMenu, &QMenu::triggered, this, &PrintingSelectorCardOverlayWidget::hidePreview); + + menu.addMenu(overrideMenu); + + menu.addSeparator(); + // filling out the related cards submenu - auto *relatedMenu = new QMenu(tr("Show Related cards")); + auto *relatedMenu = new QMenu(tr("Show Related cards"), &menu); menu.addMenu(relatedMenu); auto relatedCards = rootCard.getInfo().getAllRelatedCards(); if (relatedCards.isEmpty()) { @@ -234,7 +394,11 @@ void PrintingSelectorCardOverlayWidget::customMenu(QPoint point) }); } } + // The preview anchors itself to this popup's global geometry while it is open, so the + // pointer must stay valid for the whole exec() and be dropped before the stack unwinds. + previewSourceMenu = overrideMenu; menu.exec(this->mapToGlobal(point)); + previewSourceMenu = nullptr; } /** @@ -290,3 +454,136 @@ void PrintingSelectorCardOverlayWidget::initializePinBadge() pinBadge->setVisible(false); pinBadge->raise(); } + +/** + * @brief Asks for an image file and installs it as the card's custom art. + * + * Unreadable files answer with a visible warning instead of a silent no-op. + */ +void PrintingSelectorCardOverlayWidget::loadCustomImage() +{ + QString filePath = QFileDialog::getOpenFileName(this, tr("Select Card Image"), QString(), + tr("Images (*.png *.jpg *.jpeg *.webp)")); + + if (filePath.isEmpty()) { + return; + } + + QPixmap pixmap(filePath); + if (pixmap.isNull()) { + // No silent paths: a file that cannot be read answers visibly instead of a no-op. + QMessageBox::warning(this, tr("Load Custom Image"), tr("The selected file could not be read as an image.")); + return; + } + + CardPictureLoader::getInstance().saveCardImageToLocalStorage(rootCard, pixmap, true); + + QPixmapCache::clear(); + rootCard.emitPixmapUpdated(); +} + +/** + * @brief Shows the hover preview for a highlighted printing entry in the Image Overrides submenu. + * + * QMenu::hovered fires on keyboard highlight too, so the preview appears when arrows walk + * onto a printing entry, not only under the mouse. + * + * Non-printing entries (e.g., Load Custom Image, Clear Custom Image) hide the preview. + * + * @param action The action that was highlighted. + */ +void PrintingSelectorCardOverlayWidget::showPreviewForAction(QAction *action) +{ + if (!action) { + hidePreview(); + return; + } + + const QVariant data = action->data(); + + if (!data.canConvert()) { + hidePreview(); + return; + } + + const ExactCard previewCard = qvariant_cast(data); + if (previewCard.isEmpty()) { + hidePreview(); + return; + } + + hoveredOverrideCard = previewCard; + hoveredOverrideAction = action; + refreshPreview(); +} + +/** + * @brief Renders the hover preview for the currently highlighted printing. + * + * The preview shows the loading placeholder while the art is pending and swaps in the real + * art when it resolves. The label is positioned against its already-resized geometry so the + * first-ever show at the screen's edges stays fully on-screen. + */ +void PrintingSelectorCardOverlayWidget::refreshPreview() +{ + if (hoveredOverrideCard.isEmpty()) { + hidePreview(); + return; + } + + constexpr QSize previewSize(240, 336); + constexpr int previewOffset = 20; + + QPixmap pixmap; + CardPictureLoader::getPixmap(pixmap, hoveredOverrideCard, previewSize); + + if (pixmap.isNull()) { + // Keep the preview honest while loading: show the loading placeholder instead of a void. + // Fetch at the logical size and let the label scale it, so the placeholder matches the + // real art's footprint rather than doubling on HiDPI displays. + CardPictureLoader::getCardBackLoadingInProgressPixmap(pixmap, previewSize); + } + + cardOverridePreviewLabel->setPixmap(pixmap); + // QPixmap::size() is physical pixels; the label layout must use the device-independent size + // so the preview keeps a constant footprint across DPI settings (QScreen geometry is logical). + const QSize labelSize = pixmap.deviceIndependentSize().toSize(); + cardOverridePreviewLabel->resize(labelSize); + + // Anchor the preview to the walked submenu popup rather than QCursor::pos(), which is idle + // under keyboard-only operation: a keyboard-highlighted row must preview at the same place as + // a hovered one. The mouse path is unchanged in effect — the popup sits under the cursor, so + // the preview stays beside the row in both modalities. + const QMenu *popup = previewSourceMenu; + if (!popup || !popup->isVisible() || !hoveredOverrideAction) { + hidePreview(); + return; + } + + const QRect popupGeometry = popup->geometry(); + const QRect actionRectLocal = popup->actionGeometry(hoveredOverrideAction); + const QRect actionRect(popupGeometry.topLeft() + actionRectLocal.topLeft(), actionRectLocal.size()); + + QScreen *screen = QGuiApplication::screenAt(popupGeometry.center()); + if (!screen) { + hidePreview(); + return; + } + const QRect &screenGeometry = screen->geometry(); + + cardOverridePreviewLabel->move(previewPositionNear(actionRect, labelSize, screenGeometry, previewOffset)); + cardOverridePreviewLabel->show(); +} + +/** + * @brief Hides the hover preview and forgets the currently highlighted printing. + */ +void PrintingSelectorCardOverlayWidget::hidePreview() +{ + hoveredOverrideCard = ExactCard(); + hoveredOverrideAction = nullptr; + + if (cardOverridePreviewLabel) { + cardOverridePreviewLabel->hide(); + } +} diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.h b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.h index 228393c9c..20d8f7b4b 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.h +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.h @@ -7,11 +7,20 @@ #ifndef PRINTING_SELECTOR_CARD_OVERLAY_WIDGET_H #define PRINTING_SELECTOR_CARD_OVERLAY_WIDGET_H -#include "../../../interface/widgets/tabs/abstract_tab_deck_editor.h" -#include "all_zones_card_amount_widget.h" -#include "set_name_and_collectors_number_display_widget.h" +#include "libcockatrice/card/printing/exact_card.h" -#include +#include +#include + +class QAction; +class QMenu; +class AbstractTabDeckEditor; +class AllZonesCardAmountWidget; +class CardInfoPictureWidget; +class DeckStateManager; +class QLabel; +class QPoint; +class QSlider; class PrintingSelectorCardOverlayWidget : public QWidget { @@ -43,11 +52,19 @@ private slots: private: void initializePinBadge(); + void loadCustomImage(); + void showPreviewForAction(QAction *action); + void refreshPreview(); + void hidePreview(); CardInfoPictureWidget *cardInfoPicture; AllZonesCardAmountWidget *allZonesCardAmountWidget; QLabel *pinBadge = nullptr; AbstractTabDeckEditor *deckEditor; ExactCard rootCard; + QLabel *cardOverridePreviewLabel = nullptr; + ExactCard hoveredOverrideCard; + QMenu *previewSourceMenu = nullptr; + QAction *hoveredOverrideAction = nullptr; }; #endif // PRINTING_SELECTOR_CARD_OVERLAY_WIDGET_H diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_search_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_search_widget.cpp index 3c42f06dc..718e24ea0 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_search_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_search_widget.cpp @@ -1,5 +1,11 @@ #include "printing_selector_card_search_widget.h" +#include "printing_selector.h" + +#include +#include +#include + /** * @brief Constructs a PrintingSelectorCardSearchWidget for searching cards by set name or set code. * diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_search_widget.h b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_search_widget.h index 3efabe13c..d54cf5fbc 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_search_widget.h +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_search_widget.h @@ -7,11 +7,14 @@ #ifndef PRINTING_SELECTOR_CARD_SEARCH_WIDGET_H #define PRINTING_SELECTOR_CARD_SEARCH_WIDGET_H -#include "printing_selector.h" - -#include -#include +#include #include +#include + +class PrintingSelector; +class QHBoxLayout; +class QLineEdit; +class QTimer; class PrintingSelectorCardSearchWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_selection_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_selection_widget.cpp index 2eb2ef245..da66591b3 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_selection_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_selection_widget.cpp @@ -1,7 +1,12 @@ #include "printing_selector_card_selection_widget.h" #include "../../../interface/widgets/dialogs/dlg_select_set_for_cards.h" -#include "../tabs/abstract_tab_deck_editor.h" +#include "printing_selector.h" + +#include +#include + +class DeckStateManager; /** * @brief Constructs a PrintingSelectorCardSelectionWidget for navigating through cards in the deck. diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_selection_widget.h b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_selection_widget.h index 0aa56c9d8..c619c5327 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_selection_widget.h +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_selection_widget.h @@ -7,11 +7,13 @@ #ifndef PRINTING_SELECTOR_CARD_SELECTION_WIDGET_H #define PRINTING_SELECTOR_CARD_SELECTION_WIDGET_H -#include "printing_selector.h" - -#include -#include #include +#include + +class DeckStateManager; +class PrintingSelector; +class QHBoxLayout; +class QPushButton; class PrintingSelectorCardSelectionWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_sorting_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_sorting_widget.cpp index f4a05cfc7..f47ba0746 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_sorting_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_sorting_widget.cpp @@ -1,11 +1,26 @@ #include "printing_selector_card_sorting_widget.h" #include "../../../client/settings/cache_settings.h" +#include "deck_list_model.h" +#include "libcockatrice/card/set/card_set.h" +#include "libcockatrice/deck_list/tree/inner_deck_list_node.h" +#include "printing_selector.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include #include +#include +#include const QString PrintingSelectorCardSortingWidget::SORT_OPTIONS_ALPHABETICAL = tr("Alphabetical"); const QString PrintingSelectorCardSortingWidget::SORT_OPTIONS_PREFERENCE = tr("Preference"); diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_sorting_widget.h b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_sorting_widget.h index bb5b60cdc..1cd99457b 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_sorting_widget.h +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_sorting_widget.h @@ -7,11 +7,20 @@ #ifndef PRINTING_SELECTOR_CARD_SORTING_WIDGET_H #define PRINTING_SELECTOR_CARD_SORTING_WIDGET_H -#include "printing_selector.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/printing/printing_info.h" -#include -#include +#include +#include +#include #include +#include + +class DeckListModel; +class PrintingSelector; +class QComboBox; +class QHBoxLayout; +class QPushButton; class PrintingSelectorCardSortingWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_placeholder_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector_placeholder_widget.cpp index aee34bccd..7f9ad66c8 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_placeholder_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_placeholder_widget.cpp @@ -1,5 +1,10 @@ #include "printing_selector_placeholder_widget.h" +#include +#include +#include +#include + PrintingSelectorPlaceholderWidget::PrintingSelectorPlaceholderWidget(QWidget *parent) : QWidget(parent) { setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector_placeholder_widget.h b/cockatrice/src/interface/widgets/printing_selector/printing_selector_placeholder_widget.h index abf030c69..6702d325a 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector_placeholder_widget.h +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector_placeholder_widget.h @@ -1,9 +1,11 @@ #ifndef COCKATRICE_PRINTING_SELECTOR_PLACEHOLDER_WIDGET_H #define COCKATRICE_PRINTING_SELECTOR_PLACEHOLDER_WIDGET_H -#include -#include #include +#include + +class QLabel; +class QVBoxLayout; class PrintingSelectorPlaceholderWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/printing_selector/set_name_and_collectors_number_display_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/set_name_and_collectors_number_display_widget.cpp index e12775a04..9bc92ad90 100644 --- a/cockatrice/src/interface/widgets/printing_selector/set_name_and_collectors_number_display_widget.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/set_name_and_collectors_number_display_widget.cpp @@ -1,6 +1,11 @@ #include "set_name_and_collectors_number_display_widget.h" -#include +#include +#include +#include +#include +#include +#include /** * @class SetNameAndCollectorsNumberDisplayWidget diff --git a/cockatrice/src/interface/widgets/printing_selector/set_name_and_collectors_number_display_widget.h b/cockatrice/src/interface/widgets/printing_selector/set_name_and_collectors_number_display_widget.h index 140f190df..54e580b67 100644 --- a/cockatrice/src/interface/widgets/printing_selector/set_name_and_collectors_number_display_widget.h +++ b/cockatrice/src/interface/widgets/printing_selector/set_name_and_collectors_number_display_widget.h @@ -8,10 +8,12 @@ #ifndef SET_NAME_AND_COLLECTORS_NUMBER_DISPLAY_WIDGET_H #define SET_NAME_AND_COLLECTORS_NUMBER_DISPLAY_WIDGET_H -#include -#include -#include +#include #include +#include + +class QLabel; +class QVBoxLayout; class SetNameAndCollectorsNumberDisplayWidget : public QWidget { 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 9c433ab5a..981ed843c 100644 --- a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp +++ b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp @@ -1,14 +1,26 @@ #include "settings_button_widget.h" +#include "../../pixel_map_generator.h" +#include "settings_popup_widget.h" + #include #include #include +#include +#include +#include +#include #include +#include +#include +#include +#include +#include SettingsButtonWidget::SettingsButtonWidget(QWidget *parent) : QWidget(parent), button(new QToolButton(this)), popup(new SettingsPopupWidget(nullptr)) { - button->setIcon(QPixmap("theme:icons/cogwheel")); + button->setIcon(themePixmap(QStringLiteral("icons/cogwheel"))); button->setCheckable(true); button->setFixedSize(32, 32); connect(button, &QToolButton::clicked, this, &SettingsButtonWidget::togglePopup); 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 1dc52ac72..015300bf8 100644 --- a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.h +++ b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.h @@ -8,10 +8,14 @@ #ifndef SETTINGS_BUTTON_WIDGET_H #define SETTINGS_BUTTON_WIDGET_H -#include "settings_popup_widget.h" - -#include +#include #include +#include + +class QHBoxLayout; +class QPixmap; +class QToolButton; +class SettingsPopupWidget; class SettingsButtonWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/quick_settings/settings_popup_widget.cpp b/cockatrice/src/interface/widgets/quick_settings/settings_popup_widget.cpp index 8389caea9..7ace155a7 100644 --- a/cockatrice/src/interface/widgets/quick_settings/settings_popup_widget.cpp +++ b/cockatrice/src/interface/widgets/quick_settings/settings_popup_widget.cpp @@ -1,10 +1,14 @@ #include "settings_popup_widget.h" #include -#include +#include #include +#include #include #include +#include +#include +#include SettingsPopupWidget::SettingsPopupWidget(QWidget *parent) : QWidget(parent, Qt::Popup | Qt::FramelessWindowHint) { diff --git a/cockatrice/src/interface/widgets/quick_settings/settings_popup_widget.h b/cockatrice/src/interface/widgets/quick_settings/settings_popup_widget.h index 01e86abfe..61b2cb013 100644 --- a/cockatrice/src/interface/widgets/quick_settings/settings_popup_widget.h +++ b/cockatrice/src/interface/widgets/quick_settings/settings_popup_widget.h @@ -8,10 +8,11 @@ #ifndef SETTINGS_POPUP_WIDGET_H #define SETTINGS_POPUP_WIDGET_H -#include -#include -#include #include +#include + +class QScrollArea; +class QVBoxLayout; class SettingsPopupWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/replay/replay_manager.cpp b/cockatrice/src/interface/widgets/replay/replay_manager.cpp index c6e7ff1bb..f3f1da2c4 100644 --- a/cockatrice/src/interface/widgets/replay/replay_manager.cpp +++ b/cockatrice/src/interface/widgets/replay/replay_manager.cpp @@ -1,11 +1,19 @@ #include "replay_manager.h" #include "../../../client/settings/cache_settings.h" +#include "../../../game/player/event_processing_options.h" +#include "libcockatrice/protocol/pb/game_event.pb.h" +#include "libcockatrice/protocol/pb/game_event_container.pb.h" +#include "libcockatrice/protocol/pb/game_replay.pb.h" #include +#include +#include #include +#include static constexpr int TIMER_INTERVAL_MS = 200; +static constexpr int EMPTY_SECTION_MARGIN_MS = 500; static QList createReplayTimeline(const GameReplay *replay) { @@ -119,6 +127,10 @@ void ReplayManager::replayTimerTimeout() processNewEvents(NORMAL_PLAYBACK); timeChanged(currentVisualTime); + + if (skipEmptySections) { + handleSkipEmptySection(); + } } /** @brief Processes all unprocessed events up to the current time. */ @@ -136,8 +148,10 @@ void ReplayManager::processNewEvents(PlaybackMode playbackMode) } // backwards skip => always skip tap animation + // backwards skip => always skip damage animation (battlefield shimmer / life counter flash) if (playbackMode == BACKWARD_SKIP) { options |= SKIP_TAP_ANIMATION; + options |= SKIP_DAMAGE_ANIMATION; } emit eventReplayed(replay->event_list(currentEvent), options); @@ -149,6 +163,51 @@ void ReplayManager::processNewEvents(PlaybackMode playbackMode) } } +static bool hasMeaningfulEvent(const GameEventContainer &cont) +{ + const int eventListSize = cont.event_list_size(); + for (int i = 0; i < eventListSize; ++i) { + const GameEvent &event = cont.event_list(i); + const auto eventType = static_cast(getPbExtension(event)); + + if (eventType != GameEvent::PLAYER_PROPERTIES_CHANGED) { + return true; + } + } + + return false; +} + +void ReplayManager::handleSkipEmptySection() +{ + if (currentEvent == replayTimeline.size()) { + return; + } + + // find most recent meaningful event + int prevEvent = std::max(0, currentEvent - 1); + for (; prevEvent > 0 && !hasMeaningfulEvent(replay->event_list(prevEvent)); --prevEvent) { + } + + int prevEventTime = replayTimeline.value(prevEvent); + if (currentVisualTime - prevEventTime <= EMPTY_SECTION_MARGIN_MS) { + return; + } + + // find next earliest meaningful event + int nextEvent = currentEvent; + for (; nextEvent < replayTimeline.size() - 1 && !hasMeaningfulEvent(replay->event_list(nextEvent)); ++nextEvent) { + } + + int nextEventTime = replayTimeline.value(nextEvent); + if (nextEventTime - currentVisualTime <= EMPTY_SECTION_MARGIN_MS) { + return; + } + + // skip forward if we're not within margin of either event + skipToTime(nextEventTime - EMPTY_SECTION_MARGIN_MS, false); +} + void ReplayManager::setTimeScaleFactor(qreal _timeScaleFactor) { timeScaleFactor = _timeScaleFactor; @@ -156,6 +215,11 @@ void ReplayManager::setTimeScaleFactor(qreal _timeScaleFactor) replayTimer->setInterval(interval); } +void ReplayManager::setSkipEmptySections(bool value) +{ + skipEmptySections = value; +} + void ReplayManager::startReplay() { replayTimer->start(); diff --git a/cockatrice/src/interface/widgets/replay/replay_manager.h b/cockatrice/src/interface/widgets/replay/replay_manager.h index 16d3591ba..3b33deada 100644 --- a/cockatrice/src/interface/widgets/replay/replay_manager.h +++ b/cockatrice/src/interface/widgets/replay/replay_manager.h @@ -3,11 +3,14 @@ #include "../../../game/player/event_processing_options.h" +#include #include -#include +#include +#include class GameReplay; class QTimer; +class GameEventContainer; /** * @brief This class handles all logic to do with playing back replays @@ -31,6 +34,7 @@ class ReplayManager : public QObject QTimer *rewindBufferingTimer; qreal timeScaleFactor = 1.0; + bool skipEmptySections = false; int currentVisualTime = 0; ///< time currently displayed by the timeline int currentProcessedTime = 0; ///< time that events are currently processed up to. Could differ from visual time due @@ -41,6 +45,7 @@ class ReplayManager : public QObject void handleBackwardsSkip(bool doRewindBuffering); void processRewind(); void processNewEvents(PlaybackMode playbackMode); + void handleSkipEmptySection(); private slots: void replayTimerTimeout(); @@ -63,6 +68,7 @@ public: } void setTimeScaleFactor(qreal _timeScaleFactor); + void setSkipEmptySections(bool value); public slots: void startReplay(); diff --git a/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.cpp b/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.cpp index 08113d2cd..5b3829af2 100644 --- a/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.cpp +++ b/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.cpp @@ -1,10 +1,14 @@ #include "replay_quick_settings_widget.h" #include "../../../client/settings/cache_settings.h" +#include "../quick_settings/settings_button_widget.h" +#include "libcockatrice/utility/macros.h" +#include #include #include #include +#include #include #include @@ -18,12 +22,17 @@ ReplayQuickSettingsWidget::ReplayQuickSettingsWidget(QWidget *parent) : Settings connect(&fastForwardSpeedBox, qOverload(&QDoubleSpinBox::valueChanged), this, &ReplayQuickSettingsWidget::actUpdateFastForwardSpeed); + skipEmptyCheckBox.setChecked(SettingsCache::instance().userInterface().getSkipEmptySections()); + connect(&skipEmptyCheckBox, &QCheckBox::QT_STATE_CHANGED, this, + &ReplayQuickSettingsWidget::actUpdateSkipEmptySections); + // putting it all together auto *widget = new QWidget; auto *grid = new QGridLayout(widget); grid->setContentsMargins(0, 0, 0, 0); grid->addWidget(&fastForwardSpeedLabel, 0, 0, 1, 1); grid->addWidget(&fastForwardSpeedBox, 0, 1, 1, 1); + grid->addWidget(&skipEmptyCheckBox, 1, 0, 1, 2); this->addSettingsWidget(widget); @@ -36,6 +45,8 @@ void ReplayQuickSettingsWidget::retranslateUi() { fastForwardSpeedLabel.setText(tr("Fast forward speed:")); fastForwardSpeedBox.setSuffix("x"); + + skipEmptyCheckBox.setText(tr("Skip empty sections")); } void ReplayQuickSettingsWidget::actUpdateFastForwardSpeed(qreal value) @@ -43,3 +54,9 @@ void ReplayQuickSettingsWidget::actUpdateFastForwardSpeed(qreal value) SettingsCache::instance().userInterface().setFastForwardSpeed(value); emit fastForwardSpeedChanged(value); } + +void ReplayQuickSettingsWidget::actUpdateSkipEmptySections(QT_STATE_CHANGED_T value) +{ + SettingsCache::instance().userInterface().setSkipEmptySections(value); + emit skipEmptySectionsChanged(value); +} diff --git a/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.h b/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.h index b88a8b4e3..d0be7d58d 100644 --- a/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.h +++ b/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.h @@ -3,7 +3,14 @@ #include "../../interface/widgets/quick_settings/settings_button_widget.h" +#include #include +#include +#include +#include +#include + +class QWidget; class ReplayQuickSettingsWidget : public SettingsButtonWidget { @@ -16,13 +23,17 @@ public: signals: void fastForwardSpeedChanged(qreal speed); + void skipEmptySectionsChanged(bool skip); private: QLabel fastForwardSpeedLabel; QDoubleSpinBox fastForwardSpeedBox; + QCheckBox skipEmptyCheckBox; + private slots: void actUpdateFastForwardSpeed(qreal value); + void actUpdateSkipEmptySections(QT_STATE_CHANGED_T value); }; #endif // COCKATRICE_REPLAY_QUICK_SETTINGS_WIDGET_H diff --git a/cockatrice/src/interface/widgets/replay/replay_timeline_widget.cpp b/cockatrice/src/interface/widgets/replay/replay_timeline_widget.cpp index fe4ac330b..48a43dba8 100644 --- a/cockatrice/src/interface/widgets/replay/replay_timeline_widget.cpp +++ b/cockatrice/src/interface/widgets/replay/replay_timeline_widget.cpp @@ -1,9 +1,15 @@ #include "replay_timeline_widget.h" -#include "../../../client/settings/cache_settings.h" - +#include +#include +#include #include #include +#include +#include +#include +#include +#include static constexpr int BIN_LENGTH = 5000; static constexpr int MIN_RESOLUTION_MS = 1000; diff --git a/cockatrice/src/interface/widgets/replay/replay_timeline_widget.h b/cockatrice/src/interface/widgets/replay/replay_timeline_widget.h index 47d19a741..2c277228f 100644 --- a/cockatrice/src/interface/widgets/replay/replay_timeline_widget.h +++ b/cockatrice/src/interface/widgets/replay/replay_timeline_widget.h @@ -7,13 +7,9 @@ #ifndef REPLAY_TIMELINE_WIDGET #define REPLAY_TIMELINE_WIDGET -#include "../../../game/player/event_processing_options.h" - -#include +#include #include - -class QPaintEvent; -class QTimer; +#include class ReplayTimelineWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/replay/replay_widget.cpp b/cockatrice/src/interface/widgets/replay/replay_widget.cpp index fc0110ff1..07b4e72ce 100644 --- a/cockatrice/src/interface/widgets/replay/replay_widget.cpp +++ b/cockatrice/src/interface/widgets/replay/replay_widget.cpp @@ -2,12 +2,22 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" -#include "../interface/widgets/tabs/tab_game.h" +#include "../../pixel_map_generator.h" +#include "libcockatrice/settings/interface_settings.h" #include "replay_manager.h" #include "replay_quick_settings_widget.h" +#include "replay_timeline_widget.h" +#include #include +#include +#include +#include +#include #include +#include + +class GameReplay; ReplayWidget::ReplayWidget(QWidget *parent, GameReplay *replay) : QWidget(parent), replayPlayButton(nullptr), replayFastForwardButton(nullptr), aReplaySkipForward(nullptr), @@ -50,15 +60,15 @@ ReplayWidget::ReplayWidget(QWidget *parent, GameReplay *replay) replayPlayButton = new QToolButton; replayPlayButton->setIconSize(QSize(32, 32)); QIcon playButtonIcon = QIcon(); - playButtonIcon.addPixmap(QPixmap("theme:replay/start"), QIcon::Normal, QIcon::Off); - playButtonIcon.addPixmap(QPixmap("theme:replay/pause"), QIcon::Normal, QIcon::On); + playButtonIcon.addPixmap(themePixmap(QStringLiteral("replay/start")), QIcon::Normal, QIcon::Off); + playButtonIcon.addPixmap(themePixmap(QStringLiteral("replay/pause")), QIcon::Normal, QIcon::On); replayPlayButton->setIcon(playButtonIcon); replayPlayButton->setCheckable(true); connect(replayPlayButton, &QToolButton::toggled, this, &ReplayWidget::replayPlayButtonToggled); replayFastForwardButton = new QToolButton; replayFastForwardButton->setIconSize(QSize(32, 32)); - replayFastForwardButton->setIcon(QPixmap("theme:replay/fastforward")); + replayFastForwardButton->setIcon(themePixmap(QStringLiteral("replay/fastforward"))); replayFastForwardButton->setCheckable(true); connect(replayFastForwardButton, &QToolButton::toggled, this, &ReplayWidget::updateTimeScaleFactor); @@ -66,6 +76,10 @@ ReplayWidget::ReplayWidget(QWidget *parent, GameReplay *replay) settingsWidget->setFixedSize(QSize(32, 32)); connect(settingsWidget, &ReplayQuickSettingsWidget::fastForwardSpeedChanged, this, [this] { updateTimeScaleFactor(replayFastForwardButton->isChecked()); }); + connect(settingsWidget, &ReplayQuickSettingsWidget::skipEmptySectionsChanged, replayManager, + &ReplayManager::setSkipEmptySections); + + replayManager->setSkipEmptySections(SettingsCache::instance().userInterface().getSkipEmptySections()); // putting everything together auto replayControlLayout = new QHBoxLayout; diff --git a/cockatrice/src/interface/widgets/replay/replay_widget.h b/cockatrice/src/interface/widgets/replay/replay_widget.h index 6d2b7a043..74aace570 100644 --- a/cockatrice/src/interface/widgets/replay/replay_widget.h +++ b/cockatrice/src/interface/widgets/replay/replay_widget.h @@ -8,15 +8,18 @@ #ifndef REPLAY_WIDGET_H #define REPLAY_WIDGET_H -#include "replay_timeline_widget.h" +#include "../../../game/player/event_processing_options.h" -#include #include -#include +#include class ReplayManager; class ReplayQuickSettingsWidget; -class TabGame; +class GameEventContainer; +class GameReplay; +class QAction; +class QToolButton; +class ReplayTimelineWidget; /** * @brief The top-level widget that is put in the replay dock widget. diff --git a/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp b/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp index e97d25e64..037a4a1e5 100644 --- a/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp +++ b/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp @@ -2,20 +2,49 @@ #include "../../../../client/settings/cache_settings.h" #include "../../client/sound_engine.h" +#include "../../interface/card_localization.h" #include "../../interface/pixel_map_generator.h" -#include "../../interface/widgets/tabs/tab_account.h" +#include "../../tabs/tab_supervisor.h" #include "../user/user_context_menu.h" #include "../user/user_list_manager.h" #include "../user/user_list_proxy.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/protocol/pb/event_room_say.pb.h" +#include "room_message_type.h" #include +#include +#include #include #include +#include +#include +#include +#include #include +#include +#include +#include +#include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include +#include +#include +#include + +class QPoint; const QColor DEFAULT_MENTION_COLOR = QColor(194, 31, 47); @@ -48,9 +77,12 @@ ChatView::ChatView(TabSupervisor *_tabSupervisor, AbstractGame *_game, bool _sho viewport()->setCursor(Qt::IBeamCursor); setReadOnly(true); - setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::LinksAccessibleByMouse); + setTextInteractionFlags(Qt::TextSelectableByMouse | Qt::LinksAccessibleByMouse | Qt::LinksAccessibleByKeyboard); setOpenLinks(false); connect(this, &ChatView::anchorClicked, this, &ChatView::openLink); + + connect(verticalScrollBar(), &QScrollBar::rangeChanged, this, &ChatView::onScrollBarRangeChanged); + connect(verticalScrollBar(), &QScrollBar::valueChanged, this, &ChatView::onScrollBarValueChanged); } void ChatView::adjustColorsToPalette() @@ -151,7 +183,7 @@ void ChatView::appendHtml(const QString &html) bool atBottom = verticalScrollBar()->value() >= verticalScrollBar()->maximum(); prepareBlock().insertHtml(html); if (atBottom) { - verticalScrollBar()->setValue(verticalScrollBar()->maximum()); + scrollToBottom(); } } @@ -161,7 +193,7 @@ void ChatView::appendHtmlServerMessage(const QString &html, bool optionalIsBold, QString htmlText = "" + - QDateTime::currentDateTime().toString("[hh:mm:ss] ") + html + ""; + getCurrentTime() + html + "
"; if (optionalIsBold) { htmlText = "" + htmlText + ""; @@ -169,17 +201,23 @@ void ChatView::appendHtmlServerMessage(const QString &html, bool optionalIsBold, prepareBlock().insertHtml(htmlText); if (atBottom) { - verticalScrollBar()->setValue(verticalScrollBar()->maximum()); + scrollToBottom(); } } +QString ChatView::getCurrentTime() const +{ + return QDateTime::currentDateTime().toString("[hh:mm:ss] "); +} + void ChatView::appendCardTag(QTextCursor &cursor, const QString &cardName) { QTextCharFormat oldFormat = cursor.charFormat(); QTextCharFormat cardFormat = oldFormat; cardFormat.setFontItalic(true); - if (!CardDatabaseManager::query()->lookupCardByName(cardName)) { + const CardInfoPtr card = CardDatabaseManager::query()->lookupCardByName(cardName); + if (card.isNull()) { cardFormat.setForeground(unresolvedCardTagColor); cursor.setCharFormat(cardFormat); cursor.insertText(cardName); @@ -189,10 +227,12 @@ void ChatView::appendCardTag(QTextCursor &cursor, const QString &cardName) cardFormat.setForeground(linkColor); cardFormat.setAnchor(true); + // The href keeps the canonical name so the card popup can resolve it, while the text shows the name + // in the configured card language. cardFormat.setAnchorHref("card://" + cardName); cursor.setCharFormat(cardFormat); - cursor.insertText(cardName); + cursor.insertText(CardLocalization::displayName(card)); cursor.setCharFormat(oldFormat); } @@ -215,6 +255,46 @@ void ChatView::appendUrlTag(QTextCursor &cursor, QString url) cursor.setCharFormat(oldFormat); } +void ChatView::appendGameLinkTag(QTextCursor &cursor, const QString &url) +{ + const QUrl gameUrl(url); + const QUrlQuery query(gameUrl); + const QString hostname = query.queryItemValue("hostname"); + // FullyDecoded undoes every %XX escape, so a description that itself + // contains "%" cannot end up displayed as "%25" in the label. + const QString description = query.queryItemValue("game", QUrl::FullyDecoded); + const int gameId = query.queryItemValue("gameid").toInt(); + + QString label; + if (gameId > 0 && !hostname.isEmpty()) { + // Links built before the description was embedded stay readable: the + // id + server fallback below is identical to the old anchor text. + if (!description.isEmpty()) { + // Multi-arg .arg() replaces all placeholders in a single pass, so a + // description containing "%…" cannot corrupt later placeholders. + label = tr("Join game \"%1\" (#%2) on %3").arg(description, QString::number(gameId), hostname); + } else { + label = tr("Join game #%1 on %2").arg(QString::number(gameId), hostname); + } + } else { + label = tr("Join game"); + } + + QTextCharFormat oldFormat = cursor.charFormat(); + QTextCharFormat gameLinkFormat = oldFormat; + gameLinkFormat.setForeground(linkColor); + gameLinkFormat.setFontWeight(QFont::Bold); + gameLinkFormat.setAnchor(true); + gameLinkFormat.setAnchorHref(url); + QColor background = palette().highlight().color(); + background.setAlpha(40); + gameLinkFormat.setBackground(background); + + cursor.setCharFormat(gameLinkFormat); + cursor.insertText(label); + cursor.setCharFormat(oldFormat); +} + void ChatView::appendMessage(QString message, RoomMessageTypeFlags messageType, const ServerInfo_User &userInfo, @@ -227,6 +307,14 @@ void ChatView::appendMessage(QString message, // messageType should be Event_RoomSay::UserMessage though we don't actually check bool isUserMessage = !(userName.toLower() == "servatrice" || userName.isEmpty()); bool sameSender = isUserMessage && userName == lastSender; + + if (isUserMessage) { + chatHistory.append({userName, message, QDateTime::currentDateTime()}); + while (chatHistory.size() > MAX_CHAT_HISTORY) { + chatHistory.removeFirst(); + } + } + QTextCursor cursor = prepareBlock(sameSender); lastSender = userName; @@ -236,7 +324,7 @@ void ChatView::appendMessage(QString message, timeFormat.setForeground(serverMessageColor); timeFormat.setFontWeight(QFont::Bold); cursor.setCharFormat(timeFormat); - cursor.insertText(QDateTime::currentDateTime().toString("[hh:mm:ss] ")); + cursor.insertText(getCurrentTime()); } // nickname @@ -286,16 +374,27 @@ void ChatView::appendMessage(QString message, pos.relativePosition = match.captured(0).length(); // set message start auto before = match.captured(1); auto sentBy = match.captured(2); + + // The user level is not carried in the room chat history, so history + // entries used to render as fixed-level user tags. Resolve online users + // against the user list to turn their history entries into full user + // tags (correct level, name casing and moderation context menu). + QString displayName = sentBy; + // Offline users have no known level; render them as zero-level tags. + QString levelMarker = "0"; + if (const ServerInfo_User *onlineUser = userListProxy->getOnlineUser(sentBy)) { + displayName = QString::fromStdString(onlineUser->name()); + levelMarker = QString::number(onlineUser->user_level()); + } + cursor.insertText(before); // add message timestamp QTextCharFormat senderFormat(defaultFormat); senderFormat.setAnchor(true); - // this underscore is important, it is used to add the user level, but in this case the level is - // unknown, if the name contains an underscore it would split up the name - senderFormat.setAnchorHref("user://_" + sentBy); + senderFormat.setAnchorHref("user://" + levelMarker + "_" + displayName); cursor.setCharFormat(senderFormat); - cursor.insertText(sentBy); // add username with href so it shows the menu - userMessagePositions[sentBy].append(pos); // save message position - message.remove(0, pos.relativePosition - 2); // do not remove semicolon + cursor.insertText(displayName); // add username with href so it shows the menu + userMessagePositions[displayName].append(pos); // save message position + message.remove(0, pos.relativePosition - 2); // do not remove semicolon } } else { //! \todo Remove hardcoded color. @@ -338,11 +437,36 @@ void ChatView::appendMessage(QString message, } } - if (atBottom) { + // ChatHistory messages are only ever sent once per room, right after joining, before the user can + // interact with the view. Always scroll to the bottom so the whole history is visible on join. + if (atBottom || messageType.testFlag(Event_RoomSay::ChatHistory)) { + scrollToBottom(); + } +} + +void ChatView::scrollToBottom() +{ + // The document layout, and therefore the scrollbar range, may be updated asynchronously (e.g. while + // the chat history is loaded into a view that has not been laid out yet). Setting the value once is + // not enough: keep stickToBottom set so any later range change scrolls to the new maximum as well. + stickToBottom = true; + verticalScrollBar()->setValue(verticalScrollBar()->maximum()); +} + +void ChatView::onScrollBarRangeChanged() +{ + if (stickToBottom) { verticalScrollBar()->setValue(verticalScrollBar()->maximum()); } } +void ChatView::onScrollBarValueChanged(int value) +{ + if (value < verticalScrollBar()->maximum()) { + stickToBottom = false; + } +} + void ChatView::checkTag(QTextCursor &cursor, QString &message) { if (message.startsWith("[card]")) { @@ -474,6 +598,17 @@ void ChatView::checkWord(QTextCursor &cursor, QString &message) } } + if (fullWordUpToSpaceOrEnd.startsWith("cockatrice://", Qt::CaseInsensitive)) { + // Only links to a game (cockatrice://joingame) become invite buttons; + // any other cockatrice:// scheme falls through to plain text below. + const QUrl gameLink(fullWordUpToSpaceOrEnd); + if (gameLink.host().compare("joingame", Qt::CaseInsensitive) == 0) { + appendGameLinkTag(cursor, fullWordUpToSpaceOrEnd); + cursor.insertText(rest, defaultFormat); + return; + } + } + // check word mentions for (const QString &word : highlightedWords) { if (fullWordUpToSpaceOrEnd.compare(word, Qt::CaseInsensitive) == 0) { @@ -568,6 +703,19 @@ void ChatView::clearChat() document()->clear(); lastSender = ""; evenNumber = true; + chatHistory.clear(); +} + +QString ChatView::getRecentChatLog(int maxMessages) const +{ + QStringList lines; + int start = qMax(0, chatHistory.size() - maxMessages); + for (int i = start; i < chatHistory.size(); ++i) { + const ChatLogEntry &entry = chatHistory.at(i); + lines.append( + QString("[%1] %2: %3").arg(entry.timestamp.toString("hh:mm:ss")).arg(entry.userName).arg(entry.message)); + } + return lines.join("\n"); } void ChatView::redactMessages(const QString &userName, int amount) @@ -695,6 +843,11 @@ void ChatView::mouseReleaseEvent(QMouseEvent *event) void ChatView::openLink(const QUrl &link) { + if (link.scheme() == "cockatrice") { + emit cockatriceLinkActivated(link.toString(QUrl::FullyEncoded)); + return; + } + if ((link.scheme() == "card") || (link.scheme() == "user")) { return; } diff --git a/cockatrice/src/interface/widgets/server/chat_view/chat_view.h b/cockatrice/src/interface/widgets/server/chat_view/chat_view.h index 8d5894613..3fa7224e5 100644 --- a/cockatrice/src/interface/widgets/server/chat_view/chat_view.h +++ b/cockatrice/src/interface/widgets/server/chat_view/chat_view.h @@ -8,22 +8,34 @@ #ifndef CHATVIEW_H #define CHATVIEW_H -#include "../../interface/widgets/tabs/tab_supervisor.h" -#include "../user/user_list_widget.h" +#include "libcockatrice/protocol/pb/serverinfo_user.pb.h" -#include #include +#include +#include +#include +#include +#include +#include #include +#include #include #include +#include #include #include +#include class AbstractGame; -class QTextTable; -class QMouseEvent; class UserContextMenu; class UserListProxy; +class QAction; +class QPoint; +class QUrl; +class QWidget; +class TabSupervisor; +struct CardRef; +template class QFlags; class UserMessagePosition { @@ -33,6 +45,13 @@ public: QTextBlock block; }; +struct ChatLogEntry +{ + QString userName; + QString message; + QDateTime timestamp; +}; + class ChatView : public QTextBrowser { Q_OBJECT @@ -60,15 +79,20 @@ private: QStringList highlightedWords; bool evenNumber; bool showTimestamps; + bool stickToBottom = false; HoveredItemType hoveredItemType; QString hoveredContent; QAction *messageClicked; QMap> userMessagePositions; + QList chatHistory; + static constexpr int MAX_CHAT_HISTORY = 200; [[nodiscard]] QTextFragment getFragmentUnderMouse(const QPoint &pos) const; QTextCursor prepareBlock(bool same = false); + void scrollToBottom(); void appendCardTag(QTextCursor &cursor, const QString &cardName); void appendUrlTag(QTextCursor &cursor, QString url); + void appendGameLinkTag(QTextCursor &cursor, const QString &url); static QColor getCustomMentionColor(); static QColor getCustomHighlightColor(); void showSystemPopup(const QString &userName); @@ -88,22 +112,25 @@ private slots: void actMessageClicked(); void adjustColorsToPalette(); void refreshBlockColors(); + void onScrollBarRangeChanged(); + void onScrollBarValueChanged(int value); public: ChatView(TabSupervisor *_tabSupervisor, AbstractGame *_game, bool _showTimestamps, QWidget *parent = nullptr); void retranslateUi(); void appendHtml(const QString &html); - void virtual appendHtmlServerMessage(const QString &html, - bool optionalIsBold = false, - QString optionalFontColor = QString()); + virtual void + appendHtmlServerMessage(const QString &html, bool optionalIsBold = false, QString optionalFontColor = QString()); void appendMessage(QString message, RoomMessageTypeFlags messageType = {}, const ServerInfo_User &userInfo = {}, bool playerBold = false); void clearChat(); void redactMessages(const QString &userName, int amount); + QString getRecentChatLog(int maxMessages = 50) const; protected: + [[nodiscard]] virtual QString getCurrentTime() const; void enterEvent(QEnterEvent *event) override; void leaveEvent(QEvent *event) override; void mouseMoveEvent(QMouseEvent *event) override; @@ -117,6 +144,7 @@ signals: void addMentionTag(QString mentionTag); void messageClickedSignal(); void showMentionPopup(const QString &userName); + void cockatriceLinkActivated(const QString &url); }; #endif diff --git a/cockatrice/src/interface/widgets/server/game_filter_configs.h b/cockatrice/src/interface/widgets/server/game_filter_configs.h index 0ece7e00c..1b7322c55 100644 --- a/cockatrice/src/interface/widgets/server/game_filter_configs.h +++ b/cockatrice/src/interface/widgets/server/game_filter_configs.h @@ -1,6 +1,11 @@ #ifndef COCKATRICE_GAME_FILTER_CONFIGS_H #define COCKATRICE_GAME_FILTER_CONFIGS_H +#include +#include +#include +#include +#include #include /** @@ -19,7 +24,7 @@ struct GameFilterConfigs bool hideNotBuddyCreatedGames = false; bool hideOpenDecklistGames = false; QString gameNameFilter = ""; - QStringList creatorNameFilters = {}; + QStringList hostNameFilters = {}; QSet gameTypeFilter = {}; int maxPlayersFilterMin = DEFAULT_MAX_PLAYERS_MIN; int maxPlayersFilterMax = DEFAULT_MAX_PLAYERS_MAX; diff --git a/cockatrice/src/interface/widgets/server/game_link.cpp b/cockatrice/src/interface/widgets/server/game_link.cpp new file mode 100644 index 000000000..c866f6571 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/game_link.cpp @@ -0,0 +1,23 @@ +#include "game_link.h" + +#include +#include + +QString makeGameJoinLink(const QString &hostname, int port, int roomId, int gameId, const QString &description) +{ + QUrl url; + url.setScheme("cockatrice"); + url.setHost("joingame"); + QUrlQuery query; + query.addQueryItem("hostname", hostname); + query.addQueryItem("port", QString::number(port)); + query.addQueryItem("roomid", QString::number(roomId)); + query.addQueryItem("gameid", QString::number(gameId)); + if (!description.isEmpty()) { + // addQueryItem percent-encodes, so arbitrary descriptions (quotes, + // ampersands, non-ASCII…) survive the trip through chat. + query.addQueryItem("game", description); + } + url.setQuery(query); + return url.toString(QUrl::FullyEncoded); +} diff --git a/cockatrice/src/interface/widgets/server/game_link.h b/cockatrice/src/interface/widgets/server/game_link.h new file mode 100644 index 000000000..d57bbd6d2 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/game_link.h @@ -0,0 +1,41 @@ +/** + * @file game_link.h + * @ingroup UI + * @brief Builds cockatrice://joingame links that let another user join a server game. + */ + +#ifndef GAME_LINK_H +#define GAME_LINK_H + +#include + +/** + * Builds a cockatrice://joingame link for the given server game. The receiver's + * client opens it through the intent chain (connect -> join room -> join game). + * @p description, when non-empty, is embedded in the link as the URL-encoded + * "game" query item so the receiving client can name the game in its confirm + * prompt and chat anchor instead of only its numeric id. Links built without it + * stay valid: the parser and chat renderer fall back to the id alone. + */ +QString +makeGameJoinLink(const QString &hostname, int port, int roomId, int gameId, const QString &description = QString()); + +/** + * One game the inviter is currently in and can invite another user to. + * @p label is meant for display in menus, @p url is the ready-made invite link. + * @p description is the raw game description for building tr()-wrapped invite + * messages (the label already embeds it, but the send sites need the raw value). + * @p onlyBuddies and @p creatorName mirror the server game's room settings so + * callers can gate the invite to the creator's buddies. + */ +struct GameInviteOption +{ + int gameId = 0; + QString label; + QString url; + QString description; + bool onlyBuddies = false; + QString creatorName; +}; + +#endif // GAME_LINK_H diff --git a/cockatrice/src/interface/widgets/server/game_selector.cpp b/cockatrice/src/interface/widgets/server/game_selector.cpp index 6580f0262..27464e14e 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector.cpp @@ -1,32 +1,54 @@ #include "game_selector.h" +#include "../../../client/settings/cache_settings.h" +#include "../../pixel_map_generator.h" #include "../interface/widgets/dialogs/dlg_create_game.h" #include "../interface/widgets/dialogs/dlg_filter_games.h" -#include "../interface/widgets/tabs/tab_account.h" -#include "../interface/widgets/tabs/tab_game.h" #include "../interface/widgets/tabs/tab_room.h" #include "../interface/widgets/tabs/tab_supervisor.h" #include "../interface/widgets/utility/get_text_with_max.h" +#include "game_link.h" +#include "game_selector_quick_filter_toolbar.h" #include "games_model.h" +#include "libcockatrice/protocol/pb/event_add_to_list.pb.h" +#include "libcockatrice/protocol/pb/event_remove_from_list.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_user.pb.h" #include "user/user_list_manager.h" +#include #include #include #include #include #include +#include +#include +#include +#include #include +#include #include +#include +#include +#include #include -#include -#include +#include +#include +#include #include #include #include #include #include -#include #include +#include +#include +#include + +class QModelIndex; +class QPoint; +class QWidget; +template class QList; GameSelector::GameSelector(AbstractClient *_client, TabSupervisor *_tabSupervisor, @@ -96,10 +118,10 @@ GameSelector::GameSelector(AbstractClient *_client, } filterButton = new QPushButton; - filterButton->setIcon(QPixmap("theme:icons/search")); + filterButton->setIcon(themePixmap(QStringLiteral("icons/search"))); connect(filterButton, &QPushButton::clicked, this, &GameSelector::actSetFilter); clearFilterButton = new QPushButton; - clearFilterButton->setIcon(QPixmap("theme:icons/clearsearch")); + clearFilterButton->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); bool filtersSetToDefault = showFilters && gameListProxyModel->areFilterParametersSetToDefaults(); clearFilterButton->setEnabled(!filtersSetToDefault); connect(clearFilterButton, &QPushButton::clicked, this, &GameSelector::actClearFilter); @@ -320,19 +342,12 @@ void GameSelector::customContextMenu(const QPoint &point) dlg.exec(); }); - QAction copyLink(tr("Copy Game Link")); + QAction copyLink(tr("Cop&y game link")); connect(©Link, &QAction::triggered, this, [=, this]() { const ServerInfo_Game &gameInfo = gameListModel->getGame(index.data(Qt::UserRole).toInt()); - QUrl url; - url.setScheme("cockatrice"); - url.setHost("joingame"); - QUrlQuery query; - query.addQueryItem("hostname", client->serverName()); - query.addQueryItem("port", QString::number(client->serverPort())); - query.addQueryItem("roomid", QString::number(gameInfo.room_id())); - query.addQueryItem("gameid", QString::number(gameInfo.game_id())); - url.setQuery(query); - QGuiApplication::clipboard()->setText(url.toString(QUrl::FullyEncoded)); + QGuiApplication::clipboard()->setText(makeGameJoinLink(client->serverName(), client->serverPort(), + gameInfo.room_id(), gameInfo.game_id(), + QString::fromStdString(gameInfo.description()))); }); QMenu menu; @@ -367,18 +382,40 @@ void GameSelector::joinGame(const bool asSpectator, const bool asJudge) return; } - const ServerInfo_Game &game = gameListModel->getGame(ind.data(Qt::UserRole).toInt()); + joinGame(gameListModel->getGame(ind.data(Qt::UserRole).toInt()), asSpectator, asJudge); +} + +void GameSelector::joinGame(const ServerInfo_Game &game, const bool asSpectator, const bool asJudge) +{ if (tabSupervisor->switchToGameTabIfAlreadyExists(game.game_id())) { return; } - bool spectator = asSpectator || game.player_count() == game.max_players(); + bool overrideRestrictions = tabSupervisor->canOverrideGameRestrictions(); + + // Joining a full game without override privileges silently becomes a + // spectator join, so ask first instead of surprising the player. + const bool gameFull = game.player_count() == game.max_players(); + if (gameFull && !asSpectator && !asJudge && !overrideRestrictions) { + const QMessageBox::StandardButton answer = + QMessageBox::question(this, tr("Join game"), tr("The game is full. Join as a spectator instead?"), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No); + if (answer != QMessageBox::Yes) { + return; + } + } + + bool spectator = asSpectator || gameFull; - bool overrideRestrictions = !tabSupervisor->getAdminLocked(); QString password; if (game.with_password() && !(spectator && !game.spectators_need_password()) && !overrideRestrictions) { bool ok; - password = getTextWithMax(this, tr("Join game"), tr("Password:"), QLineEdit::Password, QString(), &ok); + // Games without a description have no sensible label — fall back to the + // game id so the prompt still tells the user which game they're entering. + const QString gameLabel = QString::fromStdString(game.description()); + const QString prompt = gameLabel.isEmpty() ? tr("Password for game #%1:").arg(game.game_id()) + : tr("Password for \"%1\":").arg(gameLabel); + password = getTextWithMax(this, tr("Join game"), prompt, QLineEdit::Password, QString(), &ok); if (!ok) { return; } @@ -404,18 +441,16 @@ void GameSelector::joinGame(const bool asSpectator, const bool asJudge) disableButtons(); } -bool GameSelector::joinGameById(int gameId) +bool GameSelector::joinGameById(const int gameId, const bool asSpectator) { - auto *model = gameListView->model(); - - for (int row = 0; row < model->rowCount(); ++row) { - QModelIndex idx = model->index(row, 0); - const ServerInfo_Game &game = gameListModel->getGame(idx.data(Qt::UserRole).toInt()); - if (game.game_id() == gameId) { - gameListView->setCurrentIndex(idx); - joinGame(); - return true; + for (int row = 0; row < gameListModel->rowCount(); ++row) { + const ServerInfo_Game &game = gameListModel->getGame(row); + if (game.game_id() != gameId) { + continue; } + + joinGame(game, asSpectator); + return true; } qWarning() << "Game" << gameId << "not found"; @@ -449,7 +484,7 @@ void GameSelector::enableButtonsForIndex(const QModelIndex ¤t) } const ServerInfo_Game &game = gameListModel->getGame(current.data(Qt::UserRole).toInt()); - bool overrideRestrictions = !tabSupervisor->getAdminLocked(); + bool overrideRestrictions = tabSupervisor->canOverrideGameRestrictions(); spectateButton->setEnabled(game.spectators_allowed() || overrideRestrictions); joinButton->setEnabled(game.player_count() < game.max_players() || overrideRestrictions); diff --git a/cockatrice/src/interface/widgets/server/game_selector.h b/cockatrice/src/interface/widgets/server/game_selector.h index da34d5322..01e093b06 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.h +++ b/cockatrice/src/interface/widgets/server/game_selector.h @@ -1,25 +1,34 @@ #ifndef GAMESELECTOR_H #define GAMESELECTOR_H -#include "game_selector_quick_filter_toolbar.h" #include "game_type_map.h" #include -#include -#include -#include +#include +// IWYU pragma: keep +// ServerInfo_User appears by value and inside QList in slot signatures, so the +// moc-generated code needs the complete type. +#include class QTreeView; class GamesModel; class GamesProxyModel; class QPushButton; -class QCheckBox; -class QLabel; class AbstractClient; class TabSupervisor; class TabRoom; class ServerInfo_Game; class Response; +class Event_AddToList; +class Event_RemoveFromList; +class GameSelectorQuickFilterToolBar; +class QModelIndex; +class QPoint; +class QString; +class QWidget; +class ServerInfo_User; +template class QMap; +template class QList; /** * @class GameSelector @@ -171,6 +180,17 @@ private: */ void joinGame(bool asSpectator = false, bool asJudge = false); + /** + * @brief Performs the join or spectate action for a specific game. + * @param game The game to join. + * @param asSpectator True to join as a spectator, false to join as a player. + * @param asJudge True to join as a judge, false to join as a player. + * + * Unlike the selection-based overload, this does not depend on the game being + * visible in the filtered game list. + */ + void joinGame(const ServerInfo_Game &game, bool asSpectator = false, bool asJudge = false); + public: /** * @brief Constructs a GameSelector widget. @@ -202,7 +222,16 @@ public: * @param info The ServerInfo_Game object containing information about the game to update. */ void processGameInfo(const ServerInfo_Game &info); - bool joinGameById(int gameId); + /** + * @brief Finds a game by ID and joins or spectates it. + * @param gameId The ID of the game to join. + * @param asSpectator True to join as a spectator, false to join as a player. + * @return True if the game was found and joined, false otherwise. + * + * Unlike the selection-based overload, this does not depend on the game + * being visible in the filtered game list. + */ + bool joinGameById(int gameId, bool asSpectator = false); }; #endif diff --git a/cockatrice/src/interface/widgets/server/game_selector_quick_filter_toolbar.cpp b/cockatrice/src/interface/widgets/server/game_selector_quick_filter_toolbar.cpp index d668b1c33..0fc3d879c 100644 --- a/cockatrice/src/interface/widgets/server/game_selector_quick_filter_toolbar.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector_quick_filter_toolbar.cpp @@ -1,12 +1,22 @@ #include "game_selector_quick_filter_toolbar.h" +#include "game_filter_configs.h" #include "games_model.h" -#include "user/user_list_manager.h" #include #include #include #include +#include +#include +#include +#include +#include +#include +#include + +class TabSupervisor; +template class QMap; GameSelectorQuickFilterToolBar::GameSelectorQuickFilterToolBar(QWidget *parent, TabSupervisor *_tabSupervisor, diff --git a/cockatrice/src/interface/widgets/server/game_selector_quick_filter_toolbar.h b/cockatrice/src/interface/widgets/server/game_selector_quick_filter_toolbar.h index 9b7b6e61f..99ed179b5 100644 --- a/cockatrice/src/interface/widgets/server/game_selector_quick_filter_toolbar.h +++ b/cockatrice/src/interface/widgets/server/game_selector_quick_filter_toolbar.h @@ -1,12 +1,19 @@ #ifndef COCKATRICE_GAME_SELECTOR_QUICK_FILTER_TOOLBAR_H #define COCKATRICE_GAME_SELECTOR_QUICK_FILTER_TOOLBAR_H -#include "../tabs/tab_supervisor.h" -#include "games_model.h" - -#include -#include #include +#include +#include + +class GamesProxyModel; +class QCheckBox; +class QComboBox; +class QHBoxLayout; +class QLineEdit; +class QString; +class TabSupervisor; +struct GameFilterConfigs; +template class QMap; class GameSelectorQuickFilterToolBar : public QWidget { diff --git a/cockatrice/src/interface/widgets/server/games_model.cpp b/cockatrice/src/interface/widgets/server/games_model.cpp index 8e623d392..289dbee44 100644 --- a/cockatrice/src/interface/widgets/server/games_model.cpp +++ b/cockatrice/src/interface/widgets/server/games_model.cpp @@ -2,13 +2,26 @@ #include "../../../client/settings/cache_settings.h" #include "../interface/pixel_map_generator.h" -#include "../interface/widgets/tabs/tab_account.h" -#include "user/user_list_manager.h" -#include "user/user_list_widget.h" +#include "libcockatrice/protocol/pb/serverinfo_user.pb.h" +#include "user/user_list_proxy.h" +#include "user_level.h" +#include +#include #include +#include #include +#include +#include +#include +#include +#include +#include #include +#include +#include +#include +#include #include #include @@ -17,13 +30,27 @@ enum GameListColumn ROOM, CREATED, DESCRIPTION, - CREATOR, + HOST, GAME_TYPE, RESTRICTIONS, PLAYERS, SPECTATORS }; +namespace +{ +/** + * @brief Returns the user info of the game's current host, falling back to the creator. + * + * The server only sends host_info once a host transfer has happened, so older + * servers and freshly created games fall back to the original creator. + */ +const ServerInfo_User &getGameHost(const ServerInfo_Game &game) +{ + return game.has_host_info() ? game.host_info() : game.creator_info(); +} +} // namespace + const QString GamesModel::getGameCreatedString(const int secs) { static const QTime zeroTime{0, 0}; @@ -110,16 +137,16 @@ QVariant GamesModel::data(const QModelIndex &index, int role) const default: return QVariant(); } - case CREATOR: { + case HOST: { switch (role) { case SORT_ROLE: case Qt::DisplayRole: - return QString::fromStdString(gameentry.creator_info().name()); + return QString::fromStdString(getGameHost(gameentry).name()); case Qt::DecorationRole: { - return UserLevelPixmapGenerator::generateIcon( - 13, UserLevelFlags(gameentry.creator_info().user_level()), - gameentry.creator_info().pawn_colors(), false, - QString::fromStdString(gameentry.creator_info().privlevel())); + const ServerInfo_User &host = getGameHost(gameentry); + return UserLevelPixmapGenerator::generateIcon(13, UserLevelFlags(host.user_level()), + host.pawn_colors(), false, + QString::fromStdString(host.privlevel())); } default: return QVariant(); @@ -233,8 +260,8 @@ QVariant GamesModel::headerData(int section, Qt::Orientation /*orientation*/, in } case DESCRIPTION: return tr("Description"); - case CREATOR: - return tr("Creator"); + case HOST: + return tr("Host"); case GAME_TYPE: return tr("Type"); case RESTRICTIONS: @@ -271,6 +298,9 @@ void GamesModel::updateGameList(const ServerInfo_Game &game) gameList.removeAt(i); endRemoveRows(); } else { + // MergeFrom concatenates repeated fields instead of replacing them, + // so clear game_types first to avoid duplicated entries. + gameList[i].clear_game_types(); gameList[i].MergeFrom(game); emit dataChanged(index(i, 0), index(i, NUM_COLS - 1)); } @@ -347,7 +377,7 @@ void GamesProxyModel::loadFilterParameters(const QMap &allGameType gameFilters.isHideFullGames(), gameFilters.isHideGamesThatStarted(), gameFilters.isHidePasswordProtectedGames(), gameFilters.isHideNotBuddyCreatedGames(), gameFilters.isHideOpenDecklistGames(), gameFilters.getGameNameFilter(), - gameFilters.getCreatorNameFilters(), newGameTypeFilter, gameFilters.getMinPlayers(), + gameFilters.getHostNameFilters(), newGameTypeFilter, gameFilters.getMinPlayers(), gameFilters.getMaxPlayers(), gameFilters.getMaxGameAge(), gameFilters.isShowOnlyIfSpectatorsCanWatch(), gameFilters.isShowSpectatorPasswordProtected(), gameFilters.isShowOnlyIfSpectatorsCanChat(), gameFilters.isShowOnlyIfSpectatorsCanSeeHands()}); @@ -364,7 +394,7 @@ void GamesProxyModel::saveFilterParameters(const QMap &allGameType gameFilters.setHideNotBuddyCreatedGames(filters.hideNotBuddyCreatedGames); gameFilters.setHideOpenDecklistGames(filters.hideOpenDecklistGames); gameFilters.setGameNameFilter(filters.gameNameFilter); - gameFilters.setCreatorNameFilters(filters.creatorNameFilters); + gameFilters.setHostNameFilters(filters.hostNameFilters); QMapIterator gameTypeIterator(allGameTypes); while (gameTypeIterator.hasNext()) { @@ -409,11 +439,11 @@ bool GamesProxyModel::filterAcceptsRow(int sourceRow) const return false; } if (filters.hideIgnoredUserGames && - userListProxy->isUserIgnored(QString::fromStdString(game.creator_info().name()))) { + userListProxy->isUserIgnored(QString::fromStdString(getGameHost(game).name()))) { return false; } if (filters.hideNotBuddyCreatedGames && - !userListProxy->isUserBuddy(QString::fromStdString(game.creator_info().name()))) { + !userListProxy->isUserBuddy(QString::fromStdString(getGameHost(game).name()))) { return false; } if (filters.hideFullGames && game.player_count() == game.max_players()) { @@ -435,10 +465,10 @@ bool GamesProxyModel::filterAcceptsRow(int sourceRow) const return false; } } - if (!filters.creatorNameFilters.isEmpty()) { + if (!filters.hostNameFilters.isEmpty()) { bool found = false; - for (const auto &createNameFilter : filters.creatorNameFilters) { - if (QString::fromStdString(game.creator_info().name()).contains(createNameFilter, Qt::CaseInsensitive)) { + for (const auto &hostNameFilter : filters.hostNameFilters) { + if (QString::fromStdString(getGameHost(game).name()).contains(hostNameFilter, Qt::CaseInsensitive)) { found = true; } } diff --git a/cockatrice/src/interface/widgets/server/games_model.h b/cockatrice/src/interface/widgets/server/games_model.h index b5f86c291..9bf54b83a 100644 --- a/cockatrice/src/interface/widgets/server/games_model.h +++ b/cockatrice/src/interface/widgets/server/games_model.h @@ -4,13 +4,18 @@ #include "game_filter_configs.h" #include "game_type_map.h" +#include #include -#include +#include +#include #include -#include +#include #include +#include +#include class UserListProxy; +class QObject; /** * @class GamesModel diff --git a/cockatrice/src/interface/widgets/server/handle_public_servers.cpp b/cockatrice/src/interface/widgets/server/handle_public_servers.cpp index f5d682c36..08b7837dd 100644 --- a/cockatrice/src/interface/widgets/server/handle_public_servers.cpp +++ b/cockatrice/src/interface/widgets/server/handle_public_servers.cpp @@ -2,10 +2,19 @@ #include "../../../client/settings/cache_settings.h" +#include +#include #include +#include +#include #include +#include +#include #include +#include +#include #include +#include #define PUBLIC_SERVERS_JSON "https://cockatrice.github.io/public-servers.json" diff --git a/cockatrice/src/interface/widgets/server/handle_public_servers.h b/cockatrice/src/interface/widgets/server/handle_public_servers.h index fa924b183..bacc7d3bf 100644 --- a/cockatrice/src/interface/widgets/server/handle_public_servers.h +++ b/cockatrice/src/interface/widgets/server/handle_public_servers.h @@ -9,8 +9,17 @@ #include "user/user_info_connection.h" +#include +#include +#include +#include +#include +#include +#include + class QNetworkReply; class QNetworkAccessManager; +class QVariant; /** * This class is used to update the servers.ini file and ensure diff --git a/cockatrice/src/interface/widgets/server/remote/remote_decklist_tree_widget.cpp b/cockatrice/src/interface/widgets/server/remote/remote_decklist_tree_widget.cpp index a6add3fca..74e403ea8 100644 --- a/cockatrice/src/interface/widgets/server/remote/remote_decklist_tree_widget.cpp +++ b/cockatrice/src/interface/widgets/server/remote/remote_decklist_tree_widget.cpp @@ -1,14 +1,23 @@ #include "remote_decklist_tree_widget.h" +#include "libcockatrice/protocol/pb/response.pb.h" + +#include #include #include +#include +#include #include +#include #include #include #include #include #include +class QObject; +class QWidget; + RemoteDeckList_TreeModel::DirectoryNode::DirectoryNode(const QString &_name, RemoteDeckList_TreeModel::DirectoryNode *_parent) : RemoteDeckList_TreeModel::Node(_name, _parent) @@ -113,7 +122,7 @@ int RemoteDeckList_TreeModel::rowCount(const QModelIndex &parent) const int RemoteDeckList_TreeModel::columnCount(const QModelIndex & /*parent*/) const { - return 3; + return 4; } QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) const @@ -121,7 +130,7 @@ QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) cons if (!index.isValid()) { return QVariant(); } - if (index.column() >= 3) { + if (index.column() >= 4) { return QVariant(); } @@ -134,12 +143,29 @@ QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) cons switch (index.column()) { case 0: return node->getName(); + case 3: + // Report the node's own bit, not the inherited effective + // state, so it stays in step with what publishing toggles. + if (node->isPublic()) { + return tr("Public"); + } + return isEffectivelyPublic(node) ? tr("Public (inherited)") : tr("Private"); default: return QVariant(); } } case Qt::DecorationRole: return index.column() == 0 ? dirIcon : QVariant(); + case Qt::ToolTipRole: + if (index.column() == 3) { + if (node->isPublic()) { + return tr("This folder is visible to other users"); + } + return isEffectivelyPublic(node) + ? tr("This folder is private, but a parent folder is public (inherited).") + : tr("This folder is only visible to you"); + } + return QVariant(); default: return QVariant(); } @@ -153,6 +179,13 @@ QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) cons return file->getId(); case 2: return file->getUploadTime(); + case 3: + // Report the node's own bit, not the inherited effective + // state, so it stays in step with what publishing toggles. + if (file->isPublic()) { + return tr("Public"); + } + return isEffectivelyPublic(file) ? tr("Public (inherited)") : tr("Private"); default: return QVariant(); } @@ -161,6 +194,16 @@ QVariant RemoteDeckList_TreeModel::data(const QModelIndex &index, int role) cons return index.column() == 0 ? fileIcon : QVariant(); case Qt::TextAlignmentRole: return index.column() == 1 ? Qt::AlignRight : Qt::AlignLeft; + case Qt::ToolTipRole: + if (index.column() == 3) { + if (file->isPublic()) { + return tr("This deck is visible to other users"); + } + return isEffectivelyPublic(file) + ? tr("This deck is private, but a parent folder is public (inherited).") + : tr("This deck is only visible to you"); + } + return QVariant(); default: return QVariant(); } @@ -183,6 +226,8 @@ QVariant RemoteDeckList_TreeModel::headerData(int section, Qt::Orientation orien return tr("ID"); case 2: return tr("Upload time"); + case 3: + return tr("Visibility"); default: return QVariant(); } @@ -239,13 +284,14 @@ void RemoteDeckList_TreeModel::addFileToTree(const ServerInfo_DeckStorage_TreeIt time.setSecsSinceEpoch(fileInfo.creation_time()); beginInsertRows(nodeToIndex(parent), parent->size(), parent->size()); - parent->append(new FileNode(QString::fromStdString(file.name()), file.id(), time, parent)); + parent->append(new FileNode(QString::fromStdString(file.name()), file.id(), time, parent, fileInfo.is_public())); endInsertRows(); } void RemoteDeckList_TreeModel::addFolderToTree(const ServerInfo_DeckStorage_TreeItem &folder, DirectoryNode *parent) { DirectoryNode *newItem = addNamedFolderToTree(QString::fromStdString(folder.name()), parent); + newItem->setIsPublic(folder.folder().is_public()); const ServerInfo_DeckStorage_Folder &folderInfo = folder.folder(); const int folderItemsSize = folderInfo.items_size(); for (int i = 0; i < folderItemsSize; ++i) { @@ -285,6 +331,21 @@ void RemoteDeckList_TreeModel::refreshTree() client->sendCommand(pend); } +bool RemoteDeckList_TreeModel::isEffectivelyPublic(const Node *node) const +{ + if (node == nullptr || node == root) { + return false; + } + const Node *current = node; + while (current != nullptr) { + if (current->isPublic()) { + return true; + } + current = current->getParent(); + } + return false; +} + void RemoteDeckList_TreeModel::clearTree() { beginResetModel(); diff --git a/cockatrice/src/interface/widgets/server/remote/remote_decklist_tree_widget.h b/cockatrice/src/interface/widgets/server/remote/remote_decklist_tree_widget.h index 3dd91d7a4..d553ffe9e 100644 --- a/cockatrice/src/interface/widgets/server/remote/remote_decklist_tree_widget.h +++ b/cockatrice/src/interface/widgets/server/remote/remote_decklist_tree_widget.h @@ -8,13 +8,23 @@ #ifndef REMOTEDECKLIST_TREEWIDGET_H #define REMOTEDECKLIST_TREEWIDGET_H +#include #include +#include +#include +#include +#include +#include #include +#include +#include class Response; class AbstractClient; class QSortFilterProxyModel; class ServerInfo_DeckStorage_TreeItem; +class QObject; +class QWidget; class RemoteDeckList_TreeModel : public QAbstractItemModel { @@ -22,14 +32,17 @@ class RemoteDeckList_TreeModel : public QAbstractItemModel public: class DirectoryNode; class FileNode; + class Node { protected: DirectoryNode *parent; QString name; + bool publicFlag; public: - explicit Node(const QString &_name, DirectoryNode *_parent = nullptr) : parent(_parent), name(_name) + explicit Node(const QString &_name, DirectoryNode *_parent = nullptr) + : parent(_parent), name(_name), publicFlag(false) { } virtual ~Node() = default; @@ -41,6 +54,14 @@ public: { return name; } + [[nodiscard]] bool isPublic() const + { + return publicFlag; + } + void setIsPublic(bool _public) + { + publicFlag = _public; + } }; class DirectoryNode : public Node, public QList { @@ -59,9 +80,14 @@ public: QDateTime uploadTime; public: - FileNode(const QString &_name, int _id, const QDateTime &_uploadTime, DirectoryNode *_parent = nullptr) + FileNode(const QString &_name, + int _id, + const QDateTime &_uploadTime, + DirectoryNode *_parent = nullptr, + bool _isPublic = false) : Node(_name, _parent), id(_id), uploadTime(_uploadTime) { + setIsPublic(_isPublic); } [[nodiscard]] int getId() const { @@ -109,6 +135,11 @@ public: { return root; } + /** + * @brief Whether a node is visible to other users (own flag or inherited + * from any ancestor folder). + */ + [[nodiscard]] bool isEffectivelyPublic(const Node *node) const; void addFileToTree(const ServerInfo_DeckStorage_TreeItem &file, DirectoryNode *parent); void addFolderToTree(const ServerInfo_DeckStorage_TreeItem &folder, DirectoryNode *parent); DirectoryNode *addNamedFolderToTree(const QString &name, DirectoryNode *parent); 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 1f034b767..c131c3cbf 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,14 +1,27 @@ #include "remote_replay_list_tree_widget.h" +#include "../../../pixel_map_generator.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_replay_match.pb.h" + +#include +#include #include #include +#include #include +#include +#include +#include #include #include #include #include #include +class QObject; +class QWidget; + const int RemoteReplayList_TreeModel::numberOfColumns = 6; RemoteReplayList_TreeModel::MatchNode::MatchNode(const ServerInfo_ReplayMatch &_matchInfo) @@ -37,7 +50,7 @@ RemoteReplayList_TreeModel::RemoteReplayList_TreeModel(AbstractClient *_client, QFileIconProvider fip; dirIcon = fip.icon(QFileIconProvider::Folder); fileIcon = fip.icon(QFileIconProvider::File); - lockIcon = QPixmap("theme:icons/lock"); + lockIcon = themePixmap(QStringLiteral("icons/lock")); } RemoteReplayList_TreeModel::~RemoteReplayList_TreeModel() diff --git a/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.h b/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.h index 10e698aee..e99702e60 100644 --- a/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.h +++ b/cockatrice/src/interface/widgets/server/remote/remote_replay_list_tree_widget.h @@ -9,21 +9,30 @@ #ifndef REMOTEREPLAYLIST_TREEWIDGET_H #define REMOTEREPLAYLIST_TREEWIDGET_H -#include +#include +#include +#include +#include +#include +#include #include #include #include +#include +#include class Response; class AbstractClient; class QSortFilterProxyModel; +class QObject; +class QWidget; class RemoteReplayList_TreeModel : public QAbstractItemModel { Q_OBJECT private: - class MatchNode; class ReplayNode; + class Node { protected: diff --git a/cockatrice/src/interface/widgets/server/user/user_avatar_provider.cpp b/cockatrice/src/interface/widgets/server/user/user_avatar_provider.cpp index c115caa47..cb5f84eef 100644 --- a/cockatrice/src/interface/widgets/server/user/user_avatar_provider.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_avatar_provider.cpp @@ -1,8 +1,15 @@ #include "user_avatar_provider.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_user.pb.h" +#include "libcockatrice/protocol/pb/session_commands.pb.h" + +#include #include #include #include +#include +#include UserAvatarProvider::UserAvatarProvider(AbstractClient *client, QObject *parent) : QObject(parent), client(client) { diff --git a/cockatrice/src/interface/widgets/server/user/user_avatar_provider.h b/cockatrice/src/interface/widgets/server/user/user_avatar_provider.h index 44491e544..c1e75bc1c 100644 --- a/cockatrice/src/interface/widgets/server/user/user_avatar_provider.h +++ b/cockatrice/src/interface/widgets/server/user/user_avatar_provider.h @@ -5,6 +5,8 @@ #include #include #include +#include +#include class AbstractClient; diff --git a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp index 67fb4f684..d84bd5804 100644 --- a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp @@ -1,9 +1,23 @@ #include "user_card_art_provider.h" #include "../../../card_picture_loader/card_picture_loader.h" +#include "../../cards/card_art_utils.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/database/card_database.h" +#include "libcockatrice/card/database/card_database_loader.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/card/printing/printing_info.h" +#include +#include #include +#include +#include +#include #include +#include +#include static QString makeKey(const QString &user, const QString &card, const QString &providerId) { @@ -39,7 +53,10 @@ void UserCardArtProvider::requestCardArt(const QString &userName, const QString const QString key = makeKey(userName, cardName, providerId); - if (cardArtCache.contains(key) || pending.contains(key)) { + if (pending.contains(key)) { + return; + } + if (cardArtCache.contains(key) && !cardArtCache.value(key).isNull()) { return; } @@ -49,20 +66,33 @@ void UserCardArtProvider::requestCardArt(const QString &userName, const QString processQueue(); } -QPixmap UserCardArtProvider::cropCardArt(const QPixmap &fullRes) +QPixmap UserCardArtProvider::cropCardArt(const QPixmap &fullRes, const ExactCard &card) { - const QSize sz = fullRes.size(); + QPixmap source = fullRes; + + // Sideways-layout cards (plane, siege/battle, split) store their landscape + // artwork rotated 90° inside a portrait frame. Rotate it upright first so + // the crop below lands on the horizontal art, mirroring the way + // CardInfoPictureWidget displays these cards. + const bool landscape = card.getInfo().getUiAttributes().landscapeOrientation; + source = CardArtUtils::rotateSidewaysLayoutArt(source, card); + + const QSize sz = source.size(); const int marginX = sz.width() * 0.07; - const int topMargin = sz.height() * 0.11; - const int bottomMargin = sz.height() * 0.45; + const int topMargin = landscape ? sz.height() * 0.05 : sz.height() * 0.11; + const int bottomMargin = landscape ? sz.height() * 0.42 : sz.height() * 0.45; - const QRect foilRect(marginX, topMargin, sz.width() - 2 * marginX, sz.height() - topMargin - bottomMargin); + const QRect artRect(marginX, topMargin, sz.width() - 2 * marginX, sz.height() - topMargin - bottomMargin); - return fullRes.copy(foilRect.intersected(fullRes.rect())); + return source.copy(artRect.intersected(source.rect())); } void UserCardArtProvider::insertIntoCache(const QString &key, const QPixmap &pixmap) { + if (pixmap.isNull()) { + return; + } + if (!cardArtCache.contains(key)) { cacheInsertionOrder.append(key); while (cacheInsertionOrder.size() > MaxCacheEntries) { @@ -104,7 +134,7 @@ void UserCardArtProvider::processQueue() // Synchronous hit (already loaded/on disk) if (!fullRes.isNull()) { - insertIntoCache(key, cropCardArt(fullRes)); + insertIntoCache(key, cropCardArt(fullRes, card)); pending.remove(key); emit cardArtUpdated(userName); @@ -128,9 +158,7 @@ void UserCardArtProvider::processQueue() CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040)); if (!fullRes.isNull()) { - self->insertIntoCache(key, self->cropCardArt(fullRes)); - } else { - self->insertIntoCache(key, QPixmap()); + self->insertIntoCache(key, self->cropCardArt(fullRes, card)); } self->pending.remove(key); diff --git a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h index fb2f37812..ce9225b00 100644 --- a/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h +++ b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.h @@ -1,11 +1,16 @@ #ifndef COCKATRICE_USER_CARD_ART_PROVIDER_H #define COCKATRICE_USER_CARD_ART_PROVIDER_H +#include #include #include #include #include #include +#include +#include + +class ExactCard; class UserCardArtProvider : public QObject { @@ -16,7 +21,7 @@ public: void requestCardArt(const QString &userName, const QString &cardName, const QString &providerId); const QMap &cache() const; - static QPixmap cropCardArt(const QPixmap &fullRes); + static QPixmap cropCardArt(const QPixmap &fullRes, const ExactCard &card); signals: void cardArtUpdated(const QString &userName); @@ -26,7 +31,7 @@ public slots: private: bool dbReady = false; - static constexpr int MaxCacheEntries = 300; + static constexpr int MaxCacheEntries = 1024; QList cacheInsertionOrder; // FIFO eviction QMap cardArtCache; QSet pending; diff --git a/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp index 56c9600a0..f8b57cfc2 100644 --- a/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.cpp @@ -1,32 +1,80 @@ #include "user_card_settings_dialog.h" #include "../../../card_picture_loader/card_picture_loader.h" -#include "card/card_completer_proxy_model.h" -#include "card/card_search_model.h" +#include "../../cards/art_crop_attribution.h" +#include "../../utility/completer_utils.h" #include "card_database_display_model.h" #include "card_database_model.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/card/printing/printing_info.h" +#include "libcockatrice/card/set/card_set.h" #include "user_card_art_provider.h" #include "user_list_painter.h" +#include +#include +#include #include #include #include +#include #include #include -#include +#include #include #include +#include +#include +#include #include #include +#include +#include #include -#include +#include +#include +#include +#include +#include #include +#include +#include +#include +#include #include +#include +#include +#include + +namespace +{ +// Gesture clamps and step sizes for this direct manipulation surface. The +// gesture zoom floor is 1.0: basescale already cover fits the art, so any +// smaller scale would underfill the strip. +constexpr qreal kMinGestureZoom = 1.0; +constexpr qreal kMaxZoom = 4.0; +constexpr qreal kKeyPanOffsetStep = 0.01; +constexpr qreal kKeyZoomStep = 1.05; +constexpr qreal kWheelZoomBase = 1.15; // zoom factor per wheel notch +} // namespace CardArtPreviewWidget::CardArtPreviewWidget(QWidget *parent) : QWidget(parent) { setMinimumSize(400, 72); setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + setFocusPolicy(Qt::StrongFocus); + setAccessibleName(tr("Banner preview")); + setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2))); +} + +void CardArtPreviewWidget::focusInEvent(QFocusEvent *event) +{ + // Snapshot for the Esc or Backspace reset, restoring whatever the user + // had when the surface took focus + paramsAtFocusIn = params; + QWidget::focusInEvent(event); } void CardArtPreviewWidget::setPixmap(const QPixmap &pixmap) @@ -38,6 +86,13 @@ void CardArtPreviewWidget::setPixmap(const QPixmap &pixmap) void CardArtPreviewWidget::setParams(const CardArtParams &p) { params = p; + setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2))); + update(); +} + +void CardArtPreviewWidget::setAttribution(const QString &attribution) +{ + attributionText = attribution; update(); } @@ -60,9 +115,22 @@ void CardArtPreviewWidget::paintEvent(QPaintEvent *) painter.setBrush(accentColor); painter.drawRoundedRect(QRectF(cardRect.left(), cardRect.top(), 3, cardRect.height()), 2, 2); + // Visible keyboard focus per the focus cursor contract, Tab must show + // where the keys land, including in the empty state + const auto paintFocusRing = [&painter, &cardRect, this]() { + if (!hasFocus()) { + return; + } + QPen focusPen(palette().color(QPalette::Highlight), 2); + painter.setPen(focusPen); + painter.setBrush(Qt::NoBrush); + painter.drawRoundedRect(cardRect.adjusted(-1, -1, 1, 1), 6, 6); + }; + if (sourcePixmap.isNull()) { painter.setPen(QColor(150, 150, 150)); painter.drawText(rect, Qt::AlignCenter, tr("No card selected")); + paintFocusRing(); return; } @@ -73,7 +141,7 @@ void CardArtPreviewWidget::paintEvent(QPaintEvent *) &sourcePixmap // direct pixmap ); - // Avatar placeholder so the left-margin interaction is visible + // Avatar placeholder so the left margin interaction is visible const int avatarX = rect.left() + 14; const int avatarY = rect.top() + (rect.height() - 36) / 2; const QRect avatarRect(avatarX, avatarY, 36, 36); @@ -90,24 +158,202 @@ void CardArtPreviewWidget::paintEvent(QPaintEvent *) painter.setPen(QPen(QColor(70, 80, 95), 2)); painter.setBrush(Qt::NoBrush); painter.drawEllipse(avatarRect.adjusted(-1, -1, 1, 1)); + + paintArtAttribution(painter, cardRect, attributionText); + + paintFocusRing(); +} + +qreal CardArtPreviewWidget::bannerTravel() const +{ + if (sourcePixmap.isNull()) { + return 0.0; + } + // Mirror UserListPainter::drawCardArt() exactly: same strip metrics, the + // copy is drawn 1:1, so output pixels equal widget pixels here. + const int cardH = rect().height() - 4; + const int totalW = (rect().right() - 4) - rect().left(); + const int marginL = qRound(totalW * params.marginPctL); + const int marginR = qRound(totalW * params.marginPctR); + const int drawW = totalW - marginL - marginR; + const double basescale = qMax(double(drawW) / sourcePixmap.width(), double(cardH) / sourcePixmap.height()); + // qRound for literal parity with drawCardArt, which rounds the scaled + // height before computing travel + const double scaledH = qRound(sourcePixmap.height() * basescale * params.zoom); + return scaledH - cardH; +} + +void CardArtPreviewWidget::applyCropDelta(qreal dOffset, qreal zoomFactor) +{ + CardArtParams next = params; + next.verticalOffset = qBound(0.0, params.verticalOffset + dOffset, 1.0); + next.zoom = qBound(kMinGestureZoom, params.zoom * zoomFactor, kMaxZoom); + + if (sameCrop(next, params)) { + return; + } + + params = next; + setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2))); + update(); + emit paramsEdited(params); +} + +bool CardArtPreviewWidget::sameCrop(const CardArtParams &a, const CardArtParams &b) const +{ + // Exact comparison on purpose: clamped assignments yield identical bits, + // while qFuzzyCompare based equality misbehaves around zero + return a.verticalOffset == b.verticalOffset && a.zoom == b.zoom; +} + +void CardArtPreviewWidget::restoreSnapshot() +{ + // The snapshot only ever holds values that passed the gesture clamps, + // so it is safe to restore verbatim + if (sameCrop(paramsAtFocusIn, params)) { + return; + } + params = paramsAtFocusIn; + setAccessibleDescription(tr("Zoom %1×").arg(QString::number(params.zoom, 'f', 2))); + update(); + emit paramsEdited(params); +} + +void CardArtPreviewWidget::mousePressEvent(QMouseEvent *event) +{ + if (event->button() != Qt::LeftButton || sourcePixmap.isNull() || bannerTravel() <= 0.5) { + QWidget::mousePressEvent(event); + return; + } + dragging = true; + lastDragPos = event->pos(); + setCursor(Qt::ClosedHandCursor); + event->accept(); +} + +void CardArtPreviewWidget::mouseMoveEvent(QMouseEvent *event) +{ + if (!dragging || sourcePixmap.isNull()) { + QWidget::mouseMoveEvent(event); + return; + } + + const qreal dy = event->pos().y() - lastDragPos.y(); + lastDragPos = event->pos(); + + const qreal travel = bannerTravel(); + if (travel <= 0.5) { + event->accept(); + return; + } + + // Dragging moves the ART with the cursor, so the crop window slides the + // other way through the available travel. + applyCropDelta(-dy / travel, 1.0); + event->accept(); +} + +void CardArtPreviewWidget::mouseReleaseEvent(QMouseEvent *event) +{ + if (event->button() == Qt::LeftButton) { + dragging = false; + unsetCursor(); + event->accept(); + return; + } + QWidget::mouseReleaseEvent(event); +} + +void CardArtPreviewWidget::wheelEvent(QWheelEvent *event) +{ + if (sourcePixmap.isNull()) { + QWidget::wheelEvent(event); + return; + } + const qreal notches = static_cast(event->angleDelta().y()) / 120.0; + if (notches == 0.0) { + event->accept(); + return; + } + applyCropDelta(0.0, std::pow(kWheelZoomBase, notches)); + event->accept(); +} + +void CardArtPreviewWidget::keyPressEvent(QKeyEvent *event) +{ + if (sourcePixmap.isNull()) { + QWidget::keyPressEvent(event); + return; + } + + switch (event->key()) { + case Qt::Key_Escape: + if (sameCrop(params, paramsAtFocusIn)) { + // Nothing to undo on this surface, let the event reach the + // dialog so Esc keeps its close meaning there + QWidget::keyPressEvent(event); + return; + } + restoreSnapshot(); + break; + case Qt::Key_Backspace: + restoreSnapshot(); + break; + case Qt::Key_Up: + applyCropDelta(-kKeyPanOffsetStep, 1.0); + break; + case Qt::Key_Down: + applyCropDelta(kKeyPanOffsetStep, 1.0); + break; + case Qt::Key_Plus: + case Qt::Key_Equal: + applyCropDelta(0.0, kKeyZoomStep); + break; + case Qt::Key_Minus: + applyCropDelta(0.0, 1.0 / kKeyZoomStep); + break; + default: + QWidget::keyPressEvent(event); + return; + } + event->accept(); } UserCardArtSettingsDialog::UserCardArtSettingsDialog(const CardArtParams &initial, QWidget *parent) : QDialog(parent), currentParams(initial) { - setWindowTitle(tr("Card Art Settings")); + // Legacy stored banners may carry zoom below the gesture floor or an out + // of range offset. Normalize once on open so the preview renders filled + // and Ok saves a state the gestures can reach again + currentParams.zoom = qBound(kMinGestureZoom, currentParams.zoom, kMaxZoom); + currentParams.verticalOffset = qBound(0.0, currentParams.verticalOffset, 1.0); + setMinimumWidth(500); setupUi(); // Seed UI from initial params - if (!initial.cardName.isEmpty()) { - searchBar->setText(initial.cardName); - onCardNameChanged(initial.cardName); + if (!currentParams.cardName.isEmpty()) { + // onCardNameChanged overwrites cardProviderId with the first printing, + // so remember the stored one before it runs + const QString storedProviderId = currentParams.cardProviderId; + searchBar->setText(currentParams.cardName); + onCardNameChanged(currentParams.cardName); + + // onCardNameChanged leaves the printing combo on the first printing in + // the database, which would silently change the stored banner card on + // accept. Restore the stored printing when it resolves locally. + const int storedPrintingIndex = providerComboBox->findData(storedProviderId); + if (storedPrintingIndex != -1) { + providerComboBox->setCurrentIndex(storedPrintingIndex); + } else if (!storedProviderId.isEmpty()) { + // Stored printing not in the local database: keep it rather than + // silently substituting the first printing. + currentParams.cardProviderId = storedProviderId; + reloadPreview(); + } } - marginLSpin->setValue(initial.marginPctL); - marginRSpin->setValue(initial.marginPctR); - verticalOffsetSpin->setValue(initial.verticalOffset); - zoomSpin->setValue(initial.zoom); + marginLSpin->setValue(currentParams.marginPctL); + marginRSpin->setValue(currentParams.marginPctR); } CardArtParams UserCardArtSettingsDialog::params() const @@ -128,34 +374,18 @@ QDoubleSpinBox *UserCardArtSettingsDialog::makeSpinBox(double min, double max, d void UserCardArtSettingsDialog::initializeSearchBar() { searchBar = new QLineEdit; - searchBar->setPlaceholderText(tr("Type a card name...")); cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this); cardDatabaseDisplayModel = new CardDatabaseDisplayModel(this); cardDatabaseDisplayModel->setSourceModel(cardDatabaseModel); - searchModel = new CardSearchModel(cardDatabaseDisplayModel, this); - proxyModel = new CardCompleterProxyModel(this); - proxyModel->setSourceModel(searchModel); - proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive); - proxyModel->setFilterRole(Qt::DisplayRole); - - completer = new QCompleter(proxyModel, this); - completer->setCompletionRole(Qt::DisplayRole); - completer->setCompletionMode(QCompleter::PopupCompletion); - completer->setCaseSensitivity(Qt::CaseInsensitive); - completer->setFilterMode(Qt::MatchContains); - completer->setMaxVisibleItems(15); + const CardCompleterSetup cardSetup = createCardCompleter(cardDatabaseDisplayModel, this, 15); + searchModel = cardSetup.searchModel; + proxyModel = cardSetup.proxyModel; + completer = cardSetup.completer; searchBar->setCompleter(completer); - connect(searchBar, &QLineEdit::textEdited, searchModel, &CardSearchModel::updateSearchResults); - connect(searchBar, &QLineEdit::textEdited, this, [this](const QString &text) { - const QString pattern = ".*" + QRegularExpression::escape(text) + ".*"; - proxyModel->setFilterRegularExpression(QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption)); - if (!text.isEmpty()) { - completer->complete(); - } - }); + connectCardCompleterSearch(searchBar, cardSetup); connect(completer, static_cast(&QCompleter::activated), this, [this](const QString &completion) { @@ -183,29 +413,33 @@ void UserCardArtSettingsDialog::setupUi() marginLSpin = makeSpinBox(0.0, 0.95, currentParams.marginPctL, 0.01); marginRSpin = makeSpinBox(0.0, 0.95, currentParams.marginPctR, 0.01); - verticalOffsetSpin = makeSpinBox(0.0, 1.0, currentParams.verticalOffset, 0.01); - zoomSpin = makeSpinBox(0.1, 4.0, currentParams.zoom, 0.05); auto *form = new QFormLayout; - form->addRow(tr("Card name:"), searchBar); - form->addRow(tr("Card ProviderId:"), providerComboBox); - form->addRow(tr("Left margin (%):"), marginLSpin); - form->addRow(tr("Right margin (%):"), marginRSpin); - form->addRow(tr("Vertical offset:"), verticalOffsetSpin); - form->addRow(tr("Zoom:"), zoomSpin); + cardNameLabel = new QLabel; + printingLabel = new QLabel; + marginLLabel = new QLabel; + marginRLabel = new QLabel; + form->addRow(cardNameLabel, searchBar); + form->addRow(printingLabel, providerComboBox); + form->addRow(marginLLabel, marginLSpin); + form->addRow(marginRLabel, marginRSpin); - auto *controlsGroup = new QGroupBox(tr("Parameters")); + controlsGroup = new QGroupBox; controlsGroup->setLayout(form); preview = new CardArtPreviewWidget; auto *previewLayout = new QVBoxLayout; previewLayout->addWidget(preview); - auto *previewGroup = new QGroupBox(tr("Preview")); + previewCaptionLabel = new QLabel; + previewCaptionLabel->setAlignment(Qt::AlignCenter); + previewCaptionLabel->setWordWrap(true); + previewLayout->addWidget(previewCaptionLabel); + previewGroup = new QGroupBox; previewGroup->setLayout(previewLayout); auto *buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); - auto *removeBtn = new QPushButton(tr("Remove Banner Card")); + removeBtn = new QPushButton; buttons->addButton(removeBtn, QDialogButtonBox::ResetRole); connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); @@ -215,16 +449,38 @@ void UserCardArtSettingsDialog::setupUi() accept(); }); + // The banner leads visually, card selection and margins support it below. auto *root = new QVBoxLayout; - root->addWidget(controlsGroup); root->addWidget(previewGroup); + root->addWidget(controlsGroup); root->addWidget(buttons); setLayout(root); connect(marginLSpin, &QDoubleSpinBox::valueChanged, this, &UserCardArtSettingsDialog::onParamChanged); connect(marginRSpin, &QDoubleSpinBox::valueChanged, this, &UserCardArtSettingsDialog::onParamChanged); - connect(verticalOffsetSpin, &QDoubleSpinBox::valueChanged, this, &UserCardArtSettingsDialog::onParamChanged); - connect(zoomSpin, &QDoubleSpinBox::valueChanged, this, &UserCardArtSettingsDialog::onParamChanged); + + // Gestures are the only editors of offset and zoom on this surface. + // Margins stay explicit numeric controls: they trim the strip's + // sides and have no natural drag mapping. + connect(preview, &CardArtPreviewWidget::paramsEdited, this, + [this](const CardArtParams &edited) { currentParams = edited; }); + + retranslateUi(); +} + +void UserCardArtSettingsDialog::retranslateUi() +{ + setWindowTitle(tr("Card Art Settings")); + searchBar->setPlaceholderText(tr("Type a card name...")); + cardNameLabel->setText(tr("Card name:")); + printingLabel->setText(tr("Printing:")); + marginLLabel->setText(tr("Left margin (%):")); + marginRLabel->setText(tr("Right margin (%):")); + controlsGroup->setTitle(tr("Card")); + previewCaptionLabel->setText( + tr("Drag to pan, scroll to zoom, arrow keys nudge, plus and minus zoom, Backspace or Esc restores.")); + previewGroup->setTitle(tr("Banner")); + removeBtn->setText(tr("Remove Banner Card")); } void UserCardArtSettingsDialog::populateProviderCombo(const QString &cardName) @@ -274,7 +530,7 @@ void UserCardArtSettingsDialog::onCardNameChanged(const QString &name) populateProviderCombo(name); if (providerComboBox->count() == 0) { - // No printings found for this card; nothing to preview. + // No printings found for this card, nothing to preview. currentPixmap = QPixmap(); preview->setPixmap(currentPixmap); currentParams.cardProviderId.clear(); @@ -304,7 +560,7 @@ void UserCardArtSettingsDialog::reloadPreview() // whichever CardInfo we just asked for, so the preview catches up once // the image actually arrives instead of staying on the placeholder. // - // Disconnect any previous listener first -- otherwise switching cards + // Disconnect any previous listener first, otherwise switching cards // repeatedly stacks up connections to old CardInfo objects, each of // which would still fire reloadPreview() (harmlessly, but wastefully) // whenever ITS art finishes loading later. @@ -314,8 +570,8 @@ void UserCardArtSettingsDialog::reloadPreview() CardPictureLoader::getPixmap(fullRes, card, QSize(745, 1040)); if (fullRes.isNull()) { - // Not loaded yet -- wait for the signal instead of giving up. - // card.getCardPtr() is a CardInfoPtr (QSharedPointer); + // Not loaded yet, wait for the signal instead of giving up. + // card.getCardPtr() is a CardInfoPtr (QSharedPointer), // .data() gives the raw QObject* needed for connect(). CardInfo *cardInfo = card.getCardPtr().data(); if (cardInfo) { @@ -324,16 +580,19 @@ void UserCardArtSettingsDialog::reloadPreview() return; } - currentPixmap = UserCardArtProvider::cropCardArt(fullRes); + currentPixmap = UserCardArtProvider::cropCardArt(fullRes, card); preview->setPixmap(currentPixmap); preview->setParams(currentParams); + + // Only attribute the art once the new pixmap is actually displayed, so a + // cache miss (which keeps the previous pixmap on screen) doesn't pair the + // new card's attribution with the old card's art. + preview->setAttribution(buildArtAttribution(card)); } void UserCardArtSettingsDialog::onParamChanged() { currentParams.marginPctL = marginLSpin->value(); currentParams.marginPctR = marginRSpin->value(); - currentParams.verticalOffset = verticalOffsetSpin->value(); - currentParams.zoom = zoomSpin->value(); preview->setParams(currentParams); -} \ No newline at end of file +} diff --git a/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.h b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.h index 018043278..bfba8b007 100644 --- a/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.h +++ b/cockatrice/src/interface/widgets/server/user/user_card_settings_dialog.h @@ -3,18 +3,36 @@ #include "user_list_painter.h" -#include #include +#include #include +#include +#include +#include +#include +#include class QCompleter; +class QGroupBox; class QLineEdit; class QDoubleSpinBox; +class QLabel; +class QPushButton; class CardDatabaseModel; class CardDatabaseDisplayModel; class CardSearchModel; class CardCompleterProxyModel; +class QComboBox; +/** + * @brief Interactive preview of the user list banner art. + * + * Renders the banner strip with the given CardArtParams through the same + * UserListPainter::drawCardArt() the live delegate uses, including the + * avatar placeholder and fade masks. Dragging pans the art vertically at + * output scale, the wheel zooms, arrow keys nudge, Backspace or Esc + * restores the parameters as of focus gain. + */ class CardArtPreviewWidget : public QWidget { Q_OBJECT @@ -24,13 +42,33 @@ public: void setPixmap(const QPixmap &pixmap); void setParams(const CardArtParams ¶ms); + void setAttribution(const QString &attribution); + +signals: + /** @brief Emitted whenever direct manipulation (drag, wheel, keys) changes the parameters. */ + void paramsEdited(const CardArtParams ¶ms); protected: void paintEvent(QPaintEvent *event) override; + void mousePressEvent(QMouseEvent *event) override; + void mouseMoveEvent(QMouseEvent *event) override; + void mouseReleaseEvent(QMouseEvent *event) override; + void wheelEvent(QWheelEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; + void focusInEvent(QFocusEvent *event) override; private: + qreal bannerTravel() const; ///< vertical travel of the art behind the strip, in output pixels + void applyCropDelta(qreal dOffset, qreal zoomFactor); + bool sameCrop(const CardArtParams &a, const CardArtParams &b) const; + void restoreSnapshot(); + QPixmap sourcePixmap; CardArtParams params; + CardArtParams paramsAtFocusIn; ///< crop as of the latest focus gain, restored by Esc or Backspace + QString attributionText; + QPoint lastDragPos; ///< widget space position of the previous mouse move while panning + bool dragging{false}; ///< true between an accepted press and its release, guards stale drag positions }; class UserCardArtSettingsDialog : public QDialog @@ -49,6 +87,7 @@ private slots: private: void setupUi(); + void retranslateUi(); void populateProviderCombo(const QString &cardName); void initializeSearchBar(); QDoubleSpinBox *makeSpinBox(double min, double max, double value, double step); @@ -64,14 +103,20 @@ private: QMetaObject::Connection pixmapUpdatedConnection; + QLabel *cardNameLabel; + QLabel *printingLabel; + QLabel *marginLLabel; + QLabel *marginRLabel; + QGroupBox *controlsGroup; + QLabel *previewCaptionLabel; + QGroupBox *previewGroup; + QPushButton *removeBtn; QDoubleSpinBox *marginLSpin; QDoubleSpinBox *marginRSpin; - QDoubleSpinBox *verticalOffsetSpin; - QDoubleSpinBox *zoomSpin; CardArtPreviewWidget *preview; QPixmap currentPixmap; CardArtParams currentParams; }; -#endif // COCKATRICE_USER_CARD_ART_SETTINGS_DIALOG_H \ No newline at end of file +#endif // COCKATRICE_USER_CARD_ART_SETTINGS_DIALOG_H diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp index 5c4a88974..c8fe97cda 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp @@ -1,19 +1,42 @@ #include "user_context_menu.h" -#include "../../interface/widgets/tabs/tab_account.h" -#include "../../interface/widgets/tabs/tab_game.h" +#include "../../../../game/abstract_game.h" +#include "../../../../game/game_event_handler.h" +#include "../../../../game/game_meta_info.h" +#include "../../dialogs/dlg_report_user.h" #include "../../interface/widgets/tabs/tab_supervisor.h" #include "../chat_view/chat_view.h" +#include "../game_link.h" #include "../game_selector.h" +#include "../game_type_map.h" +#include "libcockatrice/protocol/pb/admin_commands.pb.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_ban.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_gametype.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_room.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_user.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_warning.pb.h" #include "user_info_box.h" +#include "user_level.h" +#include "user_list_dialog.h" #include "user_list_manager.h" #include "user_list_proxy.h" -#include "user_list_widget.h" #include +#include +#include +#include +#include +#include +#include +#include #include -#include -#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -26,6 +49,10 @@ #include #include #include +#include +#include + +class QVariant; UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent, AbstractGame *_game) : QObject(parent), client(_tabSupervisor->getClient()), tabSupervisor(_tabSupervisor), @@ -36,11 +63,13 @@ UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent, aDetails = new QAction(QString(), this); aChat = new QAction(QString(), this); aShowGames = new QAction(QString(), this); + aViewPublicDecks = new QAction(QString(), this); aAddToBuddyList = new QAction(QString(), this); aRemoveFromBuddyList = new QAction(QString(), this); aAddToIgnoreList = new QAction(QString(), this); aRemoveFromIgnoreList = new QAction(QString(), this); aKick = new QAction(QString(), this); + aReport = new QAction(QString(), this); aWarnUser = new QAction(QString(), this); aWarnHistory = new QAction(QString(), this); aBan = new QAction(QString(), this); @@ -49,7 +78,10 @@ UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent, aDemoteFromMod = new QAction(QString(), this); aPromoteToJudge = new QAction(QString(), this); aDemoteFromJudge = new QAction(QString(), this); + aPromoteToDeveloper = new QAction(QString(), this); + aDemoteFromDeveloper = new QAction(QString(), this); aGetAdminNotes = new QAction(QString(), this); + aInvestigateUser = new QAction(QString(), this); retranslateUi(); } @@ -59,11 +91,13 @@ void UserContextMenu::retranslateUi() aDetails->setText(tr("User &details")); aChat->setText(tr("Private &chat")); aShowGames->setText(tr("Show this user's &games")); + aViewPublicDecks->setText(tr("View this user's &public decks")); aAddToBuddyList->setText(tr("Add to &buddy list")); aRemoveFromBuddyList->setText(tr("Remove from &buddy list")); aAddToIgnoreList->setText(tr("Add to &ignore list")); aRemoveFromIgnoreList->setText(tr("Remove from &ignore list")); aKick->setText(tr("Kick from &game")); + aReport->setText(tr("Report user")); aWarnUser->setText(tr("Warn user")); aWarnHistory->setText(tr("View user's war&n history")); aBan->setText(tr("Ban from &server")); @@ -72,7 +106,10 @@ void UserContextMenu::retranslateUi() aDemoteFromMod->setText(tr("Dem&ote user from moderator")); aPromoteToJudge->setText(tr("Promote user to &judge")); aDemoteFromJudge->setText(tr("Demote user from judge")); + aPromoteToDeveloper->setText(tr("Promote user to &developer")); + aDemoteFromDeveloper->setText(tr("Demote user from de&veloper")); aGetAdminNotes->setText(tr("View admin notes")); + aInvestigateUser->setText(tr("Investigate user")); } void UserContextMenu::gamesOfUserReceived(const Response &resp, const CommandContainer &commandContainer) @@ -144,7 +181,8 @@ void UserContextMenu::warnUser_processGetWarningsListResponse(const Response &r) if (response.warning_size() > 0) { for (int i = 0; i < response.warning_size(); ++i) { - dlg->addWarningOption(QString::fromStdString(response.warning(i)).simplified()); + int startingIl = i < response.warning_il_size() ? response.warning_il(i) : 1; + dlg->addWarningOption(QString::fromStdString(response.warning(i)).simplified(), startingIl); } } dlg->show(); @@ -262,7 +300,7 @@ void UserContextMenu::adjustMod_processUserResponse(const Response &resp, const const Command_AdjustMod &cmd = commandContainer.admin_command(0).GetExtension(Command_AdjustMod::ext); if (resp.response_code() == Response::RespOk) { - if (cmd.should_be_mod() || cmd.should_be_judge()) { + if (cmd.should_be_mod() || cmd.should_be_judge() || cmd.should_be_developer()) { QMessageBox::information(static_cast(parent()), tr("Success"), tr("Successfully promoted user.")); } else { @@ -270,7 +308,7 @@ void UserContextMenu::adjustMod_processUserResponse(const Response &resp, const } } else { - if (cmd.should_be_mod() || cmd.should_be_judge()) { + if (cmd.should_be_mod() || cmd.should_be_judge() || cmd.should_be_developer()) { QMessageBox::information(static_cast(parent()), tr("Failed"), tr("Failed to promote user.")); } else { QMessageBox::information(static_cast(parent()), tr("Failed"), tr("Failed to demote user.")); @@ -355,6 +393,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos, { QAction *aCopyToClipBoard = nullptr, *aRemoveMessages = nullptr; aUserName->setText(userName); + const bool anotherUser = userName != userListProxy->getOwnUsername(); auto *menu = new QMenu(static_cast(parent())); menu->addAction(aUserName); @@ -365,7 +404,21 @@ void UserContextMenu::showContextMenu(const QPoint &pos, } menu->addAction(aDetails); menu->addAction(aShowGames); + if (userLevel.testFlag(ServerInfo_User::IsRegistered)) { + menu->addAction(aViewPublicDecks); + } menu->addAction(aChat); + const QList inviteOptions = inviteOptionsForUser(userName); + if (!inviteOptions.isEmpty()) { + auto *inviteMenu = new QMenu(tr("&Invite to Game"), menu); + for (const GameInviteOption &option : inviteOptions) { + QAction *inviteAction = inviteMenu->addAction(option.label); + inviteAction->setEnabled(anotherUser && online); + connect(inviteAction, &QAction::triggered, this, + [this, userName, option] { execInvite(userName, option); }); + } + menu->addMenu(inviteMenu); + } if (userLevel.testFlag(ServerInfo_User::IsRegistered) && userListProxy->isOwnUserRegistered()) { menu->addSeparator(); if (userListProxy->isUserBuddy(userName)) { @@ -383,6 +436,9 @@ void UserContextMenu::showContextMenu(const QPoint &pos, aRemoveMessages = new QAction(tr("Remove this user's messages"), this); menu->addAction(aRemoveMessages); } + if (userListProxy->isOwnUserRegistered()) { + menu->addAction(aReport); + } if (game && (game->isHost() || !tabSupervisor->getAdminLocked())) { menu->addSeparator(); menu->addAction(aKick); @@ -396,6 +452,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos, menu->addAction(aBanHistory); menu->addSeparator(); menu->addAction(aGetAdminNotes); + menu->addAction(aInvestigateUser); menu->addSeparator(); if (userLevel.testFlag(ServerInfo_User::IsModerator) && @@ -415,11 +472,21 @@ void UserContextMenu::showContextMenu(const QPoint &pos, (tabSupervisor->getUserInfo()->user_level() & ServerInfo_User::IsAdmin)) { menu->addAction(aPromoteToJudge); } + + if (userLevel.testFlag(ServerInfo_User::IsDeveloper) && + (tabSupervisor->getUserInfo()->user_level() & ServerInfo_User::IsAdmin)) { + menu->addAction(aDemoteFromDeveloper); + + } else if (userLevel.testFlag(ServerInfo_User::IsRegistered) && + (tabSupervisor->getUserInfo()->user_level() & ServerInfo_User::IsAdmin)) { + menu->addAction(aPromoteToDeveloper); + } } - bool anotherUser = userName != userListProxy->getOwnUsername(); aDetails->setEnabled(true); - aChat->setEnabled(anotherUser && online); + aChat->setEnabled(anotherUser && online && !userListProxy->isUserIgnored(userName)); aShowGames->setEnabled(online); + aReport->setEnabled(anotherUser); + aViewPublicDecks->setEnabled(anotherUser); aAddToBuddyList->setEnabled(anotherUser); aRemoveFromBuddyList->setEnabled(anotherUser); aAddToIgnoreList->setEnabled(anotherUser); @@ -430,8 +497,13 @@ void UserContextMenu::showContextMenu(const QPoint &pos, aBan->setEnabled(anotherUser); aBanHistory->setEnabled(anotherUser); aGetAdminNotes->setEnabled(anotherUser); + aInvestigateUser->setEnabled(anotherUser); aPromoteToMod->setEnabled(anotherUser); aDemoteFromMod->setEnabled(anotherUser); + aPromoteToJudge->setEnabled(anotherUser); + aDemoteFromJudge->setEnabled(anotherUser); + aPromoteToDeveloper->setEnabled(anotherUser); + aDemoteFromDeveloper->setEnabled(anotherUser); QAction *actionClicked = menu->exec(pos); if (actionClicked == nullptr) { @@ -441,6 +513,8 @@ void UserContextMenu::showContextMenu(const QPoint &pos, execChat(userName); } else if (actionClicked == aShowGames) { execShowGames(userName); + } else if (actionClicked == aViewPublicDecks) { + execViewPublicDecks(userName); } else if (actionClicked == aAddToBuddyList) { execAddToBuddy(userName); } else if (actionClicked == aRemoveFromBuddyList) { @@ -451,12 +525,23 @@ void UserContextMenu::showContextMenu(const QPoint &pos, execRemoveFromIgnore(userName); } else if (actionClicked == aKick) { execKick(playerId); + } else if (actionClicked == aReport) { + int gameId = game ? game->getGameMetaInfo()->gameId() : -1; + QString autoChatLog; + if (chatView) { + autoChatLog = chatView->getRecentChatLog(50); + } + auto dlgReport = new DlgReportUser(client, userName, gameId, autoChatLog, static_cast(parent())); + dlgReport->setAttribute(Qt::WA_DeleteOnClose); + dlgReport->exec(); } else if (actionClicked == aBan) { execBan(userName); } else if (actionClicked == aPromoteToMod || actionClicked == aDemoteFromMod) { execAdjustMod(userName, actionClicked == aPromoteToMod); } else if (actionClicked == aPromoteToJudge || actionClicked == aDemoteFromJudge) { execAdjustJudge(userName, actionClicked == aPromoteToJudge); + } else if (actionClicked == aPromoteToDeveloper || actionClicked == aDemoteFromDeveloper) { + execAdjustDeveloper(userName, actionClicked == aPromoteToDeveloper); } else if (actionClicked == aBanHistory) { execBanHistory(userName); } else if (actionClicked == aWarnUser) { @@ -465,6 +550,8 @@ void UserContextMenu::showContextMenu(const QPoint &pos, execWarnHistory(userName); } else if (actionClicked == aGetAdminNotes) { execAdminNotes(userName); + } else if (actionClicked == aInvestigateUser) { + execInvestigateUser(userName); } else if (actionClicked == aCopyToClipBoard) { QClipboard *clipboard = QGuiApplication::clipboard(); clipboard->setText(deckHash); @@ -480,6 +567,60 @@ void UserContextMenu::execChat(const QString &userName) emit openMessageDialog(userName, true); } +QList UserContextMenu::inviteOptionsForUser(const QString &userName) const +{ + if (!gameInviteLinkProvider) { + return {}; + } + const QList options = gameInviteLinkProvider(); + QList result; + for (const GameInviteOption &option : options) { + // Buddy-only games accept invites only from their creator, and only to + // users on the creator's buddy list. + if (option.onlyBuddies && + (option.creatorName != userListProxy->getOwnUsername() || !userListProxy->isUserBuddy(userName))) { + continue; + } + result.append(option); + } + return result; +} + +void UserContextMenu::execInvite(const QString &userName) +{ + const QList options = inviteOptionsForUser(userName); + if (options.isEmpty()) { + return; + } + + if (options.size() == 1) { + execInvite(userName, options.first()); + return; + } + + // More than one game in the room — let the user pick which one to invite to. + auto *menu = new QMenu(static_cast(parent())); + for (const GameInviteOption &option : options) { + QAction *action = menu->addAction(option.label); + connect(action, &QAction::triggered, this, [this, userName, option] { execInvite(userName, option); }); + } + menu->setAttribute(Qt::WA_DeleteOnClose); + menu->popup(QCursor::pos()); +} + +void UserContextMenu::execInvite(const QString &userName, const GameInviteOption &option) +{ + // Name the game by description first, then its id — "Join my game 'Magic' + // (#123)" — so a description-less fallback still identifies the game. + // The multi-arg .arg() overloads replace in a single pass, so a description + // containing "%…" cannot corrupt later placeholders. + const QString prefix = + option.description.isEmpty() + ? tr("Join my game (#%1):").arg(option.gameId) + : tr("Join my game \"%1\" (#%2):").arg(option.description, QString::number(option.gameId)); + tabSupervisor->sendInviteToUser(userName, prefix + " " + option.url); +} + void UserContextMenu::execDetails(const QString &userName) { auto *w = new UserInfoBox(client, false, static_cast(parent()), @@ -497,6 +638,11 @@ void UserContextMenu::execShowGames(const QString &userName) client->sendCommand(pend); } +void UserContextMenu::execViewPublicDecks(const QString &userName) +{ + tabSupervisor->openTabPublicDecks(userName); +} + void UserContextMenu::execAddToBuddy(const QString &userName) { Command_AddToList cmd; @@ -518,7 +664,15 @@ void UserContextMenu::execAddToIgnore(const QString &userName) Command_AddToList cmd; cmd.set_list("ignore"); cmd.set_user_name(userName.toStdString()); - client->sendCommand(client->prepareSessionCommand(cmd)); + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, + [this, userName](const Response &response, const CommandContainer &, const QVariant &) { + if (response.response_code() == Response::RespOk) { + QMessageBox::information(static_cast(parent()), tr("Ignore list"), + tr("%1 has been added to your ignore list.").arg(userName)); + } + }); + client->sendCommand(pend); } void UserContextMenu::execRemoveFromIgnore(const QString &userName) @@ -587,6 +741,11 @@ void UserContextMenu::execAdminNotes(const QString &userName) client->sendCommand(pend); } +void UserContextMenu::execInvestigateUser(const QString &userName) +{ + tabSupervisor->openTabModeration(userName); +} + void UserContextMenu::execAdjustMod(const QString &userName, bool shouldBeMod) { Command_AdjustMod cmd; @@ -605,4 +764,14 @@ void UserContextMenu::execAdjustJudge(const QString &userName, bool shouldBeJudg PendingCommand *pend = client->prepareAdminCommand(cmd); connect(pend, &PendingCommand::finished, this, &UserContextMenu::adjustMod_processUserResponse); client->sendCommand(pend); -} \ No newline at end of file +} + +void UserContextMenu::execAdjustDeveloper(const QString &userName, bool shouldBeDeveloper) +{ + Command_AdjustMod cmd; + cmd.set_user_name(userName.toStdString()); + cmd.set_should_be_developer(shouldBeDeveloper); + PendingCommand *pend = client->prepareAdminCommand(cmd); + connect(pend, &PendingCommand::finished, this, &UserContextMenu::adjustMod_processUserResponse); + client->sendCommand(pend); +} diff --git a/cockatrice/src/interface/widgets/server/user/user_context_menu.h b/cockatrice/src/interface/widgets/server/user/user_context_menu.h index 00fdc51fe..2536819a4 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.h +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.h @@ -7,8 +7,15 @@ #ifndef USER_CONTEXT_MENU_H #define USER_CONTEXT_MENU_H +#include "../../interface/widgets/server/game_link.h" + +#include #include +#include +#include #include +#include +#include class AbstractGame; class UserListProxy; @@ -16,11 +23,10 @@ class AbstractClient; class ChatView; class CommandContainer; class QAction; -class QMenu; class QPoint; class Response; -class ServerInfo_User; class TabSupervisor; +class QWidget; class UserContextMenu : public QObject { @@ -34,15 +40,20 @@ private: QAction *aUserName; QAction *aDetails; QAction *aShowGames; + QAction *aViewPublicDecks; QAction *aChat; QAction *aAddToBuddyList, *aRemoveFromBuddyList; QAction *aAddToIgnoreList, *aRemoveFromIgnoreList; QAction *aKick; + QAction *aReport; QAction *aBan, *aBanHistory; QAction *aPromoteToMod, *aDemoteFromMod; QAction *aPromoteToJudge, *aDemoteFromJudge; + QAction *aPromoteToDeveloper, *aDemoteFromDeveloper; QAction *aWarnUser, *aWarnHistory; QAction *aGetAdminNotes; + std::function()> gameInviteLinkProvider; + QAction *aInvestigateUser; signals: void openMessageDialog(const QString &userName, bool focus); private slots: @@ -80,11 +91,31 @@ public: return userListProxy; } + void setGameInviteLinkProvider(std::function()> provider) + { + gameInviteLinkProvider = std::move(provider); + } + + /** + * The games currently inviteable for @p userName, honoring the room's + * buddy-only setting (the inviter must be the game's creator and the + * target a buddy of theirs). Empty when there is no live provider. + */ + QList inviteOptionsForUser(const QString &userName) const; + + /** Whether at least one invite link is currently available for @p userName. */ + bool hasGameInviteLink(const QString &userName) const + { + return !inviteOptionsForUser(userName).isEmpty(); + } + // Individual action entry points — used by UserInfoPopup to trigger // actions without re-running the full context menu flow. void execChat(const QString &userName); + void execInvite(const QString &userName); void execDetails(const QString &userName); void execShowGames(const QString &userName); + void execViewPublicDecks(const QString &userName); void execAddToBuddy(const QString &userName); void execRemoveFromBuddy(const QString &userName); void execAddToIgnore(const QString &userName); @@ -95,8 +126,13 @@ public: void execBanHistory(const QString &userName); void execWarnHistory(const QString &userName); void execAdminNotes(const QString &userName); + void execInvestigateUser(const QString &userName); void execAdjustMod(const QString &userName, bool shouldBeMod); void execAdjustJudge(const QString &userName, bool shouldBeJudge); + void execAdjustDeveloper(const QString &userName, bool shouldBeDeveloper); + +private: + void execInvite(const QString &userName, const GameInviteOption &option); }; #endif diff --git a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp index 416cd42e3..ff3d9a224 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp @@ -5,20 +5,36 @@ #include "../../interface/widgets/dialogs/dlg_edit_password.h" #include "../../interface/widgets/dialogs/dlg_edit_user.h" #include "../../interface/widgets/utility/get_text_with_max.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/utility/days_years_between.h" #include "user_card_settings_dialog.h" +#include "user_level.h" +#include "user_list_painter.h" +#include +#include #include +#include +#include +#include #include #include -#include #include +#include +#include #include +#include +#include +#include +#include #include #include #include #include #include #include +#include +#include UserInfoBox::UserInfoBox(AbstractClient *_client, bool _editable, QWidget *parent, Qt::WindowFlags flags) : QWidget(parent, flags), client(_client), editable(_editable) @@ -122,6 +138,8 @@ void UserInfoBox::updateInfo(const ServerInfo_User &user) QString userLevelText; if (userLevel.testFlag(ServerInfo_User::IsAdmin)) { userLevelText = tr("Administrator"); + } else if (userLevel.testFlag(ServerInfo_User::IsDeveloper)) { + userLevelText = tr("Developer"); } else if (userLevel.testFlag(ServerInfo_User::IsModerator)) { userLevelText = tr("Moderator"); } else if (userLevel.testFlag(ServerInfo_User::IsRegistered)) { @@ -319,6 +337,7 @@ void UserInfoBox::actBannerCard() if (hasUserInfo && currentUserInfo.has_card_art_params()) { const auto &cap = currentUserInfo.card_art_params(); initial.cardName = QString::fromStdString(cap.card_name()); + initial.cardProviderId = QString::fromStdString(cap.card_provider_id()); initial.marginPctL = cap.margin_pct_l(); initial.marginPctR = cap.margin_pct_r(); initial.verticalOffset = cap.vertical_offset(); diff --git a/cockatrice/src/interface/widgets/server/user/user_info_box.h b/cockatrice/src/interface/widgets/server/user/user_info_box.h index 955cb9d3d..6bc571a37 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_box.h +++ b/cockatrice/src/interface/widgets/server/user/user_info_box.h @@ -7,13 +7,15 @@ #ifndef USERINFOBOX_H #define USERINFOBOX_H -#include #include +#include #include +#include #include #include #include -#include +#include +#include class AbstractClient; class Response; diff --git a/cockatrice/src/interface/widgets/server/user/user_info_connection.cpp b/cockatrice/src/interface/widgets/server/user/user_info_connection.cpp index f934960f8..d23ac63ba 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_connection.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_connection.cpp @@ -2,7 +2,10 @@ #include "../../../../client/settings/cache_settings.h" -#include +#include +#include +#include +#include #include #include diff --git a/cockatrice/src/interface/widgets/server/user/user_info_connection.h b/cockatrice/src/interface/widgets/server/user/user_info_connection.h index 6278815a9..b44d97d42 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_connection.h +++ b/cockatrice/src/interface/widgets/server/user/user_info_connection.h @@ -7,12 +7,11 @@ #ifndef USERCONNECTION_INFORMATION_H #define USERCONNECTION_INFORMATION_H -#include -#include -#include #include -#include -#include +#include +#include +#include +#include inline Q_LOGGING_CATEGORY(UserInfoConnectionLog, "user_info_connection"); diff --git a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp index edb95f2df..c3e880084 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp @@ -1,26 +1,100 @@ #include "user_info_popup.h" +#include "../../cards/art_crop_attribution.h" #include "../../interface/pixel_map_generator.h" +#include "../../interface/theme_manager.h" #include "../../interface/widgets/tabs/tab_supervisor.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_game.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_user.pb.h" +#include "libcockatrice/protocol/pb/session_commands.pb.h" +#include "user_level.h" #include "user_list_painter.h" #include +#include +#include +#include +#include +#include +#include #include #include +#include #include +#include +#include +#include +#include +#include +#include +#include #include #include -#include +#include +#include #include -#include -#include +#include +#include +#include +#include +#include #include +#include +#include +#include +#include #include #include +#include +#include +#include #include -#include #include #include +#include +#include +#include + +class QAction; + +/// Qt stylesheets accept #aarrggbb, which is QColor::name(QColor::HexArgb). +static QString colorStr(const QColor &color) +{ + return color.name(QColor::HexArgb); +} + +PopupTheme PopupTheme::fromPalette(const QPalette &palette, bool dark) +{ + PopupTheme t; + t.dark = dark; + const QColor window = palette.color(QPalette::Window); + const QColor base = palette.color(QPalette::Base); + const QColor mid = palette.color(QPalette::Mid); + const QColor text = palette.color(QPalette::Text); + const QColor disabledText = palette.color(QPalette::Disabled, QPalette::Text); + const QColor highlight = palette.color(QPalette::Highlight); + + t.bg = window; + t.border = mid; + t.text = text; + t.subText = disabledText; + t.statusText = disabledText; + t.buttonBg = base; + t.buttonBorder = mid; + t.buttonHover = UserListPainter::blend(base, highlight, dark ? 0.30 : 0.12); + t.buttonPressed = UserListPainter::blend(base, highlight, dark ? 0.50 : 0.25); + t.buttonDisabled = disabledText; + t.closeBg = UserListPainter::blend(base, window, 0.5); + t.closeHover = dark ? QColor(200, 50, 50) : UserListPainter::blend(QColor(200, 50, 50), base, 0.45); + t.gamesRow = base; + t.gamesSelected = UserListPainter::blend(base, highlight, dark ? 0.45 : 0.30); + t.gamesSeparator = mid; + t.gamesSeparator.setAlpha(90); + return t; +} // ── Compact game row delegate ───────────────────────────────────────────────── @@ -48,8 +122,14 @@ public: const QRect rect = option.rect; const ServerInfo_Game game = var.value(); const bool selected = option.state & QStyle::State_Selected; + const bool dark = themeManager && themeManager->isDarkModeActive(); + // The widget palette can be stale after a runtime theme change, so the + // rows are styled from the application palette (always current). + const QPalette pal = qApp->palette(); + const QColor base = pal.color(QPalette::Base); + const QColor highlight = pal.color(QPalette::Highlight); - p->fillRect(rect, selected ? QColor(35, 45, 62) : QColor(14, 18, 26)); + p->fillRect(rect, selected ? UserListPainter::blend(base, highlight, dark ? 0.45 : 0.30) : base); // State colour dot const QColor dot = game.started() ? QColor(239, 68, 68) @@ -64,7 +144,7 @@ public: QFont tf = option.font; tf.setBold(true); p->setFont(tf); - p->setPen(QColor(205, 215, 230)); + p->setPen(pal.color(QPalette::Text)); const int textX = rect.left() + 26; const int countW = 52; const int titleW = rect.width() - textX - countW - 6; @@ -74,13 +154,15 @@ public: // Player count const bool full = game.player_count() >= game.max_players(); p->setFont(option.font); - p->setPen(full ? QColor(249, 115, 22) : QColor(110, 128, 150)); + p->setPen(full ? QColor(249, 115, 22) : pal.color(QPalette::Disabled, QPalette::Text)); p->drawText(QRect(rect.right() - countW - 4, rect.top(), countW, rect.height()), Qt::AlignVCenter | Qt::AlignRight, QStringLiteral("%1/%2").arg(game.player_count()).arg(game.max_players())); // Row separator - p->setPen(QColor(24, 32, 44)); + QColor separator = pal.color(QPalette::Mid); + separator.setAlpha(90); + p->setPen(separator); p->drawLine(rect.bottomLeft(), rect.bottomRight()); p->restore(); @@ -95,17 +177,27 @@ UserInfoHeaderWidget::UserInfoHeaderWidget(QWidget *parent) : QWidget(parent) setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); } -void UserInfoHeaderWidget::setUserData(const ServerInfo_User &user, - bool online, - const QPixmap &avatar, - const QPixmap &cardArt, - const CardArtParams ¶ms) +void UserInfoHeaderWidget::setUserData(const ServerInfo_User &_user, + bool _online, + const QPixmap &_avatar, + const QPixmap &_cardArt, + const CardArtParams &_params) { - m_user = user; - m_online = online; - m_avatar = avatar; - m_cardArt = cardArt; - m_params = params; + user = _user; + online = _online; + avatar = _avatar; + cardArt = _cardArt; + params = _params; + + attribution.clear(); + if (user.has_card_art_params()) { + const ExactCard card = + CardDatabaseManager::query()->getCard({QString::fromStdString(user.card_art_params().card_name()), + QString::fromStdString(user.card_art_params().card_provider_id())}); + if (card) { + attribution = buildArtAttribution(card); + } + } update(); } @@ -115,31 +207,48 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *) p.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform | QPainter::TextAntialiasing); const QRect rect = this->rect(); - const UserLevelFlags level(m_user.user_level()); - const QString userName = QString::fromStdString(m_user.name()); - const QString privLevel = QString::fromStdString(m_user.privlevel()); + const UserLevelFlags level(user.user_level()); + const QString userName = QString::fromStdString(user.name()); + const QString privLevel = QString::fromStdString(user.privlevel()); - // Dark base - p.fillRect(rect, QColor(14, 18, 26)); + const bool dark = themeManager && themeManager->isDarkModeActive(); + const UserListPainter::Style style = UserListPainter::resolveStyle(qApp->palette(), dark); + + // Palette surface + { + QLinearGradient bg(0, 0, rect.width(), 0); + bg.setColorAt(0, style.cardStart); + bg.setColorAt(1, style.cardEnd); + p.fillRect(rect, bg); + } // ── Card art background ─────────────────────────────────────────────────── - if (!m_cardArt.isNull()) { + if (!cardArt.isNull()) { + // Same DPR normalization as UserListPainter::drawCardArt: the cache + // carries screen scaled pixmaps on HiDPI displays, the math below is + // in raw pixels. + QPixmap art = cardArt; + art.setDevicePixelRatio(1.0); + const int w = rect.width(); const int h = rect.height(); - const int mL = qRound(w * m_params.marginPctL); - const int mR = qRound(w * m_params.marginPctR); + const int mL = qRound(w * params.marginPctL); + const int mR = qRound(w * params.marginPctR); const int dW = w - mL - mR; - const double base = qMax(double(dW) / m_cardArt.width(), double(h) / m_cardArt.height()); - const double scale = base * m_params.zoom; - const int sW = qRound(m_cardArt.width() * scale); - const int sH = qRound(m_cardArt.height() * scale); + const double base = qMax(double(dW) / art.width(), double(h) / art.height()); + const double scale = base * params.zoom; + const int sW = qRound(art.width() * scale); + const int sH = qRound(art.height() * scale); - const QPixmap scaled = m_cardArt.scaled(sW, sH, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); - const int srcX = (sW - dW) / 2; - const int srcY = qBound(0, qRound((sH - h) * m_params.verticalOffset), qMax(0, sH - h)); + const QPixmap scaled = art.scaled(sW, sH, Qt::IgnoreAspectRatio, Qt::SmoothTransformation); + // Clamp against stored zoom < 1, which can push srcX negative and silently + // underfill the strip with transparent padding + const int safeSrcX = qBound(0, (sW - dW) / 2, qMax(0, sW - dW)); + const int safeSrcY = qBound(0, qRound((sH - h) * params.verticalOffset), qMax(0, sH - h)); - QImage img = scaled.copy(srcX, srcY, dW, h).toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied); + QImage img = + scaled.copy(safeSrcX, safeSrcY, dW, h).toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied); { QPainter mask(&img); mask.setCompositionMode(QPainter::CompositionMode_DestinationIn); @@ -155,12 +264,14 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *) p.setOpacity(1.0); } - // Bottom gradient overlay so avatar and text are always legible + // Bottom gradient overlay so avatar and text are always legible. The scrim + // is the palette's Window color so it reads naturally in either scheme. { + const QColor scrim = qApp->palette().color(QPalette::Window); QLinearGradient ov(0, 0, 0, rect.height()); - ov.setColorAt(0.0, QColor(14, 18, 26, 0)); - ov.setColorAt(0.55, QColor(14, 18, 26, 110)); - ov.setColorAt(1.0, QColor(14, 18, 26, 230)); + ov.setColorAt(0.0, QColor(scrim.red(), scrim.green(), scrim.blue(), 0)); + ov.setColorAt(0.55, QColor(scrim.red(), scrim.green(), scrim.blue(), 110)); + ov.setColorAt(1.0, QColor(scrim.red(), scrim.green(), scrim.blue(), 230)); p.fillRect(rect, ov); } @@ -169,6 +280,9 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *) if (level.testFlag(ServerInfo_User::IsAdmin)) { return QColor(245, 158, 11); } + if (level.testFlag(ServerInfo_User::IsDeveloper)) { + return QColor(185, 28, 28); + } if (level.testFlag(ServerInfo_User::IsModerator)) { return QColor(59, 130, 246); } @@ -187,20 +301,20 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *) p.save(); p.setClipPath(clip); - if (!m_avatar.isNull()) { - p.drawPixmap(ar, m_avatar.scaled(ar.size(), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation)); + if (!avatar.isNull()) { + p.drawPixmap(ar, avatar.scaled(ar.size(), Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation)); } else { p.setPen(Qt::NoPen); - p.setBrush(accent.darker(200)); + p.setBrush(UserListPainter::blend(accent, style.base, dark ? 0.45 : 0.72)); p.drawEllipse(ar); const QPixmap pawn = - UserLevelPixmapGenerator::generatePixmap(AvatarPawnSize, level, m_user.pawn_colors(), false, privLevel); + UserLevelPixmapGenerator::generatePixmap(AvatarPawnSize, level, user.pawn_colors(), false, privLevel); p.drawPixmap(ar.center().x() - AvatarPawnSize / 2, ar.center().y() - AvatarPawnSize / 2, pawn); } p.restore(); // Status ring - p.setPen(QPen(m_online ? QColor(34, 197, 94) : QColor(70, 80, 95), 2.5)); + p.setPen(QPen(online ? QColor(34, 197, 94) : style.ringOffline, 2.5)); p.setBrush(Qt::NoBrush); p.drawEllipse(QRectF(ar).adjusted(-1.25, -1.25, 1.25, 1.25)); @@ -212,7 +326,7 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *) nf.setBold(true); nf.setPointSizeF(nf.pointSizeF() * 1.12); p.setFont(nf); - p.setPen(m_online ? QColor(220, 228, 240) : QColor(90, 100, 115)); + p.setPen(online ? style.textOnline : style.textOffline); p.drawText(QRect(tx, ay, tw, AvatarSize / 2 + 4), Qt::AlignBottom | Qt::AlignLeft, QFontMetrics(nf).elidedText(userName, Qt::ElideRight, tw)); @@ -224,6 +338,8 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *) } badge; if (level.testFlag(ServerInfo_User::IsAdmin)) { badge = {"ADMIN", QColor(245, 158, 11)}; + } else if (level.testFlag(ServerInfo_User::IsDeveloper)) { + badge = {"DEV", QColor(185, 28, 28)}; } else if (level.testFlag(ServerInfo_User::IsModerator)) { badge = {"MOD", QColor(59, 130, 246)}; } else if (level.testFlag(ServerInfo_User::IsJudge)) { @@ -243,143 +359,184 @@ void UserInfoHeaderWidget::paintEvent(QPaintEvent *) const int bw = bfm.horizontalAdvance(badge.text) + 10; const QRect br(tx, ay + AvatarSize / 2 + 6, bw, 15); p.setPen(Qt::NoPen); - p.setBrush(badge.color.darker(160)); + p.setBrush(UserListPainter::blend(badge.color, style.base, dark ? 0.55 : 0.78)); p.drawRoundedRect(br, 3, 3); - p.setPen(badge.color.lighter(150)); + p.setPen(dark ? UserListPainter::blend(badge.color, Qt::white, 0.5) + : UserListPainter::blend(badge.color, Qt::black, 0.35)); p.drawText(br, Qt::AlignCenter, badge.text); } + + // The painter font at this point depends on whether a badge was drawn + // (badge font vs username font), so pin an explicit font for the pill. + p.setFont(font()); + + // Only show the attribution when there is actually art on screen: on a + // cache miss cardArt is null and the pill would float over the plain + // header with no art behind it. + if (!cardArt.isNull()) { + paintArtAttribution(p, rect, attribution, Qt::AlignRight | Qt::AlignBottom, 0.8); + } } // ── UserInfoPopup ───────────────────────────────────────────────────────────── -UserInfoPopup::UserInfoPopup(TabSupervisor *ts, - AbstractClient *client, - const QMap *avatarCache, - const QMap *cardArtCache, - const QMap *cardArtParamsMap, +UserInfoPopup::UserInfoPopup(TabSupervisor *_ts, + AbstractClient *_client, + const QMap *_avatarCache, + const QMap *_cardArtCache, + const QMap *_cardArtParamsMap, QWidget *parent) - : QFrame(parent, Qt::Tool | Qt::FramelessWindowHint), m_ts(ts), m_client(client), m_avatarCache(avatarCache), - m_cardArtCache(cardArtCache), m_cardArtParamsMap(cardArtParamsMap) + : QFrame(parent, Qt::Tool | Qt::FramelessWindowHint), ts(_ts), client(_client), avatarCache(_avatarCache), + cardArtCache(_cardArtCache), cardArtParamsMap(_cardArtParamsMap) { setAttribute(Qt::WA_ShowWithoutActivating); setFixedWidth(PopupWidth); setFrameShape(QFrame::NoFrame); buildUi(); + + // Restyle the popup chrome when the theme or its color scheme changes. + if (themeManager) { + connect(themeManager, &ThemeManager::themeChanged, this, &UserInfoPopup::applyTheme); + } } void UserInfoPopup::buildUi() { - setStyleSheet(QStringLiteral("UserInfoPopup {" - " background:#0e1218;" - " border:1px solid #1e2838;" - " border-radius:8px;" - "}")); - auto *root = new QVBoxLayout(this); root->setContentsMargins(0, 0, 0, 0); root->setSpacing(0); // Header - m_header = new UserInfoHeaderWidget(this); - root->addWidget(m_header); + header = new UserInfoHeaderWidget(this); + root->addWidget(header); - // Action area — rebuilt per user - m_actionArea = new QWidget(this); - m_actionArea->setStyleSheet(QStringLiteral("background:#0e1218;")); - root->addWidget(m_actionArea); + // Action area, rebuilt per user + actionArea = new QWidget(this); + root->addWidget(actionArea); // Thin separator - auto *sep = new QFrame(this); - sep->setFrameShape(QFrame::HLine); - sep->setStyleSheet(QStringLiteral("color:#1a2434; margin: 0 8px;")); - root->addWidget(sep); + separator = new QFrame(this); + separator->setFrameShape(QFrame::HLine); + root->addWidget(separator); // Games header row auto *gh = new QHBoxLayout; gh->setContentsMargins(10, 4, 8, 2); - auto *gl = new QLabel(tr("Games"), this); - gl->setStyleSheet(QStringLiteral("color:#6882a0; font-size:11px; font-weight:bold; background:transparent;")); - gh->addWidget(gl); + gamesLabel = new QLabel(tr("Games"), this); + gh->addWidget(gamesLabel); gh->addStretch(); - m_refreshBtn = new QPushButton(QStringLiteral("↻"), this); - m_refreshBtn->setFixedSize(20, 20); - m_refreshBtn->setFlat(true); - m_refreshBtn->setStyleSheet( - QStringLiteral("QPushButton{color:#6882a0;border:none;font-size:14px;background:transparent;}" - "QPushButton:hover{color:white;}")); - connect(m_refreshBtn, &QPushButton::clicked, this, &UserInfoPopup::refreshGames); - gh->addWidget(m_refreshBtn); + refreshBtn = new QPushButton(QStringLiteral("↻"), this); + refreshBtn->setFixedSize(20, 20); + refreshBtn->setFlat(true); + connect(refreshBtn, &QPushButton::clicked, this, &UserInfoPopup::refreshGames); + gh->addWidget(refreshBtn); root->addLayout(gh); // Status label - m_gamesStatus = new QLabel(this); - m_gamesStatus->setAlignment(Qt::AlignCenter); - m_gamesStatus->setStyleSheet( - QStringLiteral("color:#3a4a5e; font-size:11px; padding:10px; background:transparent;")); - root->addWidget(m_gamesStatus); + gamesStatus = new QLabel(this); + gamesStatus->setAlignment(Qt::AlignCenter); + root->addWidget(gamesStatus); // Games list - m_gamesModel = new QStandardItemModel(this); - m_gamesView = new QListView(this); - m_gamesView->setModel(m_gamesModel); - m_gamesView->setItemDelegate(new PopupGameDelegate(m_gamesView)); - m_gamesView->setFrameShape(QFrame::NoFrame); - m_gamesView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - m_gamesView->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); - m_gamesView->setMaximumHeight(220); - m_gamesView->setStyleSheet(QStringLiteral("QListView{background:#0e1218;border:none;}" - "QListView::item:selected{background:#232e42;}")); - m_gamesView->setContextMenuPolicy(Qt::CustomContextMenu); - connect(m_gamesView, &QListView::customContextMenuRequested, this, &UserInfoPopup::onGamesContextMenu); + gamesModel = new QStandardItemModel(this); + gamesView = new QListView(this); + gamesView->setModel(gamesModel); + gamesView->setItemDelegate(new PopupGameDelegate(gamesView)); + gamesView->setFrameShape(QFrame::NoFrame); + gamesView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + gamesView->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); + gamesView->setMaximumHeight(220); + gamesView->setContextMenuPolicy(Qt::CustomContextMenu); + connect(gamesView, &QListView::customContextMenuRequested, this, &UserInfoPopup::onGamesContextMenu); - root->addWidget(m_gamesView); + root->addWidget(gamesView); - // Close button — positioned absolutely in the top-right corner - m_closeBtn = new QPushButton(QStringLiteral("✕"), this); - m_closeBtn->setFixedSize(22, 22); - m_closeBtn->setFlat(true); - m_closeBtn->setStyleSheet(QStringLiteral("QPushButton{background:rgba(14,18,26,180);color:#607080;" - "border:none;border-radius:11px;font-size:10px;}" - "QPushButton:hover{color:white;background:rgba(200,50,50,200);}")); - connect(m_closeBtn, &QPushButton::clicked, this, &UserInfoPopup::closeRequested); + // Close button, positioned absolutely in the top right corner + closeBtn = new QPushButton(QStringLiteral("✕"), this); + closeBtn->setFixedSize(22, 22); + closeBtn->setFlat(true); + connect(closeBtn, &QPushButton::clicked, this, &UserInfoPopup::closeRequested); + + applyTheme(); +} + +void UserInfoPopup::applyTheme() +{ + const bool dark = themeManager && themeManager->isDarkModeActive(); + theme = PopupTheme::fromPalette(qApp->palette(), dark); + + setStyleSheet(QStringLiteral("UserInfoPopup {" + " background:%1;" + " border:1px solid %2;" + " border-radius:8px;" + "}") + .arg(colorStr(theme.bg), colorStr(theme.border))); + + actionArea->setStyleSheet(QStringLiteral("background:%1;").arg(colorStr(theme.bg))); + + separator->setStyleSheet(QStringLiteral("color:%1; margin: 0 8px;").arg(colorStr(theme.border))); + + gamesLabel->setStyleSheet(QStringLiteral("color:%1; font-size:11px; font-weight:bold; background:transparent;") + .arg(colorStr(theme.subText))); + + refreshBtn->setStyleSheet(QStringLiteral("QPushButton{color:%1;border:none;font-size:14px;background:transparent;}" + "QPushButton:hover{color:%2;}") + .arg(colorStr(theme.subText), colorStr(theme.text))); + + gamesStatus->setStyleSheet(QStringLiteral("color:%1; font-size:11px; padding:10px; background:transparent;") + .arg(colorStr(theme.statusText))); + + gamesView->setStyleSheet(QStringLiteral("QListView{background:%1;border:none;}" + "QListView::item:selected{background:%2;}") + .arg(colorStr(theme.gamesRow), colorStr(theme.gamesSelected))); + + closeBtn->setStyleSheet( + QStringLiteral("QPushButton{background:%1;color:%2;" + "border:none;border-radius:11px;font-size:10px;}" + "QPushButton:hover{color:%3;background:%4;}") + .arg(colorStr(theme.closeBg), colorStr(theme.subText), colorStr(theme.text), colorStr(theme.closeHover))); + + header->update(); } // ── Action button factory ───────────────────────────────────────────────────── -static QPushButton *makeBtn(const QString &label, const QString &tip, QWidget *p) +static QPushButton *makeBtn(const QString &label, const QString &tip, QWidget *p, const PopupTheme &t) { auto *b = new QPushButton(label, p); b->setToolTip(tip); b->setFixedHeight(26); b->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); b->setStyleSheet(QStringLiteral("QPushButton{" - " background:#192030;color:#b8c8de;border:1px solid #263040;" + " background:%1;color:%2;border:1px solid %3;" " border-radius:4px;font-size:11px;padding:0 4px;" "}" - "QPushButton:hover{background:#223050;color:white;}" - "QPushButton:pressed{background:#162030;}" - "QPushButton:disabled{color:#384858;border-color:#192030;}")); + "QPushButton:hover{background:%4;color:%5;}" + "QPushButton:pressed{background:%6;}" + "QPushButton:disabled{color:%7;border-color:%3;}") + .arg(colorStr(t.buttonBg), colorStr(t.text), colorStr(t.buttonBorder), colorStr(t.buttonHover), + colorStr(t.text), colorStr(t.buttonPressed), colorStr(t.buttonDisabled))); return b; } void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored) { // Clear previous contents - delete m_actionArea->layout(); - const auto old = m_actionArea->findChildren(QString{}, Qt::FindDirectChildrenOnly); + delete actionArea->layout(); + const auto old = actionArea->findChildren(QString{}, Qt::FindDirectChildrenOnly); for (auto *w : old) { w->deleteLater(); } const QString name = QString::fromStdString(userInfo.name()); - const auto ownLevel = UserLevelFlags(m_ts->getUserInfo()->user_level()); - const bool isSelf = (name == QString::fromStdString(m_ts->getUserInfo()->name())); + const auto ownLevel = UserLevelFlags(ts->getUserInfo()->user_level()); + const bool isSelf = (name == QString::fromStdString(ts->getUserInfo()->name())); const bool isMod = ownLevel.testFlag(ServerInfo_User::IsModerator); const bool isAdmin = ownLevel.testFlag(ServerInfo_User::IsAdmin); const auto their = UserLevelFlags(userInfo.user_level()); const bool isReg = their.testFlag(ServerInfo_User::IsRegistered); - auto *grid = new QGridLayout(m_actionArea); + auto *grid = new QGridLayout(actionArea); grid->setContentsMargins(8, 6, 8, 6); grid->setSpacing(4); @@ -394,37 +551,44 @@ void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool o }; // ── Always visible ──────────────────────────────────────────────────────── - auto *chat = makeBtn(tr("Chat"), tr("Open private chat"), m_actionArea); + auto *chat = makeBtn(tr("Chat"), tr("Open private chat"), actionArea, theme); chat->setEnabled(!isSelf && online); connect(chat, &QPushButton::clicked, this, [this, name] { emit chatRequested(name); }); add(chat); - auto *prof = makeBtn(tr("Profile"), tr("View user profile"), m_actionArea); + auto *prof = makeBtn(tr("Profile"), tr("View user profile"), actionArea, theme); connect(prof, &QPushButton::clicked, this, [this, name] { emit detailsRequested(name); }); add(prof); - auto *games = makeBtn(tr("Games"), tr("Show this user's games"), m_actionArea); + auto *games = makeBtn(tr("Games"), tr("Show this user's games"), actionArea, theme); games->setEnabled(!isSelf && online); connect(games, &QPushButton::clicked, this, [this, name] { emit showGamesRequested(name); }); add(games); + // ── Invite (only while the inviter has a joinable game for this user) ──── + if (!isSelf && online && gameInviteAvailable && gameInviteAvailable(name)) { + auto *invite = makeBtn(tr("Invite"), tr("Invite to your game"), actionArea, theme); + connect(invite, &QPushButton::clicked, this, [this, name] { emit inviteRequested(name); }); + add(invite); + } + // ── Buddy / ignore (registered users only) ──────────────────────────────── if (!isSelf && isReg) { if (isBuddy) { - auto *b = makeBtn(tr("− Buddy"), tr("Remove from buddy list"), m_actionArea); + auto *b = makeBtn(tr("− Buddy"), tr("Remove from buddy list"), actionArea, theme); connect(b, &QPushButton::clicked, this, [this, name] { emit removeBuddyRequested(name); }); add(b); } else { - auto *b = makeBtn(tr("+ Buddy"), tr("Add to buddy list"), m_actionArea); + auto *b = makeBtn(tr("+ Buddy"), tr("Add to buddy list"), actionArea, theme); connect(b, &QPushButton::clicked, this, [this, name] { emit addBuddyRequested(name); }); add(b); } if (isIgnored) { - auto *b = makeBtn(tr("− Ignore"), tr("Remove from ignore list"), m_actionArea); + auto *b = makeBtn(tr("− Ignore"), tr("Remove from ignore list"), actionArea, theme); connect(b, &QPushButton::clicked, this, [this, name] { emit removeIgnoreRequested(name); }); add(b); } else { - auto *b = makeBtn(tr("+ Ignore"), tr("Add to ignore list"), m_actionArea); + auto *b = makeBtn(tr("+ Ignore"), tr("Add to ignore list"), actionArea, theme); connect(b, &QPushButton::clicked, this, [this, name] { emit addIgnoreRequested(name); }); add(b); } @@ -437,10 +601,10 @@ void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool o col = 0; } // start mod section on a fresh row - auto *ban = makeBtn(tr("Ban"), tr("Ban from server"), m_actionArea); - auto *warn = makeBtn(tr("Warn"), tr("Warn user"), m_actionArea); - auto *bLog = makeBtn(tr("Ban log"), tr("View ban history"), m_actionArea); - auto *wLog = makeBtn(tr("Warn log"), tr("View warning history"), m_actionArea); + auto *ban = makeBtn(tr("Ban"), tr("Ban from server"), actionArea, theme); + auto *warn = makeBtn(tr("Warn"), tr("Warn user"), actionArea, theme); + auto *bLog = makeBtn(tr("Ban log"), tr("View ban history"), actionArea, theme); + auto *wLog = makeBtn(tr("Warn log"), tr("View warning history"), actionArea, theme); connect(ban, &QPushButton::clicked, this, [this, name] { emit banRequested(name); }); connect(warn, &QPushButton::clicked, this, [this, name] { emit warnRequested(name); }); connect(bLog, &QPushButton::clicked, this, [this, name] { emit banHistoryRequested(name); }); @@ -453,31 +617,31 @@ void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool o // ── Admin actions ───────────────────────────────────────────────────────── if (!isSelf && isAdmin) { - auto *notes = makeBtn(tr("Notes"), tr("View admin notes"), m_actionArea); + auto *notes = makeBtn(tr("Notes"), tr("View admin notes"), actionArea, theme); connect(notes, &QPushButton::clicked, this, [this, name] { emit adminNotesRequested(name); }); add(notes); if (their.testFlag(ServerInfo_User::IsModerator)) { - auto *b = makeBtn(tr("− Mod"), tr("Demote from moderator"), m_actionArea); + auto *b = makeBtn(tr("− Mod"), tr("Demote from moderator"), actionArea, theme); connect(b, &QPushButton::clicked, this, [this, name] { emit demoteFromModRequested(name); }); add(b); } else if (isReg) { - auto *b = makeBtn(tr("+ Mod"), tr("Promote to moderator"), m_actionArea); + auto *b = makeBtn(tr("+ Mod"), tr("Promote to moderator"), actionArea, theme); connect(b, &QPushButton::clicked, this, [this, name] { emit promoteToModRequested(name); }); add(b); } if (their.testFlag(ServerInfo_User::IsJudge)) { - auto *b = makeBtn(tr("− Judge"), tr("Demote from judge"), m_actionArea); + auto *b = makeBtn(tr("− Judge"), tr("Demote from judge"), actionArea, theme); connect(b, &QPushButton::clicked, this, [this, name] { emit demoteFromJudgeRequested(name); }); add(b); } else if (isReg) { - auto *b = makeBtn(tr("+ Judge"), tr("Promote to judge"), m_actionArea); + auto *b = makeBtn(tr("+ Judge"), tr("Promote to judge"), actionArea, theme); connect(b, &QPushButton::clicked, this, [this, name] { emit promoteToJudgeRequested(name); }); add(b); } } - m_actionArea->adjustSize(); + actionArea->adjustSize(); } void UserInfoPopup::updateActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored) @@ -488,7 +652,7 @@ void UserInfoPopup::updateActionButtons(const ServerInfo_User &userInfo, bool on void UserInfoPopup::onGamesContextMenu(const QPoint &pos) { - const QModelIndex idx = m_gamesView->indexAt(pos); + const QModelIndex idx = gamesView->indexAt(pos); if (!idx.isValid()) { return; } @@ -501,19 +665,21 @@ void UserInfoPopup::onGamesContextMenu(const QPoint &pos) QMenu menu(this); menu.setStyleSheet( - QStringLiteral("QMenu{background:#12182a;color:#c8d8ec;border:1px solid #1e2838;border-radius:4px;}" - "QMenu::item:selected{background:#223050;}")); + QStringLiteral("QMenu{background:%1;color:%2;border:1px solid %3;border-radius:4px;}" + "QMenu::item:selected{background:%4;}") + .arg(colorStr(theme.bg), colorStr(theme.text), colorStr(theme.border), colorStr(theme.buttonHover))); - const bool canJoin = !game.started() && game.player_count() < game.max_players(); + // Never grey the join action out: GameSelector::joinGame() asks whether to + // fall back to spectating when the game is full or already running, so a + // disabled entry would just read as a dead button. QAction *join = menu.addAction(tr("Join game")); - join->setEnabled(canJoin); QAction *spec = nullptr; if (game.spectators_allowed()) { spec = menu.addAction(tr("Spectate")); } - const QAction *chosen = menu.exec(m_gamesView->viewport()->mapToGlobal(pos)); + const QAction *chosen = menu.exec(gamesView->viewport()->mapToGlobal(pos)); if (!chosen) { return; } @@ -527,37 +693,46 @@ void UserInfoPopup::onGamesContextMenu(const QPoint &pos) // ── showForUser ─────────────────────────────────────────────────────────────── +void UserInfoPopup::refreshHeader() +{ + if (currentUser.isEmpty()) { + return; + } + + const QPixmap avatar = avatarCache ? avatarCache->value(currentUser) : QPixmap{}; + const CardArtParams params = (cardArtParamsMap && cardArtParamsMap->contains(currentUser)) + ? cardArtParamsMap->value(currentUser) + : CardArtParams{}; + const QString artKey = currentUser + u'|' + params.cardName + u'|' + params.cardProviderId; + const QPixmap cardArt = (cardArtCache && !params.cardName.isEmpty()) ? cardArtCache->value(artKey) : QPixmap{}; + header->setUserData(currentUserInfo, currentOnline, avatar, cardArt, params); +} + void UserInfoPopup::showForUser(const QString &userName, const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored) { - m_currentUser = userName; - m_currentUserInfo = userInfo; - m_currentOnline = online; + currentUser = userName; + currentUserInfo = userInfo; + currentOnline = online; // Header - const QPixmap avatar = m_avatarCache ? m_avatarCache->value(userName) : QPixmap{}; - const CardArtParams params = (m_cardArtParamsMap && m_cardArtParamsMap->contains(userName)) - ? m_cardArtParamsMap->value(userName) - : CardArtParams{}; - const QString artKey = userName + u'|' + params.cardName + u'|' + params.cardProviderId; - const QPixmap cardArt = (m_cardArtCache && !params.cardName.isEmpty()) ? m_cardArtCache->value(artKey) : QPixmap{}; - m_header->setUserData(userInfo, online, avatar, cardArt, params); + refreshHeader(); // Actions rebuildActionButtons(userInfo, online, isBuddy, isIgnored); // Games list reset - m_gamesModel->clear(); - m_gamesView->hide(); - m_gamesStatus->setText(tr("Loading games…")); - m_gamesStatus->show(); + gamesModel->clear(); + gamesView->hide(); + gamesStatus->setText(tr("Loading games…")); + gamesStatus->show(); - // Close button — top-right corner, above everything - m_closeBtn->move(PopupWidth - m_closeBtn->width() - 6, 6); - m_closeBtn->raise(); + // Close button, top right corner, above everything + closeBtn->move(PopupWidth - closeBtn->width() - 6, 6); + closeBtn->raise(); adjustSize(); fetchGames(); @@ -567,40 +742,40 @@ void UserInfoPopup::showForUser(const QString &userName, void UserInfoPopup::fetchGames() { - if (!m_client || m_currentUser.isEmpty()) { + if (!client || currentUser.isEmpty()) { return; } Command_GetGamesOfUser cmd; - cmd.set_user_name(m_currentUser.toStdString()); + cmd.set_user_name(currentUser.toStdString()); - const QString snapshot = m_currentUser; - PendingCommand *pend = m_client->prepareSessionCommand(cmd); + const QString snapshot = currentUser; + PendingCommand *pend = client->prepareSessionCommand(cmd); connect(pend, &PendingCommand::finished, this, [this, snapshot](const Response &r) { onGamesReceived(r, snapshot); }); - m_client->sendCommand(pend); + client->sendCommand(pend); } void UserInfoPopup::onGamesReceived(const Response &r, const QString &forUser) { - if (forUser != m_currentUser) { - return; // stale response — different user showing now + if (forUser != currentUser) { + return; // stale response, different user showing now } - m_gamesModel->clear(); + gamesModel->clear(); if (r.response_code() != Response::RespOk) { - m_gamesStatus->setText(tr("Could not load games.")); - m_gamesStatus->show(); - m_gamesView->hide(); + gamesStatus->setText(tr("Could not load games.")); + gamesStatus->show(); + gamesView->hide(); return; } const auto &resp = r.GetExtension(Response_GetGamesOfUser::ext); if (resp.game_list_size() == 0) { - m_gamesStatus->setText(tr("No active games.")); - m_gamesStatus->show(); - m_gamesView->hide(); + gamesStatus->setText(tr("No active games.")); + gamesStatus->show(); + gamesView->hide(); return; } @@ -608,29 +783,29 @@ void UserInfoPopup::onGamesReceived(const Response &r, const QString &forUser) auto *item = new QStandardItem; item->setData(QVariant::fromValue(resp.game_list(i)), PopupRoles::GameData); item->setEditable(false); - m_gamesModel->appendRow(item); + gamesModel->appendRow(item); } - m_gamesStatus->hide(); - m_gamesView->show(); + gamesStatus->hide(); + gamesView->show(); // Fit exactly to the number of visible rows, scroll when more than 5 constexpr int rowH = 38; // must match PopupGameDelegate::sizeHint constexpr int maxRows = 5; - const int count = m_gamesModel->rowCount(); + const int count = gamesModel->rowCount(); const int visible = qMin(count, maxRows); - m_gamesView->setFixedHeight(visible * rowH + 2); - m_gamesView->setVerticalScrollBarPolicy(count > maxRows ? Qt::ScrollBarAlwaysOn : Qt::ScrollBarAlwaysOff); + gamesView->setFixedHeight(visible * rowH + 2); + gamesView->setVerticalScrollBarPolicy(count > maxRows ? Qt::ScrollBarAlwaysOn : Qt::ScrollBarAlwaysOff); adjustSize(); } void UserInfoPopup::refreshGames() { - m_gamesModel->clear(); - m_gamesView->hide(); - m_gamesStatus->setText(tr("Loading games…")); - m_gamesStatus->show(); + gamesModel->clear(); + gamesView->hide(); + gamesStatus->setText(tr("Loading games…")); + gamesStatus->show(); fetchGames(); } @@ -645,4 +820,4 @@ void UserInfoPopup::leaveEvent(QEvent *e) { QFrame::leaveEvent(e); emit mouseLeftPopup(); -} \ No newline at end of file +} diff --git a/cockatrice/src/interface/widgets/server/user/user_info_popup.h b/cockatrice/src/interface/widgets/server/user/user_info_popup.h index 69517093f..f2a91d025 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_popup.h +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.h @@ -1,23 +1,29 @@ #ifndef COCKATRICE_USER_INFO_POPUP_H #define COCKATRICE_USER_INFO_POPUP_H -#include "../../interface/widgets/server/game_type_map.h" #include "user_list_painter.h" +#include #include -#include -#include +#include #include -#include -#include -#include -#include +#include +#include +#include #include +#include +#include +#include class AbstractClient; class QLabel; class QPushButton; class TabSupervisor; +class QListView; +class QPoint; +class QStandardItemModel; +class Response; +template class QMap; // ── Roles ───────────────────────────────────────────────────────────────────── @@ -26,6 +32,35 @@ namespace PopupRoles constexpr int GameData = Qt::UserRole + 10; } +// Popup theme + +/** + * Palette-derived colors for the popup chrome. Both color schemes read from + * the active QPalette so custom palettes are respected. @c dark only tunes the + * blend strengths. + */ +struct PopupTheme +{ + bool dark = false; + QColor bg; + QColor border; + QColor text; + QColor subText; + QColor buttonBg; + QColor buttonBorder; + QColor buttonHover; + QColor buttonPressed; + QColor buttonDisabled; + QColor closeBg; + QColor closeHover; + QColor gamesRow; + QColor gamesSelected; + QColor gamesSeparator; + QColor statusText; + + static PopupTheme fromPalette(const QPalette &palette, bool dark); +}; + // ── Header widget ───────────────────────────────────────────────────────────── /** @@ -51,21 +86,22 @@ class UserInfoHeaderWidget : public QWidget public: explicit UserInfoHeaderWidget(QWidget *parent = nullptr); - void setUserData(const ServerInfo_User &user, - bool online, - const QPixmap &avatar, - const QPixmap &cardArt, - const CardArtParams ¶ms); + void setUserData(const ServerInfo_User &_user, + bool _online, + const QPixmap &_avatar, + const QPixmap &_cardArt, + const CardArtParams &_params); protected: void paintEvent(QPaintEvent *e) override; private: - ServerInfo_User m_user; - bool m_online = false; - QPixmap m_avatar; - QPixmap m_cardArt; - CardArtParams m_params; + ServerInfo_User user; + bool online = false; + QPixmap avatar; + QPixmap cardArt; + CardArtParams params; + QString attribution; }; // ── Main popup ──────────────────────────────────────────────────────────────── @@ -93,11 +129,11 @@ class UserInfoPopup : public QFrame static constexpr int PopupWidth = 316; public: - explicit UserInfoPopup(TabSupervisor *tabSupervisor, - AbstractClient *client, - const QMap *avatarCache, - const QMap *cardArtCache, - const QMap *cardArtParamsMap, + explicit UserInfoPopup(TabSupervisor *_ts, + AbstractClient *_client, + const QMap *_avatarCache, + const QMap *_cardArtCache, + const QMap *_cardArtParamsMap, QWidget *parent); /** @@ -108,14 +144,28 @@ public: showForUser(const QString &userName, const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored); void fetchGames(); - [[nodiscard]] QString currentUser() const + [[nodiscard]] QString getCurrentUser() const { - return m_currentUser; + return currentUser; } /** Called when buddy/ignore status changes externally while popup is open. */ void updateActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored); + /** Re-pulls the avatar/card art for the currently shown user (e.g. after it loads). */ + void refreshHeader(); + + /** + * Sets a predicate evaluated on every action-button rebuild. It receives + * the name of the user the popup currently shows; when it returns true an + * "Invite" button is shown. The popup itself never resolves the invite + * link, it just forwards the request. + */ + void setGameInviteAvailable(std::function available) + { + gameInviteAvailable = std::move(available); + } + signals: void mouseEnteredPopup(); void mouseLeftPopup(); @@ -126,6 +176,7 @@ signals: // ── Action signals — connect to UserContextMenu::exec*() ────────────────── void chatRequested(const QString &userName); + void inviteRequested(const QString &userName); void detailsRequested(const QString &userName); void showGamesRequested(const QString &userName); void addBuddyRequested(const QString &userName); @@ -153,25 +204,31 @@ private slots: private: void buildUi(); + void applyTheme(); void rebuildActionButtons(const ServerInfo_User &userInfo, bool online, bool isBuddy, bool isIgnored); - TabSupervisor *m_ts; - AbstractClient *m_client; - const QMap *m_avatarCache; - const QMap *m_cardArtCache; - const QMap *m_cardArtParamsMap; + TabSupervisor *ts; + AbstractClient *client; + const QMap *avatarCache; + const QMap *cardArtCache; + const QMap *cardArtParamsMap; - QString m_currentUser; - ServerInfo_User m_currentUserInfo; - bool m_currentOnline = false; + PopupTheme theme; - UserInfoHeaderWidget *m_header; - QWidget *m_actionArea; ///< rebuilt per user - QListView *m_gamesView; - QStandardItemModel *m_gamesModel; - QLabel *m_gamesStatus; - QPushButton *m_closeBtn; - QPushButton *m_refreshBtn; + QString currentUser; + ServerInfo_User currentUserInfo; + bool currentOnline = false; + std::function gameInviteAvailable; + + UserInfoHeaderWidget *header; + QWidget *actionArea; ///< rebuilt per user + QLabel *gamesLabel; + QFrame *separator; + QListView *gamesView; + QStandardItemModel *gamesModel; + QLabel *gamesStatus; + QPushButton *closeBtn; + QPushButton *refreshBtn; }; #endif // COCKATRICE_USER_INFO_POPUP_H \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_list_dialog.cpp b/cockatrice/src/interface/widgets/server/user/user_list_dialog.cpp new file mode 100644 index 000000000..605c89734 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_list_dialog.cpp @@ -0,0 +1,311 @@ +#include "user_list_dialog.h" + +#include "libcockatrice/protocol/pb/serverinfo_user.pb.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class QWidget; + +BanDialog::BanDialog(const ServerInfo_User &info, QWidget *parent) : QDialog(parent) +{ + setAttribute(Qt::WA_DeleteOnClose); + + nameBanCheckBox = new QCheckBox(tr("ban &user name")); + nameBanCheckBox->setChecked(true); + nameBanEdit = new QLineEdit(QString::fromStdString(info.name())); + nameBanEdit->setMaxLength(MAX_NAME_LENGTH); + ipBanCheckBox = new QCheckBox(tr("ban &IP address")); + ipBanCheckBox->setChecked(true); + ipBanEdit = new QLineEdit(QString::fromStdString(info.address())); + ipBanEdit->setMaxLength(MAX_NAME_LENGTH); + idBanCheckBox = new QCheckBox(tr("ban client I&D")); + idBanCheckBox->setChecked(true); + idBanEdit = new QLineEdit(QString::fromStdString(info.clientid())); + idBanEdit->setMaxLength(MAX_NAME_LENGTH); + if (QString::fromStdString(info.clientid()).isEmpty()) { + idBanCheckBox->setChecked(false); + } + + QGridLayout *banTypeGrid = new QGridLayout; + banTypeGrid->addWidget(nameBanCheckBox, 0, 0); + banTypeGrid->addWidget(nameBanEdit, 0, 1); + banTypeGrid->addWidget(ipBanCheckBox, 1, 0); + banTypeGrid->addWidget(ipBanEdit, 1, 1); + banTypeGrid->addWidget(idBanCheckBox, 2, 0); + banTypeGrid->addWidget(idBanEdit, 2, 1); + QGroupBox *banTypeGroupBox = new QGroupBox(tr("Ban type")); + banTypeGroupBox->setLayout(banTypeGrid); + + permanentRadio = new QRadioButton(tr("&permanent ban")); + temporaryRadio = new QRadioButton(tr("&temporary ban")); + temporaryRadio->setChecked(true); + connect(temporaryRadio, &QRadioButton::toggled, this, &BanDialog::enableTemporaryEdits); + daysLabel = new QLabel(tr("&Days:")); + daysEdit = new QSpinBox; + daysEdit->setMinimum(0); + daysEdit->setValue(0); + daysEdit->setMaximum(10000); + daysLabel->setBuddy(daysEdit); + hoursLabel = new QLabel(tr("&Hours:")); + hoursEdit = new QSpinBox; + hoursEdit->setMinimum(0); + hoursEdit->setValue(0); + hoursEdit->setMaximum(24); + hoursLabel->setBuddy(hoursEdit); + minutesLabel = new QLabel(tr("&Minutes:")); + minutesEdit = new QSpinBox; + minutesEdit->setMinimum(0); + minutesEdit->setValue(5); + minutesEdit->setMaximum(60); + minutesLabel->setBuddy(minutesEdit); + QGridLayout *durationLayout = new QGridLayout; + durationLayout->addWidget(permanentRadio, 0, 0, 1, 6); + durationLayout->addWidget(temporaryRadio, 1, 0, 1, 6); + durationLayout->addWidget(daysLabel, 2, 0); + durationLayout->addWidget(daysEdit, 2, 1); + durationLayout->addWidget(hoursLabel, 2, 2); + durationLayout->addWidget(hoursEdit, 2, 3); + durationLayout->addWidget(minutesLabel, 2, 4); + durationLayout->addWidget(minutesEdit, 2, 5); + QGroupBox *durationGroupBox = new QGroupBox(tr("Duration of the ban")); + durationGroupBox->setLayout(durationLayout); + + QLabel *reasonLabel = new QLabel(tr("Please enter the reason for the ban.\n" + "This is only saved for moderators and cannot be seen by the banned person.")); + reasonEdit = new QPlainTextEdit; + + QLabel *visibleReasonLabel = + new QLabel(tr("Please enter the reason for the ban that will be visible to the banned person.")); + visibleReasonEdit = new QPlainTextEdit; + + deleteMessages = new QCheckBox(tr("Redact all messages from this user in all rooms")); + + QPushButton *okButton = new QPushButton(tr("&OK")); + okButton->setAutoDefault(true); + connect(okButton, &QPushButton::clicked, this, &BanDialog::okClicked); + QPushButton *cancelButton = new QPushButton(tr("&Cancel")); + connect(cancelButton, &QPushButton::clicked, this, &BanDialog::reject); + + QHBoxLayout *buttonLayout = new QHBoxLayout; + buttonLayout->addStretch(); + buttonLayout->addWidget(okButton); + buttonLayout->addWidget(cancelButton); + + QVBoxLayout *vbox = new QVBoxLayout; + vbox->addWidget(banTypeGroupBox); + vbox->addWidget(durationGroupBox); + vbox->addWidget(reasonLabel); + vbox->addWidget(reasonEdit); + vbox->addWidget(visibleReasonLabel); + vbox->addWidget(visibleReasonEdit); + vbox->addWidget(deleteMessages); + vbox->addLayout(buttonLayout); + + setLayout(vbox); + setWindowTitle(tr("Ban user from server")); +} + +WarningDialog::WarningDialog(const QString &userName, const QString &clientID, QWidget *parent) : QDialog(parent) +{ + setAttribute(Qt::WA_DeleteOnClose); + descriptionLabel = new QLabel(tr("Which warning would you like to send?")); + nameWarning = new QLineEdit(userName); + nameWarning->setMaxLength(MAX_NAME_LENGTH); + warnClientID = new QLineEdit(clientID); + warnClientID->setMaxLength(MAX_NAME_LENGTH); + warningOption = new QComboBox(); + warningOption->addItem("", ""); + + deleteMessages = new QCheckBox(tr("Redact all messages from this user in all rooms")); + + QPushButton *okButton = new QPushButton(tr("&OK")); + okButton->setAutoDefault(true); + connect(okButton, &QPushButton::clicked, this, &WarningDialog::okClicked); + QPushButton *cancelButton = new QPushButton(tr("&Cancel")); + connect(cancelButton, &QPushButton::clicked, this, &WarningDialog::reject); + + QHBoxLayout *buttonLayout = new QHBoxLayout; + buttonLayout->addStretch(); + buttonLayout->addWidget(okButton); + buttonLayout->addWidget(cancelButton); + + QVBoxLayout *vbox = new QVBoxLayout; + vbox->addWidget(descriptionLabel); + vbox->addWidget(nameWarning); + vbox->addWidget(warningOption); + vbox->addWidget(deleteMessages); + vbox->addLayout(buttonLayout); + setLayout(vbox); + setWindowTitle(tr("Warn user for misconduct")); +} + +void WarningDialog::okClicked() +{ + if (nameWarning->text().simplified().isEmpty()) { + QMessageBox::critical(this, tr("Error"), + tr("User name to send a warning to can not be blank, please specify a user to warn.")); + return; + } + + if (warningOption->currentData().toString().simplified().isEmpty()) { + QMessageBox::critical(this, tr("Error"), + tr("Warning to use can not be blank, please select a valid warning to send.")); + return; + } + + accept(); +} + +QString WarningDialog::getName() const +{ + return nameWarning->text().simplified(); +} + +QString WarningDialog::getWarnID() const +{ + return warnClientID->text().simplified(); +} + +QString WarningDialog::getReason() const +{ + return warningOption->currentData().toString().simplified(); +} + +int WarningDialog::getDeleteMessages() const +{ + return deleteMessages->isChecked() ? -1 : 0; +} + +void WarningDialog::addWarningOption(const QString &warning, int startingIl) +{ + if (startingIl > 1) { + warningOption->addItem(tr("%1 (IL %2)").arg(warning).arg(startingIl), warning); + } else { + warningOption->addItem(warning, warning); + } +} + +void BanDialog::okClicked() +{ + if (!nameBanCheckBox->isChecked() && !ipBanCheckBox->isChecked() && !idBanCheckBox->isChecked()) { + QMessageBox::critical(this, tr("Error"), + tr("You have to select a name-based, IP-based, clientId based, or some combination of " + "the three to place a ban.")); + return; + } + + if (nameBanCheckBox->isChecked()) { + if (nameBanEdit->text().simplified() == "") { + QMessageBox::critical(this, tr("Error"), + tr("You must have a value in the name ban when selecting the name ban checkbox.")); + return; + } + } + + if (ipBanCheckBox->isChecked()) { + if (ipBanEdit->text().simplified() == "") { + QMessageBox::critical(this, tr("Error"), + tr("You must have a value in the ip ban when selecting the ip ban checkbox.")); + return; + } + } + + if (idBanCheckBox->isChecked()) { + if (idBanEdit->text().simplified() == "") { + QMessageBox::critical( + this, tr("Error"), + tr("You must have a value in the clientid ban when selecting the clientid ban checkbox.")); + return; + } + } + + accept(); +} + +void BanDialog::enableTemporaryEdits(bool enabled) +{ + daysLabel->setEnabled(enabled); + daysEdit->setEnabled(enabled); + hoursLabel->setEnabled(enabled); + hoursEdit->setEnabled(enabled); + minutesLabel->setEnabled(enabled); + minutesEdit->setEnabled(enabled); +} + +QString BanDialog::getBanId() const +{ + return idBanCheckBox->isChecked() ? idBanEdit->text() : QString(); +} + +QString BanDialog::getBanName() const +{ + return nameBanCheckBox->isChecked() ? nameBanEdit->text() : QString(); +} + +QString BanDialog::getBanIP() const +{ + return ipBanCheckBox->isChecked() ? ipBanEdit->text() : QString(); +} + +int BanDialog::getMinutes() const +{ + return permanentRadio->isChecked() ? 0 + : (daysEdit->value() * 24 * 60 + hoursEdit->value() * 60 + minutesEdit->value()); +} + +QString BanDialog::getReason() const +{ + return reasonEdit->toPlainText(); +} + +QString BanDialog::getVisibleReason() const +{ + return visibleReasonEdit->toPlainText(); +} + +int BanDialog::getDeleteMessages() const +{ + return deleteMessages->isChecked() ? -1 : 0; +} + +AdminNotesDialog::AdminNotesDialog(const QString &_userName, const QString &_notes, QWidget *_parent) + : QDialog(_parent), userName(_userName) +{ + setAttribute(Qt::WA_DeleteOnClose); + + auto *updateButton = new QPushButton(tr("Update Notes")); + updateButton->setEnabled(false); + connect(updateButton, &QPushButton::clicked, this, &AdminNotesDialog::accept); + + notes = new QPlainTextEdit(_notes); + notes->setMinimumWidth(500); + connect(notes, &QPlainTextEdit::textChanged, this, [=]() { updateButton->setEnabled(true); }); + + auto *vbox = new QVBoxLayout; + vbox->addWidget(notes); + vbox->addWidget(updateButton); + + setLayout(vbox); + setWindowTitle(tr("Admin Notes for %1").arg(_userName)); +} + +QString AdminNotesDialog::getNotes() const +{ + return notes->toPlainText(); +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/server/user/user_list_dialog.h b/cockatrice/src/interface/widgets/server/user/user_list_dialog.h new file mode 100644 index 000000000..e10fca5bb --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_list_dialog.h @@ -0,0 +1,82 @@ +#ifndef COCKATRICE_USER_LIST_DIALOG_H +#define COCKATRICE_USER_LIST_DIALOG_H + +#include +#include +#include + +class QComboBox; +class QLabel; +class QPlainTextEdit; +class QRadioButton; +class QSpinBox; +class QLineEdit; +class QCheckBox; +class QWidget; +class ServerInfo_User; + +class BanDialog : public QDialog +{ + Q_OBJECT + + QLabel *daysLabel, *hoursLabel, *minutesLabel; + QCheckBox *nameBanCheckBox, *ipBanCheckBox, *idBanCheckBox, *deleteMessages; + QLineEdit *nameBanEdit, *ipBanEdit, *idBanEdit; + QSpinBox *daysEdit, *hoursEdit, *minutesEdit; + QRadioButton *permanentRadio, *temporaryRadio; + QPlainTextEdit *reasonEdit, *visibleReasonEdit; + +private slots: + void okClicked(); + void enableTemporaryEdits(bool enabled); + +public: + explicit BanDialog(const ServerInfo_User &info, QWidget *parent = nullptr); + [[nodiscard]] QString getBanName() const; + [[nodiscard]] QString getBanIP() const; + [[nodiscard]] QString getBanId() const; + [[nodiscard]] int getMinutes() const; + [[nodiscard]] QString getReason() const; + [[nodiscard]] QString getVisibleReason() const; + [[nodiscard]] int getDeleteMessages() const; +}; + +class WarningDialog : public QDialog +{ + Q_OBJECT + + QLabel *descriptionLabel; + QLineEdit *nameWarning; + QComboBox *warningOption; + QLineEdit *warnClientID; + QCheckBox *deleteMessages; + +private slots: + void okClicked(); + +public: + WarningDialog(const QString &userName, const QString &clientID, QWidget *parent = nullptr); + [[nodiscard]] QString getName() const; + [[nodiscard]] QString getWarnID() const; + [[nodiscard]] QString getReason() const; + [[nodiscard]] int getDeleteMessages() const; + void addWarningOption(const QString &warning, int startingIl = 1); +}; + +class AdminNotesDialog : public QDialog +{ + Q_OBJECT + + QString userName; + QPlainTextEdit *notes; + +public: + explicit AdminNotesDialog(const QString &_userName, const QString &_notes, QWidget *_parent = nullptr); + [[nodiscard]] QString getName() const + { + return userName; + } + [[nodiscard]] QString getNotes() const; +}; + +#endif // COCKATRICE_USER_LIST_DIALOG_H diff --git a/cockatrice/src/interface/widgets/server/user/user_list_manager.cpp b/cockatrice/src/interface/widgets/server/user/user_list_manager.cpp index 216420006..385a969fd 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_manager.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_manager.cpp @@ -1,8 +1,11 @@ #include "user_list_manager.h" -#include "../../client/sound_engine.h" -#include "user_info_box.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_user.pb.h" +#include +#include +#include #include #include #include diff --git a/cockatrice/src/interface/widgets/server/user/user_list_manager.h b/cockatrice/src/interface/widgets/server/user/user_list_manager.h index 6238f0799..b2ff69286 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_manager.h +++ b/cockatrice/src/interface/widgets/server/user/user_list_manager.h @@ -10,18 +10,22 @@ #include "user_list_proxy.h" #include -#include +#include +#include +#include +// IWYU pragma: keep +// ServerInfo_User appears by value and inside QList in slot signatures, so the +// moc-generated code needs the complete type. #include class AbstractClient; class Event_AddToList; -class Event_ListRooms; class Event_RemoveFromList; class Event_UserJoined; class Event_UserLeft; class Response; class ServerInfo_User; -class TabSupervisor; +template class QList; class UserListManager : public QObject, public UserListProxy { diff --git a/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp b/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp index 8891ff268..08eaaaff7 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp @@ -1,13 +1,35 @@ #include "user_list_painter.h" #include "../../interface/pixel_map_generator.h" +#include "libcockatrice/protocol/pb/serverinfo_user.pb.h" #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include +#include +#include +#include #include +#include #include #include +#include +#include +#include +#include +#include static constexpr int RowHeight = 72; static constexpr int AvatarSize = 36; @@ -19,12 +41,37 @@ QSize UserListPainter::sizeHint() return QSize(0, RowHeight); } +UserListPainter::Style UserListPainter::resolveStyle(const QPalette &palette, bool dark) +{ + Style style; + style.dark = dark; + const QColor base = palette.color(QPalette::Base); + const QColor alt = palette.color(QPalette::AlternateBase); + style.cardStart = base; + style.cardEnd = (alt != base) ? alt : palette.color(QPalette::Midlight); + style.base = base; + style.textOnline = palette.color(QPalette::Text); + style.textOffline = palette.color(QPalette::Disabled, QPalette::Text); + style.ringOffline = palette.color(QPalette::Disabled, QPalette::Text); + style.dropShadow = dark; + return style; +} + +QColor UserListPainter::blend(const QColor &a, const QColor &b, qreal t) +{ + const qreal u = 1.0 - t; + return QColor(qRound(a.red() * u + b.red() * t), qRound(a.green() * u + b.green() * t), + qRound(a.blue() * u + b.blue() * t), qRound(a.alpha() * u + b.alpha() * t)); +} + QColor UserListPainter::getAccentColor(const UserLevelFlags &userLevel, bool online) { QColor accentColor; if (userLevel.testFlag(ServerInfo_User::IsAdmin)) { accentColor = QColor(245, 158, 11); + } else if (userLevel.testFlag(ServerInfo_User::IsDeveloper)) { + accentColor = QColor(185, 28, 28); } else if (userLevel.testFlag(ServerInfo_User::IsModerator)) { accentColor = QColor(59, 130, 246); } else if (userLevel.testFlag(ServerInfo_User::IsJudge)) { @@ -59,16 +106,37 @@ int UserListPainter::getCardRight(const QStyleOptionViewItem &option, const QRec void UserListPainter::drawBackground(QPainter *painter, const QRectF &cardRect, const QColor &accentColor, - bool selected) + bool selected, + const Style &style, + bool hasRole) { QLinearGradient bg(cardRect.topLeft(), cardRect.topRight()); - bg.setColorAt(0, selected ? accentColor.darker(130) : accentColor.darker(320)); - bg.setColorAt(1, selected ? QColor(40, 48, 60) : QColor(18, 22, 30)); + if (style.dark) { + // Dark mode darkens the role color to fit the dark surface and fades + // it into the deep navy surface on the right. The text drop shadow + // keeps the username legible over the colored edge. + bg.setColorAt(0, selected ? accentColor.darker(130) : accentColor.darker(320)); + bg.setColorAt(1, selected ? QColor(40, 48, 60) : QColor(18, 22, 30)); + } else if (hasRole) { + // Light mode pegs the role color on the left at near full strength + // and fades it into the white surface on the right. The tint stays + // bright enough that the dark text remains legible without a shadow. + bg.setColorAt(0, blend(style.cardStart, accentColor, selected ? 0.75 : 0.65)); + bg.setColorAt(1, blend(style.cardEnd, accentColor, selected ? 0.18 : 0.10)); + } else { + // Regular users keep a scaled-down accent tint so the banner card art + // stays legible over a colored backdrop (the pre-branch painter was + // always dark-styled) while the role hierarchy still reads. + bg.setColorAt(0, blend(style.cardStart, accentColor, (selected ? 0.75 : 0.65) * 0.7)); + bg.setColorAt(1, blend(style.cardEnd, accentColor, (selected ? 0.18 : 0.10) * 0.7)); + } painter->setPen(Qt::NoPen); painter->setBrush(bg); painter->drawRoundedRect(cardRect, 6, 6); + // The 3px accent bar anchors every row so the banner card art reads as a + // consistent strip in either scheme (pre-branch parity). painter->setBrush(accentColor); painter->drawRoundedRect(QRectF(cardRect.left(), cardRect.top(), 3, cardRect.height()), 2, 2); } @@ -108,6 +176,12 @@ void UserListPainter::drawCardArt(QPainter *painter, return; } + // CardPictureLoader::getPixmap tags its output with the screen's + // devicePixelRatio on HiDPI displays. Every calculation below is in raw + // pixels, so normalize to 1.0 or the crop renders at 1/dpr scale anchored + // to the top left corner of the row. + art.setDevicePixelRatio(1.0); + const int cardH = rect.height() - 4; const int totalW = cardRight - rect.left(); const int marginL = qRound(totalW * params.marginPctL); @@ -125,11 +199,14 @@ void UserListPainter::drawCardArt(QPainter *painter, const int srcX = (scaledW - drawW) / 2; const int srcY = qRound((scaledH - cardH) * params.verticalOffset); - // Clamp srcY so we never copy outside the pixmap bounds + // Clamp so we never copy outside the pixmap bounds. srcX can go negative + // for stored zoom values below 1, which would silently underfill the + // strip with transparent padding. + const int safeSrcX = qBound(0, srcX, qMax(0, scaledW - drawW)); const int safeSrcY = qBound(0, srcY, qMax(0, scaledH - cardH)); QImage img = - scaled.copy(srcX, safeSrcY, drawW, cardH).toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied); + scaled.copy(safeSrcX, safeSrcY, drawW, cardH).toImage().convertToFormat(QImage::Format_ARGB32_Premultiplied); { QPainter mask(&img); @@ -163,7 +240,8 @@ void UserListPainter::drawAvatar(QPainter *painter, const UserLevelFlags &userLevel, const ServerInfo_User &userInfo, const QString &privLevel, - const QMap *avatarCache) + const QMap *avatarCache, + const Style &style) { QPainterPath clipPath; clipPath.addEllipse(avatarRect); @@ -183,7 +261,7 @@ void UserListPainter::drawAvatar(QPainter *painter, } if (!drewAvatar) { - painter->setBrush(accentColor.darker(200)); + painter->setBrush(blend(accentColor, style.base, style.dark ? 0.45 : 0.72)); painter->setPen(Qt::NoPen); painter->drawEllipse(avatarRect); @@ -196,9 +274,9 @@ void UserListPainter::drawAvatar(QPainter *painter, painter->restore(); } -void UserListPainter::drawStatusRing(QPainter *painter, const QRect &avatarRect, bool online) +void UserListPainter::drawStatusRing(QPainter *painter, const QRect &avatarRect, bool online, const Style &style) { - const QColor statusColor = online ? QColor(34, 197, 94) : QColor(70, 80, 95); + const QColor statusColor = online ? QColor(34, 197, 94) : style.ringOffline; painter->setPen(QPen(statusColor, 2)); painter->setBrush(Qt::NoBrush); @@ -212,7 +290,7 @@ void UserListPainter::drawUserName(QPainter *painter, int textX, const QString &userName, bool online, - bool selected) + const Style &style) { QFont nameFont = option.font; nameFont.setBold(true); @@ -221,10 +299,12 @@ void UserListPainter::drawUserName(QPainter *painter, const QRect nameRect(textX, rect.top() + 8, cardRight - textX - 10, 20); const QString elidedName = QFontMetrics(nameFont).elidedText(userName, Qt::ElideRight, cardRight - textX - 10); - painter->setPen(QColor(0, 0, 0, 200)); - painter->drawText(nameRect.translated(1, 1), Qt::AlignVCenter | Qt::AlignLeft, elidedName); + if (style.dropShadow) { + painter->setPen(QColor(0, 0, 0, 200)); + painter->drawText(nameRect.translated(1, 1), Qt::AlignVCenter | Qt::AlignLeft, elidedName); + } - painter->setPen(online ? (selected ? Qt::white : QColor(226, 232, 240)) : QColor(90, 100, 115)); + painter->setPen(online ? style.textOnline : style.textOffline); painter->drawText(nameRect, Qt::AlignVCenter | Qt::AlignLeft, elidedName); } @@ -242,6 +322,8 @@ QList UserListPainter::buildBadges(const UserLevelFlags if (userLevel.testFlag(ServerInfo_User::IsAdmin)) { badges << Badge{"ADMIN", QColor(245, 158, 11)}; + } else if (userLevel.testFlag(ServerInfo_User::IsDeveloper)) { + badges << Badge{"DEV", QColor(185, 28, 28)}; } else if (userLevel.testFlag(ServerInfo_User::IsModerator)) { badges << Badge{"MOD", QColor(59, 130, 246)}; } else if (userLevel.testFlag(ServerInfo_User::IsJudge)) { @@ -262,7 +344,8 @@ void UserListPainter::drawBadges(QPainter *painter, const QRect &rect, int cardRight, const QList &badges, - bool online) + bool online, + const Style &style) { if (badges.isEmpty()) { return; @@ -284,15 +367,17 @@ void UserListPainter::drawBadges(QPainter *painter, int bx = cardRight - 6 - totalBadgeW; for (const Badge &b : badges) { - const QColor col = online ? b.color : b.color.darker(180); + const QColor col = online ? b.color : blend(b.color, style.base, 0.55); + const QColor surface = blend(col, style.base, style.dark ? 0.55 : 0.78); + const QColor text = style.dark ? blend(col, Qt::white, 0.5) : blend(col, Qt::black, 0.35); const int bw = fm.horizontalAdvance(b.text) + 8; const QRect br(bx, rect.top() + 44, bw, 13); painter->setPen(Qt::NoPen); - painter->setBrush(col.darker(online ? 160 : 220)); + painter->setBrush(surface); painter->drawRoundedRect(br, 3, 3); - painter->setPen(col.lighter(online ? 160 : 100)); + painter->setPen(text); painter->drawText(br, Qt::AlignCenter, b.text); bx += bw + 4; @@ -305,11 +390,19 @@ void UserListPainter::paint(QPainter *painter, const ServerInfo_User &userInfo, const QMap *avatarCache, const QMap *cardArtCache, - const QMap *cardArtParamsMap) + const QMap *cardArtParamsMap, + bool dark) { painter->save(); painter->setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform | QPainter::TextAntialiasing); + // The delegate supplies the application palette in option.palette, which + // always reflects the active theme. The widget palette can be stale after + // a runtime theme change, so it is only used as a defensive fallback. + const QPalette pal = + option.palette == QPalette() ? (option.widget ? option.widget->palette() : qApp->palette()) : option.palette; + const Style style = resolveStyle(pal, dark); + const QRect rect = option.rect; const bool online = index.data(Qt::UserRole + 1).toBool(); const bool selected = option.state & QStyle::State_Selected; @@ -317,6 +410,9 @@ void UserListPainter::paint(QPainter *painter, const QString userName = QString::fromStdString(userInfo.name()); const QString privLevel = QString::fromStdString(userInfo.privlevel()); const QColor accentColor = getAccentColor(userLevel, online); + const bool hasRole = + userLevel.testFlag(ServerInfo_User::IsAdmin) || userLevel.testFlag(ServerInfo_User::IsDeveloper) || + userLevel.testFlag(ServerInfo_User::IsModerator) || userLevel.testFlag(ServerInfo_User::IsJudge); const QRectF cardRect = QRectF(rect).adjusted(3, 2, -3, -2); const int cardRight = getCardRight(option, rect); @@ -324,19 +420,19 @@ void UserListPainter::paint(QPainter *painter, ? cardArtParamsMap->value(userName) : CardArtParams{}; - drawBackground(painter, cardRect, accentColor, selected); + drawBackground(painter, cardRect, accentColor, selected, style, hasRole); drawCardArt(painter, rect, cardRight, userName, cardArtCache, params); const QRect avatarRect = getAvatarRect(rect); - drawAvatar(painter, avatarRect, userName, accentColor, userLevel, userInfo, privLevel, avatarCache); - drawStatusRing(painter, avatarRect, online); + drawAvatar(painter, avatarRect, userName, accentColor, userLevel, userInfo, privLevel, avatarCache, style); + drawStatusRing(painter, avatarRect, online, style); const int textX = avatarRect.right() + TextSpacing; - drawUserName(painter, option, rect, cardRight, textX, userName, online, selected); + drawUserName(painter, option, rect, cardRight, textX, userName, online, style); drawCountryFlag(painter, rect, textX, userInfo); const QList badges = buildBadges(userLevel, privLevel); - drawBadges(painter, option, rect, cardRight, badges, online); + drawBadges(painter, option, rect, cardRight, badges, online, style); painter->restore(); -} \ No newline at end of file +} diff --git a/cockatrice/src/interface/widgets/server/user/user_list_painter.h b/cockatrice/src/interface/widgets/server/user/user_list_painter.h index 28cab9675..e7293a097 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_painter.h +++ b/cockatrice/src/interface/widgets/server/user/user_list_painter.h @@ -5,15 +5,19 @@ #include #include -#include -#include +#include #include +#include #include +#include +#include class QPainter; class QModelIndex; class QStyleOptionViewItem; class ServerInfo_User; +class QPixmap; +template class QMap; struct CardArtParams { @@ -28,13 +32,36 @@ struct CardArtParams class UserListPainter { public: + /** + * Palette-derived surface colors for the current color scheme. Both the + * light and the dark scheme read from the active QPalette so custom + * palettes are respected. @c dark only tunes the blend strengths (and + * whether the name text keeps its drop shadow). + */ + struct Style + { + bool dark = true; + QColor cardStart; ///< row fill, left edge (normal) + QColor cardEnd; ///< row fill, right edge (normal) + QColor base; ///< lightest surface, used for blending accent hues + QColor textOnline; + QColor textOffline; + QColor ringOffline; + bool dropShadow = false; + }; + + static Style resolveStyle(const QPalette &palette, bool dark); + /// Linear interpolation: @p t = 0 returns @p a, @p t = 1 returns @p b. + static QColor blend(const QColor &a, const QColor &b, qreal t); + static void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index, const ServerInfo_User &userInfo, const QMap *avatarCache, const QMap *cardArtCache, - const QMap *cardArtParamsMap); + const QMap *cardArtParamsMap, + bool dark); static QSize sizeHint(); @@ -55,7 +82,12 @@ private: static QColor getAccentColor(const UserLevelFlags &userLevel, bool online); static int getCardRight(const QStyleOptionViewItem &option, const QRect &rect); - static void drawBackground(QPainter *painter, const QRectF &cardRect, const QColor &accentColor, bool selected); + static void drawBackground(QPainter *painter, + const QRectF &cardRect, + const QColor &accentColor, + bool selected, + const Style &style, + bool hasRole); static QRect getAvatarRect(const QRect &rect); static void drawAvatar(QPainter *painter, const QRect &avatarRect, @@ -64,8 +96,9 @@ private: const UserLevelFlags &userLevel, const ServerInfo_User &userInfo, const QString &privLevel, - const QMap *avatarCache); - static void drawStatusRing(QPainter *painter, const QRect &avatarRect, bool online); + const QMap *avatarCache, + const Style &style); + static void drawStatusRing(QPainter *painter, const QRect &avatarRect, bool online, const Style &style); static void drawUserName(QPainter *painter, const QStyleOptionViewItem &option, const QRect &rect, @@ -73,7 +106,7 @@ private: int textX, const QString &userName, bool online, - bool selected); + const Style &style); static void drawCountryFlag(QPainter *painter, const QRect &rect, int textX, const ServerInfo_User &userInfo); static QList buildBadges(const UserLevelFlags &userLevel, const QString &privLevel); static void drawBadges(QPainter *painter, @@ -81,7 +114,8 @@ private: const QRect &rect, int cardRight, const QList &badges, - bool online); + bool online, + const Style &style); }; -#endif // COCKATRICE_USER_LIST_PAINTER_H \ No newline at end of file +#endif // COCKATRICE_USER_LIST_PAINTER_H diff --git a/cockatrice/src/interface/widgets/server/user/user_list_panel_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_panel_widget.cpp new file mode 100644 index 000000000..78d45d120 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_list_panel_widget.cpp @@ -0,0 +1,89 @@ +#include "user_list_panel_widget.h" + +#include "../../../../client/settings/cache_settings.h" +#include "user_list_widget.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +// The persisted section keys are the serialization contract with the user's +// settings file, so the values must stay stable across versions. +QString sectionKey(UserListWidget::Section section) +{ + switch (section) { + case UserListWidget::Section::Buddy: + return QStringLiteral("buddy"); + case UserListWidget::Section::Online: + return QStringLiteral("online"); + case UserListWidget::Section::Ignore: + return QStringLiteral("ignore"); + } + return {}; +} +} // namespace + +UserListPanelWidget::UserListPanelWidget(TabSupervisor *_tabSupervisor, AbstractClient *_client, QWidget *parent) + : QWidget(parent) +{ + auto *mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins(0, 0, 0, 0); + mainLayout->setSpacing(2); + + searchBar = new QLineEdit(this); + searchBar->setClearButtonEnabled(true); + mainLayout->addWidget(searchBar); + + userList = new UserListWidget(_tabSupervisor, _client, UserListWidget::RoomList, this); + userList->setSectioned( + {UserListWidget::Section::Buddy, UserListWidget::Section::Online, UserListWidget::Section::Ignore}); + mainLayout->addWidget(userList, 1); + + connect(searchBar, &QLineEdit::textChanged, userList, &UserListWidget::setFilterText); + + connect(userList, &UserListWidget::sectionExpanded, this, &UserListPanelWidget::persistExpandedSections); + connect(userList, &UserListWidget::openMessageDialog, this, &UserListPanelWidget::openMessageDialog); + + // Restore the persisted expansion state, then apply it to the tree. + const QStringList expandedSections = SettingsCache::instance().userInterface().getUserListExpandedSections(); + for (const UserListWidget::Section section : userList->getSectionIds()) { + userList->setSectionExpanded(section, expandedSections.contains(sectionKey(section))); + } + + retranslateUi(); +} + +void UserListPanelWidget::bind(UserListManager *manager) +{ + userList->bind(manager); +} + +void UserListPanelWidget::persistExpandedSections(UserListWidget::Section section, bool expanded) +{ + const QString key = sectionKey(section); + QStringList expandedSections = SettingsCache::instance().userInterface().getUserListExpandedSections(); + if (expanded) { + if (!expandedSections.contains(key)) { + expandedSections.append(key); + } + } else { + expandedSections.removeAll(key); + } + SettingsCache::instance().userInterface().setUserListExpandedSections(expandedSections); +} + +void UserListPanelWidget::retranslateUi() +{ + searchBar->setPlaceholderText(tr("Search users...")); + userList->retranslateUi(); +} + +UserListWidget *UserListPanelWidget::getUserList() const +{ + return userList; +} diff --git a/cockatrice/src/interface/widgets/server/user/user_list_panel_widget.h b/cockatrice/src/interface/widgets/server/user/user_list_panel_widget.h new file mode 100644 index 000000000..088365bbc --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_list_panel_widget.h @@ -0,0 +1,45 @@ +/** + * @file user_list_panel_widget.h + * @ingroup Lobby + */ + +#ifndef COCKATRICE_USER_LIST_PANEL_WIDGET_H +#define COCKATRICE_USER_LIST_PANEL_WIDGET_H + +#include "user_list_widget.h" + +#include +#include +#include + +class AbstractClient; +class QLineEdit; +class TabSupervisor; +class UserListManager; + +/** + * A unified user list: a search bar above a single tree whose section headers + * (buddy, online, ignored) are inline dividers. The tree owns the scrolling. + */ +class UserListPanelWidget : public QWidget +{ + Q_OBJECT + +public: + explicit UserListPanelWidget(TabSupervisor *tabSupervisor, AbstractClient *client, QWidget *parent = nullptr); + void bind(UserListManager *manager); + void retranslateUi(); + + [[nodiscard]] UserListWidget *getUserList() const; + +signals: + void openMessageDialog(const QString &userName, bool focus); + +private: + void persistExpandedSections(UserListWidget::Section section, bool expanded); + + QLineEdit *searchBar = nullptr; + UserListWidget *userList = nullptr; +}; + +#endif // COCKATRICE_USER_LIST_PANEL_WIDGET_H diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp index 64cbb7b7d..b3fd1d1a9 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -1,321 +1,67 @@ #include "user_list_widget.h" #include "../../../../client/settings/cache_settings.h" -#include "../../../card_picture_loader/card_picture_loader.h" #include "../../interface/pixel_map_generator.h" -#include "../../interface/widgets/tabs/tab_account.h" +#include "../../interface/theme_manager.h" #include "../../interface/widgets/tabs/tab_supervisor.h" -#include "../game_selector.h" +#include "user_avatar_provider.h" +#include "user_card_art_provider.h" #include "user_context_menu.h" +#include "user_info_popup.h" +#include "user_level.h" +#include "user_list_manager.h" #include "user_list_painter.h" +#include "user_list_proxy.h" +#include +#include #include -#include -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include -#include -#include -#include -#include +#include +#include +#include +#include +#include +#include +#include #include +#include #include -#include -#include -#include -#include -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include -#include -#include -#include -#include -#include +#include #include -#include +#include -BanDialog::BanDialog(const ServerInfo_User &info, QWidget *parent) : QDialog(parent) -{ - setAttribute(Qt::WA_DeleteOnClose); - - nameBanCheckBox = new QCheckBox(tr("ban &user name")); - nameBanCheckBox->setChecked(true); - nameBanEdit = new QLineEdit(QString::fromStdString(info.name())); - nameBanEdit->setMaxLength(MAX_NAME_LENGTH); - ipBanCheckBox = new QCheckBox(tr("ban &IP address")); - ipBanCheckBox->setChecked(true); - ipBanEdit = new QLineEdit(QString::fromStdString(info.address())); - ipBanEdit->setMaxLength(MAX_NAME_LENGTH); - idBanCheckBox = new QCheckBox(tr("ban client I&D")); - idBanCheckBox->setChecked(true); - idBanEdit = new QLineEdit(QString::fromStdString(info.clientid())); - idBanEdit->setMaxLength(MAX_NAME_LENGTH); - if (QString::fromStdString(info.clientid()).isEmpty()) { - idBanCheckBox->setChecked(false); - } - - QGridLayout *banTypeGrid = new QGridLayout; - banTypeGrid->addWidget(nameBanCheckBox, 0, 0); - banTypeGrid->addWidget(nameBanEdit, 0, 1); - banTypeGrid->addWidget(ipBanCheckBox, 1, 0); - banTypeGrid->addWidget(ipBanEdit, 1, 1); - banTypeGrid->addWidget(idBanCheckBox, 2, 0); - banTypeGrid->addWidget(idBanEdit, 2, 1); - QGroupBox *banTypeGroupBox = new QGroupBox(tr("Ban type")); - banTypeGroupBox->setLayout(banTypeGrid); - - permanentRadio = new QRadioButton(tr("&permanent ban")); - temporaryRadio = new QRadioButton(tr("&temporary ban")); - temporaryRadio->setChecked(true); - connect(temporaryRadio, &QRadioButton::toggled, this, &BanDialog::enableTemporaryEdits); - daysLabel = new QLabel(tr("&Days:")); - daysEdit = new QSpinBox; - daysEdit->setMinimum(0); - daysEdit->setValue(0); - daysEdit->setMaximum(10000); - daysLabel->setBuddy(daysEdit); - hoursLabel = new QLabel(tr("&Hours:")); - hoursEdit = new QSpinBox; - hoursEdit->setMinimum(0); - hoursEdit->setValue(0); - hoursEdit->setMaximum(24); - hoursLabel->setBuddy(hoursEdit); - minutesLabel = new QLabel(tr("&Minutes:")); - minutesEdit = new QSpinBox; - minutesEdit->setMinimum(0); - minutesEdit->setValue(5); - minutesEdit->setMaximum(60); - minutesLabel->setBuddy(minutesEdit); - QGridLayout *durationLayout = new QGridLayout; - durationLayout->addWidget(permanentRadio, 0, 0, 1, 6); - durationLayout->addWidget(temporaryRadio, 1, 0, 1, 6); - durationLayout->addWidget(daysLabel, 2, 0); - durationLayout->addWidget(daysEdit, 2, 1); - durationLayout->addWidget(hoursLabel, 2, 2); - durationLayout->addWidget(hoursEdit, 2, 3); - durationLayout->addWidget(minutesLabel, 2, 4); - durationLayout->addWidget(minutesEdit, 2, 5); - QGroupBox *durationGroupBox = new QGroupBox(tr("Duration of the ban")); - durationGroupBox->setLayout(durationLayout); - - QLabel *reasonLabel = new QLabel(tr("Please enter the reason for the ban.\nThis is only saved for moderators and " - "cannot be seen by the banned person.")); - reasonEdit = new QPlainTextEdit; - - QLabel *visibleReasonLabel = - new QLabel(tr("Please enter the reason for the ban that will be visible to the banned person.")); - visibleReasonEdit = new QPlainTextEdit; - - deleteMessages = new QCheckBox(tr("Redact all messages from this user in all rooms")); - - QPushButton *okButton = new QPushButton(tr("&OK")); - okButton->setAutoDefault(true); - connect(okButton, &QPushButton::clicked, this, &BanDialog::okClicked); - QPushButton *cancelButton = new QPushButton(tr("&Cancel")); - connect(cancelButton, &QPushButton::clicked, this, &BanDialog::reject); - - QHBoxLayout *buttonLayout = new QHBoxLayout; - buttonLayout->addStretch(); - buttonLayout->addWidget(okButton); - buttonLayout->addWidget(cancelButton); - - QVBoxLayout *vbox = new QVBoxLayout; - vbox->addWidget(banTypeGroupBox); - vbox->addWidget(durationGroupBox); - vbox->addWidget(reasonLabel); - vbox->addWidget(reasonEdit); - vbox->addWidget(visibleReasonLabel); - vbox->addWidget(visibleReasonEdit); - vbox->addWidget(deleteMessages); - vbox->addLayout(buttonLayout); - - setLayout(vbox); - setWindowTitle(tr("Ban user from server")); -} - -WarningDialog::WarningDialog(const QString userName, const QString clientID, QWidget *parent) : QDialog(parent) -{ - setAttribute(Qt::WA_DeleteOnClose); - descriptionLabel = new QLabel(tr("Which warning would you like to send?")); - nameWarning = new QLineEdit(userName); - nameWarning->setMaxLength(MAX_NAME_LENGTH); - warnClientID = new QLineEdit(clientID); - warnClientID->setMaxLength(MAX_NAME_LENGTH); - warningOption = new QComboBox(); - warningOption->addItem(""); - - deleteMessages = new QCheckBox(tr("Redact all messages from this user in all rooms")); - - QPushButton *okButton = new QPushButton(tr("&OK")); - okButton->setAutoDefault(true); - connect(okButton, &QPushButton::clicked, this, &WarningDialog::okClicked); - QPushButton *cancelButton = new QPushButton(tr("&Cancel")); - connect(cancelButton, &QPushButton::clicked, this, &WarningDialog::reject); - - QHBoxLayout *buttonLayout = new QHBoxLayout; - buttonLayout->addStretch(); - buttonLayout->addWidget(okButton); - buttonLayout->addWidget(cancelButton); - - QVBoxLayout *vbox = new QVBoxLayout; - vbox->addWidget(descriptionLabel); - vbox->addWidget(nameWarning); - vbox->addWidget(warningOption); - vbox->addWidget(deleteMessages); - vbox->addLayout(buttonLayout); - setLayout(vbox); - setWindowTitle(tr("Warn user for misconduct")); -} - -void WarningDialog::okClicked() -{ - if (nameWarning->text().simplified().isEmpty()) { - QMessageBox::critical(this, tr("Error"), - tr("User name to send a warning to can not be blank, please specify a user to warn.")); - return; - } - - if (warningOption->currentText().simplified().isEmpty()) { - QMessageBox::critical(this, tr("Error"), - tr("Warning to use can not be blank, please select a valid warning to send.")); - return; - } - - accept(); -} - -QString WarningDialog::getName() const -{ - return nameWarning->text().simplified(); -} - -QString WarningDialog::getWarnID() const -{ - return warnClientID->text().simplified(); -} - -QString WarningDialog::getReason() const -{ - return warningOption->currentText().simplified(); -} - -int WarningDialog::getDeleteMessages() const -{ - return deleteMessages->isChecked() ? -1 : 0; -} - -void WarningDialog::addWarningOption(const QString warning) -{ - warningOption->addItem(warning); -} - -void BanDialog::okClicked() -{ - if (!nameBanCheckBox->isChecked() && !ipBanCheckBox->isChecked() && !idBanCheckBox->isChecked()) { - QMessageBox::critical(this, tr("Error"), - tr("You have to select a name-based, IP-based, clientId based, or some combination of " - "the three to place a ban.")); - return; - } - - if (nameBanCheckBox->isChecked()) { - if (nameBanEdit->text().simplified() == "") { - QMessageBox::critical(this, tr("Error"), - tr("You must have a value in the name ban when selecting the name ban checkbox.")); - return; - } - } - - if (ipBanCheckBox->isChecked()) { - if (ipBanEdit->text().simplified() == "") { - QMessageBox::critical(this, tr("Error"), - tr("You must have a value in the ip ban when selecting the ip ban checkbox.")); - return; - } - } - - if (idBanCheckBox->isChecked()) { - if (idBanEdit->text().simplified() == "") { - QMessageBox::critical( - this, tr("Error"), - tr("You must have a value in the clientid ban when selecting the clientid ban checkbox.")); - return; - } - } - - accept(); -} - -void BanDialog::enableTemporaryEdits(bool enabled) -{ - daysLabel->setEnabled(enabled); - daysEdit->setEnabled(enabled); - hoursLabel->setEnabled(enabled); - hoursEdit->setEnabled(enabled); - minutesLabel->setEnabled(enabled); - minutesEdit->setEnabled(enabled); -} - -QString BanDialog::getBanId() const -{ - return idBanCheckBox->isChecked() ? idBanEdit->text() : QString(); -} - -QString BanDialog::getBanName() const -{ - return nameBanCheckBox->isChecked() ? nameBanEdit->text() : QString(); -} - -QString BanDialog::getBanIP() const -{ - return ipBanCheckBox->isChecked() ? ipBanEdit->text() : QString(); -} - -int BanDialog::getMinutes() const -{ - return permanentRadio->isChecked() ? 0 - : (daysEdit->value() * 24 * 60 + hoursEdit->value() * 60 + minutesEdit->value()); -} - -QString BanDialog::getReason() const -{ - return reasonEdit->toPlainText(); -} - -QString BanDialog::getVisibleReason() const -{ - return visibleReasonEdit->toPlainText(); -} - -int BanDialog::getDeleteMessages() const -{ - return deleteMessages->isChecked() ? -1 : 0; -} - -AdminNotesDialog::AdminNotesDialog(const QString &_userName, const QString &_notes, QWidget *_parent) - : QDialog(_parent), userName(_userName) -{ - setAttribute(Qt::WA_DeleteOnClose); - - auto *updateButton = new QPushButton(tr("Update Notes")); - updateButton->setEnabled(false); - connect(updateButton, &QPushButton::clicked, this, &AdminNotesDialog::accept); - - notes = new QPlainTextEdit(_notes); - notes->setMinimumWidth(500); - connect(notes, &QPlainTextEdit::textChanged, this, [=]() { updateButton->setEnabled(true); }); - - auto *vbox = new QVBoxLayout; - vbox->addWidget(notes); - vbox->addWidget(updateButton); - - setLayout(vbox); - setWindowTitle(tr("Admin Notes for %1").arg(_userName)); -} - -QString AdminNotesDialog::getNotes() const -{ - return notes->toPlainText(); -} +class QPixmap; +struct GameInviteOption; namespace UserListRoles { @@ -323,11 +69,16 @@ constexpr int Online = Qt::UserRole + 1; constexpr int UserInfo = Qt::UserRole + 2; } // namespace UserListRoles -UserListItemDelegate::UserListItemDelegate(QObject *const parent, +// Divider items (section headers) in sectioned mode are distinguished from user +// rows (UserListTWI, which uses QTreeWidgetItem::Type) by this item type. +constexpr int SectionItemType = QTreeWidgetItem::UserType + 1; + +UserListItemDelegate::UserListItemDelegate(UserListWidget *owner, + QTreeWidget *tree, const QMap *avatarCache, const QMap *cardArtCache, const QMap *cardArtParamsMap) - : QStyledItemDelegate(parent), avatarCache(avatarCache), cardArtCache(cardArtCache), + : QStyledItemDelegate(tree), tree(tree), owner(owner), avatarCache(avatarCache), cardArtCache(cardArtCache), cardArtParamsMap(cardArtParamsMap) { } @@ -340,7 +91,10 @@ bool UserListItemDelegate::editorEvent(QEvent *event, if ((event->type() == QEvent::MouseButtonPress) && index.isValid()) { QMouseEvent *const mouseEvent = static_cast(event); if (mouseEvent->button() == Qt::RightButton) { - static_cast(parent())->showContextMenu(mouseEvent->globalPosition().toPoint(), index); + // Dialog mode has no context menu: consume the press, show nothing. + if (owner->getHasUserInfoPopup()) { + owner->showContextMenu(mouseEvent->globalPosition().toPoint(), index); + } return true; } } @@ -352,25 +106,129 @@ QSize UserListItemDelegate::sizeHint(const QStyleOptionViewItem &option, const Q if (!SettingsCache::instance().appearance().getStyleUserList()) { return QStyledItemDelegate::sizeHint(option, index); } + if (!index.data(UserListRoles::UserInfo).isValid()) { + return QStyledItemDelegate::sizeHint(option, index); // section dividers stay compact + } return UserListPainter::sizeHint(); } void UserListItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { - if (!SettingsCache::instance().appearance().getStyleUserList()) { - QStyledItemDelegate::paint(painter, option, index); + const bool styled = SettingsCache::instance().appearance().getStyleUserList(); + // UserInfo/Online are stored on column 0 only. The name lives on column 2, + // so resolve the user data against column 0 no matter which cell is painted. + const QModelIndex userIndex = index.siblingAtColumn(0).isValid() ? index.siblingAtColumn(0) : index; + const QVariant var = userIndex.data(UserListRoles::UserInfo); + + if (styled && var.isValid()) { + // Styled card rows: the tree's cached palette can be stale after a + // runtime theme change, so paint from the application palette (always + // current) instead of option.palette (frozen at the last style switch). + QStyleOptionViewItem opt = option; + opt.palette = qApp->palette(); + + UserListPainter::paint(painter, opt, index, var.value(), avatarCache, cardArtCache, + cardArtParamsMap, themeManager && themeManager->isDarkModeActive()); return; } - const QVariant var = index.data(UserListRoles::UserInfo); + // Unstyled rows and section dividers are painted manually so every color + // is derived from the current application palette at paint time. The widget + // palette, the view's alternation, and the stored brushes (one per item) + // all go stale after a runtime theme change. + const QPalette appPal = qApp->palette(); + const bool selected = option.state & QStyle::State_Selected; + const bool hovered = option.state & QStyle::State_MouseOver; - if (!var.isValid()) { - QStyledItemDelegate::paint(painter, option, index); - return; + // Row background: selection, hover, zebra striping, plain base. + QColor bg = appPal.color(QPalette::Base); + if (selected) { + bg = appPal.color(QPalette::Highlight); + } else if (hovered) { + bg = UserListPainter::blend(appPal.color(QPalette::Base), appPal.color(QPalette::Highlight), 0.12); + } else if (var.isValid()) { + // Zebra alternation per section. The dividers are top level rows too, + // so the view's own alternation would drift across sections. Count the + // visible user rows back to the previous divider within the same parent + // (sectioned mode stores users as children of the dividers, flat mode + // as top level rows), so the stripe restarts at every divider and at + // the tree top. Hidden filter matches are skipped the same way the view + // skips them, so adjacent visible rows always alternate. + int usersSinceDivider = 0; + const QModelIndex parent = userIndex.parent(); + for (int r = userIndex.row() - 1; r >= 0; --r) { + if (tree->isRowHidden(r, parent)) { + continue; + } + const QModelIndex above = userIndex.model()->index(r, 0, parent); + if (above.isValid() && above.data(UserListRoles::UserInfo).isValid()) { + ++usersSinceDivider; + } else { + break; + } + } + if (usersSinceDivider % 2 == 1) { + bg = appPal.color(QPalette::AlternateBase); + } + } + // Paint the row background. In the column 0 pass the fill spans the full + // viewport width so stripes, hover and selection cover the whole row (the + // name column is content sized in unstyled mode). Later column passes fill + // only their own cell, which is the same color and cannot cover the icons. + QRect bgRect = option.rect; + if (index.column() == 0) { + bgRect = QRect(0, option.rect.top(), tree->viewport()->width(), option.rect.height()); + } + painter->fillRect(bgRect, bg); + + // Text color. + QColor fg = appPal.color(QPalette::Text); + if (selected) { + fg = appPal.color(QPalette::HighlightedText); + } else if (!var.isValid()) { + // Section divider: muted application text color. + fg = appPal.color(QPalette::WindowText); + fg.setAlpha(170); + } else if (index.column() == 2) { + // Name column: online/offline color recomputed at paint time instead + // of trusting the brush stored at login time. + QTreeWidgetItem *item = tree->itemFromIndex(index); + const bool online = item && item->data(0, UserListRoles::Online).toBool(); + if (online) { + fg = appPal.color(QPalette::WindowText); + } else { + fg = (themeManager && themeManager->isDarkModeActive()) + ? QColor(Qt::gray) + : UserListPainter::blend(appPal.color(QPalette::Text), appPal.color(QPalette::Mid), 0.5); + } } - UserListPainter::paint(painter, option, index, var.value(), avatarCache, cardArtCache, - cardArtParamsMap); + // Icon (level badge in column 0, country flag in column 1). + QRect textRect = option.rect; + const QIcon icon = index.data(Qt::DecorationRole).value(); + if (!icon.isNull()) { + const QSize iconSize = icon.actualSize(QSize(18, 18)); + const QRect iconRect(option.rect.left() + 2, option.rect.center().y() - iconSize.height() / 2, iconSize.width(), + iconSize.height()); + icon.paint(painter, iconRect); + textRect.setLeft(iconRect.right() + 4); + } + + // Text (name column / divider title), elided to the row width. + painter->save(); + painter->setPen(fg); + const QFont itemFont = index.data(Qt::FontRole).value(); + painter->setFont(itemFont.isCopyOf(QFont()) ? option.font : itemFont); + const QString text = index.data(Qt::DisplayRole).toString(); + const QString elided = painter->fontMetrics().elidedText(text, Qt::ElideRight, textRect.width() - 4); + painter->drawText(textRect.adjusted(2, 0, -2, 0), Qt::AlignLeft | Qt::AlignVCenter, elided); + painter->restore(); + + // Focus indicator for the current item. + if (option.state & QStyle::State_HasFocus) { + painter->setPen(appPal.color(QPalette::Highlight)); + painter->drawRect(option.rect.adjusted(0, 0, -1, -1)); + } } UserListTWI::UserListTWI(const ServerInfo_User &_userInfo) : QTreeWidgetItem(Type) @@ -394,8 +252,10 @@ void UserListTWI::setUserInfo(const ServerInfo_User &_userInfo) void UserListTWI::setOnline(bool online) { + // Only the online state is stored here: the delegate derives the + // online/offline text color at paint time from the current application + // palette, so no brush is cached (it would go stale on theme change). setData(0, UserListRoles::Online, online); - setData(2, Qt::ForegroundRole, online ? qApp->palette().brush(QPalette::WindowText) : QBrush(Qt::gray)); } /** @@ -404,9 +264,10 @@ void UserListTWI::setOnline(bool online) * 2) Admins, judge/vip/donator status ignored * 3) Moderators, judge/vip/donator status ignored * 4) Judges - * 5) VIPs - * 6) Donators - * 7) Everyone else + * 5) Developers + * 6) VIPs + * 7) Donators + * 8) Everyone else * @param other RHS to compare to * @return Left is less than the Right */ @@ -420,7 +281,8 @@ bool UserListTWI::operator<(const QTreeWidgetItem &other) const const auto &lhsUserLevelFlags = UserLevelFlags(data(0, Qt::UserRole).toInt()); const auto &rhsUserLevelFlags = UserLevelFlags(other.data(0, Qt::UserRole).toInt()); - // Admins & Mods need no additional comparison checks, just to see if they're an admin or a moderator + // Admins & Moderators need no additional comparison checks, just to see if they're an admin + // or a moderator static const QList userLevelWithNoOtherPrefOrder = { ServerInfo_User_UserLevelFlag_IsAdmin, ServerInfo_User_UserLevelFlag_IsModerator}; for (const auto &userLevelEntry : userLevelWithNoOtherPrefOrder) { @@ -433,10 +295,10 @@ bool UserListTWI::operator<(const QTreeWidgetItem &other) const } } - // Judges can be sorted by their additional ranks - static const QList userLevelOrder = {ServerInfo_User_UserLevelFlag_IsJudge, - ServerInfo_User_UserLevelFlag_IsRegistered, - ServerInfo_User_UserLevelFlag_IsUser}; + // Judges and developers can be sorted by their additional ranks + static const QList userLevelOrder = { + ServerInfo_User_UserLevelFlag_IsJudge, ServerInfo_User_UserLevelFlag_IsDeveloper, + ServerInfo_User_UserLevelFlag_IsRegistered, ServerInfo_User_UserLevelFlag_IsUser}; for (const auto &userLevelEntry : userLevelOrder) { if (lhsUserLevelFlags.testFlag(userLevelEntry) != rhsUserLevelFlags.testFlag(userLevelEntry)) { return lhsUserLevelFlags.testFlag(userLevelEntry) > rhsUserLevelFlags.testFlag(userLevelEntry); @@ -458,15 +320,14 @@ bool UserListTWI::operator<(const QTreeWidgetItem &other) const UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, AbstractClient *_client, UserListType _type, - QWidget *parent) - : QGroupBox(parent), tabSupervisor(_tabSupervisor), client(_client), type(_type), onlineCount(0) + QWidget *parent, + bool _hasUserInfoPopup) + : QGroupBox(parent), hasUserInfoPopup(_hasUserInfoPopup), tabSupervisor(_tabSupervisor), client(_client), + type(_type), onlineCount(0) { avatarProvider = new UserAvatarProvider(client, this); cardArtProvider = new UserCardArtProvider(this); - itemDelegate = - new UserListItemDelegate(this, &avatarProvider->cache(), &cardArtProvider->cache(), &cardArtParamsMap); - userContextMenu = new UserContextMenu(tabSupervisor, this); connect(userContextMenu, &UserContextMenu::openMessageDialog, this, &UserListWidget::openMessageDialog); @@ -477,6 +338,8 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, userTree->setHeaderHidden(true); userTree->setRootIsDecorated(false); userTree->setIconSize(QSize(20, 18)); + itemDelegate = new UserListItemDelegate(this, userTree, &avatarProvider->cache(), &cardArtProvider->cache(), + &cardArtParamsMap); userTree->setItemDelegate(itemDelegate); userTree->setAlternatingRowColors(true); userTree->hideColumn(1); @@ -484,79 +347,175 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, userTree->hideColumn(3); connect(userTree, &QTreeWidget::itemActivated, this, &UserListWidget::userClicked); userTree->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); - userTree->header()->setStretchLastSection(true); + // QTreeWidget enables stretchLastSection by default. Left on, the hidden + // last section absorbs viewport resizes, the Stretch sections never + // redistribute, and the header keeps a stale length past the viewport — + // an invisible horizontal pan range under ScrollBarAlwaysOff. Disable it + // so the explicit resize modes in applyDisplayMode() own the geometry. + userTree->header()->setStretchLastSection(false); - // ── Hover popup ─────────────────────────────────────────────────────────── - m_userInfoPopup = new UserInfoPopup(tabSupervisor, tabSupervisor->getClient(), &avatarProvider->cache(), - &cardArtProvider->cache(), &cardArtParamsMap, - window()); // parented to main window so it floats above siblings - - m_userInfoPopup->hide(); - m_userInfoPopup->setWindowOpacity(0.0); - m_userInfoPopup->installEventFilter(this); - - connectPopupSignals(); - - m_showPopupTimer = new QTimer(this); - m_showPopupTimer->setSingleShot(true); - m_showPopupTimer->setInterval(280); - connect(m_showPopupTimer, &QTimer::timeout, this, [this] { - if (!m_hoveredUser.isEmpty()) { - showPopupForUser(m_hoveredUser); + // Always create timers so callers never segfault on a null deref; + // showPopupForUser / hidePopup already guard against a null userInfoPopup. + showPopupTimer = new QTimer(this); + showPopupTimer->setSingleShot(true); + showPopupTimer->setInterval(280); + connect(showPopupTimer, &QTimer::timeout, this, [this] { + if (hoveredUser.isEmpty()) { + return; + } + // Resolve the row under the cursor again. In sectioned mode a user can + // own several rows (online + buddy), so the popup must anchor to the + // exact hovered row instead of a lookup by name. + const QPoint viewportPos = userTree->viewport()->mapFromGlobal(QCursor::pos()); + QTreeWidgetItem *item = userTree->itemAt(viewportPos); + if (item && item->type() == QTreeWidgetItem::Type && + QString::fromStdString(static_cast(item)->getUserInfo().name()) == hoveredUser) { + showPopupForUser(static_cast(item)); } }); - m_hidePopupTimer = new QTimer(this); - m_hidePopupTimer->setSingleShot(true); - m_hidePopupTimer->setInterval(160); - connect(m_hidePopupTimer, &QTimer::timeout, this, [this] { - if (!m_popupPinned && !m_userInfoPopup->underMouse() && !userTree->underMouse()) { + hidePopupTimer = new QTimer(this); + hidePopupTimer->setSingleShot(true); + hidePopupTimer->setInterval(160); + connect(hidePopupTimer, &QTimer::timeout, this, [this] { + // The hover ends when the cursor leaves the user row. Empty list + // space, a section divider and anything outside the tree all close + // the popup, while the popup itself keeps it alive. + if (!popupPinned && userInfoPopup && !userInfoPopup->underMouse() && + (hoveredUser.isEmpty() || !userTree->underMouse())) { hidePopup(); } }); + if (hasUserInfoPopup) { + // ── Hover popup ─────────────────────────────────────────────────────── + userInfoPopup = new UserInfoPopup(tabSupervisor, tabSupervisor->getClient(), &avatarProvider->cache(), + &cardArtProvider->cache(), &cardArtParamsMap, + window()); // parented to main window so it floats above siblings + + // The invite availability is scoped to the room this list belongs to, + // and gated on the room's buddy-only setting for the hovered user. + userInfoPopup->setGameInviteAvailable( + [this](const QString &userName) { return userContextMenu->hasGameInviteLink(userName); }); + + userInfoPopup->hide(); + userInfoPopup->setWindowOpacity(0.0); + userInfoPopup->installEventFilter(this); + + connectPopupSignals(); + } + userTree->setMouseTracking(true); userTree->viewport()->setMouseTracking(true); userTree->viewport()->installEventFilter(this); + userTree->installEventFilter(this); // keyboard handling for section dividers - // Pin on item click - connect(userTree, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem *item, int) { - if (!SettingsCache::instance().appearance().getStyleUserList()) { - return; - } - const QString name = static_cast(item)->getUserInfo().name().c_str(); - m_popupPinned = false; // reset so showPopupForUser can update - showPopupForUser(name); - m_popupPinned = true; // pin after showing - }); + if (hasUserInfoPopup) { + // Clicking anywhere outside the list clears its selection and closes the + // popup. The filter watches all widgets because the press can land on any + // part of the window, on another list or on the popup itself. + qApp->installEventFilter(this); - connect(userTree->selectionModel(), &QItemSelectionModel::selectionChanged, this, - [this](const QItemSelection &sel, const QItemSelection &) { - // if (m_rebuildingTree) return; - if (sel.isEmpty() && m_popupPinned) { - m_popupPinned = false; - hidePopup(); - } - }); + // Pin on item click + connect(userTree, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem *item, int) { + // Clicking a section divider toggles it + if (sectioned && item->type() == SectionItemType) { + setExpandedProgrammatically(item, !item->isExpanded()); + handleSectionExpansion(item, item->isExpanded()); + return; + } + if (!SettingsCache::instance().appearance().getStyleUserList()) { + return; + } + if (item->type() != QTreeWidgetItem::Type) { + return; // divider rows have no user popup + } + popupPinned = false; // reset so showPopupForUser can update + showPopupForUser(static_cast(item)); + popupPinned = true; // pin after showing + }); - // Hide popup when list scrolls (reference row has moved) - connect(userTree->verticalScrollBar(), &QScrollBar::valueChanged, this, [this] { - m_showPopupTimer->stop(); - hidePopup(true); - }); + connect(userTree->selectionModel(), &QItemSelectionModel::selectionChanged, this, + [this](const QItemSelection &sel, const QItemSelection &) { + if (sel.isEmpty() && popupPinned) { + popupPinned = false; + hidePopup(); + } + }); - // Forward join requests from popup upward - connect(m_userInfoPopup, &UserInfoPopup::joinGameRequested, this, &UserListWidget::joinGameRequested); + // Keyboard selection: the popup is a mouse surface, so keyboard + // navigation shows no floating popup. A pinned (clicked) popup still + // follows the selection so it does not strand on a stale user while + // arrows move the cursor. + connect(userTree, &QTreeWidget::currentItemChanged, this, [this](QTreeWidgetItem *current, QTreeWidgetItem *) { + if (!popupPinned) { + return; // keyboard navigation shows no popup + } + if (!isVisible() || !SettingsCache::instance().appearance().getStyleUserList()) { + return; + } + if (current && current->type() == QTreeWidgetItem::Type) { + showPopupForUser(static_cast(current)); + } else { + popupPinned = false; + hidePopup(); + } + }); - connect(avatarProvider, &UserAvatarProvider::avatarUpdated, this, - [this](const QString &) { userTree->viewport()->update(); }); - connect(cardArtProvider, &UserCardArtProvider::cardArtUpdated, this, - [this](const QString &) { userTree->viewport()->update(); }); + // Section dividers can be collapsed/expanded by the user. Surface those + // changes only from real user interaction. Programmatic expansion is + // applied through setSectionExpanded() / setExpandedProgrammatically(). + connect(userTree, &QTreeWidget::itemExpanded, this, + [this](QTreeWidgetItem *item) { handleSectionExpansion(item, true); }); + connect(userTree, &QTreeWidget::itemCollapsed, this, + [this](QTreeWidgetItem *item) { handleSectionExpansion(item, false); }); + + // Hide popup when list scrolls (reference row has moved) + connect(userTree->verticalScrollBar(), &QScrollBar::valueChanged, this, [this] { + showPopupTimer->stop(); + hidePopup(true); + requestVisibleItemResources(); + }); + } else { + // Dialog mode: keyboard selection drives the Invite button. + connect(userTree, &QTreeWidget::currentItemChanged, this, [this](QTreeWidgetItem *current, QTreeWidgetItem *) { + const QString userName = (current && current->type() == QTreeWidgetItem::Type) + ? current->data(2, Qt::UserRole).toString() + : QString(); + emit currentUserChanged(userName); + }); + + // Keep the popup-less scroll path alive for avatar prefetch. + connect(userTree->verticalScrollBar(), &QScrollBar::valueChanged, this, + [this] { requestVisibleItemResources(); }); + } + + // Section dividers can be collapsed/expanded by the user. Surface those + // changes only from real user interaction. Programmatic expansion is + // applied through setSectionExpanded() / setExpandedProgrammatically(). + connect(userTree, &QTreeWidget::itemExpanded, this, + [this](QTreeWidgetItem *item) { handleSectionExpansion(item, true); }); + connect(userTree, &QTreeWidget::itemCollapsed, this, + [this](QTreeWidgetItem *item) { handleSectionExpansion(item, false); }); + + connect(avatarProvider, &UserAvatarProvider::avatarUpdated, this, &UserListWidget::refreshVisibleUserHeader); + connect(cardArtProvider, &UserCardArtProvider::cardArtUpdated, this, &UserListWidget::refreshVisibleUserHeader); connect(&SettingsCache::instance().appearance(), &AppearanceSettings::styleUserListChanged, this, &UserListWidget::applyDisplayMode); applyDisplayMode(); + // The tree's cached palette can go stale after a runtime theme change (Qt + // freezes widget palettes when the style is switched), so rows and dividers + // derive all colors from the application palette at paint time. The theme + // change only needs a repaint to pick them up. + if (themeManager) { + connect(themeManager, &ThemeManager::themeChanged, this, [this] { + userTree->viewport()->update(); + userTree->update(); + }); + } + QVBoxLayout *vbox = new QVBoxLayout; vbox->addWidget(userTree); @@ -565,6 +524,11 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, retranslateUi(); } +UserListWidget::~UserListWidget() +{ + qApp->removeEventFilter(this); +} + void UserListWidget::bind(UserListManager *mgr) { manager = mgr; @@ -572,50 +536,70 @@ void UserListWidget::bind(UserListManager *mgr) // ── Full rebuild: disconnect / reconnect / bulk initial load ────────────── connect(manager, &UserListManager::listReset, this, &UserListWidget::rebuild); - // ── Online users list (AllUsersList / RoomList) ─────────────────────────── - if (type == AllUsersList || type == RoomList) { + if (!sectioned) { + // Online users list (AllUsersList / RoomList) + if (type == AllUsersList || type == RoomList) { + connect(manager, &UserListManager::userJoinedOnline, this, + [this](const ServerInfo_User &user) { processUserInfo(user, true); }); + connect(manager, &UserListManager::userLeftOnline, this, [this](const QString &name) { deleteUser(name); }); + } + + // Buddy list + if (type == BuddyList) { + connect(manager, &UserListManager::addedToBuddyList, this, [this](const ServerInfo_User &user) { + const QString name = QString::fromStdString(user.name()); + processUserInfo(user, manager->getOnlineUser(name) != nullptr); + }); + connect(manager, &UserListManager::removedFromBuddyList, this, + [this](const QString &name) { deleteUser(name); }); + // Track online presence changes for buddies already in the tree + connect(manager, &UserListManager::userJoinedOnline, this, [this](const ServerInfo_User &user) { + const QString name = QString::fromStdString(user.name()); + if (users.contains(name)) { + users[name]->setUserInfo(user); + setUserOnline(name, true); + } + }); + connect(manager, &UserListManager::userLeftOnline, this, [this](const QString &name) { + if (users.contains(name)) { + setUserOnline(name, false); + } + }); + } + + // Ignore list + if (type == IgnoreList) { + connect(manager, &UserListManager::addedToIgnoreList, this, [this](const ServerInfo_User &user) { + const QString name = QString::fromStdString(user.name()); + processUserInfo(user, manager->getOnlineUser(name) != nullptr); + }); + connect(manager, &UserListManager::removedFromIgnoreList, this, + [this](const QString &name) { deleteUser(name); }); + } + } else { + // Sectioned mode: one tree, every source feeds its own section. + // Sections are pure membership views: the "Online" section holds every + // currently online user, the "Buddy"/"Ignore" sections hold those + // lists. A user can therefore appear in several sections at once (an + // online buddy gets one row in each). connect(manager, &UserListManager::userJoinedOnline, this, - [this](const ServerInfo_User &user) { processUserInfo(user, true); }); - connect(manager, &UserListManager::userLeftOnline, this, [this](const QString &name) { deleteUser(name); }); - } - - // ── Buddy list ──────────────────────────────────────────────────────────── - if (type == BuddyList) { - connect(manager, &UserListManager::addedToBuddyList, this, [this](const ServerInfo_User &user) { - const QString name = QString::fromStdString(user.name()); - processUserInfo(user, manager->getOnlineUser(name) != nullptr); - }); + [this](const ServerInfo_User &user) { handleOnlineChange(user); }); + connect(manager, &UserListManager::userLeftOnline, this, + [this](const QString &name) { handleOnlineChangeLeft(name); }); + connect(manager, &UserListManager::addedToBuddyList, this, + [this](const ServerInfo_User &user) { handleListAdd(Section::Buddy, user); }); connect(manager, &UserListManager::removedFromBuddyList, this, - [this](const QString &name) { deleteUser(name); }); - // Track online presence changes for buddies already in the tree - connect(manager, &UserListManager::userJoinedOnline, this, [this](const ServerInfo_User &user) { - const QString name = QString::fromStdString(user.name()); - if (users.contains(name)) { - users[name]->setUserInfo(user); - setUserOnline(name, true); - } - }); - connect(manager, &UserListManager::userLeftOnline, this, [this](const QString &name) { - if (users.contains(name)) { - setUserOnline(name, false); - } - }); - } - - // ── Ignore list ─────────────────────────────────────────────────────────── - if (type == IgnoreList) { - connect(manager, &UserListManager::addedToIgnoreList, this, [this](const ServerInfo_User &user) { - const QString name = QString::fromStdString(user.name()); - processUserInfo(user, manager->getOnlineUser(name) != nullptr); - }); + [this](const QString &name) { handleListRemove(Section::Buddy, name); }); + connect(manager, &UserListManager::addedToIgnoreList, this, + [this](const ServerInfo_User &user) { handleListAdd(Section::Ignore, user); }); connect(manager, &UserListManager::removedFromIgnoreList, this, - [this](const QString &name) { deleteUser(name); }); + [this](const QString &name) { handleListRemove(Section::Ignore, name); }); } // ── Popup button refresh ────────────────────────────────────────────────── // Any buddy/ignore mutation while the popup is open refreshes its buttons auto refreshIfPopupOpen = [this](const QString &name) { - if (m_userInfoPopup && m_userInfoPopup->isVisible() && m_userInfoPopup->currentUser() == name) { + if (userInfoPopup && userInfoPopup->isVisible() && userInfoPopup->getCurrentUser() == name) { refreshPopupButtons(name); } }; @@ -633,8 +617,22 @@ void UserListWidget::bind(UserListManager *mgr) rebuild(); } +void UserListWidget::refreshVisibleUserHeader(const QString &name) +{ + userTree->viewport()->update(); + if (!userInfoPopup) { + return; + } + if (userInfoPopup->isVisible() && userInfoPopup->getCurrentUser() == name) { + userInfoPopup->refreshHeader(); + } +} + void UserListWidget::refreshPopupButtons(const QString &userName) { + if (!userInfoPopup) { + return; + } UserListTWI *item = users.value(userName); if (!item) { return; @@ -645,29 +643,52 @@ void UserListWidget::refreshPopupButtons(const QString &userName) const bool isBuddy = proxy->isUserBuddy(userName); const bool isIgn = proxy->isUserIgnored(userName); - m_userInfoPopup->updateActionButtons(item->getUserInfo(), online, isBuddy, isIgn); - positionPopup(userName); // height may have changed — reposition + userInfoPopup->updateActionButtons(item->getUserInfo(), online, isBuddy, isIgn); + positionPopup(item); // height may have changed, reposition } void UserListWidget::hideEvent(QHideEvent *e) { QGroupBox::hideEvent(e); - m_showPopupTimer->stop(); - m_hidePopupTimer->stop(); + if (!userInfoPopup) { + return; + } + showPopupTimer->stop(); + hidePopupTimer->stop(); hidePopup(true); } +void UserListWidget::showEvent(QShowEvent *e) +{ + QGroupBox::showEvent(e); + if (!userInfoPopup) { + return; + } + requestVisibleItemResources(); +} + void UserListWidget::applyDisplayMode() { const bool styled = SettingsCache::instance().appearance().getStyleUserList(); + // Both modes must keep the header length at the viewport width: with + // ScrollBarAlwaysOff a nonzero horizontal range is invisible but still + // pans via trackpad gestures, which reads as janky random drift. if (styled) { userTree->header()->setSectionResizeMode(0, QHeaderView::Stretch); userTree->hideColumn(1); userTree->hideColumn(2); userTree->hideColumn(3); } else { - userTree->header()->setSectionResizeMode(QHeaderView::ResizeToContents); + // Bounded widths instead of ResizeToContents: content sizing measures + // the FULL text width while the delegate elides afterwards, so long + // names widened the header past the viewport. Fixed icon columns plus + // a stretched name column keep the range at zero, eliding trims. + userTree->header()->setSectionResizeMode(0, QHeaderView::Fixed); + userTree->header()->resizeSection(0, 24); + userTree->header()->setSectionResizeMode(1, QHeaderView::Fixed); + userTree->header()->resizeSection(1, 22); + userTree->header()->setSectionResizeMode(2, QHeaderView::Stretch); userTree->showColumn(1); userTree->showColumn(2); userTree->hideColumn(3); @@ -678,72 +699,137 @@ void UserListWidget::applyDisplayMode() void UserListWidget::connectPopupSignals() { - connect(m_userInfoPopup, &UserInfoPopup::closeRequested, this, [this] { - m_popupPinned = false; + connect(userInfoPopup, &UserInfoPopup::closeRequested, this, [this] { + popupPinned = false; hidePopup(true); }); - connect(m_userInfoPopup, &UserInfoPopup::mouseEnteredPopup, m_hidePopupTimer, &QTimer::stop); - connect(m_userInfoPopup, &UserInfoPopup::mouseLeftPopup, this, [this] { - if (!m_popupPinned) { - m_hidePopupTimer->start(); + connect(userInfoPopup, &UserInfoPopup::mouseEnteredPopup, hidePopupTimer, &QTimer::stop); + connect(userInfoPopup, &UserInfoPopup::mouseLeftPopup, this, [this] { + if (!popupPinned) { + hidePopupTimer->start(); } }); // Wire all action signals to UserContextMenu::exec*() - connect(m_userInfoPopup, &UserInfoPopup::chatRequested, userContextMenu, &UserContextMenu::execChat); - connect(m_userInfoPopup, &UserInfoPopup::detailsRequested, userContextMenu, &UserContextMenu::execDetails); - connect(m_userInfoPopup, &UserInfoPopup::showGamesRequested, userContextMenu, &UserContextMenu::execShowGames); - connect(m_userInfoPopup, &UserInfoPopup::addBuddyRequested, userContextMenu, &UserContextMenu::execAddToBuddy); - connect(m_userInfoPopup, &UserInfoPopup::removeBuddyRequested, userContextMenu, + connect(userInfoPopup, &UserInfoPopup::chatRequested, userContextMenu, &UserContextMenu::execChat); + connect(userInfoPopup, &UserInfoPopup::inviteRequested, this, + [this](const QString &userName) { userContextMenu->execInvite(userName); }); + connect(userInfoPopup, &UserInfoPopup::detailsRequested, userContextMenu, &UserContextMenu::execDetails); + connect(userInfoPopup, &UserInfoPopup::showGamesRequested, userContextMenu, &UserContextMenu::execShowGames); + connect(userInfoPopup, &UserInfoPopup::addBuddyRequested, userContextMenu, &UserContextMenu::execAddToBuddy); + connect(userInfoPopup, &UserInfoPopup::removeBuddyRequested, userContextMenu, &UserContextMenu::execRemoveFromBuddy); - connect(m_userInfoPopup, &UserInfoPopup::addIgnoreRequested, userContextMenu, &UserContextMenu::execAddToIgnore); - connect(m_userInfoPopup, &UserInfoPopup::removeIgnoreRequested, userContextMenu, + connect(userInfoPopup, &UserInfoPopup::addIgnoreRequested, userContextMenu, &UserContextMenu::execAddToIgnore); + connect(userInfoPopup, &UserInfoPopup::removeIgnoreRequested, userContextMenu, &UserContextMenu::execRemoveFromIgnore); - connect(m_userInfoPopup, &UserInfoPopup::banRequested, userContextMenu, &UserContextMenu::execBan); - connect(m_userInfoPopup, &UserInfoPopup::warnRequested, userContextMenu, &UserContextMenu::execWarn); - connect(m_userInfoPopup, &UserInfoPopup::banHistoryRequested, userContextMenu, &UserContextMenu::execBanHistory); - connect(m_userInfoPopup, &UserInfoPopup::warnHistoryRequested, userContextMenu, &UserContextMenu::execWarnHistory); - connect(m_userInfoPopup, &UserInfoPopup::adminNotesRequested, userContextMenu, &UserContextMenu::execAdminNotes); - connect(m_userInfoPopup, &UserInfoPopup::promoteToModRequested, this, + connect(userInfoPopup, &UserInfoPopup::banRequested, userContextMenu, &UserContextMenu::execBan); + connect(userInfoPopup, &UserInfoPopup::warnRequested, userContextMenu, &UserContextMenu::execWarn); + connect(userInfoPopup, &UserInfoPopup::banHistoryRequested, userContextMenu, &UserContextMenu::execBanHistory); + connect(userInfoPopup, &UserInfoPopup::warnHistoryRequested, userContextMenu, &UserContextMenu::execWarnHistory); + connect(userInfoPopup, &UserInfoPopup::adminNotesRequested, userContextMenu, &UserContextMenu::execAdminNotes); + connect(userInfoPopup, &UserInfoPopup::promoteToModRequested, this, [this](const QString &n) { userContextMenu->execAdjustMod(n, true); }); - connect(m_userInfoPopup, &UserInfoPopup::demoteFromModRequested, this, + connect(userInfoPopup, &UserInfoPopup::demoteFromModRequested, this, [this](const QString &n) { userContextMenu->execAdjustMod(n, false); }); - connect(m_userInfoPopup, &UserInfoPopup::promoteToJudgeRequested, this, + connect(userInfoPopup, &UserInfoPopup::promoteToJudgeRequested, this, [this](const QString &n) { userContextMenu->execAdjustJudge(n, true); }); - connect(m_userInfoPopup, &UserInfoPopup::demoteFromJudgeRequested, this, + connect(userInfoPopup, &UserInfoPopup::demoteFromJudgeRequested, this, [this](const QString &n) { userContextMenu->execAdjustJudge(n, false); }); + + // Joining is a tab-level action, not a per-user one: the popup's games list + // spans every room the user is in, so the target room may not be open yet. + // Hand it to the supervisor, which owns the intent that joins the room if + // needed. The popup closes here because that intent switches tabs, which + // would otherwise strand it on a list that is no longer on screen. + connect(userInfoPopup, &UserInfoPopup::joinGameRequested, this, + [this](const int gameId, const int roomId, const bool asSpectator) { + hidePopup(true); + tabSupervisor->joinGameFromUserCard(gameId, roomId, asSpectator); + }); } bool UserListWidget::eventFilter(QObject *obj, QEvent *event) { - if (obj == userTree->viewport()) { + // A press outside the tree, the popup and any open menu deselects the + // list and closes the popup. The filter is installed application-wide, so + // the target can be any widget in the window or another list. + if (event->type() == QEvent::MouseButtonPress) { + auto *pressTarget = qobject_cast(obj); + if (pressTarget && !isPressInsideListUi(pressTarget)) { + clearSelectionAndClosePopup(); + } + } + + // Keyboard entry to the user context menu: the Menu key (or Shift+F10) + // pops the same menu the right-click shows, anchored to the focused row. + // Divider rows have no menu. Mouse-triggered context events are NOT handled + // here — the delegate's right-press path already pops the menu, and + // handling both would open two menus on one right-click. + if (hasUserInfoPopup && (obj == userTree || obj == userTree->viewport()) && event->type() == QEvent::ContextMenu) { + auto *contextEvent = static_cast(event); + if (contextEvent->reason() == QContextMenuEvent::Keyboard) { + QTreeWidgetItem *current = userTree->currentItem(); + if (current && current->type() == QTreeWidgetItem::Type) { + const QPoint globalPos = userTree->viewport()->mapToGlobal(userTree->visualItemRect(current).center()); + showContextMenu(globalPos, userTree->indexFromItem(current)); + return true; + } + return false; // divider rows: no menu + } + } + + // Keyboard navigation of the section dividers. + // The dividers are selectable so arrow keys land on them. When one is the + // current item, Enter/Space toggle it (like a button) and Left/Right follow + // the tree convention (Left collapses, Right expands). + if (obj == userTree && event->type() == QEvent::KeyPress) { + auto *keyEvent = static_cast(event); + QTreeWidgetItem *current = userTree->currentItem(); + if (sectioned && current && current->type() == SectionItemType) { + const bool toggle = keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter || + keyEvent->key() == Qt::Key_Space; + const bool collapse = keyEvent->key() == Qt::Key_Left && current->isExpanded(); + const bool expand = keyEvent->key() == Qt::Key_Right && !current->isExpanded(); + if (toggle || collapse || expand) { + const bool expanded = toggle ? !current->isExpanded() : expand; + setExpandedProgrammatically(current, expanded); + handleSectionExpansion(current, expanded); + return true; + } + } + } + + if (hasUserInfoPopup && obj == userTree->viewport()) { if (event->type() == QEvent::MouseMove) { if (!SettingsCache::instance().appearance().getStyleUserList()) { return QGroupBox::eventFilter(obj, event); } auto *me = static_cast(event); - auto *twi = static_cast(userTree->itemAt(me->pos())); - const QString hovName = twi ? QString::fromStdString(twi->getUserInfo().name()) : QString{}; + QTreeWidgetItem *hoveredItem = userTree->itemAt(me->pos()); + QString hovName; + if (hoveredItem && hoveredItem->type() == QTreeWidgetItem::Type) { + hovName = QString::fromStdString(static_cast(hoveredItem)->getUserInfo().name()); + } - if (hovName != m_hoveredUser) { - m_hoveredUser = hovName; + if (hovName != hoveredUser) { + hoveredUser = hovName; if (!hovName.isEmpty()) { - m_hidePopupTimer->stop(); - if (!m_popupPinned) { - m_showPopupTimer->start(); + hidePopupTimer->stop(); + if (!popupPinned) { + showPopupTimer->start(); } } else { - m_showPopupTimer->stop(); - if (!m_popupPinned) { - m_hidePopupTimer->start(); + showPopupTimer->stop(); + if (!popupPinned) { + hidePopupTimer->start(); } } } } else if (event->type() == QEvent::Leave) { - m_hoveredUser.clear(); - m_showPopupTimer->stop(); - if (!m_popupPinned) { - m_hidePopupTimer->start(); + hoveredUser.clear(); + showPopupTimer->stop(); + if (!popupPinned) { + hidePopupTimer->start(); } } } @@ -751,41 +837,76 @@ bool UserListWidget::eventFilter(QObject *obj, QEvent *event) return QGroupBox::eventFilter(obj, event); } -void UserListWidget::showPopupForUser(const QString &userName) +void UserListWidget::showPopupForUser(UserListTWI *item) { - UserListTWI *item = users.value(userName); + if (!userInfoPopup) { + return; + } if (!item) { return; } + const QString userName = QString::fromStdString(item->getUserInfo().name()); + avatarProvider->requestAvatar(userName); // ensure the hovered user's avatar is fetched promptly + if (cardArtParamsMap.contains(userName)) { + const CardArtParams ¶ms = cardArtParamsMap.value(userName); + cardArtProvider->requestCardArt(userName, params.cardName, params.cardProviderId); + } + const ServerInfo_User &info = item->getUserInfo(); const bool online = item->data(0, UserListRoles::Online).toBool(); const bool isBuddy = userContextMenu->getUserListProxy()->isUserBuddy(userName); const bool isIgn = userContextMenu->getUserListProxy()->isUserIgnored(userName); - m_userInfoPopup->showForUser(userName, info, online, isBuddy, isIgn); + // The popup is already showing this user (e.g. arrow key navigation between + // the online/buddy rows of the same user): just reposition it. + if (userInfoPopup->isVisible() && userInfoPopup->getCurrentUser() == userName) { + positionPopup(item); + return; + } - // Realize the native window at opacity 0 before positioning so that: - // 1) move() applies to an existing native handle (not overridden by Qt's - // default centering logic on first show) - // 2) adjustSize() inside positionPopup() can measure the final laid-out - // geometry correctly - m_userInfoPopup->setWindowOpacity(0.0); - m_userInfoPopup->show(); - m_userInfoPopup->raise(); + // Cancel any pending show/hide so a hover timer that armed before a row + // selected via the keyboard cannot override it, and a pending hide cannot + // kill the popup right after it appears. + showPopupTimer->stop(); + hidePopupTimer->stop(); - positionPopup(userName); // geometry is now accurate; move() sticks + userInfoPopup->showForUser(userName, info, online, isBuddy, isIgn); - auto *fade = new QPropertyAnimation(m_userInfoPopup, "windowOpacity", m_userInfoPopup); + const bool wasVisible = userInfoPopup->isVisible(); + if (!wasVisible) { + // Realize the native window at opacity 0 before positioning so that: + // 1) move() applies to an existing native handle (not overridden by + // Qt's default centering logic on first show) + // 2) adjustSize() inside positionPopup() can measure the final + // laid out geometry correctly + userInfoPopup->setWindowOpacity(0.0); + } + userInfoPopup->show(); + userInfoPopup->raise(); + + positionPopup(item); // geometry is accurate after show, so move() is not overridden + + if (wasVisible) { + // Content swap while already open (hover or arrow key navigation): + // keep the popup opaque instead of flashing through a fade on every + // step. + userInfoPopup->setWindowOpacity(1.0); + return; + } + + auto *fade = new QPropertyAnimation(userInfoPopup, "windowOpacity", userInfoPopup); fade->setDuration(120); fade->setStartValue(0.0); fade->setEndValue(1.0); fade->start(QAbstractAnimation::DeleteWhenStopped); } -void UserListWidget::positionPopup(const QString &userName) +void UserListWidget::positionPopup(UserListTWI *item) { - UserListTWI *item = users.value(userName); + if (!userInfoPopup) { + return; + } if (!item) { return; } @@ -796,12 +917,17 @@ void UserListWidget::positionPopup(const QString &userName) const QPoint vpTL = vp->mapToGlobal(vp->rect().topLeft()); const QPoint vpTR = vp->mapToGlobal(vp->rect().topRight()); - m_userInfoPopup->adjustSize(); - const int popW = m_userInfoPopup->width(); - const int popH = m_userInfoPopup->height(); + userInfoPopup->adjustSize(); + const int popW = userInfoPopup->width(); + const int popH = userInfoPopup->height(); const int margin = 12; - const QRect screen = QGuiApplication::primaryScreen()->availableGeometry(); + QScreen *activeScreen = QGuiApplication::screenAt(itemTL); + if (!activeScreen) { + activeScreen = window()->screen(); + } + const QRect screen = + activeScreen ? activeScreen->availableGeometry() : QGuiApplication::primaryScreen()->availableGeometry(); // ── X: prefer the side with more space ─────────────────────────────────── const int spaceLeft = vpTL.x() - screen.left() - margin; @@ -830,31 +956,53 @@ void UserListWidget::positionPopup(const QString &userName) } y = qBound(screen.top() + margin, y, screen.bottom() - popH - margin); - m_userInfoPopup->move(x, y); + userInfoPopup->move(x, y); } void UserListWidget::hidePopup(bool immediate) { - m_showPopupTimer->stop(); - m_hidePopupTimer->stop(); - if (!m_userInfoPopup->isVisible()) { + if (!userInfoPopup) { + return; + } + showPopupTimer->stop(); + hidePopupTimer->stop(); + if (!userInfoPopup->isVisible()) { return; } if (immediate) { - m_userInfoPopup->hide(); + userInfoPopup->hide(); return; } // Fade out - auto *fade = new QPropertyAnimation(m_userInfoPopup, "windowOpacity", m_userInfoPopup); + auto *fade = new QPropertyAnimation(userInfoPopup, "windowOpacity", userInfoPopup); fade->setDuration(100); - fade->setStartValue(m_userInfoPopup->windowOpacity()); + fade->setStartValue(userInfoPopup->windowOpacity()); fade->setEndValue(0.0); - connect(fade, &QPropertyAnimation::finished, m_userInfoPopup, &QWidget::hide); + connect(fade, &QPropertyAnimation::finished, userInfoPopup, &QWidget::hide); fade->start(QAbstractAnimation::DeleteWhenStopped); } +bool UserListWidget::isPressInsideListUi(const QWidget *widget) const +{ + const QWidget *w = widget; + while (w) { + if (w == userTree || w == userInfoPopup || qobject_cast(w)) { + return true; + } + w = w->parentWidget(); + } + return false; +} + +void UserListWidget::clearSelectionAndClosePopup() +{ + popupPinned = false; + hidePopup(true); + userTree->clearSelection(); +} + void UserListWidget::retranslateUi() { userContextMenu->retranslateUi(); @@ -875,17 +1023,100 @@ void UserListWidget::retranslateUi() updateCount(); } +void UserListWidget::beginBulkLoad() +{ + bulkLoading = true; +} + +void UserListWidget::endBulkLoad() +{ + bulkLoading = false; + sortItems(); + updateCount(); // divider counts were deferred during the bulk build + requestVisibleItemResources(); + userTree->viewport()->update(); +} + +bool UserListWidget::isItemNearViewport(const UserListTWI *item) const +{ + // Prefetch a full viewport of rows above and below so scrolling never shows + // an unloaded row. + const QRect nearView = + userTree->viewport()->rect().adjusted(0, -userTree->viewport()->height(), 0, userTree->viewport()->height()); + return userTree->visualItemRect(item).intersects(nearView); +} + +void UserListWidget::requestVisibleItemResources() +{ + const auto requestResources = [this](UserListTWI *twi) { + if (!isItemNearViewport(twi)) { + return; + } + const QString userName = QString::fromStdString(twi->getUserInfo().name()); + avatarProvider->requestAvatar(userName); + if (cardArtParamsMap.contains(userName)) { + const CardArtParams ¶ms = cardArtParamsMap.value(userName); + cardArtProvider->requestCardArt(userName, params.cardName, params.cardProviderId); + } + }; + + if (sectioned) { + // Top level items are dividers, user rows hang below them. + for (const Section section : sectionIds) { + QTreeWidgetItem *divider = sectionItems.value(section); + if (!divider) { + continue; + } + for (int i = 0; i < divider->childCount(); ++i) { + requestResources(static_cast(divider->child(i))); + } + } + return; + } + + for (int i = 0; i < userTree->topLevelItemCount(); ++i) { + requestResources(static_cast(userTree->topLevelItem(i))); + } +} + void UserListWidget::rebuild() { userTree->clear(); users.clear(); + sectionUsers.clear(); cardArtParamsMap.clear(); onlineCount = 0; + if (sectioned) { + createSectionItems(); + } + if (!manager) { return; } + if (sectioned) { + // Every source feeds its own section. Users that belong to several + // sources (an online buddy) get one row per section because + // ensureSectionMembership() creates the row when it is missing. + beginBulkLoad(); + const auto &onlineUsers = manager->getAllUsersList(); + for (auto it = onlineUsers.cbegin(); it != onlineUsers.cend(); ++it) { + processUserInfo(Section::Online, it.value(), true); + } + const auto &buddyUsers = manager->getBuddyList(); + for (auto it = buddyUsers.cbegin(); it != buddyUsers.cend(); ++it) { + processUserInfo(Section::Buddy, it.value(), manager->getOnlineUser(it.key()) != nullptr); + } + const auto &ignoreUsers = manager->getIgnoreList(); + for (auto it = ignoreUsers.cbegin(); it != ignoreUsers.cend(); ++it) { + processUserInfo(Section::Ignore, it.value(), manager->getOnlineUser(it.key()) != nullptr); + } + endBulkLoad(); + applyFilter(); + return; + } + const QMap *source = nullptr; switch (type) { @@ -901,19 +1132,18 @@ void UserListWidget::rebuild() break; } + beginBulkLoad(); for (auto it = source->cbegin(); it != source->cend(); ++it) { processUserInfo(it.value(), manager->getOnlineUser(it.key()) != nullptr); } - - sortItems(); + endBulkLoad(); + applyFilter(); } -void UserListWidget::processUserInfo(const ServerInfo_User &user, bool online) +void UserListWidget::updateCardArtParams(const ServerInfo_User &user, const QString &userName) { - const QString userName = QString::fromStdString(user.name()); - // Always update params from the latest ServerInfo_User, whether the - // item is new or existing, so a live server-push refreshes the rendering. + // item is new or existing, so a live server push refreshes the rendering. if (user.has_card_art_params()) { const auto &cap = user.card_art_params(); CardArtParams params; @@ -925,9 +1155,20 @@ void UserListWidget::processUserInfo(const ServerInfo_User &user, bool online) params.zoom = cap.zoom(); cardArtParamsMap.insert(userName, params); cardArtProvider->requestCardArt(userName, params.cardName, params.cardProviderId); - } else { - cardArtParamsMap.remove(userName); // clear stale params on removal } + // Intentionally no removal branch: buddy/ignore list copies never carry + // card_art_params (the server omits the column), so a params-less copy here + // means "this snapshot doesn't include it", not "the banner was removed". + // Removing on such copies would wipe banners that the live online list set. + // The map is rebuilt from scratch (clear() + repopulate) on every rebuild, + // which is what actually drops stale entries. +} + +void UserListWidget::processUserInfo(const ServerInfo_User &user, bool online) +{ + const QString userName = QString::fromStdString(user.name()); + + updateCardArtParams(user, userName); UserListTWI *item = users.value(userName); if (item) { @@ -940,37 +1181,92 @@ void UserListWidget::processUserInfo(const ServerInfo_User &user, bool online) ++onlineCount; } updateCount(); - avatarProvider->requestAvatar(userName); + if (!bulkLoading && isItemNearViewport(item)) { + avatarProvider->requestAvatar(userName); + } } item->setOnline(online); - sortItems(); - userTree->viewport()->update(); + if (!bulkLoading) { + sortItems(); + applyFilter(); + userTree->viewport()->update(); + } +} + +void UserListWidget::processUserInfo(Section section, const ServerInfo_User &user, bool online) +{ + ensureSectionMembership(section, user, online); + if (!bulkLoading) { + sortItems(); + applyFilter(); + userTree->viewport()->update(); + } } bool UserListWidget::deleteUser(const QString &userName) { + if (sectioned) { + // The user may own several rows (one per section). Drop them all. + bool removed = false; + const QList
sections = sectionUsers.keys(); // snapshot: maps mutate + for (const Section section : sections) { + removed = dropSectionMembership(section, userName) || removed; + } + if (removed && !bulkLoading) { + sortItems(); + applyFilter(); + userTree->viewport()->update(); + } + return removed; + } + UserListTWI *twi = users.value(userName); if (!twi) { return false; } users.remove(userName); - userTree->takeTopLevelItem(userTree->indexOfTopLevelItem(twi)); - if (twi->data(0, Qt::UserRole + 1).toBool()) { + if (twi->parent()) { + twi->parent()->removeChild(twi); // sectioned mode: rows hang off a divider + } else { + userTree->takeTopLevelItem(userTree->indexOfTopLevelItem(twi)); + } + if (twi->data(0, UserListRoles::Online).toBool()) { --onlineCount; } delete twi; updateCount(); + applyFilter(); return true; } void UserListWidget::setUserOnline(const QString &userName, bool online) { + if (sectioned) { + // The rows in the "Online" section are created/removed by the presence + // handlers. This only keeps the presence flag of the surviving rows + // (e.g. a buddy row after the user went offline) in sync. + for (auto it = sectionUsers.cbegin(); it != sectionUsers.cend(); ++it) { + UserListTWI *item = it.value().value(userName); + if (item) { + item->setOnline(online); + } + } + return; + } + UserListTWI *twi = users.value(userName); if (!twi) { return; } + // No state change: nothing to resort. This also keeps the presence + // broadcasts cheap (userJoinedOnline fires once per online user) when the + // row already carries the right flag. + if (twi->data(0, UserListRoles::Online).toBool() == online) { + return; + } + twi->setOnline(online); if (online) { ++onlineCount; @@ -978,26 +1274,158 @@ void UserListWidget::setUserOnline(const QString &userName, bool online) --onlineCount; } updateCount(); + + // Online users sort above offline users (UserListTWI::operator<), so a + // flag change moves the row. Resort to place the user by the new state. + if (!bulkLoading) { + sortItems(); + applyFilter(); + userTree->viewport()->update(); + } } void UserListWidget::updateCount() { - QString str = titleStr; - if ((type == BuddyList) || (type == IgnoreList)) { - str = str.arg(onlineCount); + if (sectioned) { + // The dividers carry the section titles + setTitle(QString()); + for (const Section section : sectionIds) { + updateSectionDivider(section); + } + return; } - setTitle(str.arg(userTree->topLevelItemCount())); + + if (showTitle) { + QString str = titleStr; + if ((type == BuddyList) || (type == IgnoreList)) { + str = str.arg(onlineCount); + } + setTitle(str.arg(userTree->topLevelItemCount())); + } else { + setTitle(QString()); + } +} + +void UserListWidget::setShowTitle(bool _showTitle) +{ + this->showTitle = _showTitle; + updateCount(); +} + +void UserListWidget::setFilterText(const QString &text) +{ + if (filterText == text) { + return; + } + filterText = text; + applyFilter(); +} + +void UserListWidget::applyFilter() +{ + if (sectioned) { + const bool searching = !filterText.isEmpty(); + const QString lower = filterText.toLower(); + for (const Section section : sectionIds) { + QTreeWidgetItem *divider = sectionItems.value(section); + if (!divider) { + continue; + } + int visible = 0; + for (int i = 0; i < divider->childCount(); ++i) { + auto *child = static_cast(divider->child(i)); + const QString name = QString::fromStdString(child->getUserInfo().name()); + const bool passesFilter = + !userFilter || userFilter(name, child->data(0, UserListRoles::Online).toBool()); + const bool match = passesFilter && (!searching || name.toLower().contains(lower)); + child->setHidden(!match); + if (match) { + ++visible; + } + } + if (searching) { + // During a search the sections with matches stay open and empty + // sections disappear entirely. The persisted expansion state is + // untouched and restored when the search is cleared. + divider->setHidden(visible == 0); + setExpandedProgrammatically(divider, visible > 0); + } else { + divider->setHidden(false); + setExpandedProgrammatically(divider, expandedSections.contains(section)); + } + updateSectionDivider(section); + } + requestVisibleItemResources(); + userTree->viewport()->update(); + emit userListChanged(); + return; + } + + if (filterText.isEmpty()) { + for (auto it = users.cbegin(); it != users.cend(); ++it) { + it.value()->setHidden(false); + } + } else { + const QString lower = filterText.toLower(); + for (auto it = users.cbegin(); it != users.cend(); ++it) { + const bool match = QString::fromStdString(it.value()->getUserInfo().name()).toLower().contains(lower); + it.value()->setHidden(!match); + } + } + + requestVisibleItemResources(); + userTree->viewport()->update(); + emit userListChanged(); } void UserListWidget::userClicked(QTreeWidgetItem *item, int /*column*/) { - emit openMessageDialog(item->data(2, Qt::UserRole).toString(), true); + if (item->type() != QTreeWidgetItem::Type) { + return; // divider rows open no chat + } + const QString userName = item->data(2, Qt::UserRole).toString(); + if (hasUserInfoPopup) { + emit openMessageDialog(userName, true); + } else { + emit userActivated(userName); + } +} + +int UserListWidget::visibleUserRowCount() const +{ + int count = 0; + if (sectioned) { + for (const Section section : sectionIds) { + QTreeWidgetItem *divider = sectionItems.value(section); + if (!divider || divider->isHidden()) { + continue; + } + for (int i = 0; i < divider->childCount(); ++i) { + if (!divider->child(i)->isHidden()) { + ++count; + } + } + } + return count; + } + for (int i = 0; i < userTree->topLevelItemCount(); ++i) { + QTreeWidgetItem *item = userTree->topLevelItem(i); + if (!item->isHidden() && item->type() == QTreeWidgetItem::Type) { + ++count; + } + } + return count; } void UserListWidget::showContextMenu(const QPoint &pos, const QModelIndex &index) { - const ServerInfo_User &userInfo = static_cast(userTree->topLevelItem(index.row()))->getUserInfo(); - bool online = index.sibling(index.row(), 0).data(Qt::UserRole + 1).toBool(); + QTreeWidgetItem *item = userTree->itemFromIndex(index); + if (!item || item->type() != QTreeWidgetItem::Type) { + return; // divider rows have no user menu + } + const auto *userItem = static_cast(item); + const ServerInfo_User &userInfo = userItem->getUserInfo(); + const bool online = userItem->data(0, UserListRoles::Online).toBool(); userContextMenu->showContextMenu(pos, QString::fromStdString(userInfo.name()), UserLevelFlags(userInfo.user_level()), online); @@ -1005,5 +1433,313 @@ void UserListWidget::showContextMenu(const QPoint &pos, const QModelIndex &index void UserListWidget::sortItems() { + if (sectioned) { + // Sorting must stay inside each section so the dividers keep their + // places as top level items. + for (auto it = sectionItems.cbegin(); it != sectionItems.cend(); ++it) { + it.value()->sortChildren(0, Qt::AscendingOrder); + } + return; + } userTree->sortItems(0, Qt::AscendingOrder); } + +// Sectioned mode + +void UserListWidget::setSectioned(const QList
&ids) +{ + if (sectioned || ids.isEmpty()) { + return; + } + + sectioned = true; + sectionIds = ids; + expandedSections.clear(); + for (const Section section : sectionIds) { + expandedSections.insert(section); // everything starts expanded + } + + // The single tree owns scrolling and the dividers carry the section titles, + // so the group box chrome and tree decorations collapse into a flat list. + setFlat(true); + setShowTitle(false); + userTree->setFrameStyle(QFrame::NoFrame); + // No tree branches: the dividers draw their own arrow glyph, so the rows can + // sit flush with the left border. + userTree->setRootIsDecorated(false); + userTree->setIndentation(0); + userTree->setAlternatingRowColors(false); + if (auto *listLayout = layout()) { + listLayout->setContentsMargins(0, 0, 0, 0); + } + + createSectionItems(); + updateCount(); +} + +void UserListWidget::createSectionItems() +{ + sectionItems.clear(); + QSignalBlocker blocker(userTree); // no expansion signals while building + for (const Section section : sectionIds) { + QTreeWidgetItem *divider = createSectionItem(section); + sectionItems.insert(section, divider); + divider->setExpanded(expandedSections.contains(section)); + } +} + +QTreeWidgetItem *UserListWidget::createSectionItem(Section section) +{ + Q_UNUSED(section); + auto *divider = new QTreeWidgetItem(SectionItemType); + // Selectable so keyboard navigation (Up/Down) can land on the dividers. + // They act as collapsible section headers once they have focus. + divider->setFlags(Qt::ItemIsEnabled | Qt::ItemIsSelectable); + + QFont font = userTree->font(); + font.setBold(true); + divider->setFont(0, font); + // A little taller than a plain text row so the header reads as a section + // separator without matching the full user row height. + divider->setSizeHint(0, QSize(0, QFontMetrics(font).height() + 16)); + + userTree->addTopLevelItem(divider); + // QTreeWidgetItem::setFirstColumnSpanned() does nothing while the item is + // detached from the tree (Qt returns early when treeModel() is null), so it + // must be called after addTopLevelItem(). Without the span the divider text + // is confined to column 0 and gets elided in unstyled mode. + divider->setFirstColumnSpanned(true); + return divider; +} + +QString UserListWidget::sectionTitle(Section section) const +{ + switch (section) { + case Section::Buddy: + return tr("Buddies"); + case Section::Online: + return tr("Online"); + case Section::Ignore: + return tr("Ignored"); + } + return {}; +} + +void UserListWidget::updateSectionDivider(Section section) +{ + QTreeWidgetItem *divider = sectionItems.value(section); + if (!divider) { + return; + } + int visible = 0; + int online = 0; + for (int i = 0; i < divider->childCount(); ++i) { + QTreeWidgetItem *child = divider->child(i); + if (!child->isHidden()) { + ++visible; + if (child->data(0, UserListRoles::Online).toBool()) { + ++online; + } + } + } + // The tree draws no branches (rows are flush), so the divider carries its + // own collapse arrow glyph. + const QString arrow = divider->isExpanded() ? QStringLiteral("\u25BE") : QStringLiteral("\u25B8"); + if (section == Section::Buddy) { + // The buddy divider reports how many of the shown buddies are online, + // mirroring the "Buddies online: %1 / %2" title of the non-sectioned + // buddy list. + divider->setText(0, tr("%1 %2 (%3/%4)").arg(arrow, sectionTitle(section)).arg(online).arg(visible)); + } else { + divider->setText(0, tr("%1 %2 (%3)").arg(arrow, sectionTitle(section)).arg(visible)); + } +} + +void UserListWidget::handleSectionExpansion(QTreeWidgetItem *item, bool expanded) +{ + if (!sectioned || item->type() != SectionItemType) { + return; + } + // Reverse lookup. Only three dividers exist, so a linear scan over the + // section map is cheaper than caching the section on each divider. + auto dividerIt = sectionItems.constBegin(); + while (dividerIt != sectionItems.constEnd() && dividerIt.value() != item) { + ++dividerIt; + } + if (dividerIt == sectionItems.constEnd()) { + return; + } + const Section section = dividerIt.key(); + if (expanded) { + expandedSections.insert(section); + } else { + expandedSections.remove(section); + } + updateSectionDivider(section); // the arrow glyph follows the state + emit sectionExpanded(section, expanded); +} + +void UserListWidget::setExpandedProgrammatically(QTreeWidgetItem *item, bool expanded) +{ + QSignalBlocker blocker(userTree); + item->setExpanded(expanded); +} + +void UserListWidget::setSectionExpanded(Section section, bool expanded) +{ + if (!sectioned) { + return; + } + if (expanded) { + expandedSections.insert(section); + } else { + expandedSections.remove(section); + } + QTreeWidgetItem *divider = sectionItems.value(section); + if (!divider) { + return; + } + QSignalBlocker blocker(userTree); + divider->setExpanded(expanded); + updateSectionDivider(section); // the arrow glyph follows the state + userTree->viewport()->update(); +} + +void UserListWidget::handleOnlineChange(const ServerInfo_User &user) +{ + // A user came online: they get a row in the "Online" section, plus (if + // applicable) a row in the buddy/ignore sections, which flip to online. + const QString name = QString::fromStdString(user.name()); + ensureSectionMembership(Section::Online, user, true); + if (manager->isUserBuddy(name)) { + ensureSectionMembership(Section::Buddy, user, true); + } + if (manager->isUserIgnored(name)) { + ensureSectionMembership(Section::Ignore, user, true); + } + finishSectionedMutation(); +} + +void UserListWidget::handleOnlineChangeLeft(const QString &userName) +{ + // The user is no longer online: their "Online" row disappears. Buddies and + // ignored users keep their own section's row, marked offline. A plain user + // has no rows left. + const bool dropped = dropSectionMembership(Section::Online, userName); + const bool kept = manager->isUserBuddy(userName) || manager->isUserIgnored(userName); + if (kept) { + setUserOnline(userName, false); + } + if (dropped || kept) { + finishSectionedMutation(); + } +} + +void UserListWidget::handleListAdd(Section section, const ServerInfo_User &user) +{ + const QString name = QString::fromStdString(user.name()); + const bool online = manager->getOnlineUser(name) != nullptr; + ensureSectionMembership(section, user, online); + if (online) { + // The user belongs to the "Online" section as well. Make sure the row + // exists even if the join event raced ahead of the list mutation. + ensureSectionMembership(Section::Online, user, true); + } + finishSectionedMutation(); +} + +void UserListWidget::handleListRemove(Section section, const QString &userName) +{ + // Only the row of the removed section disappears: an online user keeps + // their "Online" row, and other list memberships keep theirs. + if (dropSectionMembership(section, userName)) { + finishSectionedMutation(); + } +} + +UserListTWI *UserListWidget::ensureSectionMembership(Section section, const ServerInfo_User &user, bool online) +{ + const QString userName = QString::fromStdString(user.name()); + + updateCardArtParams(user, userName); + + // Dialog mode: rows that fail the user filter never exist. applyFilter() + // re-checks the predicate on every pass so a live state change (e.g. the + // user being ignored mid-dialog) hides an already created row. + if (userFilter && !userFilter(userName, online)) { + return nullptr; + } + + QTreeWidgetItem *divider = sectionItems.value(section); + if (!divider) { + return nullptr; + } + + QMap §ionMap = sectionUsers[section]; + UserListTWI *item = sectionMap.value(userName); + if (!item) { + item = new UserListTWI(user); + sectionMap.insert(userName, item); + divider->addChild(item); + if (!users.contains(userName)) { + users.insert(userName, item); // primary row for lookups by name + } + // The divider counts are refreshed once in endBulkLoad(). Calling + // updateCount() per row during a large rebuild would be quadratic. + if (!bulkLoading) { + updateCount(); // a new row changes the divider's count + } + if (!bulkLoading && isItemNearViewport(item)) { + avatarProvider->requestAvatar(userName); + } + } else { + item->setUserInfo(user); + } + item->setOnline(online); + return item; +} + +bool UserListWidget::dropSectionMembership(Section section, const QString &userName) +{ + QMap §ionMap = sectionUsers[section]; + UserListTWI *item = sectionMap.take(userName); + if (!item) { + return false; + } + + if (item->parent()) { + item->parent()->removeChild(item); + } else { + userTree->takeTopLevelItem(userTree->indexOfTopLevelItem(item)); + } + if (users.value(userName) == item) { + // Repoint the primary row at another surviving row, if any. + UserListTWI *replacement = nullptr; + for (auto it = sectionUsers.cbegin(); it != sectionUsers.cend() && !replacement; ++it) { + replacement = it.value().value(userName); + } + if (replacement) { + users.insert(userName, replacement); + } else { + users.remove(userName); + } + } + delete item; + updateCount(); + return true; +} + +void UserListWidget::finishSectionedMutation() +{ + if (bulkLoading) { + return; + } + sortItems(); + applyFilter(); + userTree->viewport()->update(); +} + +void UserListWidget::setGameInviteLinkProvider(std::function()> provider) +{ + userContextMenu->setGameInviteLinkProvider(std::move(provider)); +} diff --git a/cockatrice/src/interface/widgets/server/user/user_list_widget.h b/cockatrice/src/interface/widgets/server/user/user_list_widget.h index d70cdfbbd..8093222ea 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.h +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.h @@ -7,107 +7,46 @@ #ifndef USERLIST_H #define USERLIST_H -#include "../../cards/card_info_picture_art_crop_widget.h" -#include "user_avatar_provider.h" -#include "user_card_art_provider.h" -#include "user_info_popup.h" -#include "user_list_manager.h" -#include "user_list_painter.h" +#include "libcockatrice/protocol/pb/serverinfo_user.pb.h" -#include -#include #include -#include +#include +#include +#include +#include #include -#include #include -#include -#include +#include +#include +#include -class QTreeWidget; -class ServerInfo_User; class AbstractClient; class TabSupervisor; -class QLabel; -class QCheckBox; -class QSpinBox; -class QRadioButton; -class QPlainTextEdit; -class Response; -class CommandContainer; class UserContextMenu; - -class BanDialog : public QDialog -{ - Q_OBJECT -private: - QLabel *daysLabel, *hoursLabel, *minutesLabel; - QCheckBox *nameBanCheckBox, *ipBanCheckBox, *idBanCheckBox, *deleteMessages; - QLineEdit *nameBanEdit, *ipBanEdit, *idBanEdit; - QSpinBox *daysEdit, *hoursEdit, *minutesEdit; - QRadioButton *permanentRadio, *temporaryRadio; - QPlainTextEdit *reasonEdit, *visibleReasonEdit; -private slots: - void okClicked(); - void enableTemporaryEdits(bool enabled); - -public: - explicit BanDialog(const ServerInfo_User &info, QWidget *parent = nullptr); - [[nodiscard]] QString getBanName() const; - [[nodiscard]] QString getBanIP() const; - [[nodiscard]] QString getBanId() const; - [[nodiscard]] int getMinutes() const; - [[nodiscard]] QString getReason() const; - [[nodiscard]] QString getVisibleReason() const; - [[nodiscard]] int getDeleteMessages() const; -}; - -class WarningDialog : public QDialog -{ - Q_OBJECT -private: - QLabel *descriptionLabel; - QLineEdit *nameWarning; - QComboBox *warningOption; - QLineEdit *warnClientID; - QCheckBox *deleteMessages; -private slots: - void okClicked(); - -public: - WarningDialog(const QString userName, const QString clientID, QWidget *parent = nullptr); - [[nodiscard]] QString getName() const; - [[nodiscard]] QString getWarnID() const; - [[nodiscard]] QString getReason() const; - [[nodiscard]] int getDeleteMessages() const; - void addWarningOption(const QString warning); -}; - -class AdminNotesDialog : public QDialog -{ - Q_OBJECT - -private: - QString userName; - QPlainTextEdit *notes; - -public: - explicit AdminNotesDialog(const QString &_userName, const QString &_notes, QWidget *_parent = nullptr); - [[nodiscard]] QString getName() const - { - return userName; - } - [[nodiscard]] QString getNotes() const; -}; +class UserListWidget; +class QModelIndex; +class QPixmap; +class QPoint; +class QTimer; +class QWidget; +class UserAvatarProvider; +class UserCardArtProvider; +class UserInfoPopup; +class UserListManager; +struct CardArtParams; +struct GameInviteOption; class UserListItemDelegate : public QStyledItemDelegate { + QTreeWidget *tree; + UserListWidget *owner; const QMap *avatarCache; const QMap *cardArtCache; const QMap *cardArtParamsMap; public: - explicit UserListItemDelegate(QObject *const parent, + explicit UserListItemDelegate(UserListWidget *owner, + QTreeWidget *tree, const QMap *avatarCache, const QMap *cardArtCache, const QMap *cardArtParamsMap); @@ -146,6 +85,12 @@ public: BuddyList, IgnoreList }; + enum class Section + { + Buddy, + Online, + Ignore + }; private: UserListManager *manager = nullptr; @@ -153,60 +98,135 @@ private: UserCardArtProvider *cardArtProvider = nullptr; QMap cardArtParamsMap; // ── Hover popup ─────────────────────────────────────────────────────────── - UserInfoPopup *m_userInfoPopup = nullptr; - QTimer *m_showPopupTimer = nullptr; - QTimer *m_hidePopupTimer = nullptr; - QString m_hoveredUser; - bool m_popupPinned = false; + UserInfoPopup *userInfoPopup = nullptr; + QTimer *showPopupTimer = nullptr; + QTimer *hidePopupTimer = nullptr; + QString hoveredUser; + bool popupPinned = false; + bool bulkLoading = false; + bool hasUserInfoPopup = true; + std::function userFilter; - void showPopupForUser(const QString &userName); + /** + * Popup functions are anchored on the row, not the user name. In sectioned + * mode a user can own several rows (online + buddy), and the popup must + * follow the hovered/selected row rather than a lookup by name. + */ + void showPopupForUser(UserListTWI *item); void hidePopup(bool immediate = false); - void positionPopup(const QString &userName); + void positionPopup(UserListTWI *item); void connectPopupSignals(); + /** True when @p widget is the tree, the popup or an open menu. */ + bool isPressInsideListUi(const QWidget *widget) const; + void clearSelectionAndClosePopup(); + bool isItemNearViewport(const UserListTWI *item) const; + void requestVisibleItemResources(); + + // Sectioned mode (single tree with inline dividers) + bool sectioned = false; + QList
sectionIds; + QMap sectionItems; + // One row per (section, user): a user that is online AND a buddy appears in + // both the "Online" and the "Buddies" sections, so the same user can own + // several rows, each hanging off its section's divider. + QMap> sectionUsers; + QSet
expandedSections; + void createSectionItems(); + QTreeWidgetItem *createSectionItem(Section section); + [[nodiscard]] QString sectionTitle(Section section) const; + void updateSectionDivider(Section section); + void handleSectionExpansion(QTreeWidgetItem *item, bool expanded); + void setExpandedProgrammatically(QTreeWidgetItem *item, bool expanded); + void handleOnlineChange(const ServerInfo_User &user); + void handleOnlineChangeLeft(const QString &userName); + void handleListAdd(Section section, const ServerInfo_User &user); + void handleListRemove(Section section, const QString &userName); + /** Creates or updates the row for @p user in @p section. */ + UserListTWI *ensureSectionMembership(Section section, const ServerInfo_User &user, bool online); + /** Removes and deletes the row for @p userName in @p section. */ + bool dropSectionMembership(Section section, const QString &userName); + /** Sorts, refilters and repaints after a sectioned mode mutation. */ + void finishSectionedMutation(); + void updateCardArtParams(const ServerInfo_User &user, const QString &userName); + void processUserInfo(Section section, const ServerInfo_User &user, bool online); QMap users; TabSupervisor *tabSupervisor; AbstractClient *client; UserListType type; - QTreeWidget *userTree; + QTreeWidget *userTree = nullptr; UserListItemDelegate *itemDelegate; UserContextMenu *userContextMenu; int onlineCount; QString titleStr; + QString filterText; + bool showTitle = true; void updateCount(); + void applyFilter(); void refreshPopupButtons(const QString &userName); private slots: void userClicked(QTreeWidgetItem *item, int column); + void refreshVisibleUserHeader(const QString &name); signals: void openMessageDialog(const QString &userName, bool focus); void addBuddy(const QString &userName); void removeBuddy(const QString &userName); void addIgnore(const QString &userName); void removeIgnore(const QString &userName); - void joinGameRequested(int gameId, int roomId, bool asSpectator); + void sectionExpanded(Section section, bool expanded); + /** Dialog mode: the user activated (Enter/double-click) the given row. */ + void userActivated(const QString &userName); + /** Dialog mode: the current row changed; empty string means no user row. */ + void currentUserChanged(const QString &userName); + /** The set of visible rows changed (filter, search or a live mutation). */ + void userListChanged(); public: UserListWidget(TabSupervisor *_tabSupervisor, AbstractClient *_client, UserListType _type, - QWidget *parent = nullptr); + QWidget *parent = nullptr, + bool hasUserInfoPopup = true); + ~UserListWidget() override; void bind(UserListManager *mgr); void applyDisplayMode(); + void beginBulkLoad(); + void endBulkLoad(); bool eventFilter(QObject *obj, QEvent *event) override; void retranslateUi(); void rebuild(); void processUserInfo(const ServerInfo_User &user, bool online); bool deleteUser(const QString &userName); void setUserOnline(const QString &userName, bool online); + void setFilterText(const QString &text); + void setShowTitle(bool showTitle); + void setSectioned(const QList
&ids); + void setSectionExpanded(Section section, bool expanded); + /** Dialog mode: rows that fail the predicate are never shown. */ + void setUserFilter(std::function filter) + { + userFilter = std::move(filter); + } + [[nodiscard]] int visibleUserRowCount() const; + [[nodiscard]] bool getHasUserInfoPopup() const + { + return hasUserInfoPopup; + } + [[nodiscard]] const QList
&getSectionIds() const + { + return sectionIds; + } [[nodiscard]] const QMap &getUsers() const { return users; } void showContextMenu(const QPoint &pos, const QModelIndex &index); void sortItems(); + void setGameInviteLinkProvider(std::function()> provider); protected: void hideEvent(QHideEvent *e) override; + void showEvent(QShowEvent *e) override; }; #endif diff --git a/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.cpp index 9a70e94c5..9c43d1f7b 100644 --- a/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.cpp @@ -2,14 +2,22 @@ #include "settings_search_model.h" +#include #include #include #include #include +#include #include +#include #include +#include #include #include +#include +#include +#include +#include /** * @brief Recursively collects all widgets within a layout diff --git a/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.h b/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.h index 3140f94f3..722a91e62 100644 --- a/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.h @@ -3,6 +3,7 @@ #include #include +#include #define WIKI_CUSTOM_PIC_URL "https://github.com/Cockatrice/Cockatrice/wiki/Custom-Picture-Download-URLs" #define WIKI_CUSTOM_SHORTCUTS "https://github.com/Cockatrice/Cockatrice/wiki/Custom-Keyboard-Shortcuts" diff --git a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp index c470e54fe..4c38459c6 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp @@ -1,25 +1,44 @@ #include "appearance_settings_page.h" #include "../../../client/settings/cache_settings.h" -#include "../../../client/settings/card_counter_settings.h" #include "../../client/settings/card_counter_settings.h" #include "../../palette_editor/palette_editor_dialog.h" +#include "../../theme_config.h" #include "../dialogs/override_printing_warning.h" +#include "../general/home_tab_button_color.h" #include "../interface/theme_manager.h" #include "../interface/widgets/general/background_sources.h" +#include "../playmat/playmat_collection_dialog.h" +#include "libcockatrice/interfaces/interface_interface_settings_provider.h" +#include "libcockatrice/utility/macros.h" +#include #include +#include #include +#include #include +#include #include +#include #include +#include +#include +#include +#include +#include #include #include +#include +#include +#include +#include #include #include #include #include #include +#include AppearanceSettingsPage::AppearanceSettingsPage() { @@ -55,8 +74,9 @@ AppearanceSettingsPage::AppearanceSettingsPage() connect(&schemeCombo, &QComboBox::currentIndexChanged, this, [this] { themeManager->setColorScheme(schemeCombo.currentData().toString()); }); - // Qt widget style; "Default" lets the application decide - styleCombo.addItem(tr("Default"), QStringLiteral("Default")); + // Qt widget style; "System" lets the application decide. The broken Windows + // native styles are still offered, but selecting one raises a warning. + styleCombo.addItem(tr("System"), QStringLiteral("System")); for (const QString &key : QStyleFactory::keys()) { styleCombo.addItem(key, key); } @@ -67,6 +87,7 @@ AppearanceSettingsPage::AppearanceSettingsPage() connect(&styleCombo, &QComboBox::currentIndexChanged, this, [this] { themeManager->setStyleName(styleCombo.currentData().toString()); }); + connect(&styleCombo, &QComboBox::currentIndexChanged, this, &AppearanceSettingsPage::updateStyleWarning); connect(themeManager, &ThemeManager::themeChanged, this, [this, dirPath] { const QString newDir = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName()); @@ -83,6 +104,8 @@ AppearanceSettingsPage::AppearanceSettingsPage() const int styleIdx = currentStyle.isEmpty() ? 0 : styleCombo.findData(currentStyle); styleCombo.setCurrentIndex(styleIdx >= 0 ? styleIdx : 0); styleCombo.blockSignals(false); + + updateStyleWarning(); }); connect(&editPaletteButton, &QPushButton::clicked, this, &AppearanceSettingsPage::editPalette); @@ -95,7 +118,11 @@ AppearanceSettingsPage::AppearanceSettingsPage() themeGrid->addWidget(&schemeCombo, 2, 1); themeGrid->addWidget(&styleComboLabel, 3, 0); themeGrid->addWidget(&styleCombo, 3, 1); - themeGrid->addWidget(&editPaletteButton, 4, 1); + themeGrid->addWidget(&styleWarningLabel, 4, 0, 1, 2); + themeGrid->addWidget(&editPaletteButton, 5, 1); + + styleWarningLabel.setWordWrap(true); + styleWarningLabel.setAlignment(Qt::AlignLeft | Qt::AlignVCenter); themeGroupBox = new QGroupBox; themeGroupBox->setLayout(themeGrid); @@ -128,6 +155,18 @@ AppearanceSettingsPage::AppearanceSettingsPage() connect(&homeTabDisplayCardNameCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.appearance(), &AppearanceSettings::setHomeTabDisplayCardName); + homeTabBackgroundDimCheckBox.setChecked(settings.appearance().getHomeTabBackgroundDim()); + connect(&homeTabBackgroundDimCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.appearance(), + &AppearanceSettings::setHomeTabBackgroundDim); + + for (const auto &entry : HomeTabButtonColor::all()) { + homeTabButtonColorSourceBox.addItem(QObject::tr(entry.trKey)); + } + + homeTabButtonColorSourceBox.setCurrentIndex(settings.appearance().getHomeTabButtonColorSourceIndex()); + connect(&homeTabButtonColorSourceBox, QOverload::of(&QComboBox::currentIndexChanged), &settings.appearance(), + &AppearanceSettings::setHomeTabButtonColorSourceIndex); + updateHomeTabSettingsVisibility(); auto *homeTabGrid = new QGridLayout; @@ -136,10 +175,55 @@ AppearanceSettingsPage::AppearanceSettingsPage() homeTabGrid->addWidget(&homeTabBackgroundShuffleFrequencyLabel, 1, 0); homeTabGrid->addWidget(&homeTabBackgroundShuffleFrequencySpinBox, 1, 1); homeTabGrid->addWidget(&homeTabDisplayCardNameCheckBox, 2, 0, 1, 2); + homeTabGrid->addWidget(&homeTabButtonColorSourceLabel, 3, 0); + homeTabGrid->addWidget(&homeTabButtonColorSourceBox, 3, 1); + homeTabGrid->addWidget(&homeTabBackgroundDimCheckBox, 4, 0, 1, 2); homeTabGroupBox = new QGroupBox; homeTabGroupBox->setLayout(homeTabGrid); + // Playmat settings + playmatVisibilityCombo.addItem(tr("Show all playmats"), PlaymatVisibilityAll); + playmatVisibilityCombo.addItem(tr("Show own playmat only"), PlaymatVisibilityOwnOnly); + playmatVisibilityCombo.addItem(tr("Don't use playmats"), PlaymatVisibilityNone); + int visIdx = playmatVisibilityCombo.findData(settings.userInterface().getPlaymatVisibility()); + if (visIdx >= 0) { + playmatVisibilityCombo.setCurrentIndex(visIdx); + } + connect(&playmatVisibilityCombo, qOverload(&QComboBox::currentIndexChanged), this, [this](int index) { + SettingsCache::instance().userInterface().setPlaymatVisibility(playmatVisibilityCombo.itemData(index).toInt()); + }); + playmatVisibilityLabel.setBuddy(&playmatVisibilityCombo); + + // Playmat mode: Override / Fallback / Deck-only + playmatModeCombo.addItem(tr("Override deck playmat"), PlaymatModeOverrideDeck); + playmatModeCombo.addItem(tr("Fallback if deck has none"), PlaymatModeFallback); + playmatModeCombo.addItem(tr("Deck only, ignore collection"), PlaymatModeDeckOnly); + int modeIdx = playmatModeCombo.findData(settings.userInterface().getPlaymatMode()); + if (modeIdx >= 0) { + playmatModeCombo.setCurrentIndex(modeIdx); + } + connect(&playmatModeCombo, qOverload(&QComboBox::currentIndexChanged), this, [this](int index) { + SettingsCache::instance().userInterface().setPlaymatMode(playmatModeCombo.itemData(index).toInt()); + }); + playmatModeLabel.setBuddy(&playmatModeCombo); + + // User-level playmat settings: fallback collection. + connect(&playmatDefaultEditButton, &QPushButton::clicked, this, + &AppearanceSettingsPage::openPlaymatCollectionDialog); + + auto *playmatGrid = new QGridLayout; + playmatGrid->addWidget(&playmatVisibilityLabel, 0, 0, 1, 1); + playmatGrid->addWidget(&playmatVisibilityCombo, 0, 1, 1, 1); + playmatGrid->addWidget(&playmatModeLabel, 1, 0, 1, 1); + playmatGrid->addWidget(&playmatModeCombo, 1, 1, 1, 1); + playmatGrid->addWidget(&playmatDefaultLabel, 2, 0, 1, 1); + playmatGrid->addWidget(&playmatDefaultEditButton, 2, 1, 1, 1); + + playmatGroupBox = new QGroupBox; + playmatGroupBox->setLayout(playmatGrid); + + // Styling settings styleUserListCheckBox.setChecked(settings.appearance().getStyleUserList()); connect(&styleUserListCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.appearance(), &AppearanceSettings::setStyleUserList); @@ -245,7 +329,6 @@ AppearanceSettingsPage::AppearanceSettingsPage() cardLayoutGroupBox->setLayout(cardLayoutGrid); // Card counter colors - auto *cardCounterColorsLayout = new QGridLayout; cardCounterColorsLayout->setColumnStretch(1, 1); cardCounterColorsLayout->setColumnStretch(3, 1); @@ -329,6 +412,7 @@ AppearanceSettingsPage::AppearanceSettingsPage() auto *mainLayout = new QVBoxLayout; mainLayout->addWidget(themeGroupBox); mainLayout->addWidget(homeTabGroupBox); + mainLayout->addWidget(playmatGroupBox); mainLayout->addWidget(stylingGroupBox); mainLayout->addWidget(menuGroupBox); mainLayout->addWidget(printingsGroupBox); @@ -375,14 +459,22 @@ void AppearanceSettingsPage::editPalette() void AppearanceSettingsPage::updateHomeTabSettingsVisibility() { - bool visible = SettingsCache::instance().appearance().getHomeTabBackgroundSource() != - BackgroundSources::toId(BackgroundSources::Theme); + QString sourceId = SettingsCache::instance().appearance().getHomeTabBackgroundSource(); + bool visible = BackgroundSources::fromId(sourceId) != BackgroundSources::Theme; homeTabBackgroundShuffleFrequencyLabel.setVisible(visible); homeTabBackgroundShuffleFrequencySpinBox.setVisible(visible); homeTabDisplayCardNameCheckBox.setVisible(visible); } +void AppearanceSettingsPage::updateStyleWarning() +{ + const QString style = styleCombo.currentData().toString(); + const bool broken = + style.compare("windows11", Qt::CaseInsensitive) == 0 || style.compare("windowsvista", Qt::CaseInsensitive) == 0; + styleWarningLabel.setVisible(broken); +} + void AppearanceSettingsPage::showShortcutsChanged(QT_STATE_CHANGED_T value) { SettingsCache::instance().userInterface().setShowShortcuts(value); @@ -431,6 +523,12 @@ void AppearanceSettingsPage::cardViewExpandedRowsMaxChanged(int value) } } +void AppearanceSettingsPage::openPlaymatCollectionDialog() +{ + PlaymatCollectionDialog dialog(this); + dialog.exec(); +} + void AppearanceSettingsPage::retranslateUi() { themeGroupBox->setTitle(tr("Theme settings")); @@ -438,7 +536,12 @@ void AppearanceSettingsPage::retranslateUi() openThemeButton.setText(tr("Open themes folder")); schemeComboLabel.setText(tr("Active theme palette:")); styleComboLabel.setText(tr("Active theme style:")); - styleCombo.setToolTip(tr("Qt widget style saved to this theme (\"Default\" lets the application decide)")); + styleCombo.setToolTip(tr("Qt widget style saved to this theme (\"System\" lets the application decide)")); + styleWarningLabel.setText( + tr("Warning: the Windows 11 and Windows Vista styles are buggy and can render controls such as combo boxes " + "unreadable, especially in dark mode. Fusion is the recommended style on every platform: it is Qt's " + "cross-platform style and the one Cockatrice is developed against.")); + updateStyleWarning(); editPaletteButton.setText(tr("Edit theme palette")); homeTabGroupBox->setTitle(tr("Home tab settings")); @@ -446,6 +549,18 @@ void AppearanceSettingsPage::retranslateUi() homeTabBackgroundShuffleFrequencyLabel.setText(tr("Home tab background shuffle frequency:")); homeTabBackgroundShuffleFrequencySpinBox.setSpecialValueText(tr("Disabled")); homeTabDisplayCardNameCheckBox.setText(tr("Display card name of background in bottom right")); + homeTabBackgroundDimCheckBox.setText(tr("Dim the home tab background")); + homeTabBackgroundDimCheckBox.setToolTip( + tr("Draw a translucent overlay over the home tab background so buttons and text stand out")); + homeTabButtonColorSourceLabel.setText(tr("Home tab button color:")); + homeTabButtonColorSourceBox.setToolTip( + tr("Use the theme's identity accent colors, or extract colors from the background image")); + + playmatGroupBox->setTitle(tr("Playmat settings")); + playmatVisibilityLabel.setText(tr("Playmat visibility:")); + playmatModeLabel.setText(tr("Default collection behavior:")); + playmatDefaultLabel.setText(tr("Default playmat collection:")); + playmatDefaultEditButton.setText(tr("Edit...")); stylingGroupBox->setTitle(tr("Styling settings")); styleUserListCheckBox.setText(tr("Style user list")); @@ -489,4 +604,4 @@ void AppearanceSettingsPage::retranslateUi() tableGroupBox->setTitle(tr("Table grid layout")); invertVerticalCoordinateCheckBox.setText(tr("Invert vertical coordinate")); minPlayersForMultiColumnLayoutLabel.setText(tr("Minimum player count for multi-column layout:")); -} \ No newline at end of file +} diff --git a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h index 0b6b6832c..8afd326a3 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h @@ -5,11 +5,14 @@ #include #include -#include #include +#include #include #include #include +#include + +class QGroupBox; class AppearanceSettingsPage : public AbstractSettingsPage { @@ -19,11 +22,13 @@ private slots: void openThemeLocation(); void editPalette(); void updateHomeTabSettingsVisibility(); + void updateStyleWarning(); void showShortcutsChanged(QT_STATE_CHANGED_T enabled); void overrideAllCardArtWithPersonalPreferenceToggled(QT_STATE_CHANGED_T enabled); void cardViewInitialRowsMaxChanged(int value); void cardViewExpandedRowsMaxChanged(int value); + void openPlaymatCollectionDialog(); private: QLabel themeLabel; @@ -33,45 +38,67 @@ private: QComboBox schemeCombo; QLabel styleComboLabel; QComboBox styleCombo; + QLabel styleWarningLabel; QPushButton editPaletteButton; + QLabel homeTabBackgroundSourceLabel; QComboBox homeTabBackgroundSourceBox; QLabel homeTabBackgroundShuffleFrequencyLabel; QSpinBox homeTabBackgroundShuffleFrequencySpinBox; QCheckBox homeTabDisplayCardNameCheckBox; + QCheckBox homeTabBackgroundDimCheckBox; + QLabel homeTabButtonColorSourceLabel; + QComboBox homeTabButtonColorSourceBox; + + QLabel playmatVisibilityLabel; + QComboBox playmatVisibilityCombo; + QLabel playmatModeLabel; + QComboBox playmatModeCombo; + QLabel playmatDefaultLabel; + QPushButton playmatDefaultEditButton; + QCheckBox styleUserListCheckBox; + QCheckBox showShortcutsCheckBox; QCheckBox showGameSelectorFilterToolbarCheckBox; - QLabel minPlayersForMultiColumnLayoutLabel; - QLabel maxFontSizeForCardsLabel; + QCheckBox overrideAllCardArtWithPersonalPreferenceCheckBox; QCheckBox bumpSetsWithCardsInDeckToTopCheckBox; + QCheckBox displayCardNamesCheckBox; QCheckBox autoRotateSidewaysLayoutCardsCheckBox; QCheckBox cardScalingCheckBox; QCheckBox roundCardCornersCheckBox; + QLabel maxFontSizeForCardsLabel; + QSpinBox maxFontSizeForCardsEdit; + QLabel verticalCardOverlapPercentLabel; QSpinBox verticalCardOverlapPercentBox; QLabel cardViewInitialRowsMaxLabel; QSpinBox cardViewInitialRowsMaxBox; QLabel cardViewExpandedRowsMaxLabel; QSpinBox cardViewExpandedRowsMaxBox; + + QList cardCounterNames; + QCheckBox horizontalHandCheckBox; QCheckBox leftJustifiedHandCheckBox; + QCheckBox invertVerticalCoordinateCheckBox; + QLabel minPlayersForMultiColumnLayoutLabel; + QSpinBox minPlayersForMultiColumnLayoutEdit; + QGroupBox *themeGroupBox; QGroupBox *homeTabGroupBox; + QGroupBox *playmatGroupBox; QGroupBox *stylingGroupBox; QGroupBox *menuGroupBox; QGroupBox *printingsGroupBox; QGroupBox *cardsGroupBox; QGroupBox *cardLayoutGroupBox; + QGroupBox *cardCountersGroupBox; QGroupBox *handGroupBox; QGroupBox *tableGroupBox; - QGroupBox *cardCountersGroupBox; - QList cardCounterNames; - QSpinBox minPlayersForMultiColumnLayoutEdit; - QSpinBox maxFontSizeForCardsEdit; public: AppearanceSettingsPage(); 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 f425afe60..2020b2031 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,19 +1,42 @@ #include "deck_editor_settings_page.h" #include "../../../client/settings/cache_settings.h" +#include "../../pixel_map_generator.h" #include "update/card_spoiler/spoiler_background_updater.h" +#include +#include +#include +#include +#include +#include +#include #include #include +#include +#include #include +#include #include #include +#include +#include +#include #include +#include +#include +#include +#include #include +#include +#include +#include #include #include #include #include +#include +#include DeckEditorSettingsPage::DeckEditorSettingsPage() { @@ -50,25 +73,32 @@ DeckEditorSettingsPage::DeckEditorSettingsPage() urlList->setDragDropMode(QAbstractItemView::InternalMove); connect(urlList->model(), &QAbstractItemModel::rowsMoved, this, &DeckEditorSettingsPage::urlListChanged); - urlList->addItems(SettingsCache::instance().downloads().getAllURLs()); + for (const QString &url : SettingsCache::instance().downloads().getAllURLs()) { + addUrlItem(url); + } aAdd = new QAction(this); - aAdd->setIcon(QPixmap("theme:icons/increment")); + aAdd->setIcon(themePixmap(QStringLiteral("icons/increment"))); connect(aAdd, &QAction::triggered, this, &DeckEditorSettingsPage::actAddURL); aEdit = new QAction(this); - aEdit->setIcon(QPixmap("theme:icons/pencil")); + aEdit->setIcon(themePixmap(QStringLiteral("icons/pencil"))); connect(aEdit, &QAction::triggered, this, &DeckEditorSettingsPage::actEditURL); aRemove = new QAction(this); - aRemove->setIcon(QPixmap("theme:icons/decrement")); + aRemove->setIcon(themePixmap(QStringLiteral("icons/decrement"))); connect(aRemove, &QAction::triggered, this, &DeckEditorSettingsPage::actRemoveURL); + aRateLimit = new QAction(this); + aRateLimit->setIcon(themePixmap(QStringLiteral("icons/cogwheel"))); + connect(aRateLimit, &QAction::triggered, this, &DeckEditorSettingsPage::actAdjustRateLimit); + auto *urlToolBar = new QToolBar; urlToolBar->setOrientation(Qt::Vertical); urlToolBar->addAction(aAdd); urlToolBar->addAction(aRemove); urlToolBar->addAction(aEdit); + urlToolBar->addAction(aRateLimit); urlToolBar->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::MinimumExpanding); auto *urlListLayout = new QHBoxLayout; @@ -116,7 +146,9 @@ void DeckEditorSettingsPage::resetDownloadedURLsButtonClicked() { SettingsCache::instance().downloads().resetToDefaultURLs(); urlList->clear(); - urlList->addItems(SettingsCache::instance().downloads().getAllURLs()); + for (const QString &url : SettingsCache::instance().downloads().getAllURLs()) { + addUrlItem(url); + } QMessageBox::information(this, tr("Success"), tr("Download URLs have been reset.")); } @@ -125,7 +157,7 @@ void DeckEditorSettingsPage::actAddURL() bool ok; QString msg = QInputDialog::getText(this, tr("Add URL"), tr("URL:"), QLineEdit::Normal, QString(), &ok); if (ok) { - urlList->addItem(msg); + addUrlItem(msg); storeSettings(); } } @@ -140,12 +172,14 @@ void DeckEditorSettingsPage::actRemoveURL() void DeckEditorSettingsPage::actEditURL() { - if (urlList->currentItem()) { - QString oldText = urlList->currentItem()->text(); + QListWidgetItem *item = urlList->currentItem(); + if (item) { + const QString oldText = urlForItem(item); bool ok; QString msg = QInputDialog::getText(this, tr("Edit URL"), tr("URL:"), QLineEdit::Normal, oldText, &ok); if (ok) { - urlList->currentItem()->setText(msg); + item->setData(Qt::UserRole, msg); + item->setText(urlLabel(msg)); storeSettings(); } } @@ -157,10 +191,133 @@ void DeckEditorSettingsPage::storeSettings() QStringList downloadUrls; for (int i = 0; i < urlList->count(); i++) { - qInfo() << "Priority" << i << ":" << urlList->item(i)->text(); - downloadUrls << urlList->item(i)->text(); + const QString url = urlForItem(urlList->item(i)); + qInfo() << "Priority" << i << ":" << url; + downloadUrls << url; } SettingsCache::instance().downloads().setDownloadUrls(downloadUrls); + + // Drop per-host limits whose host is no longer referenced by any configured URL, so removing + // a URL doesn't leave a stale throttle behind that reactivates if the host is re-added. + QSet usedHosts; + for (const QString &url : downloadUrls) { + const QString host = QUrl(url).host(); + if (!host.isEmpty()) { + usedHosts.insert(host); + } + } + QHash limits = SettingsCache::instance().downloads().getHostRequestLimits(); + bool limitsChanged = false; + for (auto it = limits.begin(); it != limits.end();) { + // Prune only limits for hosts that are neither referenced by a configured URL nor carry a + // developer cap. Capped hosts are often redirect targets (e.g. api.scryfall.com redirects + // to cards.scryfall.io) that never appear in the URL list, yet they are exactly the hosts + // the throttle applies to, so dropping them when a URL is removed would silently re-enable + // free-running traffic to a rate-sensitive server. + if (!usedHosts.contains(it.key()) && !DownloadSettings::getDeveloperHostCaps().contains(it.key())) { + it = limits.erase(it); + limitsChanged = true; + } else { + ++it; + } + } + if (limitsChanged) { + SettingsCache::instance().downloads().setHostRequestLimits(limits); + } + + refreshUrlItems(); +} + +QListWidgetItem *DeckEditorSettingsPage::addUrlItem(const QString &url) +{ + auto *item = new QListWidgetItem(urlLabel(url)); + item->setData(Qt::UserRole, url); + urlList->addItem(item); + return item; +} + +QString DeckEditorSettingsPage::urlForItem(const QListWidgetItem *item) const +{ + return item->data(Qt::UserRole).toString(); +} + +QString DeckEditorSettingsPage::urlLabel(const QString &url) const +{ + const QString host = QUrl(url).host(); + if (host.isEmpty()) { + return url; + } + + const QHash limits = SettingsCache::instance().downloads().getHostRequestLimits(); + const int devCap = + DownloadSettings::getDeveloperHostCaps().value(host, DownloadSettings::DEFAULT_HOST_REQUEST_LIMIT); + if (devCap == DownloadSettings::UNLIMITED_HOST_QUOTA && !limits.contains(host)) { + return tr("%1 (unlimited)").arg(url); + } + + const int requested = limits.value( + host, devCap == DownloadSettings::UNLIMITED_HOST_QUOTA ? DownloadSettings::DEFAULT_HOST_REQUEST_LIMIT : devCap); + const int effective = SettingsCache::instance().downloads().clampHostRequestLimit(host, requested); + return tr("%1 (%2/s)").arg(url).arg(effective); +} + +void DeckEditorSettingsPage::refreshUrlItems() +{ + for (int i = 0; i < urlList->count(); ++i) { + QListWidgetItem *item = urlList->item(i); + item->setText(urlLabel(urlForItem(item))); + } +} + +void DeckEditorSettingsPage::actAdjustRateLimit() +{ + if (urlList->currentItem() == nullptr) { + QMessageBox::information(this, tr("Adjust Rate Limit"), tr("Select a URL in the list first.")); + return; + } + + const QString host = QUrl(urlForItem(urlList->currentItem())).host(); + if (host.isEmpty()) { + QMessageBox::information(this, tr("Adjust Rate Limit"), tr("The selected URL does not have a valid host.")); + return; + } + + const QHash &devCaps = DownloadSettings::getDeveloperHostCaps(); + const QHash currentLimits = SettingsCache::instance().downloads().getHostRequestLimits(); + const int devCap = devCaps.value(host, DownloadSettings::DEFAULT_HOST_REQUEST_LIMIT); + const bool unlocked = devCap == DownloadSettings::UNLIMITED_HOST_QUOTA; + + bool ok = false; + int minimum; + int maximum; + int defaultValue; + QString prompt; + if (unlocked) { + minimum = 0; // 0 means "unlimited" + maximum = DownloadSettings::UNLOCKED_HOST_LIMIT_MAX; + defaultValue = currentLimits.value(host, 0); + prompt = tr("Requests per second (0 = unlimited, fastest; up to %1):").arg(maximum); + } else { + minimum = DownloadSettings::MIN_HOST_REQUEST_LIMIT; + maximum = devCap; + defaultValue = currentLimits.value(host, devCap); + prompt = tr("Requests per second (developer maximum is %1):").arg(maximum); + } + + const int value = QInputDialog::getInt(this, tr("Adjust Rate Limit for %1").arg(host), prompt, defaultValue, + minimum, maximum, 1, &ok); + if (!ok) { + return; + } + + QHash limits = currentLimits; + if (unlocked ? value == 0 : value == devCap) { + limits.remove(host); + } else { + limits.insert(host, value); + } + SettingsCache::instance().downloads().setHostRequestLimits(limits); + refreshUrlItems(); } void DeckEditorSettingsPage::urlListChanged(const QModelIndex &, int, int, const QModelIndex &, int) @@ -243,4 +400,8 @@ void DeckEditorSettingsPage::retranslateUi() aAdd->setText(tr("Add New URL")); aEdit->setText(tr("Edit URL")); aRemove->setText(tr("Remove URL")); -} \ No newline at end of file + aRateLimit->setText(tr("Adjust Rate Limit")); + + // The per-URL rate limit suffixes are translated, so refresh them when the language changes. + refreshUrlItems(); +} diff --git a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.h b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.h index 5db009c8a..47f840191 100644 --- a/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/deck_editor_settings_page.h @@ -4,10 +4,17 @@ #include "abstract_settings_page.h" #include -#include #include -#include #include +#include +#include + +class QAction; +class QGroupBox; +class QLineEdit; +class QListWidget; +class QListWidgetItem; +class QModelIndex; class DeckEditorSettingsPage : public AbstractSettingsPage { @@ -27,6 +34,7 @@ private slots: void actAddURL(); void actRemoveURL(); void actEditURL(); + void actAdjustRateLimit(); void resetDownloadedURLsButtonClicked(); private: @@ -34,7 +42,7 @@ private: QLabel urlLinkLabel; QCheckBox picDownloadCheckBox; QListWidget *urlList; - QAction *aAdd, *aEdit, *aRemove; + QAction *aAdd, *aEdit, *aRemove, *aRateLimit; QCheckBox mcDownloadSpoilersCheckBox; QLabel msDownloadSpoilersLabel; QGroupBox *mpGeneralGroupBox; @@ -46,6 +54,18 @@ private: QLabel infoOnSpoilersLabel; QPushButton *mpSpoilerPathButton; QPushButton *updateNowButton; + + /** @brief Adds a list item for the given URL, storing the raw URL alongside its displayed label. */ + QListWidgetItem *addUrlItem(const QString &url); + + /** @brief Returns the raw URL stored on a list item. */ + [[nodiscard]] QString urlForItem(const QListWidgetItem *item) const; + + /** @brief Returns the display label for a URL, including its current effective rate limit. */ + [[nodiscard]] QString urlLabel(const QString &url) const; + + /** @brief Refreshes the displayed label of every URL item after limits or settings change. */ + void refreshUrlItems(); }; #endif // COCKATRICE_DECK_EDITOR_SETTINGS_PAGE_H diff --git a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp index 91c4943e1..73a0f7be2 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp @@ -1,18 +1,43 @@ #include "general_settings_page.h" #include "../../../client/settings/cache_settings.h" +#include "../interface/card_picture_loader/card_picture_loader.h" #include "../main.h" +#include "../server/user/user_info_connection.h" #include "update/client/release_channel.h" +#include #include +#include +#include +#include #include #include +#include #include +#include +#include +#include +#include +#include +#include #include +#include +#include +#include +#include +#include +#include +#include #include #include +#include #include #include +#include +#include +#include +#include enum startupCardUpdateCheckBehaviorIndex { @@ -44,10 +69,40 @@ GeneralSettingsPage::GeneralSettingsPage() connect(&languageBox, qOverload(&QComboBox::currentIndexChanged), this, &GeneralSettingsPage::languageBoxChanged); + // card text & images language, independent of the UI language + cardLanguageBox.addItem(tr("English"), "en"); + for (const QString &code : CardLocalization::supportedLanguages()) { + cardLanguageBox.addItem(CardLocalization::languageDisplayName(code), code); + } + const int cardLangIndex = cardLanguageBox.findData(SettingsCache::instance().cardsDisplay().getCardLang()); + cardLanguageBox.setCurrentIndex(cardLangIndex < 0 ? 0 : cardLangIndex); + + connect(&cardLanguageBox, qOverload(&QComboBox::currentIndexChanged), this, + &GeneralSettingsPage::cardLanguageBoxChanged); + + // card search language, independent of the card display language + cardSearchLanguageBox.addItem(""); // texts set in retranslateUi + cardSearchLanguageBox.addItem(""); + cardSearchLanguageBox.addItem(""); + const int cardSearchLanguageIndex = SettingsCache::instance().cardsDisplay().getCardSearchLanguage(); + cardSearchLanguageBox.setCurrentIndex(cardSearchLanguageIndex < 0 ? static_cast(SearchLanguageMode::English) + : cardSearchLanguageIndex); + + connect(&cardSearchLanguageBox, qOverload(&QComboBox::currentIndexChanged), this, + &GeneralSettingsPage::cardSearchLanguageBoxChanged); + auto *languageGrid = new QGridLayout; languageGrid->addWidget(&languageLabel, 0, 0); languageGrid->addWidget(&languageBox, 0, 1); - languageGrid->addWidget(&advertiseTranslationPageLabel, 1, 1, Qt::AlignRight); + languageGrid->addWidget(&cardLanguageLabel, 1, 0); + languageGrid->addWidget(&cardLanguageBox, 1, 1); + languageGrid->addWidget(&cardLanguageNoteLabel, 2, 1); + languageGrid->addWidget(&cardSearchLanguageLabel, 3, 0); + languageGrid->addWidget(&cardSearchLanguageBox, 3, 1); + languageGrid->addWidget(&advertiseTranslationPageLabel, 4, 1, Qt::AlignRight); + + cardLanguageNoteLabel.setWordWrap(true); + cardLanguageNoteLabel.setAlignment(Qt::AlignLeft | Qt::AlignVCenter); languageGroupBox = new QGroupBox; languageGroupBox->setLayout(languageGrid); @@ -106,12 +161,18 @@ GeneralSettingsPage::GeneralSettingsPage() newVersionOracleCheckBox.setChecked(settings.updates().getNotifyAboutNewVersion()); + cardDatabaseUpdateTrayNotificationCheckBox.setChecked(settings.updates().getCardDatabaseUpdateTrayNotification()); + + connect(&cardDatabaseUpdateTrayNotificationCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.updates(), + &UpdatesSettings::setCardDatabaseUpdateTrayNotification); + auto *cardDatabaseGrid = new QGridLayout; cardDatabaseGrid->addWidget(&startupCardUpdateCheckBehaviorLabel, 0, 0); cardDatabaseGrid->addWidget(&startupCardUpdateCheckBehaviorSelector, 0, 1); cardDatabaseGrid->addWidget(&cardUpdateCheckIntervalLabel, 1, 0); cardDatabaseGrid->addWidget(&cardUpdateCheckIntervalSpinBox, 1, 1); cardDatabaseGrid->addWidget(&lastCardUpdateCheckDateLabel, 2, 1); + cardDatabaseGrid->addWidget(&cardDatabaseUpdateTrayNotificationCheckBox, 3, 0, 1, 2); cardDatabaseGroupBox = new QGroupBox; cardDatabaseGroupBox->setLayout(cardDatabaseGrid); @@ -121,8 +182,61 @@ GeneralSettingsPage::GeneralSettingsPage() connect(&showTipsOnStartup, &QCheckBox::clicked, &settings.personal(), &PersonalSettings::setShowTipsOnStartup); + // startup destination + for (int i = 0; i < 8; ++i) { + startupTabSelector.addItem(""); // texts set in retranslateUi + } + startupTabSelector.setCurrentIndex(settings.tabs().getStartupTabIndex()); + + connect(&startupTabSelector, qOverload(&QComboBox::currentIndexChanged), &settings.tabs(), + &TabsSettings::setStartupTabIndex); + connect(&startupTabSelector, qOverload(&QComboBox::currentIndexChanged), this, + &GeneralSettingsPage::updateStartupServerControlsVisibility); + + const QString savedHost = settings.tabs().getStartupServerHost(); + const QString savedPort = settings.tabs().getStartupServerPort(); + int startupServerIndex = -1; + UserConnection_Information uci; + for (const auto &savedServer : uci.getServerInfo()) { + const UserConnection_Information &info = savedServer.second; + const QString saveName = info.getSaveName(); + if (saveName.isEmpty()) { + continue; + } + startupServerSelector.addItem(saveName, QVariantList{info.getServer(), info.getPort()}); + if (startupServerIndex == -1 && info.getServer() == savedHost && info.getPort() == savedPort) { + startupServerIndex = startupServerSelector.count() - 1; + } + } + startupServerSelector.setCurrentIndex(startupServerIndex); + + connect(&startupServerSelector, qOverload(&QComboBox::currentIndexChanged), this, [this](int index) { + const QVariantList serverInfo = startupServerSelector.itemData(index).toList(); + if (serverInfo.size() != 2) { + return; + } + TabsSettings &tabs = SettingsCache::instance().tabs(); + tabs.setStartupServerHost(serverInfo[0].toString()); + tabs.setStartupServerPort(serverInfo[1].toString()); + }); + + startupRoomNameEdit = new QLineEdit(settings.tabs().getStartupRoomName()); + // Default (Expanding) would stretch the whole controls column when this row becomes visible, + // so size it like the combo boxes instead: fills the column, never widens it. + startupRoomNameEdit->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); + connect(startupRoomNameEdit, &QLineEdit::editingFinished, this, + [this] { SettingsCache::instance().tabs().setStartupRoomName(startupRoomNameEdit->text().trimmed()); }); + auto *startupGrid = new QGridLayout; startupGrid->addWidget(&showTipsOnStartup, 0, 0, 1, 2); + startupGrid->addWidget(&startupTabLabel, 1, 0); + startupGrid->addWidget(&startupTabSelector, 1, 1); + startupGrid->addWidget(&startupServerLabel, 2, 0); + startupGrid->addWidget(&startupServerSelector, 2, 1); + startupGrid->addWidget(&startupRoomLabel, 3, 0); + startupGrid->addWidget(startupRoomNameEdit, 3, 1); + + updateStartupServerControlsVisibility(); startupGroupBox = new QGroupBox; startupGroupBox->setLayout(startupGrid); @@ -242,22 +356,18 @@ GeneralSettingsPage::GeneralSettingsPage() QStringList GeneralSettingsPage::findQmFiles() { - QDir dir(translationPath); - QStringList fileNames = dir.entryList(QStringList(translationPrefix + "_*.qm"), QDir::Files, QDir::Name); - fileNames.replaceInStrings(QRegularExpression(translationPrefix + "_(.*)\\.qm"), "\\1"); - return fileNames; + return TranslationLoader::availableLanguages(translationPrefix, + TranslationLoader::applicationTranslationPaths(translationPath)); } QString GeneralSettingsPage::languageName(const QString &lang) { QTranslator qTranslator; - QString appNameHint = translationPrefix + "_" + lang; - bool appTranslationLoaded = qTranslator.load(appNameHint, translationPath); - if (!appTranslationLoaded) { - qCWarning(GeneralSettingsPageLog) - << "Unable to load" << translationPrefix << "translation" << appNameHint << "at" << translationPath; - } + // Only the language name is needed here, so the file is loaded without logging a line per + // language: findQmFiles() asked for languages whose files exist in the first place. + TranslationLoader::loadFrom(qTranslator, translationPrefix + "_" + lang, + TranslationLoader::applicationTranslationPaths(translationPath)); return qTranslator.translate("i18n", DEFAULT_LANG_NAME); } @@ -357,51 +467,92 @@ void GeneralSettingsPage::languageBoxChanged(int index) SettingsCache::instance().personal().setLang(languageBox.itemData(index).toString()); } +void GeneralSettingsPage::cardLanguageBoxChanged(int index) +{ + const QString lang = cardLanguageBox.itemData(index).toString(); + SettingsCache::instance().cardsDisplay().setCardLang(lang); + + // Switching to a non-default language only takes effect after the card + // database is re-imported with that language selected; English data is always + // present, so switching back to English needs no prompt. + if (lang == "en") { + return; + } + + // The binary cache does not track the language its entries were imported in, + // and the downloaded pictures were fetched with English art names, so both are + // stale until Oracle re-imports the database in the new language: drop them. + QFile::remove(SettingsCache::instance().getCardDatabasePath() + ".cache"); + CardPictureLoader::clearNetworkCache(); + CardPictureLoader::clearPixmapCache(); + + // Art is resolved by the translated card name for non-English languages, so the + // matching Scryfall URL is added to the top of the download list. It stays + // visible in the deck editor settings, where it can be removed or reordered. + const bool localizedUrlAdded = SettingsCache::instance().downloads().addLocalizedScryfallUrl(); + + QString message = tr("

The card database only contains English card data. To see cards in %1, " + "Oracle must run once with this language selected and re-import the card " + "database.

" + "

The cached database and the downloaded card pictures have been cleared, so a " + "re-import is picked up without stale entries.

") + .arg(cardLanguageBox.itemText(index)); + if (localizedUrlAdded) { + message += tr("

The Scryfall URL that resolves card art by translated name was added to the top of your " + "download list. You can remove or reorder it any time.

"); + } + message += tr("

Run Oracle now?

"); + + const QMessageBox::StandardButton answer = QMessageBox::question( + this, tr("Card text & images language changed"), message, QMessageBox::Yes | QMessageBox::No, QMessageBox::Yes); + + // The answer only controls whether Oracle starts right away; the caches stay + // cleared so the next import or launch rebuilds them in the new language. + if (answer == QMessageBox::Yes) { + emit cardDatabaseUpdateRequested(); + } +} + +void GeneralSettingsPage::cardSearchLanguageBoxChanged(int index) +{ + SettingsCache::instance().cardsDisplay().setCardSearchLanguage(index); +} + +void GeneralSettingsPage::updateStartupServerControlsVisibility() +{ + const int index = startupTabSelector.currentIndex(); + const bool serverNeeded = index == StartupTab::StartupTabServer || index == StartupTab::StartupTabServerRoom; + const bool roomNeeded = index == StartupTab::StartupTabServerRoom; + startupServerLabel.setVisible(serverNeeded); + startupServerSelector.setVisible(serverNeeded); + startupRoomLabel.setVisible(roomNeeded); + startupRoomNameEdit->setVisible(roomNeeded); +} + void GeneralSettingsPage::retranslateUi() { - languageGroupBox->setTitle(tr("Language settings")); - languageLabel.setText(tr("Language:")); - - versionGroupBox->setTitle(tr("Version settings")); - cardDatabaseGroupBox->setTitle(tr("Card database")); - startupGroupBox->setTitle(tr("Startup settings")); - - if (SettingsCache::instance().getIsPortableBuild()) { - pathsGroupBox->setTitle(tr("Paths (editing disabled in portable mode)")); - } else { - pathsGroupBox->setTitle(tr("Paths")); - } - advertiseTranslationPageLabel.setText( - QString("%2").arg(WIKI_TRANSLATION_FAQ).arg(tr("How to help with translations"))); - deckPathLabel.setText(tr("Decks directory:")); - filtersPathLabel.setText(tr("Filters directory:")); - replaysPathLabel.setText(tr("Replays directory:")); - picsPathLabel.setText(tr("Pictures directory:")); - cardDatabasePathLabel.setText(tr("Card database:")); - customCardDatabasePathLabel.setText(tr("Custom database directory:")); - tokenDatabasePathLabel.setText(tr("Token database:")); - updateReleaseChannelLabel.setText(tr("Update channel")); - startupUpdateCheckCheckBox.setText(tr("Check for client updates on startup")); - startupCardUpdateCheckBehaviorLabel.setText(tr("Check for card database updates on startup")); - startupCardUpdateCheckBehaviorSelector.setItemText(startupCardUpdateCheckBehaviorIndexNone, tr("Don't check")); - startupCardUpdateCheckBehaviorSelector.setItemText(startupCardUpdateCheckBehaviorIndexPrompt, - tr("Prompt for update")); - startupCardUpdateCheckBehaviorSelector.setItemText(startupCardUpdateCheckBehaviorIndexAlways, - tr("Always update in the background")); - cardUpdateCheckIntervalLabel.setText(tr("Check for card database updates every")); - cardUpdateCheckIntervalSpinBox.setSuffix(tr(" days")); - updateNotificationCheckBox.setText(tr("Notify if a feature supported by the server is missing in my client")); - newVersionOracleCheckBox.setText(tr("Automatically run Oracle when running a new version of Cockatrice")); - showTipsOnStartup.setText(tr("Show tips on startup")); - resetAllPathsButton->setText(tr("Reset all paths")); - const auto &settings = SettingsCache::instance(); - QDate lastCheckDate = settings.updates().getLastCardUpdateCheck(); - int daysAgo = lastCheckDate.daysTo(QDate::currentDate()); + languageGroupBox->setTitle(tr("Language settings")); + languageLabel.setText(tr("Language:")); + cardLanguageBox.setItemText(0, tr("English")); + cardLanguageLabel.setText(tr("Card text & images language:")); + cardLanguageNoteLabel.setText( + tr("Foreign card names, text and art apply after you update the card database (Oracle).")); + cardSearchLanguageLabel.setText(tr("Language used in card search:")); + cardSearchLanguageBox.setItemText(static_cast(SearchLanguageMode::English), tr("English")); + cardSearchLanguageBox.setItemText(static_cast(SearchLanguageMode::Selected), + tr("Selected card language (untranslated cards still match in English)")); + cardSearchLanguageBox.setItemText(static_cast(SearchLanguageMode::Both), + tr("English and selected card language")); + advertiseTranslationPageLabel.setText( + QString("%2").arg(WIKI_TRANSLATION_FAQ).arg(tr("How to help with translations"))); - lastCardUpdateCheckDateLabel.setText( - tr("Last update check on %1 (%2 days ago)").arg(lastCheckDate.toString()).arg(daysAgo)); + versionGroupBox->setTitle(tr("Version settings")); + updateReleaseChannelLabel.setText(tr("Update channel")); + startupUpdateCheckCheckBox.setText(tr("Check for client updates on startup")); + updateNotificationCheckBox.setText(tr("Notify if a feature supported by the server is missing in my client")); + newVersionOracleCheckBox.setText(tr("Automatically run Oracle when running a new version of Cockatrice")); // We can't change the strings after they're put into the QComboBox, so this is our workaround int oldIndex = updateReleaseChannelBox.currentIndex(); @@ -410,4 +561,51 @@ void GeneralSettingsPage::retranslateUi() updateReleaseChannelBox.addItem(tr(chan->getName().toUtf8())); } updateReleaseChannelBox.setCurrentIndex(oldIndex); -} \ No newline at end of file + + cardDatabaseGroupBox->setTitle(tr("Card database")); + startupCardUpdateCheckBehaviorLabel.setText(tr("Check for card database updates on startup")); + startupCardUpdateCheckBehaviorSelector.setItemText(startupCardUpdateCheckBehaviorIndexNone, tr("Don't check")); + startupCardUpdateCheckBehaviorSelector.setItemText(startupCardUpdateCheckBehaviorIndexPrompt, + tr("Prompt for update")); + startupCardUpdateCheckBehaviorSelector.setItemText(startupCardUpdateCheckBehaviorIndexAlways, + tr("Always update in the background")); + cardUpdateCheckIntervalLabel.setText(tr("Check for card database updates every")); + cardUpdateCheckIntervalSpinBox.setSuffix(tr(" days")); + cardDatabaseUpdateTrayNotificationCheckBox.setText(tr("Notify in the system tray while the card database updates")); + + QDate lastCheckDate = settings.updates().getLastCardUpdateCheck(); + int daysAgo = lastCheckDate.daysTo(QDate::currentDate()); + lastCardUpdateCheckDateLabel.setText( + tr("Last update check on %1 (%2 days ago)").arg(lastCheckDate.toString()).arg(daysAgo)); + + startupGroupBox->setTitle(tr("Startup settings")); + showTipsOnStartup.setText(tr("Show tips on startup")); + startupTabLabel.setText(tr("Startup tab:")); + startupTabSelector.setItemText(StartupTab::StartupTabHome, tr("Home")); + startupTabSelector.setItemText(StartupTab::StartupTabVisualDeckStorage, tr("Visual Deck Storage")); + startupTabSelector.setItemText(StartupTab::StartupTabDeckStorage, tr("Deck Storage")); + startupTabSelector.setItemText(StartupTab::StartupTabReplays, tr("Game Replays")); + startupTabSelector.setItemText(StartupTab::StartupTabDeckEditor, tr("Deck Editor")); + startupTabSelector.setItemText(StartupTab::StartupTabVisualDeckEditor, tr("Visual Deck Editor")); + startupTabSelector.setItemText(StartupTab::StartupTabServer, tr("Server")); + startupTabSelector.setItemText(StartupTab::StartupTabServerRoom, tr("Server Room")); + startupTabSelector.setToolTip( + tr("The tab shown when Cockatrice starts. If the chosen tab is not open yet, it is opened.")); + startupServerLabel.setText(tr("Server:")); + startupRoomLabel.setText(tr("Room:")); + startupRoomNameEdit->setPlaceholderText(tr("Room name")); + + if (settings.getIsPortableBuild()) { + pathsGroupBox->setTitle(tr("Paths (editing disabled in portable mode)")); + } else { + pathsGroupBox->setTitle(tr("Paths")); + } + deckPathLabel.setText(tr("Decks directory:")); + filtersPathLabel.setText(tr("Filters directory:")); + replaysPathLabel.setText(tr("Replays directory:")); + picsPathLabel.setText(tr("Pictures directory:")); + cardDatabasePathLabel.setText(tr("Card database:")); + customCardDatabasePathLabel.setText(tr("Custom database directory:")); + tokenDatabasePathLabel.setText(tr("Token database:")); + resetAllPathsButton->setText(tr("Reset all paths")); +} diff --git a/cockatrice/src/interface/widgets/settings_page/general_settings_page.h b/cockatrice/src/interface/widgets/settings_page/general_settings_page.h index 8aa39ff65..fcf1c78d0 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.h @@ -5,11 +5,16 @@ #include #include -#include #include #include -#include #include +#include +#include +#include + +class QGroupBox; +class QLineEdit; +class QPushButton; inline Q_LOGGING_CATEGORY(GeneralSettingsPageLog, "general_settings_page"); @@ -20,6 +25,13 @@ public: GeneralSettingsPage(); void retranslateUi() override; + static QStringList findQmFiles(); + static QString languageName(const QString &lang); + +signals: + /// Request to re-import the card database with the newly selected card language + void cardDatabaseUpdateRequested(); + private slots: void deckPathButtonClicked(); void filtersPathButtonClicked(); @@ -30,17 +42,56 @@ private slots: void tokenDatabasePathButtonClicked(); void resetAllPathsClicked(); void languageBoxChanged(int index); + void cardLanguageBoxChanged(int index); + void cardSearchLanguageBoxChanged(int index); + void updateStartupServerControlsVisibility(); private: - QStringList findQmFiles(); - QString languageName(const QString &lang); - QGroupBox *languageGroupBox; QGroupBox *versionGroupBox; QGroupBox *cardDatabaseGroupBox; QGroupBox *startupGroupBox; QGroupBox *pathsGroupBox; + QLabel languageLabel; + QComboBox languageBox; + QLabel advertiseTranslationPageLabel; + + QLabel cardLanguageLabel; + QComboBox cardLanguageBox; + QLabel cardLanguageNoteLabel; + + QLabel cardSearchLanguageLabel; + QComboBox cardSearchLanguageBox; + + QLabel updateReleaseChannelLabel; + QComboBox updateReleaseChannelBox; + QCheckBox startupUpdateCheckCheckBox; + QCheckBox updateNotificationCheckBox; + QCheckBox newVersionOracleCheckBox; + + QLabel startupCardUpdateCheckBehaviorLabel; + QComboBox startupCardUpdateCheckBehaviorSelector; + QLabel cardUpdateCheckIntervalLabel; + QSpinBox cardUpdateCheckIntervalSpinBox; + QLabel lastCardUpdateCheckDateLabel; + QCheckBox cardDatabaseUpdateTrayNotificationCheckBox; + + QCheckBox showTipsOnStartup; + QLabel startupTabLabel; + QComboBox startupTabSelector; + QLabel startupServerLabel; + QComboBox startupServerSelector; + QLabel startupRoomLabel; + QLineEdit *startupRoomNameEdit; + + QLabel deckPathLabel; + QLabel filtersPathLabel; + QLabel replaysPathLabel; + QLabel picsPathLabel; + QLabel cardDatabasePathLabel; + QLabel customCardDatabasePathLabel; + QLabel tokenDatabasePathLabel; QLineEdit *deckPathEdit; QLineEdit *filtersPathEdit; QLineEdit *replaysPathEdit; @@ -50,27 +101,6 @@ private: QLineEdit *tokenDatabasePathEdit; QPushButton *resetAllPathsButton; QLabel *allPathsResetLabel; - QComboBox languageBox; - QCheckBox startupUpdateCheckCheckBox; - QLabel startupCardUpdateCheckBehaviorLabel; - QComboBox startupCardUpdateCheckBehaviorSelector; - QLabel cardUpdateCheckIntervalLabel; - QSpinBox cardUpdateCheckIntervalSpinBox; - QLabel lastCardUpdateCheckDateLabel; - QCheckBox updateNotificationCheckBox; - QCheckBox newVersionOracleCheckBox; - QComboBox updateReleaseChannelBox; - QLabel languageLabel; - QLabel deckPathLabel; - QLabel filtersPathLabel; - QLabel replaysPathLabel; - QLabel picsPathLabel; - QLabel cardDatabasePathLabel; - QLabel customCardDatabasePathLabel; - QLabel tokenDatabasePathLabel; - QLabel updateReleaseChannelLabel; - QLabel advertiseTranslationPageLabel; - QCheckBox showTipsOnStartup; }; #endif // COCKATRICE_GENERAL_SETTINGS_PAGE_H diff --git a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp index e4f24ab73..9494155af 100644 --- a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp @@ -1,15 +1,29 @@ #include "messages_settings_page.h" #include "../../../client/settings/cache_settings.h" +#include "../../pixel_map_generator.h" #include "../interface/widgets/utility/get_text_with_max.h" +#include "libcockatrice/utility/macros.h" +#include +#include +#include #include +#include +#include #include +#include +#include +#include +#include #include +#include +#include #include #include #include #include +#include MessagesSettingsPage::MessagesSettingsPage() { @@ -59,6 +73,14 @@ MessagesSettingsPage::MessagesSettingsPage() connect(&roomHistory, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().chat(), &ChatSettings::setRoomHistory); + ignoreAllPrivateMessagesCheckBox.setChecked(SettingsCache::instance().chat().getIgnoreAllPrivateMessages()); + connect(&ignoreAllPrivateMessagesCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().chat(), + &ChatSettings::setIgnoreAllPrivateMessages); + + useGameTimeCheckBox.setChecked(SettingsCache::instance().chat().getUseGameTime()); + connect(&useGameTimeCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().chat(), + &ChatSettings::setUseGameTime); + customAlertString = new QLineEdit(); customAlertString->setText(SettingsCache::instance().chat().getHighlightWords()); connect(customAlertString, &QLineEdit::textChanged, &SettingsCache::instance().chat(), @@ -76,6 +98,8 @@ MessagesSettingsPage::MessagesSettingsPage() chatGrid->addWidget(&messagePopups, 5, 0); chatGrid->addWidget(&mentionPopups, 6, 0); chatGrid->addWidget(&roomHistory, 7, 0); + chatGrid->addWidget(&ignoreAllPrivateMessagesCheckBox, 8, 0); + chatGrid->addWidget(&useGameTimeCheckBox, 9, 0); chatGroupBox = new QGroupBox; chatGroupBox->setLayout(chatGrid); @@ -102,15 +126,15 @@ MessagesSettingsPage::MessagesSettingsPage() } aAdd = new QAction(this); - aAdd->setIcon(QPixmap("theme:icons/increment")); + aAdd->setIcon(themePixmap(QStringLiteral("icons/increment"))); connect(aAdd, &QAction::triggered, this, &MessagesSettingsPage::actAdd); aEdit = new QAction(this); - aEdit->setIcon(QPixmap("theme:icons/pencil")); + aEdit->setIcon(themePixmap(QStringLiteral("icons/pencil"))); connect(aEdit, &QAction::triggered, this, &MessagesSettingsPage::actEdit); aRemove = new QAction(this); - aRemove->setIcon(QPixmap("theme:icons/decrement")); + aRemove->setIcon(themePixmap(QStringLiteral("icons/decrement"))); connect(aRemove, &QAction::triggered, this, &MessagesSettingsPage::actRemove); auto *messageToolBar = new QToolBar; @@ -256,6 +280,8 @@ void MessagesSettingsPage::retranslateUi() messagePopups.setText(tr("Enable desktop notifications for private messages")); mentionPopups.setText(tr("Enable desktop notification for mentions")); roomHistory.setText(tr("Enable room message history on join")); + ignoreAllPrivateMessagesCheckBox.setText(tr("Ignore all private messages")); + useGameTimeCheckBox.setText(tr("Use game time instead of local time in game logs")); hexLabel.setText(tr("(Color is hexadecimal)")); hexHighlightLabel.setText(tr("(Color is hexadecimal)")); customAlertStringLabel.setText(tr("Separate words with a space, alphanumeric characters only")); diff --git a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h index e98ae0592..e85e6cca7 100644 --- a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h @@ -4,10 +4,15 @@ #include "abstract_settings_page.h" #include -#include #include -#include +#include #include +#include + +class QAction; +class QGroupBox; +class QLineEdit; +class QListWidget; class MessagesSettingsPage : public AbstractSettingsPage { @@ -40,6 +45,8 @@ private: QCheckBox messagePopups; QCheckBox mentionPopups; QCheckBox roomHistory; + QCheckBox ignoreAllPrivateMessagesCheckBox; + QCheckBox useGameTimeCheckBox; QGroupBox *chatGroupBox; QGroupBox *highlightGroupBox; QGroupBox *messageGroupBox; diff --git a/cockatrice/src/interface/widgets/settings_page/settings_search_delegate.cpp b/cockatrice/src/interface/widgets/settings_page/settings_search_delegate.cpp index 2fdff3bce..bf35b7557 100644 --- a/cockatrice/src/interface/widgets/settings_page/settings_search_delegate.cpp +++ b/cockatrice/src/interface/widgets/settings_page/settings_search_delegate.cpp @@ -7,7 +7,24 @@ #include "settings_search_model.h" +#include +#include +#include +#include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class QObject; SettingsSearchDelegate::SettingsSearchDelegate(QObject *parent) : QStyledItemDelegate(parent) { diff --git a/cockatrice/src/interface/widgets/settings_page/settings_search_delegate.h b/cockatrice/src/interface/widgets/settings_page/settings_search_delegate.h index aa296049a..2e48a3de9 100644 --- a/cockatrice/src/interface/widgets/settings_page/settings_search_delegate.h +++ b/cockatrice/src/interface/widgets/settings_page/settings_search_delegate.h @@ -6,8 +6,14 @@ #ifndef COCKATRICE_SETTINGS_SEARCH_DELEGATE_H #define COCKATRICE_SETTINGS_SEARCH_DELEGATE_H +#include #include +#include +#include #include +#include + +class QObject; /** * @brief Custom paint delegate for settings search result items diff --git a/cockatrice/src/interface/widgets/settings_page/settings_search_model.cpp b/cockatrice/src/interface/widgets/settings_page/settings_search_model.cpp index 71f859d4b..015a7d45d 100644 --- a/cockatrice/src/interface/widgets/settings_page/settings_search_model.cpp +++ b/cockatrice/src/interface/widgets/settings_page/settings_search_model.cpp @@ -6,7 +6,14 @@ #include "settings_search_model.h" #include +#include +#include +#include +#include #include +#include + +class QObject; SettingsSearchModel::SettingsSearchModel(QObject *parent) : QAbstractListModel(parent) { diff --git a/cockatrice/src/interface/widgets/settings_page/settings_search_model.h b/cockatrice/src/interface/widgets/settings_page/settings_search_model.h index 0b590f180..902a29312 100644 --- a/cockatrice/src/interface/widgets/settings_page/settings_search_model.h +++ b/cockatrice/src/interface/widgets/settings_page/settings_search_model.h @@ -7,9 +7,17 @@ #define COCKATRICE_SETTINGS_SEARCH_MODEL_H #include +#include #include +#include +#include #include -#include +#include +#include +#include + +class QObject; +class QWidget; /** * @brief Represents a single searchable setting entry 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 1f1867f7c..ea7ed745c 100644 --- a/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp @@ -3,12 +3,22 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcut_treeview.h" #include "../../../client/settings/shortcuts_settings.h" +#include "../../pixel_map_generator.h" #include "../interface/widgets/utility/custom_line_edit.h" #include "../interface/widgets/utility/sequence_edit.h" #include +#include +#include +#include +#include #include +#include +#include +#include +#include #include +#include ShortcutSettingsPage::ShortcutSettingsPage() { @@ -47,8 +57,8 @@ ShortcutSettingsPage::ShortcutSettingsPage() btnResetAll = new QPushButton(this); btnClearAll = new QPushButton(this); - btnResetAll->setIcon(QPixmap("theme:icons/update")); - btnClearAll->setIcon(QPixmap("theme:icons/clearsearch")); + btnResetAll->setIcon(themePixmap(QStringLiteral("icons/update"))); + btnClearAll->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); // layout auto *_editLayout = new QGridLayout; diff --git a/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.h b/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.h index 05391df77..6ae7fbfa7 100644 --- a/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.h @@ -3,14 +3,18 @@ #include "abstract_settings_page.h" -#include -#include -#include -#include +#include +#include class SequenceEdit; class ShortcutTreeView; class SearchLineEdit; +class QGridLayout; +class QGroupBox; +class QHBoxLayout; +class QLabel; +class QPushButton; +class QVBoxLayout; class ShortcutSettingsPage : public AbstractSettingsPage { diff --git a/cockatrice/src/interface/widgets/settings_page/sound_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/sound_settings_page.cpp index 2bd776671..05725bb42 100644 --- a/cockatrice/src/interface/widgets/settings_page/sound_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/sound_settings_page.cpp @@ -3,10 +3,21 @@ #include "../../../client/settings/cache_settings.h" #include "../client/sound_engine.h" +#include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include +#include SoundSettingsPage::SoundSettingsPage() { diff --git a/cockatrice/src/interface/widgets/settings_page/sound_settings_page.h b/cockatrice/src/interface/widgets/settings_page/sound_settings_page.h index f90bcbc5a..acaa4f7c4 100644 --- a/cockatrice/src/interface/widgets/settings_page/sound_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/sound_settings_page.h @@ -5,10 +5,13 @@ #include #include -#include #include #include -#include +#include + +class QGroupBox; +class QSlider; +class QSpinBox; class SoundSettingsPage : public AbstractSettingsPage { diff --git a/cockatrice/src/interface/widgets/settings_page/storage_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/storage_settings_page.cpp index 17838e501..a71296820 100644 --- a/cockatrice/src/interface/widgets/settings_page/storage_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/storage_settings_page.cpp @@ -5,12 +5,21 @@ #include "../../card_picture_loader/card_picture_loader_local_schemes.h" #include "../interface/card_picture_loader/card_picture_loader.h" +#include #include #include +#include +#include +#include #include +#include +#include +#include +#include #include #include #include +#include StorageSettingsPage::StorageSettingsPage() { @@ -181,9 +190,14 @@ StorageSettingsPage::StorageSettingsPage() void StorageSettingsPage::clearDownloadedPicsButtonClicked() { - CardPictureLoader::clearNetworkCache(); + // The network cache is cleared asynchronously on the worker thread, so wait for the completion + // signal before confirming; the in-memory pixmap cache is cleared synchronously right away. + connect( + &CardPictureLoader::getInstance(), &CardPictureLoader::networkCacheCleared, this, + [this] { QMessageBox::information(this, tr("Success"), tr("Cached card pictures have been reset.")); }, + Qt::SingleShotConnection); CardPictureLoader::clearPixmapCache(); - QMessageBox::information(this, tr("Success"), tr("Cached card pictures have been reset.")); + CardPictureLoader::clearNetworkCache(); } void StorageSettingsPage::clearImageBackupsButtonClicked() diff --git a/cockatrice/src/interface/widgets/settings_page/storage_settings_page.h b/cockatrice/src/interface/widgets/settings_page/storage_settings_page.h index 5c8a00981..1c9a35323 100644 --- a/cockatrice/src/interface/widgets/settings_page/storage_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/storage_settings_page.h @@ -3,11 +3,13 @@ #include "abstract_settings_page.h" -#include -#include #include #include #include +#include + +class QComboBox; +class QGroupBox; class StorageSettingsPage : public AbstractSettingsPage { diff --git a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp index 634df0b15..0a5f57e1f 100644 --- a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp @@ -3,13 +3,22 @@ #include "../../../client/settings/cache_settings.h" #include "../interface/widgets/tabs/tab_supervisor.h" #include "../tabs/api/commander_spellbook/commander_spellbook_bracket_explainer.h" +#include "libcockatrice/utility/macros.h" +#include #include +#include +#include +#include +#include +#include +#include #include #include #include #include #include +#include enum visualDeckStoragePromptForConversionIndex { @@ -20,26 +29,7 @@ enum visualDeckStoragePromptForConversionIndex UserInterfaceSettingsPage::UserInterfaceSettingsPage() { - // general settings and notification settings - notificationsEnabledCheckBox.setChecked(SettingsCache::instance().userInterface().getNotificationsEnabled()); - connect(¬ificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), - &InterfaceSettings::setNotificationsEnabled); - connect(¬ificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, this, - &UserInterfaceSettingsPage::setNotificationEnabled); - - specNotificationsEnabledCheckBox.setChecked( - SettingsCache::instance().userInterface().getSpectatorNotificationsEnabled()); - specNotificationsEnabledCheckBox.setEnabled(SettingsCache::instance().userInterface().getNotificationsEnabled()); - connect(&specNotificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), - &InterfaceSettings::setSpectatorNotificationsEnabled); - - buddyConnectNotificationsEnabledCheckBox.setChecked( - SettingsCache::instance().userInterface().getBuddyConnectNotificationsEnabled()); - buddyConnectNotificationsEnabledCheckBox.setEnabled( - SettingsCache::instance().userInterface().getNotificationsEnabled()); - connect(&buddyConnectNotificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, - &SettingsCache::instance().userInterface(), &InterfaceSettings::setBuddyConnectNotificationsEnabled); - + // general settings doubleClickToPlayCheckBox.setChecked(SettingsCache::instance().userInterface().getDoubleClickToPlay()); connect(&doubleClickToPlayCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), &InterfaceSettings::setDoubleClickToPlay); @@ -103,6 +93,26 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() generalGroupBox = new QGroupBox; generalGroupBox->setLayout(generalGrid); + // notification settings + notificationsEnabledCheckBox.setChecked(SettingsCache::instance().userInterface().getNotificationsEnabled()); + connect(¬ificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setNotificationsEnabled); + connect(¬ificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, this, + &UserInterfaceSettingsPage::setNotificationEnabled); + + specNotificationsEnabledCheckBox.setChecked( + SettingsCache::instance().userInterface().getSpectatorNotificationsEnabled()); + specNotificationsEnabledCheckBox.setEnabled(SettingsCache::instance().userInterface().getNotificationsEnabled()); + connect(&specNotificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setSpectatorNotificationsEnabled); + + buddyConnectNotificationsEnabledCheckBox.setChecked( + SettingsCache::instance().userInterface().getBuddyConnectNotificationsEnabled()); + buddyConnectNotificationsEnabledCheckBox.setEnabled( + SettingsCache::instance().userInterface().getNotificationsEnabled()); + connect(&buddyConnectNotificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, + &SettingsCache::instance().userInterface(), &InterfaceSettings::setBuddyConnectNotificationsEnabled); + auto *notificationsGrid = new QGridLayout; notificationsGrid->addWidget(¬ificationsEnabledCheckBox, 0, 0); notificationsGrid->addWidget(&specNotificationsEnabledCheckBox, 1, 0); @@ -116,8 +126,29 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() connect(&tapAnimationCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::setTapAnimation); + arrowDrawAnimationCheckBox.setChecked(SettingsCache::instance().cardsDisplay().getArrowDrawAnimation()); + connect(&arrowDrawAnimationCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setArrowDrawAnimation); + + lifeCounterAnimationsCheckBox.setChecked( + SettingsCache::instance().userInterface().getLifeCounterAnimationsEnabled()); + connect(&lifeCounterAnimationsCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setLifeCounterAnimationsEnabled); + + battlefieldFlashCheckBox.setChecked(SettingsCache::instance().userInterface().getBattlefieldFlashEnabled()); + connect(&battlefieldFlashCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setBattlefieldFlashEnabled); + + connect(&enableAllAnimationsButton, &QPushButton::clicked, this, &UserInterfaceSettingsPage::enableAllAnimations); + connect(&disableAllAnimationsButton, &QPushButton::clicked, this, &UserInterfaceSettingsPage::disableAllAnimations); + auto *animationGrid = new QGridLayout; - animationGrid->addWidget(&tapAnimationCheckBox, 0, 0); + animationGrid->addWidget(&enableAllAnimationsButton, 0, 0); + animationGrid->addWidget(&disableAllAnimationsButton, 0, 1); + animationGrid->addWidget(&tapAnimationCheckBox, 1, 0); + animationGrid->addWidget(&arrowDrawAnimationCheckBox, 2, 0); + animationGrid->addWidget(&lifeCounterAnimationsCheckBox, 3, 0); + animationGrid->addWidget(&battlefieldFlashCheckBox, 4, 0); animationGroupBox = new QGroupBox; animationGroupBox->setLayout(animationGrid); @@ -127,6 +158,11 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() connect(&openDeckInNewTabCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().deckEditor(), &DeckEditorSettings::setOpenDeckInNewTab); + visualDeckEditorShowCardCountsCheckBox.setChecked( + SettingsCache::instance().cardsDisplay().getVisualDeckEditorShowCardCounts()); + connect(&visualDeckEditorShowCardCountsCheckBox, &QCheckBox::QT_STATE_CHANGED, + &SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::setVisualDeckEditorShowCardCounts); + visualDeckStorageInGameCheckBox.setChecked( SettingsCache::instance().visualDeckStorage().getVisualDeckStorageInGame()); connect(&visualDeckStorageInGameCheckBox, &QCheckBox::QT_STATE_CHANGED, @@ -162,6 +198,13 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() connect(&defaultDeckEditorTypeSelector, QOverload::of(&QComboBox::currentIndexChanged), &SettingsCache::instance().deckEditor(), &DeckEditorSettings::setDefaultDeckEditorType); + vdeStartupTabSelector.addItem(""); // these will be set in retranslateUI + vdeStartupTabSelector.addItem(""); + vdeStartupTabSelector.addItem(""); + vdeStartupTabSelector.setCurrentIndex(SettingsCache::instance().deckEditor().getVdeStartupTab()); + connect(&vdeStartupTabSelector, QOverload::of(&QComboBox::currentIndexChanged), + &SettingsCache::instance().deckEditor(), &DeckEditorSettings::setVdeStartupTab); + commanderSpellbookIntegrationUseOfficialBracketNamesExplainer.setText("?"); commanderSpellbookIntegrationUseOfficialBracketNamesExplainer.setAutoRaise(true); commanderSpellbookIntegrationUseOfficialBracketNamesExplainer.setEnabled(false); @@ -215,16 +258,19 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() auto *deckEditorGrid = new QGridLayout; deckEditorGrid->addWidget(&openDeckInNewTabCheckBox, 0, 0); - deckEditorGrid->addWidget(&visualDeckStorageInGameCheckBox, 1, 0); - deckEditorGrid->addWidget(&visualDeckStorageSelectionAnimationCheckBox, 2, 0); - deckEditorGrid->addWidget(&visualDeckStoragePromptForConversionLabel, 3, 0); - deckEditorGrid->addWidget(&visualDeckStoragePromptForConversionSelector, 3, 1); - deckEditorGrid->addWidget(&defaultDeckEditorTypeLabel, 4, 0); - deckEditorGrid->addWidget(&defaultDeckEditorTypeSelector, 4, 1); - deckEditorGrid->addWidget(&commanderSpellbookIntegrationEnabledLabel, 5, 0); - deckEditorGrid->addWidget(&commanderSpellbookIntegrationEnabledSelector, 5, 1); - deckEditorGrid->addWidget(labelWidget, 6, 0); - deckEditorGrid->addWidget(&commanderSpellbookIntegrationBracketNamingSelector, 6, 1); + deckEditorGrid->addWidget(&visualDeckEditorShowCardCountsCheckBox, 1, 0); + deckEditorGrid->addWidget(&visualDeckStorageInGameCheckBox, 2, 0); + deckEditorGrid->addWidget(&visualDeckStorageSelectionAnimationCheckBox, 3, 0); + deckEditorGrid->addWidget(&visualDeckStoragePromptForConversionLabel, 4, 0); + deckEditorGrid->addWidget(&visualDeckStoragePromptForConversionSelector, 4, 1); + deckEditorGrid->addWidget(&defaultDeckEditorTypeLabel, 5, 0); + deckEditorGrid->addWidget(&defaultDeckEditorTypeSelector, 5, 1); + deckEditorGrid->addWidget(&vdeStartupTabLabel, 6, 0); + deckEditorGrid->addWidget(&vdeStartupTabSelector, 6, 1); + deckEditorGrid->addWidget(&commanderSpellbookIntegrationEnabledLabel, 7, 0); + deckEditorGrid->addWidget(&commanderSpellbookIntegrationEnabledSelector, 7, 1); + deckEditorGrid->addWidget(labelWidget, 8, 0); + deckEditorGrid->addWidget(&commanderSpellbookIntegrationBracketNamingSelector, 8, 1); deckEditorGroupBox = new QGroupBox; deckEditorGroupBox->setLayout(deckEditorGrid); @@ -268,6 +314,22 @@ void UserInterfaceSettingsPage::setNotificationEnabled(QT_STATE_CHANGED_T i) } } +void UserInterfaceSettingsPage::enableAllAnimations() +{ + tapAnimationCheckBox.setChecked(true); + arrowDrawAnimationCheckBox.setChecked(true); + lifeCounterAnimationsCheckBox.setChecked(true); + battlefieldFlashCheckBox.setChecked(true); +} + +void UserInterfaceSettingsPage::disableAllAnimations() +{ + tapAnimationCheckBox.setChecked(false); + arrowDrawAnimationCheckBox.setChecked(false); + lifeCounterAnimationsCheckBox.setChecked(false); + battlefieldFlashCheckBox.setChecked(false); +} + void UserInterfaceSettingsPage::updateCommanderSpellbookUiState() { const int mode = SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationEnabled(); @@ -309,10 +371,18 @@ void UserInterfaceSettingsPage::retranslateUi() notificationsEnabledCheckBox.setText(tr("Enable notifications in taskbar")); specNotificationsEnabledCheckBox.setText(tr("Notify in the taskbar for game events while you are spectating")); buddyConnectNotificationsEnabledCheckBox.setText(tr("Notify in the taskbar when users in your buddy list connect")); + animationGroupBox->setTitle(tr("Animation settings")); + enableAllAnimationsButton.setText(tr("&Enable all animations")); + disableAllAnimationsButton.setText(tr("&Disable all animations")); tapAnimationCheckBox.setText(tr("&Tap/untap animation")); + arrowDrawAnimationCheckBox.setText(tr("&Arrow draw animation")); + lifeCounterAnimationsCheckBox.setText(tr("Life counter flash")); + battlefieldFlashCheckBox.setText(tr("Battlefield flash on damage")); + deckEditorGroupBox->setTitle(tr("Deck editor/storage settings")); openDeckInNewTabCheckBox.setText(tr("Open deck in new tab by default")); + visualDeckEditorShowCardCountsCheckBox.setText(tr("Show card counts in Visual Deck Editor")); visualDeckStorageInGameCheckBox.setText(tr("Use visual deck storage in game lobby")); visualDeckStorageSelectionAnimationCheckBox.setText(tr("Use selection animation for Visual Deck Storage")); visualDeckStoragePromptForConversionLabel.setText( @@ -326,6 +396,12 @@ void UserInterfaceSettingsPage::retranslateUi() defaultDeckEditorTypeLabel.setText(tr("Default deck editor type")); defaultDeckEditorTypeSelector.setItemText(TabSupervisor::ClassicDeckEditor, tr("Classic Deck Editor")); defaultDeckEditorTypeSelector.setItemText(TabSupervisor::VisualDeckEditor, tr("Visual Deck Editor")); + vdeStartupTabLabel.setText(tr("Visual deck editor startup tab")); + vdeStartupTabSelector.setItemText(VdeStartupTabContext, tr("Context")); + vdeStartupTabSelector.setItemText(VdeStartupTabDeckDisplay, tr("Deck display")); + vdeStartupTabSelector.setItemText(VdeStartupTabDatabaseDisplay, tr("Database display")); + vdeStartupTabSelector.setToolTip( + tr("Context mode: New decks open on the database display, existing decks open on the deck view.")); commanderSpellbookIntegrationEnabledLabel.setText( tr("CommanderSpellbook integration to estimate commander bracket")); @@ -340,8 +416,8 @@ void UserInterfaceSettingsPage::retranslateUi() 0, CommanderBracketNames::CommanderSpellbookBracketNames); commanderSpellbookIntegrationBracketNamingSelector.setItemText( 1, CommanderBracketNames::OfficialCommanderBracketNames); - commanderSpellbookIntegrationUseOfficialBracketNamesExplainer.setToolTip(CommanderBracketNames::Explainer); + replayGroupBox->setTitle(tr("Replay settings")); rewindBufferingMsLabel.setText(tr("Buffer time for backwards skip via shortcut:")); rewindBufferingMsBox.setSuffix(" ms"); diff --git a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h index 9e6fada69..297d80c35 100644 --- a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h @@ -5,24 +5,25 @@ #include #include -#include #include +#include #include #include -#include #include +#include + +class QGroupBox; class UserInterfaceSettingsPage : public AbstractSettingsPage { Q_OBJECT private slots: void setNotificationEnabled(QT_STATE_CHANGED_T); + void enableAllAnimations(); + void disableAllAnimations(); void updateCommanderSpellbookUiState(); private: - QCheckBox notificationsEnabledCheckBox; - QCheckBox specNotificationsEnabledCheckBox; - QCheckBox buddyConnectNotificationsEnabledCheckBox; QCheckBox doubleClickToPlayCheckBox; QCheckBox clickPlaysAllSelectedCheckBox; QCheckBox playToStackCheckBox; @@ -34,21 +35,37 @@ private: QCheckBox showTotalSelectionCountCheckBox; QCheckBox useTearOffMenusCheckBox; QCheckBox keepGameChatFocusCheckBox; + + QCheckBox notificationsEnabledCheckBox; + QCheckBox specNotificationsEnabledCheckBox; + QCheckBox buddyConnectNotificationsEnabledCheckBox; + + QPushButton enableAllAnimationsButton; + QPushButton disableAllAnimationsButton; QCheckBox tapAnimationCheckBox; + QCheckBox arrowDrawAnimationCheckBox; + QCheckBox lifeCounterAnimationsCheckBox; + QCheckBox battlefieldFlashCheckBox; + QCheckBox openDeckInNewTabCheckBox; QLabel visualDeckStoragePromptForConversionLabel; QComboBox visualDeckStoragePromptForConversionSelector; QCheckBox visualDeckStorageInGameCheckBox; QCheckBox visualDeckStorageSelectionAnimationCheckBox; + QCheckBox visualDeckEditorShowCardCountsCheckBox; QLabel defaultDeckEditorTypeLabel; QComboBox defaultDeckEditorTypeSelector; + QLabel vdeStartupTabLabel; + QComboBox vdeStartupTabSelector; QLabel commanderSpellbookIntegrationEnabledLabel; QComboBox commanderSpellbookIntegrationEnabledSelector; QLabel commanderSpellbookIntegrationUseOfficialBracketNamesLabel; QToolButton commanderSpellbookIntegrationUseOfficialBracketNamesExplainer; QComboBox commanderSpellbookIntegrationBracketNamingSelector; + QLabel rewindBufferingMsLabel; QSpinBox rewindBufferingMsBox; + QGroupBox *generalGroupBox; QGroupBox *notificationsGroupBox; QGroupBox *animationGroupBox; diff --git a/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp b/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp index f80649eba..4044f03db 100644 --- a/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp +++ b/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp @@ -11,33 +11,49 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" +#include "../../deck_loader/deck_file_format.h" +#include "../../deck_loader/deck_loader.h" +#include "../../deck_loader/loaded_deck.h" +#include "../cards/additional_info/deck_color_identity.h" +#include "../cards/card_info_frame_widget.h" #include "../client/network/interfaces/deck_stats_interface.h" #include "../client/network/interfaces/tapped_out_interface.h" +#include "../deck_editor/deck_editor_card_database_dock_widget.h" +#include "../deck_editor/deck_editor_card_info_dock_widget.h" +#include "../deck_editor/deck_editor_deck_dock_widget.h" +#include "../deck_editor/deck_editor_filter_dock_widget.h" +#include "../deck_editor/deck_editor_printing_selector_dock_widget.h" #include "../deck_editor/deck_state_manager.h" #include "../interface/card_picture_loader/card_picture_loader.h" -#include "../interface/pixel_map_generator.h" #include "../interface/widgets/dialogs/dlg_load_deck.h" #include "../interface/widgets/dialogs/dlg_load_deck_from_clipboard.h" #include "../interface/widgets/dialogs/dlg_load_deck_from_website.h" +#include "../interface/widgets/dialogs/dlg_share_deck.h" +#include "../menus/deck_editor_menu.h" +#include "../printing_selector/printing_selector.h" #include "../utility/visibility_change_listener.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/deck_list/deck_list.h" +#include "libcockatrice/utility/card_ref.h" #include "tab_supervisor.h" #include -#include #include #include +#include #include -#include -#include +#include +#include +#include +#include #include #include #include -#include #include -#include -#include -#include +#include #include +#include #include #include #include @@ -48,6 +64,8 @@ #include #include #include +#include +#include /** * @brief Constructs the AbstractTabDeckEditor. @@ -323,6 +341,7 @@ bool AbstractTabDeckEditor::actSaveDeck() Command_DeckUpload cmd; cmd.set_deck_id(static_cast(loadedDeck.lastLoadInfo.remoteDeckId)); cmd.set_deck_list(deckString.toStdString()); + cmd.set_color_identity(getDeckColorIdentity(loadedDeck.deckList, CardDatabaseManager::query()).toStdString()); PendingCommand *pend = AbstractClient::prepareSessionCommand(cmd); connect(pend, &PendingCommand::finished, this, &AbstractTabDeckEditor::saveDeckRemoteFinished); @@ -382,6 +401,27 @@ bool AbstractTabDeckEditor::actSaveDeckAs() return true; } +/** + * @brief Opens the deck share dialog with the current deck preselected. + */ +void AbstractTabDeckEditor::actShareDeck() +{ + AbstractClient *client = tabSupervisor->getServerClient(); + if (client->getStatus() != StatusLoggedIn) { + QMessageBox::information(this, tr("Share deck"), tr("You must be connected to the server to share a deck.")); + return; + } + + const QSharedPointer deck = deckStateManager->getDeckListShared(); + if (deck->isBlankDeck()) { + QMessageBox::information(this, tr("Share deck"), tr("The deck is empty. Add cards before sharing it.")); + return; + } + + DlgShareDeck shareDialog(client, deck, this); + shareDialog.exec(); +} + /** * @brief Callback for remote deck save completion. * @param response Server response. diff --git a/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.h b/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.h index 34c585597..1782e7afd 100644 --- a/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.h +++ b/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.h @@ -8,48 +8,32 @@ #ifndef TAB_GENERIC_DECK_EDITOR_H #define TAB_GENERIC_DECK_EDITOR_H -#include "../interface/widgets/deck_editor/deck_editor_card_database_dock_widget.h" -#include "../interface/widgets/deck_editor/deck_editor_card_info_dock_widget.h" -#include "../interface/widgets/deck_editor/deck_editor_database_display_widget.h" -#include "../interface/widgets/deck_editor/deck_editor_deck_dock_widget.h" -#include "../interface/widgets/deck_editor/deck_editor_filter_dock_widget.h" -#include "../interface/widgets/deck_editor/deck_editor_printing_selector_dock_widget.h" -#include "../interface/widgets/menus/deck_editor_menu.h" -#include "../interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.h" +#include "../../deck_loader/deck_loader.h" +#include "libcockatrice/card/card_info.h" #include "tab.h" -#include +#include +#include +#include +#include class DeckStateManager; class CardDatabaseModel; -class CardDatabaseDisplayModel; - -class CardInfoFrameWidget; -class DeckLoader; class DeckEditorMenu; class DeckEditorCardInfoDockWidget; class DeckEditorCardDatabaseDockWidget; class DeckEditorDeckDockWidget; class DeckEditorFilterDockWidget; class DeckEditorPrintingSelectorDockWidget; -class DeckPreviewDeckTagsDisplayWidget; class Response; class FilterTree; -class FilterTreeModel; -class FilterBuilder; - -class QTreeView; -class QTextEdit; -class QLabel; -class QComboBox; -class QGroupBox; class QMessageBox; -class QHBoxLayout; -class QVBoxLayout; -class QPushButton; class QDockWidget; class QMenu; class QAction; +class ExactCard; +class TabSupervisor; +struct LoadedDeck; /** * @class AbstractTabDeckEditor @@ -213,6 +197,9 @@ protected slots: /** @brief Saves the current deck under a new name. */ virtual bool actSaveDeckAs(); + /** @brief Opens the deck share dialog for the current deck. */ + void actShareDeck(); + /** @brief Loads a deck from the clipboard. */ virtual void actLoadDeckFromClipboard(); @@ -263,12 +250,12 @@ protected slots: /** @brief Handles dock close events. */ void closeEvent(QCloseEvent *event) override; -private: /** @brief Sets the deck for this tab. * @param _deck The deck object. */ virtual void setDeck(const LoadedDeck &_deck); +private: /** @brief Helper for editing decks from the clipboard. */ void editDeckInClipboard(bool annotated); diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/archidekt_deck_listing_api_response.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/archidekt_deck_listing_api_response.cpp index 40882db04..8d023cd61 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/archidekt_deck_listing_api_response.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/archidekt_deck_listing_api_response.cpp @@ -2,6 +2,9 @@ #include #include +#include +#include +#include void ArchidektDeckListingApiResponse::fromJson(const QJsonObject &json) { diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/archidekt_deck_listing_api_response.h b/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/archidekt_deck_listing_api_response.h index 723c65ce2..c42bc1315 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/archidekt_deck_listing_api_response.h +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/archidekt_deck_listing_api_response.h @@ -4,8 +4,9 @@ #include "deck_listings/archidekt_api_response_deck_listing_container.h" #include -#include +#include #include +#include class ArchidektDeckListingApiResponse { diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/card/archidekt_api_response_card.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/card/archidekt_api_response_card.cpp index 46c229156..d118301bb 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/card/archidekt_api_response_card.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/card/archidekt_api_response_card.cpp @@ -1,5 +1,9 @@ #include "archidekt_api_response_card.h" +#include +#include +#include + void ArchidektApiResponseCard::fromJson(const QJsonObject &json) { id = json.value("id").toInt(); diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/card/archidekt_api_response_card.h b/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/card/archidekt_api_response_card.h index 559580da2..8e9e0b12a 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/card/archidekt_api_response_card.h +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/card/archidekt_api_response_card.h @@ -3,11 +3,10 @@ #include "archidekt_api_response_edition.h" -#include -#include #include +#include #include -#include +#include class ArchidektApiResponseCard { diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/card/archidekt_api_response_card_entry.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/card/archidekt_api_response_card_entry.cpp index f51d0f3e7..14b13a4ed 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/card/archidekt_api_response_card_entry.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/card/archidekt_api_response_card_entry.cpp @@ -1,5 +1,11 @@ #include "archidekt_api_response_card_entry.h" +#include +#include +#include +#include +#include + void ArchidektApiResponseCardEntry::fromJson(const QJsonObject &json) { id = json.value("id").toInt(); diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/card/archidekt_api_response_card_entry.h b/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/card/archidekt_api_response_card_entry.h index 118cf5b13..eb7c4190c 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/card/archidekt_api_response_card_entry.h +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/card/archidekt_api_response_card_entry.h @@ -3,11 +3,10 @@ #include "archidekt_api_response_card.h" -#include -#include #include +#include #include -#include +#include struct Category { diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/card/archidekt_api_response_edition.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/card/archidekt_api_response_edition.cpp index 122617a22..73836b9c8 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/card/archidekt_api_response_edition.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/card/archidekt_api_response_edition.cpp @@ -1,5 +1,9 @@ #include "archidekt_api_response_edition.h" +#include +#include +#include + void ArchidektApiResponseEdition::fromJson(const QJsonObject &json) { editionCode = json.value("editioncode").toString(); diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/card/archidekt_api_response_edition.h b/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/card/archidekt_api_response_edition.h index b898cd816..63d29652f 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/card/archidekt_api_response_edition.h +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/card/archidekt_api_response_edition.h @@ -1,11 +1,8 @@ #ifndef COCKATRICE_ARCHIDEKT_API_RESPONSE_EDITION_H #define COCKATRICE_ARCHIDEKT_API_RESPONSE_EDITION_H -#include -#include #include #include -#include class ArchidektApiResponseEdition { diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/deck/archidekt_api_response_deck.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/deck/archidekt_api_response_deck.cpp index a27d8d961..362ac0f63 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/deck/archidekt_api_response_deck.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/deck/archidekt_api_response_deck.cpp @@ -1,6 +1,13 @@ #include "archidekt_api_response_deck.h" #include "../card/archidekt_api_response_card_entry.h" +#include "../deck_listings/archidekt_api_response_deck_owner.h" + +#include +#include +#include +#include +#include void ArchidektApiResponseDeck::fromJson(const QJsonObject &json) { diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/deck/archidekt_api_response_deck.h b/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/deck/archidekt_api_response_deck.h index b539d9dd1..6af0a01c5 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/deck/archidekt_api_response_deck.h +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/deck/archidekt_api_response_deck.h @@ -1,15 +1,14 @@ #ifndef COCKATRICE_ARCHIDEKT_API_RESPONSE_DECK_H #define COCKATRICE_ARCHIDEKT_API_RESPONSE_DECK_H -#include "../card/archidekt_api_response_card.h" #include "../card/archidekt_api_response_card_entry.h" #include "../deck_listings/archidekt_api_response_deck_owner.h" #include "archidekt_api_response_deck_category.h" -#include -#include #include +#include #include +#include #include class ArchidektApiResponseDeck diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/deck/archidekt_api_response_deck_category.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/deck/archidekt_api_response_deck_category.cpp index 990293bc6..0df8b9f68 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/deck/archidekt_api_response_deck_category.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/deck/archidekt_api_response_deck_category.cpp @@ -1,5 +1,9 @@ #include "archidekt_api_response_deck_category.h" +#include +#include +#include + void ArchidektApiResponseDeckCategory::fromJson(const QJsonObject &json) { id = json.value("id").toInt(); diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/deck/archidekt_api_response_deck_category.h b/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/deck/archidekt_api_response_deck_category.h index 5e218b6a4..b31743643 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/deck/archidekt_api_response_deck_category.h +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/deck/archidekt_api_response_deck_category.h @@ -1,15 +1,8 @@ #ifndef COCKATRICE_ARCHIDEKT_API_RESPONSE_DECK_CATEGORY_H #define COCKATRICE_ARCHIDEKT_API_RESPONSE_DECK_CATEGORY_H -#include "../card/archidekt_api_response_card.h" -#include "../card/archidekt_api_response_card_entry.h" -#include "../deck_listings/archidekt_api_response_deck_owner.h" - -#include -#include #include #include -#include class ArchidektApiResponseDeckCategory { diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/deck_listings/archidekt_api_response_deck_listing_container.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/deck_listings/archidekt_api_response_deck_listing_container.cpp index 416621b41..0f5290f02 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/deck_listings/archidekt_api_response_deck_listing_container.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/deck_listings/archidekt_api_response_deck_listing_container.cpp @@ -3,8 +3,10 @@ #include "archidekt_api_response_deck_owner.h" #include -#include #include +#include +#include +#include void ArchidektApiResponseDeckListingContainer::fromJson(const QJsonObject &json) { diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/deck_listings/archidekt_api_response_deck_listing_container.h b/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/deck_listings/archidekt_api_response_deck_listing_container.h index 8924bb3f5..904d6b624 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/deck_listings/archidekt_api_response_deck_listing_container.h +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/deck_listings/archidekt_api_response_deck_listing_container.h @@ -3,11 +3,11 @@ #include "archidekt_api_response_deck_owner.h" -#include -#include #include +#include +#include #include -#include +#include class ArchidektApiResponseDeckListingContainer { diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/deck_listings/archidekt_api_response_deck_owner.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/deck_listings/archidekt_api_response_deck_owner.cpp index 3d6eac03d..67b80d67a 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/deck_listings/archidekt_api_response_deck_owner.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/deck_listings/archidekt_api_response_deck_owner.cpp @@ -1,5 +1,9 @@ #include "archidekt_api_response_deck_owner.h" +#include +#include +#include + void ArchidektApiResponseDeckOwner::fromJson(const QJsonObject &json) { id = json.value("id").toInt(); diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/deck_listings/archidekt_api_response_deck_owner.h b/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/deck_listings/archidekt_api_response_deck_owner.h index df3f48fd2..f63d6af0e 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/deck_listings/archidekt_api_response_deck_owner.h +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/api_response/deck_listings/archidekt_api_response_deck_owner.h @@ -1,11 +1,11 @@ #ifndef COCKATRICE_ARCHIDEKT_API_RESPONSE_DECK_OWNER_H #define COCKATRICE_ARCHIDEKT_API_RESPONSE_DECK_OWNER_H -#include -#include #include +#include #include -#include +#include +#include class ArchidektApiResponseDeckOwner { diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp index 66b68d823..555ec7ec5 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.cpp @@ -1,15 +1,40 @@ #include "archidekt_api_response_deck_display_widget.h" +#include "../../../../../../client/settings/cache_settings.h" #include "../../../../../deck_loader/card_node_function.h" -#include "../../../../../deck_loader/deck_loader.h" -#include "../../../../cards/card_size_widget.h" +#include "../../../../../deck_loader/loaded_deck.h" #include "../../../../cards/deck_card_zone_display_widget.h" #include "../../../../visual_deck_editor/visual_deck_display_options_widget.h" +#include "../../../../visual_deck_editor/visual_deck_editor_widget.h" #include "../api_response/archidekt_formats.h" +#include "../api_response/card/archidekt_api_response_card.h" +#include "../api_response/card/archidekt_api_response_card_entry.h" +#include "../api_response/card/archidekt_api_response_edition.h" #include "../api_response/deck/archidekt_api_response_deck.h" +#include "deck_list_model.h" +#include "libcockatrice/deck_list/deck_list.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include +#include +#include +#include +#include #include +#include +#include +#include + +class CardSizeWidget; ArchidektApiResponseDeckDisplayWidget::ArchidektApiResponseDeckDisplayWidget(QWidget *parent, ArchidektApiResponseDeck _response, @@ -120,6 +145,9 @@ ArchidektApiResponseDeckDisplayWidget::ArchidektApiResponseDeckDisplayWidget(QWi } model = new DeckListModel(this); + model->setDisplayLanguage(SettingsCache::instance().cardsDisplay().getCardLang()); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, model, + [this](const QString &lang) { model->setDisplayLanguage(lang); }); connect(model, &DeckListModel::modelReset, this, &ArchidektApiResponseDeckDisplayWidget::decklistModelReset); auto decklist = QSharedPointer(new DeckList); diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.h b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.h index aaf272718..cdacc70b3 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_display_widget.h @@ -1,18 +1,22 @@ #ifndef COCKATRICE_ARCHIDEKT_API_RESPONSE_DECK_DISPLAY_WIDGET_H #define COCKATRICE_ARCHIDEKT_API_RESPONSE_DECK_DISPLAY_WIDGET_H -#include "../../../../../deck_loader/deck_loader.h" -#include "../../../../cards/card_size_widget.h" -#include "../../../../general/layout_containers/flow_widget.h" -#include "../../../../visual_deck_editor/visual_deck_display_options_widget.h" #include "../api_response/deck/archidekt_api_response_deck.h" -#include "deck_list_model.h" -#include -#include -#include -#include +#include +#include +#include #include +#include + +class CardSizeWidget; +class DeckListModel; +class QHBoxLayout; +class QPushButton; +class QScrollArea; +class QVBoxLayout; +class VisualDeckDisplayOptionsWidget; +struct LoadedDeck; /** * @class ArchidektApiResponseDeckDisplayWidget diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.cpp index c35c2d0b8..b5e28fcf5 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.cpp @@ -1,17 +1,30 @@ #include "archidekt_api_response_deck_entry_display_widget.h" #include "../../../../../card_picture_loader/card_picture_loader.h" -#include "../../../../cards/card_info_picture_with_text_overlay_widget.h" #include "../../../../general/display/background_plate_widget.h" #include "../../../../general/display/charts/bars/color_bar.h" +#include "../../../../general/display/shadow_background_label.h" +#include "../api_response/deck_listings/archidekt_api_response_deck_listing_container.h" +#include "../api_response/deck_listings/archidekt_api_response_deck_owner.h" #include "archidekt_deck_preview_image_display_widget.h" -#include +#include +#include +#include #include #include #include +#include #include +#include +#include +#include +#include +#include +#include #include +#include +#include #include #define ARCHIDEKT_DEFAULT_IMAGE "https://storage.googleapis.com/topdekt-user/images/archidekt_deck_card_shadow.jpg" diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.h b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.h index 575a81b5f..e8ed8561d 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.h @@ -1,17 +1,20 @@ #ifndef COCKATRICE_ARCHIDEKT_API_RESPONSE_DECK_ENTRY_DISPLAY_WIDGET_H #define COCKATRICE_ARCHIDEKT_API_RESPONSE_DECK_ENTRY_DISPLAY_WIDGET_H -#include "../../../../cards/card_info_picture_with_text_overlay_widget.h" #include "../api_response/deck_listings/archidekt_api_response_deck_listing_container.h" -#include "archidekt_deck_preview_image_display_widget.h" -#include -#include -#include -#include +#include +#include +#include #include +#include class BackgroundPlateWidget; +class ArchidektDeckPreviewImageDisplayWidget; +class QLabel; +class QNetworkAccessManager; +class QNetworkReply; +class QVBoxLayout; /** * @class ArchidektApiResponseDeckEntryDisplayWidget diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.cpp index 4d3dfd660..9c4ea59ab 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.cpp @@ -1,8 +1,18 @@ #include "archidekt_api_response_deck_listings_display_widget.h" -#include "../../../../cards/card_info_picture_with_text_overlay_widget.h" +#include "../../../../cards/card_size_widget.h" +#include "../../../../general/layout_containers/flow_widget.h" +#include "../api_response/archidekt_deck_listing_api_response.h" +#include "../api_response/deck_listings/archidekt_api_response_deck_listing_container.h" #include "archidekt_api_response_deck_entry_display_widget.h" +#include +#include +#include +#include +#include +#include + ArchidektApiResponseDeckListingsDisplayWidget::ArchidektApiResponseDeckListingsDisplayWidget( QWidget *parent, ArchidektDeckListingApiResponse response, @@ -15,6 +25,8 @@ ArchidektApiResponseDeckListingsDisplayWidget::ArchidektApiResponseDeckListingsD flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); + cardSizeSlider->enableCtrlScrollResize(flowWidget); + imageNetworkManager = new QNetworkAccessManager(this); imageNetworkManager->setTransferTimeout(); // Use Qt's default timeout imageNetworkManager->setRedirectPolicy(QNetworkRequest::ManualRedirectPolicy); diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.h b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.h index f9a887aa4..a9a7da7a1 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.h @@ -1,15 +1,15 @@ #ifndef COCKATRICE_ARCHIDEKT_API_RESPONSE_DECK_LISTINGS_DISPLAY_WIDGET_H #define COCKATRICE_ARCHIDEKT_API_RESPONSE_DECK_LISTINGS_DISPLAY_WIDGET_H -#include "../../../../cards/card_size_widget.h" -#include "../../../../general/layout_containers/flow_widget.h" -#include "../api_response/archidekt_deck_listing_api_response.h" - -#include -#include -#include -#include +#include #include +#include + +class ArchidektDeckListingApiResponse; +class CardSizeWidget; +class FlowWidget; +class QHBoxLayout; +class QNetworkAccessManager; /** * @class ArchidektApiResponseDeckListingsDisplayWidget diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.cpp index ced08491e..97383534f 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.cpp @@ -1,7 +1,11 @@ #include "archidekt_deck_preview_image_display_widget.h" -#include -#include +#include "../../../../general/display/shadow_background_label.h" + +#include +#include +#include +#include ArchidektDeckPreviewImageDisplayWidget::ArchidektDeckPreviewImageDisplayWidget(QWidget *parent) : QWidget(parent) { diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.h b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.h index 053823c45..991f27b6a 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.h @@ -1,10 +1,11 @@ #ifndef COCKATRICE_ARCHIDEKT_DECK_PREVIEW_IMAGE_DISPLAY_WIDGET_H #define COCKATRICE_ARCHIDEKT_DECK_PREVIEW_IMAGE_DISPLAY_WIDGET_H -#include "../../../../general/display/shadow_background_label.h" - -#include #include +#include + +class QLabel; +class ShadowBackgroundLabel; /** * @class ArchidektDeckPreviewImageDisplayWidget 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 374d35cdf..02badf1aa 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp @@ -1,33 +1,56 @@ #include "tab_archidekt.h" #include "../../../../../client/settings/cache_settings.h" +#include "../../../../pixel_map_generator.h" #include "../../../cards/additional_info/mana_symbol_widget.h" +#include "../../../cards/card_size_widget.h" +#include "../../../quick_settings/settings_button_widget.h" +#include "../../../utility/completer_utils.h" +#include "../../tab.h" #include "../../tab_supervisor.h" #include "api_response/archidekt_deck_listing_api_response.h" +#include "api_response/deck/archidekt_api_response_deck.h" +#include "card_database_display_model.h" +#include "card_database_model.h" #include "display/archidekt_api_response_deck_display_widget.h" #include "display/archidekt_api_response_deck_listings_display_widget.h" -#include +#include +#include +#include #include -#include +#include #include -#include #include #include #include #include +#include +#include +#include +#include #include #include +#include +#include #include -#include -#include #include -#include +#include +#include +#include +#include +#include +#include +#include #include +#include +#include +#include +#include #include -#include -#include #include +#include +#include #include TabArchidekt::TabArchidekt(TabSupervisor *_tabSupervisor) @@ -214,7 +237,7 @@ void TabArchidekt::setupFilterWidgets() // Format filter (collapsible) formatButton = new SettingsButtonWidget(secondaryToolbar); formatButton->setButtonText(tr("Formats")); - formatButton->setButtonIcon(QPixmap("theme:icons/scale_balanced")); + formatButton->setButtonIcon(themePixmap(QStringLiteral("icons/scale_balanced"))); QWidget *formatContainer = new QWidget(secondaryToolbar); QGridLayout *formatLayout = new QGridLayout(formatContainer); @@ -279,41 +302,14 @@ void TabArchidekt::setupFilterWidgets() auto cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this); auto displayModel = new CardDatabaseDisplayModel(this); displayModel->setSourceModel(cardDatabaseModel); - auto *searchModel = new CardSearchModel(displayModel, this); - auto *proxyModel = new CardCompleterProxyModel(this); - proxyModel->setSourceModel(searchModel); - proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive); - proxyModel->setFilterRole(Qt::DisplayRole); - - auto *completer = new QCompleter(proxyModel, this); - completer->setCompletionRole(Qt::DisplayRole); - completer->setCompletionMode(QCompleter::PopupCompletion); - completer->setCaseSensitivity(Qt::CaseInsensitive); - completer->setFilterMode(Qt::MatchContains); - completer->setMaxVisibleItems(10); - - cardsField->setCompleter(completer); - commandersField->setCompleter(completer); + const CardCompleterSetup cardSetup = createCardCompleter(displayModel, this); + cardsField->setCompleter(cardSetup.completer); + commandersField->setCompleter(cardSetup.completer); // Keep autocomplete working for both fields - connect(cardsField, &QLineEdit::textChanged, this, [=](const QString &text) { - searchModel->updateSearchResults(text); - QString pattern = ".*" + QRegularExpression::escape(text) + ".*"; - proxyModel->setFilterRegularExpression(QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption)); - if (!text.isEmpty()) { - completer->complete(); - } - }); - - connect(commandersField, &QLineEdit::textChanged, this, [=](const QString &text) { - searchModel->updateSearchResults(text); - QString pattern = ".*" + QRegularExpression::escape(text) + ".*"; - proxyModel->setFilterRegularExpression(QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption)); - if (!text.isEmpty()) { - completer->complete(); - } - }); + connectCardCompleterSearch(cardsField, cardSetup); + connectCardCompleterSearch(commandersField, cardSetup); // Assemble secondary toolbar secondaryToolbarLayout->addWidget(bracketLabel); diff --git a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.h b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.h index e837e672b..015b8590b 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.h +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.h @@ -1,26 +1,35 @@ #ifndef COCKATRICE_TAB_ARCHIDEKT_H #define COCKATRICE_TAB_ARCHIDEKT_H -#include "../../interface/widgets/cards/card_size_widget.h" -#include "../../interface/widgets/quick_settings/settings_button_widget.h" #include "../../tab.h" -#include "display/archidekt_api_response_deck_listings_display_widget.h" +#include "libcockatrice/card/card_info.h" -#include -#include -#include -#include -#include -#include -#include -#include +#include +#include +#include +#include #include -#include #include -#include -#include -#include -#include +#include +#include + +class ArchidektApiResponseDeckListingsDisplayWidget; +class CardSizeWidget; +class QCheckBox; +class QComboBox; +class QHBoxLayout; +class QLabel; +class QLineEdit; +class QNetworkAccessManager; +class QNetworkReply; +class QPushButton; +class QScrollArea; +class QSpinBox; +class QTimer; +class QVBoxLayout; +class QWidget; +class SettingsButtonWidget; +class TabSupervisor; /** Base API link for Archidekt deck search */ inline QString archidektApiLink = "https://archidekt.com/api/decks/v3/?name="; diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/card_in_deck_request.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/card_in_deck_request.cpp index 3c95a4553..aa892d0d4 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/card_in_deck_request.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/card_in_deck_request.cpp @@ -1,5 +1,7 @@ #include "card_in_deck_request.h" +#include + CardInDeckRequest CardInDeckRequest::fromJson(const QJsonObject &json) { CardInDeckRequest request; diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/card_in_deck_request.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/card_in_deck_request.h index b7eb96211..6bcaca42f 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/card_in_deck_request.h +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/card_in_deck_request.h @@ -1,6 +1,7 @@ #ifndef COCKATRICE_CARD_IN_DECK_REQUEST_H #define COCKATRICE_CARD_IN_DECK_REQUEST_H #include +#include struct CardInDeckRequest { diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_card_result.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_card_result.cpp index 14558d234..0ae5dd360 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_card_result.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_card_result.cpp @@ -1,5 +1,7 @@ #include "commander_spellbook_card_result.h" +#include + CommanderSpellbookCardResult CommanderSpellbookCardResult::fromJson(const QJsonObject &json) { CommanderSpellbookCardResult result; diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_deck_request.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_deck_request.cpp index 166469353..ac259f3aa 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_deck_request.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_deck_request.cpp @@ -1,6 +1,14 @@ #include "commander_spellbook_deck_request.h" +#include "libcockatrice/deck_list/deck_list.h" +#include "libcockatrice/deck_list/tree/inner_deck_list_node.h" +#include "libcockatrice/utility/card_ref.h" + #include +#include +#include +#include +#include #include CommanderSpellbookDeckRequest CommanderSpellbookDeckRequest::fromJson(const QJsonObject &json) diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_deck_request.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_deck_request.h index 46d8e053b..002f7666f 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_deck_request.h +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_deck_request.h @@ -1,11 +1,12 @@ #ifndef COCKATRICE_COMMANDER_SPELLBOOK_DECK_REQUEST_H #define COCKATRICE_COMMANDER_SPELLBOOK_DECK_REQUEST_H #include "card_in_deck_request.h" -#include "libcockatrice/deck_list/deck_list.h" #include #include +class DeckList; + struct CommanderSpellbookDeckRequest { static CommanderSpellbookDeckRequest fromJson(const QJsonObject &json); diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_estimate_bracket_result.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_estimate_bracket_result.cpp index c3640ce79..35ba954a1 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_estimate_bracket_result.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_estimate_bracket_result.cpp @@ -1,5 +1,9 @@ #include "commander_spellbook_estimate_bracket_result.h" +#include +#include +#include + EstimateBracketResult EstimateBracketResult::fromJson(const QJsonObject &json) { EstimateBracketResult result; diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_estimate_bracket_result.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_estimate_bracket_result.h index bb589a901..c9c152d35 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_estimate_bracket_result.h +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_estimate_bracket_result.h @@ -6,6 +6,7 @@ #include #include +#include struct EstimateBracketResult { diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_variant_result.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_variant_result.cpp index bbc23e15e..53630a69e 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_variant_result.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_variant_result.cpp @@ -1,5 +1,7 @@ #include "commander_spellbook_variant_result.h" +#include + CommanderSpellbookVariantResult CommanderSpellbookVariantResult::fromJson(const QJsonObject &json) { CommanderSpellbookVariantResult result; diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_variant_result.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_variant_result.h index d908f1299..49f45f92c 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_variant_result.h +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_variant_result.h @@ -3,6 +3,7 @@ #include #include +#include struct CommanderSpellbookVariantResult { diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.cpp index b85129a3f..8e0448faa 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.cpp @@ -4,6 +4,8 @@ #include +class DeckList; + CommanderBracketService &CommanderBracketService::instance() { static CommanderBracketService service; diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.h index e0c2a9d55..22962cc95 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.h +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.h @@ -1,10 +1,15 @@ #ifndef COCKATRICE_COMMANDER_BRACKET_SERVICE_H #define COCKATRICE_COMMANDER_BRACKET_SERVICE_H +#include "api_response/commander_spellbook_estimate_bracket_result.h" #include "commander_spellbook_api_accessor.h" -#include "libcockatrice/deck_list/deck_list.h" #include +#include +#include +#include + +class DeckList; struct CommanderBracketEstimate { 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 4f50e38a6..06a76dd19 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,8 +1,11 @@ #include "commander_bracket_widget.h" #include "../../../../../client/settings/cache_settings.h" +#include "../../../../pixel_map_generator.h" #include "commander_bracket_service.h" +#include "libcockatrice/deck_list/deck_list.h" +#include #include #include #include @@ -10,11 +13,17 @@ #include #include #include +#include +#include #include +#include #include #include #include #include +#include + +template class QSharedPointer; CommanderBracketWidget::CommanderBracketWidget(QWidget *parent) : QWidget(parent) { @@ -30,7 +39,7 @@ CommanderBracketWidget::CommanderBracketWidget(QWidget *parent) : QWidget(parent bracketInfoButton->setEnabled(false); bracketRefreshButton = new QToolButton(this); - bracketRefreshButton->setIcon(QPixmap("theme:icons/reload")); + bracketRefreshButton->setIcon(themePixmap(QStringLiteral("icons/reload"))); bracketRefreshButton->setAutoRaise(true); connect(bracketRefreshButton, &QToolButton::clicked, this, &CommanderBracketWidget::requestBracketEstimate); diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.h index a45610fcf..f5b2b7958 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.h @@ -4,12 +4,17 @@ #include "commander_spellbook_bracket_explainer.h" #include +#include #include -#include +#include +#include class QLabel; class QToolButton; struct CommanderBracketEstimate; +class DeckList; +class QObject; +template class QSharedPointer; class CommanderBracketWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.cpp index 590692d98..bd8d9379d 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.cpp @@ -1,13 +1,21 @@ #include "commander_spellbook_api_accessor.h" #include "api_response/commander_spellbook_deck_request.h" +#include "api_response/commander_spellbook_estimate_bracket_result.h" +#include #include #include +#include +#include #include +#include #include +#include #include +class DeckList; + static const QUrl ESTIMATE_BRACKET_URL(QStringLiteral("https://backend.commanderspellbook.com/estimate-bracket")); CommanderSpellbookApiAccessor &CommanderSpellbookApiAccessor::instance() diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.h index 682a48524..98e7e3710 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.h +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.h @@ -1,12 +1,15 @@ #ifndef COCKATRICE_COMMANDER_SPELLBOOK_API_ACCESSOR_H #define COCKATRICE_COMMANDER_SPELLBOOK_API_ACCESSOR_H -#include "api_response/commander_spellbook_estimate_bracket_result.h" - #include -#include #include -#include +#include +#include +#include + +class DeckList; +class QNetworkReply; +struct EstimateBracketResult; class CommanderSpellbookApiAccessor final : public QObject { diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_bracket_explainer.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_bracket_explainer.cpp index fc308ed60..06250f1ea 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_bracket_explainer.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_bracket_explainer.cpp @@ -1,5 +1,9 @@ #include "commander_spellbook_bracket_explainer.h" +#include "api_response/commander_spellbook_card_result.h" +#include "api_response/commander_spellbook_estimate_bracket_result.h" +#include "api_response/commander_spellbook_variant_result.h" + static QString cardList(const QList &cards, int max = 5) { QStringList names; diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_bracket_explainer.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_bracket_explainer.h index 9ed499742..3ab317df9 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_bracket_explainer.h +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_bracket_explainer.h @@ -1,6 +1,12 @@ #ifndef COCKATRICE_COMMANDER_SPELLBOOK_BRACKET_EXPLAINER_H #define COCKATRICE_COMMANDER_SPELLBOOK_BRACKET_EXPLAINER_H -#include "api_response/commander_spellbook_estimate_bracket_result.h" + +#include +#include +#include +#include + +struct EstimateBracketResult; namespace CommanderBracketNames { diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp index 90b490efe..22200ffa2 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp @@ -2,9 +2,16 @@ #include "../../../../../client/settings/cache_settings.h" +#include #include -#include +#include +#include +#include +#include +#include +#include #include +#include #include static const QUrl COMMANDER_BRACKET_JSON_URL(QStringLiteral("https://cockatrice.github.io/commander-brackets.json")); diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.h index b8348b13c..39bddba05 100644 --- a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.h +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.h @@ -1,9 +1,12 @@ #ifndef COCKATRICE_HANDLE_COMMANDER_BRACKETS_H #define COCKATRICE_HANDLE_COMMANDER_BRACKETS_H -#include #include #include +#include +#include + +class QNetworkAccessManager; class HandleCommanderBrackets : public QObject { diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/archidekt_links/edhrec_api_response_archidekt_links.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/archidekt_links/edhrec_api_response_archidekt_links.cpp index 145a3fca0..411fdd5ed 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/archidekt_links/edhrec_api_response_archidekt_links.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/archidekt_links/edhrec_api_response_archidekt_links.cpp @@ -3,6 +3,9 @@ #include #include #include +#include +#include +#include void EdhrecApiResponseArchidektLink::fromJson(const QJsonObject &json) { diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/archidekt_links/edhrec_api_response_archidekt_links.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/archidekt_links/edhrec_api_response_archidekt_links.h index d265c164d..0786550fe 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/archidekt_links/edhrec_api_response_archidekt_links.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/archidekt_links/edhrec_api_response_archidekt_links.h @@ -1,7 +1,9 @@ #ifndef ARCHIDEKTENTRY_H #define ARCHIDEKTENTRY_H +#include #include +#include #include #include diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/average_deck/edhrec_average_deck_api_response.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/average_deck/edhrec_average_deck_api_response.cpp index 1297fc157..b6ecac0af 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/average_deck/edhrec_average_deck_api_response.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/average_deck/edhrec_average_deck_api_response.cpp @@ -1,7 +1,13 @@ #include "edhrec_average_deck_api_response.h" +#include "../archidekt_links/edhrec_api_response_archidekt_links.h" +#include "../cards/edhrec_api_response_card_container.h" +#include "../commander/edhrec_commander_api_response_average_deck_statistics.h" + #include #include +#include +#include void EdhrecAverageDeckApiResponse::fromJson(const QJsonObject &json) { diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/average_deck/edhrec_deck_api_response.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/average_deck/edhrec_deck_api_response.cpp index 1cb070c30..324dce52a 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/average_deck/edhrec_deck_api_response.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/average_deck/edhrec_deck_api_response.cpp @@ -1,10 +1,14 @@ #include "edhrec_deck_api_response.h" -#include #include #include -#include +#include +#include +#include +#include +#include #include +#include void EdhrecDeckApiResponse::fromJson(const QJsonArray &json) { diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/average_deck/edhrec_deck_api_response.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/average_deck/edhrec_deck_api_response.h index 7bc97c431..a47fcc10b 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/average_deck/edhrec_deck_api_response.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/average_deck/edhrec_deck_api_response.h @@ -7,7 +7,9 @@ #ifndef EDHREC_DECK_API_RESPONSE_H #define EDHREC_DECK_API_RESPONSE_H -#include "../../../../../../deck_loader/deck_loader.h" +#include "libcockatrice/deck_list/deck_list.h" + +#include class EdhrecDeckApiResponse { diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/card_prices/edhrec_api_response_card_prices.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/card_prices/edhrec_api_response_card_prices.cpp index 1231d147a..26502f1de 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/card_prices/edhrec_api_response_card_prices.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/card_prices/edhrec_api_response_card_prices.cpp @@ -1,6 +1,8 @@ #include "edhrec_api_response_card_prices.h" #include +#include +#include void CardPrices::fromJson(const QJsonObject &json) { diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/cards/edhrec_api_response_card_container.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/cards/edhrec_api_response_card_container.cpp index 3fef8be34..8660cac50 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/cards/edhrec_api_response_card_container.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/cards/edhrec_api_response_card_container.cpp @@ -3,6 +3,9 @@ #include #include #include +#include +#include +#include void EdhrecApiResponseCardContainer::fromJson(const QJsonObject &json) { diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/cards/edhrec_api_response_card_container.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/cards/edhrec_api_response_card_container.h index d76dda490..70d39474d 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/cards/edhrec_api_response_card_container.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/cards/edhrec_api_response_card_container.h @@ -11,6 +11,7 @@ #include "edhrec_commander_api_response_commander_details.h" #include +#include #include #include diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/cards/edhrec_api_response_card_details.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/cards/edhrec_api_response_card_details.cpp index a4a746c61..ebf09d2a9 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/cards/edhrec_api_response_card_details.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/cards/edhrec_api_response_card_details.cpp @@ -1,6 +1,8 @@ #include "edhrec_api_response_card_details.h" #include +#include +#include EdhrecApiResponseCardDetails::EdhrecApiResponseCardDetails() : synergy(0.0), inclusion(0), numDecks(0), potentialDecks(0) diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/cards/edhrec_api_response_card_list.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/cards/edhrec_api_response_card_list.cpp index 72d6fe2cc..0cf585ee9 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/cards/edhrec_api_response_card_list.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/cards/edhrec_api_response_card_list.cpp @@ -1,6 +1,10 @@ #include "edhrec_api_response_card_list.h" #include +#include +#include +#include +#include EdhrecApiResponseCardList::EdhrecApiResponseCardList() { diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/cards/edhrec_api_response_card_list.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/cards/edhrec_api_response_card_list.h index 2df29de0c..d6e0d7f75 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/cards/edhrec_api_response_card_list.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/cards/edhrec_api_response_card_list.h @@ -9,7 +9,7 @@ #include "edhrec_api_response_card_details.h" -#include +#include #include #include diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/cards/edhrec_commander_api_response_commander_details.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/cards/edhrec_commander_api_response_commander_details.cpp index 7b220603e..7383677c8 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/cards/edhrec_commander_api_response_commander_details.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/cards/edhrec_commander_api_response_commander_details.cpp @@ -1,6 +1,11 @@ #include "edhrec_commander_api_response_commander_details.h" +#include "../card_prices/edhrec_api_response_card_prices.h" + #include +#include +#include +#include void EdhrecCommanderApiResponseCommanderDetails::fromJson(const QJsonObject &json) { diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/cards/edhrec_commander_api_response_commander_details.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/cards/edhrec_commander_api_response_commander_details.h index 293e9a150..a0858e9d2 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/cards/edhrec_commander_api_response_commander_details.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/cards/edhrec_commander_api_response_commander_details.h @@ -10,6 +10,8 @@ #include "../card_prices/edhrec_api_response_card_prices.h" #include +#include +#include #include #include diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/commander/edhrec_commander_api_response.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/commander/edhrec_commander_api_response.cpp index da2a9c052..1e0304e8a 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/commander/edhrec_commander_api_response.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/commander/edhrec_commander_api_response.cpp @@ -1,7 +1,12 @@ #include "edhrec_commander_api_response.h" +#include "../archidekt_links/edhrec_api_response_archidekt_links.h" +#include "../cards/edhrec_api_response_card_container.h" + #include #include +#include +#include void EdhrecCommanderApiResponse::fromJson(const QJsonObject &json) { diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/commander/edhrec_commander_api_response_average_deck_statistics.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/commander/edhrec_commander_api_response_average_deck_statistics.cpp index 74cce5322..996ec3c0c 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/commander/edhrec_commander_api_response_average_deck_statistics.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/commander/edhrec_commander_api_response_average_deck_statistics.cpp @@ -1,5 +1,7 @@ #include "edhrec_commander_api_response_average_deck_statistics.h" +#include + void EdhrecCommanderApiResponseAverageDeckStatistics::fromJson(const QJsonObject &json) { creature = json.value("creature").toInt(0); diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/top_cards/edhrec_top_cards_api_response.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/top_cards/edhrec_top_cards_api_response.cpp index 26122ce89..4df2f3523 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/top_cards/edhrec_top_cards_api_response.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/top_cards/edhrec_top_cards_api_response.cpp @@ -1,7 +1,10 @@ #include "edhrec_top_cards_api_response.h" +#include "../cards/edhrec_api_response_card_container.h" + #include -#include +#include +#include void EdhrecTopCardsApiResponse::fromJson(const QJsonObject &json) { diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/top_cards/edhrec_top_cards_api_response.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/top_cards/edhrec_top_cards_api_response.h index 695498e2b..cdf0d4a71 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/top_cards/edhrec_top_cards_api_response.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/top_cards/edhrec_top_cards_api_response.h @@ -9,6 +9,7 @@ #include "../cards/edhrec_api_response_card_container.h" +#include #include class EdhrecTopCardsApiResponse diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/top_commanders/edhrec_top_commanders_api_response.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/top_commanders/edhrec_top_commanders_api_response.cpp index 83b10c9e5..c1bf71cb2 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/top_commanders/edhrec_top_commanders_api_response.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/top_commanders/edhrec_top_commanders_api_response.cpp @@ -1,7 +1,10 @@ #include "edhrec_top_commanders_api_response.h" +#include "../cards/edhrec_api_response_card_container.h" + #include -#include +#include +#include void EdhrecTopCommandersApiResponse::fromJson(const QJsonObject &json) { diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/top_commanders/edhrec_top_commanders_api_response.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/top_commanders/edhrec_top_commanders_api_response.h index 10aa51ff4..4579632c6 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/top_commanders/edhrec_top_commanders_api_response.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/top_commanders/edhrec_top_commanders_api_response.h @@ -9,6 +9,7 @@ #include "../cards/edhrec_api_response_card_container.h" +#include #include class EdhrecTopCommandersApiResponse diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/top_tags/edhrec_top_tags_api_response.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/top_tags/edhrec_top_tags_api_response.cpp index 56eb4fa4f..cf6ea5df8 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/top_tags/edhrec_top_tags_api_response.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/top_tags/edhrec_top_tags_api_response.cpp @@ -1,7 +1,10 @@ #include "edhrec_top_tags_api_response.h" +#include "../cards/edhrec_api_response_card_container.h" + #include -#include +#include +#include void EdhrecTopTagsApiResponse::fromJson(const QJsonObject &json) { diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/top_tags/edhrec_top_tags_api_response.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/top_tags/edhrec_top_tags_api_response.h index 0337d94f8..e6e25d97b 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/top_tags/edhrec_top_tags_api_response.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/api_response/top_tags/edhrec_top_tags_api_response.h @@ -9,6 +9,7 @@ #include "../cards/edhrec_api_response_card_container.h" +#include #include class EdhrecTopTagsApiResponse diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/card_prices/edhrec_api_response_card_prices_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/card_prices/edhrec_api_response_card_prices_display_widget.cpp index 75902daad..0ba74fd60 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/card_prices/edhrec_api_response_card_prices_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/card_prices/edhrec_api_response_card_prices_display_widget.cpp @@ -1,5 +1,13 @@ #include "edhrec_api_response_card_prices_display_widget.h" +#include "../../api_response/card_prices/edhrec_api_response_card_prices.h" + +#include +#include +#include +#include +#include + EdhrecApiResponseCardPricesDisplayWidget::EdhrecApiResponseCardPricesDisplayWidget(QWidget *parent, const CardPrices &_cardPrices) : QWidget(parent), cardPrices(_cardPrices) diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/card_prices/edhrec_api_response_card_prices_display_widget.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/card_prices/edhrec_api_response_card_prices_display_widget.h index 8943ae9f3..75fe033dc 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/card_prices/edhrec_api_response_card_prices_display_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/card_prices/edhrec_api_response_card_prices_display_widget.h @@ -9,9 +9,11 @@ #include "../../api_response/card_prices/edhrec_api_response_card_prices.h" -#include -#include #include +#include + +class QGridLayout; +class QLabel; class EdhrecApiResponseCardPricesDisplayWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp index 9d294f10c..0728b3154 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp @@ -1,10 +1,21 @@ #include "edhrec_api_response_card_details_display_widget.h" +#include "../../../../../cards/card_info_picture_widget.h" +#include "../../../../../cards/card_size_widget.h" #include "../../../../../general/display/background_plate_widget.h" +#include "../../api_response/cards/edhrec_api_response_card_details.h" #include "../../tab_edhrec_main.h" +#include "edhrec_api_response_card_inclusion_display_widget.h" +#include "edhrec_api_response_card_synergy_display_widget.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include #include +#include +#include +#include #include +#include EdhrecApiResponseCardDetailsDisplayWidget::EdhrecApiResponseCardDetailsDisplayWidget( QWidget *parent, @@ -54,6 +65,7 @@ EdhrecApiResponseCardDetailsDisplayWidget::EdhrecApiResponseCardDetailsDisplayWi &CardInfoPictureWidget::setScaleFactor); connect(this, &EdhrecApiResponseCardDetailsDisplayWidget::requestUrl, parentTab, &TabEdhRecMain::actNavigatePage); + parentTab->getCardSizeSlider()->enableCtrlScrollResize(this); } } diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.h index b09cc4e04..d738f4685 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.h @@ -7,15 +7,18 @@ #ifndef EDHREC_COMMANDER_API_RESPONSE_CARD_DETAILS_DISPLAY_WIDGET_H #define EDHREC_COMMANDER_API_RESPONSE_CARD_DETAILS_DISPLAY_WIDGET_H -#include "../../../../../cards/card_info_picture_widget.h" -#include "../../../../../general/display/background_plate_widget.h" #include "../../api_response/cards/edhrec_api_response_card_details.h" -#include "edhrec_api_response_card_inclusion_display_widget.h" -#include "edhrec_api_response_card_synergy_display_widget.h" -#include -#include +#include #include +#include + +class BackgroundPlateWidget; +class CardInfoPictureWidget; +class EdhrecApiResponseCardInclusionDisplayWidget; +class EdhrecApiResponseCardSynergyDisplayWidget; +class QLabel; +class QVBoxLayout; class EdhrecApiResponseCardDetailsDisplayWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_inclusion_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_inclusion_display_widget.cpp index 8292b00ea..6786b006e 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_inclusion_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_inclusion_display_widget.cpp @@ -1,5 +1,13 @@ #include "edhrec_api_response_card_inclusion_display_widget.h" +#include "../../../../../general/display/charts/bars/percent_bar_widget.h" +#include "../../api_response/cards/edhrec_api_response_card_details.h" + +#include +#include +#include +#include + EdhrecApiResponseCardInclusionDisplayWidget::EdhrecApiResponseCardInclusionDisplayWidget( QWidget *parent, const EdhrecApiResponseCardDetails &_toDisplay) diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_inclusion_display_widget.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_inclusion_display_widget.h index 516d820eb..61a1bd645 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_inclusion_display_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_inclusion_display_widget.h @@ -7,12 +7,14 @@ #ifndef EDHREC_API_RESPONSE_CARD_INCLUSION_DISPLAY_WIDGET_H #define EDHREC_API_RESPONSE_CARD_INCLUSION_DISPLAY_WIDGET_H -#include "../../../../../general/display/charts/bars/percent_bar_widget.h" #include "../../api_response/cards/edhrec_api_response_card_details.h" -#include -#include #include +#include + +class PercentBarWidget; +class QLabel; +class QVBoxLayout; class EdhrecApiResponseCardInclusionDisplayWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_list_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_list_display_widget.cpp index 841ef315b..5295b92d5 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_list_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_list_display_widget.cpp @@ -1,9 +1,19 @@ #include "edhrec_api_response_card_list_display_widget.h" #include "../../../../../general/display/banner_widget.h" +#include "../../../../../general/layout_containers/flow_widget.h" +#include "../../api_response/cards/edhrec_api_response_card_details.h" +#include "../../api_response/cards/edhrec_api_response_card_list.h" #include "edhrec_api_response_card_details_display_widget.h" -#include +#include +#include +#include +#include +#include +#include +#include +#include EdhrecApiResponseCardListDisplayWidget::EdhrecApiResponseCardListDisplayWidget(QWidget *parent, EdhrecApiResponseCardList toDisplay) diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_list_display_widget.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_list_display_widget.h index 555da6ecf..20bdad8ea 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_list_display_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_list_display_widget.h @@ -8,12 +8,14 @@ #define EDHREC_COMMANDER_API_RESPONSE_CARD_LIST_DISPLAY_WIDGET_H #include "../../../../../general/display/banner_widget.h" -#include "../../../../../general/layout_containers/flow_widget.h" -#include "../../api_response/cards/edhrec_api_response_card_list.h" -#include -#include +#include #include +#include + +class EdhrecApiResponseCardList; +class FlowWidget; +class QVBoxLayout; class EdhrecApiResponseCardListDisplayWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_synergy_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_synergy_display_widget.cpp index 9d5571a8f..5f4b02327 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_synergy_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_synergy_display_widget.cpp @@ -1,5 +1,13 @@ #include "edhrec_api_response_card_synergy_display_widget.h" +#include "../../../../../general/display/charts/bars/percent_bar_widget.h" +#include "../../api_response/cards/edhrec_api_response_card_details.h" + +#include +#include +#include +#include + EdhrecApiResponseCardSynergyDisplayWidget::EdhrecApiResponseCardSynergyDisplayWidget( QWidget *parent, const EdhrecApiResponseCardDetails &_toDisplay) diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_synergy_display_widget.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_synergy_display_widget.h index 5800b5497..71cca6ca4 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_synergy_display_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_synergy_display_widget.h @@ -7,12 +7,14 @@ #ifndef EDHREC_API_RESPONSE_CARD_SYNERGY_DISPLAY_WIDGET_H #define EDHREC_API_RESPONSE_CARD_SYNERGY_DISPLAY_WIDGET_H -#include "../../../../../general/display/charts/bars/percent_bar_widget.h" #include "../../api_response/cards/edhrec_api_response_card_details.h" -#include -#include #include +#include + +class PercentBarWidget; +class QLabel; +class QVBoxLayout; class EdhrecApiResponseCardSynergyDisplayWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.cpp index 515475f3e..3da050edd 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.cpp @@ -1,11 +1,20 @@ #include "edhrec_api_response_commander_details_display_widget.h" #include "../../../../../cards/card_info_picture_widget.h" +#include "../../../../../cards/card_size_widget.h" +#include "../../api_response/cards/edhrec_commander_api_response_commander_details.h" #include "../../tab_edhrec_main.h" #include "../card_prices/edhrec_api_response_card_prices_display_widget.h" -#include "edhrec_commander_api_response_bracket_navigation_widget.h" +#include "edhrec_commander_api_response_navigation_widget.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include +#include +#include +#include +#include #include +#include EdhrecCommanderResponseCommanderDetailsDisplayWidget::EdhrecCommanderResponseCommanderDetailsDisplayWidget( QWidget *parent, @@ -38,6 +47,7 @@ EdhrecCommanderResponseCommanderDetailsDisplayWidget::EdhrecCommanderResponseCom connect(parentTab->getCardSizeSlider()->getSlider(), &QSlider::valueChanged, commanderPicture, &CardInfoPictureWidget::setScaleFactor); commanderPicture->setScaleFactor(parentTab->getCardSizeSlider()->getSlider()->value()); + parentTab->getCardSizeSlider()->enableCtrlScrollResize(this); } commanderDetails.debugPrint(); diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.h index 4201ce3b3..197263512 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_api_response_commander_details_display_widget.h @@ -7,16 +7,19 @@ #ifndef EDHREC_COMMANDER_API_RESPONSE_COMMANDER_DETAILS_DISPLAY_WIDGET_H #define EDHREC_COMMANDER_API_RESPONSE_COMMANDER_DETAILS_DISPLAY_WIDGET_H -#include "../../../../../cards/card_info_picture_widget.h" #include "../../api_response/cards/edhrec_commander_api_response_commander_details.h" -#include "../card_prices/edhrec_api_response_card_prices_display_widget.h" -#include "edhrec_commander_api_response_navigation_widget.h" -#include -#include +#include #include +#include class EdhrecCommanderApiResponseNavigationWidget; +class CardInfoPictureWidget; +class EdhrecApiResponseCardPricesDisplayWidget; +class QHBoxLayout; +class QLabel; +class QVBoxLayout; + class EdhrecCommanderResponseCommanderDetailsDisplayWidget : public QWidget { Q_OBJECT diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.cpp index c3ab23e41..b26a7e574 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.cpp @@ -1,6 +1,12 @@ #include "edhrec_commander_api_response_bracket_navigation_widget.h" +#include +#include +#include +#include #include +#include +#include EdhrecCommanderApiResponseBracketNavigationWidget::EdhrecCommanderApiResponseBracketNavigationWidget( QWidget *parent, diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.h index 713ef2791..a741b6696 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_bracket_navigation_widget.h @@ -1,11 +1,16 @@ #ifndef COCKATRICE_EDHREC_COMMANDER_API_RESPONSE_BRACKET_NAVIGATION_WIDGET_H #define COCKATRICE_EDHREC_COMMANDER_API_RESPONSE_BRACKET_NAVIGATION_WIDGET_H -#include -#include +#include #include -#include +#include +#include #include +#include + +class QGridLayout; +class QLabel; +class QPushButton; class EdhrecCommanderApiResponseBracketNavigationWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.cpp index 1845c020d..48704d010 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.cpp @@ -1,6 +1,12 @@ #include "edhrec_commander_api_response_budget_navigation_widget.h" +#include +#include +#include +#include #include +#include +#include EdhrecCommanderApiResponseBudgetNavigationWidget::EdhrecCommanderApiResponseBudgetNavigationWidget( QWidget *parent, diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.h index 666edba16..8013d1d6b 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.h @@ -1,11 +1,16 @@ #ifndef COCKATRICE_EDHREC_COMMANDER_API_RESPONSE_BUDGET_NAVIGATION_WIDGET_H #define COCKATRICE_EDHREC_COMMANDER_API_RESPONSE_BUDGET_NAVIGATION_WIDGET_H -#include -#include +#include #include -#include +#include +#include #include +#include + +class QGridLayout; +class QLabel; +class QPushButton; class EdhrecCommanderApiResponseBudgetNavigationWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_display_widget.cpp index 230858d6c..111d91b91 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_display_widget.cpp @@ -1,15 +1,30 @@ #include "edhrec_commander_api_response_display_widget.h" -#include "../../../../../cards/card_info_picture_widget.h" +#include "../../api_response/cards/edhrec_api_response_card_container.h" +#include "../../api_response/cards/edhrec_api_response_card_list.h" #include "../../api_response/commander/edhrec_commander_api_response.h" #include "../cards/edhrec_api_response_card_list_display_widget.h" #include "edhrec_api_response_commander_details_display_widget.h" +#include +#include +#include +#include +#include #include +#include +#include #include #include +#include #include +#include #include +#include +#include +#include + +class QModelIndex; EdhrecCommanderApiResponseDisplayWidget::EdhrecCommanderApiResponseDisplayWidget(QWidget *parent, EdhrecCommanderApiResponse response, diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_display_widget.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_display_widget.h index 80da8a7f8..5ada40df3 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_display_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_display_widget.h @@ -7,11 +7,14 @@ #ifndef EDHREC_COMMANDER_API_RESPONSE_DISPLAY_WIDGET_H #define EDHREC_COMMANDER_API_RESPONSE_DISPLAY_WIDGET_H -#include "../../api_response/commander/edhrec_commander_api_response.h" - -#include -#include +#include #include +#include + +class EdhrecCommanderApiResponse; +class QHBoxLayout; +class QScrollArea; +class QVBoxLayout; class EdhrecCommanderApiResponseDisplayWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.cpp index c42d2ed90..fd02c4261 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.cpp @@ -1,6 +1,12 @@ #include "edhrec_commander_api_response_navigation_widget.h" #include "../../tab_edhrec_main.h" +#include "edhrec_commander_api_response_bracket_navigation_widget.h" +#include "edhrec_commander_api_response_budget_navigation_widget.h" + +#include +#include +#include EdhrecCommanderApiResponseNavigationWidget::EdhrecCommanderApiResponseNavigationWidget( QWidget *parent, diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.h index 9b46b54ba..737558625 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_navigation_widget.h @@ -7,14 +7,16 @@ #ifndef EDHREC_COMMANDER_API_RESPONSE_NAVIGATION_WIDGET_H #define EDHREC_COMMANDER_API_RESPONSE_NAVIGATION_WIDGET_H -#include "edhrec_api_response_commander_details_display_widget.h" -#include "edhrec_commander_api_response_bracket_navigation_widget.h" -#include "edhrec_commander_api_response_budget_navigation_widget.h" +#include "../../api_response/cards/edhrec_commander_api_response_commander_details.h" -#include -#include -#include +#include #include +#include + +class EdhrecCommanderApiResponseBracketNavigationWidget; +class EdhrecCommanderApiResponseBudgetNavigationWidget; +class QGridLayout; +class QPushButton; class EdhrecCommanderApiResponseNavigationWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_cards/edhrec_top_cards_api_response_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_cards/edhrec_top_cards_api_response_display_widget.cpp index ae07c9dc8..6f32db102 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_cards/edhrec_top_cards_api_response_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_cards/edhrec_top_cards_api_response_display_widget.cpp @@ -1,8 +1,17 @@ #include "edhrec_top_cards_api_response_display_widget.h" +#include "../../api_response/cards/edhrec_api_response_card_container.h" +#include "../../api_response/cards/edhrec_api_response_card_list.h" #include "../../api_response/top_cards/edhrec_top_cards_api_response.h" #include "../cards/edhrec_api_response_card_list_display_widget.h" +#include +#include +#include +#include +#include +#include + EdhrecTopCardsApiResponseDisplayWidget::EdhrecTopCardsApiResponseDisplayWidget(QWidget *parent, EdhrecTopCardsApiResponse response) : QWidget(parent) diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_cards/edhrec_top_cards_api_response_display_widget.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_cards/edhrec_top_cards_api_response_display_widget.h index 30998391c..8b5ddfad0 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_cards/edhrec_top_cards_api_response_display_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_cards/edhrec_top_cards_api_response_display_widget.h @@ -7,12 +7,13 @@ #ifndef EDHREC_TOP_CARDS_API_RESPONSE_DISPLAY_WIDGET_H #define EDHREC_TOP_CARDS_API_RESPONSE_DISPLAY_WIDGET_H -#include "../../api_response/top_cards/edhrec_top_cards_api_response.h" - -#include -#include -#include #include +#include + +class EdhrecTopCardsApiResponse; +class QHBoxLayout; +class QScrollArea; +class QVBoxLayout; class EdhrecTopCardsApiResponseDisplayWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_commander/edhrec_top_commanders_api_response_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_commander/edhrec_top_commanders_api_response_display_widget.cpp index 13f54a979..4ab333fd4 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_commander/edhrec_top_commanders_api_response_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_commander/edhrec_top_commanders_api_response_display_widget.cpp @@ -1,8 +1,20 @@ #include "edhrec_top_commanders_api_response_display_widget.h" +#include "../../api_response/cards/edhrec_api_response_card_container.h" +#include "../../api_response/cards/edhrec_api_response_card_list.h" #include "../../api_response/top_commanders/edhrec_top_commanders_api_response.h" #include "../cards/edhrec_api_response_card_list_display_widget.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + EdhrecTopCommandersApiResponseDisplayWidget::EdhrecTopCommandersApiResponseDisplayWidget( QWidget *parent, EdhrecTopCommandersApiResponse response) diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_commander/edhrec_top_commanders_api_response_display_widget.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_commander/edhrec_top_commanders_api_response_display_widget.h index 2b9563ea5..8471a8dda 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_commander/edhrec_top_commanders_api_response_display_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_commander/edhrec_top_commanders_api_response_display_widget.h @@ -7,12 +7,13 @@ #ifndef EDHREC_TOP_COMMANDERS_API_RESPONSE_DISPLAY_WIDGET_H #define EDHREC_TOP_COMMANDERS_API_RESPONSE_DISPLAY_WIDGET_H -#include "../../api_response/top_commanders/edhrec_top_commanders_api_response.h" - -#include -#include -#include #include +#include + +class EdhrecTopCommandersApiResponse; +class QHBoxLayout; +class QScrollArea; +class QVBoxLayout; class EdhrecTopCommandersApiResponseDisplayWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp index 91a1ce3fa..9c7ce8d06 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.cpp @@ -1,8 +1,17 @@ #include "edhrec_top_tags_api_response_display_widget.h" +#include "../../api_response/cards/edhrec_api_response_card_container.h" +#include "../../api_response/cards/edhrec_api_response_card_list.h" #include "../../api_response/top_tags/edhrec_top_tags_api_response.h" #include "../cards/edhrec_api_response_card_list_display_widget.h" +#include +#include +#include +#include +#include +#include + EdhrecTopTagsApiResponseDisplayWidget::EdhrecTopTagsApiResponseDisplayWidget(QWidget *parent, EdhrecTopTagsApiResponse response) : QWidget(parent) diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.h index 4a4fc8991..2c13d069f 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/top_tags/edhrec_top_tags_api_response_display_widget.h @@ -7,11 +7,13 @@ #ifndef EDHREC_TOP_TAGS_API_RESPONSE_DISPLAY_WIDGET_H #define EDHREC_TOP_TAGS_API_RESPONSE_DISPLAY_WIDGET_H -#include "../../api_response/top_tags/edhrec_top_tags_api_response.h" - -#include -#include #include +#include + +class EdhrecTopTagsApiResponse; +class QHBoxLayout; +class QScrollArea; +class QVBoxLayout; class EdhrecTopTagsApiResponseDisplayWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec.cpp index 10389f4eb..3aa395126 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec.cpp @@ -1,16 +1,26 @@ #include "tab_edhrec.h" +#include "../../tab.h" #include "api_response/commander/edhrec_commander_api_response.h" #include "display/commander/edhrec_commander_api_response_display_widget.h" +#include "libcockatrice/card/card_info.h" +#include +#include #include #include #include #include +#include #include #include +#include #include -#include +#include +#include +#include + +class TabSupervisor; TabEdhRec::TabEdhRec(TabSupervisor *_tabSupervisor) : Tab(_tabSupervisor) { diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec.h index 5ce36c9cf..4e3c9c8ba 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec.h @@ -8,10 +8,16 @@ #define TAB_EDHREC_H #include "../../tab.h" -#include "display/commander/edhrec_commander_api_response_display_widget.h" -#include +#include +#include #include +#include + +class EdhrecCommanderApiResponseDisplayWidget; +class QNetworkAccessManager; +class QNetworkReply; +class TabSupervisor; class TabEdhRec : public Tab { diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp index 42a689898..ca575dc02 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp @@ -1,6 +1,10 @@ #include "tab_edhrec_main.h" #include "../../../../../client/settings/cache_settings.h" +#include "../../../../deck_loader/loaded_deck.h" +#include "../../../cards/card_size_widget.h" +#include "../../../quick_settings/settings_button_widget.h" +#include "../../../utility/completer_utils.h" #include "../../tab_supervisor.h" #include "api_response/average_deck/edhrec_average_deck_api_response.h" #include "api_response/commander/edhrec_commander_api_response.h" @@ -12,19 +16,22 @@ #include "display/top_commander/edhrec_top_commanders_api_response_display_widget.h" #include "display/top_tags/edhrec_top_tags_api_response_display_widget.h" -#include #include #include #include #include #include +#include #include #include #include #include #include +#include #include #include +#include +#include #include #include @@ -63,32 +70,12 @@ TabEdhRecMain::TabEdhRecMain(TabSupervisor *_tabSupervisor) : Tab(_tabSupervisor auto cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this); auto displayModel = new CardDatabaseDisplayModel(this); displayModel->setSourceModel(cardDatabaseModel); - auto *searchModel = new CardSearchModel(displayModel, this); - auto *proxyModel = new CardCompleterProxyModel(this); - proxyModel->setSourceModel(searchModel); - proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive); - proxyModel->setFilterRole(Qt::DisplayRole); - - auto *completer = new QCompleter(proxyModel, this); - completer->setCompletionRole(Qt::DisplayRole); - completer->setCompletionMode(QCompleter::PopupCompletion); - completer->setCaseSensitivity(Qt::CaseInsensitive); - completer->setFilterMode(Qt::MatchContains); - completer->setMaxVisibleItems(10); - searchBar->setCompleter(completer); + const CardCompleterSetup cardSetup = createCardCompleter(displayModel, this); + searchBar->setCompleter(cardSetup.completer); // Update suggestions dynamically - connect(searchBar, &QLineEdit::textChanged, searchModel, &CardSearchModel::updateSearchResults); - connect(searchBar, &QLineEdit::textChanged, this, [=](const QString &text) { - // Ensure substring matching - QString pattern = ".*" + QRegularExpression::escape(text) + ".*"; - proxyModel->setFilterRegularExpression(QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption)); - - if (!text.isEmpty()) { - completer->complete(); // Force the dropdown to appear - } - }); + connectCardCompleterSearch(searchBar, cardSetup); searchPushButton = new QPushButton(navigationContainer); connect(searchPushButton, &QPushButton::clicked, this, [=, this]() { doSearch(); }); @@ -154,6 +141,8 @@ void TabEdhRecMain::setCard(CardInfoPtr _cardToQuery, bool isCommander) return; } + emit tabTextChanged(this, getTabText()); + QString cardName = cardToQuery->getName(); QString formattedName = cardName.toLower().replace(" ", "-").remove(QRegularExpression("[^a-z0-9\\-]")); diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.h b/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.h index a7ed83efd..5d0245ed4 100644 --- a/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.h +++ b/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.h @@ -7,16 +7,25 @@ #ifndef TAB_EDHREC_MAIN_H #define TAB_EDHREC_MAIN_H -#include "../../interface/widgets/cards/card_size_widget.h" -#include "../../interface/widgets/quick_settings/settings_button_widget.h" #include "../../tab.h" -#include "display/commander/edhrec_commander_api_response_display_widget.h" +#include "libcockatrice/card/card_info.h" -#include -#include -#include -#include -#include +#include +#include +#include +#include + +class CardSizeWidget; +class EdhrecCommanderApiResponseDisplayWidget; +class QHBoxLayout; +class QLineEdit; +class QNetworkAccessManager; +class QNetworkReply; +class QPushButton; +class QVBoxLayout; +class QWidget; +class SettingsButtonWidget; +class TabSupervisor; class TabEdhRecMain : public Tab { diff --git a/cockatrice/src/interface/widgets/tabs/tab.cpp b/cockatrice/src/interface/widgets/tabs/tab.cpp index 3dcb3b523..5ef4d18b8 100644 --- a/cockatrice/src/interface/widgets/tabs/tab.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab.cpp @@ -1,12 +1,17 @@ #include "tab.h" #include "../interface/widgets/cards/card_info_display_widget.h" +#include "libcockatrice/utility/card_ref.h" #include "tab_supervisor.h" #include -#include -#include +#include +#include +#include #include +#include +#include +#include Tab::Tab(TabSupervisor *_tabSupervisor) : QMainWindow(_tabSupervisor), tabSupervisor(_tabSupervisor), contentsChanged(false), infoPopup(0) diff --git a/cockatrice/src/interface/widgets/tabs/tab.h b/cockatrice/src/interface/widgets/tabs/tab.h index 6ea1f5077..e114f9bd9 100644 --- a/cockatrice/src/interface/widgets/tabs/tab.h +++ b/cockatrice/src/interface/widgets/tabs/tab.h @@ -7,12 +7,16 @@ #ifndef TAB_H #define TAB_H +#include #include +#include #include +#include class QMenu; class TabSupervisor; class CardInfoDisplayWidget; +class QPoint; class Tab : public QMainWindow { @@ -20,6 +24,7 @@ class Tab : public QMainWindow signals: void userEvent(bool globalEvent = true); void tabTextChanged(Tab *tab, const QString &newTabText); + void cockatriceLinkActivated(const QString &url); protected: TabSupervisor *tabSupervisor; diff --git a/cockatrice/src/interface/widgets/tabs/tab_account.cpp b/cockatrice/src/interface/widgets/tabs/tab_account.cpp index 2cc8165e8..e55dc55d0 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_account.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_account.cpp @@ -1,14 +1,22 @@ #include "tab_account.h" #include "../client/sound_engine.h" +#include "../interface/widgets/dialogs/dlg_my_reports.h" #include "../interface/widgets/server/user/user_info_box.h" #include "../interface/widgets/server/user/user_list_manager.h" #include "../interface/widgets/server/user/user_list_widget.h" #include "../interface/widgets/utility/custom_line_edit.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_user.pb.h" #include "tab_supervisor.h" +#include +#include +#include #include +#include #include +#include #include #include #include @@ -18,6 +26,7 @@ #include #include #include +#include TabAccount::TabAccount(TabSupervisor *_tabSupervisor, AbstractClient *_client, const ServerInfo_User &userInfo) : Tab(_tabSupervisor), client(_client) @@ -49,6 +58,11 @@ TabAccount::TabAccount(TabSupervisor *_tabSupervisor, AbstractClient *_client, c auto *vbox = new QVBoxLayout; vbox->addWidget(userInfoBox); + + myReportsButton = new QPushButton(tr("My Reports")); + connect(myReportsButton, &QPushButton::clicked, this, &TabAccount::openMyReports); + vbox->addWidget(myReportsButton); + vbox->addWidget(allUsersList); auto *addToBuddyList = new QHBoxLayout; @@ -126,15 +140,22 @@ void TabAccount::addToList(const std::string &listName, const QString &userName) void TabAccount::retranslateUi() { + myReportsButton->setText(tr("My Reports")); allUsersList->retranslateUi(); buddyList->retranslateUi(); ignoreList->retranslateUi(); userInfoBox->retranslateUi(); + + buddyList->setToolTip(tr("Buddies are marked with a star in chat, a sound plays when they join or leave the " + "server, and they can be invited to buddy-only games.")); + ignoreList->setToolTip(tr("Ignored users' chat messages are hidden from you, and they cannot send you private " + "messages or join your games.")); } void TabAccount::processListUsersResponse(const Response &response) { const Response_ListUsers &resp = response.GetExtension(Response_ListUsers::ext); + allUsersList->beginBulkLoad(); for (int i = 0; i < resp.user_list_size(); ++i) { const ServerInfo_User &info = resp.user_list(i); const QString &userName = QString::fromStdString(info.name()); @@ -142,8 +163,8 @@ void TabAccount::processListUsersResponse(const Response &response) ignoreList->setUserOnline(userName, true); buddyList->setUserOnline(userName, true); } + allUsersList->endBulkLoad(); - allUsersList->sortItems(); ignoreList->sortItems(); buddyList->sortItems(); } @@ -188,18 +209,20 @@ void TabAccount::processUserLeftEvent(const Event_UserLeft &event) void TabAccount::buddyListReceived(const QList &_buddyList) { + buddyList->beginBulkLoad(); for (const auto &user : _buddyList) { buddyList->processUserInfo(user, false); } - buddyList->sortItems(); + buddyList->endBulkLoad(); } void TabAccount::ignoreListReceived(const QList &_ignoreList) { + ignoreList->beginBulkLoad(); for (const auto &user : _ignoreList) { ignoreList->processUserInfo(user, false); } - ignoreList->sortItems(); + ignoreList->endBulkLoad(); } void TabAccount::processAddToListEvent(const Event_AddToList &event) @@ -237,3 +260,10 @@ void TabAccount::processRemoveFromListEvent(const Event_RemoveFromList &event) userList->deleteUser(user); } + +void TabAccount::openMyReports() +{ + auto *dlg = new DlgMyReports(client, this); + dlg->setAttribute(Qt::WA_DeleteOnClose); + dlg->exec(); +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_account.h b/cockatrice/src/interface/widgets/tabs/tab_account.h index 887038ebb..ab2c4b38d 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_account.h +++ b/cockatrice/src/interface/widgets/tabs/tab_account.h @@ -9,19 +9,27 @@ #include "tab.h" +#include +#include +#include +// IWYU pragma: keep +// ServerInfo_User appears by value and inside QList in slot signatures, so the +// moc-generated code needs the complete type. #include class AbstractClient; class Event_AddToList; -class Event_ListRooms; class Event_RemoveFromList; class Event_UserJoined; class Event_UserLeft; class LineEditUnfocusable; +class QPushButton; class Response; class ServerInfo_User; class UserInfoBox; class UserListWidget; +class TabSupervisor; +template class QList; class TabAccount : public Tab { @@ -41,6 +49,7 @@ private slots: void processRemoveFromListEvent(const Event_RemoveFromList &event); void addToIgnoreList(); void addToBuddyList(); + void openMyReports(); private: AbstractClient *client; @@ -50,6 +59,7 @@ private: UserInfoBox *userInfoBox; LineEditUnfocusable *addBuddyEdit; LineEditUnfocusable *addIgnoreEdit; + QPushButton *myReportsButton; void addToList(const std::string &listName, const QString &userName); public: diff --git a/cockatrice/src/interface/widgets/tabs/tab_admin.cpp b/cockatrice/src/interface/widgets/tabs/tab_admin.cpp index 16f17e03e..cfbd00538 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_admin.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_admin.cpp @@ -1,19 +1,31 @@ #include "tab_admin.h" +#include "libcockatrice/protocol/pb/response.pb.h" + #include #include #include +#include #include #include #include #include #include +#include +#include +#include #include #include #include #include #include #include +#include +#include + +class CommandContainer; +class QVariant; +class TabSupervisor; ShutdownDialog::ShutdownDialog(QWidget *parent) : QDialog(parent) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_admin.h b/cockatrice/src/interface/widgets/tabs/tab_admin.h index 0da098ba5..8eb139a8e 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_admin.h +++ b/cockatrice/src/interface/widgets/tabs/tab_admin.h @@ -10,15 +10,17 @@ #include "tab.h" #include -#include -#include +#include +#include class AbstractClient; - class QGroupBox; class QPushButton; class QSpinBox; class QLineEdit; +class QWidget; +class Response; +class TabSupervisor; class ShutdownDialog : public QDialog { diff --git a/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp index 3dc4de15f..1fc2854ce 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp @@ -1,15 +1,39 @@ #include "tab_card_art_rules.h" +#include "../utility/completer_utils.h" +#include "card_database_display_model.h" +#include "card_database_model.h" +#include "libcockatrice/card/card_info.h" #include "libcockatrice/card/database/card_database_manager.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/card/printing/printing_info.h" +#include "libcockatrice/card/set/card_set.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include +#include #include #include #include +#include +#include +#include +#include +#include #include +#include +#include +#include +#include #include #include #include #include +#include + +class QObject; +class TabSupervisor; CardArtRulesModel::CardArtRulesModel(AbstractClient *client, QObject *parent) : QAbstractTableModel(parent), client(client) @@ -194,29 +218,14 @@ void TabCardArtRules::initSearchBar() cardDbModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this); cardDbDisplayModel = new CardDatabaseDisplayModel(this); cardDbDisplayModel->setSourceModel(cardDbModel); - cardSearchModel = new CardSearchModel(cardDbDisplayModel, this); - cardProxyModel = new CardCompleterProxyModel(this); - cardProxyModel->setSourceModel(cardSearchModel); - cardProxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive); - - searchCompleter = new QCompleter(cardProxyModel, this); - searchCompleter->setCompletionRole(Qt::DisplayRole); - searchCompleter->setCompletionMode(QCompleter::PopupCompletion); - searchCompleter->setCaseSensitivity(Qt::CaseInsensitive); - searchCompleter->setFilterMode(Qt::MatchContains); - searchCompleter->setMaxVisibleItems(15); + const CardCompleterSetup cardSetup = createCardCompleter(cardDbDisplayModel, this, 15); + cardSearchModel = cardSetup.searchModel; + cardProxyModel = cardSetup.proxyModel; + searchCompleter = cardSetup.completer; searchEdit->setCompleter(searchCompleter); - connect(searchEdit, &QLineEdit::textEdited, cardSearchModel, &CardSearchModel::updateSearchResults); - connect(searchEdit, &QLineEdit::textEdited, this, [this](const QString &text) { - const QString pattern = ".*" + QRegularExpression::escape(text) + ".*"; - cardProxyModel->setFilterRegularExpression( - QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption)); - if (!text.isEmpty()) { - searchCompleter->complete(); - } - }); + connectCardCompleterSearch(searchEdit, cardSetup); connect(searchCompleter, static_cast(&QCompleter::activated), this, [this](const QString &name) { searchEdit->setText(name); }); connect(searchEdit, &QLineEdit::editingFinished, this, diff --git a/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.h b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.h index b9ea2ca83..f34300179 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.h +++ b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.h @@ -1,16 +1,28 @@ #ifndef COCKATRICE_DLG_CARD_ART_RULES_H #define COCKATRICE_DLG_CARD_ART_RULES_H -#include "card/card_search_model.h" -#include "tab_supervisor.h" +#include "tab.h" #include -#include -#include -#include -#include +#include +#include +#include +#include +#include class AbstractClient; +class CardCompleterProxyModel; +class CardDatabaseDisplayModel; +class CardDatabaseModel; +class CardSearchModel; +class QComboBox; +class QCompleter; +class QLineEdit; +class QObject; +class QPushButton; +class QTableView; +class Response; +class TabSupervisor; class CardArtRulesModel : public QAbstractTableModel { diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_editor.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_editor.cpp index 8439aef70..7ca179cea 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_editor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_editor.cpp @@ -2,28 +2,28 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" +#include "../deck_editor/deck_editor_card_database_dock_widget.h" +#include "../deck_editor/deck_editor_card_info_dock_widget.h" +#include "../deck_editor/deck_editor_deck_dock_widget.h" +#include "../deck_editor/deck_editor_printing_selector_dock_widget.h" #include "../deck_editor/deck_state_manager.h" -#include "../filters/filter_builder.h" -#include "../interface/pixel_map_generator.h" -#include "../interface/widgets/cards/card_info_frame_widget.h" #include "../interface/widgets/deck_editor/deck_editor_filter_dock_widget.h" #include "../interface/widgets/menus/deck_editor_menu.h" -#include "tab_supervisor.h" #include -#include -#include -#include +#include #include -#include -#include -#include -#include -#include -#include -#include +#include +#include +#include +#include +#include +#include #include #include +#include + +class TabSupervisor; /** * @brief Constructs a new TabDeckEditor object. diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_editor.h b/cockatrice/src/interface/widgets/tabs/tab_deck_editor.h index 14be59cd7..afe62e962 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_editor.h +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_editor.h @@ -1,17 +1,11 @@ #ifndef WINDOW_DECKEDITOR_H #define WINDOW_DECKEDITOR_H -#include "../../key_signals.h" -#include "../interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.h" #include "abstract_tab_deck_editor.h" -#include +#include -class CardDatabaseModel; -class CardDatabaseDisplayModel; -class DeckListModel; - -class QLabel; +class TabSupervisor; /** * @class TabDeckEditor diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index 49e42e4cf..44d04bd24 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -1,35 +1,90 @@ #include "tab_deck_storage.h" #include "../../../client/settings/cache_settings.h" +#include "../../deck_loader/deck_file_format.h" #include "../../deck_loader/deck_loader.h" +#include "../../deck_loader/loaded_deck.h" +#include "../../pixel_map_generator.h" +#include "../cards/additional_info/deck_color_identity.h" +#include "../deck_share/deck_share_utils.h" +#include "../deck_share/share_bar_widget.h" #include "../interface/widgets/server/remote/remote_decklist_tree_widget.h" #include "../interface/widgets/utility/get_text_with_max.h" +#include "abstract_client.h" +#include "libcockatrice/protocol/pb/commands.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_user.pb.h" +#include "libcockatrice/protocol/pb/session_commands.pb.h" +#include +#include #include #include #include #include +#include +#include +#include #include +#include #include +#include #include #include +#include +#include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include #include +#include +#include +#include +#include #include #include #include #include #include +#include +#include #include #include #include #include #include +#include #include #include +#include +#include +#include +#include + +class TabSupervisor; + +namespace +{ +// How long to wait after the last visibility change before reading back the +// Public/Private column, in milliseconds. +constexpr int VISIBILITY_REFRESH_DELAY = 500; + +// Whether the file's name matches one of the deck formats Cockatrice can load. +bool isSupportedDeckFile(const QString &filePath) +{ + return QDir::match(DeckLoader::ACCEPTED_FILE_EXTENSIONS, QFileInfo(filePath).fileName()); +} +} // namespace TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, AbstractClient *_client, @@ -38,6 +93,8 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, { localDirModel = new QFileSystemModel(this); localDirModel->setRootPath(SettingsCache::instance().paths().getDeckPath()); + localDirModel->setNameFilters(DeckLoader::ACCEPTED_FILE_EXTENSIONS); + localDirModel->setNameFilterDisables(false); localDirModel->sort(0, Qt::AscendingOrder); localDirView = new QTreeView; @@ -91,8 +148,34 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, serverDirView = new RemoteDeckList_TreeWidget(client); connect(serverDirView, &QTreeView::doubleClicked, this, &TabDeckStorage::actRemoteDoubleClick); + connect(serverDirView->selectionModel(), &QItemSelectionModel::selectionChanged, this, + [this] { onServerSelectionChanged(); }); + + // Share bar for creating a share link from the selected server decks/folders. + shareBar = new ShareBarWidget(this); + connect(shareBar, &ShareBarWidget::createRequested, this, &TabDeckStorage::actShareSelection); + connect(shareBar, &ShareBarWidget::cancelRequested, this, &TabDeckStorage::cancelShareDecks); + shareBar->setVisible(false); + + shareTimeoutTimer = new QTimer(this); + shareTimeoutTimer->setSingleShot(true); + shareTimeoutTimer->setInterval( + static_cast((static_cast(SettingsCache::instance().network().getTimeOut()) + 1) * + SettingsCache::instance().network().getKeepAlive() * 1000)); + connect(shareTimeoutTimer, &QTimer::timeout, this, &TabDeckStorage::onShareFromTreeTimeout); + + // Restartable single-shot refresh for the Public/Private column. It is + // armed with the full network timeout when a publish is sent (so a dropped + // reply still drains once) and re-armed with the short delay every time a + // reply lands, so the drain cannot fire while a slow round trip is still in + // flight. Either way the tree is re-read once things quiet down. + visibilityRefreshTimer = new QTimer(this); + visibilityRefreshTimer->setSingleShot(true); + visibilityRefreshTimer->setInterval(VISIBILITY_REFRESH_DELAY); + connect(visibilityRefreshTimer, &QTimer::timeout, this, &TabDeckStorage::onVisibilityRefreshTimeout); QVBoxLayout *rightVbox = new QVBoxLayout; + rightVbox->addWidget(shareBar); rightVbox->addWidget(serverDirView); rightVbox->addLayout(rightToolBarLayout); rightGroupBox = new QGroupBox; @@ -105,19 +188,19 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, // Left side actions aOpenLocalDeck = new QAction(this); - aOpenLocalDeck->setIcon(QPixmap("theme:icons/pencil")); + aOpenLocalDeck->setIcon(themePixmap(QStringLiteral("icons/pencil"))); connect(aOpenLocalDeck, &QAction::triggered, this, &TabDeckStorage::actOpenLocalDeck); aRenameLocal = new QAction(this); - aRenameLocal->setIcon(QPixmap("theme:icons/rename")); + aRenameLocal->setIcon(themePixmap(QStringLiteral("icons/rename"))); connect(aRenameLocal, &QAction::triggered, this, &TabDeckStorage::actRenameLocal); aUpload = new QAction(this); - aUpload->setIcon(QPixmap("theme:icons/arrow_right_green")); + aUpload->setIcon(themePixmap(QStringLiteral("icons/arrow_right_green"))); 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(QPixmap("theme:icons/remove_row")); + aDeleteLocalDeck->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); connect(aDeleteLocalDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteLocalDeck); aOpenDecksFolder = new QAction(this); @@ -126,18 +209,26 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, // Right side actions aOpenRemoteDeck = new QAction(this); - aOpenRemoteDeck->setIcon(QPixmap("theme:icons/pencil")); + aOpenRemoteDeck->setIcon(themePixmap(QStringLiteral("icons/pencil"))); connect(aOpenRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actOpenRemoteDeck); aDownload = new QAction(this); - aDownload->setIcon(QPixmap("theme:icons/arrow_left_green")); + aDownload->setIcon(themePixmap(QStringLiteral("icons/arrow_left_green"))); 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(QPixmap("theme:icons/remove_row")); + aDeleteRemoteDeck->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); connect(aDeleteRemoteDeck, &QAction::triggered, this, &TabDeckStorage::actDeleteRemoteDeck); + aShareDecks = new QAction(this); + aShareDecks->setIcon(themePixmap(QStringLiteral("icons/share"))); + connect(aShareDecks, &QAction::triggered, this, &TabDeckStorage::actShareDecks); + + aPublishDeck = new QAction(this); + aPublishDeck->setIcon(QPixmap("theme:icons/lock")); + connect(aPublishDeck, &QAction::triggered, this, &TabDeckStorage::actPublishDeck); + // Add actions to toolbars leftToolBar->addAction(aOpenLocalDeck); leftToolBar->addAction(aRenameLocal); @@ -149,6 +240,8 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, rightToolBar->addAction(aOpenRemoteDeck); rightToolBar->addAction(aDownload); + rightToolBar->addAction(aShareDecks); + rightToolBar->addAction(aPublishDeck); rightToolBar->addAction(aNewFolder); rightToolBar->addAction(aDeleteRemoteDeck); @@ -177,7 +270,13 @@ void TabDeckStorage::retranslateUi() aNewFolder->setText(tr("New folder")); aDeleteLocalDeck->setText(tr("Delete")); aDeleteRemoteDeck->setText(tr("Delete")); + aShareDecks->setText(tr("Share decks")); + aPublishDeck->setText(tr("Publish/unpublish deck")); aOpenDecksFolder->setText(tr("Open decks folder")); + shareBar->retranslateUi(); + if (shareBar->isVisible()) { + onServerSelectionChanged(); + } } QString TabDeckStorage::getTargetPath() const @@ -210,6 +309,8 @@ void TabDeckStorage::handleConnected(const ServerInfo_User &userInfo) void TabDeckStorage::handleConnectionChanged(ClientStatus status) { if (status == StatusDisconnected) { + visibilityRefreshTimer->stop(); + visibilityRefreshStarted = false; setRemoteEnabled(false); } } @@ -219,12 +320,15 @@ void TabDeckStorage::setRemoteEnabled(bool enabled) aUpload->setEnabled(enabled); aOpenRemoteDeck->setEnabled(enabled); aDownload->setEnabled(enabled); + aShareDecks->setEnabled(enabled); + aPublishDeck->setEnabled(enabled); aNewFolder->setEnabled(enabled); aDeleteRemoteDeck->setEnabled(enabled); if (enabled) { serverDirView->refreshTree(); } else { + setShareModeEnabled(false); serverDirView->clearTree(); } } @@ -245,6 +349,10 @@ void TabDeckStorage::actOpenLocalDeck() } QString filePath = localDirModel->filePath(curLeft); + if (!isSupportedDeckFile(filePath)) { + continue; + } + std::optional deckOpt = DeckLoader::loadFromFile(filePath, DeckFileFormat::Cockatrice, true); if (!deckOpt) { continue; @@ -309,6 +417,11 @@ void TabDeckStorage::actUpload() void TabDeckStorage::uploadDeck(const QString &filePath, const QString &targetPath) { + if (!isSupportedDeckFile(filePath)) { + QMessageBox::critical(this, tr("Error"), tr("Invalid deck file")); + return; + } + QFile deckFile(filePath); QFileInfo deckFileInfo(deckFile); @@ -346,6 +459,8 @@ void TabDeckStorage::uploadDeck(const QString &filePath, const QString &targetPa cmd.set_path(targetPath.toStdString()); cmd.set_deck_list(deckString.toStdString()); + cmd.set_color_identity(getDeckColorIdentity(deck, CardDatabaseManager::query()).toStdString()); + PendingCommand *pend = client->prepareSessionCommand(cmd); connect(pend, &PendingCommand::finished, this, &TabDeckStorage::uploadFinished); client->sendCommand(pend); @@ -625,3 +740,241 @@ void TabDeckStorage::deleteFolderFinished(const Response &response, const Comman serverDirView->removeNode(toDelete); } } + +void TabDeckStorage::actShareDecks() +{ + setShareModeEnabled(true); +} + +void TabDeckStorage::cancelShareDecks() +{ + setShareModeEnabled(false); +} + +void TabDeckStorage::setShareModeEnabled(bool enabled) +{ + shareBar->setVisible(enabled); + if (enabled) { + shareBar->setCreateEnabled(true); + shareBar->setName(tr("Shared decks")); + onServerSelectionChanged(); + shareBar->focusName(); + } else { + // Abandon any in-flight request: otherwise the timer keeps running and a late + // response reports the share as created after the user already backed out. + shareTimeoutTimer->stop(); + shareInFlightSeq = 0; + serverDirView->clearSelection(); + } +} + +void TabDeckStorage::onServerSelectionChanged() +{ + if (!shareBar->isVisible()) { + return; + } + const auto selection = serverDirView->getCurrentSelection(); + int folders = 0; + int files = 0; + for (const auto *node : selection) { + if (dynamic_cast(node)) { + ++folders; + } else { + ++files; + } + } + + QString hint; + if (folders > 1) { + hint = tr("Only one folder can be shared at a time."); + } else if (folders > 0 && files > 0) { + hint = tr("Share either a folder or decks, not both."); + } else if (folders == 0 && files == 0) { + hint = tr("Select folders or decks in the tree to share."); + } + shareBar->setHintText(hint, !hint.isEmpty()); + + QStringList parts; + if (folders > 0) { + parts << tr("%n folder(s)", "", folders); + } + if (files > 0) { + parts << tr("%n deck(s)", "", files); + } + shareBar->setCountText(parts.isEmpty() ? tr("No decks selected") + : tr("Selected: %1").arg(parts.join(QStringLiteral(", ")))); +} + +void TabDeckStorage::actShareSelection() +{ + const auto selection = serverDirView->getCurrentSelection(); + QString sharedFolder; + bool hasFile = false; + bool hasFolder = false; + for (const auto *node : selection) { + if (const auto *dirNode = dynamic_cast(node)) { + hasFolder = true; + if (!sharedFolder.isEmpty()) { + showShareNotice(tr("Only one folder can be shared at a time."), true); + return; + } + sharedFolder = dirNode->getPath(); + } else { + hasFile = true; + } + } + + if (hasFile && hasFolder) { + showShareNotice(tr("Share either a folder or decks, not both."), true); + return; + } + if (hasFolder && sharedFolder.isEmpty()) { + showShareNotice(tr("The root folder cannot be shared."), true); + return; + } + + Command_DeckShareCreate cmd; + cmd.set_name(shareBar->name().toStdString()); + if (cmd.name().empty()) { + cmd.set_name(tr("Shared decks").toStdString()); + } + + if (!sharedFolder.isEmpty()) { + cmd.set_folder_path(sharedFolder.toStdString()); + } else { + for (const auto *node : selection) { + if (const auto *fileNode = dynamic_cast(node)) { + DeckShareItem *item = cmd.add_items(); + item->set_deck_id(fileNode->getId()); + } + } + } + + if (cmd.items_size() == 0 && cmd.folder_path().empty()) { + showShareNotice(tr("Select decks to share."), true); + return; + } + + shareBar->setCreateEnabled(false); + const int seq = ++shareRequestSeq; + shareInFlightSeq = seq; + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, + [this, seq](const Response &response, const CommandContainer &commandContainer) { + if (shareInFlightSeq != seq) { + return; // the user cancelled or a newer request superseded this one + } + shareInFlightSeq = 0; + shareFromTreeFinished(response, commandContainer); + }); + client->sendCommand(pend); + shareTimeoutTimer->start(); +} + +void TabDeckStorage::shareFromTreeFinished(const Response &response, const CommandContainer & /*commandContainer*/) +{ + shareTimeoutTimer->stop(); + shareBar->setCreateEnabled(true); + if (response.response_code() != Response::RespOk) { + qWarning() << "failed to create deck share:" << response.response_code(); + showShareNotice(tr("Failed to create the share link (server response code %1).") + .arg(QString::number(static_cast(response.response_code()))), + true); + return; + } + const DeckShareUtils::ShareResponse share = DeckShareUtils::handleShareResponse(client, response); + + showShareNotice( + tr("Share link copied to the clipboard.\nExpires on %1.").arg(DeckShareUtils::formatShareExpiry(share.expiry))); + setShareModeEnabled(false); +} + +void TabDeckStorage::showShareNotice(const QString &message, bool warning) +{ + QMessageBox box(warning ? QMessageBox::Warning : QMessageBox::Information, tr("Share link"), message, + QMessageBox::Ok, this); + box.exec(); +} + +void TabDeckStorage::onShareFromTreeTimeout() +{ + if (shareInFlightSeq == 0) { + return; // share mode was left while the request was still outstanding + } + shareInFlightSeq = 0; + shareBar->setCreateEnabled(true); + showShareNotice(tr("The server did not respond in time. Try again."), true); +} + +void TabDeckStorage::actPublishDeck() +{ + visibilityFailures.clear(); + // Arm the drain with the full network timeout so a lost reply still costs + // one refresh instead of a dead column; each reply shrinks it to the short + // delay below, so a slow round trip is never drained before it lands. + const int visibilityFailSafeDelay = + static_cast((static_cast(SettingsCache::instance().network().getTimeOut()) + 1) * + SettingsCache::instance().network().getKeepAlive() * 1000); + + const auto selection = serverDirView->getCurrentSelection(); + for (const auto *node : selection) { + Command_DeckSetVisibility cmd; + if (const auto *fileNode = dynamic_cast(node)) { + cmd.set_deck_id(fileNode->getId()); + } else if (const auto *dirNode = dynamic_cast(node)) { + const QString path = dirNode->getPath(); + if (path.isEmpty()) { + continue; // the root folder cannot be published + } + cmd.set_folder_path(path.toStdString()); + } else { + continue; + } + // Toggle the node's own visibility bit (what the server persists); the + // effective visibility shown by the column may additionally be inherited + // from a parent folder. + cmd.set_is_public(!node->isPublic()); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabDeckStorage::setVisibilityFinished); + visibilityRefreshStarted = true; + visibilityRefreshTimer->setInterval(visibilityFailSafeDelay); + visibilityRefreshTimer->start(); + client->sendCommand(pend); + } +} + +void TabDeckStorage::setVisibilityFinished(const Response &r, const CommandContainer & /*commandContainer*/) +{ + if (r.response_code() == Response::RespOk) { + if (visibilityRefreshStarted) { + visibilityRefreshTimer->setInterval(VISIBILITY_REFRESH_DELAY); + visibilityRefreshTimer->start(); + } + return; + } + + // Collect batch failures and surface them once, when publishing quiets + // down, instead of stacking one modal dialog per rejected node. + const QString message = tr("Failed to change deck visibility on server (response code %1).") + .arg(QString::number(static_cast(r.response_code()))); + if (visibilityRefreshStarted) { + visibilityFailures.append(message); + visibilityRefreshTimer->setInterval(VISIBILITY_REFRESH_DELAY); + visibilityRefreshTimer->start(); + } else { + QMessageBox::critical(this, tr("Error"), message); + } +} + +void TabDeckStorage::onVisibilityRefreshTimeout() +{ + visibilityRefreshStarted = false; + if (!visibilityFailures.isEmpty()) { + QMessageBox::critical( + this, tr("Error"), + tr("Failed to change the visibility of %n selected deck(s).", "", visibilityFailures.size())); + visibilityFailures.clear(); + } + serverDirView->refreshTree(); +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h index a863e0625..35a4649c7 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.h @@ -11,19 +11,26 @@ #include "../interface/widgets/server/remote/remote_decklist_tree_widget.h" #include "tab.h" +#include +#include +#include +#include #include +#include struct LoadedDeck; class ServerInfo_User; -class AbstractClient; class QTreeView; class QFileSystemModel; class QToolBar; -class QTreeWidget; -class QTreeWidgetItem; class QGroupBox; +class QTimer; class CommandContainer; class Response; +class ShareBarWidget; +class QAction; +class QModelIndex; +class TabSupervisor; class TabDeckStorage : public Tab { @@ -35,14 +42,25 @@ private: QToolBar *leftToolBar, *rightToolBar; RemoteDeckList_TreeWidget *serverDirView; QGroupBox *leftGroupBox, *rightGroupBox; + ShareBarWidget *shareBar; + QTimer *shareTimeoutTimer; + int shareRequestSeq = 0; + int shareInFlightSeq = 0; QAction *aOpenLocalDeck, *aRenameLocal, *aUpload, *aNewLocalFolder, *aDeleteLocalDeck; QAction *aOpenDecksFolder; - QAction *aOpenRemoteDeck, *aDownload, *aNewFolder, *aDeleteRemoteDeck; + QAction *aOpenRemoteDeck, *aDownload, *aShareDecks, *aPublishDeck, *aNewFolder, *aDeleteRemoteDeck; + bool visibilityRefreshStarted = false; + QTimer *visibilityRefreshTimer; + QStringList visibilityFailures; QString getTargetPath() const; void setRemoteEnabled(bool enabled); + void showShareNotice(const QString &message, bool warning = false); + + void setShareModeEnabled(bool enabled); + void uploadDeck(const QString &filePath, const QString &targetPath); void deleteRemoteDeck(const RemoteDeckList_TreeModel::Node *node); @@ -75,6 +93,17 @@ private slots: void actNewFolder(); void newFolderFinished(const Response &response, const CommandContainer &commandContainer); + void actShareDecks(); + void actShareSelection(); + void cancelShareDecks(); + void onServerSelectionChanged(); + void shareFromTreeFinished(const Response &r, const CommandContainer &commandContainer); + void onShareFromTreeTimeout(); + + void actPublishDeck(); + void setVisibilityFinished(const Response &r, const CommandContainer &commandContainer); + void onVisibilityRefreshTimeout(); + void actDeleteRemoteDeck(); void deleteFolderFinished(const Response &response, const CommandContainer &commandContainer); void deleteDeckFinished(const Response &response, const CommandContainer &commandContainer); diff --git a/cockatrice/src/interface/widgets/tabs/tab_developer.cpp b/cockatrice/src/interface/widgets/tabs/tab_developer.cpp new file mode 100644 index 000000000..d641a1ea5 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_developer.cpp @@ -0,0 +1,275 @@ +/** + * @file tab_developer.cpp + * @ingroup ServerTabs + */ +//! \todo Document this file. + +#include "tab_developer.h" + +#include "libcockatrice/protocol/pb/response.pb.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class TabSupervisor; + +static constexpr int DEFAULT_AUTO_REFRESH_INTERVAL_SECS = 30; + +TabDeveloper::TabDeveloper(TabSupervisor *_tabSupervisor, AbstractClient *_client) + : Tab(_tabSupervisor), client(_client) +{ + statsTable = new QTableWidget(0, 2); + statsTable->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); + statsTable->setEditTriggers(QAbstractItemView::NoEditTriggers); + statsTable->setSelectionBehavior(QAbstractItemView::SelectRows); + statsTable->setSelectionMode(QAbstractItemView::SingleSelection); + statsTable->verticalHeader()->setVisible(false); + statsTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Interactive); + statsTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Interactive); + statsTable->horizontalHeader()->setStretchLastSection(true); + + commandTable = new QTableWidget(0, 4); + commandTable->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + commandTable->setEditTriggers(QAbstractItemView::NoEditTriggers); + commandTable->setSelectionBehavior(QAbstractItemView::SelectRows); + commandTable->setSelectionMode(QAbstractItemView::SingleSelection); + commandTable->verticalHeader()->setVisible(false); + commandTable->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Interactive); + commandTable->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Interactive); + commandTable->horizontalHeader()->setSectionResizeMode(2, QHeaderView::Interactive); + commandTable->horizontalHeader()->setSectionResizeMode(3, QHeaderView::Interactive); + + statusLabel = new QLabel; + + autoRefreshCheckBox = new QCheckBox; + autoRefreshCheckBox->setChecked(false); + + refreshIntervalSpinBox = new QSpinBox; + refreshIntervalSpinBox->setRange(5, 3600); + refreshIntervalSpinBox->setValue(DEFAULT_AUTO_REFRESH_INTERVAL_SECS); + refreshIntervalSpinBox->setEnabled(false); + + autoRefreshTimer = new QTimer(this); + connect(autoRefreshTimer, &QTimer::timeout, this, &TabDeveloper::refreshClicked); + connect(autoRefreshCheckBox, &QCheckBox::toggled, this, &TabDeveloper::autoRefreshToggled); + connect(refreshIntervalSpinBox, QOverload::of(&QSpinBox::valueChanged), this, + &TabDeveloper::refreshIntervalChanged); + + refreshButton = new QPushButton; + refreshButton->setAutoDefault(true); + connect(refreshButton, &QPushButton::clicked, this, &TabDeveloper::refreshClicked); + + auto *buttonLayout = new QHBoxLayout; + buttonLayout->addWidget(statusLabel, 1, Qt::AlignLeft); + buttonLayout->addWidget(autoRefreshCheckBox, 0, Qt::AlignRight); + buttonLayout->addWidget(refreshIntervalSpinBox, 0, Qt::AlignRight); + buttonLayout->addWidget(refreshButton, 0, Qt::AlignRight); + + auto *tableLayout = new QHBoxLayout; + tableLayout->addWidget(statsTable, 1); + tableLayout->addWidget(commandTable, 2); + + auto *mainLayout = new QVBoxLayout; + mainLayout->addLayout(tableLayout, 1); + mainLayout->addLayout(buttonLayout); + + auto *central = new QWidget; + central->setLayout(mainLayout); + setCentralWidget(central); + + retranslateUi(); +} + +void TabDeveloper::retranslateUi() +{ + autoRefreshCheckBox->setText(tr("Auto-refresh")); + autoRefreshCheckBox->setToolTip(tr("Automatically request fresh server statistics at a fixed interval.")); + refreshIntervalSpinBox->setSuffix(tr(" s")); + refreshIntervalSpinBox->setToolTip(tr("Seconds between automatic refreshes.")); + refreshButton->setText(tr("Refresh server stats")); + statsTable->setHorizontalHeaderLabels(QString(tr("Statistic;Value")).split(";")); + commandTable->setHorizontalHeaderLabels(QString(tr("Command;Count;Total ms;Avg ms")).split(";")); + if (statsTable->rowCount() == 0) { + statusLabel->clear(); + } +} + +QString TabDeveloper::formatBytes(quint64 bytes) +{ + const quint64 kib = 1024; + const quint64 mib = 1024 * kib; + const quint64 gib = 1024 * mib; + if (bytes >= gib) { + return tr("%1 GiB").arg(QString::number(bytes / static_cast(gib), 'f', 2)); + } + if (bytes >= mib) { + return tr("%1 MiB").arg(QString::number(bytes / static_cast(mib), 'f', 2)); + } + if (bytes >= kib) { + return tr("%1 KiB").arg(QString::number(bytes / static_cast(kib), 'f', 2)); + } + return tr("%1 bytes").arg(bytes); +} + +QString TabDeveloper::formatDurationMs(qint64 ms) +{ + if (ms >= 1000) { + return tr("%1 s").arg(QString::number(ms / 1000.0, 'f', 2)); + } + return tr("%1 ms").arg(ms); +} + +void TabDeveloper::appendStatRow(const QString &name, const QString &value) +{ + const int row = statsTable->rowCount(); + statsTable->insertRow(row); + statsTable->setItem(row, 0, new QTableWidgetItem(name)); + statsTable->setItem(row, 1, new QTableWidgetItem(value)); +} + +void TabDeveloper::appendSeparatorRow(const QString §ionTitle) +{ + const int row = statsTable->rowCount(); + statsTable->insertRow(row); + auto *labelItem = new QTableWidgetItem(sectionTitle); + auto font = labelItem->font(); + font.setBold(true); + labelItem->setFont(font); + labelItem->setFlags(labelItem->flags() & ~Qt::ItemIsSelectable); + statsTable->setItem(row, 0, labelItem); + statsTable->setItem(row, 1, new QTableWidgetItem(QString())); +} + +void TabDeveloper::refreshClicked() +{ + if (requestPending) { + return; + } + requestPending = true; + Command_GetServerStats cmd; + PendingCommand *pend = client->prepareDeveloperCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabDeveloper::serverStatsResponse); + client->sendCommand(pend); +} + +void TabDeveloper::autoRefreshToggled(bool checked) +{ + refreshIntervalSpinBox->setEnabled(checked); + if (checked) { + refreshIntervalChanged(); + refreshClicked(); + } else { + autoRefreshTimer->stop(); + } +} + +void TabDeveloper::refreshIntervalChanged() +{ + if (autoRefreshCheckBox->isChecked()) { + autoRefreshTimer->start(refreshIntervalSpinBox->value() * 1000); + } +} + +void TabDeveloper::serverStatsResponse(const Response &resp) +{ + requestPending = false; + if (resp.response_code() != Response::RespOk) { + statusLabel->setText(tr("No server statistics available yet.")); + return; + } + + const Response_GetServerStats &response = resp.GetExtension(Response_GetServerStats::ext); + + statsTable->setRowCount(0); + + // Overview section + appendStatRow(tr("Registered users online"), QString::number(response.users_count())); + appendStatRow(tr("Moderators online"), QString::number(response.mods_count())); + appendStatRow(tr("Games running"), QString::number(response.games_count())); + appendStatRow(tr("Traffic sent (last tick)"), formatBytes(response.tx_bytes())); + appendStatRow(tr("Traffic received (last tick)"), formatBytes(response.rx_bytes())); + + const qint64 uptime = static_cast(response.uptime_secs()); + const int days = static_cast(uptime / 86400); + const int hours = static_cast((uptime % 86400) / 3600); + const int minutes = static_cast((uptime % 3600) / 60); + appendStatRow(tr("Server uptime"), days > 0 ? tr("%1d %2h %3m").arg(days).arg(hours).arg(minutes) + : tr("%1h %2m").arg(hours).arg(minutes)); + + const QDateTime snapshotTime = QDateTime::fromSecsSinceEpoch(static_cast(response.timest())); + appendStatRow(tr("Snapshot taken"), snapshotTime.toLocalTime().toString("yyyy-MM-dd HH:mm")); + + // Live metrics section + appendSeparatorRow(tr("Live Metrics")); + appendStatRow(tr("Cards in live games"), QString::number(response.cards_in_games())); + appendStatRow(tr("Total commands processed"), QString::number(response.total_commands())); + + if (response.total_commands() > 0) { + const double avgMs = static_cast(response.total_command_time_ms()) / response.total_commands(); + appendStatRow(tr("Avg command time"), QString::number(avgMs, 'f', 2) + " ms"); + } + appendStatRow(tr("Active command types"), QString::number(response.active_command_types())); + + appendStatRow(tr("Event loop stalls"), QString::number(response.eventloop_stalls_total())); + appendStatRow(tr("Last stall overshoot"), formatDurationMs(response.eventloop_last_stall_ms())); + appendStatRow(tr("Worst stall overshoot"), formatDurationMs(response.eventloop_max_stall_ms())); + + if (response.game_start_count() > 0) { + appendStatRow(tr("Game starts"), QString::number(response.game_start_count())); + const double avgStartMs = static_cast(response.game_start_total_ms()) / response.game_start_count(); + appendStatRow(tr("Avg game start time"), QString::number(avgStartMs, 'f', 1) + " ms"); + } + + // Per-command breakdown table + QList sortedStats(response.command_stats().begin(), response.command_stats().end()); + std::sort(sortedStats.begin(), sortedStats.end(), + [](const auto &a, const auto &b) { return a.total_ms() > b.total_ms(); }); + + commandTable->setRowCount(0); + for (const auto &cs : sortedStats) { + const int row = commandTable->rowCount(); + commandTable->insertRow(row); + commandTable->setItem(row, 0, new QTableWidgetItem(QString::fromStdString(cs.command_name()))); + + auto *countItem = new QTableWidgetItem(QString::number(cs.count())); + countItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); + commandTable->setItem(row, 1, countItem); + + auto *totalItem = new QTableWidgetItem(QString::number(cs.total_ms())); + totalItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); + commandTable->setItem(row, 2, totalItem); + + const double avg = cs.count() > 0 ? static_cast(cs.total_ms()) / cs.count() : 0.0; + auto *avgItem = new QTableWidgetItem(QString::number(avg, 'f', 2)); + avgItem->setTextAlignment(Qt::AlignRight | Qt::AlignVCenter); + commandTable->setItem(row, 3, avgItem); + } + commandTable->resizeColumnsToContents(); + statsTable->resizeColumnsToContents(); + commandTable->resizeColumnsToContents(); + + statusLabel->setText(tr("Updated %1").arg(QDateTime::currentDateTime().toString("yyyy-MM-dd HH:mm"))); +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_developer.h b/cockatrice/src/interface/widgets/tabs/tab_developer.h new file mode 100644 index 000000000..1a4e5904f --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_developer.h @@ -0,0 +1,60 @@ +/** + * @file tab_developer.h + * @ingroup ServerTabs + */ +//! \todo Document this file. + +#ifndef TAB_DEVELOPER_H +#define TAB_DEVELOPER_H + +#include "tab.h" + +#include +#include +#include + +class AbstractClient; +class QCheckBox; +class QLabel; +class QPushButton; +class QSpinBox; +class QTableWidget; +class QTimer; +class Response; +class TabSupervisor; + +class TabDeveloper : public Tab +{ + Q_OBJECT +private: + AbstractClient *client; + QTableWidget *statsTable; + QTableWidget *commandTable; + QPushButton *refreshButton; + QLabel *statusLabel; + QCheckBox *autoRefreshCheckBox; + QSpinBox *refreshIntervalSpinBox; + QTimer *autoRefreshTimer; + bool requestPending = false; + + void appendStatRow(const QString &name, const QString &value); + void appendSeparatorRow(const QString §ionTitle); + static QString formatBytes(quint64 bytes); + static QString formatDurationMs(qint64 ms); + +private slots: + void refreshClicked(); + void serverStatsResponse(const Response &resp); + void autoRefreshToggled(bool checked); + void refreshIntervalChanged(); + +public: + explicit TabDeveloper(TabSupervisor *_tabSupervisor, AbstractClient *_client); + void retranslateUi() override; + [[nodiscard]] QString getTabText() const override + { + return tr("Developer"); + } +}; + +#endif diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index 01c355c12..a3c397104 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -2,11 +2,16 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" +#include "../../../game/abstract_game.h" +#include "../../../game/game_event_handler.h" +#include "../../../game/game_meta_info.h" +#include "../../../game/game_state.h" +#include "../../../game/player/player_info.h" +#include "../../../game/player/player_manager.h" +#include "../../../game_graphics/board/abstract_card_item.h" #include "../game/game.h" #include "../game/player/player_logic.h" #include "../game/replay.h" -#include "../game_graphics/board/arrow_item.h" -#include "../game_graphics/board/card_item.h" #include "../game_graphics/deckview/deck_view_container.h" #include "../game_graphics/deckview/tabbed_deck_view_container.h" #include "../game_graphics/game_scene.h" @@ -20,29 +25,53 @@ #include "../interface/card_picture_loader/card_picture_loader.h" #include "../interface/widgets/cards/card_info_frame_widget.h" #include "../interface/widgets/dialogs/dlg_create_game.h" +#include "../interface/widgets/dialogs/dlg_invite_to_game.h" +#include "../interface/widgets/server/game_link.h" #include "../interface/widgets/server/user/user_list_manager.h" +#include "../interface/widgets/utility/completer_utils.h" #include "../interface/widgets/utility/line_edit_completer.h" -#include "../interface/window_main.h" #include "../main.h" +#include "../menus/tearoff_menu.h" +#include "../replay/replay_widget.h" #include "../utility/visibility_change_listener.h" +#include "card/card_completer_proxy_model.h" +#include "card/card_search_model.h" +#include "card_database_display_model.h" +#include "card_database_model.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/deck_list/deck_list.h" +#include "libcockatrice/protocol/pb/serverinfo_game.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_playerproperties.pb.h" +#include "libcockatrice/utility/card_ref.h" #include "tab_supervisor.h" #include +#include +#include +#include #include #include #include +#include #include #include +#include #include #include +#include +#include +#include +#include #include +#include +#include #include +#include #include -#include +#include #include #include -#include -#include #include #include #include @@ -50,6 +79,18 @@ #include #include #include +#include +#include +#include + +class Event_GameJoined; +namespace google +{ +namespace protobuf +{ +class Message; +} // namespace protobuf +} // namespace google TabGame::TabGame(TabSupervisor *_tabSupervisor, GameReplay *_replay) : Tab(_tabSupervisor), sayLabel(nullptr), sayEdit(nullptr) @@ -450,13 +491,20 @@ void TabGame::linkCardToChat(const QString &cardName) sayEdit->setFocus(); } -void TabGame::resetChatAndPhase() +void TabGame::resetForRewind() { // reset chat log messageLog->clearChat(); // reset phase markers game->getGameState()->setCurrentPhase(-1); + + // reset view zones + static_cast(gameView->scene())->emptyActiveViews(); + + // reset spectator state so the replay can rebuild it from the start + game->getPlayerManager()->clearSpectators(); + playerListWidget->clearSpectators(); } void TabGame::emitUserEvent() @@ -490,6 +538,9 @@ void TabGame::retranslateUi() QString tabText = " | " + type + " #" + QString::number(game->getGameMetaInfo()->gameId()); updatePlayerListDockTitle(); + if (inviteButton) { + inviteButton->setText(tr("Invite")); + } cardInfoDock->setWindowTitle(tr("Card Info") + (cardInfoDock->isWindow() ? tabText : QString())); messageLayoutDock->setWindowTitle(tr("Messages") + (messageLayoutDock->isWindow() ? tabText : QString())); if (replayDock) { @@ -528,6 +579,12 @@ void TabGame::retranslateUi() if (aGameInfo) { aGameInfo->setText(tr("Game &information")); } + if (aCopyGameLink) { + aCopyGameLink->setText(tr("Cop&y game link")); + } + if (aInviteToGame) { + aInviteToGame->setText(tr("Invite to Game...")); + } if (aConcede) { if (game->getPlayerManager()->isMainPlayerConceded()) { aConcede->setText(tr("Un&concede")); @@ -695,6 +752,56 @@ void TabGame::actGameInfo() dlg.exec(); } +void TabGame::actCopyGameLink() +{ + const QString link = + makeGameJoinLink(tabSupervisor->getClient()->serverName(), tabSupervisor->getClient()->serverPort(), + game->getGameMetaInfo()->proto().room_id(), game->getGameMetaInfo()->gameId(), + QString::fromStdString(game->getGameMetaInfo()->proto().description())); + QApplication::clipboard()->setText(link); +} + +void TabGame::updateInviteButtonState() +{ + // The dock button stays conservative (pre-start, not full); the menu action + // additionally covers started/full games, which are legitimate spectate + // invites, so it only needs the server-linked + not-closed conditions. + const bool canInvite = !tabSupervisor->getIsLocalGame() && !game->getGameState()->isGameClosed() && + !game->getGameMetaInfo()->started() && + game->getPlayerManager()->getPlayerCount() < game->getGameMetaInfo()->maxPlayers(); + if (inviteButton) { + inviteButton->setVisible(canInvite); + } + if (aInviteToGame) { + aInviteToGame->setEnabled(!tabSupervisor->getIsLocalGame() && !game->getGameState()->isGameClosed()); + } +} + +void TabGame::actInviteToGame() +{ + if (!tabSupervisor || tabSupervisor->getIsLocalGame()) { + return; + } + + GameMetaInfo *metaInfo = game->getGameMetaInfo(); + const QString inviteUrl = makeGameJoinLink( + tabSupervisor->getClient()->serverName(), tabSupervisor->getClient()->serverPort(), metaInfo->proto().room_id(), + metaInfo->gameId(), QString::fromStdString(metaInfo->proto().description())); + + QStringList excludeUserNames; + excludeUserNames << tabSupervisor->getUserListManager()->getOwnUsername(); + for (auto player : game->getPlayerManager()->getPlayers()) { + excludeUserNames << player->getPlayerInfo()->getName(); + } + for (auto it = game->getPlayerManager()->getSpectators().cbegin(); + it != game->getPlayerManager()->getSpectators().cend(); ++it) { + excludeUserNames << QString::fromStdString(it.value().name()); + } + + DlgInviteToGame dlg(tabSupervisor, inviteUrl, metaInfo->proto().only_buddies(), excludeUserNames, this); + dlg.exec(); +} + void TabGame::actConcede() { PlayerLogic *player = game->getPlayerManager()->getActiveLocalPlayer(game->getGameState()->getActivePlayer()); @@ -742,7 +849,8 @@ bool TabGame::leaveGame() void TabGame::actSay() { - if (completer->popup()->isVisible()) { + if (sayEdit->hasVisibleCompleterPopup()) { + sayEdit->hideCompleterPopups(); return; } @@ -762,14 +870,14 @@ void TabGame::addPlayerToAutoCompleteList(QString playerName) { if (sayEdit && !autocompleteUserList.contains(playerName)) { autocompleteUserList << playerName; - sayEdit->setCompletionList(autocompleteUserList); + mentionModel->setStringList(autocompleteUserList); } } void TabGame::removePlayerFromAutoCompleteList(QString playerName) { if (sayEdit && autocompleteUserList.removeOne(playerName)) { - sayEdit->setCompletionList(autocompleteUserList); + mentionModel->setStringList(autocompleteUserList); } } @@ -832,8 +940,8 @@ void TabGame::actRotateViewCCW() void TabGame::actCompleterChanged() { - SettingsCache::instance().chat().getChatMentionCompleter() ? completer->setCompletionRole(2) - : completer->setCompletionRole(1); + SettingsCache::instance().chat().getChatMentionCompleter() ? mentionCompleter->setCompletionRole(2) + : mentionCompleter->setCompletionRole(1); } void TabGame::notifyPlayerJoin(QString playerName) @@ -1045,6 +1153,7 @@ void TabGame::stopGame() QMapIterator i(deckViewContainers); while (i.hasNext()) { i.next(); + i.value()->playerDeckView->advancePlaymatRotation(); i.value()->show(); } @@ -1185,6 +1294,11 @@ void TabGame::createMenuItems() connect(aRotateViewCCW, &QAction::triggered, this, &TabGame::actRotateViewCCW); aGameInfo = new QAction(this); connect(aGameInfo, &QAction::triggered, this, &TabGame::actGameInfo); + aCopyGameLink = new QAction(this); + aCopyGameLink->setEnabled(!tabSupervisor->getIsLocalGame() && !tabSupervisor->getClient()->serverName().isEmpty()); + connect(aCopyGameLink, &QAction::triggered, this, &TabGame::actCopyGameLink); + aInviteToGame = new QAction(this); + connect(aInviteToGame, &QAction::triggered, this, &TabGame::actInviteToGame); aConcede = new QAction(this); connect(aConcede, &QAction::triggered, this, &TabGame::actConcede); if (!game->getGameMetaInfo()->started()) { @@ -1223,6 +1337,8 @@ void TabGame::createMenuItems() gameMenu->addAction(aRotateViewCCW); gameMenu->addSeparator(); gameMenu->addAction(aGameInfo); + gameMenu->addAction(aCopyGameLink); + gameMenu->addAction(aInviteToGame); gameMenu->addAction(aConcede); gameMenu->addAction(aFocusChat); gameMenu->addAction(aLeaveGame); @@ -1231,6 +1347,9 @@ void TabGame::createMenuItems() aCardMenu = gameMenu->addMenu(new QMenu(this)); + // Sync the new action with the same state the dock button already shows. + updateInviteButtonState(); + addTabMenu(gameMenu); } @@ -1245,6 +1364,8 @@ void TabGame::createReplayMenuItems() aRotateViewCCW = nullptr; aResetLayout = nullptr; aGameInfo = nullptr; + aCopyGameLink = nullptr; + aInviteToGame = nullptr; aConcede = nullptr; aFocusChat = nullptr; aLeaveGame = new QAction(this); @@ -1387,7 +1508,7 @@ void TabGame::createReplayDock(GameReplay *replay) replayDock->setWidget(replayWidget); replayDock->setFloating(false); - connect(replayWidget, &ReplayWidget::rewound, this, &TabGame::resetChatAndPhase); + connect(replayWidget, &ReplayWidget::rewound, this, &TabGame::resetForRewind); connect(replayWidget, &ReplayWidget::eventReplayed, game->getGameEventHandler(), [this](const auto &event, auto options) { game->getGameEventHandler()->processGameEventContainer(event, nullptr, options); @@ -1443,11 +1564,29 @@ void TabGame::createPlayerListDock(bool bReplay) } playerListWidget->setFocusPolicy(Qt::NoFocus); + auto *playerListBox = new QWidget(this); + auto *vbox = new QVBoxLayout(playerListBox); + vbox->setContentsMargins(0, 0, 0, 0); + vbox->setSpacing(0); + + vbox->addWidget(playerListWidget); + + if (!bReplay) { + inviteButton = new QPushButton(tr("Invite"), playerListBox); + inviteButton->setVisible(false); + connect(inviteButton, &QPushButton::clicked, this, &TabGame::actInviteToGame); + vbox->addWidget(inviteButton); + + connect(game->getGameMetaInfo(), &GameMetaInfo::startedChanged, this, &TabGame::updateInviteButtonState); + connect(game->getPlayerManager(), &PlayerManager::playerCountChanged, this, &TabGame::updateInviteButtonState); + updateInviteButtonState(); + } + playerListDock = new QDockWidget(this); playerListDock->setObjectName("playerListDock"); playerListDock->setFeatures(QDockWidget::DockWidgetClosable | QDockWidget::DockWidgetFloatable | QDockWidget::DockWidgetMovable); - playerListDock->setWidget(playerListWidget); + playerListDock->setWidget(playerListBox); playerListDock->setFloating(false); } @@ -1471,6 +1610,7 @@ void TabGame::createMessageDock(bool bReplay) qOverload(&CardInfoFrameWidget::setCard)); connect(messageLog, &MessageLogWidget::showCardInfoPopup, this, &TabGame::showCardInfoPopup); connect(messageLog, &MessageLogWidget::deleteCardInfoPopup, this, &TabGame::deleteCardInfoPopup); + connect(messageLog, &MessageLogWidget::cockatriceLinkActivated, this, &TabGame::cockatriceLinkActivated); if (!bReplay) { connect(messageLog, &MessageLogWidget::openMessageDialog, this, &TabGame::openMessageDialog); @@ -1488,12 +1628,25 @@ void TabGame::createMessageDock(bool bReplay) sayEdit->setMaxLength(MAX_TEXT_LENGTH); sayLabel->setBuddy(sayEdit); connect(this, &TabGame::chatMessageSent, game->getGameEventHandler(), &GameEventHandler::handleChatMessageSent); - completer = new QCompleter(autocompleteUserList, sayEdit); - completer->setCaseSensitivity(Qt::CaseInsensitive); - completer->setMaxVisibleItems(5); - completer->setFilterMode(Qt::MatchStartsWith); + mentionModel = new QStringListModel(autocompleteUserList, sayEdit); + mentionCompleter = createMentionCompleter(mentionModel, sayEdit); + sayEdit->addCompleter(mentionCompleter, CompleterTrigger::Mention); + + auto *cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, sayEdit); + auto *displayModel = new CardDatabaseDisplayModel(sayEdit); + displayModel->setSourceModel(cardDatabaseModel); + const CardCompleterSetup cardSetup = createCardCompleter(displayModel, sayEdit); + sayEdit->addCompleter(cardSetup.completer, CompleterTrigger::Card); + + connect(sayEdit, &LineEditCompleter::cardPartialChanged, this, [this, cardSetup](const QString &text) { + cardSetup.searchModel->updateSearchResults(text); + cardSetup.proxyModel->setFilterRegularExpression( + QRegularExpression(QRegularExpression::escape(text), QRegularExpression::CaseInsensitiveOption)); + if (sayEdit->hasFocus()) { + cardSetup.completer->complete(); + } + }); - sayEdit->setCompleter(completer); actCompleterChanged(); if (game->getPlayerManager()->isSpectator()) { diff --git a/cockatrice/src/interface/widgets/tabs/tab_game.h b/cockatrice/src/interface/widgets/tabs/tab_game.h index e70802e82..25c72d8ae 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.h +++ b/cockatrice/src/interface/widgets/tabs/tab_game.h @@ -9,24 +9,43 @@ #ifndef TAB_GAME_H #define TAB_GAME_H -#include "../game/abstract_game.h" -#include "../game/player/player_logic.h" -#include "../game_graphics/log/message_log_widget.h" -#include "../interface/widgets/menus/tearoff_menu.h" -#include "../interface/widgets/replay/replay_widget.h" #include "tab.h" -#include +#include #include #include +#include +#include +#include +#include +#include +#include +// IWYU pragma: keep +// ServerInfo_User appears by value and inside QList in slot signatures, so the +// moc-generated code needs the complete type. +#include class CardMenu; -class ServerInfo_PlayerProperties; class TabbedDeckViewContainer; +class AbstractGame; +class Event_GameJoined; +class GameEventContext; +class MessageLogWidget; +class PlayerLogic; +class QAction; +class QCompleter; +class QStringListModel; +class QWidget; +class ServerInfo_Player; +class ServerInfo_User; +class TabSupervisor; +class TearOffMenu; +struct CardRef; +struct LoadedDeck; + inline Q_LOGGING_CATEGORY(TabGameLog, "tab_game"); class UserListProxy; -class DeckViewContainer; class AbstractClient; class TutorialController; class CardDatabase; @@ -34,21 +53,13 @@ class GameView; class GameScene; class ReplayWidget; class CardInfoFrameWidget; -class QTimer; -class QSplitter; class QLabel; -class QToolButton; +class QPushButton; class QMenu; -class ZoneViewLayout; -class ZoneViewWidget; class PhasesToolbar; class PlayerListWidget; -class ReplayTimelineWidget; -class CardZone; class AbstractCardItem; -class CardItem; class QVBoxLayout; -class QHBoxLayout; class GameReplay; class LineEditCompleter; class QDockWidget; @@ -65,12 +76,14 @@ private: const UserListProxy *userListProxy; ReplayWidget *replayWidget = nullptr; QStringList gameTypes; - QCompleter *completer; + QCompleter *mentionCompleter; + QStringListModel *mentionModel; QStringList autocompleteUserList; QStackedWidget *mainWidget; CardInfoFrameWidget *cardInfoFrameWidget; PlayerListWidget *playerListWidget; + QPushButton *inviteButton = nullptr; QLabel *timeElapsedLabel; MessageLogWidget *messageLog; QLabel *sayLabel; @@ -85,9 +98,10 @@ private: QAction *playersSeparator; QMenu *gameMenu, *viewMenu; TearOffMenu *phasesMenu; - QAction *aGameInfo, *aConcede, *aLeaveGame, *aNextPhase, *aNextPhaseAction, *aNextTurn, *aReverseTurn, - *aRemoveLocalArrows, *aRotateViewCW, *aRotateViewCCW, *aResetLayout, *aResetReplayLayout; + QAction *aGameInfo, *aConcede, *aCopyGameLink, *aLeaveGame, *aNextPhase, *aNextPhaseAction, *aNextTurn, + *aReverseTurn, *aRemoveLocalArrows, *aRotateViewCW, *aRotateViewCCW, *aResetLayout, *aResetReplayLayout; QAction *aFocusChat; + QAction *aInviteToGame = nullptr; QList phaseActions; QAction *aCardMenu; @@ -130,6 +144,7 @@ private: void createPlayAreaWidget(bool bReplay = false); void createDeckViewContainerWidget(bool bReplay = false); void createReplayDock(GameReplay *replay); + void updateInviteButtonState(); signals: void localPlayerReadyStateChanged(bool ready); void gameClosing(TabGame *tab); @@ -150,7 +165,9 @@ private slots: void setCardMenu(CardMenu *menu); void actGameInfo(); + void actInviteToGame(); void actConcede(); + void actCopyGameLink(); void actRemoveLocalArrows(); void actRotateViewCW(); void actRotateViewCCW(); @@ -202,7 +219,7 @@ public: public slots: void viewCardInfo(const CardRef &cardRef = {}) const; - void resetChatAndPhase(); + void resetForRewind(); void updateTimeElapsedLabel(QString newTime); void addPlayerToAutoCompleteList(QString playerName); void removePlayerFromAutoCompleteList(QString playerName); diff --git a/cockatrice/src/interface/widgets/tabs/tab_home.cpp b/cockatrice/src/interface/widgets/tabs/tab_home.cpp index 7a79b1785..1358645d0 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_home.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_home.cpp @@ -1,5 +1,9 @@ #include "tab_home.h" +#include "../general/home_widget.h" + +class TabSupervisor; + TabHome::TabHome(TabSupervisor *_tabSupervisor, AbstractClient *_client) : Tab(_tabSupervisor), client(_client) { homeWidget = new HomeWidget(this, tabSupervisor); diff --git a/cockatrice/src/interface/widgets/tabs/tab_home.h b/cockatrice/src/interface/widgets/tabs/tab_home.h index 3aae19c2b..bc86348cb 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_home.h +++ b/cockatrice/src/interface/widgets/tabs/tab_home.h @@ -7,12 +7,14 @@ #ifndef TAB_HOME_H #define TAB_HOME_H -#include "../interface/widgets/general/home_widget.h" #include "tab.h" -#include +#include +#include class AbstractClient; +class HomeWidget; +class TabSupervisor; class TabHome : public Tab { diff --git a/cockatrice/src/interface/widgets/tabs/tab_logs.cpp b/cockatrice/src/interface/widgets/tabs/tab_logs.cpp index e3678a903..9f17afea2 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_logs.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_logs.cpp @@ -1,25 +1,38 @@ #include "tab_logs.h" #include "../interface/widgets/utility/custom_line_edit.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_chat_message.pb.h" #include #include #include #include #include +#include #include #include #include +#include +#include #include #include #include +#include +#include +#include #include #include #include #include #include +#include +#include -TabLog::TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client) : Tab(_tabSupervisor), client(_client) +class TabSupervisor; + +TabLog::TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client, bool _canUseDeveloperCommands) + : Tab(_tabSupervisor), client(_client), canUseDeveloperCommands(_canUseDeveloperCommands) { roomTable = new QTableWidget(); roomTable->setColumnCount(6); @@ -80,7 +93,9 @@ void TabLog::getClicked() if (!mainRoom->isChecked() && !gameRoom->isChecked() && !privateChat->isChecked()) { mainRoom->setChecked(true); gameRoom->setChecked(true); - privateChat->setChecked(true); + if (!canUseDeveloperCommands) { + privateChat->setChecked(true); + } } if (maximumResults->value() == 0) { @@ -117,7 +132,15 @@ void TabLog::getClicked() }; cmd.set_date_range(dateRange); cmd.set_maximum_results(maximumResults->value()); - PendingCommand *pend = client->prepareModeratorCommand(cmd); + + PendingCommand *pend; + if (canUseDeveloperCommands) { + // Developers query logs through the developer command family. + pend = client->prepareDeveloperCommand(cmd); + } else { + pend = client->prepareModeratorCommand(cmd); + } + connect(pend, &PendingCommand::finished, this, &TabLog::viewLogHistory_processResponse); client->sendCommand(pend); } @@ -171,6 +194,14 @@ void TabLog::createDock() mainRoom = new QCheckBox(tr("Main Room")); gameRoom = new QCheckBox(tr("Game Room")); privateChat = new QCheckBox(tr("Private Chat")); + if (canUseDeveloperCommands) { + // Developers cannot query private conversations. + privateChat->setVisible(false); + // The developer family ignores the IP filter server-side, so showing + // the field would silently unfilter the result by it. Hide it. + labelFindIPAddress->setVisible(false); + findIPAddress->setVisible(false); + } pastDays = new QRadioButton(tr("Past X Days: ")); today = new QRadioButton(tr("Today")); diff --git a/cockatrice/src/interface/widgets/tabs/tab_logs.h b/cockatrice/src/interface/widgets/tabs/tab_logs.h index 5d164dc92..fc8ef1ea0 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_logs.h +++ b/cockatrice/src/interface/widgets/tabs/tab_logs.h @@ -9,7 +9,8 @@ #include "tab.h" -#include +#include +#include class AbstractClient; class LineEditUnfocusable; @@ -24,15 +25,15 @@ class QWidget; class QGridLayout; class QVBoxLayout; class QTableWidget; -class CommandContainer; class Response; -class AbstractClient; +class TabSupervisor; class TabLog : public Tab { Q_OBJECT private: AbstractClient *client; + bool canUseDeveloperCommands; QLabel *labelFindUserName, *labelFindIPAddress, *labelFindGameName, *labelFindGameID, *labelMessage, *labelMaximum, *labelDescription; LineEditUnfocusable *findUsername, *findIPAddress, *findGameName, *findGameID, *findMessage; @@ -58,7 +59,7 @@ private slots: void restartLayout(); public: - TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client); + TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client, bool _canUseDeveloperCommands = false); ~TabLog() override; void retranslateUi() override; [[nodiscard]] QString getTabText() const override diff --git a/cockatrice/src/interface/widgets/tabs/tab_message.cpp b/cockatrice/src/interface/widgets/tabs/tab_message.cpp index 9e9dbce1c..65186e396 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_message.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_message.cpp @@ -6,12 +6,18 @@ #include "../interface/widgets/server/user/user_list_manager.h" #include "../interface/widgets/utility/custom_line_edit.h" #include "../main.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "tab_supervisor.h" +#include #include -#include +#include #include +#include +#include #include #include +#include #include #include #include @@ -19,18 +25,21 @@ #include #include #include +#include TabMessage::TabMessage(TabSupervisor *_tabSupervisor, AbstractClient *_client, const ServerInfo_User &_ownUserInfo, - const ServerInfo_User &_otherUserInfo) + const ServerInfo_User &_otherUserInfo, + bool _userOnline) : Tab(_tabSupervisor), client(_client), ownUserInfo(new ServerInfo_User(_ownUserInfo)), - otherUserInfo(new ServerInfo_User(_otherUserInfo)), userOnline(true) + otherUserInfo(new ServerInfo_User(_otherUserInfo)), userOnline(_userOnline) { chatView = new ChatView(tabSupervisor, 0, true); connect(chatView, &ChatView::showCardInfoPopup, this, &TabMessage::showCardInfoPopup); connect(chatView, &ChatView::deleteCardInfoPopup, this, &TabMessage::deleteCardInfoPopup); connect(chatView, &ChatView::addMentionTag, this, &TabMessage::addMentionTag); + connect(chatView, &ChatView::cockatriceLinkActivated, this, &TabMessage::cockatriceLinkActivated); sayEdit = new LineEditUnfocusable; sayEdit->setMaxLength(MAX_TEXT_LENGTH); connect(sayEdit, &LineEditUnfocusable::returnPressed, this, &TabMessage::sendMessage); @@ -94,28 +103,64 @@ void TabMessage::closeEvent(QCloseEvent *event) event->accept(); } -void TabMessage::sendMessage() +void TabMessage::sendPrivateMessage(const QString &text) { - if (sayEdit->text().isEmpty() || !userOnline) { + if (tabSupervisor->getUserListManager()->isUserIgnored(getUserName())) { + chatView->appendMessage(tr("You have ignored %1; your messages are not delivered.") + .arg(QString::fromStdString(otherUserInfo->name()))); return; } Command_Message cmd; cmd.set_user_name(otherUserInfo->name()); - cmd.set_message(sayEdit->text().toStdString()); + cmd.set_message(text.toStdString()); PendingCommand *pend = client->prepareSessionCommand(cmd); + pend->setExtraData(text); connect(pend, &PendingCommand::finished, this, &TabMessage::messageSent); client->sendCommand(pend); +} +void TabMessage::sendMessage() +{ + if (sayEdit->text().isEmpty()) { + return; + } + + if (!userOnline) { + notifyUserOffline(); + return; + } + + sendPrivateMessage(sayEdit->text()); sayEdit->clear(); } -void TabMessage::messageSent(const Response &response) +bool TabMessage::isUserOnline() const +{ + return userOnline; +} + +void TabMessage::sendInviteMessage(const QString &text) +{ + sayEdit->setText(text); + sendMessage(); +} + +void TabMessage::messageSent(const Response &response, + const CommandContainer & /*commandContainer*/, + const QVariant &extraData) { if (response.response_code() == Response::RespInIgnoreList) { chatView->appendMessage(tr( "This user is ignoring you, they cannot see your messages in main chat and you cannot join their games.")); + } else if (response.response_code() == Response::RespNameNotFound) { + // The recipient went offline before the command reached the server: restore the draft. + userOnline = false; + if (sayEdit->text().isEmpty()) { + sayEdit->setText(extraData.toString()); + } + notifyUserOffline(); } } @@ -175,3 +220,8 @@ void TabMessage::processUserJoined(const ServerInfo_User &_userInfo) userOnline = true; *otherUserInfo = _userInfo; } + +void TabMessage::notifyUserOffline() +{ + chatView->appendMessage(tr("Message not sent — %1 is offline.").arg(QString::fromStdString(otherUserInfo->name()))); +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_message.h b/cockatrice/src/interface/widgets/tabs/tab_message.h index 0472bb061..bea58ecb3 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_message.h +++ b/cockatrice/src/interface/widgets/tabs/tab_message.h @@ -10,6 +10,13 @@ #include "tab.h" #include +#include +#include +#include + +class QAction; +class QMenu; +class TabSupervisor; inline Q_LOGGING_CATEGORY(TabMessageLog, "tab_message"); @@ -19,6 +26,7 @@ class LineEditUnfocusable; class Event_UserMessage; class Response; class ServerInfo_User; +class CommandContainer; class TabMessage : public Tab { @@ -39,7 +47,7 @@ signals: void maximizeClient(); private slots: void sendMessage(); - void messageSent(const Response &response); + void messageSent(const Response &response, const CommandContainer &commandContainer, const QVariant &extraData); void addMentionTag(QString mentionTag); void messageClicked(); @@ -50,7 +58,8 @@ public: TabMessage(TabSupervisor *_tabSupervisor, AbstractClient *_client, const ServerInfo_User &_ownUserInfo, - const ServerInfo_User &_otherUserInfo); + const ServerInfo_User &_otherUserInfo, + bool _userOnline); ~TabMessage() override; void retranslateUi() override; void tabActivated() override; @@ -62,9 +71,14 @@ public: void processUserLeft(); void processUserJoined(const ServerInfo_User &_userInfo); + [[nodiscard]] bool isUserOnline() const; + void sendPrivateMessage(const QString &text); + void sendInviteMessage(const QString &text); + private: bool shouldShowSystemPopup(const Event_UserMessage &event); void showSystemPopup(const Event_UserMessage &event); + void notifyUserOffline(); }; #endif diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp new file mode 100644 index 000000000..09a640540 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp @@ -0,0 +1,479 @@ +#include "tab_moderation.h" + +#include "abstract_client.h" +#include "libcockatrice/protocol/pb/admin_commands.pb.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_moderator_login.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_user_alt.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_user_session.pb.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class TabSupervisor; + +namespace +{ +constexpr int COL_ALTS_USER = 0; +constexpr int COL_ALTS_EMAIL = 1; +constexpr int COL_ALTS_CLIENTID = 2; +constexpr int COL_ALTS_REGISTERED = 3; +constexpr int COL_ALTS_LAST_LOGIN = 4; +constexpr int COL_ALTS_WARNS = 5; +constexpr int COL_ALTS_BANS = 6; +constexpr int COL_ALTS_ACTIVE = 7; +constexpr int COL_ALTS_COUNT = 8; + +constexpr int COL_SESSIONS_IP = 0; +constexpr int COL_SESSIONS_CLIENTID = 1; +constexpr int COL_SESSIONS_START = 2; +constexpr int COL_SESSIONS_END = 3; +constexpr int COL_SESSIONS_TYPE = 4; +constexpr int COL_SESSIONS_COUNT = 5; + +constexpr int COL_STAFF_USER = 0; +constexpr int COL_STAFF_LEVEL = 1; +constexpr int COL_STAFF_LAST_LOGIN = 2; +constexpr int COL_STAFF_COUNT = 3; +} // namespace + +TabModeration::TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser) + : Tab(_tabSupervisor), client(_client) +{ + auto *centralWidget = new QWidget(this); + setCentralWidget(centralWidget); + + searchEdit = new QLineEdit; + searchEdit->setClearButtonEnabled(true); + connect(searchEdit, &QLineEdit::returnPressed, this, &TabModeration::investigateUser); + + investigateButton = new QPushButton; + connect(investigateButton, &QPushButton::clicked, this, &TabModeration::investigateUser); + + resetPasswordButton = new QPushButton; + connect(resetPasswordButton, &QPushButton::clicked, this, &TabModeration::resetPassword); + + removeAvatarButton = new QPushButton; + connect(removeAvatarButton, &QPushButton::clicked, this, &TabModeration::removeAvatar); + + auto *topBar = new QHBoxLayout; + topBar->addWidget(searchEdit); + topBar->addWidget(investigateButton); + topBar->addStretch(); + topBar->addWidget(resetPasswordButton); + topBar->addWidget(removeAvatarButton); + + userInfoGroup = new QGroupBox; + userInfoNameLabel = new QLabel; + userInfoNameValue = new QLabel; + userInfoRegisteredLabel = new QLabel; + userInfoRegisteredValue = new QLabel; + userInfoLastLoginLabel = new QLabel; + userInfoLastLoginValue = new QLabel; + userInfoStatusLabel = new QLabel; + userInfoStatusValue = new QLabel; + userInfoCountsLabel = new QLabel; + userInfoCountsValue = new QLabel; + userInfoNotesLabel = new QLabel; + userInfoNotesEdit = new QTextEdit; + userInfoNotesEdit->setReadOnly(true); + + auto *infoGrid = new QGridLayout; + infoGrid->addWidget(userInfoNameLabel, 0, 0); + infoGrid->addWidget(userInfoNameValue, 0, 1); + infoGrid->addWidget(userInfoRegisteredLabel, 0, 2); + infoGrid->addWidget(userInfoRegisteredValue, 0, 3); + infoGrid->addWidget(userInfoLastLoginLabel, 1, 0); + infoGrid->addWidget(userInfoLastLoginValue, 1, 1); + infoGrid->addWidget(userInfoStatusLabel, 1, 2); + infoGrid->addWidget(userInfoStatusValue, 1, 3); + infoGrid->addWidget(userInfoCountsLabel, 2, 0); + infoGrid->addWidget(userInfoCountsValue, 2, 1, 1, 3); + infoGrid->addWidget(userInfoNotesLabel, 3, 0, Qt::AlignTop); + infoGrid->addWidget(userInfoNotesEdit, 3, 1, 1, 3); + infoGrid->setColumnStretch(1, 1); + infoGrid->setColumnStretch(3, 1); + + auto *infoLayout = new QVBoxLayout(userInfoGroup); + infoLayout->addLayout(infoGrid); + + auto configureTable = [](QTableWidget *table) { + table->setSelectionBehavior(QAbstractItemView::SelectRows); + table->setSelectionMode(QAbstractItemView::SingleSelection); + table->setEditTriggers(QAbstractItemView::NoEditTriggers); + table->verticalHeader()->setVisible(false); + table->setAlternatingRowColors(true); + table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); + table->horizontalHeader()->setStretchLastSection(true); + }; + + altsGroup = new QGroupBox; + altsTable = new QTableWidget(0, COL_ALTS_COUNT); + configureTable(altsTable); + auto *altsLayout = new QVBoxLayout(altsGroup); + altsLayout->addWidget(altsTable); + + sessionsGroup = new QGroupBox; + sessionsTable = new QTableWidget(0, COL_SESSIONS_COUNT); + configureTable(sessionsTable); + auto *sessionsLayout = new QVBoxLayout(sessionsGroup); + sessionsLayout->addWidget(sessionsTable); + + staffGroup = new QGroupBox; + staffTable = new QTableWidget(0, COL_STAFF_COUNT); + configureTable(staffTable); + refreshStaffButton = new QPushButton; + connect(refreshStaffButton, &QPushButton::clicked, this, &TabModeration::requestModeratorLogins); + auto *staffHeader = new QHBoxLayout; + staffHeader->addStretch(); + staffHeader->addWidget(refreshStaffButton); + auto *staffLayout = new QVBoxLayout(staffGroup); + staffLayout->addWidget(staffTable); + staffLayout->addLayout(staffHeader); + + auto *splitter = new QSplitter(Qt::Vertical); + splitter->addWidget(userInfoGroup); + splitter->addWidget(altsGroup); + splitter->addWidget(sessionsGroup); + splitter->addWidget(staffGroup); + splitter->setStretchFactor(0, 1); + splitter->setStretchFactor(1, 2); + splitter->setStretchFactor(2, 2); + splitter->setStretchFactor(3, 1); + + auto *mainLayout = new QVBoxLayout(centralWidget); + mainLayout->addLayout(topBar); + mainLayout->addWidget(splitter); + + retranslateUi(); + clearUserData(); + requestModeratorLogins(); + investigate(initialUser); +} + +void TabModeration::retranslateUi() +{ + searchEdit->setPlaceholderText(tr("User name")); + investigateButton->setText(tr("Investigate")); + resetPasswordButton->setText(tr("Reset Password")); + removeAvatarButton->setText(tr("Remove Avatar")); + refreshStaffButton->setText(tr("Refresh")); + + userInfoGroup->setTitle(tr("User Info")); + userInfoNameLabel->setText(tr("Name:")); + userInfoRegisteredLabel->setText(tr("Registered:")); + userInfoLastLoginLabel->setText(tr("Last login:")); + userInfoStatusLabel->setText(tr("Status:")); + userInfoCountsLabel->setText(tr("Counts:")); + userInfoNotesLabel->setText(tr("Admin notes:")); + + altsGroup->setTitle(tr("Alts")); + sessionsGroup->setTitle(tr("Sessions")); + staffGroup->setTitle(tr("Staff Last Logins")); + + altsTable->setHorizontalHeaderLabels({tr("User"), tr("eMail"), tr("Client ID"), tr("Registered"), tr("Last login"), + tr("Warns"), tr("Bans"), tr("Active")}); + sessionsTable->setHorizontalHeaderLabels({tr("IP"), tr("Client ID"), tr("Start"), tr("End"), tr("Type")}); + staffTable->setHorizontalHeaderLabels({tr("User"), tr("Level"), tr("Last login")}); +} + +QString TabModeration::formatEpoch(quint64 ts) const +{ + if (ts == 0) { + return tr("Unknown"); + } + return QDateTime::fromSecsSinceEpoch(ts).toLocalTime().toString("yyyy-MM-dd HH:mm"); +} + +void TabModeration::clearUserData() +{ + currentUser.clear(); + userInfoNameValue->clear(); + userInfoRegisteredValue->clear(); + userInfoLastLoginValue->clear(); + userInfoStatusValue->clear(); + userInfoCountsValue->clear(); + userInfoNotesEdit->clear(); + altsTable->setRowCount(0); + sessionsTable->setRowCount(0); + resetPasswordButton->setEnabled(false); + removeAvatarButton->setEnabled(false); +} + +void TabModeration::investigate(const QString &userName) +{ + if (userName.isEmpty()) { + return; + } + searchEdit->setText(userName); + investigateUser(); +} + +void TabModeration::investigateUser() +{ + const QString userName = searchEdit->text().simplified(); + if (userName.isEmpty()) { + return; + } + currentUser = userName; + resetPasswordButton->setEnabled(true); + removeAvatarButton->setEnabled(true); + altsTable->setRowCount(0); + sessionsTable->setRowCount(0); + requestUserInfo(userName); + requestSessions(userName); + requestAlts(userName); +} + +void TabModeration::requestUserInfo(const QString &userName) +{ + userInfoNameValue->setText(userName); + userInfoRegisteredValue->setText(tr("Loading...")); + userInfoLastLoginValue->setText(tr("Loading...")); + userInfoStatusValue->clear(); + userInfoCountsValue->clear(); + userInfoNotesEdit->clear(); + + Command_ReportUserInfo cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabModeration::userInfoResponse); + client->sendCommand(pend); +} + +void TabModeration::userInfoResponse(const Response &response) +{ + if (response.response_code() != Response::RespOk) { + userInfoRegisteredValue->clear(); + userInfoLastLoginValue->clear(); + userInfoStatusValue->setText(tr("Error loading user info.")); + return; + } + + const Response_ReportUserInfo &resp = response.GetExtension(Response_ReportUserInfo::ext); + if (resp.has_user_name() && QString::fromStdString(resp.user_name()) != currentUser) { + return; + } + + userInfoRegisteredValue->setText(formatEpoch(resp.registration_time())); + userInfoLastLoginValue->setText(formatEpoch(resp.last_login())); + + QStringList statusParts; + statusParts << (resp.is_active() ? tr("active") : tr("inactive")); + if (resp.has_is_admin() && resp.is_admin()) { + statusParts << tr("admin"); + } + userInfoStatusValue->setText(statusParts.join(", ")); + + userInfoCountsValue->setText(tr("Reports: %1 Bans: %2 Warnings: %3") + .arg(resp.total_reports()) + .arg(resp.total_bans()) + .arg(resp.total_warns())); + + if (resp.has_admin_notes() && !resp.admin_notes().empty()) { + userInfoNotesEdit->setPlainText(QString::fromStdString(resp.admin_notes())); + } else { + userInfoNotesEdit->setPlainText(tr("(none)")); + } +} + +void TabModeration::requestSessions(const QString &userName) +{ + Command_GetUserSessions cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabModeration::sessionsResponse); + client->sendCommand(pend); +} + +void TabModeration::sessionsResponse(const Response &response) +{ + sessionsTable->setRowCount(0); + if (response.response_code() != Response::RespOk) { + return; + } + + const Response_UserSessions &resp = response.GetExtension(Response_UserSessions::ext); + sessionsTable->setRowCount(resp.sessions_size()); + for (int i = 0; i < resp.sessions_size(); ++i) { + const ServerInfo_UserSession &session = resp.sessions(i); + sessionsTable->setItem(i, COL_SESSIONS_IP, new QTableWidgetItem(QString::fromStdString(session.ip_address()))); + sessionsTable->setItem(i, COL_SESSIONS_CLIENTID, + new QTableWidgetItem(QString::fromStdString(session.clientid()))); + sessionsTable->setItem(i, COL_SESSIONS_START, new QTableWidgetItem(formatEpoch(session.start_time()))); + sessionsTable->setItem( + i, COL_SESSIONS_END, + new QTableWidgetItem(session.end_time() == 0 ? tr("Active") : formatEpoch(session.end_time()))); + sessionsTable->setItem(i, COL_SESSIONS_TYPE, + new QTableWidgetItem(QString::fromStdString(session.connection_type()))); + } +} + +void TabModeration::requestAlts(const QString &userName) +{ + Command_GetUserAlts cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabModeration::altsResponse); + client->sendCommand(pend); +} + +void TabModeration::altsResponse(const Response &response) +{ + altsTable->setRowCount(0); + if (response.response_code() != Response::RespOk) { + return; + } + + const Response_UserAlts &resp = response.GetExtension(Response_UserAlts::ext); + altsTable->setRowCount(resp.alts_size()); + for (int i = 0; i < resp.alts_size(); ++i) { + const ServerInfo_UserAlt &alt = resp.alts(i); + altsTable->setItem(i, COL_ALTS_USER, new QTableWidgetItem(QString::fromStdString(alt.user_name()))); + altsTable->setItem(i, COL_ALTS_EMAIL, new QTableWidgetItem(QString::fromStdString(alt.email()))); + altsTable->setItem(i, COL_ALTS_CLIENTID, new QTableWidgetItem(QString::fromStdString(alt.clientid()))); + altsTable->setItem(i, COL_ALTS_REGISTERED, new QTableWidgetItem(formatEpoch(alt.registration_time()))); + altsTable->setItem(i, COL_ALTS_LAST_LOGIN, new QTableWidgetItem(formatEpoch(alt.last_login()))); + altsTable->setItem(i, COL_ALTS_WARNS, new QTableWidgetItem(QString::number(alt.warn_count()))); + altsTable->setItem(i, COL_ALTS_BANS, new QTableWidgetItem(QString::number(alt.ban_count()))); + altsTable->setItem(i, COL_ALTS_ACTIVE, new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no"))); + } +} + +void TabModeration::requestModeratorLogins() +{ + Command_GetModeratorLastLogins cmd; + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabModeration::moderatorLoginsResponse); + client->sendCommand(pend); +} + +void TabModeration::moderatorLoginsResponse(const Response &response) +{ + staffTable->setRowCount(0); + if (response.response_code() != Response::RespOk) { + return; + } + + const Response_ModeratorLastLogins &resp = response.GetExtension(Response_ModeratorLastLogins::ext); + staffTable->setRowCount(resp.logins_size()); + for (int i = 0; i < resp.logins_size(); ++i) { + const ServerInfo_ModeratorLogin &login = resp.logins(i); + staffTable->setItem(i, COL_STAFF_USER, new QTableWidgetItem(QString::fromStdString(login.user_name()))); + staffTable->setItem(i, COL_STAFF_LAST_LOGIN, new QTableWidgetItem(formatEpoch(login.last_login()))); + + QStringList levels; + if (login.user_level() & ServerInfo_User::IsAdmin) { + levels << tr("Admin"); + } + if (login.user_level() & ServerInfo_User::IsDeveloper) { + levels << tr("Developer"); + } + if (login.user_level() & ServerInfo_User::IsModerator) { + levels << tr("Moderator"); + } + if (login.user_level() & ServerInfo_User::IsJudge) { + levels << tr("Judge"); + } + staffTable->setItem(i, COL_STAFF_LEVEL, new QTableWidgetItem(levels.join(" / "))); + } +} + +void TabModeration::resetPassword() +{ + if (currentUser.isEmpty()) { + return; + } + + QMessageBox::StandardButton choice = + QMessageBox::warning(this, tr("Reset Password"), + tr("Reset the password of %1? A temporary password will be generated and shown to you. " + "The user must change it on their first login.") + .arg(currentUser), + QMessageBox::Ok | QMessageBox::Cancel, QMessageBox::Cancel); + if (choice != QMessageBox::Ok) { + return; + } + + Command_ResetUserPassword cmd; + cmd.set_user_name(currentUser.toStdString()); + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabModeration::resetPasswordResponse); + client->sendCommand(pend); +} + +void TabModeration::resetPasswordResponse(const Response &response) +{ + if (response.response_code() != Response::RespOk) { + QMessageBox::critical(this, tr("Error"), tr("Password reset failed.")); + return; + } + + const Response_ResetUserPassword &resp = response.GetExtension(Response_ResetUserPassword::ext); + QMessageBox::information(this, tr("Password reset"), + tr("Temporary password for %1:\n\n%2\n\nPass it to the user through a secure channel.") + .arg(QString::fromStdString(resp.user_name())) + .arg(QString::fromStdString(resp.temporary_password()))); +} + +void TabModeration::removeAvatar() +{ + if (currentUser.isEmpty()) { + return; + } + + QMessageBox::StandardButton choice = + QMessageBox::warning(this, tr("Remove Avatar"), + tr("Remove the avatar of %1? The user will have to upload a new one.").arg(currentUser), + QMessageBox::Ok | QMessageBox::Cancel, QMessageBox::Cancel); + if (choice != QMessageBox::Ok) { + return; + } + + Command_RemoveUserAvatar cmd; + cmd.set_user_name(currentUser.toStdString()); + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabModeration::removeAvatarResponse); + client->sendCommand(pend); +} + +void TabModeration::removeAvatarResponse(const Response &response) +{ + if (response.response_code() != Response::RespOk) { + QMessageBox::critical(this, tr("Error"), tr("Could not remove the avatar.")); + return; + } + + const Response_RemoveUserAvatar &resp = response.GetExtension(Response_RemoveUserAvatar::ext); + QMessageBox::information(this, tr("Avatar removed"), + tr("The avatar of %1 has been removed.").arg(QString::fromStdString(resp.user_name()))); +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_moderation.h b/cockatrice/src/interface/widgets/tabs/tab_moderation.h new file mode 100644 index 000000000..e352e90bb --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.h @@ -0,0 +1,87 @@ +#ifndef TAB_MODERATION_H +#define TAB_MODERATION_H + +#include "tab.h" + +#include +#include +#include + +class AbstractClient; +class QGroupBox; +class QLabel; +class QLineEdit; +class QPushButton; +class QTableWidget; +class QTextEdit; +class Response; +class TabSupervisor; + +/** + * Staff investigation tool. Lets moderators look up a user's account data, + * alternate accounts, login sessions, and staff login activity, and offers + * the password-reset and remove-avatar actions. + */ +class TabModeration : public Tab +{ + Q_OBJECT +public: + explicit TabModeration(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser = {}); + void retranslateUi() override; + [[nodiscard]] QString getTabText() const override + { + return tr("Moderation"); + } + void investigate(const QString &userName); + +private slots: + void investigateUser(); + void userInfoResponse(const Response &response); + void sessionsResponse(const Response &response); + void altsResponse(const Response &response); + void moderatorLoginsResponse(const Response &response); + void resetPassword(); + void resetPasswordResponse(const Response &response); + void removeAvatar(); + void removeAvatarResponse(const Response &response); + +private: + void requestUserInfo(const QString &userName); + void requestSessions(const QString &userName); + void requestAlts(const QString &userName); + void requestModeratorLogins(); + void clearUserData(); + [[nodiscard]] QString formatEpoch(quint64 ts) const; + + AbstractClient *client; + QString currentUser; + + QLineEdit *searchEdit; + QPushButton *investigateButton; + QPushButton *resetPasswordButton; + QPushButton *removeAvatarButton; + + QGroupBox *userInfoGroup; + QLabel *userInfoNameLabel; + QLabel *userInfoNameValue; + QLabel *userInfoRegisteredLabel; + QLabel *userInfoRegisteredValue; + QLabel *userInfoLastLoginLabel; + QLabel *userInfoLastLoginValue; + QLabel *userInfoStatusLabel; + QLabel *userInfoStatusValue; + QLabel *userInfoCountsLabel; + QLabel *userInfoCountsValue; + QLabel *userInfoNotesLabel; + QTextEdit *userInfoNotesEdit; + + QGroupBox *altsGroup; + QTableWidget *altsTable; + QGroupBox *sessionsGroup; + QTableWidget *sessionsTable; + QGroupBox *staffGroup; + QTableWidget *staffTable; + QPushButton *refreshStaffButton; +}; + +#endif // TAB_MODERATION_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp new file mode 100644 index 000000000..6bf3f6be7 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_public_decks.cpp @@ -0,0 +1,262 @@ +#include "tab_public_decks.h" + +#include "../../../client/settings/cache_settings.h" +#include "../../deck_loader/deck_loader.h" +#include "../../deck_loader/loaded_deck.h" +#include "../cards/card_size_widget.h" +#include "../general/layout_containers/flow_widget.h" +#include "../visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h" +#include "../visual_deck_storage/deck_preview/public_deck_preview_widget.h" +#include "../visual_deck_storage/remote_public_decks_model.h" +#include "../visual_deck_storage/visual_deck_storage_quick_settings_widget.h" +#include "../visual_deck_storage/visual_deck_storage_search_widget.h" +#include "../visual_deck_storage/visual_deck_storage_tag_filter_widget.h" +#include "tab_supervisor.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +TabPublicDecks::TabPublicDecks(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &_userName) + : Tab(_tabSupervisor), client(_client), userName(_userName) +{ + model = new RemotePublicDecksModel(client, this); + cardSize = SettingsCache::instance().cardsDisplay().getVisualDeckStorageCardSize(); + + titleLabel = new QLabel(tr("Public decks of %1").arg(userName.toHtmlEscaped()), this); + QFont titleFont = titleLabel->font(); + titleFont.setBold(true); + titleLabel->setFont(titleFont); + + auto *headerLayout = new QHBoxLayout; + headerLayout->addWidget(titleLabel); + headerLayout->addStretch(1); + + // Filter/toolbar row, matching the Visual Deck Storage: color identity filter + // first, the search bar stretching in the middle, and the quick settings + // cogwheel at the end. The card size slider lives inside the cogwheel popup. + emptyLabel = new QLabel(tr("This user has not published any decks."), this); + emptyLabel->setAlignment(Qt::AlignCenter); + emptyLabel->setVisible(false); + + statusLabel = new QLabel(this); + statusLabel->setAlignment(Qt::AlignCenter); + statusLabel->setVisible(false); + + flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); + flowWidget->setSpacing(8, 8); + + colorIdentityFilter = new DeckPreviewColorIdentityFilterWidget(this); + searchWidget = new VisualDeckStorageSearchWidget(this); + refreshButton = new QToolButton(this); + refreshButton->setIcon(QPixmap("theme:icons/reload")); + refreshButton->setFixedSize(32, 32); + quickSettingsWidget = new VisualDeckStorageQuickSettingsWidget(this); + quickSettingsWidget->setPublicDecksMode(true); + + auto *filterLayout = new QHBoxLayout; + filterLayout->addWidget(colorIdentityFilter); + filterLayout->addWidget(searchWidget, 1); + filterLayout->addWidget(refreshButton); + filterLayout->addWidget(quickSettingsWidget); + + tagFilterWidget = new VisualDeckStorageTagFilterWidget(this); + tagFilterWidget->setAllTagsProvider([this] { return model->allTags(); }); + updateTagsVisibility(quickSettingsWidget->getShowTagFilter()); + + auto *layout = new QVBoxLayout; + layout->addLayout(headerLayout); + layout->addLayout(filterLayout); + layout->addWidget(tagFilterWidget); + layout->addWidget(statusLabel); + layout->addWidget(emptyLabel); + layout->addWidget(flowWidget, 1); + + auto *mainWidget = new QWidget(this); + mainWidget->setLayout(layout); + setCentralWidget(mainWidget); + + connect(refreshButton, &QToolButton::clicked, this, [this] { model->refresh(userName); }); + connect(model, &QAbstractItemModel::modelReset, this, &TabPublicDecks::rebuildGrid); + connect(model, &RemotePublicDecksModel::loadingChanged, this, &TabPublicDecks::updateLoadingState); + connect(model, &RemotePublicDecksModel::loadFailed, this, [this](const QString &message) { + lastFailureMessage = message; + statusLabel->setText(message); + statusLabel->setVisible(true); + flowWidget->setVisible(false); + emptyLabel->setVisible(false); + }); + connect(searchWidget, &VisualDeckStorageSearchWidget::searchTextChanged, this, + [this](const QString &text) { model->setSearchText(text); }); + connect(colorIdentityFilter, &DeckPreviewColorIdentityFilterWidget::activeColorsChanged, this, + &TabPublicDecks::updateColorFilter); + connect(colorIdentityFilter, &DeckPreviewColorIdentityFilterWidget::filterModeChanged, this, + &TabPublicDecks::updateColorFilter); + connect(tagFilterWidget, &VisualDeckStorageTagFilterWidget::filterChanged, this, &TabPublicDecks::updateTagFilter); + connect(quickSettingsWidget, &VisualDeckStorageQuickSettingsWidget::cardSizeChanged, this, + &TabPublicDecks::updateCardSize); + connect(quickSettingsWidget, &VisualDeckStorageQuickSettingsWidget::showTagFilterChanged, this, + &TabPublicDecks::updateTagsVisibility); + quickSettingsWidget->getCardSizeWidget()->enableCtrlScrollResize(flowWidget); + + retranslateUi(); + model->refresh(userName); +} + +QString TabPublicDecks::getTabText() const +{ + return tr("Public decks of %1").arg(userName); +} + +void TabPublicDecks::retranslateUi() +{ + // The username is another user's data, so escape it for the AutoText QLabel. + titleLabel->setText(tr("Public decks of %1").arg(userName.toHtmlEscaped())); + // The same choice rebuildGrid makes, so a language change does not swap + // the "no match" variant for the "nothing published" one. + emptyLabel->setText(model->totalCount() > 0 ? tr("No decks match your filters.") + : tr("This user has not published any decks.")); + refreshButton->setToolTip(tr("Refresh")); + refreshButton->setAccessibleName(tr("Refresh")); + quickSettingsWidget->setToolTip(tr("Public Decks Settings")); + // Re-show whatever the status label is showing so a language change picks up + // the new language or, for a failure message, at least does not hide it. + if (model->isLoading()) { + updateLoadingState(true); + } else if (!lastFailureMessage.isEmpty()) { + statusLabel->setText(lastFailureMessage); + statusLabel->setVisible(true); + flowWidget->setVisible(false); + emptyLabel->setVisible(false); + } else { + updateLoadingState(false); + } + emit tabTextChanged(this, getTabText()); +} + +bool TabPublicDecks::closeRequest() +{ + emit closing(this); + return Tab::closeRequest(); +} + +void TabPublicDecks::rebuildGrid() +{ + flowWidget->clearLayout(); + + const int count = model->rowCount(); + if (count == 0) { + emptyLabel->setText(model->totalCount() > 0 ? tr("No decks match your filters.") + : tr("This user has not published any decks.")); + } + emptyLabel->setVisible(count == 0); + for (int i = 0; i < count; ++i) { + auto *tile = new PublicDeckPreviewWidget(flowWidget, model->entryAt(i)); + tile->setScaleFactor(cardSize); + connect(tile, &PublicDeckPreviewWidget::openDeckRequested, this, &TabPublicDecks::openDeck); + flowWidget->addWidget(tile); + } + + // The deck set changed, so the tag filter chips are re-gathered from it. + tagFilterWidget->refreshTags(); +} + +void TabPublicDecks::updateColorFilter() +{ + model->setColorFilter(colorIdentityFilter->getFilterMode(), colorIdentityFilter->getActiveColors()); +} + +void TabPublicDecks::updateTagFilter() +{ + const QStringList selectedTags = tagFilterWidget->selectedTags(); + const QStringList excludedTags = tagFilterWidget->excludedTags(); + model->setTagFilter(QSet(selectedTags.cbegin(), selectedTags.cend()), + QSet(excludedTags.cbegin(), excludedTags.cend())); + tagFilterWidget->refreshTags(); +} + +void TabPublicDecks::updateTagsVisibility(bool visible) +{ + tagFilterWidget->setVisible(visible); +} + +void TabPublicDecks::updateLoadingState(bool loading) +{ + if (loading) { + // A new attempt is under way, so the previously shown failure, if any, + // no longer describes the current state. + lastFailureMessage.clear(); + statusLabel->setText(tr("Loading public decks…")); + statusLabel->setVisible(true); + flowWidget->setVisible(false); + emptyLabel->setVisible(false); + } else { + statusLabel->setVisible(false); + flowWidget->setVisible(true); + } +} + +void TabPublicDecks::updateCardSize(int scale) +{ + cardSize = scale; + applyCardSize(scale); +} + +void TabPublicDecks::applyCardSize(int scale) +{ + const auto tiles = flowWidget->findChildren(); + for (PublicDeckPreviewWidget *tile : tiles) { + tile->setScaleFactor(scale); + } + flowWidget->setMinimumSizeToMaxSizeHint(); +} + +void TabPublicDecks::openDeck(int deckId) +{ + Command_DeckDownloadPublic cmd; + cmd.set_deck_id(deckId); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabPublicDecks::openDeckFinished); + client->sendCommand(pend); +} + +void TabPublicDecks::openDeckFinished(const Response &response, const CommandContainer & /*commandContainer*/) +{ + if (response.response_code() != Response::RespOk) { + QMessageBox::warning(this, tr("Open public deck"), + tr("Failed to open the public deck (server response code %1).") + .arg(QString::number(static_cast(response.response_code())))); + return; + } + + const Response_DeckDownload &resp = response.GetExtension(Response_DeckDownload::ext); + std::optional deckOpt = + DeckLoader::loadFromRemote(QString::fromStdString(resp.deck()), LoadedDeck::LoadInfo::NON_REMOTE_ID); + if (!deckOpt) { + QMessageBox::warning(this, tr("Open public deck"), tr("The public deck could not be parsed.")); + return; + } + + tabSupervisor->openDeckInNewTab(deckOpt.value()); +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_public_decks.h b/cockatrice/src/interface/widgets/tabs/tab_public_decks.h new file mode 100644 index 000000000..847479e78 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_public_decks.h @@ -0,0 +1,83 @@ +/** + * @file tab_public_decks.h + * @ingroup Tabs + */ + +#ifndef TAB_PUBLIC_DECKS_H +#define TAB_PUBLIC_DECKS_H + +#include "tab.h" + +#include +#include + +class AbstractClient; +class CommandContainer; +class DeckPreviewColorIdentityFilterWidget; +class FlowWidget; +class QLabel; +class QToolButton; +class RemotePublicDecksModel; +class Response; +class VisualDeckStorageQuickSettingsWidget; +class VisualDeckStorageSearchWidget; +class VisualDeckStorageTagFilterWidget; +class TabSupervisor; + +/** + * @brief A visual grid of the public decks published by another user. + * + * The grid is rendered from the preview metadata the server stores for the + * decks, so browsing costs no downloads; the deck list is fetched via + * Command_DeckDownloadPublic only when the user opens a deck. Multiple users + * can be browsed simultaneously; each gets its own tab. + */ +class TabPublicDecks final : public Tab +{ + Q_OBJECT + +public: + TabPublicDecks(TabSupervisor *tabSupervisor, AbstractClient *client, const QString &userName); + + [[nodiscard]] QString getTabText() const override; + void retranslateUi() override; + bool closeRequest() override; + + [[nodiscard]] QString getUserName() const + { + return userName; + } + +signals: + void closing(TabPublicDecks *tab); + +private slots: + void openDeck(int deckId); + void openDeckFinished(const Response &response, const CommandContainer &commandContainer); + void updateColorFilter(); + void updateTagFilter(); + void updateCardSize(int scale); + void updateTagsVisibility(bool visible); + void updateLoadingState(bool loading); + +private: + void rebuildGrid(); + void applyCardSize(int scale); + + AbstractClient *client; + QString userName; + RemotePublicDecksModel *model; + FlowWidget *flowWidget; + VisualDeckStorageSearchWidget *searchWidget; + DeckPreviewColorIdentityFilterWidget *colorIdentityFilter; + VisualDeckStorageTagFilterWidget *tagFilterWidget; + QToolButton *refreshButton; + VisualDeckStorageQuickSettingsWidget *quickSettingsWidget; + QLabel *titleLabel; + QLabel *statusLabel; + QLabel *emptyLabel; + QString lastFailureMessage; ///< Last load-failure text, re-shown on retranslate. + int cardSize = 100; +}; + +#endif // TAB_PUBLIC_DECKS_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp index 5618604df..52bc4cd8f 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp @@ -1,22 +1,53 @@ #include "tab_replays.h" #include "../../../client/settings/cache_settings.h" +#include "../../pixel_map_generator.h" #include "../interface/widgets/server/remote/remote_replay_list_tree_widget.h" -#include "tab_game.h" +#include "libcockatrice/protocol/pb/commands.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_replay.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_replay_match.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_user.pb.h" +#include "libcockatrice/protocol/pb/session_commands.pb.h" +#include +#include #include #include +#include #include +#include #include +#include +#include +#include #include +#include #include #include #include +#include #include +#include +#include +#include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include +#include +#include #include #include #include @@ -30,9 +61,20 @@ #include #include #include +#include +#include +#include + +class TabSupervisor; inline Q_LOGGING_CATEGORY(TabReplaysLog, "replays_tab"); +namespace +{ +// File name filters for the local replay files Cockatrice can load. +const QStringList REPLAY_FILE_NAME_FILTERS = {"*.cor"}; +} // namespace + TabReplays::TabReplays(TabSupervisor *_tabSupervisor, AbstractClient *_client, const ServerInfo_User *currentUserInfo) : Tab(_tabSupervisor), client(_client) { @@ -61,6 +103,8 @@ QGroupBox *TabReplays::createLeftLayout() { localDirModel = new QFileSystemModel(this); localDirModel->setRootPath(SettingsCache::instance().paths().getReplaysPath()); + localDirModel->setNameFilters(REPLAY_FILE_NAME_FILTERS); + localDirModel->setNameFilterDisables(false); localDirModel->sort(0, Qt::AscendingOrder); localDirView = new QTreeView; @@ -102,17 +146,17 @@ QGroupBox *TabReplays::createLeftLayout() // Left side actions aOpenLocalReplay = new QAction(this); - aOpenLocalReplay->setIcon(QPixmap("theme:icons/view")); + aOpenLocalReplay->setIcon(themePixmap(QStringLiteral("icons/view"))); connect(aOpenLocalReplay, &QAction::triggered, this, &TabReplays::actOpenLocalReplay); connect(localDirView, &QTreeView::doubleClicked, this, &TabReplays::actOpenLocalReplay); aRenameLocal = new QAction(this); - aRenameLocal->setIcon(QPixmap("theme:icons/rename")); + aRenameLocal->setIcon(themePixmap(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(QPixmap("theme:icons/remove_row")); + aDeleteLocalReplay->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); connect(aDeleteLocalReplay, &QAction::triggered, this, &TabReplays::actDeleteLocalReplay); aOpenReplaysFolder = new QAction(this); @@ -164,24 +208,24 @@ QGroupBox *TabReplays::createRightLayout() // Right side actions aOpenRemoteReplay = new QAction(this); - aOpenRemoteReplay->setIcon(QPixmap("theme:icons/view")); + aOpenRemoteReplay->setIcon(themePixmap(QStringLiteral("icons/view"))); connect(aOpenRemoteReplay, &QAction::triggered, this, &TabReplays::actOpenRemoteReplay); connect(serverDirView, &QTreeView::doubleClicked, this, &TabReplays::actOpenRemoteReplay); aDownload = new QAction(this); - aDownload->setIcon(QPixmap("theme:icons/arrow_left_green")); + aDownload->setIcon(themePixmap(QStringLiteral("icons/arrow_left_green"))); connect(aDownload, &QAction::triggered, this, &TabReplays::actDownload); aKeep = new QAction(this); - aKeep->setIcon(QPixmap("theme:icons/lock")); + aKeep->setIcon(themePixmap(QStringLiteral("icons/lock"))); connect(aKeep, &QAction::triggered, this, &TabReplays::actKeepRemoteReplay); aDeleteRemoteReplay = new QAction(this); - aDeleteRemoteReplay->setIcon(QPixmap("theme:icons/remove_row")); + aDeleteRemoteReplay->setIcon(themePixmap(QStringLiteral("icons/remove_row"))); connect(aDeleteRemoteReplay, &QAction::triggered, this, &TabReplays::actDeleteRemoteReplay); aGetReplayCode = new QAction(this); - aGetReplayCode->setIcon(QPixmap("theme:icons/share")); + aGetReplayCode->setIcon(themePixmap(QStringLiteral("icons/share"))); connect(aGetReplayCode, &QAction::triggered, this, &TabReplays::actGetReplayCode); aSubmitReplayCode = new QAction(this); - aSubmitReplayCode->setIcon(QPixmap("theme:icons/search")); + aSubmitReplayCode->setIcon(themePixmap(QStringLiteral("icons/search"))); connect(aSubmitReplayCode, &QAction::triggered, this, &TabReplays::actSubmitReplayCode); // Add actions to toolbars @@ -262,6 +306,10 @@ void TabReplays::actOpenLocalReplay() } QString filePath = localDirModel->filePath(curLeft); + if (!QDir::match(REPLAY_FILE_NAME_FILTERS, QFileInfo(filePath).fileName())) { + continue; + } + QFile f(filePath); if (!f.open(QIODevice::ReadOnly)) { continue; diff --git a/cockatrice/src/interface/widgets/tabs/tab_replays.h b/cockatrice/src/interface/widgets/tabs/tab_replays.h index aa4ec039c..e29e29bd7 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_replays.h +++ b/cockatrice/src/interface/widgets/tabs/tab_replays.h @@ -10,19 +10,26 @@ #include "tab.h" +#include +#include #include +// IWYU pragma: keep +// GameReplay is a protobuf message passed through the openReplay signal, +// so the moc-generated code needs the complete type. +#include +#include class ServerInfo_User; class Response; -class AbstractClient; class QTreeView; class QFileSystemModel; -class QToolBar; class QGroupBox; class RemoteReplayList_TreeWidget; -class GameReplay; class Event_ReplayAdded; class CommandContainer; +class QAction; +class QModelIndex; +class TabSupervisor; class TabReplays : public Tab { diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.cpp b/cockatrice/src/interface/widgets/tabs/tab_report.cpp new file mode 100644 index 000000000..5f158e9b1 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_report.cpp @@ -0,0 +1,937 @@ +#include "tab_report.h" + +#include "../utility/report_utils.h" +#include "abstract_client.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "tab_supervisor.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +constexpr int COL_ID = 0; +constexpr int COL_TIME = 1; +constexpr int COL_REPORTER = 2; +constexpr int COL_REPORTED = 3; +constexpr int COL_CATEGORY = 4; +constexpr int COL_GAMEID = 5; +constexpr int COL_STATUS = 6; +constexpr int COL_ASSIGNED = 7; +constexpr int COL_REPLAY = 8; +constexpr int COL_ROOM = 9; +constexpr int COL_COUNT = 10; +constexpr int REFRESH_INTERVAL_MS = 300000; +} // namespace + +TabReport::TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client) : Tab(_tabSupervisor), client(_client) +{ + auto *centralWidget = new QWidget(this); + setCentralWidget(centralWidget); + + searchEdit = new QLineEdit; + searchEdit->setClearButtonEnabled(true); + connect(searchEdit, &QLineEdit::textChanged, this, &TabReport::applyFilters); + + statusFilter = new QComboBox; + connect(statusFilter, &QComboBox::currentIndexChanged, this, &TabReport::applyFilters); + + unresolvedOnlyBox = new QCheckBox; + unresolvedOnlyBox->setChecked(true); +#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0) + connect(unresolvedOnlyBox, &QCheckBox::checkStateChanged, this, &TabReport::refreshList); +#else + connect(unresolvedOnlyBox, &QCheckBox::stateChanged, this, &TabReport::refreshList); +#endif + + refreshButton = new QPushButton; + connect(refreshButton, &QPushButton::clicked, this, &TabReport::refreshList); + + refreshTimer = new QTimer(this); + refreshTimer->setInterval(REFRESH_INTERVAL_MS); + connect(refreshTimer, &QTimer::timeout, this, [this]() { + if (tabSupervisor->currentWidget() == this) { + refreshList(); + } + }); + refreshTimer->start(); + + auto *topBar = new QHBoxLayout; + topBar->addWidget(searchEdit); + topBar->addWidget(statusFilter); + topBar->addWidget(unresolvedOnlyBox); + topBar->addStretch(); + topBar->addWidget(refreshButton); + + statsLabel = new QLabel; + + table = new QTableWidget(0, COL_COUNT); + table->setSelectionBehavior(QAbstractItemView::SelectRows); + table->setSelectionMode(QAbstractItemView::SingleSelection); + table->setEditTriggers(QAbstractItemView::NoEditTriggers); + table->setSortingEnabled(true); + table->verticalHeader()->setVisible(false); + table->setAlternatingRowColors(true); + table->horizontalHeader()->setSectionResizeMode(COL_TIME, QHeaderView::ResizeToContents); + table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch); + table->horizontalHeader()->setSectionResizeMode(COL_REPORTER, QHeaderView::ResizeToContents); + connect(table, &QTableWidget::itemSelectionChanged, this, &TabReport::onSelectionChanged); + + descGroup = new QGroupBox; + descriptionEdit = new QTextEdit; + descriptionEdit->setReadOnly(true); + auto *descLayout = new QVBoxLayout(descGroup); + descLayout->setContentsMargins(4, 4, 4, 4); + descLayout->addWidget(descriptionEdit); + + chatGroup = new QGroupBox; + chatLogEdit = new QTextEdit; + chatLogEdit->setReadOnly(true); + QFont monoFont("monospace"); + monoFont.setStyleHint(QFont::Monospace); + const int systemPointSize = QFontDatabase::systemFont(QFontDatabase::GeneralFont).pointSize(); + if (systemPointSize > 0) { + monoFont.setPointSize(systemPointSize); + } + chatLogEdit->setFont(monoFont); + auto *chatLayout = new QVBoxLayout(chatGroup); + chatLayout->setContentsMargins(4, 4, 4, 4); + chatLayout->addWidget(chatLogEdit); + + commentsGroup = new QGroupBox; + commentsEdit = new QTextEdit; + commentsEdit->setReadOnly(true); + auto *commentInputRow = new QHBoxLayout; + commentInput = new QLineEdit; + commentButton = new QPushButton; + commentButton->setEnabled(false); + connect(commentButton, &QPushButton::clicked, this, &TabReport::addComment); + connect(commentInput, &QLineEdit::returnPressed, this, &TabReport::addComment); + commentInputRow->addWidget(commentInput); + commentInputRow->addWidget(commentButton); + auto *commentsLayout = new QVBoxLayout(commentsGroup); + commentsLayout->setContentsMargins(4, 4, 4, 4); + commentsLayout->addWidget(commentsEdit); + commentsLayout->addLayout(commentInputRow); + + userContextGroup = new QGroupBox; + userContextName = new QLabel; + userContextAccountAge = new QLabel; + userContextReports = new QLabel; + userContextBans = new QLabel; + userContextWarns = new QLabel; + userContextNotes = new QTextEdit; + userContextNotes->setReadOnly(true); + userContextNotes->setMaximumHeight(80); + userContextRecentReports = new QTextEdit; + userContextRecentReports->setReadOnly(true); + userContextRecentReports->setMaximumHeight(120); + userContextUserLabel = new QLabel; + userContextAgeLabel = new QLabel; + userContextReportsLabel = new QLabel; + userContextBansLabel = new QLabel; + userContextWarnsLabel = new QLabel; + userContextNotesLabel = new QLabel; + userContextRecentReportsLabel = new QLabel; + auto *ucLayout = new QGridLayout(userContextGroup); + ucLayout->setContentsMargins(8, 8, 8, 8); + ucLayout->addWidget(userContextUserLabel, 0, 0); + ucLayout->addWidget(userContextName, 0, 1, 1, 3); + ucLayout->addWidget(userContextAgeLabel, 1, 0); + ucLayout->addWidget(userContextAccountAge, 1, 1); + ucLayout->addWidget(userContextReportsLabel, 1, 2); + ucLayout->addWidget(userContextReports, 1, 3); + ucLayout->addWidget(userContextBansLabel, 2, 0); + ucLayout->addWidget(userContextBans, 2, 1); + ucLayout->addWidget(userContextWarnsLabel, 2, 2); + ucLayout->addWidget(userContextWarns, 2, 3); + ucLayout->addWidget(userContextNotesLabel, 3, 0, Qt::AlignTop); + ucLayout->addWidget(userContextNotes, 3, 1, 1, 3); + ucLayout->addWidget(userContextRecentReportsLabel, 4, 0, 1, 4); + ucLayout->addWidget(userContextRecentReports, 5, 0, 1, 4); + userContextGroup->setVisible(false); + + statsGroup = new QGroupBox; + statsGroup->setCheckable(true); + statsTotalLabel = new QLabel; + statsTrendLabel = new QLabel; + statsCategoriesLabel = new QLabel; + statsDetailText = new QTextEdit; + statsDetailText->setReadOnly(true); + statsDetailText->setMaximumHeight(150); + auto *statsContent = new QWidget; + auto *sgLayout = new QVBoxLayout(statsContent); + sgLayout->setContentsMargins(8, 8, 8, 8); + sgLayout->addWidget(statsTotalLabel); + sgLayout->addWidget(statsTrendLabel); + sgLayout->addWidget(statsCategoriesLabel); + sgLayout->addWidget(statsDetailText); + auto *statsGroupLayout = new QVBoxLayout(statsGroup); + statsGroupLayout->setContentsMargins(0, 0, 0, 0); + statsGroupLayout->addWidget(statsContent); + statsGroup->setChecked(true); + connect(statsGroup, &QGroupBox::toggled, this, [this, statsContent](bool checked) { + statsContent->setVisible(checked); + if (checked) { + requestStats(); + } + }); + + detailSplitter = new QSplitter(Qt::Vertical); + detailSplitter->addWidget(descGroup); + detailSplitter->addWidget(chatGroup); + detailSplitter->addWidget(commentsGroup); + detailSplitter->setStretchFactor(0, 1); + detailSplitter->setStretchFactor(1, 1); + detailSplitter->setStretchFactor(2, 2); + + assignButton = new QPushButton; + resolveButton = new QPushButton; + resolveWithNoteButton = new QPushButton; + dismissButton = new QPushButton; + viewReplayButton = new QPushButton; + joinGameButton = new QPushButton; + connect(assignButton, &QPushButton::clicked, this, &TabReport::assignReport); + connect(resolveButton, &QPushButton::clicked, this, [this]() { resolveReport(false, false); }); + connect(resolveWithNoteButton, &QPushButton::clicked, this, [this]() { resolveReport(false, true); }); + connect(dismissButton, &QPushButton::clicked, this, [this]() { resolveReport(true, true); }); + connect(viewReplayButton, &QPushButton::clicked, this, &TabReport::viewReplay); + connect(joinGameButton, &QPushButton::clicked, this, &TabReport::joinGame); + + statusLabel = new QLabel; + + auto *actionBar = new QHBoxLayout; + actionBar->addWidget(assignButton); + actionBar->addWidget(resolveButton); + actionBar->addWidget(resolveWithNoteButton); + actionBar->addWidget(dismissButton); + actionBar->addSpacing(20); + actionBar->addWidget(viewReplayButton); + actionBar->addWidget(joinGameButton); + actionBar->addStretch(); + actionBar->addWidget(statusLabel); + + auto *layout = new QVBoxLayout(centralWidget); + layout->addLayout(topBar); + layout->addWidget(statsLabel); + layout->addWidget(table, 2); + layout->addWidget(detailSplitter, 1); + layout->addWidget(userContextGroup); + layout->addWidget(statsGroup); + layout->addLayout(actionBar); + + retranslateUi(); + + setActionsEnabled(false); + refreshList(); +} + +void TabReport::retranslateUi() +{ + searchEdit->setPlaceholderText(tr("Search by username, category...")); + statusFilter->clear(); + statusFilter->addItem(tr("All Statuses"), ""); + statusFilter->addItem(tr("Open"), "open"); + statusFilter->addItem(tr("Assigned"), "assigned"); + statusFilter->addItem(tr("Resolved"), "resolved"); + statusFilter->addItem(tr("Dismissed"), "dismissed"); + unresolvedOnlyBox->setText(tr("Unresolved only")); + refreshButton->setText(tr("Refresh")); + + table->setHorizontalHeaderLabels({tr("#"), tr("Time"), tr("Reporter"), tr("Reported User"), tr("Category"), + tr("Game ID"), tr("Status"), tr("Assigned To"), tr("Replay"), tr("Room")}); + + descGroup->setTitle(tr("Description")); + chatGroup->setTitle(tr("Chat Log Context")); + chatGroup->setToolTip( + tr("Chat log attached by the reporter. It is captured from their client and may be incomplete or edited.")); + commentsGroup->setTitle(tr("Comments / Thread")); + descriptionEdit->setPlaceholderText(tr("No description.")); + chatLogEdit->setPlaceholderText(tr("No chat log attached.")); + commentsEdit->setPlaceholderText(tr("No comments yet.")); + commentInput->setPlaceholderText(tr("Type a reply...")); + commentButton->setText(tr("Send")); + + userContextGroup->setTitle(tr("Reported User Context")); + userContextUserLabel->setText(tr("User:")); + userContextAgeLabel->setText(tr("Account Age:")); + userContextReportsLabel->setText(tr("Reports:")); + userContextBansLabel->setText(tr("Bans:")); + userContextWarnsLabel->setText(tr("Warns:")); + userContextNotesLabel->setText(tr("Admin Notes:")); + userContextRecentReportsLabel->setText(tr("Recent Reports Against User:")); + userContextAccountAge->setText(QString()); + userContextReports->setText(QString()); + userContextBans->setText(QString()); + userContextWarns->setText(QString()); + + statsGroup->setTitle(tr("Report Statistics")); + + assignButton->setText(tr("Assign to Me")); + resolveButton->setText(tr("Resolve")); + resolveWithNoteButton->setText(tr("Resolve with note...")); + dismissButton->setText(tr("Dismiss...")); + viewReplayButton->setText(tr("View Replay")); + joinGameButton->setText(tr("Join Game")); +} + +void TabReport::refreshList() +{ + selectedReportIdBeforeRefresh = selectedReportId(); + commentDraftBeforeRefresh = commentInput->text(); + previousSelectedReportValid = selectedReportInfo(previousSelectedReport); + + refreshButton->setEnabled(false); + statusLabel->setText(tr("Loading...")); + table->setRowCount(0); + allReports.clear(); + filteredReports.clear(); + commentButton->setEnabled(false); + setActionsEnabled(false); + + Command_ReportList cmd; + cmd.set_unresolved_only(unresolvedOnlyBox->isChecked()); + + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabReport::reportListResponse); + client->sendCommand(pend); +} + +void TabReport::reportListResponse(const Response &response) +{ + refreshButton->setEnabled(true); + + if (response.response_code() != Response::RespOk) { + statusLabel->setText(tr("Failed to load reports.")); + return; + } + + const Response_ReportList &resp = response.GetExtension(Response_ReportList::ext); + allReports.clear(); + for (int i = 0; i < resp.reports_size(); ++i) { + allReports.append(resp.reports(i)); + } + + applyFilters(); + updateStats(); + + if (selectedReportIdBeforeRefresh >= 0) { + bool found = false; + for (int row = 0; row < table->rowCount(); ++row) { + if (table->item(row, COL_ID) && + table->item(row, COL_ID)->data(Qt::UserRole).toInt() == selectedReportIdBeforeRefresh) { + found = true; + { + QSignalBlocker blocker(table); + table->setCurrentCell(row, 0); + } + break; + } + } + + if (found) { + bool dataChanged = false; + ServerInfo_Report newReport; + if (!selectedReportInfo(newReport) || !previousSelectedReportValid || + previousSelectedReport.description() != newReport.description() || + previousSelectedReport.status() != newReport.status() || + previousSelectedReport.resolution_note() != newReport.resolution_note() || + previousSelectedReport.assigned_mod_name() != newReport.assigned_mod_name()) { + dataChanged = true; + } + + if (dataChanged || detailsRequestedReportId != selectedReportIdBeforeRefresh) { + loadReportDetails(selectedReportIdBeforeRefresh); + } + updateActionStates(); + } else { + descriptionEdit->clear(); + chatLogEdit->clear(); + commentsEdit->clear(); + setActionsEnabled(false); + userContextGroup->setVisible(false); + } + } + + if (commentInput->text().isEmpty()) { + commentInput->setText(commentDraftBeforeRefresh); + } + + if (statsGroup->isChecked()) { + requestStats(); + } +} + +void TabReport::applyFilters() +{ + filteredReports.clear(); + + const QString searchText = searchEdit->text().toLower(); + const QString statusFilterValue = statusFilter->currentData().toString(); + + for (const ServerInfo_Report &r : allReports) { + bool matchesSearch = searchText.isEmpty() || + QString::fromStdString(r.reporter_name()).toLower().contains(searchText) || + QString::fromStdString(r.reported_user_name()).toLower().contains(searchText) || + QString::fromStdString(r.category()).toLower().contains(searchText) || + QString::fromStdString(r.description()).toLower().contains(searchText); + + bool matchesStatus = statusFilterValue.isEmpty() || QString::fromStdString(r.status()) == statusFilterValue; + + if (matchesSearch && matchesStatus) { + filteredReports.append(r); + } + } + + table->setSortingEnabled(false); + table->setRowCount(filteredReports.size()); + + for (int row = 0; row < filteredReports.size(); ++row) { + const ServerInfo_Report &r = filteredReports[row]; + + report_utils::fillReportTableRow(table, row, r, COL_ID, COL_TIME, COL_REPORTED, COL_CATEGORY, COL_GAMEID, + COL_STATUS, COL_ASSIGNED); + + table->setItem(row, COL_REPORTER, new QTableWidgetItem(QString::fromStdString(r.reporter_name()))); + + table->setItem(row, COL_REPLAY, + new QTableWidgetItem(r.has_replay_id() && r.replay_id() > 0 ? tr("Yes") : tr("No"))); + + table->setItem(row, COL_ROOM, + new QTableWidgetItem(r.has_room_id() && r.room_id() > 0 ? QString::number(r.room_id()) : "")); + } + + table->setSortingEnabled(true); + table->resizeColumnsToContents(); + table->horizontalHeader()->setSectionResizeMode(COL_REPORTED, QHeaderView::Stretch); + + statusLabel->setText(tr("%1 report(s)").arg(filteredReports.size())); +} + +void TabReport::updateStats() +{ + int open = 0, assigned = 0, resolved = 0, dismissed = 0; + for (const ServerInfo_Report &r : allReports) { + QString status = QString::fromStdString(r.status()); + if (status == "open") { + open++; + } else if (status == "assigned") { + assigned++; + } else if (status == "resolved") { + resolved++; + } else if (status == "dismissed") { + dismissed++; + } + } + + statsLabel->setText(tr("%1 open | %2 assigned | %3 resolved | %4 dismissed") + .arg(open) + .arg(assigned) + .arg(resolved) + .arg(dismissed)); +} + +void TabReport::onSelectionChanged() +{ + const int row = table->currentRow(); + if (row < 0 || !table->item(row, COL_ID)) { + descriptionEdit->clear(); + chatLogEdit->clear(); + commentsEdit->clear(); + commentInput->clear(); + commentButton->setEnabled(false); + setActionsEnabled(false); + userContextGroup->setVisible(false); + return; + } + + const int reportId = table->item(row, COL_ID)->data(Qt::UserRole).toInt(); + loadReportDetails(reportId); + updateActionStates(); +} + +void TabReport::loadReportDetails(int reportId) +{ + detailsRequestedReportId = reportId; + + for (const ServerInfo_Report &r : filteredReports) { + if (r.report_id() == reportId) { + descriptionEdit->setPlainText(QString::fromStdString(r.description())); + + QString reportedUser = QString::fromStdString(r.reported_user_name()); + if (!reportedUser.isEmpty()) { + requestUserInfo(reportedUser); + } else { + userContextGroup->setVisible(false); + } + + QString status = QString::fromStdString(r.status()); + bool canComment = (status == "open" || status == "assigned"); + commentButton->setEnabled(canComment); + commentInput->setEnabled(canComment); + if (!canComment) { + commentInput->setPlaceholderText(tr("This report is closed.")); + } else { + commentInput->setPlaceholderText(tr("Type a reply...")); + } + break; + } + } + + chatLogEdit->setPlainText(tr("Loading...")); + commentsEdit->setPlainText(tr("Loading...")); + + Command_ReportDetails cmd; + cmd.set_report_id(reportId); + + const int seq = ++detailsRequestSeq; + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, [this, seq](const Response &response) { + if (seq != detailsRequestSeq) { + return; + } + reportDetailsResponse(response); + }); + client->sendCommand(pend); +} + +void TabReport::reportDetailsResponse(const Response &response) +{ + if (response.response_code() != Response::RespOk) { + if (selectedReportId() == detailsRequestedReportId) { + chatLogEdit->clear(); + commentsEdit->setPlainText(tr("Failed to load report details.")); + } + return; + } + + const Response_ReportDetails &resp = response.GetExtension(Response_ReportDetails::ext); + const ServerInfo_Report &r = resp.report(); + + if (selectedReportId() != r.report_id()) { + return; + } + + report_utils::renderReportDetails(chatLogEdit, commentsEdit, r, tr("No comments yet."), tr("[Moderator]"), + tr("[Reporter]")); +} + +void TabReport::updateActionStates() +{ + const int row = table->currentRow(); + if (row < 0 || !table->item(row, COL_STATUS)) { + setActionsEnabled(false); + return; + } + + ServerInfo_Report report; + if (!selectedReportInfo(report)) { + setActionsEnabled(false); + return; + } + + const QString status = QString::fromStdString(report.status()); + assignButton->setEnabled(status == "open"); + resolveButton->setEnabled(status == "open" || status == "assigned"); + resolveWithNoteButton->setEnabled(status == "open" || status == "assigned"); + dismissButton->setEnabled(status == "open" || status == "assigned"); + + const bool hasGameId = report.game_id() > 0; + const bool hasReplay = hasGameId && report.has_replay_id() && report.replay_id() > 0; + viewReplayButton->setEnabled(hasReplay); + joinGameButton->setEnabled(hasGameId && report.has_room_id() && report.room_id() > 0); +} + +void TabReport::setActionsEnabled(bool enabled) +{ + assignButton->setEnabled(enabled); + resolveButton->setEnabled(enabled); + resolveWithNoteButton->setEnabled(enabled); + dismissButton->setEnabled(enabled); + viewReplayButton->setEnabled(false); + joinGameButton->setEnabled(false); + commentButton->setEnabled(enabled); + commentInput->setEnabled(enabled); +} + +int TabReport::selectedReportId() const +{ + const int row = table->currentRow(); + if (row < 0 || !table->item(row, COL_ID)) { + return -1; + } + return table->item(row, COL_ID)->data(Qt::UserRole).toInt(); +} + +bool TabReport::selectedReportInfo(ServerInfo_Report &info) const +{ + const int reportId = selectedReportId(); + if (reportId < 0) { + return false; + } + + for (const ServerInfo_Report &r : filteredReports) { + if (r.report_id() == reportId) { + info = r; + return true; + } + } + + return false; +} + +void TabReport::assignReport() +{ + const int reportId = selectedReportId(); + if (reportId < 0) { + return; + } + + setActionsEnabled(false); + statusLabel->setText(tr("Assigning...")); + + Command_ReportAssign cmd; + cmd.set_report_id(reportId); + + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabReport::assignResponse); + client->sendCommand(pend); +} + +void TabReport::assignResponse(const Response &response) +{ + if (response.response_code() == Response::RespOk) { + statusLabel->setText(tr("Assigned.")); + refreshList(); + } else { + statusLabel->setText(tr("Assignment failed.")); + updateActionStates(); + } +} + +void TabReport::resolveReport(bool dismissed, bool promptNote) +{ + const int reportId = selectedReportId(); + if (reportId < 0) { + return; + } + + QString note; + if (promptNote) { + bool ok; + note = QInputDialog::getText(this, dismissed ? tr("Dismiss Report") : tr("Resolve Report"), + dismissed ? tr("Optional note:") : tr("Resolution note (optional):"), + QLineEdit::Normal, QString(), &ok); + if (!ok) { + return; + } + } + + setActionsEnabled(false); + statusLabel->setText(dismissed ? tr("Dismissing...") : tr("Resolving...")); + + Command_ReportResolve cmd; + cmd.set_report_id(reportId); + cmd.set_dismissed(dismissed); + if (!note.isEmpty()) { + cmd.set_resolution_note(note.toStdString()); + } + + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabReport::resolveResponse); + client->sendCommand(pend); +} + +void TabReport::resolveResponse(const Response &response) +{ + if (response.response_code() == Response::RespOk) { + statusLabel->setText(tr("Done.")); + refreshList(); + } else { + statusLabel->setText(tr("Action failed.")); + updateActionStates(); + } +} + +void TabReport::viewReplay() +{ + ServerInfo_Report report; + if (!selectedReportInfo(report) || report.game_id() <= 0) { + return; + } + + setActionsEnabled(false); + statusLabel->setText(tr("Loading replay...")); + + Command_ReplayDownloadByGameId cmd; + cmd.set_game_id(report.game_id()); + + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabReport::viewReplayResponse); + client->sendCommand(pend); +} + +void TabReport::viewReplayResponse(const Response &response) +{ + if (response.response_code() != Response::RespOk) { + statusLabel->setText(tr("No replay available for this game.")); + updateActionStates(); + return; + } + + const Response_ReplayDownloadByGameId &resp = response.GetExtension(Response_ReplayDownloadByGameId::ext); + GameReplay *replay = new GameReplay; + if (replay->ParseFromString(resp.replay_data())) { + emit openReplay(replay); + statusLabel->setText(tr("Replay opened.")); + } else { + delete replay; + statusLabel->setText(tr("Failed to parse replay.")); + } + + updateActionStates(); +} + +void TabReport::joinGame() +{ + ServerInfo_Report report; + if (!selectedReportInfo(report) || report.game_id() <= 0) { + return; + } + + const int roomId = report.has_room_id() ? report.room_id() : -1; + if (roomId <= 0) { + statusLabel->setText(tr("No room recorded for this report, use the replay instead.")); + return; + } + + emit requestJoinGame(report.game_id(), roomId); + statusLabel->setText(tr("Attempting to join game...")); +} + +void TabReport::addComment() +{ + const int reportId = selectedReportId(); + if (reportId < 0) { + return; + } + + QString text = commentInput->text().trimmed(); + if (text.isEmpty()) { + return; + } + + commentButton->setEnabled(false); + + Command_ReportAddComment cmd; + cmd.set_report_id(reportId); + cmd.set_comment(text.toStdString()); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabReport::addCommentResponse); + client->sendCommand(pend); +} + +void TabReport::addCommentResponse(const Response &response) +{ + if (response.response_code() == Response::RespOk) { + commentInput->clear(); + refreshList(); + } else { + commentButton->setEnabled(true); + statusLabel->setText(tr("Failed to send comment.")); + } +} + +void TabReport::requestUserInfo(const QString &userName) +{ + if (userName.isEmpty()) { + userContextGroup->setVisible(false); + return; + } + + lastRequestedUser = userName; + userContextGroup->setVisible(true); + userContextName->setText(userName); + userContextAccountAge->setText(tr("Loading...")); + userContextReports->clear(); + userContextBans->clear(); + userContextWarns->clear(); + userContextNotes->clear(); + userContextRecentReports->clear(); + + Command_ReportUserInfo cmd; + cmd.set_user_name(userName.toStdString()); + + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabReport::userInfoResponse); + client->sendCommand(pend); +} + +void TabReport::userInfoResponse(const Response &response) +{ + if (response.response_code() != Response::RespOk) { + userContextAccountAge->setText(tr("Error loading user info.")); + return; + } + + const Response_ReportUserInfo &resp = response.GetExtension(Response_ReportUserInfo::ext); + + if (resp.has_user_name() && QString::fromStdString(resp.user_name()) != lastRequestedUser) { + return; + } + + QDateTime regTime = QDateTime::fromSecsSinceEpoch(resp.registration_time()); + qint64 daysSinceReg = regTime.daysTo(QDateTime::currentDateTime()); + userContextAccountAge->setText(tr("%1 days (since %2)").arg(daysSinceReg).arg(regTime.toString("yyyy-MM-dd"))); + + userContextReports->setText(QString::number(resp.total_reports())); + userContextBans->setText(QString::number(resp.total_bans())); + userContextWarns->setText(QString::number(resp.total_warns())); + + if (resp.has_admin_notes() && !resp.admin_notes().empty()) { + userContextNotes->setPlainText(QString::fromStdString(resp.admin_notes())); + } else { + userContextNotes->setPlainText(tr("(none)")); + } + + userContextRecentReports->clear(); + if (resp.recent_reports_size() == 0) { + userContextRecentReports->setPlainText(tr("No previous reports against this user.")); + } else { + for (int i = 0; i < resp.recent_reports_size(); ++i) { + const ServerInfo_Report &r = resp.recent_reports(i); + QDateTime dt = QDateTime::fromSecsSinceEpoch(r.report_time()); + userContextRecentReports->moveCursor(QTextCursor::End); + userContextRecentReports->insertPlainText(QString("[%1] #%2 by %3 [%4]: %5\n") + .arg(dt.toString("yyyy-MM-dd")) + .arg(r.report_id()) + .arg(QString::fromStdString(r.reporter_name())) + .arg(QString::fromStdString(r.status())) + .arg(QString::fromStdString(r.category()))); + } + } +} + +void TabReport::requestStats() +{ + statsTotalLabel->setText(tr("Loading statistics...")); + statsTrendLabel->clear(); + statsCategoriesLabel->clear(); + statsDetailText->clear(); + + Command_ReportStats cmd; + + PendingCommand *pend = client->prepareModeratorCommand(cmd); + connect(pend, &PendingCommand::finished, this, &TabReport::statsResponse); + client->sendCommand(pend); +} + +void TabReport::statsResponse(const Response &response) +{ + if (response.response_code() != Response::RespOk) { + statsTotalLabel->setText(tr("Error loading statistics.")); + return; + } + + const Response_ReportStats &resp = response.GetExtension(Response_ReportStats::ext); + + statsTotalLabel->setText(tr("Total: %1 reports (%2 open, %3 assigned, %4 resolved/dismissed)") + .arg(resp.total_reports()) + .arg(resp.total_pending()) + .arg(resp.total_assigned()) + .arg(resp.total_resolved())); + + statsTrendLabel->setText(tr("Last 24h: %1 | Last 7d: %2 | Last 30d: %3 | Avg resolution: %4h") + .arg(resp.reports_last_24h()) + .arg(resp.reports_last_7d()) + .arg(resp.reports_last_30d()) + .arg(resp.avg_resolution_hours(), 0, 'f', 1)); + + QString weekCompare = + tr("This week: %1 vs last week: %2 (%3%)") + .arg(resp.reports_this_week()) + .arg(resp.reports_last_week()) + .arg(resp.reports_last_week() > 0 ? QString::number(((resp.reports_this_week() - resp.reports_last_week()) * + 100.0 / resp.reports_last_week()), + 'f', 0) + : resp.reports_this_week() > 0 ? "new" + : "0"); + statsTrendLabel->setText(statsTrendLabel->text() + " | " + weekCompare); + + statsCategoriesLabel->setText(tr("By category:")); + statsDetailText->clear(); + + statsDetailText->moveCursor(QTextCursor::End); + statsDetailText->insertPlainText(tr("=== Top Categories ===") + "\n"); + for (int i = 0; i < resp.category_counts_size(); ++i) { + const ReportCategoryCount &cc = resp.category_counts(i); + QString cat = QString::fromStdString(cc.category()); + cat.replace('_', ' '); + cat = cat.left(1).toUpper() + cat.mid(1); + statsDetailText->moveCursor(QTextCursor::End); + statsDetailText->insertPlainText(QString(" %1: %2\n").arg(cat).arg(cc.count())); + } + + statsDetailText->moveCursor(QTextCursor::End); + statsDetailText->insertPlainText("\n" + tr("=== Most Reported Users ===") + "\n"); + for (int i = 0; i < resp.top_reported_users_size(); ++i) { + const ReportTopUser &tu = resp.top_reported_users(i); + statsDetailText->moveCursor(QTextCursor::End); + statsDetailText->insertPlainText( + QString(" %1: %2 reports\n").arg(QString::fromStdString(tu.user_name())).arg(tu.count())); + } + + statsDetailText->moveCursor(QTextCursor::End); + statsDetailText->insertPlainText("\n" + tr("=== Top Reporters ===") + "\n"); + for (int i = 0; i < resp.top_reporters_size(); ++i) { + const ReportTopUser &tu = resp.top_reporters(i); + statsDetailText->moveCursor(QTextCursor::End); + statsDetailText->insertPlainText( + QString(" %1: %2 reports filed\n").arg(QString::fromStdString(tu.user_name())).arg(tu.count())); + } +} diff --git a/cockatrice/src/interface/widgets/tabs/tab_report.h b/cockatrice/src/interface/widgets/tabs/tab_report.h new file mode 100644 index 000000000..645f949de --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_report.h @@ -0,0 +1,127 @@ +#ifndef TAB_REPORT_H +#define TAB_REPORT_H + +#include "tab.h" + +#include +#include +#include +#include + +class AbstractClient; +class QCheckBox; +class QComboBox; +class QGroupBox; +class QLabel; +class QLineEdit; +class QPushButton; +class QSplitter; +class QTableWidget; +class QTextEdit; +class QTimer; +class GameReplay; +class Response; +class TabSupervisor; + +class TabReport : public Tab +{ + Q_OBJECT +public: + TabReport(TabSupervisor *_tabSupervisor, AbstractClient *_client); + void retranslateUi() override; + [[nodiscard]] QString getTabText() const override + { + return tr("Report Queue"); + } + +signals: + void openReplay(GameReplay *replay); + void requestJoinGame(int gameId, int roomId); + +private slots: + void refreshList(); + void reportListResponse(const Response &response); + void assignReport(); + void assignResponse(const Response &response); + void resolveReport(bool dismissed, bool promptNote); + void resolveResponse(const Response &response); + void onSelectionChanged(); + void viewReplay(); + void viewReplayResponse(const Response &response); + void joinGame(); + void addComment(); + void addCommentResponse(const Response &response); + void requestUserInfo(const QString &userName); + void userInfoResponse(const Response &response); + void reportDetailsResponse(const Response &response); + void requestStats(); + void statsResponse(const Response &response); + +private: + int selectedReportId() const; + bool selectedReportInfo(ServerInfo_Report &info) const; + void setActionsEnabled(bool enabled); + void updateActionStates(); + void applyFilters(); + void updateStats(); + void loadReportDetails(int reportId); + + AbstractClient *client; + + QLineEdit *searchEdit; + QComboBox *statusFilter; + QCheckBox *unresolvedOnlyBox; + QPushButton *refreshButton; + QLabel *statsLabel; + QTableWidget *table; + QSplitter *detailSplitter; + QGroupBox *descGroup; + QGroupBox *chatGroup; + QGroupBox *commentsGroup; + QTextEdit *descriptionEdit; + QTextEdit *chatLogEdit; + QTextEdit *commentsEdit; + QLineEdit *commentInput; + QPushButton *commentButton; + QPushButton *assignButton; + QPushButton *resolveButton; + QPushButton *resolveWithNoteButton; + QPushButton *dismissButton; + QPushButton *viewReplayButton; + QPushButton *joinGameButton; + QLabel *statusLabel; + QTimer *refreshTimer; + + QGroupBox *userContextGroup; + QLabel *userContextName; + QLabel *userContextAccountAge; + QLabel *userContextReports; + QLabel *userContextBans; + QLabel *userContextWarns; + QTextEdit *userContextNotes; + QTextEdit *userContextRecentReports; + QLabel *userContextUserLabel; + QLabel *userContextAgeLabel; + QLabel *userContextReportsLabel; + QLabel *userContextBansLabel; + QLabel *userContextWarnsLabel; + QLabel *userContextNotesLabel; + QLabel *userContextRecentReportsLabel; + QString lastRequestedUser; + QGroupBox *statsGroup; + QLabel *statsTotalLabel; + QLabel *statsTrendLabel; + QLabel *statsCategoriesLabel; + QTextEdit *statsDetailText; + + QList allReports; + QList filteredReports; + int detailsRequestedReportId = -1; + int detailsRequestSeq = 0; + int selectedReportIdBeforeRefresh = -1; + QString commentDraftBeforeRefresh; + ServerInfo_Report previousSelectedReport; + bool previousSelectedReportValid = false; +}; + +#endif // TAB_REPORT_H diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.cpp b/cockatrice/src/interface/widgets/tabs/tab_room.cpp index 5705c184e..67106ba34 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_room.cpp @@ -2,26 +2,50 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" +#include "../../pixel_map_generator.h" #include "../interface/widgets/dialogs/dlg_settings.h" #include "../interface/widgets/server/chat_view/chat_view.h" +#include "../interface/widgets/server/game_link.h" #include "../interface/widgets/server/game_selector.h" #include "../interface/widgets/server/user/user_list_manager.h" +#include "../interface/widgets/server/user/user_list_panel_widget.h" #include "../interface/widgets/server/user/user_list_widget.h" #include "../main.h" -#include "tab_account.h" +#include "../server/game_type_map.h" +#include "../server/user/user_list_proxy.h" +#include "../utility/completer_utils.h" +#include "../utility/line_edit_completer.h" +#include "card/card_completer_proxy_model.h" +#include "card/card_search_model.h" +#include "card_database_display_model.h" +#include "card_database_model.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/room_event.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_gametype.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_user.pb.h" #include "tab_supervisor.h" +#include "user_level.h" +#include #include +#include #include +#include +#include +#include +#include #include #include -#include -#include +#include #include +#include +#include #include #include #include -#include +#include +#include +#include #include #include #include @@ -34,6 +58,8 @@ #include #include #include +#include +#include TabRoom::TabRoom(TabSupervisor *_tabSupervisor, AbstractClient *_client, @@ -52,28 +78,19 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, tempMap.insert(info.room_id(), gameTypes); gameSelector = new GameSelector(client, tabSupervisor, this, QMap(), tempMap, true, true); - auto *tabs = new QTabWidget(this); + userListPanel = new UserListPanelWidget(tabSupervisor, client, this); + userListPanel->bind(tabSupervisor->getUserListManager()); + userList = userListPanel->getUserList(); + connect(userListPanel, &UserListPanelWidget::openMessageDialog, this, &TabRoom::openMessageDialog); - friendsList = new UserListWidget(tabSupervisor, client, UserListWidget::BuddyList); - friendsList->bind(tabSupervisor->getUserListManager()); - userList = new UserListWidget(tabSupervisor, client, UserListWidget::RoomList); - userList->bind(tabSupervisor->getUserListManager()); - ignoreList = new UserListWidget(tabSupervisor, client, UserListWidget::IgnoreList); - ignoreList->bind(tabSupervisor->getUserListManager()); - - connect(friendsList, SIGNAL(openMessageDialog(const QString &, bool)), this, - SIGNAL(openMessageDialog(const QString &, bool))); - connect(userList, SIGNAL(openMessageDialog(const QString &, bool)), this, - SIGNAL(openMessageDialog(const QString &, bool))); - - tabs->addTab(friendsList, tr("Friends")); - tabs->addTab(userList, tr("Online")); - tabs->addTab(ignoreList, tr("Ignored")); + const auto gameInviteLinkProvider = [this]() { return tabSupervisor->getGameInviteLinksForRoom(roomId); }; + userList->setGameInviteLinkProvider(gameInviteLinkProvider); chatView = new ChatView(tabSupervisor, nullptr, true, this); connect(chatView, &ChatView::showMentionPopup, this, &TabRoom::actShowMentionPopup); connect(chatView, &ChatView::messageClickedSignal, this, &TabRoom::focusTab); connect(chatView, &ChatView::openMessageDialog, this, &TabRoom::openMessageDialog); + connect(chatView, &ChatView::cockatriceLinkActivated, this, &TabRoom::cockatriceLinkActivated); connect(chatView, &ChatView::showCardInfoPopup, this, &TabRoom::showCardInfoPopup); connect(chatView, &ChatView::deleteCardInfoPopup, this, &TabRoom::deleteCardInfoPopup); connect(chatView, &ChatView::addMentionTag, this, &TabRoom::addMentionTag); @@ -96,7 +113,7 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, connect(aOpenChatSettings, &QAction::triggered, this, &TabRoom::actOpenChatSettings); auto *chatSettingsButton = new QToolButton; - chatSettingsButton->setIcon(QPixmap("theme:icons/settings")); + chatSettingsButton->setIcon(themePixmap(QStringLiteral("icons/settings"))); chatSettingsButton->setMenu(chatSettingsMenu); chatSettingsButton->setPopupMode(QToolButton::InstantPopup); @@ -118,7 +135,7 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, auto *hbox = new QHBoxLayout; hbox->addWidget(splitter, 3); - hbox->addWidget(tabs, 1); + hbox->addWidget(userListPanel, 1); aLeaveRoom = new QAction(this); connect(aLeaveRoom, &QAction::triggered, this, &TabRoom::closeRequest); @@ -137,13 +154,27 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, gameSelector->processGameInfo(info.game_list(i)); } - completer = new QCompleter(autocompleteUserList, sayEdit); - completer->setCaseSensitivity(Qt::CaseInsensitive); - completer->setMaxVisibleItems(5); - completer->setFilterMode(Qt::MatchStartsWith); + mentionModel = new QStringListModel(autocompleteUserList, sayEdit); + mentionCompleter = createMentionCompleter(mentionModel, sayEdit); + sayEdit->addCompleter(mentionCompleter, CompleterTrigger::Mention); + + auto *cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, sayEdit); + auto *displayModel = new CardDatabaseDisplayModel(sayEdit); + displayModel->setSourceModel(cardDatabaseModel); + const CardCompleterSetup cardSetup = createCardCompleter(displayModel, sayEdit); + sayEdit->addCompleter(cardSetup.completer, CompleterTrigger::Card); + + connect(sayEdit, &LineEditCompleter::cardPartialChanged, this, [this, cardSetup](const QString &text) { + cardSetup.searchModel->updateSearchResults(text); + cardSetup.proxyModel->setFilterRegularExpression( + QRegularExpression(QRegularExpression::escape(text), QRegularExpression::CaseInsensitiveOption)); + if (sayEdit->hasFocus()) { + cardSetup.completer->complete(); + } + }); - sayEdit->setCompleter(completer); actCompleterChanged(); + connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this, &TabRoom::refreshShortcuts); refreshShortcuts(); @@ -159,7 +190,7 @@ void TabRoom::retranslateUi() { gameSelector->retranslateUi(); chatView->retranslateUi(); - userList->retranslateUi(); + userListPanel->retranslateUi(); sayLabel->setText(tr("&Say:")); chatGroupBox->setTitle(tr("Chat")); roomMenu->setTitle(tr("&Room")); @@ -213,8 +244,8 @@ void TabRoom::sendMessage() { if (sayEdit->text().isEmpty()) { return; - } else if (completer->popup()->isVisible()) { - completer->popup()->hide(); + } else if (sayEdit->hasVisibleCompleterPopup()) { + sayEdit->hideCompleterPopups(); return; } else { Command_RoomSay cmd; @@ -248,8 +279,8 @@ void TabRoom::actOpenChatSettings() void TabRoom::actCompleterChanged() { - SettingsCache::instance().chat().getChatMentionCompleter() ? completer->setCompletionRole(2) - : completer->setCompletionRole(1); + SettingsCache::instance().chat().getChatMentionCompleter() ? mentionCompleter->setCompletionRole(2) + : mentionCompleter->setCompletionRole(1); } void TabRoom::processRoomEvent(const RoomEvent &event) @@ -285,16 +316,18 @@ void TabRoom::processListGamesEvent(const Event_ListGames &event) void TabRoom::processJoinRoomEvent(const Event_JoinRoom &event) { - if (!autocompleteUserList.contains("@" + QString::fromStdString(event.user_info().name()))) { - autocompleteUserList << "@" + QString::fromStdString(event.user_info().name()); - sayEdit->setCompletionList(autocompleteUserList); + QString mention = "@" + QString::fromStdString(event.user_info().name()); + if (!autocompleteUserList.contains(mention)) { + autocompleteUserList << mention; + mentionModel->setStringList(autocompleteUserList); } } void TabRoom::processLeaveRoomEvent(const Event_LeaveRoom &event) { - autocompleteUserList.removeOne("@" + QString::fromStdString(event.name())); - sayEdit->setCompletionList(autocompleteUserList); + QString mention = "@" + QString::fromStdString(event.name()); + autocompleteUserList.removeOne(mention); + mentionModel->setStringList(autocompleteUserList); } void TabRoom::processRoomSayEvent(const Event_RoomSay &event) diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.h b/cockatrice/src/interface/widgets/tabs/tab_room.h index 2881c25f4..031056145 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.h +++ b/cockatrice/src/interface/widgets/tabs/tab_room.h @@ -8,15 +8,21 @@ #ifndef TAB_ROOM_H #define TAB_ROOM_H -#include "../interface/widgets/utility/line_edit_completer.h" #include "tab.h" -#include -#include +#include #include +#include +#include +#include class UserListProxy; -class UserListManager; +class QAction; +class QGroupBox; +class QMenu; +class QStringListModel; +class TabSupervisor; + namespace google { namespace protobuf @@ -26,14 +32,12 @@ class Message; } // namespace google class AbstractClient; class UserListWidget; +class UserListPanelWidget; class QLabel; class ChatView; -class QPushButton; -class QTextTable; class QCompleter; class RoomEvent; class ServerInfo_Room; -class ServerInfo_Game; class Event_ListGames; class Event_JoinRoom; class Event_LeaveRoom; @@ -56,13 +60,13 @@ private: QMap gameTypes; GameSelector *gameSelector; - UserListWidget *friendsList; + UserListPanelWidget *userListPanel; UserListWidget *userList; - UserListWidget *ignoreList; const UserListProxy *userListProxy; ChatView *chatView; QLabel *sayLabel; LineEditCompleter *sayEdit; + QStringListModel *mentionModel; QGroupBox *chatGroupBox; QMenu *roomMenu; @@ -72,7 +76,7 @@ private: [[nodiscard]] QString sanitizeHtml(QString dirty) const; QStringList autocompleteUserList; - QCompleter *completer; + QCompleter *mentionCompleter; signals: void roomClosing(TabRoom *tab); void openMessageDialog(const QString &userName, bool focus); @@ -112,6 +116,10 @@ public: { return roomId; } + [[nodiscard]] QString getRoomName() const + { + return roomName; + } [[nodiscard]] const QMap &getGameTypes() const { return gameTypes; diff --git a/cockatrice/src/interface/widgets/tabs/tab_server.cpp b/cockatrice/src/interface/widgets/tabs/tab_server.cpp index 13a77e957..0f3e76c76 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_server.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_server.cpp @@ -1,20 +1,30 @@ #include "tab_server.h" -#include "../interface/widgets/server/user/user_list_widget.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_room.pb.h" #include "tab_supervisor.h" #include #include -#include +#include #include #include -#include +#include +#include +#include +#include +#include +#include #include #include #include #include +#include #include #include +#include + +class TabRoom; RoomSelector::RoomSelector(AbstractClient *_client, QWidget *parent) : QGroupBox(parent), client(_client) { @@ -185,25 +195,37 @@ void TabServer::processServerMessageEvent(const Event_ServerMessage &event) void TabServer::joinRoom(int id, bool setCurrent) { TabRoom *room = tabSupervisor->getRoomTabs().value(id); - if (!room) { - Command_JoinRoom cmd; - cmd.set_room_id(id); - - PendingCommand *pend = client->prepareSessionCommand(cmd); - pend->setExtraData(setCurrent); - connect(pend, &PendingCommand::finished, this, - [this, id](const Response &r, const CommandContainer &c, const QVariant &v) { - joinRoomFinished(r, c, v, id); - }); - - client->sendCommand(pend); - + if (room) { + if (setCurrent) { + tabSupervisor->setCurrentWidget((QWidget *)room); + } return; } - if (setCurrent) { - tabSupervisor->setCurrentWidget((QWidget *)room); + auto pendingIt = pendingRoomJoins.find(id); + if (pendingIt != pendingRoomJoins.end()) { + // A join for this room is already in flight: the room tab opens when its response + // arrives. Fold the new request into the pending one so that, for example, clicking + // a room the selector is auto-joining does not send a second Command_JoinRoom - the + // server would reject that duplicate with RespContextError. + if (setCurrent) { + pendingIt.value() = true; + } + return; } + + pendingRoomJoins.insert(id, setCurrent); + + Command_JoinRoom cmd; + cmd.set_room_id(id); + + PendingCommand *pend = client->prepareSessionCommand(cmd); + pend->setExtraData(setCurrent); + connect( + pend, &PendingCommand::finished, this, + [this, id](const Response &r, const CommandContainer &c, const QVariant &v) { joinRoomFinished(r, c, v, id); }); + + client->sendCommand(pend); } void TabServer::joinRoomFinished(const Response &r, @@ -211,34 +233,72 @@ void TabServer::joinRoomFinished(const Response &r, const QVariant &extraData, int roomId) { + const bool setCurrent = pendingRoomJoins.value(roomId, extraData.toBool()); + pendingRoomJoins.remove(roomId); + const bool healedJoin = healedRoomJoins.contains(roomId); + healedRoomJoins.remove(roomId); + switch (r.response_code()) { case Response::RespOk: break; case Response::RespNameNotFound: - QMessageBox::critical(this, tr("Error"), - tr("Failed to join the server room: it doesn't exist on the server.")); + if (setCurrent) { + QMessageBox::critical(this, tr("Error"), + tr("Failed to join the server room: it doesn't exist on the server.")); + } emit roomJoinFailed(roomId); return; case Response::RespContextError: - QMessageBox::critical( - this, tr("Error"), - tr("The server thinks you are in the server room but your client is unable to display it. " - "Try restarting your client.")); - emit roomJoinFailed(roomId); + if (healedJoin) { + // The rejoin below was already answered and the server still rejects the join, so + // the stale-membership heal cannot help: surface the error. The guard was already + // released above so a later user-initiated join may try a fresh heal. + if (setCurrent) { + QMessageBox::critical( + this, tr("Error"), + tr("The server thinks you are in the server room but your client is unable to display it. " + "Try restarting your client.")); + } + emit roomJoinFailed(roomId); + return; + } + // The server already had us registered in the room even though no tab was open, + // usually because two join attempts for the same room overlapped. Leaving and + // rejoining makes the server reply with a fresh RespOk so the tab is displayed + // without requiring a client restart. The guard above covers exactly the rejoin that + // leaveAndRejoinRoom triggers, so a server that keeps replying with RespContextError + // gets one heal attempt per join instead of an endless recursion. + healedRoomJoins.insert(roomId); + leaveAndRejoinRoom(roomId, setCurrent); return; case Response::RespUserLevelTooLow: - QMessageBox::critical(this, tr("Error"), - tr("You do not have the required permission to join this server room.")); + if (setCurrent) { + QMessageBox::critical(this, tr("Error"), + tr("You do not have the required permission to join this server room.")); + } emit roomJoinFailed(roomId); return; default: - QMessageBox::critical( - this, tr("Error"), - tr("Failed to join the server room due to an unknown error: %1.").arg(r.response_code())); + if (setCurrent) { + QMessageBox::critical( + this, tr("Error"), + tr("Failed to join the server room due to an unknown error: %1.").arg(r.response_code())); + } emit roomJoinFailed(roomId); return; } const Response_JoinRoom &resp = r.GetExtension(Response_JoinRoom::ext); - emit roomJoined(resp.room_info(), extraData.toBool()); + emit roomJoined(resp.room_info(), setCurrent); +} + +void TabServer::leaveAndRejoinRoom(int roomId, bool setCurrent) +{ + // Clear the stale room membership server-side. The leave is sent before the rejoin below, + // so the server no longer considers us a member by the time the join arrives. The leave + // response is intentionally not awaited: commands are processed in send order on the + // connection, and a failed leave (RespNotInRoom) only means the membership was already gone. + client->sendCommand(client->prepareRoomCommand(Command_LeaveRoom(), roomId)); + + joinRoom(roomId, setCurrent); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_server.h b/cockatrice/src/interface/widgets/tabs/tab_server.h index c10b7945b..eb2f572fe 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_server.h +++ b/cockatrice/src/interface/widgets/tabs/tab_server.h @@ -10,20 +10,23 @@ #include "tab.h" #include -#include -#include +#include +#include +#include +#include +#include class AbstractClient; -class QTextEdit; -class QLabel; -class UserListWidget; class QPushButton; - class Event_ListRooms; class Event_ServerMessage; class Response; class ServerInfo_Room; class CommandContainer; +class QTextBrowser; +class QTreeWidget; +class QWidget; +class TabSupervisor; class RoomSelector : public QGroupBox { @@ -58,10 +61,17 @@ private slots: int roomId); private: + void leaveAndRejoinRoom(int roomId, bool setCurrent); + AbstractClient *client; RoomSelector *roomSelector; QTextBrowser *serverInfoBox; bool shouldEmitUpdate = false; + /** Room ids with a join command in flight, mapped to whether the tab should be focused once it opens. */ + QHash pendingRoomJoins; + /** Room ids for which a stale-membership heal (leave + rejoin) is currently in flight. Released as soon as the + * rejoin has been answered, so a heal is attempted at most once per join. */ + QSet healedRoomJoins; public: TabServer(TabSupervisor *_tabSupervisor, AbstractClient *_client); diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index d8f2e7935..70014ec47 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -2,34 +2,71 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" +#include "../../../game/abstract_game.h" +#include "../../../game/game_event_handler.h" +#include "../../../game/game_meta_info.h" +#include "../../deck_loader/loaded_deck.h" +#include "../../intents/contexts/context_connect_to_server.h" +#include "../../intents/contexts/context_join_game.h" +#include "../../intents/contexts/context_join_room.h" +#include "../../intents/intent.h" +#include "../../intents/intent_join_server_game.h" #include "../interface/pixel_map_generator.h" +#include "../interface/widgets/server/game_link.h" #include "../interface/widgets/server/user/user_list_manager.h" -#include "../interface/widgets/server/user/user_list_widget.h" #include "../main.h" +#include "abstract_tab_deck_editor.h" #include "api/archidekt/tab_archidekt.h" +#include "api/edhrec/tab_edhrec.h" #include "api/edhrec/tab_edhrec_main.h" +#include "latency_tracker.h" +#include "libcockatrice/protocol/pb/serverinfo_game.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_gametype.pb.h" +#include "tab.h" #include "tab_account.h" #include "tab_admin.h" #include "tab_card_art_rules.h" #include "tab_deck_editor.h" #include "tab_deck_storage.h" +#include "tab_developer.h" #include "tab_game.h" #include "tab_home.h" #include "tab_logs.h" #include "tab_message.h" +#include "tab_moderation.h" +#include "tab_public_decks.h" #include "tab_replays.h" +#include "tab_report.h" #include "tab_room.h" #include "tab_server.h" #include "tab_visual_database_display.h" +#include "user_level.h" #include "visual_deck_editor/tab_deck_editor_visual.h" -#include "visual_deck_editor/tab_deck_editor_visual_tab_widget.h" #include "visual_deck_storage/tab_deck_storage_visual.h" +#include #include +#include +#include +#include +#include +#include +#include +#include +#include #include +#include +#include #include +#include +#include +#include +#include +#include #include +#include #include +#include #include #include #include @@ -42,6 +79,12 @@ #include #include #include +#include +#include +#include +#include + +class QWidget; QRect MacOSTabFixStyle::subElementRect(SubElement element, const QStyleOption *option, const QWidget *widget) const { @@ -110,9 +153,10 @@ void CloseButton::paintEvent(QPaintEvent * /*event*/) } TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget *parent) - : QTabWidget(parent), userInfo(nullptr), client(_client), tabsMenu(tabsMenu), tabVisualDeckStorage(nullptr), - tabServer(nullptr), tabAccount(nullptr), tabDeckStorage(nullptr), tabReplays(nullptr), tabAdmin(nullptr), - tabLog(nullptr), isLocalGame(false) + : QTabWidget(parent), userInfo(nullptr), client(_client), tabsMenu(tabsMenu), tabHome(nullptr), + tabVisualDeckStorage(nullptr), tabServer(nullptr), tabAccount(nullptr), tabDeckStorage(nullptr), + tabReplays(nullptr), tabAdmin(nullptr), tabCardArtRules(nullptr), tabLog(nullptr), tabReport(nullptr), + tabModeration(nullptr), tabDeveloper(nullptr), isLocalGame(false) { setElideMode(Qt::ElideRight); setMovable(true); @@ -135,6 +179,7 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget * connect(client, &AbstractClient::gameJoinedEventReceived, this, &TabSupervisor::gameJoined); connect(client, &AbstractClient::userMessageEventReceived, this, &TabSupervisor::processUserMessageEvent); connect(client, &AbstractClient::maxPingTime, this, &TabSupervisor::updatePingTime); + connect(client, &AbstractClient::pingStatsUpdated, this, &TabSupervisor::updateLatencyTooltip); connect(client, &AbstractClient::notifyUserEventReceived, this, &TabSupervisor::processNotifyUserEvent); // create tabs menu actions @@ -189,6 +234,18 @@ TabSupervisor::TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget * aTabLog->setCheckable(true); connect(aTabLog, &QAction::triggered, this, &TabSupervisor::actTabLog); + aTabReport = new QAction(this); + aTabReport->setCheckable(true); + connect(aTabReport, &QAction::triggered, this, &TabSupervisor::actTabReport); + + aTabModeration = new QAction(this); + aTabModeration->setCheckable(true); + connect(aTabModeration, &QAction::triggered, this, &TabSupervisor::actTabModeration); + + aTabDeveloper = new QAction(this); + aTabDeveloper->setCheckable(true); + connect(aTabDeveloper, &QAction::triggered, this, &TabSupervisor::actTabDeveloper); + connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this, &TabSupervisor::refreshShortcuts); refreshShortcuts(); @@ -228,6 +285,10 @@ void TabSupervisor::retranslateUi() aTabReplays->setText(tr("Game Replays")); aTabAdmin->setText(tr("Administration")); aTabLog->setText(tr("Logs")); + aTabReport->setText(tr("Report Queue")); + aTabModeration->setText(tr("Moderation")); + aTabCardArtRules->setText(tr("Card Art Rules")); + aTabDeveloper->setText(tr("Developer")); // tabs QList tabs; @@ -237,6 +298,10 @@ void TabSupervisor::retranslateUi() tabs.append(tabAdmin); tabs.append(tabAccount); tabs.append(tabLog); + tabs.append(tabReport); + tabs.append(tabModeration); + tabs.append(tabCardArtRules); + tabs.append(tabDeveloper); QMapIterator roomIterator(roomTabs); while (roomIterator.hasNext()) { tabs.append(roomIterator.next().value()); @@ -245,6 +310,10 @@ void TabSupervisor::retranslateUi() while (gameIterator.hasNext()) { tabs.append(gameIterator.next().value()); } + QMapIterator publicDecksIterator(publicDecksTabs); + while (publicDecksIterator.hasNext()) { + tabs.append(publicDecksIterator.next().value()); + } QListIterator replayIterator(replayTabs); while (replayIterator.hasNext()) { tabs.append(replayIterator.next()); @@ -283,6 +352,8 @@ void TabSupervisor::refreshShortcuts() aTabReplays->setShortcuts(shortcuts.getShortcut("Tabs/aTabReplays")); aTabAdmin->setShortcuts(shortcuts.getShortcut("Tabs/aTabAdmin")); aTabLog->setShortcuts(shortcuts.getShortcut("Tabs/aTabLog")); + aTabReport->setShortcuts(shortcuts.getShortcut("Tabs/aTabReport")); + aTabModeration->setShortcuts(shortcuts.getShortcut("Tabs/aTabModeration")); } void TabSupervisor::closeEvent(QCloseEvent *event) @@ -335,7 +406,12 @@ static void checkAndTrigger(QAction *checkableAction, bool checked) } /** - * Opens the always-available tabs, depending on settings. + * Opens the always-available tabs, depending on settings, and lands on the configured startup tab. + * + * The startup destination is a request: tabs that were not open before (deck editors, storage + * tabs disabled in the Tabs menu) are opened as part of the startup flow. Destinations that + * require a server connection (Server, Server Room) are handled asynchronously by MainWindow + * through the intent system, since this class has no RemoteClient. */ void TabSupervisor::initStartupTabs() { @@ -351,6 +427,42 @@ void TabSupervisor::initStartupTabs() if (SettingsCache::instance().tabs().getTabReplaysOpen()) { openTabReplays(); } + + switch (SettingsCache::instance().tabs().getStartupTabIndex()) { + case StartupTab::StartupTabVisualDeckStorage: + if (!tabVisualDeckStorage) { + openTabVisualDeckStorage(); + } + setCurrentWidget(tabVisualDeckStorage); + break; + case StartupTab::StartupTabDeckStorage: + if (!tabDeckStorage) { + openTabDeckStorage(); + } + setCurrentWidget(tabDeckStorage); + break; + case StartupTab::StartupTabReplays: + if (!tabReplays) { + openTabReplays(); + } + setCurrentWidget(tabReplays); + break; + case StartupTab::StartupTabDeckEditor: + addDeckEditorTab(LoadedDeck()); + break; + case StartupTab::StartupTabVisualDeckEditor: + addVisualDeckEditorTab(LoadedDeck()); + break; + case StartupTab::StartupTabServer: + case StartupTab::StartupTabServerRoom: + // Handled asynchronously by MainWindow::applyStartupDestination(); Home stays selected + // until the server connection succeeds. + break; + case StartupTab::StartupTabHome: + default: + setCurrentWidget(tabHome); + break; + } } /** @@ -365,6 +477,7 @@ int TabSupervisor::myAddTab(Tab *tab, QAction *manager) { connect(tab, &TabGame::userEvent, this, &TabSupervisor::tabUserEvent); connect(tab, &TabGame::tabTextChanged, this, &TabSupervisor::updateTabText); + connect(tab, &TabGame::cockatriceLinkActivated, this, &TabSupervisor::cockatriceLinkActivated); QString tabText = tab->getTabText(); int idx = addTab(tab, sanitizeTabName(tabText)); @@ -442,6 +555,8 @@ void TabSupervisor::start(const ServerInfo_User &_userInfo) tabsMenu->addAction(aTabAdmin); tabsMenu->addAction(aTabLog); tabsMenu->addAction(aTabCardArtRules); + tabsMenu->addAction(aTabReport); + tabsMenu->addAction(aTabModeration); if (SettingsCache::instance().tabs().getTabAdminOpen()) { openTabAdmin(); @@ -449,7 +564,28 @@ void TabSupervisor::start(const ServerInfo_User &_userInfo) if (SettingsCache::instance().tabs().getTabLogOpen()) { openTabLog(); } - openTabCardArtRules(); + if (SettingsCache::instance().tabs().getTabReportOpen()) { + openTabReport(); + } + if (SettingsCache::instance().tabs().getTabModerationOpen()) { + openTabModeration(); + } + if (SettingsCache::instance().tabs().getTabCardArtRulesOpen()) { + openTabCardArtRules(); + } + } + + if (userInfo->user_level() & ServerInfo_User::IsDeveloper) { + tabsMenu->addSeparator(); + tabsMenu->addAction(aTabDeveloper); + // Developers without moderation rights get log access through their + // own role. Moderators already have the Logs entry from above. + if (!(userInfo->user_level() & ServerInfo_User::IsModerator)) { + tabsMenu->addAction(aTabLog); + if (SettingsCache::instance().tabs().getTabLogOpen()) { + openTabLog(); + } + } } retranslateUi(); @@ -462,6 +598,9 @@ void TabSupervisor::startLocal(const QList &_clients) tabAccount = nullptr; tabAdmin = nullptr; tabLog = nullptr; + tabReport = nullptr; + tabModeration = nullptr; + tabDeveloper = nullptr; isLocalGame = true; userInfo = new ServerInfo_User; localClients = _clients; @@ -503,6 +642,18 @@ void TabSupervisor::stop() if (tabLog) { tabLog->close(); } + if (tabReport) { + tabReport->close(); + } + if (tabModeration) { + tabModeration->close(); + } + if (tabCardArtRules) { + tabCardArtRules->close(); + } + if (tabDeveloper) { + tabDeveloper->close(); + } } QList tabsToDelete; @@ -515,6 +666,10 @@ void TabSupervisor::stop() tabsToDelete << i.value(); } + for (auto i = publicDecksTabs.cbegin(), end = publicDecksTabs.cend(); i != end; ++i) { + tabsToDelete << i.value(); + } + for (const auto tab : tabsToDelete) { tab->close(); } @@ -561,7 +716,7 @@ void TabSupervisor::actTabVisualDeckStorage(bool checked) void TabSupervisor::openTabVisualDeckStorage() { - tabVisualDeckStorage = new TabDeckStorageVisual(this); + tabVisualDeckStorage = new TabDeckStorageVisual(this, client); myAddTab(tabVisualDeckStorage, aTabVisualDeckStorage); connect(tabVisualDeckStorage, &QObject::destroyed, this, [this] { tabVisualDeckStorage = nullptr; @@ -696,6 +851,7 @@ void TabSupervisor::openTabAdmin() void TabSupervisor::actTabCardArtRules(bool checked) { + SettingsCache::instance().tabs().setTabCardArtRulesOpen(checked); if (checked && !tabCardArtRules) { openTabCardArtRules(); setCurrentWidget(tabCardArtRules); @@ -731,7 +887,13 @@ void TabSupervisor::actTabLog(bool checked) void TabSupervisor::openTabLog() { - tabLog = new TabLog(this, client); + // Developers query logs through the developer command family, so tell the + // tab which family to use. The moderator family is strictly stronger, so a + // moderator who also holds the developer bit keeps the moderator path — the + // developer bit only selects the (narrowed) developer family on its own. + const bool useDeveloperCommands = (userInfo->user_level() & ServerInfo_User::IsDeveloper) && + !(userInfo->user_level() & ServerInfo_User::IsModerator); + tabLog = new TabLog(this, client, useDeveloperCommands); myAddTab(tabLog, aTabLog); connect(tabLog, &QObject::destroyed, this, [this] { tabLog = nullptr; @@ -740,6 +902,80 @@ void TabSupervisor::openTabLog() aTabLog->setChecked(true); } +void TabSupervisor::actTabReport(bool checked) +{ + SettingsCache::instance().tabs().setTabReportOpen(checked); + if (checked && !tabReport) { + openTabReport(); + setCurrentWidget(tabReport); + } else if (!checked && tabReport) { + tabReport->closeRequest(); + } +} + +void TabSupervisor::openTabReport() +{ + tabReport = new TabReport(this, client); + myAddTab(tabReport, aTabReport); + connect(tabReport, &TabReport::openReplay, this, &TabSupervisor::openReplay); + connect(tabReport, &TabReport::requestJoinGame, this, &TabSupervisor::joinReportGame); + connect(tabReport, &QObject::destroyed, this, [this] { + tabReport = nullptr; + aTabReport->setChecked(false); + }); + aTabReport->setChecked(true); +} + +void TabSupervisor::actTabModeration(bool checked) +{ + SettingsCache::instance().tabs().setTabModerationOpen(checked); + if (checked && !tabModeration) { + openTabModeration(); + setCurrentWidget(tabModeration); + } else if (!checked && tabModeration) { + tabModeration->closeRequest(); + } +} + +void TabSupervisor::openTabModeration(const QString &userName) +{ + if (tabModeration) { + setCurrentWidget(tabModeration); + if (!userName.isEmpty()) { + tabModeration->investigate(userName); + } + return; + } + tabModeration = new TabModeration(this, client, userName); + myAddTab(tabModeration, aTabModeration); + connect(tabModeration, &QObject::destroyed, this, [this] { + tabModeration = nullptr; + aTabModeration->setChecked(false); + }); + aTabModeration->setChecked(true); +} + +void TabSupervisor::actTabDeveloper(bool checked) +{ + if (checked && !tabDeveloper) { + openTabDeveloper(); + setCurrentWidget(tabDeveloper); + } else if (!checked && tabDeveloper) { + tabDeveloper->closeRequest(); + } +} + +void TabSupervisor::openTabDeveloper() +{ + tabDeveloper = new TabDeveloper(this, client); + myAddTab(tabDeveloper, aTabDeveloper); + connect(tabDeveloper, &QObject::destroyed, this, [this] { + tabDeveloper = nullptr; + aTabDeveloper->setChecked(false); + }); + aTabDeveloper->setChecked(true); +} + void TabSupervisor::updatePingTime(int value, int max) { if (!tabServer) { @@ -752,6 +988,23 @@ void TabSupervisor::updatePingTime(int value, int max) setTabIcon(indexOf(tabServer), QIcon(PingPixmapGenerator::generatePixmap(15, value, max))); } +void TabSupervisor::updateLatencyTooltip(const LatencyTracker::Stats &stats) +{ + if (!tabServer) { + return; + } + + if (stats.sampleCount == 0) { + setTabToolTip(indexOf(tabServer), QString()); + return; + } + + setTabToolTip(indexOf(tabServer), + tr("Connection quality over the last %n sample(s):", "", stats.sampleCount) + "\n" + + tr("Last: %1 ms").arg(stats.lastMs) + "\n" + tr("Median: %1 ms").arg(stats.medianMs) + "\n" + + tr("95th percentile: %1 ms").arg(stats.p95Ms) + "\n" + tr("Maximum: %1 ms").arg(stats.maxMs)); +} + void TabSupervisor::gameJoined(const Event_GameJoined &event) { QMap roomGameTypes; @@ -810,6 +1063,7 @@ void TabSupervisor::addRoomTab(const ServerInfo_Room &info, bool setCurrent) connect(tab, &TabRoom::maximizeClient, this, &TabSupervisor::maximizeMainWindow); connect(tab, &TabRoom::roomClosing, this, &TabSupervisor::roomLeft); connect(tab, &TabRoom::openMessageDialog, this, &TabSupervisor::addMessageTab); + connect(tab, &TabRoom::cockatriceLinkActivated, this, &TabSupervisor::cockatriceLinkActivated); myAddTab(tab); roomTabs.insert(info.room_id(), tab); if (setCurrent) { @@ -827,6 +1081,30 @@ void TabSupervisor::roomLeft(TabRoom *tab) removeTab(indexOf(tab)); } +void TabSupervisor::openTabPublicDecks(const QString &userName) +{ + if (auto *existing = publicDecksTabs.value(userName, nullptr)) { + setCurrentWidget(existing); + return; + } + + auto *tab = new TabPublicDecks(this, client, userName); + connect(tab, &TabPublicDecks::closing, this, &TabSupervisor::publicDecksClosed); + myAddTab(tab); + publicDecksTabs.insert(userName, tab); + setCurrentWidget(tab); +} + +void TabSupervisor::publicDecksClosed(TabPublicDecks *tab) +{ + if (tab == currentWidget()) { + emit setMenu(); + } + + publicDecksTabs.remove(tab->getUserName()); + removeTab(indexOf(tab)); +} + void TabSupervisor::switchToFirstAvailableNetworkTab() { if (!roomTabs.isEmpty()) { @@ -856,6 +1134,47 @@ void TabSupervisor::replayLeft(TabGame *tab) replayTabs.removeOne(tab); } +void TabSupervisor::joinReportGame(const int gameId, const int roomId) +{ + joinGameByIntent(gameId, roomId, true); +} + +void TabSupervisor::joinGameFromUserCard(const int gameId, const int roomId, const bool asSpectator) +{ + joinGameByIntent(gameId, roomId, asSpectator); +} + +/** + * Runs the join/spectate intent for @p gameId in @p roomId. + * + * The intent owns the whole flow: if the room tab is not open yet it joins the + * room first, then retries the game, so callers do not have to care whether the + * room happens to be on screen. + */ +void TabSupervisor::joinGameByIntent(const int gameId, const int roomId, const bool asSpectator) +{ + auto *remoteClient = qobject_cast(client); + if (!remoteClient) { + actShowPopup(tr("Game joins are only available on a remote server.")); + return; + } + + auto ctx = std::make_unique(); + ctx->roomContext.serverContext.hostname = remoteClient->peerName(); + ctx->roomContext.serverContext.port = QString::number(remoteClient->peerPort()); + ctx->roomContext.roomId = roomId; + ctx->gameId = gameId; + ctx->asSpectator = asSpectator; + + auto *joinGameIntent = new IntentJoinServerGame(this, remoteClient, std::move(ctx)); + joinGameIntent->setParent(this); + connect(joinGameIntent, &Intent::failed, this, [gameId](const QString &reason) { + actShowPopup(tr("Could not join game %1.\n%2").arg(gameId).arg(reason)); + }); + + joinGameIntent->execute(); +} + TabMessage *TabSupervisor::addMessageTab(const QString &receiverName, bool focus) { if (receiverName == QString::fromStdString(userInfo->name())) { @@ -863,8 +1182,10 @@ TabMessage *TabSupervisor::addMessageTab(const QString &receiverName, bool focus } ServerInfo_User otherUser; + bool userOnline = false; if (auto user = userListManager->getOnlineUser(receiverName)) { otherUser = ServerInfo_User(*user); + userOnline = true; } else { otherUser.set_name(receiverName.toStdString()); } @@ -878,9 +1199,17 @@ TabMessage *TabSupervisor::addMessageTab(const QString &receiverName, bool focus return tab; } - tab = new TabMessage(this, client, *userInfo, otherUser); + if (focus && userListManager->isUserIgnored(receiverName)) { + QMessageBox::information( + this, tr("Ignored user"), + tr("You have ignored %1. Remove them from your ignore list to open a private chat.").arg(receiverName)); + return nullptr; + } + + tab = new TabMessage(this, client, *userInfo, otherUser, userOnline); connect(tab, &TabMessage::talkClosing, this, &TabSupervisor::talkLeft); connect(tab, &TabMessage::maximizeClient, this, &TabSupervisor::maximizeMainWindow); + connect(tab, &TabMessage::cockatriceLinkActivated, this, &TabSupervisor::cockatriceLinkActivated); myAddTab(tab); messageTabs.insert(receiverName, tab); if (focus) { @@ -904,6 +1233,54 @@ void TabSupervisor::talkLeft(TabMessage *tab) removeTab(indexOf(tab)); } +QList TabSupervisor::getGameInviteLinksForRoom(int roomId) const +{ + QList options; + if (isLocalGame) { + return options; + } + + // The inviter may be in several games of the same room (hosting one and + // spectating another, for example). Return every game so the caller can + // let the user choose which one to invite to. + for (auto it = gameTabs.cbegin(); it != gameTabs.cend(); ++it) { + TabGame *tab = it.value(); + GameMetaInfo *metaInfo = tab->getGame()->getGameMetaInfo(); + if (metaInfo->proto().room_id() != roomId) { + continue; + } + // A closed game is a dead end — drop it. Started/full games stay + // listed: an invite to them is a legitimate "come spectate" offer. + if (metaInfo->proto().closed()) { + continue; + } + + const int gameId = metaInfo->gameId(); + const QString description = QString::fromStdString(metaInfo->proto().description()); + + GameInviteOption option{ + .gameId = gameId, + .label = + description.isEmpty() ? tr("Game #%1").arg(gameId) : tr("Game #%1 — %2").arg(gameId).arg(description), + .url = makeGameJoinLink(client->serverName(), client->serverPort(), roomId, gameId, description), + .description = description, + .onlyBuddies = metaInfo->proto().only_buddies(), + .creatorName = QString::fromStdString(metaInfo->proto().creator_info().name()), + }; + options.append(option); + } + + return options; +} + +void TabSupervisor::sendInviteToUser(const QString &userName, const QString &inviteText) +{ + TabMessage *tab = addMessageTab(userName, true); + if (tab) { + tab->sendInviteMessage(inviteText); + } +} + /** * Creates a new deck editor tab and loads the deck into it. * Creates either a classic or visual deck editor tab depending on settings @@ -1008,7 +1385,7 @@ void TabSupervisor::tabUserEvent(bool globalEvent) auto *tab = static_cast(sender()); if (tab != currentWidget()) { tab->setContentsChanged(true); - setTabIcon(indexOf(tab), QPixmap("theme:icons/tab_changed")); + setTabIcon(indexOf(tab), themePixmap(QStringLiteral("icons/tab_changed"))); } if (globalEvent && SettingsCache::instance().userInterface().getNotificationsEnabled()) { QApplication::alert(this); @@ -1043,7 +1420,21 @@ void TabSupervisor::processGameEventContainer(const GameEventContainer &cont) void TabSupervisor::processUserMessageEvent(const Event_UserMessage &event) { + // "Ignore all private messages" silences every PM, including messages to + // already-open tabs — unlike the unregistered/non-buddy filters below, + // which only apply when creating a new tab. Messages from moderators/admins + // are exempt to ensure warnings still reach users. QString senderName = QString::fromStdString(event.sender_name()); + if (SettingsCache::instance().chat().getIgnoreAllPrivateMessages()) { + const ServerInfo_User *onlineUserInfo = userListManager->getOnlineUser(senderName); + if (!onlineUserInfo) { + return; + } + const UserLevelFlags userLevel(onlineUserInfo->user_level()); + if (!userLevel.testFlag(ServerInfo_User::IsModerator) && !userLevel.testFlag(ServerInfo_User::IsAdmin)) { + return; + } + } TabMessage *tab = messageTabs.value(senderName); if (!tab) { tab = messageTabs.value(QString::fromStdString(event.receiver_name())); @@ -1145,6 +1536,11 @@ bool TabSupervisor::getAdminLocked() const return tabAdmin->getLocked(); } +bool TabSupervisor::canOverrideGameRestrictions() const +{ + return !getAdminLocked() || (userInfo->user_level() & ServerInfo_User::IsJudge); +} + void TabSupervisor::processNotifyUserEvent(const Event_NotifyUser &event) { @@ -1190,6 +1586,24 @@ void TabSupervisor::processNotifyUserEvent(const Event_NotifyUser &event) } break; } + case Event_NotifyUser::REPORT_RESOLVED: { + QString title = QString::fromStdString(event.custom_title()).simplified(); + QString content = QString::fromStdString(event.custom_content()).trimmed(); + if (!title.isEmpty() && !content.isEmpty()) { + actShowPopup(title + "\n" + content); + QApplication::alert(this); + } + break; + } + case Event_NotifyUser::REPORT_COMMENT: { + QString title = QString::fromStdString(event.custom_title()).simplified(); + QString content = QString::fromStdString(event.custom_content()).trimmed(); + if (!title.isEmpty() && !content.isEmpty()) { + actShowPopup(title + "\n" + content); + QApplication::alert(this); + } + break; + } default:; } } diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h index 8d24f221e..3fcc37af8 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h @@ -8,27 +8,36 @@ #ifndef TAB_SUPERVISOR_H #define TAB_SUPERVISOR_H -#include "../../deck_loader/deck_loader.h" -#include "../interface/widgets/server/user/user_list_proxy.h" -#include "abstract_tab_deck_editor.h" -#include "api/archidekt/tab_archidekt.h" -#include "api/edhrec/tab_edhrec.h" -#include "api/edhrec/tab_edhrec_main.h" -#include "tab_visual_database_display.h" -#include "visual_deck_editor/tab_deck_editor_visual.h" -#include "visual_deck_storage/tab_deck_storage_visual.h" +#include "../interface/widgets/server/game_link.h" +#include "libcockatrice/card/card_info.h" #include +#include #include #include +#include #include +#include +#include #include +#include +#include class TabCardArtRules; +class AbstractTabDeckEditor; +class QAction; +class QWidget; +class TabArchidekt; +class TabDeckEditorVisual; +class TabDeckStorageVisual; +class TabEdhRec; +class TabEdhRecMain; +class TabVisualDatabaseDisplay; +struct LoadedDeck; + inline Q_LOGGING_CATEGORY(TabSupervisorLog, "tab_supervisor"); class UserListManager; -class QMenu; class AbstractClient; class Tab; class TabServer; @@ -39,9 +48,13 @@ class TabDeckStorage; class TabReplays; class TabAdmin; class TabMessage; +class TabReport; +class TabModeration; class TabAccount; class TabDeckEditor; +class TabDeveloper; class TabLog; +class TabPublicDecks; class RoomEvent; class GameEventContainer; class Event_GameJoined; @@ -50,7 +63,6 @@ class Event_NotifyUser; class ServerInfo_Room; class ServerInfo_User; class GameReplay; -class DeckList; class MacOSTabFixStyle : public QProxyStyle { @@ -102,22 +114,28 @@ private: TabAdmin *tabAdmin; TabCardArtRules *tabCardArtRules; TabLog *tabLog; + TabReport *tabReport; + TabModeration *tabModeration; + TabDeveloper *tabDeveloper; QMap roomTabs; QMap gameTabs; QList replayTabs; QMap messageTabs; + QMap publicDecksTabs; QList deckEditorTabs; bool isLocalGame; QAction *aTabHome, *aTabDeckEditor, *aTabVisualDeckEditor, *aTabEdhRec, *aTabArchidekt, *aTabVisualDeckStorage, *aTabVisualDatabaseDisplay, *aTabServer, *aTabAccount, *aTabDeckStorage, *aTabReplays, *aTabAdmin, - *aTabCardArtRules, *aTabLog; + *aTabCardArtRules, *aTabLog, *aTabReport, *aTabModeration, *aTabDeveloper; int myAddTab(Tab *tab, QAction *manager = nullptr); void addCloseButtonToTab(Tab *tab, int tabIndex, QAction *manager); static QString sanitizeTabName(QString dirty); static QString sanitizeHtml(QString dirty); void resetTabsMenu(); + /** Shared join/spectate driver behind the report tab and the profile hover card. */ + void joinGameByIntent(int gameId, int roomId, bool asSpectator); public: explicit TabSupervisor(AbstractClient *_client, QMenu *tabsMenu, QWidget *parent = nullptr); @@ -144,6 +162,10 @@ public: return userInfo; } [[nodiscard]] AbstractClient *getClient() const; + [[nodiscard]] AbstractClient *getServerClient() const + { + return client; + } [[nodiscard]] UserListManager *getUserListManager() const { return userListManager; @@ -164,7 +186,10 @@ public: { return deckEditorTabs; } + [[nodiscard]] QList getGameInviteLinksForRoom(int roomId) const; + void sendInviteToUser(const QString &userName, const QString &inviteText); [[nodiscard]] bool getAdminLocked() const; + [[nodiscard]] bool canOverrideGameRestrictions() const; void closeEvent(QCloseEvent *event) override; bool switchToGameTabIfAlreadyExists(const int gameId); static void actShowPopup(const QString &message); @@ -173,6 +198,7 @@ signals: void localGameEnded(); void adminLockChanged(bool lock); void showWindowIfHidden(); + void cockatriceLinkActivated(const QString &url); public slots: void openDeckInNewTab(const LoadedDeck &deckToOpen); @@ -183,11 +209,17 @@ public slots: TabArchidekt *addArchidektTab(); TabEdhRec *addEdhrecTab(const CardInfoPtr &cardToQuery, bool isCommander = false); void openReplay(GameReplay *replay); + void joinReportGame(int gameId, int roomId); + /** Joins or spectates a game listed on a user's profile hover card. */ + void joinGameFromUserCard(int gameId, int roomId, bool asSpectator); + void openTabModeration(const QString &userName = {}); void switchToFirstAvailableNetworkTab(); void maximizeMainWindow(); void actTabVisualDeckStorage(bool checked); void actTabReplays(bool checked); void openTabServer(); + void addRoomTab(const ServerInfo_Room &info, bool setCurrent); + void openTabPublicDecks(const QString &userName); private slots: void refreshShortcuts(); @@ -197,6 +229,9 @@ private slots: void actTabDeckStorage(bool checked); void actTabAdmin(bool checked); void actTabLog(bool checked); + void actTabReport(bool checked); + void actTabModeration(bool checked); + void actTabDeveloper(bool checked); void openTabVisualDeckStorage(); void openTabHome(); @@ -207,14 +242,17 @@ private slots: void actTabCardArtRules(bool checked); void openTabCardArtRules(); void openTabLog(); + void openTabReport(); + void openTabDeveloper(); void updateCurrent(int index); void updatePingTime(int value, int max); + void updateLatencyTooltip(const LatencyTracker::Stats &stats); void gameJoined(const Event_GameJoined &event); void localGameJoined(const Event_GameJoined &event); void gameLeft(TabGame *tab); - void addRoomTab(const ServerInfo_Room &info, bool setCurrent); void roomLeft(TabRoom *tab); + void publicDecksClosed(TabPublicDecks *tab); TabMessage *addMessageTab(const QString &userName, bool focus); void replayLeft(TabGame *tab); void processUserLeft(const QString &userName); diff --git a/cockatrice/src/interface/widgets/tabs/tab_visual_database_display.cpp b/cockatrice/src/interface/widgets/tabs/tab_visual_database_display.cpp index 3112e7ada..f4b007072 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_visual_database_display.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_visual_database_display.cpp @@ -1,6 +1,7 @@ #include "tab_visual_database_display.h" -#include "tab_deck_editor.h" +#include "../visual_database_display/visual_database_display_widget.h" +#include "card_database_model.h" #include "tab_supervisor.h" #include diff --git a/cockatrice/src/interface/widgets/tabs/tab_visual_database_display.h b/cockatrice/src/interface/widgets/tabs/tab_visual_database_display.h index 3a4bcd6ea..31633f10c 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_visual_database_display.h +++ b/cockatrice/src/interface/widgets/tabs/tab_visual_database_display.h @@ -8,8 +8,14 @@ #define TAB_VISUAL_DATABASE_DISPLAY_H #include "../interface/widgets/visual_database_display/visual_database_display_widget.h" +#include "libcockatrice/card/card_info.h" #include "tab.h" +#include +#include + +class TabSupervisor; + class TabVisualDatabaseDisplay : public Tab { Q_OBJECT diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp index 1414943a1..0616ea899 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.cpp @@ -2,39 +2,59 @@ #include "../../../../client/settings/cache_settings.h" #include "../../../../client/settings/shortcuts_settings.h" -#include "../../cards/card_info_display_widget.h" +#include "../../../deck_loader/loaded_deck.h" +#include "../../deck_editor/deck_editor_card_database_dock_widget.h" +#include "../../deck_editor/deck_editor_card_info_dock_widget.h" +#include "../../deck_editor/deck_editor_deck_dock_widget.h" +#include "../../deck_editor/deck_editor_filter_dock_widget.h" +#include "../../deck_editor/deck_editor_printing_selector_dock_widget.h" #include "../../deck_editor/deck_state_manager.h" -#include "../../filters/filter_builder.h" -#include "../../interface/pixel_map_generator.h" -#include "../../interface/widgets/cards/card_info_frame_widget.h" +#include "../../deck_editor/deck_zone_dialog.h" #include "../../interface/widgets/deck_analytics/deck_analytics_widget.h" #include "../../interface/widgets/general/tutorial/tutorial_controller.h" #include "../../interface/widgets/visual_deck_editor/visual_deck_editor_widget.h" +#include "../../menus/deck_editor_menu.h" +#include "../../visual_database_display/visual_database_display_widget.h" +#include "../../visual_deck_editor/visual_deck_editor_sample_hand_widget.h" #include "../tab_deck_editor.h" #include "../tab_home.h" #include "../tab_supervisor.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/deck_list/tree/inner_deck_list_node.h" #include "tab_deck_editor_visual_tab_widget.h" #include -#include -#include -#include +#include +#include +#include #include -#include +#include +#include +#include +#include #include +#include +#include +#include +#include #include -#include -#include -#include +#include +#include +#include +#include #include #include #include -#include +#include +#include #include #include #include +#include #include #include +#include +#include /** * @brief Constructs the TabDeckEditorVisual instance. @@ -149,9 +169,6 @@ void TabDeckEditorVisual::createCentralFrame() centralFrame = new QVBoxLayout; centralWidget->setLayout(centralFrame); - auto databaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), true, this); - databaseModel->setObjectName("databaseModel"); - tabContainer = new TabDeckEditorVisualTabWidget(centralWidget, this, deckStateManager->getModel(), databaseModel); connect(tabContainer, &TabDeckEditorVisualTabWidget::cardChanged, this, @@ -169,6 +186,7 @@ void TabDeckEditorVisual::createCentralFrame() connect(tabContainer, &TabDeckEditorVisualTabWidget::printingSelectorRequested, this, &TabDeckEditorVisual::showPrintingSelector); connect(tabContainer, &TabDeckEditorVisualTabWidget::cardInfoRequested, this, &TabDeckEditorVisual::updateCardInfo); + tabContainer->visualDatabaseDisplay->setNewZoneCreator([this] { return createNewZone(); }); centralFrame->addWidget(tabContainer); setCentralWidget(centralWidget); @@ -184,6 +202,33 @@ void TabDeckEditorVisual::onDeckChanged() tabContainer->sampleHandWidget->setDeckModel(deckStateManager->getModel()); } +/** @brief Sets the deck and selects the startup sub-tab matching the context. */ +void TabDeckEditorVisual::setDeck(const LoadedDeck &_deck) +{ + AbstractTabDeckEditor::setDeck(_deck); + + int startupTab = SettingsCache::instance().deckEditor().getVdeStartupTab(); + if (startupTab == VdeStartupTabContext) { + // New (empty) decks open on the database display so cards can be added + // right away. Existing decks open on the deck view. + startupTab = _deck.isEmpty() ? VdeStartupTabDatabaseDisplay : VdeStartupTabDeckDisplay; + } + + switch (startupTab) { + case VdeStartupTabDatabaseDisplay: + tabContainer->setCurrentIndex(TabDeckEditorVisualTabWidget::TabIndex::VisualDatabaseDisplay); + break; + case VdeStartupTabDeckDisplay: + tabContainer->setCurrentIndex(TabDeckEditorVisualTabWidget::TabIndex::VisualDeckView); + break; + default: + qCWarning(TabSupervisorLog) << "Unknown VdeStartupTab [" << startupTab + << "]; falling back to the deck view"; + tabContainer->setCurrentIndex(TabDeckEditorVisualTabWidget::TabIndex::VisualDeckView); + break; + } +} + /** @brief Creates menus for deck editing and view options, including dock actions. */ void TabDeckEditorVisual::createMenus() { @@ -327,6 +372,19 @@ bool TabDeckEditorVisual::actSaveDeckAs() return result; } +/** @brief Prompts for and creates a new custom deck zone. Returns the name of the created zone. */ +QString TabDeckEditorVisual::createNewZone() +{ + QString boardName; + const QString zoneName = DeckZoneDialog::promptForNewZone(this, {}, &boardName, [this](const QString &candidate) { + return deckStateManager->validateNewZoneName(candidate); + }); + if (!zoneName.isEmpty()) { + deckStateManager->createCustomZone(boardName, zoneName); + } + return zoneName; +} + /** @brief Refreshes keyboard shortcuts for this tab from settings. */ void TabDeckEditorVisual::refreshShortcuts() { diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.h b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.h index 720d5f37e..c847cd5fe 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.h +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual.h @@ -1,8 +1,19 @@ #ifndef WINDOW_DECKEDITORVISUAL_H #define WINDOW_DECKEDITORVISUAL_H -#include "../tab.h" -#include "tab_deck_editor_visual_tab_widget.h" +#include "../abstract_tab_deck_editor.h" + +#include +#include +#include + +class ExactCard; +class QDockWidget; +class QHBoxLayout; +class QVBoxLayout; +class QWidget; +class TabDeckEditorVisualTabWidget; +class TabSupervisor; class TutorialController; /** @@ -175,6 +186,21 @@ public slots: * @return true if successful, false otherwise. */ bool actSaveDeckAs() override; + + /** + * @brief Prompts for and creates a new custom deck zone. + * @return The name of the created zone, or an empty string if creation was cancelled. + */ + QString createNewZone(); + +private: + /** + * @brief Sets the deck for this tab and selects the sub-tab to open on + * startup, per the "Visual deck editor startup tab" setting (Context / + * Deck display / Database display). + * @param _deck The deck object. + */ + void setDeck(const LoadedDeck &_deck) override; }; #endif diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.cpp b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.cpp index 2ee560859..8e01ff983 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.cpp +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.cpp @@ -1,7 +1,24 @@ #include "tab_deck_editor_visual_tab_widget.h" +#include "../../deck_analytics/deck_analytics_widget.h" +#include "../../deck_analytics/deck_list_statistics_analyzer.h" +#include "../../deck_editor/deck_editor_deck_dock_widget.h" #include "../../interface/widgets/visual_database_display/visual_database_display_widget.h" +#include "../../visual_deck_editor/visual_deck_editor_sample_hand_widget.h" +#include "../../visual_deck_editor/visual_deck_editor_widget.h" #include "../abstract_tab_deck_editor.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/deck_list/tree/inner_deck_list_node.h" + +#include +#include +#include +#include +#include + +class CardDatabaseModel; +class DeckListModel; +class QMouseEvent; /** * @brief Constructs the TabDeckEditorVisualTabWidget. @@ -25,7 +42,8 @@ TabDeckEditorVisualTabWidget::TabDeckEditorVisualTabWidget(QWidget *parent, layout = new QVBoxLayout(this); setLayout(layout); - visualDeckView = new VisualDeckEditorWidget(this, deckModel, _deckEditor->deckDockWidget->getSelectionModel()); + visualDeckView = new VisualDeckEditorWidget(this, deckModel, _deckEditor->deckDockWidget->getSelectionModel(), + _cardDatabaseModel); visualDeckView->setObjectName("visualDeckView"); connect(visualDeckView, &VisualDeckEditorWidget::activeCardChanged, this, &TabDeckEditorVisualTabWidget::onCardChanged); diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.h b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.h index 2aabbb26a..fff978f42 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.h +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.h @@ -1,16 +1,25 @@ #ifndef TAB_DECK_EDITOR_VISUAL_TAB_WIDGET_H #define TAB_DECK_EDITOR_VISUAL_TAB_WIDGET_H -#include "../../interface/widgets/deck_analytics/deck_analytics_widget.h" -#include "../../interface/widgets/printing_selector/printing_selector.h" -#include "../../interface/widgets/visual_database_display/visual_database_display_widget.h" -#include "../../interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.h" -#include "../../interface/widgets/visual_deck_editor/visual_deck_editor_widget.h" -#include "../abstract_tab_deck_editor.h" +#include "libcockatrice/card/card_info.h" +#include +#include #include -#include -#include +#include + +class AbstractTabDeckEditor; +class CardDatabaseModel; +class DeckAnalyticsWidget; +class DeckListModel; +class DeckListStatisticsAnalyzer; +class ExactCard; +class PrintingSelector; +class QVBoxLayout; +class QWidget; +class VisualDatabaseDisplayWidget; +class VisualDeckEditorSampleHandWidget; +class VisualDeckEditorWidget; /** * @class TabDeckEditorVisualTabWidget @@ -49,6 +58,17 @@ class TabDeckEditorVisualTabWidget : public QTabWidget Q_OBJECT public: + /** + * @brief Sub-tab order in the container; addNewTab() is called in this order. + */ + enum TabIndex + { + VisualDeckView, + VisualDatabaseDisplay, + DeckAnalytics, + SampleHand, + }; + /** * @brief Construct the tab widget with required models. * @param parent Parent widget. diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp b/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp index 0cbcb641a..cb6dfab9c 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp @@ -1,25 +1,85 @@ #include "tab_deck_storage_visual.h" +#include "../../../../client/settings/cache_settings.h" +#include "../../../deck_loader/deck_file_format.h" +#include "../../../deck_loader/deck_loader.h" +#include "../../../deck_loader/loaded_deck.h" +#include "../../cards/additional_info/deck_color_identity.h" +#include "../../deck_share/deck_share_utils.h" +#include "../../deck_share/share_bar_widget.h" #include "../../interface/widgets/visual_deck_storage/visual_deck_storage_widget.h" +#include "../tab.h" #include "../tab_supervisor.h" #include -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include -TabDeckStorageVisual::TabDeckStorageVisual(TabSupervisor *_tabSupervisor) - : Tab(_tabSupervisor), visualDeckStorageWidget(new VisualDeckStorageWidget(this)) +TabDeckStorageVisual::TabDeckStorageVisual(TabSupervisor *_tabSupervisor, AbstractClient *_client) + : Tab(_tabSupervisor), visualDeckStorageWidget(new VisualDeckStorageWidget(this)), client(_client), + shareTimeoutTimer(new QTimer(this)) { connect(this, &TabDeckStorageVisual::openDeckEditor, tabSupervisor, &TabSupervisor::openDeckInNewTab); connect(visualDeckStorageWidget, &VisualDeckStorageWidget::deckLoadRequested, this, &TabDeckStorageVisual::actOpenLocalDeck); connect(visualDeckStorageWidget, &VisualDeckStorageWidget::openDeckEditor, this, &TabDeckStorageVisual::openDeckEditor); + connect(visualDeckStorageWidget, &VisualDeckStorageWidget::shareDeckRequested, this, + &TabDeckStorageVisual::actShareDeck); + connect(visualDeckStorageWidget, &VisualDeckStorageWidget::shareSelectionChanged, this, + &TabDeckStorageVisual::onShareSelectionChanged); + connect(visualDeckStorageWidget, &VisualDeckStorageWidget::shareRequested, this, [this] { + if (shareDeckAvailable) { + enterShareMode(); + } + }); auto *widget = new QWidget(this); auto *layout = new QVBoxLayout(widget); + layout->setContentsMargins(0, 0, 0, 0); + layout->setSpacing(0); widget->setLayout(layout); this->setCentralWidget(widget); layout->addWidget(visualDeckStorageWidget); + + shareBar = new ShareBarWidget(this); + connect(shareBar, &ShareBarWidget::createRequested, this, &TabDeckStorageVisual::actShareSelected); + connect(shareBar, &ShareBarWidget::cancelRequested, this, &TabDeckStorageVisual::exitShareMode); + + layout->insertWidget(0, shareBar); + shareBar->setVisible(false); + + connect(client, &AbstractClient::statusChanged, this, &TabDeckStorageVisual::handleConnectionChanged); + shareDeckAvailable = (client->getStatus() == StatusLoggedIn); + visualDeckStorageWidget->setShareAvailable(shareDeckAvailable); + + shareTimeoutTimer->setSingleShot(true); + shareTimeoutTimer->setInterval( + static_cast((static_cast(SettingsCache::instance().network().getTimeOut()) + 1) * + SettingsCache::instance().network().getKeepAlive() * 1000)); + connect(shareTimeoutTimer, &QTimer::timeout, this, &TabDeckStorageVisual::onShareTimeout); + + retranslateUi(); +} + +void TabDeckStorageVisual::retranslateUi() +{ + visualDeckStorageWidget->retranslateUi(); + shareBar->retranslateUi(); + if (shareBar->isVisible()) { + updateShareHint(); + } } void TabDeckStorageVisual::actOpenLocalDeck(const QString &filePath) @@ -33,3 +93,144 @@ void TabDeckStorageVisual::actOpenLocalDeck(const QString &filePath) emit openDeckEditor(deckOpt.value()); } + +void TabDeckStorageVisual::enterShareMode(const QStringList &preselectFiles) +{ + if (!shareDeckAvailable) { + return; // sharing is gated on being logged in + } + shareBar->setCreateEnabled(true); + visualDeckStorageWidget->setShareSelectable(true); + visualDeckStorageWidget->setShareSelectedFiles(preselectFiles); + shareBar->setName(tr("Shared decks")); + shareBar->setVisible(true); + updateShareHint(); + shareBar->focusName(); +} + +void TabDeckStorageVisual::exitShareMode() +{ + // Abandon any in-flight request: otherwise the timer keeps running and a late + // response reports the share as created after the user already backed out. + shareTimeoutTimer->stop(); + shareInFlightSeq = 0; + visualDeckStorageWidget->setShareSelectable(false); + visualDeckStorageWidget->clearShareSelection(); + shareBar->setVisible(false); +} + +void TabDeckStorageVisual::actShareDeck(const QString &filePath) +{ + if (!shareDeckAvailable) { + QMessageBox::information(this, tr("Share deck"), tr("You must be connected to the server to share a deck.")); + return; + } + enterShareMode({filePath}); +} + +void TabDeckStorageVisual::onShareSelectionChanged() +{ + if (shareBar->isVisible()) { + updateShareHint(); + } +} + +void TabDeckStorageVisual::updateShareHint() +{ + const int count = visualDeckStorageWidget->selectedFilePaths().size(); + shareBar->setCountText(tr("%n deck(s)", "", count)); + if (count == 0) { + shareBar->setHintText(tr("Click deck tiles to select the decks you want to share."), true); + } else if (count == 1) { + shareBar->setHintText(tr("One deck selected. Create the link to share it with other players."), true); + } else { + shareBar->setHintText(tr("%n decks selected. Create the link to share them with other players.", "", count), + true); + } +} + +void TabDeckStorageVisual::actShareSelected() +{ + const QStringList filePaths = visualDeckStorageWidget->selectedFilePaths(); + if (filePaths.isEmpty()) { + QMessageBox::warning(this, tr("Share decks"), tr("Select at least one deck to share.")); + return; + } + + Command_DeckShareCreate cmd; + cmd.set_name(shareBar->name().toStdString()); + if (cmd.name().empty()) { + cmd.set_name(tr("Shared decks").toStdString()); + } + + for (const QString &filePath : filePaths) { + std::optional deckOpt = + DeckLoader::loadFromFile(filePath, DeckFileFormat::getFormatFromName(filePath), true); + if (!deckOpt) { + QMessageBox::warning(this, tr("Share decks"), tr("Unable to load deck file %1").arg(filePath)); + return; + } + DeckShareItem *item = cmd.add_items(); + item->set_deck_list(deckOpt->deckList.writeToString_Native().toStdString()); + item->set_color_identity(getDeckColorIdentity(deckOpt->deckList, CardDatabaseManager::query()).toStdString()); + } + + shareBar->setCreateEnabled(false); + const int seq = ++shareRequestSeq; + shareInFlightSeq = seq; + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, + [this, seq](const Response &response, const CommandContainer &commandContainer) { + if (shareInFlightSeq != seq) { + return; // the user cancelled or a newer request superseded this one + } + shareInFlightSeq = 0; + shareFinished(response, commandContainer); + }); + client->sendCommand(pend); + shareTimeoutTimer->start(); +} + +void TabDeckStorageVisual::shareFinished(const Response &response, const CommandContainer & /*commandContainer*/) +{ + shareTimeoutTimer->stop(); + shareBar->setCreateEnabled(true); + if (response.response_code() != Response::RespOk) { + showShareNotice(tr("Failed to create the share link (server response code %1).") + .arg(QString::number(static_cast(response.response_code()))), + true); + return; + } + + const DeckShareUtils::ShareResponse share = DeckShareUtils::handleShareResponse(client, response); + + showShareNotice( + tr("Share link copied to the clipboard.\nExpires on %1.").arg(DeckShareUtils::formatShareExpiry(share.expiry))); + exitShareMode(); +} + +void TabDeckStorageVisual::handleConnectionChanged(ClientStatus status) +{ + shareDeckAvailable = (status == StatusLoggedIn); + visualDeckStorageWidget->setShareAvailable(shareDeckAvailable); + if (!shareDeckAvailable && shareBar->isVisible()) { + exitShareMode(); + } +} + +void TabDeckStorageVisual::showShareNotice(const QString &message, bool warning) +{ + QMessageBox box(warning ? QMessageBox::Warning : QMessageBox::Information, tr("Share link"), message, + QMessageBox::Ok, this); + box.exec(); +} + +void TabDeckStorageVisual::onShareTimeout() +{ + if (shareInFlightSeq == 0) { + return; // share mode was left while the request was still outstanding + } + shareInFlightSeq = 0; + shareBar->setCreateEnabled(true); + showShareNotice(tr("The server did not respond in time. Try again."), true); +} diff --git a/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.h b/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.h index d3f64e23d..13a078fce 100644 --- a/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.h +++ b/cockatrice/src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.h @@ -7,42 +7,75 @@ #ifndef TAB_DECK_STORAGE_VISUAL_H #define TAB_DECK_STORAGE_VISUAL_H +#include "../../deck_share/share_bar_widget.h" #include "../tab.h" +#include +#include +#include +#include +#include + struct LoadedDeck; -class AbstractClient; class CommandContainer; -class DeckPreviewWidget; -class QFileSystemModel; -class QGroupBox; -class QToolBar; -class QTreeView; -class QTreeWidget; -class QTreeWidgetItem; +class QTimer; class Response; class VisualDeckStorageWidget; +class TabSupervisor; class TabDeckStorageVisual final : public Tab { Q_OBJECT public: - explicit TabDeckStorageVisual(TabSupervisor *_tabSupervisor); - void retranslateUi() override - { - } + explicit TabDeckStorageVisual(TabSupervisor *_tabSupervisor, AbstractClient *_client); + void retranslateUi() override; + [[nodiscard]] QString getTabText() const override { return tr("Visual Deck Storage"); } + /** + * @brief Enters share-selection mode, optionally preselecting the given deck files. + */ + void enterShareMode(const QStringList &preselectFiles = {}); + + /** + * @brief Leaves share-selection mode and clears the selection. + */ + void exitShareMode(); + + [[nodiscard]] bool isShareModeActive() const + { + return shareBar->isVisible(); + } + public slots: void actOpenLocalDeck(const QString &filePath); + void actShareDeck(const QString &filePath); signals: void openDeckEditor(const LoadedDeck &deck); +private slots: + void actShareSelected(); + void shareFinished(const Response &response, const CommandContainer &commandContainer); + void onShareTimeout(); + void onShareSelectionChanged(); + void handleConnectionChanged(ClientStatus status); + private: + void showShareNotice(const QString &message, bool warning = false); + void updateShareHint(); + VisualDeckStorageWidget *visualDeckStorageWidget; + + ShareBarWidget *shareBar = nullptr; + AbstractClient *client; + QTimer *shareTimeoutTimer; + int shareRequestSeq = 0; + int shareInFlightSeq = 0; + bool shareDeckAvailable = false; }; #endif diff --git a/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp b/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp new file mode 100644 index 000000000..8042fb9fc --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp @@ -0,0 +1,443 @@ +#include "card_completer_delegate.h" + +#include "../../pixel_map_generator.h" +#include "../cards/additional_info/mana_cost_widget.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/set/card_set.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class QObject; +template class QSharedPointer; + +// --------------------------------------------------------------------------- +// Internal colour helpers +// --------------------------------------------------------------------------- + +namespace +{ + +struct ManaColor +{ + QColor fill; + QColor rim; + QColor text; +}; + +ManaColor manaColor(QChar symbol) +{ + switch (symbol.unicode()) { + case 'W': + return {QColor(248, 248, 246), QColor(190, 180, 160), QColor(80, 70, 50)}; + case 'U': + return {QColor(55, 130, 210), QColor(30, 90, 160), QColor(255, 255, 255)}; + case 'B': + return {QColor(90, 65, 160), QColor(55, 38, 110), QColor(220, 200, 255)}; + case 'R': + return {QColor(210, 55, 55), QColor(150, 30, 30), QColor(255, 255, 255)}; + case 'G': + return {QColor(45, 148, 90), QColor(28, 95, 58), QColor(255, 255, 255)}; + default: + return {QColor(100, 115, 135), QColor(65, 78, 95), QColor(230, 235, 240)}; + } +} + +QColor blend(QColor a, QColor b, qreal t) +{ + return QColor::fromRgbF(a.redF() + (b.redF() - a.redF()) * t, a.greenF() + (b.greenF() - a.greenF()) * t, + a.blueF() + (b.blueF() - a.blueF()) * t, a.alphaF() + (b.alphaF() - a.alphaF()) * t); +} + +} // namespace + +// --------------------------------------------------------------------------- + +QColor CardCompleterDelegate::accentForColors(const QString &colors) +{ + if (colors.isEmpty()) { + return QColor(100, 115, 135); + } + + QSet seen; + for (const QChar c : colors) { + if (QString("WUBRG").contains(c)) { + seen.insert(c); + } + } + + if (seen.size() > 1) { + return QColor(205, 145, 25); + } + + if (seen.isEmpty()) { + return QColor(100, 115, 135); + } + + return manaColor(*seen.begin()).fill; +} + +// --------------------------------------------------------------------------- + +CardCompleterDelegate::CardCompleterDelegate(QObject *parent) : QStyledItemDelegate(parent) +{ + setCodeCache.setMaxCost(64); +} + +// --------------------------------------------------------------------------- +// sizeHint +// --------------------------------------------------------------------------- + +QSize CardCompleterDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + Q_UNUSED(option) + + if (!index.isValid()) { + return QStyledItemDelegate::sizeHint(option, index); + } + + // Fixed wide rows so the popup has room for name, type line, set and mana + return {480, CardRowHeight}; +} + +// --------------------------------------------------------------------------- +// Mana symbol painting +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- + +void CardCompleterDelegate::drawManaSymbol(QPainter *p, QPoint centre, const QString &symbol, int radius) const +{ + const QRect pip(centre.x() - radius, centre.y() - radius, radius * 2, radius * 2); + + const QPixmap px = ManaSymbolPixmapGenerator::generatePixmap(symbol, QSize(radius * 2, radius * 2)); + + if (!px.isNull()) { + p->drawPixmap(pip, px); + return; + } + + bool isNumeric = false; + const int numVal = symbol.toInt(&isNumeric); + + const QString label = isNumeric ? QString::number(numVal) : symbol; + + const ManaColor mc = + (symbol.length() == 1 && QString("WUBRG").contains(symbol)) ? manaColor(symbol[0]) : manaColor(QChar('X')); + + QPainterPath circle; + circle.addEllipse(pip); + + p->save(); + p->setClipPath(circle); + p->fillPath(circle, mc.fill); + p->restore(); + + p->setPen(QPen(mc.rim, 1.2)); + p->setBrush(Qt::NoBrush); + p->drawEllipse(pip.adjusted(1, 1, -1, -1)); + + QFont f = p->font(); + f.setPixelSize(qMax(radius - 1, 7)); + f.setBold(true); + + p->setFont(f); + p->setPen(mc.text); + p->drawText(pip, Qt::AlignCenter, label); +} + +// --------------------------------------------------------------------------- + +int CardCompleterDelegate::drawManaCost(QPainter *p, const QRect &row, const QString &manaCost, int radius) const +{ + if (manaCost.isEmpty()) { + return row.right(); + } + + const int diam = radius * 2; + + // Split, adventure, aftermath and prepare cards store both halves of the + // cost joined by "//" (e.g. "1W // W"); draw each half as its own group. + static const QRegularExpression splitRegex("\\s*//\\s*"); + + QList parts; + + for (const QString &part : manaCost.split(splitRegex, Qt::SkipEmptyParts)) { + const QStringList symbols = ManaCostWidget::parseManaCost(part); + + if (!symbols.isEmpty()) { + parts.append(symbols); + } + } + + int totalW = 0; + + for (int i = 0; i < parts.size(); ++i) { + if (i > 0) { + totalW += PartGap; + } + + totalW += parts.at(i).size() * diam + qMax(0, parts.at(i).size() - 1) * SymbolSpacing; + } + + const int rightPad = 14; + + int x = row.right() - rightPad - totalW + radius; + + const int cy = row.center().y(); + + for (int i = 0; i < parts.size(); ++i) { + const QStringList &symbols = parts.at(i); + + for (const QString &sym : symbols) { + drawManaSymbol(p, {x, cy}, sym, radius); + x += diam + SymbolSpacing; + } + + if (i < parts.size() - 1) { + x += PartGap - SymbolSpacing; + } + } + + return row.right() - rightPad - totalW - 10; +} + +// --------------------------------------------------------------------------- + +QString CardCompleterDelegate::setCodeForCard(const QSharedPointer &card) const +{ + if (!card) { + return QString(); + } + + const QString name = card->getName(); + + if (setCodeCache.contains(name)) { + return *setCodeCache[name]; + } + + QString code; + + const PrintingInfo printing = CardDatabaseManager::query()->getPreferredPrinting(card); + + if (auto set = printing.getSet()) { + code = set->getShortName(); + } + + auto *cached = new QString(code); + setCodeCache.insert(name, cached); + return code; +} + +// --------------------------------------------------------------------------- +// paint +// --------------------------------------------------------------------------- + +void CardCompleterDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + if (!index.isValid()) { + QStyledItemDelegate::paint(painter, option, index); + return; + } + + const QRect rect = option.rect; + const QPalette &pal = option.palette; + + const bool selected = option.state & QStyle::State_Selected; + const bool hovered = option.state & QStyle::State_MouseOver; + + painter->save(); + painter->setRenderHints(QPainter::Antialiasing | QPainter::TextAntialiasing | QPainter::SmoothPixmapTransform); + + auto card = index.data(CardSearchModel::CardInfoRole).value>(); + + if (!card) { + painter->fillRect(rect, pal.color(QPalette::Base)); + painter->restore(); + return; + } + + const QString cardName = card->getName(); + const QString manaCost = card->getManaCost(); + const QString typeLine = card->getCardType(); + const QString setCode = setCodeForCard(card); + + const QColor accent = accentForColors(card->getColors()); + + const QColor base = pal.color(QPalette::Base); + const QColor textColor = pal.color(QPalette::Text); + const QColor secondaryColor = pal.color(QPalette::PlaceholderText); + + QColor tinted = blend(base, accent, 0.40); + + if (hovered) { + tinted = blend(tinted, Qt::white, 0.05); + } + + const QRectF cardRect = rect.adjusted(3, 2, -3, -2); + + // ----------------------------------------------------------------------- + // Main card body + // ----------------------------------------------------------------------- + + QPainterPath path; + path.addRoundedRect(cardRect, 7, 7); + + painter->save(); + painter->setClipPath(path); + + QLinearGradient bodyGrad(cardRect.topLeft(), cardRect.bottomLeft()); + + bodyGrad.setColorAt(0.0, blend(tinted, Qt::white, 0.10)); + bodyGrad.setColorAt(1.0, blend(tinted, Qt::black, 0.18)); + + painter->fillPath(path, bodyGrad); + + // ----------------------------------------------------------------------- + // Accent strip + // ----------------------------------------------------------------------- + + QRectF accentRect(cardRect.left(), cardRect.top(), AccentBarWidth, cardRect.height()); + + QLinearGradient accentGrad(accentRect.topLeft(), accentRect.bottomLeft()); + + accentGrad.setColorAt(0.0, blend(accent, Qt::white, 0.20)); + accentGrad.setColorAt(1.0, blend(accent, Qt::black, 0.25)); + + painter->fillRect(accentRect, accentGrad); + + // ----------------------------------------------------------------------- + // Right mana zone + // ----------------------------------------------------------------------- + + const QRectF manaZone(cardRect.right() - 110, cardRect.top(), 110, cardRect.height()); + + QLinearGradient manaGrad(manaZone.topLeft(), manaZone.bottomLeft()); + + manaGrad.setColorAt(0, QColor(0, 0, 0, 18)); + manaGrad.setColorAt(1, QColor(0, 0, 0, 42)); + + painter->fillRect(manaZone, manaGrad); + + painter->restore(); + + // ----------------------------------------------------------------------- + // Border + // ----------------------------------------------------------------------- + + QColor border = blend(accent, Qt::black, 0.45); + + if (hovered) { + border = blend(border, Qt::white, 0.18); + } + + painter->setPen(QPen(border, 1.2)); + painter->drawPath(path); + + // ----------------------------------------------------------------------- + // Selection glow + // ----------------------------------------------------------------------- + + if (selected) { + QColor glow = pal.color(QPalette::Highlight); + glow.setAlpha(30); + + painter->fillPath(path, glow); + + painter->setPen(QPen(pal.color(QPalette::Highlight), 2)); + painter->drawPath(path); + } + + // ----------------------------------------------------------------------- + // Mana cost + // ----------------------------------------------------------------------- + + const int costLeft = drawManaCost(painter, cardRect.toRect(), manaCost, SymbolRadius); + + // ----------------------------------------------------------------------- + // Card name + type line + set code + // ----------------------------------------------------------------------- + + const int textLeft = cardRect.left() + AccentBarWidth + 12; + const int textRight = costLeft - 8; + const int textWidth = qMax(0, textRight - textLeft); + + // ----------------------------------------------------------------------- + // Card name (top band) + // ----------------------------------------------------------------------- + + { + const QRect nameRect(textLeft, rect.top() + 2, textWidth, 20); + + QFont f = option.font; + f.setPixelSize(13); + f.setBold(true); + + painter->setFont(f); + + const QString nameText = QFontMetrics(f).elidedText(cardName, Qt::ElideRight, nameRect.width()); + + painter->setPen(QColor(0, 0, 0, 140)); + painter->drawText(nameRect.translated(0, 1), Qt::AlignLeft | Qt::AlignVCenter, nameText); + + painter->setPen(textColor); + painter->drawText(nameRect, Qt::AlignLeft | Qt::AlignVCenter, nameText); + } + + // ----------------------------------------------------------------------- + // Type line + set code (bottom band) + // ----------------------------------------------------------------------- + + { + const QRect infoRect(textLeft, rect.top() + 22, textWidth, rect.height() - 24); + + QFont f = option.font; + f.setPixelSize(10); + + painter->setFont(f); + + QString infoLine = typeLine; + + if (!setCode.isEmpty()) { + infoLine += " \u00b7 " + setCode; + } + + const QString infoText = QFontMetrics(f).elidedText(infoLine, Qt::ElideRight, infoRect.width()); + + painter->setPen(QColor(0, 0, 0, 120)); + painter->drawText(infoRect.translated(0, 1), Qt::AlignLeft | Qt::AlignVCenter, infoText); + + painter->setPen(secondaryColor); + painter->drawText(infoRect, Qt::AlignLeft | Qt::AlignVCenter, infoText); + } + + painter->restore(); +} diff --git a/cockatrice/src/interface/widgets/utility/card_completer_delegate.h b/cockatrice/src/interface/widgets/utility/card_completer_delegate.h new file mode 100644 index 000000000..ad81d86e4 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/card_completer_delegate.h @@ -0,0 +1,63 @@ +/** + * @file card_completer_delegate.h + * @ingroup UtilityWidgets + */ +//! \todo Document this file. + +#ifndef CARD_COMPLETER_DELEGATE_H +#define CARD_COMPLETER_DELEGATE_H + +#include +#include +#include +#include +#include + +class CardInfo; +class QObject; +class QPainter; +class QPoint; +class QRect; +template class QSharedPointer; + +/** + * @brief Paints styled card completer popup rows. + * + * Each row shows the card name, type line, set code and mana cost pips, + * color-coded by the card's color identity. Card data is read directly from + * the CardSearchModel::CardInfoRole so no extra database lookups are needed. + */ +class CardCompleterDelegate : public QStyledItemDelegate +{ + Q_OBJECT +public: + explicit CardCompleterDelegate(QObject *parent = nullptr); + + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; + QSize sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const override; + +private: + // Set short codes, resolved once per card name and cached + mutable QCache setCodeCache; + + // Resolve the card's color string ("RG", "W", "", ...) → accent QColor + static QColor accentForColors(const QString &colors); + + // Draw a single mana symbol pip at centre point + void drawManaSymbol(QPainter *p, QPoint centre, const QString &symbol, int radius) const; + + // Draw all mana pips for a cost string like "2RG" or "{2}{R}{G}"; split and + // adventure costs ("1W // W") are drawn as separate groups. Returns the left-most x used + int drawManaCost(QPainter *p, const QRect &row, const QString &manaCost, int radius) const; + + // Resolve the preferred printing's set short code for a card + QString setCodeForCard(const QSharedPointer &card) const; + + static constexpr int CardRowHeight = 40; + static constexpr int AccentBarWidth = 5; + static constexpr int SymbolRadius = 9; + static constexpr int SymbolSpacing = 2; + static constexpr int PartGap = 14; +}; + +#endif // CARD_COMPLETER_DELEGATE_H diff --git a/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp b/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp new file mode 100644 index 000000000..1ddcc4200 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp @@ -0,0 +1,360 @@ +#include "card_completer_styler.h" + +#include "../cards/card_info_picture_enlarged_widget.h" +#include "card_completer_delegate.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "reversed_completer_model.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +template class QSharedPointer; + +namespace +{ +const QSize PreviewSize(300, 419); +const int PreviewMargin = 16; +const int FadeDuration = 120; +} // namespace + +// --------------------------------------------------------------------------- + +void CardCompleterStyler::apply(QCompleter *completer) +{ + if (!completer) { + return; + } + + // The styler lives as long as the completer + new CardCompleterStyler(completer, completer); +} + +// --------------------------------------------------------------------------- + +CardCompleterStyler::CardCompleterStyler(QCompleter *completer, QObject *parent) + : QObject(parent), completer(completer), reversedModel(nullptr), preview(nullptr), above(false) +{ + auto *popup = completer->popup(); + + // Wrap the completer's model so its row order can be reversed when the + // popup is shown above the text edit + QAbstractItemModel *sourceModel = completer->model(); + reversedModel = new ReversedCompleterModel(completer); + reversedModel->setSourceModel(sourceModel); + completer->setModel(reversedModel); + + popup->setItemDelegate(new CardCompleterDelegate(popup)); + + popup->viewport()->setMouseTracking(true); + + popup->installEventFilter(this); + popup->viewport()->installEventFilter(this); + + connect(popup->selectionModel(), &QItemSelectionModel::currentRowChanged, this, + &CardCompleterStyler::updatePreview); + connect(completer, qOverload(&QCompleter::activated), this, &CardCompleterStyler::hidePreview); + connect(completer->completionModel(), &QAbstractItemModel::modelReset, this, + &CardCompleterStyler::onCompletionReset); +} + +CardCompleterStyler::~CardCompleterStyler() +{ + if (preview) { + preview->deleteLater(); + } +} + +// --------------------------------------------------------------------------- + +bool CardCompleterStyler::eventFilter(QObject *obj, QEvent *ev) +{ + auto *popup = completer->popup(); + + if (obj == popup->viewport()) { + if (ev->type() == QEvent::MouseMove) { + updatePreviewFromHover(static_cast(ev)->pos()); + } + } else if (obj == popup) { + switch (ev->type()) { + case QEvent::Show: + case QEvent::Move: + case QEvent::Resize: + updateOrientation(); + reposition(); + break; + case QEvent::Hide: + hidePreview(); + break; + case QEvent::KeyPress: + if (handlePopupKeyPress(static_cast(ev))) { + return true; + } + break; + default: + break; + } + } + + return QObject::eventFilter(obj, ev); +} + +// --------------------------------------------------------------------------- + +bool CardCompleterStyler::handlePopupKeyPress(QKeyEvent *event) +{ + const int key = event->key(); + if (key != Qt::Key_Up && key != Qt::Key_Down) { + return false; + } + + if (!above) { + return false; + } + + auto *popup = completer->popup(); + const int rowCount = completer->completionModel()->rowCount(); + if (rowCount == 0) { + return true; + } + + // With the popup above the text edit the rows are shown in reverse order and + // the closest match is in the bottom-most row. The up arrow then advances + // through the list (towards its end at the top of the popup). + const int currentRow = popup->currentIndex().row(); + const int step = (key == Qt::Key_Up) ? -1 : 1; + const int newRow = qBound(0, currentRow + step, rowCount - 1); + + if (newRow != currentRow) { + const QModelIndex newIndex = completer->completionModel()->index(newRow, completer->completionColumn()); + popup->selectionModel()->setCurrentIndex(newIndex, + QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); + } + + return true; +} + +// --------------------------------------------------------------------------- + +void CardCompleterStyler::updateOrientation() +{ + above = isPopupAboveWidget(); + reversedModel->setEnabled(above); +} + +// --------------------------------------------------------------------------- + +void CardCompleterStyler::ensureClosestSelected() +{ + auto *popup = completer->popup(); + auto *completionModel = completer->completionModel(); + const int rowCount = completionModel->rowCount(); + if (rowCount == 0) { + return; + } + + const int currentRow = popup->currentIndex().row(); + const int closestRow = rowCount - 1; + + if (currentRow != closestRow) { + popup->selectionModel()->setCurrentIndex(completionModel->index(closestRow, completer->completionColumn()), + QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); + } +} + +// --------------------------------------------------------------------------- + +void CardCompleterStyler::onCompletionReset() +{ + if (above) { + ensureClosestSelected(); + } +} + +// --------------------------------------------------------------------------- + +bool CardCompleterStyler::isPopupAboveWidget() const +{ + auto *popup = completer->popup(); + QWidget *widget = completer->widget(); + + if (!widget || !popup->isVisible()) { + return false; + } + + const QPoint popupBottom = popup->mapToGlobal(QPoint(0, popup->height())); + const QPoint fieldTop = widget->mapToGlobal(QPoint(0, 0)); + + return popupBottom.y() <= fieldTop.y(); +} + +// --------------------------------------------------------------------------- + +void CardCompleterStyler::updatePreviewFromHover(const QPoint &pos) +{ + const QModelIndex index = completer->popup()->indexAt(pos); + + // Hovering updates the preview but must not change the current completion + if (index.isValid() && index != previewedIndex) { + updatePreview(index); + } +} + +// --------------------------------------------------------------------------- + +void CardCompleterStyler::updatePreview(const QModelIndex &index) +{ + previewedIndex = index; + + if (!index.isValid()) { + hidePreview(); + return; + } + + auto card = index.data(CardSearchModel::CardInfoRole).value>(); + + if (!card) { + hidePreview(); + return; + } + + if (!preview) { + QWidget *contextWindow = completer->widget() ? completer->widget()->window() : nullptr; + + preview = new CardInfoPictureEnlargedWidget(contextWindow); + preview->hide(); + preview->setWindowOpacity(0.0); + } + + const ExactCard exact = CardDatabaseManager::query()->getCard({card->getName()}); + + if (!exact) { + hidePreview(); + return; + } + + preview->setCardPixmap(exact, PreviewSize); + + reposition(); + showPreview(); +} + +// --------------------------------------------------------------------------- + +void CardCompleterStyler::showPreview() +{ + if (!preview) { + return; + } + + stopPreviewFade(); + + preview->show(); + preview->raise(); + + auto *fade = new QPropertyAnimation(preview, "windowOpacity", preview); + + fade->setDuration(FadeDuration); + fade->setStartValue(preview->windowOpacity()); + fade->setEndValue(1.0); + fade->start(QAbstractAnimation::DeleteWhenStopped); +} + +// --------------------------------------------------------------------------- + +void CardCompleterStyler::hidePreview() +{ + if (!preview) { + return; + } + + // Stop any in-flight fade first so a stale one cannot keep the preview visible + stopPreviewFade(); + preview->hide(); +} + +// --------------------------------------------------------------------------- + +void CardCompleterStyler::stopPreviewFade() +{ + if (!preview) { + return; + } + + const auto animations = preview->findChildren(); + for (auto *animation : animations) { + animation->stop(); + } +} + +// --------------------------------------------------------------------------- + +void CardCompleterStyler::reposition() +{ + if (!preview) { + return; + } + + auto *popup = completer->popup(); + + const QRect popupGlobalRect(popup->mapToGlobal(popup->rect().topLeft()), popup->rect().size()); + + int anchorY; + + if (previewedIndex.isValid()) { + const QRect itemRect = popup->visualRect(previewedIndex); + const QPoint itemCenter = popup->viewport()->mapToGlobal(itemRect.center()); + anchorY = itemCenter.y(); + } else { + anchorY = popupGlobalRect.center().y(); + } + + const QScreen *screen = popup->screen(); + const QRect screenGeom = screen ? screen->availableGeometry() : QRect(); + + const int rightX = popupGlobalRect.right() + PreviewMargin; + const int leftX = popupGlobalRect.left() - PreviewMargin - preview->width(); + + int x; + int y = anchorY - preview->height() / 2; + + if (screenGeom.isEmpty()) { + x = rightX; + } else if (rightX + preview->width() <= screenGeom.right()) { + x = rightX; + } else if (leftX >= screenGeom.left()) { + x = leftX; + } else { + x = rightX; + } + + if (!screenGeom.isEmpty()) { + x = qBound(screenGeom.left(), x, qMax(screenGeom.left(), screenGeom.right() - preview->width())); + y = qBound(screenGeom.top(), y, qMax(screenGeom.top(), screenGeom.bottom() - preview->height())); + } + + preview->move(x, y); +} diff --git a/cockatrice/src/interface/widgets/utility/card_completer_styler.h b/cockatrice/src/interface/widgets/utility/card_completer_styler.h new file mode 100644 index 000000000..9245c9095 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/card_completer_styler.h @@ -0,0 +1,68 @@ +/** + * @file card_completer_styler.h + * @ingroup UtilityWidgets + */ +//! \todo Document this file. + +#ifndef CARD_COMPLETER_STYLER_H +#define CARD_COMPLETER_STYLER_H + +#include +#include +#include + +class CardInfoPictureEnlargedWidget; +class QCompleter; +class QKeyEvent; +class QPoint; +class ReversedCompleterModel; + +/** + * @brief Applies styled row painting and a card-image preview to a card completer. + * + * The completer popup rows are painted by CardCompleterDelegate and the image of + * the currently selected (or hovered) row is shown beside the popup. + */ +class CardCompleterStyler : public QObject +{ + Q_OBJECT +public: + /** + * @brief Styles @p completer and follows its current selection. + * + * The styler is parented to the completer so it lives exactly as long as the + * completer itself. + */ + static void apply(QCompleter *completer); + +protected: + bool eventFilter(QObject *obj, QEvent *ev) override; + +private slots: + void updatePreview(const QModelIndex &index); + void updatePreviewFromHover(const QPoint &pos); + void onCompletionReset(); + +private: + explicit CardCompleterStyler(QCompleter *completer, QObject *parent = nullptr); + ~CardCompleterStyler() override; + + void showPreview(); + void hidePreview(); + void stopPreviewFade(); + void reposition(); + + void updateOrientation(); + void ensureClosestSelected(); + + bool handlePopupKeyPress(QKeyEvent *event); + bool isPopupAboveWidget() const; + + QCompleter *completer; + ReversedCompleterModel *reversedModel; + CardInfoPictureEnlargedWidget *preview; + QModelIndex previewedIndex; + bool above; +}; + +#endif // CARD_COMPLETER_STYLER_H diff --git a/cockatrice/src/interface/widgets/utility/compact_push_button.cpp b/cockatrice/src/interface/widgets/utility/compact_push_button.cpp index cf98bfede..7df105d15 100644 --- a/cockatrice/src/interface/widgets/utility/compact_push_button.cpp +++ b/cockatrice/src/interface/widgets/utility/compact_push_button.cpp @@ -1,5 +1,9 @@ #include "compact_push_button.h" +#include +#include +#include + CompactPushButton::CompactPushButton(QWidget *parent) : QPushButton(parent) { setCheckable(true); diff --git a/cockatrice/src/interface/widgets/utility/compact_push_button.h b/cockatrice/src/interface/widgets/utility/compact_push_button.h index b0017beee..fa8c8080a 100644 --- a/cockatrice/src/interface/widgets/utility/compact_push_button.h +++ b/cockatrice/src/interface/widgets/utility/compact_push_button.h @@ -1,7 +1,12 @@ #ifndef COCKATRICE_COMPACT_PUSH_BUTTON_H #define COCKATRICE_COMPACT_PUSH_BUTTON_H +#include #include +#include +#include + +class QWidget; class CompactPushButton : public QPushButton { diff --git a/cockatrice/src/interface/widgets/utility/completer_utils.cpp b/cockatrice/src/interface/widgets/utility/completer_utils.cpp new file mode 100644 index 000000000..f1cd9c6a7 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/completer_utils.cpp @@ -0,0 +1,125 @@ +#include "completer_utils.h" + +#include "../../../client/settings/cache_settings.h" +#include "card_completer_styler.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +/** + * @brief Paints the mention popup's current row with a visible highlight. + * + * A completer popup is a transient window that is usually not the application's + * active window, so the style paints a selected row using the palette's + * Inactive highlight group. Most themes set that colour close to the popup + * background, which hides the current completion entirely. Mirror the active + * highlight into the inactive group so the selection stays visible. + */ +class MentionCompleterDelegate : public QStyledItemDelegate +{ +public: + explicit MentionCompleterDelegate(QAbstractItemView *view) : QStyledItemDelegate(view), view(view) + { + } + + void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override + { + QStyleOptionViewItem opt = option; + opt.showDecorationSelected = true; + + if (view->currentIndex() == index) { + opt.state |= QStyle::State_HasFocus; + } + + if (opt.state & QStyle::State_Selected) { + opt.palette.setColor(QPalette::Inactive, QPalette::Highlight, + opt.palette.color(QPalette::Active, QPalette::Highlight)); + opt.palette.setColor(QPalette::Inactive, QPalette::HighlightedText, + opt.palette.color(QPalette::Active, QPalette::HighlightedText)); + } + + QStyledItemDelegate::paint(painter, opt, index); + } + +private: + QAbstractItemView *view; +}; + +void applyCardSearchLanguage(CardSearchModel *searchModel) +{ + const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay(); + searchModel->setSearchLanguage(CardSearchLanguage{ + cardsDisplay.getCardLang(), static_cast(cardsDisplay.getCardSearchLanguage())}); +} +} // namespace + +CardCompleterSetup createCardCompleter(CardDatabaseDisplayModel *displayModel, QObject *parent, int maxVisibleItems) +{ + auto *searchModel = new CardSearchModel(displayModel, parent); + applyCardSearchLanguage(searchModel); + + auto *proxyModel = new CardCompleterProxyModel(parent); + proxyModel->setSourceModel(searchModel); + proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive); + + auto *completer = new QCompleter(proxyModel, parent); + completer->setCompletionRole(Qt::DisplayRole); + completer->setCompletionMode(QCompleter::PopupCompletion); + completer->setCaseSensitivity(Qt::CaseInsensitive); + completer->setFilterMode(Qt::MatchContains); + completer->setMaxVisibleItems(maxVisibleItems); + CardCompleterStyler::apply(completer); + + auto *cardsDisplay = &SettingsCache::instance().cardsDisplay(); + QObject::connect(cardsDisplay, &CardsDisplaySettings::cardLangChanged, searchModel, + [searchModel] { applyCardSearchLanguage(searchModel); }); + QObject::connect(cardsDisplay, &CardsDisplaySettings::cardSearchLanguageChanged, searchModel, + [searchModel] { applyCardSearchLanguage(searchModel); }); + + return {searchModel, proxyModel, completer}; +} + +void connectCardCompleterSearch(QLineEdit *edit, const CardCompleterSetup &setup) +{ + QObject::connect(edit, &QLineEdit::textEdited, setup.searchModel, &CardSearchModel::updateSearchResults); + QObject::connect(edit, &QLineEdit::textEdited, setup.completer, [setup](const QString &text) { + setup.proxyModel->setFilterRegularExpression( + QRegularExpression(QRegularExpression::escape(text), QRegularExpression::CaseInsensitiveOption)); + if (!text.isEmpty()) { + setup.completer->complete(); + } + }); +} + +QCompleter *createMentionCompleter(QStringListModel *model, QObject *parent) +{ + auto *completer = new QCompleter(model, parent); + completer->setCaseSensitivity(Qt::CaseInsensitive); + completer->setMaxVisibleItems(5); + completer->setFilterMode(Qt::MatchStartsWith); + + // Force the popup to be created so the highlight delegate can be installed; + // otherwise QCompleter's own delegate hides the selection on most themes. + QAbstractItemView *popup = completer->popup(); + popup->setItemDelegate(new MentionCompleterDelegate(popup)); + + return completer; +} diff --git a/cockatrice/src/interface/widgets/utility/completer_utils.h b/cockatrice/src/interface/widgets/utility/completer_utils.h new file mode 100644 index 000000000..5d2ddfc30 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/completer_utils.h @@ -0,0 +1,32 @@ +/** + * @file completer_utils.h + * @ingroup UtilityWidgets + */ +//! \todo Document this file. + +#ifndef COMPLETER_UTILS_H +#define COMPLETER_UTILS_H + +class CardCompleterProxyModel; +class CardDatabaseDisplayModel; +class CardSearchModel; +class QCompleter; +class QLineEdit; +class QObject; +class QStringListModel; + +struct CardCompleterSetup +{ + CardSearchModel *searchModel; + CardCompleterProxyModel *proxyModel; + QCompleter *completer; +}; + +CardCompleterSetup +createCardCompleter(CardDatabaseDisplayModel *displayModel, QObject *parent, int maxVisibleItems = 10); + +void connectCardCompleterSearch(QLineEdit *edit, const CardCompleterSetup &setup); + +QCompleter *createMentionCompleter(QStringListModel *model, QObject *parent); + +#endif // CARD_COMPLETER_UTILS_H diff --git a/cockatrice/src/interface/widgets/utility/custom_line_edit.cpp b/cockatrice/src/interface/widgets/utility/custom_line_edit.cpp index 05f56c275..d524041c3 100644 --- a/cockatrice/src/interface/widgets/utility/custom_line_edit.cpp +++ b/cockatrice/src/interface/widgets/utility/custom_line_edit.cpp @@ -3,11 +3,17 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" +#include +#include +#include #include +#include #include -#include +#include +#include #include -#include +#include +#include LineEditUnfocusable::LineEditUnfocusable(QWidget *parent) : QLineEdit(parent) { diff --git a/cockatrice/src/interface/widgets/utility/custom_line_edit.h b/cockatrice/src/interface/widgets/utility/custom_line_edit.h index 8c389b797..4d256d3dc 100644 --- a/cockatrice/src/interface/widgets/utility/custom_line_edit.h +++ b/cockatrice/src/interface/widgets/utility/custom_line_edit.h @@ -8,6 +8,7 @@ #define CUSTOMLINEEDIT_H #include +#include class QTreeView; class QKeyEvent; diff --git a/cockatrice/src/interface/widgets/utility/get_text_with_max.cpp b/cockatrice/src/interface/widgets/utility/get_text_with_max.cpp index 134352eb3..1a1bc924d 100644 --- a/cockatrice/src/interface/widgets/utility/get_text_with_max.cpp +++ b/cockatrice/src/interface/widgets/utility/get_text_with_max.cpp @@ -2,6 +2,8 @@ #include +class QWidget; + QString getTextWithMax(QWidget *parent, const QString &title, const QString &label, diff --git a/cockatrice/src/interface/widgets/utility/get_text_with_max.h b/cockatrice/src/interface/widgets/utility/get_text_with_max.h index 424bb0c6a..f1c89c703 100644 --- a/cockatrice/src/interface/widgets/utility/get_text_with_max.h +++ b/cockatrice/src/interface/widgets/utility/get_text_with_max.h @@ -8,8 +8,11 @@ #define GETTEXTWITHMAX_H #include -#include +#include #include +#include + +class QWidget; QString getTextWithMax(QWidget *parent, const QString &title, diff --git a/cockatrice/src/interface/widgets/utility/line_edit_completer.cpp b/cockatrice/src/interface/widgets/utility/line_edit_completer.cpp index 13f475a61..056887f1a 100644 --- a/cockatrice/src/interface/widgets/utility/line_edit_completer.cpp +++ b/cockatrice/src/interface/widgets/utility/line_edit_completer.cpp @@ -1,135 +1,263 @@ #include "line_edit_completer.h" +#include #include +#include #include #include -#include -#include -#include -#include +#include +#include +#include +#include +#include +#include -LineEditCompleter::LineEditCompleter(QWidget *parent) : LineEditUnfocusable(parent), c(nullptr) +class QWidget; + +LineEditCompleter::LineEditCompleter(QWidget *parent) : LineEditUnfocusable(parent) { } +void LineEditCompleter::addCompleter(QCompleter *c, CompleterTrigger trigger) +{ + c->setWidget(this); + c->setCompletionMode(QCompleter::PopupCompletion); + c->setCaseSensitivity(Qt::CaseInsensitive); + connect(c, qOverload(&QCompleter::activated), this, + qOverload(&LineEditCompleter::insertCompletion)); + + completers.append({c, trigger}); +} + +bool LineEditCompleter::hasVisibleCompleterPopup() const +{ + for (const auto &info : completers) { + if (info.completer->popup()->isVisible()) { + return true; + } + } + return false; +} + +void LineEditCompleter::hideCompleterPopups() +{ + for (const auto &info : completers) { + info.completer->popup()->hide(); + } +} + void LineEditCompleter::focusOutEvent(QFocusEvent *e) { LineEditUnfocusable::focusOutEvent(e); - if (c->popup()->isVisible()) { - // Remove Popup - c->popup()->hide(); - // Truncate the line to last space or whole string - QString textValue = text(); - int lastIndex = textValue.length(); - int lastWordStartIndex = textValue.lastIndexOf(" ") + 1; - int leftShift = qMin(lastIndex, lastWordStartIndex); - setText(textValue.left(leftShift)); - // Insert highlighted line from popup - insert(c->completionModel()->index(c->popup()->currentIndex().row(), 0).data().toString() + " "); - // Set focus back to the textbox since tab was pressed - setFocus(); + + // Commit completion when focus moves away via Tab if popup is visible. + // Other focus losses (e.g. the unfocus shortcut / Escape) must simply close + // the popup without inserting anything. + bool popupWasVisible = false; + for (auto &info : completers) { + if (info.completer->popup()->isVisible()) { + popupWasVisible = true; + const QModelIndex currentIndex = info.completer->popup()->currentIndex(); + QString completionText; + if (currentIndex.isValid()) { + completionText = currentIndex.data().toString(); + } else { + completionText = info.completer->currentCompletion(); + } + if (!completionText.isEmpty()) { + insertCompletion(info.completer, completionText); + } + } + } + hideCompleterPopups(); + + if (popupWasVisible && e->reason() == Qt::TabFocusReason) { + // Refocus the edit so Tab doesn't move focus away + // Use a queued connection to ensure focus is restored after the focus change completes + QMetaObject::invokeMethod(this, "setFocus", Qt::QueuedConnection); + e->accept(); + return; + } + + if (popupWasVisible) { + return; } } void LineEditCompleter::keyPressEvent(QKeyEvent *event) { - switch (event->key()) { - case Qt::Key_Return: - case Qt::Key_Enter: - case Qt::Key_Escape: - if (c->popup()->isVisible()) { + if (event->key() == Qt::Key_Escape) { + if (hasVisibleCompleterPopup()) { + event->ignore(); + hideCompleterPopups(); + return; + } + LineEditUnfocusable::keyPressEvent(event); + return; + } + + if (event->key() == Qt::Key_Return || event->key() == Qt::Key_Enter || event->key() == Qt::Key_Space) { + for (auto &info : completers) { + if (info.completer->popup()->isVisible()) { event->ignore(); - // Remove Popup - c->popup()->hide(); - // Truncate the line to last space or whole string - QString textValue = text(); - int lastIndexof = qMax(0, textValue.lastIndexOf(" ")); - QString finalString = textValue.left(lastIndexof); - // Add a space if there's a word - if (finalString != "") { - finalString += " "; + const QModelIndex currentIndex = info.completer->popup()->currentIndex(); + QString completionText; + if (currentIndex.isValid()) { + completionText = currentIndex.data().toString(); + } else { + completionText = info.completer->currentCompletion(); } - setText(finalString); + if (!completionText.isEmpty()) { + insertCompletion(info.completer, completionText); + } + hideCompleterPopups(); return; } - break; - case Qt::Key_Space: - if (c->popup()->isVisible()) { - event->ignore(); - // Remove Popup - c->popup()->hide(); - // Truncate the line to last space or whole string - QString textValue = text(); - int lastIndex = textValue.length(); - int lastWordStartIndex = textValue.lastIndexOf(" ") + 1; - int leftShift = qMin(lastIndex, lastWordStartIndex); - setText(textValue.left(leftShift)); - // Insert highlighted line from popup - insert(c->completionModel()->index(c->popup()->currentIndex().row(), 0).data().toString() + " "); - return; - } - break; - default: - break; + } + } + + if (event->key() == Qt::Key_Tab) { + // Let Tab proceed normally; focusOutEvent will handle completion if popup is visible + LineEditUnfocusable::keyPressEvent(event); + return; } LineEditUnfocusable::keyPressEvent(event); - // return if the completer is null or if the most recently typed char was '@'. - // Only want the popup AFTER typing the first char of the mention. - if (!c || text().right(1).contains("@")) { - c->popup()->hide(); + + QString textValue = text(); + int cursorPos = cursorPosition(); + + CompleterInfo *active = nullptr; + QString prefix; + + for (auto &info : completers) { + bool triggered = false; + switch (info.trigger) { + case CompleterTrigger::Mention: { + int triggerPos = textValue.lastIndexOf("@", cursorPos - 1); + if (triggerPos != -1 && (triggerPos == 0 || textValue[triggerPos - 1].isSpace())) { + triggered = true; + // Keep the "@" so the prefix matches the "@"-prefixed mention model entries. + prefix = textValue.mid(triggerPos, cursorPos - triggerPos); + } + break; + } + case CompleterTrigger::Card: { + int triggerPos = textValue.lastIndexOf("[[", cursorPos - 1); + int closePos = textValue.indexOf("]]", triggerPos + 2); + if (triggerPos != -1 && (closePos == -1 || closePos >= cursorPos)) { + triggered = true; + prefix = textValue.mid(triggerPos + 2, cursorPos - (triggerPos + 2)); + } + break; + } + } + + if (triggered) { + active = &info; + break; + } + } + + if (!active) { + for (auto &info : completers) { + info.completer->popup()->hide(); + } return; } - // Set new completion prefix - c->setCompletionPrefix(cursorWord(text())); - if (c->completionPrefix().length() < 1) { - c->popup()->hide(); - return; + active->completer->setCompletionPrefix(prefix); + + switch (active->trigger) { + case CompleterTrigger::Card: + emit cardPartialChanged(prefix); + return; + case CompleterTrigger::Mention: + break; } - // Draw completion box - QRect cr = cursorRect(); - cr.setWidth(c->popup()->sizeHintForColumn(0) + c->popup()->verticalScrollBar()->sizeHint().width()); - c->complete(cr); + active->completer->complete(); - // Select first item in the completion popup - QItemSelectionModel *sm = new QItemSelectionModel(c->completionModel()); - c->popup()->setSelectionModel(sm); - sm->select(c->completionModel()->index(0, 0), QItemSelectionModel::ClearAndSelect); - sm->setCurrentIndex(c->completionModel()->index(0, 0), QItemSelectionModel::NoUpdate); -} - -QString LineEditCompleter::cursorWord(const QString &line) const -{ - return line.mid(line.left(cursorPosition()).lastIndexOf(" ") + 1, - cursorPosition() - line.left(cursorPosition()).lastIndexOf(" ") - 1); -} - -void LineEditCompleter::insertCompletion(QString arg) -{ - QString s_arg = arg + " "; - setText(text().replace(text().left(cursorPosition()).lastIndexOf(" ") + 1, - cursorPosition() - text().left(cursorPosition()).lastIndexOf(" ") - 1, s_arg)); -} - -void LineEditCompleter::setCompleter(QCompleter *completer) -{ - c = completer; - c->setWidget(this); - connect(c, qOverload(&QCompleter::activated), this, &LineEditCompleter::insertCompletion); -} - -void LineEditCompleter::setCompletionList(QStringList completionList) -{ - if (!c || c->popup()->isVisible()) { - return; + // The popup opens without a current row, so nothing is highlighted and there + // is no visual indication of what Enter will insert. Select the row that + // currentCompletion() would use so the current mention stays visible. + auto *completionModel = active->completer->completionModel(); + const int currentRow = active->completer->currentRow(); + if (currentRow >= 0 && currentRow < completionModel->rowCount()) { + active->completer->popup()->selectionModel()->setCurrentIndex( + completionModel->index(currentRow, active->completer->completionColumn()), + QItemSelectionModel::ClearAndSelect | QItemSelectionModel::Rows); + } +} + +void LineEditCompleter::insertCompletion(const QString &completion) +{ + for (auto &info : completers) { + if (info.completer == sender()) { + insertCompletion(info.completer, completion); + return; + } + } +} + +void LineEditCompleter::insertCompletion(QCompleter *completer, const QString &completion) +{ + QString t = text(); + int pos = cursorPosition(); + + for (const auto &info : completers) { + if (info.completer != completer) { + continue; + } + + switch (info.trigger) { + case CompleterTrigger::Card: { + int triggerPos = t.lastIndexOf("[[", pos - 1); + if (triggerPos == -1) { + return; + } + + // If an earlier "[[" is still open it also encloses the cursor, so + // replace from its start. Otherwise completing in text such as + // "[[Opt[[Amok" would leave a stray "[[" behind. + int startPos = triggerPos; + for (int searchFrom = triggerPos; searchFrom > 0;) { + const int earlier = t.lastIndexOf("[[", searchFrom - 1); + if (earlier == -1) { + break; + } + const int earlierClose = t.indexOf("]]", earlier + 2); + if (earlierClose != -1 && earlierClose < pos) { + break; + } + startPos = earlier; + searchFrom = earlier; + } + + // If the cursor sits inside an already-closed [[...]] pair, replace + // the whole construct instead of leaving a duplicate closing bracket + // behind. + int insertEnd = pos; + const int closePos = t.indexOf("]]", startPos + 2); + if (closePos != -1 && closePos >= pos) { + insertEnd = closePos + 2; + } + + QString after = t.mid(insertEnd); + QString replaced = t.left(startPos + 2) + completion + "]] "; + setText(replaced + after); + setCursorPosition(replaced.length()); + return; + } + case CompleterTrigger::Mention: { + int triggerPos = t.lastIndexOf("@", pos - 1); + if (triggerPos == -1) { + return; + } + setText(t.replace(triggerPos, pos - triggerPos, completion + " ")); + setCursorPosition(triggerPos + completion.length() + 1); + return; + } + } } - - QStringListModel *model; - model = (QStringListModel *)(c->model()); - if (model == NULL) { - model = new QStringListModel(); - } - model->setStringList(completionList); } diff --git a/cockatrice/src/interface/widgets/utility/line_edit_completer.h b/cockatrice/src/interface/widgets/utility/line_edit_completer.h index 65fa382ac..c141682bf 100644 --- a/cockatrice/src/interface/widgets/utility/line_edit_completer.h +++ b/cockatrice/src/interface/widgets/utility/line_edit_completer.h @@ -1,6 +1,7 @@ /** * @file line_edit_completer.h * @ingroup UI + * @brief Line edit with support for multiple trigger-based completers, e.g., @mention and [[card]]. */ //! \todo Document this file. @@ -9,25 +10,49 @@ #include "custom_line_edit.h" -#include +#include +#include +#include + +class QCompleter; +class QWidget; + +enum class CompleterTrigger +{ + Mention, //< "@" prefix, replaced in place with a trailing space. + Card //< "[[" prefix, replaced with the completion plus a closing "]] ". +}; + +struct CompleterInfo +{ + QCompleter *completer; + CompleterTrigger trigger; +}; class LineEditCompleter : public LineEditUnfocusable { Q_OBJECT +signals: + void cardPartialChanged(const QString &partial); + private: - QString cursorWord(const QString &line) const; - QCompleter *c; + QList completers; + + void insertCompletion(QCompleter *completer, const QString &completion); + private slots: - void insertCompletion(QString); + void insertCompletion(const QString &text); protected: - void keyPressEvent(QKeyEvent *event); - void focusOutEvent(QFocusEvent *e); + void keyPressEvent(QKeyEvent *event) override; + void focusOutEvent(QFocusEvent *e) override; public: explicit LineEditCompleter(QWidget *parent = nullptr); - void setCompleter(QCompleter *); - void setCompletionList(QStringList); + void addCompleter(QCompleter *c, CompleterTrigger trigger); + + bool hasVisibleCompleterPopup() const; + void hideCompleterPopups(); }; #endif diff --git a/cockatrice/src/interface/widgets/utility/report_utils.cpp b/cockatrice/src/interface/widgets/utility/report_utils.cpp new file mode 100644 index 000000000..a581c9837 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/report_utils.cpp @@ -0,0 +1,127 @@ +#include "report_utils.h" + +#include "libcockatrice/protocol/pb/serverinfo_report.pb.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace report_utils +{ + +namespace +{ +QColor reportStatusColor(const QString &status) +{ + const QColor text = qApp->palette().color(QPalette::Text); + const int luminance = (299 * text.red() + 587 * text.green() + 114 * text.blue()) / 1000; + const bool dark = luminance < 128; + + if (status == "open") { + return dark ? QColor("#e06060") : QColor("#c0392b"); + } + if (status == "assigned") { + return dark ? QColor("#e0a030") : QColor("#d68910"); + } + if (status == "resolved") { + return dark ? QColor("#50c878") : QColor("#1e8449"); + } + if (status == "dismissed") { + return qApp->palette().color(QPalette::PlaceholderText); + } + return QColor(); +} + +void setReportStatusItemColor(QTableWidgetItem *item, const QString &status) +{ + const QColor color = reportStatusColor(status); + if (color.isValid()) { + item->setForeground(QBrush(color)); + } +} +} // namespace + +QString formatReportCategory(const QString &category) +{ + QString result = category; + result.replace('_', ' '); + return result.left(1).toUpper() + result.mid(1); +} + +QString formatReportTime(qint64 secondsSinceEpoch) +{ + const QDateTime dt = QDateTime::fromSecsSinceEpoch(secondsSinceEpoch); + return dt.toString("yyyy-MM-dd hh:mm"); +} + +void fillReportTableRow(QTableWidget *table, + int row, + const ServerInfo_Report &report, + int idColumn, + int timeColumn, + int reportedColumn, + int categoryColumn, + int gameIdColumn, + int statusColumn, + int assignedColumn) +{ + auto *idItem = new QTableWidgetItem(QString::number(report.report_id())); + idItem->setData(Qt::UserRole, report.report_id()); + table->setItem(row, idColumn, idItem); + + table->setItem(row, timeColumn, new QTableWidgetItem(formatReportTime(report.report_time()))); + table->setItem(row, reportedColumn, new QTableWidgetItem(QString::fromStdString(report.reported_user_name()))); + table->setItem(row, categoryColumn, + new QTableWidgetItem(formatReportCategory(QString::fromStdString(report.category())))); + table->setItem(row, gameIdColumn, + new QTableWidgetItem(report.game_id() > 0 ? QString::number(report.game_id()) : QString())); + + auto *statusItem = new QTableWidgetItem(QString::fromStdString(report.status())); + setReportStatusItemColor(statusItem, QString::fromStdString(report.status())); + table->setItem(row, statusColumn, statusItem); + + table->setItem(row, assignedColumn, new QTableWidgetItem(QString::fromStdString(report.assigned_mod_name()))); +} + +void renderReportDetails(QTextEdit *chatLogEdit, + QTextEdit *commentsEdit, + const ServerInfo_Report &report, + const QString &commentsEmptyText, + const QString &moderatorPrefix, + const QString &nonModeratorPrefix) +{ + if (report.has_chat_log() && !report.chat_log().empty()) { + chatLogEdit->setPlainText(QString::fromStdString(report.chat_log())); + } else { + chatLogEdit->clear(); + } + + commentsEdit->clear(); + + if (report.comments_size() == 0) { + commentsEdit->setPlainText(commentsEmptyText); + return; + } + + for (int i = 0; i < report.comments_size(); ++i) { + const ServerInfo_ReportComment &c = report.comments(i); + const QString author = QString::fromStdString(c.author_name()); + const QString text = QString::fromStdString(c.comment_text()); + const QString prefix = c.is_moderator() ? moderatorPrefix : nonModeratorPrefix; + commentsEdit->moveCursor(QTextCursor::End); + commentsEdit->insertPlainText( + QString("[%1] %2 %3:\n%4\n\n").arg(formatReportTime(c.comment_time()), prefix, author, text)); + } +} + +} // namespace report_utils diff --git a/cockatrice/src/interface/widgets/utility/report_utils.h b/cockatrice/src/interface/widgets/utility/report_utils.h new file mode 100644 index 000000000..679989486 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/report_utils.h @@ -0,0 +1,37 @@ +#ifndef REPORT_UTILS_H +#define REPORT_UTILS_H + +#include +#include + +class QTableWidget; +class QTextEdit; +class ServerInfo_Report; + +namespace report_utils +{ + +QString formatReportCategory(const QString &category); +QString formatReportTime(qint64 secondsSinceEpoch); + +void fillReportTableRow(QTableWidget *table, + int row, + const ServerInfo_Report &report, + int idColumn, + int timeColumn, + int reportedColumn, + int categoryColumn, + int gameIdColumn, + int statusColumn, + int assignedColumn); + +void renderReportDetails(QTextEdit *chatLogEdit, + QTextEdit *commentsEdit, + const ServerInfo_Report &report, + const QString &commentsEmptyText, + const QString &moderatorPrefix, + const QString &nonModeratorPrefix); + +} // namespace report_utils + +#endif // REPORT_UTILS_H diff --git a/cockatrice/src/interface/widgets/utility/reversed_completer_model.cpp b/cockatrice/src/interface/widgets/utility/reversed_completer_model.cpp new file mode 100644 index 000000000..3702798fd --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/reversed_completer_model.cpp @@ -0,0 +1,105 @@ +#include "reversed_completer_model.h" + +#include +#include +#include +#include +#include +#include + +void ReversedCompleterModel::setSourceModel(QAbstractItemModel *sourceModel) +{ + if (sourceModel == this->sourceModel()) { + return; + } + + if (QAbstractItemModel *old = this->sourceModel()) { + disconnect(old, nullptr, this, nullptr); + } + + QAbstractProxyModel::setSourceModel(sourceModel); + + if (sourceModel) { + connect(sourceModel, &QAbstractItemModel::modelReset, this, &ReversedCompleterModel::invalidate); + connect(sourceModel, &QAbstractItemModel::rowsInserted, this, &ReversedCompleterModel::invalidate); + connect(sourceModel, &QAbstractItemModel::rowsRemoved, this, &ReversedCompleterModel::invalidate); + connect(sourceModel, &QAbstractItemModel::rowsMoved, this, &ReversedCompleterModel::invalidate); + connect(sourceModel, &QAbstractItemModel::columnsInserted, this, &ReversedCompleterModel::invalidate); + connect(sourceModel, &QAbstractItemModel::columnsRemoved, this, &ReversedCompleterModel::invalidate); + connect(sourceModel, &QAbstractItemModel::dataChanged, this, &ReversedCompleterModel::invalidate); + connect(sourceModel, &QAbstractItemModel::layoutChanged, this, &ReversedCompleterModel::invalidate); + } + + invalidate(); +} + +void ReversedCompleterModel::setEnabled(bool enabled) +{ + if (enabled == isEnabled) { + return; + } + isEnabled = enabled; + invalidate(); +} + +QModelIndex ReversedCompleterModel::mapToSource(const QModelIndex &proxyIndex) const +{ + if (!proxyIndex.isValid() || !sourceModel()) { + return {}; + } + const int sourceRow = isEnabled ? sourceRowCount() - 1 - proxyIndex.row() : proxyIndex.row(); + return sourceModel()->index(sourceRow, proxyIndex.column()); +} + +QModelIndex ReversedCompleterModel::mapFromSource(const QModelIndex &sourceIndex) const +{ + if (!sourceIndex.isValid() || !sourceModel()) { + return {}; + } + const int proxyRow = isEnabled ? sourceRowCount() - 1 - sourceIndex.row() : sourceIndex.row(); + return index(proxyRow, sourceIndex.column()); +} + +QModelIndex ReversedCompleterModel::index(int row, int column, const QModelIndex &parent) const +{ + if (parent.isValid() || !sourceModel() || row < 0 || row >= rowCount() || column < 0 || column >= columnCount()) { + return {}; + } + return createIndex(row, column); +} + +QModelIndex ReversedCompleterModel::parent(const QModelIndex &) const +{ + return {}; +} + +int ReversedCompleterModel::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() || !sourceModel() ? 0 : sourceModel()->rowCount(); +} + +int ReversedCompleterModel::columnCount(const QModelIndex &parent) const +{ + return parent.isValid() || !sourceModel() ? 0 : sourceModel()->columnCount(); +} + +QVariant ReversedCompleterModel::data(const QModelIndex &proxyIndex, int role) const +{ + return sourceModel() ? sourceModel()->data(mapToSource(proxyIndex), role) : QVariant(); +} + +QVariant ReversedCompleterModel::headerData(int section, Qt::Orientation orientation, int role) const +{ + return sourceModel() ? sourceModel()->headerData(section, orientation, role) : QVariant(); +} + +int ReversedCompleterModel::sourceRowCount() const +{ + return sourceModel() ? sourceModel()->rowCount() : 0; +} + +void ReversedCompleterModel::invalidate() +{ + beginResetModel(); + endResetModel(); +} diff --git a/cockatrice/src/interface/widgets/utility/reversed_completer_model.h b/cockatrice/src/interface/widgets/utility/reversed_completer_model.h new file mode 100644 index 000000000..cf4b44974 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/reversed_completer_model.h @@ -0,0 +1,47 @@ +/** + * @file reversed_completer_model.h + * @ingroup UtilityWidgets + */ +//! \todo Document this file. + +#ifndef REVERSED_COMPLETER_MODEL_H +#define REVERSED_COMPLETER_MODEL_H + +#include +#include +#include + +/** + * @brief A completer model that can present its rows bottom-to-top. + * + * The original row order is kept intact in the source model (row 0 is always the + * closest match). When enabled, the proxy maps the source rows in reverse so the + * popup shows the closest match in the row nearest to the text edit. Any change + * in the source model is forwarded as a full reset, which is all QCompleter + * needs to rebuild its completion list. + */ +class ReversedCompleterModel : public QAbstractProxyModel +{ +public: + using QAbstractProxyModel::QAbstractProxyModel; + + void setSourceModel(QAbstractItemModel *sourceModel) override; + void setEnabled(bool enabled); + + QModelIndex mapToSource(const QModelIndex &proxyIndex) const override; + QModelIndex mapFromSource(const QModelIndex &sourceIndex) const override; + QModelIndex index(int row, int column, const QModelIndex &parent = {}) const override; + QModelIndex parent(const QModelIndex &) const override; + int rowCount(const QModelIndex &parent = {}) const override; + int columnCount(const QModelIndex &parent = {}) const override; + QVariant data(const QModelIndex &proxyIndex, int role = Qt::DisplayRole) const override; + QVariant headerData(int section, Qt::Orientation orientation, int role = Qt::DisplayRole) const override; + +private: + int sourceRowCount() const; + void invalidate(); + + bool isEnabled = false; +}; + +#endif // REVERSED_COMPLETER_MODEL_H diff --git a/cockatrice/src/interface/widgets/utility/sequence_edit.cpp b/cockatrice/src/interface/widgets/utility/sequence_edit.cpp index c6bf289ba..e92287c9a 100644 --- a/cockatrice/src/interface/widgets/utility/sequence_edit.cpp +++ b/cockatrice/src/interface/widgets/utility/sequence_edit.cpp @@ -2,9 +2,18 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.h" +#include "../../pixel_map_generator.h" +#include +#include +#include #include #include +#include +#include +#include +#include +#include #include SequenceEdit::SequenceEdit(const QString &_shortcutName, QWidget *parent) : QWidget(parent) @@ -14,8 +23,8 @@ SequenceEdit::SequenceEdit(const QString &_shortcutName, QWidget *parent) : QWid defaultButton = new QPushButton("", this); lineEdit->setMinimumWidth(70); - clearButton->setIcon(QPixmap("theme:icons/clearsearch")); - defaultButton->setIcon(QPixmap("theme:icons/update")); + clearButton->setIcon(themePixmap(QStringLiteral("icons/clearsearch"))); + defaultButton->setIcon(themePixmap(QStringLiteral("icons/update"))); auto *layout = new QHBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); diff --git a/cockatrice/src/interface/widgets/utility/sequence_edit.h b/cockatrice/src/interface/widgets/utility/sequence_edit.h index 28e18d42f..0cdd48ceb 100644 --- a/cockatrice/src/interface/widgets/utility/sequence_edit.h +++ b/cockatrice/src/interface/widgets/utility/sequence_edit.h @@ -7,10 +7,15 @@ #ifndef SEQUENCEEDIT_H #define SEQUENCEEDIT_H -#include -#include -#include +#include +#include #include +#include +#include + +class QKeyEvent; +class QLineEdit; +class QPushButton; class SequenceEdit : public QWidget { diff --git a/cockatrice/src/interface/widgets/utility/visibility_change_listener.h b/cockatrice/src/interface/widgets/utility/visibility_change_listener.h index 10928caf6..f154918d6 100644 --- a/cockatrice/src/interface/widgets/utility/visibility_change_listener.h +++ b/cockatrice/src/interface/widgets/utility/visibility_change_listener.h @@ -2,6 +2,9 @@ #define COCKATRICE_VISIBILITY_LISTENER_H #include +#include + +class QWidget; /** * @brief This filter listens to the visibility changes of a target widget, emitting signals whenever the visibility of 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 57c6da762..cf815ace0 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 @@ -1,8 +1,19 @@ #include "visual_database_display_color_filter_widget.h" #include "../cards/additional_info/mana_symbol_widget.h" +#include "filter_tree_model.h" +#include "libcockatrice/filters/filter_card.h" +#include +#include +#include +#include +#include +#include +#include #include +#include +#include #include /** 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 497d25fce..38812b02a 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 @@ -7,11 +7,16 @@ #ifndef VISUAL_DATABASE_DISPLAY_COLOR_FILTER_WIDGET_H #define VISUAL_DATABASE_DISPLAY_COLOR_FILTER_WIDGET_H -#include "../../../filters/filter_tree_model.h" - -#include -#include +#include +#include +#include +#include #include +#include + +class FilterTreeModel; +class QComboBox; +class QHBoxLayout; class VisualDatabaseDisplayColorFilterCircleWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_save_load_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_save_load_widget.cpp index 76a611d52..fd005b315 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_save_load_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_save_load_widget.cpp @@ -1,13 +1,28 @@ #include "visual_database_display_filter_save_load_widget.h" #include "../../../client/settings/cache_settings.h" +#include "../general/layout_containers/flow_widget.h" +#include "filter_tree_model.h" +#include "libcockatrice/filters/filter_card.h" #include "visual_database_filter_display_widget.h" -#include +#include +#include +#include +#include +#include #include #include +#include +#include +#include +#include +#include +#include +#include #include #include +#include VisualDatabaseDisplayFilterSaveLoadWidget::VisualDatabaseDisplayFilterSaveLoadWidget(QWidget *parent, FilterTreeModel *_filterModel) diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_save_load_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_save_load_widget.h index 459633e42..ea8a0ed6b 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_save_load_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_save_load_widget.h @@ -7,17 +7,19 @@ #ifndef VISUAL_DATABASE_DISPLAY_FILTER_SAVE_LOAD_WIDGET_H #define VISUAL_DATABASE_DISPLAY_FILTER_SAVE_LOAD_WIDGET_H -#include "../../../filters/filter_tree_model.h" -#include "../general/layout_containers/flow_widget.h" - -#include -#include -#include -#include +#include #include -#include -#include +#include +#include +#include #include +#include + +class FilterTreeModel; +class FlowWidget; +class QLineEdit; +class QPushButton; +class QVBoxLayout; class VisualDatabaseDisplayFilterSaveLoadWidget : public QWidget { 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 62e1bf5ba..3e9d9e525 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,9 +1,37 @@ #include "visual_database_display_filter_toolbar_widget.h" +#include "../../pixel_map_generator.h" #include "../deck_editor/card_database_view.h" +#include "../quick_settings/settings_button_widget.h" +#include "card_database_display_model.h" +#include "visual_database_display_filter_save_load_widget.h" +#include "visual_database_display_format_legality_filter_widget.h" +#include "visual_database_display_main_type_filter_widget.h" +#include "visual_database_display_name_filter_widget.h" +#include "visual_database_display_set_filter_widget.h" +#include "visual_database_display_sub_type_filter_widget.h" #include "visual_database_display_widget.h" +#include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class DeckListModel; VisualDatabaseDisplayFilterToolbarWidget::VisualDatabaseDisplayFilterToolbarWidget(VisualDatabaseDisplayWidget *_parent, DeckListModel *deckListModel) @@ -60,39 +88,36 @@ VisualDatabaseDisplayFilterToolbarWidget::VisualDatabaseDisplayFilterToolbarWidg }); quickFilterSaveLoadWidget = new SettingsButtonWidget(this); - quickFilterSaveLoadWidget->setButtonIcon(QPixmap("theme:icons/floppy_disk")); + quickFilterSaveLoadWidget->setButtonIcon(themePixmap(QStringLiteral("icons/floppy_disk"))); quickFilterNameWidget = new SettingsButtonWidget(this); - quickFilterNameWidget->setButtonIcon(QPixmap("theme:icons/pen_to_square")); + quickFilterNameWidget->setButtonIcon(themePixmap(QStringLiteral("icons/pen_to_square"))); quickFilterMainTypeWidget = new SettingsButtonWidget(this); - quickFilterMainTypeWidget->setButtonIcon(QPixmap("theme:icons/circle_half_stroke")); + quickFilterMainTypeWidget->setButtonIcon(themePixmap(QStringLiteral("icons/circle_half_stroke"))); quickFilterSubTypeWidget = new SettingsButtonWidget(this); - quickFilterSubTypeWidget->setButtonIcon(QPixmap("theme:icons/dragon")); + quickFilterSubTypeWidget->setButtonIcon(themePixmap(QStringLiteral("icons/dragon"))); quickFilterSetWidget = new SettingsButtonWidget(this); - quickFilterSetWidget->setButtonIcon(QPixmap("theme:icons/scroll")); + quickFilterSetWidget->setButtonIcon(themePixmap(QStringLiteral("icons/scroll"))); quickFilterFormatLegalityWidget = new SettingsButtonWidget(this); - quickFilterFormatLegalityWidget->setButtonIcon(QPixmap("theme:icons/scale_balanced")); + quickFilterFormatLegalityWidget->setButtonIcon(themePixmap(QStringLiteral("icons/scale_balanced"))); retranslateUi(); } void VisualDatabaseDisplayFilterToolbarWidget::initialize() { - // create groupbox layouts - auto sortLayout = new QHBoxLayout(this); + auto sortLayout = new QHBoxLayout(sortGroupBox); sortLayout->setContentsMargins(0, 0, 0, 0); sortLayout->setSpacing(0); - sortGroupBox->setLayout(sortLayout); sortLayout->setAlignment(Qt::AlignLeft); - auto filterLayout = new QHBoxLayout(this); + auto filterLayout = new QHBoxLayout(filterGroupBox); filterLayout->setContentsMargins(0, 0, 0, 0); filterLayout->setSpacing(2); - filterGroupBox->setLayout(filterLayout); filterLayout->setAlignment(Qt::AlignLeft); // create settings widgets diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.h index f7fa92c8e..cdb4fd44d 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_filter_toolbar_widget.h @@ -1,14 +1,22 @@ #ifndef COCKATRICE_VISUAL_DATABASE_DISPLAY_FILTER_TOOLBAR_WIDGET_H #define COCKATRICE_VISUAL_DATABASE_DISPLAY_FILTER_TOOLBAR_WIDGET_H -#include "visual_database_display_filter_save_load_widget.h" -#include "visual_database_display_format_legality_filter_widget.h" -#include "visual_database_display_main_type_filter_widget.h" -#include "visual_database_display_name_filter_widget.h" -#include "visual_database_display_set_filter_widget.h" -#include "visual_database_display_sub_type_filter_widget.h" +#include "../general/layout_containers/flow_widget.h" + +#include class VisualDatabaseDisplayWidget; +class DeckListModel; +class QComboBox; +class QGroupBox; +class QLabel; +class SettingsButtonWidget; +class VisualDatabaseDisplayFilterSaveLoadWidget; +class VisualDatabaseDisplayFormatLegalityFilterWidget; +class VisualDatabaseDisplayMainTypeFilterWidget; +class VisualDatabaseDisplayNameFilterWidget; +class VisualDatabaseDisplaySetFilterWidget; +class VisualDatabaseDisplaySubTypeFilterWidget; class VisualDatabaseDisplayFilterToolbarWidget : public FlowWidget { diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.cpp index 633f07af7..a5725ff3f 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.cpp @@ -1,14 +1,29 @@ #include "visual_database_display_format_legality_filter_widget.h" #include "../../../filters/filter_tree_model.h" +#include "../general/layout_containers/flow_widget.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/filters/filter_card.h" #include "visual_database_display_filter_button.h" +#include +#include +#include #include +#include +#include #include +#include +#include #include #include +#include +#include +#include #include #include +#include +#include VisualDatabaseDisplayFormatLegalityFilterWidget::VisualDatabaseDisplayFormatLegalityFilterWidget( QWidget *parent, diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.h index 1b1a1382e..e94593e67 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_format_legality_filter_widget.h @@ -1,16 +1,17 @@ #ifndef COCKATRICE_VISUAL_DATABASE_DISPLAY_FORMAT_LEGALITY_FILTER_WIDGET_H #define COCKATRICE_VISUAL_DATABASE_DISPLAY_FORMAT_LEGALITY_FILTER_WIDGET_H -#include "../../../filters/filter_tree_model.h" -#include "../general/layout_containers/flow_widget.h" - -#include #include -#include -#include -#include -#include +#include #include +#include + +class FilterTreeModel; +class FlowWidget; +class QLabel; +class QPushButton; +class QSpinBox; +class QVBoxLayout; class VisualDatabaseDisplayFormatLegalityFilterWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.cpp index c44489c1b..eff0fe10b 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.cpp @@ -1,14 +1,27 @@ #include "visual_database_display_main_type_filter_widget.h" #include "../../../filters/filter_tree_model.h" +#include "../general/layout_containers/flow_widget.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/filters/filter_card.h" #include "visual_database_display_filter_button.h" +#include +#include +#include #include +#include +#include #include +#include +#include #include #include +#include +#include #include #include +#include VisualDatabaseDisplayMainTypeFilterWidget::VisualDatabaseDisplayMainTypeFilterWidget(QWidget *parent, FilterTreeModel *_filterModel) diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.h index 6dec58319..b5a6f332c 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_main_type_filter_widget.h @@ -7,15 +7,17 @@ #ifndef VISUAL_DATABASE_DISPLAY_MAIN_TYPE_FILTER_WIDGET_H #define VISUAL_DATABASE_DISPLAY_MAIN_TYPE_FILTER_WIDGET_H -#include "../../../filters/filter_tree_model.h" -#include "../general/layout_containers/flow_widget.h" - -#include #include -#include -#include -#include +#include #include +#include + +class FilterTreeModel; +class FlowWidget; +class QLabel; +class QPushButton; +class QSpinBox; +class QVBoxLayout; class VisualDatabaseDisplayMainTypeFilterWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.cpp index 3fa1a782a..d3e497b21 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.cpp @@ -1,11 +1,26 @@ #include "visual_database_display_name_filter_widget.h" #include "../../../interface/widgets/dialogs/dlg_load_deck_from_clipboard.h" -#include "../../../interface/widgets/tabs/abstract_tab_deck_editor.h" -#include "../deck_editor/deck_state_manager.h" +#include "../general/layout_containers/flow_widget.h" +#include "deck_list_model.h" +#include "filter_tree_model.h" +#include "libcockatrice/deck_list/deck_list.h" +#include "libcockatrice/filters/filter_card.h" +#include "libcockatrice/filters/filter_tree.h" #include "visual_database_display_filter_button.h" -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include VisualDatabaseDisplayNameFilterWidget::VisualDatabaseDisplayNameFilterWidget(QWidget *parent, FilterTreeModel *_filterModel, diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.h index 0c10408ae..1629cb93b 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_name_filter_widget.h @@ -7,14 +7,17 @@ #ifndef VISUAL_DATABASE_DISPLAY_NAME_FILTER_WIDGET_H #define VISUAL_DATABASE_DISPLAY_NAME_FILTER_WIDGET_H -#include "../../../filters/filter_tree_model.h" -#include "../../../interface/widgets/tabs/abstract_tab_deck_editor.h" -#include "../general/layout_containers/flow_widget.h" - -#include #include -#include +#include #include +#include + +class DeckListModel; +class FilterTreeModel; +class FlowWidget; +class QLineEdit; +class QPushButton; +class QVBoxLayout; class VisualDatabaseDisplayNameFilterWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp index 358c3ed88..3d76fb78a 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp @@ -2,15 +2,35 @@ #include "../../../client/settings/cache_settings.h" #include "../../../filters/filter_tree_model.h" +#include "../general/layout_containers/flow_widget.h" +#include "libcockatrice/card/database/card_database.h" +#include "libcockatrice/card/set/card_set.h" +#include "libcockatrice/card/set/card_set_list.h" +#include "libcockatrice/filters/filter_card.h" +#include "libcockatrice/utility/macros.h" #include "visual_database_display_filter_button.h" +#include +#include +#include +#include +#include #include +#include +#include #include +#include +#include +#include #include +#include +#include #include +#include #include #include #include +#include VisualDatabaseDisplayRecentSetFilterSettingsWidget::VisualDatabaseDisplayRecentSetFilterSettingsWidget(QWidget *parent) : QWidget(parent) diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.h index bcc5e93c2..468e97e0d 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.h @@ -7,16 +7,19 @@ #ifndef VISUAL_DATABASE_DISPLAY_SET_FILTER_WIDGET_H #define VISUAL_DATABASE_DISPLAY_SET_FILTER_WIDGET_H -#include "../../../filters/filter_tree_model.h" -#include "../general/layout_containers/flow_widget.h" - -#include -#include #include -#include -#include -#include +#include #include +#include + +class FilterTreeModel; +class FlowWidget; +class QCheckBox; +class QHBoxLayout; +class QLineEdit; +class QPushButton; +class QSpinBox; +class QVBoxLayout; class VisualDatabaseDisplayRecentSetFilterSettingsWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.cpp index 6d4bcb58e..705bb8efa 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.cpp @@ -1,15 +1,28 @@ #include "visual_database_display_sub_type_filter_widget.h" #include "../../../filters/filter_tree_model.h" +#include "../general/layout_containers/flow_widget.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/filters/filter_card.h" #include "visual_database_display_filter_button.h" +#include +#include +#include #include #include +#include +#include #include +#include +#include #include #include +#include +#include #include #include +#include VisualDatabaseDisplaySubTypeFilterWidget::VisualDatabaseDisplaySubTypeFilterWidget(QWidget *parent, FilterTreeModel *_filterModel) diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.h index d713c7f99..7929063fd 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_sub_type_filter_widget.h @@ -7,14 +7,18 @@ #ifndef VISUAL_DATABASE_DISPLAY_SUB_TYPE_FILTER_WIDGET_H #define VISUAL_DATABASE_DISPLAY_SUB_TYPE_FILTER_WIDGET_H -#include "../../../filters/filter_tree_model.h" -#include "../general/layout_containers/flow_widget.h" - -#include #include -#include -#include +#include #include +#include + +class FilterTreeModel; +class FlowWidget; +class QLabel; +class QLineEdit; +class QPushButton; +class QSpinBox; +class QVBoxLayout; class VisualDatabaseDisplaySubTypeFilterWidget : public QWidget { 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 bc124217b..ecb306e49 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 @@ -3,28 +3,68 @@ #include "../../../client/settings/cache_settings.h" #include "../../../filters/filter_tree_model.h" #include "../../../filters/syntax_help.h" +#include "../../key_signals.h" #include "../../pixel_map_generator.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 "../deck_editor/deck_state_manager.h" +#include "../general/layout_containers/flow_widget.h" #include "../general/tutorial/tutorial_controller.h" #include "../quick_settings/settings_button_widget.h" #include "../tabs/visual_deck_editor/tab_deck_editor_visual.h" #include "../utility/custom_line_edit.h" +#include "card_database_display_model.h" +#include "card_database_model.h" +#include "deck_list_model.h" +#include "libcockatrice/card/card_localization.h" +#include "libcockatrice/card/database/card_database_loader.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/card/printing/printing_info.h" +#include "libcockatrice/filters/filter_card.h" +#include "libcockatrice/filters/filter_tree.h" #include "visual_database_display_color_filter_widget.h" #include "visual_database_display_filter_save_load_widget.h" +#include "visual_database_display_filter_toolbar_widget.h" #include "visual_database_display_set_filter_widget.h" -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include +#include +#include +#include +#include #include +#include +#include +#include +#include +#include +#include #include #include #include +#include #include #include #include +class QMouseEvent; + VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, CardDatabaseModel *database_model, DeckListModel *deckListModel) @@ -41,6 +81,17 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, databaseDisplayModel->setSourceModel(database_model); databaseDisplayModel->setFilterKeyColumn(0); + const auto applyCardSearchLanguage = [this]() { + const CardsDisplaySettings &cardsDisplay = SettingsCache::instance().cardsDisplay(); + databaseDisplayModel->setSearchLanguage(CardSearchLanguage{ + cardsDisplay.getCardLang(), static_cast(cardsDisplay.getCardSearchLanguage())}); + }; + applyCardSearchLanguage(); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, + applyCardSearchLanguage); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardSearchLanguageChanged, this, + applyCardSearchLanguage); + cards = new QList; connect(databaseDisplayModel, &CardDatabaseDisplayModel::modelDirty, this, &VisualDatabaseDisplayWidget::modelDirty); @@ -58,6 +109,7 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, SettingsCache::instance().cardsDisplay().getVisualDatabaseDisplayCardSize()); connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::setVisualDatabaseDisplayCardSize); + cardSizeWidget->enableCtrlScrollResize(flowWidget); searchContainer = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAlwaysOff); @@ -66,7 +118,7 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, 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(QPixmap("theme:icons/info"), QLineEdit::TrailingPosition); + auto help = searchEdit->addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); connect(help, &QAction::triggered, this, [this] { createSearchSyntaxHelpWindow(searchEdit); }); setFocusProxy(searchEdit); @@ -90,6 +142,19 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, databaseView->setItemDelegate(nullptr); databaseView->setVisible(false); + // Without a deck model there is nothing to add cards to, so the zone menu stays hidden. + if (deckListModel) { + databaseView->setZoneMenuProvider( + [deckListModel]() -> QList> { + QList> result; + for (const QString &boardName : InnerDecklistNode::boardZoneNames()) { + result.append({boardName, deckListModel->getCustomZoneNames(boardName)}); + } + return result; + }, + [this] { return newZoneCreator ? newZoneCreator() : QString(); }); + } + searchEdit->setTreeView(databaseView); searchEdit->installEventFilter(databaseView->getKeySignals()); @@ -108,7 +173,7 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, clearFilterWidget = new QToolButton(); clearFilterWidget->setFixedSize(32, 32); - clearFilterWidget->setIcon(QPixmap("theme:icons/delete")); + clearFilterWidget->setIcon(themePixmap(QStringLiteral("icons/delete"))); connect(clearFilterWidget, &QToolButton::clicked, this, [this] { filterModel->blockSignals(true); filterModel->filterTree()->blockSignals(true); @@ -217,9 +282,6 @@ void VisualDatabaseDisplayWidget::initialize() { databaseLoadIndicator->setVisible(false); - filterContainer->initialize(); - filterContainer->setVisible(true); - searchContainer->addWidget(colorFilterWidget); searchContainer->addWidget(clearFilterWidget); searchContainer->addWidget(searchEdit); @@ -235,17 +297,48 @@ void VisualDatabaseDisplayWidget::initialize() mainLayout->addWidget(cardSizeWidget); - databaseDisplayModel->setFilterTree(filterModel->filterTree()); - connect(filterModel, &FilterTreeModel::layoutChanged, this, &VisualDatabaseDisplayWidget::onSearchModelChanged); - loadCardsTimer = new QTimer(this); - loadCardsTimer->setSingleShot(true); // Ensure it only fires once after the timeout + initializeFilters(); +} - connect(loadCardsTimer, &QTimer::timeout, this, [this]() { loadCurrentPage(); }); - loadCardsTimer->start(5000); +void VisualDatabaseDisplayWidget::initializeFilters() +{ + if (filtersInitialized || !isVisible() || CardDatabaseManager::getInstance()->getLoadStatus() != LoadStatus::Ok) { + return; + } - retranslateUi(); + filtersInitialized = true; + + // The filter toolbar builds its widgets by iterating the entire card database + // (per-set, per-main-type, per-sub-type and per-format buttons). Building it + // inside showEvent would block the tab switch, so keep it hidden and defer the + // build to the next event loop turn, letting the tab paint first. The toolbar + // then appears one event loop turn later, shifting the grid down by the toolbar + // height -- the intended tradeoff of an responsive tab switch. + filterContainer->setVisible(false); + + QTimer::singleShot(0, this, [this] { + filterContainer->initialize(); + filterContainer->setVisible(true); + + databaseDisplayModel->setFilterTree(filterModel->filterTree()); + + QTimer::singleShot(5000, this, [this] { loadCurrentPage(); }); + + retranslateUi(); + }); +} + +void VisualDatabaseDisplayWidget::showEvent(QShowEvent *event) +{ + QWidget::showEvent(event); + initializeFilters(); +} + +void VisualDatabaseDisplayWidget::setNewZoneCreator(const std::function &creator) +{ + newZoneCreator = creator; } void VisualDatabaseDisplayWidget::retranslateUi() @@ -369,9 +462,17 @@ void VisualDatabaseDisplayWidget::loadCurrentPage() { // Ensure only the initial page is loaded if (currentPage == 0) { - // Only load the first page initially - qCDebug(VisualDatabaseDisplayLog) << "Loading the first page"; - populateCards(); + if (!initialLoadScheduled) { + initialLoadScheduled = true; + qCDebug(VisualDatabaseDisplayLog) << "Loading the first page"; + // Defer the first page so the tab switch stays responsive. The card + // grid builds one event loop turn later. This also applies to + // search-driven reloads, which reset currentPage back to 0. + QTimer::singleShot(0, this, [this] { + initialLoadScheduled = false; + populateCards(); + }); + } } else if (nearEndOfPage()) { // If not the first page, just load the next page and append to the flow widget loadNextPage(); diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h index 67ad8d8dc..1f83931b0 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.h @@ -16,16 +16,40 @@ #include "../general/layout_containers/overlap_control_widget.h" #include "../general/tutorial/tutorial_controller.h" #include "../utility/custom_line_edit.h" +#include "libcockatrice/card/card_info.h" #include "visual_database_display_color_filter_widget.h" #include "visual_database_display_filter_toolbar_widget.h" #include -#include -#include +#include +#include +#include #include -#include -#include -#include +#include +#include +#include + +class CardDatabaseDisplayModel; +class CardDatabaseModel; +class CardDatabaseView; +class CardSizeWidget; +class DeckList; +class DeckListModel; +class ExactCard; +class FilterTreeModel; +class FlowWidget; +class OverlapControlWidget; +class QLabel; +class QModelIndex; +class QPushButton; +class QScrollArea; +class QTimer; +class QToolButton; +class QVBoxLayout; +class SearchLineEdit; +class VisualDatabaseDisplayColorFilterWidget; +class VisualDatabaseDisplayFilterToolbarWidget; +template class QList; class TutorialController; inline Q_LOGGING_CATEGORY(VisualDatabaseDisplayLog, "visual_database_display"); @@ -48,6 +72,12 @@ public: void sortCardList(const QStringList &properties, Qt::SortOrder order) const; void setDeckList(const DeckList &new_deck_list_model); + /** + * @brief Sets the callback used to create a custom zone from the add-to-zone menu. + * The callback returns the name of the created zone, or an empty string if creation was cancelled. + */ + void setNewZoneCreator(const std::function &creator); + CardDatabaseDisplayModel *getDatabaseDisplayModel() { return databaseDisplayModel; @@ -109,6 +139,7 @@ private: VisualDatabaseDisplayFilterToolbarWidget *filterContainer; CardDatabaseDisplayModel *databaseDisplayModel; CardDatabaseView *databaseView; + std::function newZoneCreator; QList *cards; QVBoxLayout *mainLayout; QScrollArea *scrollArea; @@ -118,17 +149,20 @@ private: OverlapControlWidget *overlapControlWidget; CardSizeWidget *cardSizeWidget; QTimer *debounceTimer; - QTimer *loadCardsTimer; int debounceTime = 300; // in Ms int currentPage = 0; // Current page index int cardsPerPage = 100; // Number of cards per page + bool filtersInitialized = false; + bool initialLoadScheduled = false; + void initializeFilters(); void highlightAllSearchEdit(); bool nearEndOfPage() const; protected: void resizeEvent(QResizeEvent *event) override; + void showEvent(QShowEvent *event) override; }; #endif // VISUAL_DATABASE_DISPLAY_WIDGET_H diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_filter_display_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_filter_display_widget.cpp index bb5b333d9..0f6ac51ec 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_filter_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_filter_display_widget.cpp @@ -2,13 +2,18 @@ #include "../../../client/settings/cache_settings.h" +#include #include #include #include #include #include +#include #include +#include +#include #include +#include FilterDisplayWidget::FilterDisplayWidget(QWidget *parent, const QString &filename, FilterTreeModel *_filterModel) : QWidget(parent), filterFilename(filename), filterModel(_filterModel) diff --git a/cockatrice/src/interface/widgets/visual_database_display/visual_database_filter_display_widget.h b/cockatrice/src/interface/widgets/visual_database_display/visual_database_filter_display_widget.h index 5fe29fa2d..6d051bcf7 100644 --- a/cockatrice/src/interface/widgets/visual_database_display/visual_database_filter_display_widget.h +++ b/cockatrice/src/interface/widgets/visual_database_display/visual_database_filter_display_widget.h @@ -7,12 +7,12 @@ #ifndef VISUAL_DATABASE_FILTER_DISPLAY_WIDGET_H #define VISUAL_DATABASE_FILTER_DISPLAY_WIDGET_H -#include -#include #include #include +#include class FilterTreeModel; +class QPushButton; class FilterDisplayWidget : public QWidget { 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 e1b696810..221c6fccb 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,8 +1,27 @@ #include "visual_deck_display_options_widget.h" +#include "../../pixel_map_generator.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" +#include "../utility/compact_push_button.h" +#include "deck_list_model.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include +#include VisualDeckDisplayOptionsWidget::VisualDeckDisplayOptionsWidget(QWidget *parent) : QWidget(parent) { @@ -47,7 +66,7 @@ VisualDeckDisplayOptionsWidget::VisualDeckDisplayOptionsWidget(QWidget *parent) sortByLabel = new QLabel(this); sortCriteriaButton = new SettingsButtonWidget(this); - sortCriteriaButton->setButtonIcon(QPixmap("theme:icons/sort_arrow_down")); + sortCriteriaButton->setButtonIcon(themePixmap(QStringLiteral("icons/sort_arrow_down"))); sortLabel = new QLabel(sortCriteriaButton); sortLabel->setWordWrap(true); @@ -92,7 +111,7 @@ void VisualDeckDisplayOptionsWidget::retranslateUi() sortLabel->setText(tr("Click and drag to change the sort order within the groups")); sortCriteriaButton->setToolTip(tr("Configure how cards are sorted within their groups")); displayTypeButton->setButtonText(tr("Toggle Layout: Overlap")); - displayTypeButton->setButtonIcon(QPixmap("theme:icons/scales")); + displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scales"))); displayTypeButton->setToolTip( tr("Change how cards are displayed within zones (i.e. overlapped or fully visible.)")); } @@ -117,11 +136,11 @@ void VisualDeckDisplayOptionsWidget::updateDisplayType() switch (currentDisplayType) { case DisplayType::Flat: displayTypeButton->setButtonText(tr("Toggle Layout: Flat")); - displayTypeButton->setButtonIcon(QPixmap("theme:icons/scroll")); + displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scroll"))); break; case DisplayType::Overlap: displayTypeButton->setButtonText(tr("Toggle Layout: Overlap")); - displayTypeButton->setButtonIcon(QPixmap("theme:icons/scales")); + displayTypeButton->setButtonIcon(themePixmap(QStringLiteral("icons/scales"))); break; } emit displayTypeChanged(currentDisplayType); diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.h b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.h index d567d61fb..f00693245 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_display_options_widget.h @@ -4,12 +4,18 @@ #include "../general/tutorial/tutorial_controller.h" #include "visual_deck_editor_widget.h" -#include -#include -#include -#include -#include +#include +#include +#include #include +#include + +class CompactPushButton; +class QComboBox; +class QHBoxLayout; +class QLabel; +class QListWidget; +class SettingsButtonWidget; /** * @class VisualDeckDisplayOptionsWidget diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_placeholder_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_placeholder_widget.cpp index da4fe09e5..76c3d0908 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_placeholder_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_placeholder_widget.cpp @@ -1,5 +1,9 @@ #include "visual_deck_editor_placeholder_widget.h" +#include +#include +#include + VisualDeckEditorPlaceholderWidget::VisualDeckEditorPlaceholderWidget(QWidget *parent) : QWidget(parent) { mainLayout = new QVBoxLayout(this); diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_placeholder_widget.h b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_placeholder_widget.h index de2362a81..84a3142d8 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_placeholder_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_placeholder_widget.h @@ -1,9 +1,11 @@ #ifndef COCKATRICE_VISUAL_DECK_EDITOR_PLACEHOLDER_WIDGET_H #define COCKATRICE_VISUAL_DECK_EDITOR_PLACEHOLDER_WIDGET_H -#include -#include #include +#include + +class QLabel; +class QVBoxLayout; class VisualDeckEditorPlaceholderWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.cpp index bc2ba0b50..41efc2b61 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.cpp @@ -1,19 +1,41 @@ #include "visual_deck_editor_sample_hand_widget.h" #include "../../../client/settings/cache_settings.h" -#include "../../deck_loader/deck_loader.h" #include "../cards/card_info_picture_widget.h" +#include "../cards/card_size_widget.h" #include "../deck_analytics/analyzer_modules/draw_probability/draw_probability_widget.h" #include "../deck_analytics/deck_list_statistics_analyzer.h" +#include "../general/layout_containers/flow_widget.h" #include "../general/tutorial/tutorial_controller.h" #include "../tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.h" +#include "deck_list_model.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/deck_list/tree/deck_list_card_node.h" +#include "libcockatrice/deck_list/tree/inner_deck_list_node.h" #include "libcockatrice/utility/qt_utils.h" +#include +#include +#include +#include +#include +#include #include +#include +#include +#include +#include +#include #include #include +#include +#include +#include #include +class DeckListStatisticsAnalyzer; + VisualDeckEditorSampleHandWidget::VisualDeckEditorSampleHandWidget(QWidget *parent, DeckListModel *_deckListModel, DeckListStatisticsAnalyzer *_statsAnalyzer) @@ -51,6 +73,7 @@ VisualDeckEditorSampleHandWidget::VisualDeckEditorSampleHandWidget(QWidget *pare upperLayout->addWidget(flowWidget); cardSizeWidget = new CardSizeWidget(this, flowWidget); + cardSizeWidget->enableCtrlScrollResize(flowWidget); upperLayout->addWidget(cardSizeWidget); auto upperLayoutWidget = new QWidget(this); diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.h b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.h index 89bfb5fce..1d828ccdf 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.h @@ -11,13 +11,22 @@ #include "../deck_analytics/deck_list_statistics_analyzer.h" #include "../general/layout_containers/flow_widget.h" #include "../general/tutorial/tutorial_controller.h" +#include "libcockatrice/card/printing/exact_card.h" -#include -#include +#include #include -#include +#include class DrawProbabilityWidget; +class CardSizeWidget; +class DeckListModel; +class DeckListStatisticsAnalyzer; +class FlowWidget; +class QHBoxLayout; +class QPushButton; +class QSpinBox; +class QVBoxLayout; + class VisualDeckEditorSampleHandWidget : public QWidget { Q_OBJECT 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 91c901b4c..9d7c49e50 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,38 +1,57 @@ #include "visual_deck_editor_widget.h" #include "../../../client/settings/cache_settings.h" -#include "../../../main.h" -#include "../../deck_loader/deck_loader.h" -#include "../../layouts/overlap_layout.h" -#include "../cards/card_info_picture_with_text_overlay_widget.h" +#include "../../pixel_map_generator.h" +#include "../cards/card_size_widget.h" #include "../cards/deck_card_zone_display_widget.h" #include "../general/layout_containers/flow_widget.h" -#include "../tabs/visual_deck_editor/tab_deck_editor_visual.h" -#include "../tabs/visual_deck_editor/tab_deck_editor_visual_tab_widget.h" #include "../utility/compact_push_button.h" +#include "../utility/completer_utils.h" +#include "card/card_completer_proxy_model.h" +#include "card_database_display_model.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/exact_card.h" #include "visual_deck_display_options_widget.h" +#include "visual_deck_editor_placeholder_widget.h" -#include +#include +#include #include +#include #include +#include +#include +#include +#include #include +#include +#include #include -#include -#include -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include -#include -#include #include #include #include #include +#include #include VisualDeckEditorWidget::VisualDeckEditorWidget(QWidget *parent, DeckListModel *_deckListModel, - QItemSelectionModel *_selectionModel) - : QWidget(parent), deckListModel(_deckListModel), selectionModel(_selectionModel) + QItemSelectionModel *_selectionModel, + CardDatabaseModel *_cardDatabaseModel) + : QWidget(parent), deckListModel(_deckListModel), selectionModel(_selectionModel), + cardDatabaseModel(_cardDatabaseModel) { // The Main Widget and Main Layout, which contain a single Widget: The Scroll Area setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); @@ -81,13 +100,8 @@ void VisualDeckEditorWidget::initializeSearchBarAndCompleter() searchBar = new QLineEdit(searchContainer); searchContainer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); connect(searchBar, &QLineEdit::returnPressed, this, [=, this]() { - if (!searchBar->hasFocus()) { - return; - } - - ExactCard card = CardDatabaseManager::query()->getCard({searchBar->text()}); - if (card) { - emit cardAdditionRequested(card); + if (searchBar->hasFocus()) { + addCardFromSearch(); } }); @@ -96,35 +110,19 @@ void VisualDeckEditorWidget::initializeSearchBarAndCompleter() setFocusProxy(searchBar); setFocusPolicy(Qt::ClickFocus); - cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this); + if (!cardDatabaseModel) { + cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this); + } cardDatabaseDisplayModel = new CardDatabaseDisplayModel(this); cardDatabaseDisplayModel->setSourceModel(cardDatabaseModel); - CardSearchModel *searchModel = new CardSearchModel(cardDatabaseDisplayModel, this); - proxyModel = new CardCompleterProxyModel(this); - proxyModel->setSourceModel(searchModel); - proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive); - proxyModel->setFilterRole(Qt::DisplayRole); - - completer = new QCompleter(proxyModel, this); - completer->setCompletionRole(Qt::DisplayRole); - completer->setCompletionMode(QCompleter::PopupCompletion); - completer->setCaseSensitivity(Qt::CaseInsensitive); - completer->setFilterMode(Qt::MatchContains); - completer->setMaxVisibleItems(15); + const CardCompleterSetup cardSetup = createCardCompleter(cardDatabaseDisplayModel, this, 15); + proxyModel = cardSetup.proxyModel; + completer = cardSetup.completer; searchBar->setCompleter(completer); // Update suggestions dynamically - connect(searchBar, &QLineEdit::textEdited, searchModel, &CardSearchModel::updateSearchResults); - connect(searchBar, &QLineEdit::textEdited, this, [=, this](const QString &text) { - // Ensure substring matching - QString pattern = ".*" + QRegularExpression::escape(text) + ".*"; - proxyModel->setFilterRegularExpression(QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption)); - - if (!text.isEmpty()) { - completer->complete(); // Force the dropdown to appear - } - }); + connectCardCompleterSearch(searchBar, cardSetup); connect(completer, static_cast(&QCompleter::activated), this, [=, this](const QString &completion) { @@ -147,17 +145,25 @@ void VisualDeckEditorWidget::initializeSearchBarAndCompleter() // Search button functionality searchPushButton = new CompactPushButton(searchContainer); - searchPushButton->setButtonIcon(QPixmap("theme:icons/search")); - connect(searchPushButton, &QPushButton::clicked, this, [=, this]() { - ExactCard card = CardDatabaseManager::query()->getCard({searchBar->text()}); - if (card) { - emit cardAdditionRequested(card); - } - }); + searchPushButton->setButtonIcon(themePixmap(QStringLiteral("icons/search"))); + connect(searchPushButton, &QPushButton::clicked, this, [this] { addCardFromSearch(); }); searchLayout->addWidget(searchPushButton); } +void VisualDeckEditorWidget::addCardFromSearch() +{ + ExactCard card = CardDatabaseManager::query()->getCard({searchBar->text()}); + if (!card) { + return; + } + + emit cardAdditionRequested(card); + + searchBar->setFocus(); + QTimer::singleShot(0, searchBar, [this] { searchBar->setSelection(0, searchBar->text().length()); }); +} + void VisualDeckEditorWidget::initializeDisplayOptionsWidget() { displayOptionsWidget = new VisualDeckDisplayOptionsWidget(this); diff --git a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.h b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.h index b24aee28a..dad71523d 100644 --- a/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.h @@ -16,18 +16,37 @@ #include "visual_deck_editor_placeholder_widget.h" #include +#include #include +#include +#include #include +#include +#include #include -#include -#include -#include -#include -#include -#include +#include class VisualDeckDisplayOptionsWidget; class DeckCardZoneDisplayWidget; +class CardCompleterProxyModel; +class CardDatabaseDisplayModel; +class CardDatabaseModel; +class CardSizeWidget; +class CompactPushButton; +class DeckList; +class DeckListModel; +class ExactCard; +class FlowWidget; +class QCompleter; +class QHBoxLayout; +class QItemSelection; +class QItemSelectionModel; +class QLineEdit; +class QModelIndex; +class QScrollArea; +class QVBoxLayout; +class VisualDeckEditorPlaceholderWidget; + enum class DisplayType { Flat, @@ -39,7 +58,10 @@ class VisualDeckEditorWidget : public QWidget Q_OBJECT public: - explicit VisualDeckEditorWidget(QWidget *parent, DeckListModel *deckListModel, QItemSelectionModel *selectionModel); + explicit VisualDeckEditorWidget(QWidget *parent, + DeckListModel *deckListModel, + QItemSelectionModel *selectionModel, + CardDatabaseModel *_cardDatabaseModel = nullptr); void retranslateUi(); void updateCompactMode(); void clearAllDisplayWidgets(); @@ -77,6 +99,7 @@ signals: protected: void initializeSearchBarAndCompleter(); + void addCardFromSearch(); void initializeDisplayOptionsWidget(); void initializeDisplayOptionsAndSearchWidget(); void initializeScrollAreaAndZoneContainer(); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.cpp index f1dcf113f..1ac4c856c 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.cpp @@ -1,11 +1,15 @@ #include "deck_preview_color_identity_filter_widget.h" #include "../../cards/additional_info/mana_symbol_widget.h" -#include "deck_preview_widget.h" +#include "../visual_deck_storage_sort_filter_proxy_model.h" -#include +#include +#include +#include +#include +#include -DeckPreviewColorIdentityFilterWidget::DeckPreviewColorIdentityFilterWidget(VisualDeckStorageWidget *parent) +DeckPreviewColorIdentityFilterWidget::DeckPreviewColorIdentityFilterWidget(QWidget *parent) : QWidget(parent), layout(new QHBoxLayout(this)) { setLayout(layout); @@ -32,10 +36,6 @@ DeckPreviewColorIdentityFilterWidget::DeckPreviewColorIdentityFilterWidget(Visua // Connect the button's clicked signal connect(toggleButton, &QPushButton::clicked, this, &DeckPreviewColorIdentityFilterWidget::updateFilterMode); - connect(this, &DeckPreviewColorIdentityFilterWidget::activeColorsChanged, parent, - &VisualDeckStorageWidget::updateColorFilter); - connect(this, &DeckPreviewColorIdentityFilterWidget::filterModeChanged, parent, - &VisualDeckStorageWidget::updateColorFilter); // Call retranslateUi to set the initial text retranslateUi(); @@ -45,19 +45,33 @@ void DeckPreviewColorIdentityFilterWidget::retranslateUi() { // Set the toggle button text based on the current mode switch (filterMode) { - case ExactMatch: + case VisualDeckStorageSortFilterProxyModel::FilterMode::ExactMatch: toggleButton->setText(tr("Mode: Exact Match")); break; - case Includes: + case VisualDeckStorageSortFilterProxyModel::FilterMode::Includes: toggleButton->setText(tr("Mode: Includes")); break; - case Excludes: + case VisualDeckStorageSortFilterProxyModel::FilterMode::Excludes: toggleButton->setText(tr("Mode: Excludes")); break; } toggleButton->setToolTip(tr("Color identity filter mode (AND/OR/NOT conjunctions of filters)")); } +/** + * @brief The colors that are currently toggled on. + */ +QSet DeckPreviewColorIdentityFilterWidget::getActiveColors() const +{ + QSet activeColorSet; + for (auto it = activeColors.constBegin(); it != activeColors.constEnd(); ++it) { + if (it.value()) { + activeColorSet.insert(it.key()); + } + } + return activeColorSet; +} + void DeckPreviewColorIdentityFilterWidget::handleColorToggled(QChar color, bool active) { activeColors[color] = active; @@ -68,88 +82,17 @@ void DeckPreviewColorIdentityFilterWidget::updateFilterMode() { // Cycle through the modes switch (filterMode) { - case ExactMatch: - filterMode = Includes; + case VisualDeckStorageSortFilterProxyModel::FilterMode::ExactMatch: + filterMode = VisualDeckStorageSortFilterProxyModel::FilterMode::Includes; break; - case Includes: - filterMode = Excludes; + case VisualDeckStorageSortFilterProxyModel::FilterMode::Includes: + filterMode = VisualDeckStorageSortFilterProxyModel::FilterMode::Excludes; break; - case Excludes: - filterMode = ExactMatch; + case VisualDeckStorageSortFilterProxyModel::FilterMode::Excludes: + filterMode = VisualDeckStorageSortFilterProxyModel::FilterMode::ExactMatch; break; } retranslateUi(); // Update the button text emit filterModeChanged(filterMode); } - -void DeckPreviewColorIdentityFilterWidget::filterWidgets(QList widgets) -{ - // Check if no colors are active - bool noColorsActive = true; - for (auto it = activeColors.constBegin(); it != activeColors.constEnd(); ++it) { - if (it.value()) { - noColorsActive = false; - break; - } - } - - // If no colors are active, return the unfiltered list of widgets - if (noColorsActive) { - for (DeckPreviewWidget *previewWidget : widgets) { - previewWidget->filteredByColor = false; - } - return; - } - - for (const auto &widget : widgets) { - QString colorIdentity = widget->getColorIdentity(); - - bool matchesFilter = true; - switch (filterMode) { - case ExactMatch: { - // Exact match mode: active colors must exactly match colorIdentity - - // Create a set of active colors - QSet activeColorSet; - for (auto it = activeColors.constBegin(); it != activeColors.constEnd(); ++it) { - if (it.value()) { - activeColorSet.insert(it.key().toUpper()); // Use uppercase for uniformity - } - } - - // Create a set of colors from the color identity string - QSet colorIdentitySet; - for (const QChar &color : colorIdentity) { - colorIdentitySet.insert(color.toUpper()); // Ensure case uniformity - } - - // Compare the sets: the sets must match exactly - if (activeColorSet != colorIdentitySet) { - matchesFilter = false; - } - break; - } - case Includes: - // Includes mode: colorIdentity must contain all active colors - for (auto it = activeColors.constBegin(); it != activeColors.constEnd(); ++it) { - if (it.value() && !colorIdentity.contains(it.key())) { - matchesFilter = false; - break; - } - } - break; - case Excludes: - // Excludes mode: colorIdentity must contain none of the active colors - for (auto it = activeColors.constBegin(); it != activeColors.constEnd(); ++it) { - if (it.value() && colorIdentity.contains(it.key())) { - matchesFilter = false; - break; - } - } - break; - } - - widget->filteredByColor = !matchesFilter; - } -} diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h index 8e60b16fb..e0c90034c 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h @@ -2,44 +2,54 @@ * @file deck_preview_color_identity_filter_widget.h * @ingroup VisualDeckPreviewWidgets */ -//! \todo Document this file. #ifndef DECK_PREVIEW_COLOR_IDENTITY_FILTER_WIDGET_H #define DECK_PREVIEW_COLOR_IDENTITY_FILTER_WIDGET_H -#include "../visual_deck_storage_widget.h" +#include "../visual_deck_storage_sort_filter_proxy_model.h" -#include -#include +#include +#include +#include #include +#include -class DeckPreviewWidget; -class VisualDeckStorageWidget; +class QHBoxLayout; +class QPushButton; class DeckPreviewColorIdentityFilterWidget : public QWidget { Q_OBJECT public: - /** - * How the active colors are matched against a deck's color identity. - */ - enum FilterMode - { - ExactMatch, ///< The color identity consists of exactly the active colors. - Includes, ///< The color identity contains all of the active colors. - Excludes ///< The color identity contains none of the active colors. - }; - Q_ENUM(FilterMode) - - explicit DeckPreviewColorIdentityFilterWidget(VisualDeckStorageWidget *parent); + explicit DeckPreviewColorIdentityFilterWidget(QWidget *parent = nullptr); void retranslateUi(); - void filterWidgets(QList widgets); + + /** + * @brief The currently active color identity filter mode. + */ + [[nodiscard]] VisualDeckStorageSortFilterProxyModel::FilterMode getFilterMode() const + { + return filterMode; + } + + /** + * @brief The colors that are currently toggled on. + */ + [[nodiscard]] QSet getActiveColors() const; signals: - void filterModeChanged(FilterMode mode); + /** + * Emitted when the set of active colors changed due to user interaction. + */ void activeColorsChanged(); + /** + * Emitted when the user cycles the color identity filter mode. + * @param mode The new filter mode. + */ + void filterModeChanged(VisualDeckStorageSortFilterProxyModel::FilterMode mode); + private slots: void handleColorToggled(QChar color, bool active); void updateFilterMode(); @@ -48,7 +58,7 @@ private: QHBoxLayout *layout; QPushButton *toggleButton; QMap activeColors; - FilterMode filterMode = Includes; // Default to "includes" mode + VisualDeckStorageSortFilterProxyModel::FilterMode filterMode = VisualDeckStorageSortFilterProxyModel::Includes; }; #endif // DECK_PREVIEW_COLOR_IDENTITY_FILTER_WIDGET_H diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp index b45f61be7..dc619e9fa 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp @@ -1,19 +1,23 @@ #include "deck_preview_deck_tags_display_widget.h" #include "../../../../client/settings/cache_settings.h" -#include "../../../../interface/widgets/dialogs/dlg_convert_deck_to_cod_format.h" -#include "../../../../interface/widgets/tabs/tab_deck_editor.h" +#include "../../../deck_loader/deck_file_format.h" +#include "../../../deck_loader/deck_loader.h" +#include "../../../deck_loader/loaded_deck.h" #include "../../general/layout_containers/flow_widget.h" #include "deck_preview_tag_addition_widget.h" #include "deck_preview_tag_dialog.h" #include "deck_preview_tag_display_widget.h" -#include "deck_preview_widget.h" +#include "libcockatrice/deck_list/deck_list.h" +#include +#include #include #include -#include +#include #include -#include +#include +#include DeckPreviewDeckTagsDisplayWidget::DeckPreviewDeckTagsDisplayWidget(QWidget *_parent, const QStringList &_tags) : QWidget(_parent), currentTags(_tags) @@ -54,6 +58,16 @@ void DeckPreviewDeckTagsDisplayWidget::refreshTags() flowWidget->addWidget(tagAdditionWidget); } +void DeckPreviewDeckTagsDisplayWidget::setKnownTagsProvider(const std::function &provider) +{ + knownTagsProvider = provider; +} + +void DeckPreviewDeckTagsDisplayWidget::setConversionPromptHandler(const std::function &handler) +{ + conversionPromptHandler = handler; +} + /** * Gets the filepath of all files (no directories) in target directory and all subdirectories */ @@ -92,93 +106,13 @@ static QStringList findAllKnownTags() void DeckPreviewDeckTagsDisplayWidget::openTagEditDlg() { - if (qobject_cast(parentWidget())) { - // If we're the child of a DeckPreviewWidget, then we need to handle conversion - auto *deckPreviewWidget = qobject_cast(parentWidget()); - - bool canAddTags = promptFileConversionIfRequired(deckPreviewWidget); - - if (canAddTags) { - QStringList knownTags = deckPreviewWidget->visualDeckStorageWidget->tagFilterWidget->getAllKnownTags(); - execTagDialog(knownTags); - } - } else { - // If we're the child of an AbstractTabDeckEditor, then we don't bother with conversion - QStringList knownTags = findAllKnownTags(); - execTagDialog(knownTags); - } -} - -static bool confirmOverwriteIfExists(QWidget *parent, const QString &filePath) -{ - QFileInfo fileInfo(filePath); - QString newFileName = QDir::toNativeSeparators(fileInfo.path() + "/" + fileInfo.completeBaseName() + ".cod"); - - if (QFile::exists(newFileName)) { - QMessageBox::StandardButton reply = - QMessageBox::question(parent, QObject::tr("Overwrite Existing File?"), - QObject::tr("A .cod version of this deck already exists. Overwrite it?"), - QMessageBox::Yes | QMessageBox::No); - return reply == QMessageBox::Yes; - } - return true; // Safe to proceed -} - -static void convertFileToCockatriceFormat(DeckPreviewWidget *deckPreviewWidget) -{ - DeckLoader::convertToCockatriceFormat(deckPreviewWidget->deckLoader->getDeck()); - deckPreviewWidget->filePath = deckPreviewWidget->deckLoader->getDeck().lastLoadInfo.fileName; - deckPreviewWidget->refreshBannerCardText(); -} - -/** - * Checks if the deck's file format supports tags. - * If not, then prompt the user for file conversion. - * @return whether the resulting file can support adding tags - */ -bool DeckPreviewDeckTagsDisplayWidget::promptFileConversionIfRequired(DeckPreviewWidget *deckPreviewWidget) -{ - if (DeckFileFormat::getFormatFromName(deckPreviewWidget->filePath) == DeckFileFormat::Cockatrice) { - return true; + // The deck editor path has no conversion prompt; the VDS path registers one. + if (conversionPromptHandler && !conversionPromptHandler()) { + return; } - // Retrieve saved preference if the prompt is disabled - if (!SettingsCache::instance().visualDeckStorage().getVisualDeckStoragePromptForConversion()) { - if (!SettingsCache::instance().visualDeckStorage().getVisualDeckStorageAlwaysConvert()) { - return false; - } - - if (!confirmOverwriteIfExists(this, deckPreviewWidget->filePath)) { - return false; - } - - convertFileToCockatriceFormat(deckPreviewWidget); - return true; - } - - // Show the dialog to the user - DialogConvertDeckToCodFormat conversionDialog(parentWidget()); - if (conversionDialog.exec() != QDialog::Accepted) { - SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion( - !conversionDialog.dontAskAgain()); - SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(false); - - return false; - } - - // Try to convert file - if (!confirmOverwriteIfExists(this, deckPreviewWidget->filePath)) { - return false; - } - - convertFileToCockatriceFormat(deckPreviewWidget); - - if (conversionDialog.dontAskAgain()) { - SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion(false); - SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(true); - } - - return true; + const QStringList knownTags = knownTagsProvider ? knownTagsProvider() : findAllKnownTags(); + execTagDialog(knownTags); } void DeckPreviewDeckTagsDisplayWidget::execTagDialog(const QStringList &knownTags) @@ -191,4 +125,4 @@ void DeckPreviewDeckTagsDisplayWidget::execTagDialog(const QStringList &knownTag emit tagsChanged(updatedTags); } } -} \ No newline at end of file +} diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.h index 4bd7915cd..a12f6ec3c 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.h @@ -2,41 +2,56 @@ * @file deck_preview_deck_tags_display_widget.h * @ingroup VisualDeckPreviewWidgets */ -//! \todo Document this file. #ifndef DECK_PREVIEW_DECK_TAGS_DISPLAY_WIDGET_H #define DECK_PREVIEW_DECK_TAGS_DISPLAY_WIDGET_H -#include "../../../deck_loader/deck_loader.h" -#include "deck_preview_widget.h" - +#include +#include +#include #include +#include +#include + +class FlowWidget; -class DeckPreviewWidget; class DeckPreviewDeckTagsDisplayWidget : public QWidget { Q_OBJECT QStringList currentTags; FlowWidget *flowWidget; + std::function knownTagsProvider; + std::function conversionPromptHandler; public: explicit DeckPreviewDeckTagsDisplayWidget(QWidget *_parent, const QStringList &_tags = {}); void setTags(const QStringList &_tags); void refreshTags(); + /** + * @brief Sets a provider for the tags shown in the edit dialog. + * Defaults to scanning all deck files in the deck folder. + */ + void setKnownTagsProvider(const std::function &provider); + + /** + * @brief Sets a handler run before opening the tag dialog. Returning false + * cancels the dialog. Defaults to no handler (the deck editor path). + */ + void setConversionPromptHandler(const std::function &handler); + public slots: void openTagEditDlg(); -private: - bool promptFileConversionIfRequired(DeckPreviewWidget *deckPreviewWidget); - void execTagDialog(const QStringList &knownTags); - signals: /** * Emitted when the tags have changed due to user interaction. * @param tags The new list of tags. */ void tagsChanged(const QStringList &tags); + +private: + void execTagDialog(const QStringList &knownTags); }; #endif // DECK_PREVIEW_DECK_TAGS_DISPLAY_WIDGET_H diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_addition_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_addition_widget.cpp index fdf4b2a28..82ed6191c 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_addition_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_addition_widget.cpp @@ -1,12 +1,19 @@ #include "deck_preview_tag_addition_widget.h" -#include "../../../../client/settings/cache_settings.h" -#include "../../../../interface/widgets/tabs/abstract_tab_deck_editor.h" - +#include +#include +#include +#include #include #include #include #include +#include +#include +#include +#include +#include +#include #include DeckPreviewTagAdditionWidget::DeckPreviewTagAdditionWidget(QWidget *_parent, QString _tagName) diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_addition_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_addition_widget.h index 7a3a735a2..30ce46249 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_addition_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_addition_widget.h @@ -7,7 +7,9 @@ #ifndef DECK_PREVIEW_TAG_ADDITION_WIDGET_H #define DECK_PREVIEW_TAG_ADDITION_WIDGET_H -#include "deck_preview_deck_tags_display_widget.h" +#include +#include +#include class DeckPreviewTagAdditionWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_dialog.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_dialog.cpp index f27d11d36..2a6a42e9d 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_dialog.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_dialog.cpp @@ -8,10 +8,17 @@ #include #include #include +#include +#include #include +#include #include +#include #include +#include +#include #include +#include DeckPreviewTagDialog::DeckPreviewTagDialog(const QStringList &knownTags, const QStringList &_activeTags, diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_dialog.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_dialog.h index 4d9040af4..80d5560a0 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_dialog.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_dialog.h @@ -9,10 +9,18 @@ #define DECK_PREVIEW_TAG_DIALOG_H #include -#include -#include -#include -#include +#include +#include +#include +#include + +class QHBoxLayout; +class QLabel; +class QLineEdit; +class QListWidget; +class QPushButton; +class QVBoxLayout; +class QWidget; class DeckPreviewTagDialog : public QDialog { diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp index db466b77a..e62570241 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.cpp @@ -1,9 +1,19 @@ #include "deck_preview_tag_display_widget.h" +#include +#include +#include +#include #include #include #include #include +#include +#include +#include +#include +#include +#include DeckPreviewTagDisplayWidget::DeckPreviewTagDisplayWidget(QWidget *parent, const QString &_tagName) : QWidget(parent), tagName(_tagName), state(TagState::NotSelected) @@ -48,6 +58,7 @@ QSize DeckPreviewTagDisplayWidget::sizeHint() const void DeckPreviewTagDisplayWidget::mousePressEvent(QMouseEvent *event) { + const TagState previousState = state; switch (event->button()) { case Qt::LeftButton: setState(state != TagState::Selected ? TagState::Selected : TagState::NotSelected); @@ -62,7 +73,12 @@ void DeckPreviewTagDisplayWidget::mousePressEvent(QMouseEvent *event) break; } - emit tagClicked(); + // Only announce a change when the state was actually toggled, so a click that falls + // through the switch (e.g. a button the widget does not react to) does not drive a + // full tag-filter update and layout pass for nothing. + if (state != previousState) { + emit tagClicked(); + } QWidget::mousePressEvent(event); } diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.h index 980741c5d..d3f5e7326 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_display_widget.h @@ -7,10 +7,12 @@ #ifndef DECK_PREVIEW_TAG_DISPLAY_WIDGET_H #define DECK_PREVIEW_TAG_DISPLAY_WIDGET_H -#include -#include #include #include +#include + +class QLabel; +class QPushButton; enum class TagState { @@ -48,7 +50,10 @@ public: signals: /** - * @brief Emitted when the tag is clicked. + * @brief Emitted when a click toggles the chip's selection/exclusion state. + * + * Not emitted for clicks that leave the state unchanged. Connected handlers use + * this as the trigger to update filters built from selectedTags()/excludedTags(). */ void tagClicked(); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_item_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_item_widget.cpp index d9ea9fc30..7796f893b 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_item_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_item_widget.cpp @@ -1,5 +1,8 @@ #include "deck_preview_tag_item_widget.h" +#include +#include + DeckPreviewTagItemWidget::DeckPreviewTagItemWidget(const QString &tagName, bool isChecked, QWidget *parent) : QWidget(parent), checkBox_(new QCheckBox(this)) { diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_item_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_item_widget.h index a3301799a..a09eb7501 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_item_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_item_widget.h @@ -7,9 +7,11 @@ #ifndef DECK_PREVIEW_TAG_ITEM_WIDGET_H #define DECK_PREVIEW_TAG_ITEM_WIDGET_H -#include -#include +#include #include +#include + +class QCheckBox; class DeckPreviewTagItemWidget : public QWidget { diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp index ffe954308..220c55788 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp @@ -1,47 +1,95 @@ #include "deck_preview_widget.h" #include "../../../../client/settings/cache_settings.h" +#include "../../../../interface/widgets/dialogs/dlg_convert_deck_to_cod_format.h" +#include "../../../deck_loader/deck_loader.h" +#include "../../../deck_loader/loaded_deck.h" #include "../../cards/additional_info/color_identity_widget.h" #include "../../cards/deck_preview_card_picture_widget.h" +#include "../visual_deck_storage_model.h" +#include "../visual_deck_storage_quick_settings_widget.h" +#include "../visual_deck_storage_tag_filter_widget.h" +#include "../visual_deck_storage_widget.h" #include "deck_preview_deck_tags_display_widget.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/deck_list/deck_list.h" +#include "libcockatrice/deck_list/tree/deck_list_card_node.h" +#include "libcockatrice/utility/card_ref.h" -#include -#include +#include +#include +#include +#include #include +#include #include +#include +#include #include #include +#include #include +#include +#include #include +#include +#include +#include #include +#include #include +#include +#include +#include +#include +#include #include #include +#include +#include DeckPreviewWidget::DeckPreviewWidget(QWidget *_parent, VisualDeckStorageWidget *_visualDeckStorageWidget, + VisualDeckStorageModel *_model, const QString &_filePath) - : QWidget(_parent), visualDeckStorageWidget(_visualDeckStorageWidget), filePath(_filePath), - colorIdentityWidget(nullptr), deckTagsDisplayWidget(nullptr) + : QWidget(_parent), filePath(_filePath), visualDeckStorageWidget(_visualDeckStorageWidget), model(_model) { layout = new QVBoxLayout(this); setLayout(layout); - deckLoader = new DeckLoader(this); - connect(deckLoader, &DeckLoader::loadFinished, this, &DeckPreviewWidget::initializeUi); - //! \todo Batch tag refresh: count finished deck loads and refresh tags once all decks are loaded. - // Currently expensive: refreshes on each individual deck load instead of once at the end. - connect(deckLoader, &DeckLoader::loadFinished, visualDeckStorageWidget->tagFilterWidget, - &VisualDeckStorageTagFilterWidget::refreshTags); - deckLoader->loadFromFileAsync(filePath, DeckFileFormat::getFormatFromName(filePath), false); - - bannerCardDisplayWidget = + auto *pictureWidget = new DeckPreviewCardPictureWidget(this, false, visualDeckStorageWidget->deckPreviewSelectionAnimationEnabled); - - connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageClicked, this, - &DeckPreviewWidget::imageClickedEvent); - connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageDoubleClicked, this, + pictureWidget->setFontSize(24); + connect(pictureWidget, &DeckPreviewCardPictureWidget::imageClicked, this, &DeckPreviewWidget::imageClickedEvent); + connect(pictureWidget, &DeckPreviewCardPictureWidget::imageSingleClicked, this, + &DeckPreviewWidget::imageSingleClicked); + connect(pictureWidget, &DeckPreviewCardPictureWidget::imageDoubleClicked, this, &DeckPreviewWidget::imageDoubleClickedEvent); + bannerCardDisplayWidget = pictureWidget; + + colorIdentityWidget = new ColorIdentityWidget(this); + deckTagsDisplayWidget = new DeckPreviewDeckTagsDisplayWidget(this); + connect(deckTagsDisplayWidget, &DeckPreviewDeckTagsDisplayWidget::tagsChanged, this, &DeckPreviewWidget::setTags); + deckTagsDisplayWidget->setKnownTagsProvider( + [this] { return visualDeckStorageWidget->tagFilterWidget->getAllKnownTags(); }); + deckTagsDisplayWidget->setConversionPromptHandler([this] { return promptFileConversionIfRequired(); }); + + bannerCardLabel = new QLabel(this); + bannerCardLabel->setObjectName("bannerCardLabel"); + bannerCardComboBox = new QComboBox(this); + bannerCardComboBox->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); + bannerCardComboBox->setObjectName("bannerCardComboBox"); + bannerCardComboBox->installEventFilter(new NoScrollFilter(bannerCardComboBox)); + connect(bannerCardComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, + &DeckPreviewWidget::setBannerCard); + + // Apply the initial visibility settings and keep them in sync while they change. + updateColorIdentityVisibility( + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowColorIdentity()); + updateBannerCardComboBoxVisibility( + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowBannerCardComboBox()); + updateTagsVisibility(SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowTagsOnDeckPreviews()); connect(&SettingsCache::instance().visualDeckStorage(), &VisualDeckStorageSettings::visualDeckStorageShowColorIdentityChanged, this, @@ -56,6 +104,38 @@ DeckPreviewWidget::DeckPreviewWidget(QWidget *_parent, &DeckPreviewWidget::refreshBannerCardToolTip); layout->addWidget(bannerCardDisplayWidget); + layout->addWidget(colorIdentityWidget); + layout->addWidget(deckTagsDisplayWidget); + layout->addWidget(bannerCardLabel); + layout->addWidget(bannerCardComboBox); + + // Only re-sync when this widget's own row changed. Without the row check, every + // finished deck load would trigger a full resync (card db lookup + combo rebuild) + // in every preview widget. + connect(model, &QAbstractItemModel::dataChanged, this, + [this](const QModelIndex &topLeft, const QModelIndex &bottomRight) { + const int r = row(); + if (r >= topLeft.row() && r <= bottomRight.row()) { + syncFromModel(); + } + }); + + retranslateUi(); + syncFromModel(); + + // resizeEvent clamps every child to the picture's width, so collect them once here + // to keep the resize handler from searching the widget tree on every layout pass. + fixedWidthChildren = {bannerCardDisplayWidget, colorIdentityWidget, deckTagsDisplayWidget, bannerCardLabel, + bannerCardComboBox}; + + // Child of the banner widget so the frame tracks the banner's selection animation + // (which animates the banner's position) instead of staying at a stale static offset. + selectionFrame = new QFrame(bannerCardDisplayWidget); + selectionFrame->setAttribute(Qt::WA_TransparentForMouseEvents); + selectionFrame->setStyleSheet(QStringLiteral( + "QFrame { border: 2px solid palette(highlight); border-radius: 4px; background: transparent; }")); + selectionFrame->setVisible(false); + bannerCardDisplayWidget->installEventFilter(this); } void DeckPreviewWidget::retranslateUi() @@ -69,9 +149,72 @@ void DeckPreviewWidget::resizeEvent(QResizeEvent *event) if (bannerCardDisplayWidget == nullptr) { return; } - QList widgets = findChildren(); - for (QWidget *widget : widgets) { - widget->setMaximumWidth(bannerCardDisplayWidget->width()); + + const int width = bannerCardDisplayWidget->width(); + if (width == lastKnownBannerWidth) { + return; + } + lastKnownBannerWidth = width; + + for (QWidget *widget : fixedWidthChildren) { + widget->setMaximumWidth(width); + } + updateSelectionFrameGeometry(); +} + +bool DeckPreviewWidget::eventFilter(QObject *watched, QEvent *event) +{ + if (watched == bannerCardDisplayWidget && (event->type() == QEvent::Resize || event->type() == QEvent::Move)) { + updateSelectionFrameGeometry(); + } + return QWidget::eventFilter(watched, event); +} + +void DeckPreviewWidget::setShareSelectable(bool selectable) +{ + shareSelectable = selectable; + if (!selectable) { + setShareSelected(false); + } + updateSelectionStyle(); +} + +void DeckPreviewWidget::setShareSelected(bool selected) +{ + if (shareSelected == selected) { + return; + } + shareSelected = selected; + updateSelectionStyle(); + emit shareSelectionToggled(selected); +} + +bool DeckPreviewWidget::isShareSelected() const +{ + return shareSelected; +} + +bool DeckPreviewWidget::isShareSelectable() const +{ + return shareSelectable; +} + +void DeckPreviewWidget::updateSelectionFrameGeometry() +{ + if (selectionFrame == nullptr || bannerCardDisplayWidget == nullptr) { + return; + } + // Frame is a child of the banner, so it is positioned in banner coordinates and + // tracks the banner's selection animation automatically. A small inset keeps the + // highlight visible around the card art without occluding it. + selectionFrame->setGeometry(bannerCardDisplayWidget->rect().adjusted(1, 1, -1, -1)); + selectionFrame->raise(); +} + +void DeckPreviewWidget::updateSelectionStyle() +{ + if (selectionFrame != nullptr) { + selectionFrame->setVisible(shareSelectable && isShareSelected()); } } @@ -79,83 +222,28 @@ void DeckPreviewWidget::enterEvent(QEnterEvent *event) { QWidget::enterEvent(event); - // don't do reloads until widgets have been created - if (bannerCardComboBox != nullptr) { - reloadIfModified(); + // Don't do reloads until the deck has actually been loaded once. + reloadIfModified(); +} + +/** + * @brief The row of this deck in the source model, or -1 if it no longer exists. + */ +int DeckPreviewWidget::row() const +{ + return model->rowForFilePath(filePath); +} + +/** + * @brief The display name is given by the deck name, or the filename if the deck name is not set. + */ +QString DeckPreviewWidget::getDisplayName() const +{ + const int r = row(); + if (r == -1) { + return {}; } -} - -/** - * @brief Sets the lastModifiedTime to the value given by the file. - */ -void DeckPreviewWidget::updateLastModifiedTime() -{ - QFileInfo fileInfo(filePath); - lastModifiedTime = fileInfo.lastModified(); -} - -/** - * @brief Writes the current contents of the deck to file. Updates the lastModifiedTime afterward. - */ -void DeckPreviewWidget::writeDeckToFile() -{ - DeckLoader::saveToFile(deckLoader->getDeck()); - updateLastModifiedTime(); -} - -void DeckPreviewWidget::initializeUi(const bool deckLoadSuccess) -{ - if (!deckLoadSuccess) { - return; - } - - QFileInfo fileInfo(filePath); - lastModifiedTime = fileInfo.lastModified(); - - bannerCardDisplayWidget->setFontSize(24); - setFilePath(deckLoader->getDeck().lastLoadInfo.fileName); - - colorIdentityWidget = new ColorIdentityWidget(this); - deckTagsDisplayWidget = new DeckPreviewDeckTagsDisplayWidget(this); - connect(deckTagsDisplayWidget, &DeckPreviewDeckTagsDisplayWidget::tagsChanged, this, &DeckPreviewWidget::setTags); - - bannerCardLabel = new QLabel(this); - bannerCardLabel->setObjectName("bannerCardLabel"); - bannerCardComboBox = new QComboBox(this); - bannerCardComboBox->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); - bannerCardComboBox->setObjectName("bannerCardComboBox"); - bannerCardComboBox->installEventFilter(new NoScrollFilter(bannerCardComboBox)); - connect(bannerCardComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, - &DeckPreviewWidget::setBannerCard); - - updateColorIdentityVisibility( - SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowColorIdentity()); - updateBannerCardComboBoxVisibility( - SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowBannerCardComboBox()); - updateTagsVisibility(SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowTagsOnDeckPreviews()); - - layout->addWidget(colorIdentityWidget); - layout->addWidget(deckTagsDisplayWidget); - layout->addWidget(bannerCardLabel); - layout->addWidget(bannerCardComboBox); - - retranslateUi(); - resyncWidgets(); -} - -/** - * @brief Syncs the contents of the child widgets with the current deck. - */ -void DeckPreviewWidget::resyncWidgets() -{ - auto bannerCardRef = deckLoader->getDeck().deckList.getBannerCard(); - auto bannerCard = bannerCardRef.name.isEmpty() ? ExactCard() : CardDatabaseManager::query()->getCard(bannerCardRef); - - bannerCardDisplayWidget->setCard(bannerCard); - refreshBannerCardText(); - updateBannerCardComboBox(bannerCardRef.name); - colorIdentityWidget->setColorIdentity(getColorIdentity()); - deckTagsDisplayWidget->setTags(deckLoader->getDeck().deckList.getTags()); + return model->dataForRow(r).displayName; } /** @@ -163,33 +251,36 @@ void DeckPreviewWidget::resyncWidgets() */ void DeckPreviewWidget::reloadIfModified() { - QFileInfo fileInfo(filePath); - QDateTime newLastModifiedTime = fileInfo.lastModified(); - - if (!newLastModifiedTime.isValid() || newLastModifiedTime <= lastModifiedTime) { + const int r = row(); + if (r == -1 || !model->dataForRow(r).loadSucceeded) { return; } - bool success = deckLoader->reload(); - - if (success) { - fileInfo.refresh(); - lastModifiedTime = fileInfo.lastModified(); - resyncWidgets(); - } + model->reloadIfModified(r); } -void DeckPreviewWidget::updateVisibility() +/** + * @brief Syncs the contents of the child widgets with the current row's data. + */ +void DeckPreviewWidget::syncFromModel() { - setHidden(!checkVisibility()); -} - -bool DeckPreviewWidget::checkVisibility() const -{ - if (filteredBySearch || filteredByColor || filteredByTags) { - return false; + const int r = row(); + if (r == -1) { + return; } - return true; + + const DeckPreviewData &data = model->dataForRow(r); + filePath = data.filePath; + + const CardRef bannerCardRef = data.deck.deckList.getBannerCard(); + const ExactCard bannerCard = + bannerCardRef.name.isEmpty() ? ExactCard() : CardDatabaseManager::query()->getCard(bannerCardRef); + + bannerCardDisplayWidget->setCard(bannerCard); + refreshBannerCardText(); + updateBannerCardComboBox(bannerCardRef.name); + colorIdentityWidget->setColorIdentity(data.colorIdentity); + deckTagsDisplayWidget->setTags(data.tags); } void DeckPreviewWidget::updateColorIdentityVisibility(bool visible) @@ -229,51 +320,6 @@ void DeckPreviewWidget::updateTagsVisibility(bool visible) } } -QString DeckPreviewWidget::getColorIdentity() -{ - QStringList cardList = deckLoader->getDeck().deckList.getCardList({DECK_ZONE_MAIN, DECK_ZONE_SIDE}); - if (cardList.isEmpty()) { - return {}; - } - - QSet colorSet; // A set to collect unique color symbols (e.g., W, U, B, R, G) - - for (const QString &cardName : cardList) { - CardInfoPtr currentCard = CardDatabaseManager::query()->getCardInfo(cardName); - if (currentCard) { - QString colors = currentCard->getColors(); // Assuming this returns something like "WUB" - for (const QChar &color : colors) { - colorSet.insert(color); - } - } - } - - // Ensure the color identity is in WUBRG order - QString colorIdentity; - const QString wubrgOrder = "WUBRG"; - for (const QChar &color : wubrgOrder) { - if (colorSet.contains(color)) { - colorIdentity.append(color); - } - } - - return colorIdentity; -} - -/** - * The display name is given by the deck name, or the filename if the deck name is not set. - */ -QString DeckPreviewWidget::getDisplayName() const -{ - QString deckName = deckLoader->getDeck().deckList.getName(); - return !deckName.isEmpty() ? deckName : QFileInfo(deckLoader->getDeck().lastLoadInfo.fileName).fileName(); -} - -void DeckPreviewWidget::setFilePath(const QString &_filePath) -{ - filePath = _filePath; -} - /** * Refreshes the banner card text. * This also calls `refreshBannerCardToolTip`, since those two often need to be updated together. @@ -310,11 +356,15 @@ void DeckPreviewWidget::updateBannerCardComboBox(const QString ¤tText) // Prepare the new items with deduplication QSet> bannerCardSet; - QList cardsInDeck = deckLoader->getDeck().deckList.getCardNodes(); + const int r = row(); + if (r != -1) { + const DeckList &deckList = model->dataForRow(r).deck.deckList; + const QList cardsInDeck = deckList.getCardNodes(); - for (auto currentCard : cardsInDeck) { - for (int k = 0; k < currentCard->getNumber(); ++k) { - bannerCardSet.insert(QPair(currentCard->getName(), currentCard->getCardProviderId())); + for (auto currentCard : cardsInDeck) { + for (int k = 0; k < currentCard->getNumber(); ++k) { + bannerCardSet.insert(QPair(currentCard->getName(), currentCard->getCardProviderId())); + } } } @@ -327,16 +377,16 @@ void DeckPreviewWidget::updateBannerCardComboBox(const QString ¤tText) // This is *slightly* more performant than using addItem in a loop. - QStandardItemModel *model = new QStandardItemModel(pairList.size(), 1, bannerCardComboBox); + QStandardItemModel *comboModel = new QStandardItemModel(pairList.size(), 1, bannerCardComboBox); int row = 0; for (const auto &pair : pairList) { QStandardItem *item = new QStandardItem(pair.first); item->setData(QVariant::fromValue(pair), Qt::UserRole); - model->setItem(row++, 0, item); + comboModel->setItem(row++, 0, item); } - bannerCardComboBox->setModel(model); + bannerCardComboBox->setModel(comboModel); // Try to restore the previous selection by finding the currentText int restoredIndex = bannerCardComboBox->findText(currentText); @@ -344,7 +394,9 @@ void DeckPreviewWidget::updateBannerCardComboBox(const QString ¤tText) bannerCardComboBox->setCurrentIndex(restoredIndex); } else { // Add a placeholder "-" and set it as the current selection - int bannerIndex = bannerCardComboBox->findText(deckLoader->getDeck().deckList.getBannerCard().name); + const QString currentBannerCardName = + r == -1 ? QString() : model->dataForRow(r).deck.deckList.getBannerCard().name; + int bannerIndex = bannerCardComboBox->findText(currentBannerCardName); if (bannerIndex != -1) { bannerCardComboBox->setCurrentIndex(bannerIndex); } else { @@ -362,8 +414,11 @@ void DeckPreviewWidget::setBannerCard(int /* changedIndex */) { auto [name, id] = bannerCardComboBox->currentData().value>(); CardRef cardRef = {name, id}; - deckLoader->getDeck().deckList.setBannerCard(cardRef); - writeDeckToFile(); + const int r = row(); + if (r == -1) { + return; + } + model->setBannerCard(r, cardRef); bannerCardDisplayWidget->setCard(CardDatabaseManager::query()->getCard(cardRef)); } @@ -376,26 +431,46 @@ void DeckPreviewWidget::imageClickedEvent(QMouseEvent *event, DeckPreviewCardPic } } +void DeckPreviewWidget::imageSingleClicked() +{ + if (isShareSelectable()) { + setShareSelected(!isShareSelected()); + } +} + void DeckPreviewWidget::imageDoubleClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance) { Q_UNUSED(event); Q_UNUSED(instance); + if (isShareSelectable()) { + return; // in share mode a double click would just toggle a single selection + } emit deckLoadRequested(filePath); } void DeckPreviewWidget::setTags(const QStringList &tags) { - deckLoader->getDeck().deckList.setTags(tags); - writeDeckToFile(); + const int r = row(); + if (r != -1) { + model->setTags(r, tags); + } } QMenu *DeckPreviewWidget::createRightClickMenu() { + const int r = row(); + auto *menu = new QMenu(this); menu->setAttribute(Qt::WA_DeleteOnClose); - connect(menu->addAction(tr("Open in deck editor")), &QAction::triggered, this, - [this] { emit openDeckEditor(deckLoader->getDeck()); }); + connect(menu->addAction(tr("Open in deck editor")), &QAction::triggered, this, [this, r] { + if (r != -1) { + emit openDeckEditor(model->deckForRow(r)); + } + }); + + connect(menu->addAction(tr("Share deck...")), &QAction::triggered, this, + [this] { emit shareDeckRequested(filePath); }); connect(menu->addAction(tr("Edit Tags")), &QAction::triggered, deckTagsDisplayWidget, &DeckPreviewDeckTagsDisplayWidget::openTagEditDlg); @@ -408,14 +483,26 @@ QMenu *DeckPreviewWidget::createRightClickMenu() auto saveToClipboardMenu = menu->addMenu(tr("Save Deck to Clipboard")); - connect(saveToClipboardMenu->addAction(tr("Annotated")), &QAction::triggered, this, - [this] { DeckLoader::saveToClipboard(deckLoader->getDeck().deckList, true, true); }); - connect(saveToClipboardMenu->addAction(tr("Annotated (No set info)")), &QAction::triggered, this, - [this] { DeckLoader::saveToClipboard(deckLoader->getDeck().deckList, true, false); }); - connect(saveToClipboardMenu->addAction(tr("Not Annotated")), &QAction::triggered, this, - [this] { DeckLoader::saveToClipboard(deckLoader->getDeck().deckList, false, true); }); - connect(saveToClipboardMenu->addAction(tr("Not Annotated (No set info)")), &QAction::triggered, this, - [this] { DeckLoader::saveToClipboard(deckLoader->getDeck().deckList, false, false); }); + connect(saveToClipboardMenu->addAction(tr("Annotated")), &QAction::triggered, this, [this, r] { + if (r != -1) { + DeckLoader::saveToClipboard(model->dataForRow(r).deck.deckList, true, true); + } + }); + connect(saveToClipboardMenu->addAction(tr("Annotated (No set info)")), &QAction::triggered, this, [this, r] { + if (r != -1) { + DeckLoader::saveToClipboard(model->dataForRow(r).deck.deckList, true, false); + } + }); + connect(saveToClipboardMenu->addAction(tr("Not Annotated")), &QAction::triggered, this, [this, r] { + if (r != -1) { + DeckLoader::saveToClipboard(model->dataForRow(r).deck.deckList, false, true); + } + }); + connect(saveToClipboardMenu->addAction(tr("Not Annotated (No set info)")), &QAction::triggered, this, [this, r] { + if (r != -1) { + DeckLoader::saveToClipboard(model->dataForRow(r).deck.deckList, false, false); + } + }); menu->addSeparator(); @@ -450,57 +537,58 @@ void DeckPreviewWidget::addSetBannerCardMenu(QMenu *menu) void DeckPreviewWidget::actRenameDeck() { + const int r = row(); + if (r == -1) { + return; + } + // read input - const QString oldName = deckLoader->getDeck().deckList.getName(); + const QString oldName = model->dataForRow(r).deckName; bool ok; - QString newName = QInputDialog::getText(this, "Rename deck", tr("New name:"), QLineEdit::Normal, oldName, &ok); + QString newName = QInputDialog::getText(this, tr("Rename deck"), tr("New name:"), QLineEdit::Normal, oldName, &ok); if (!ok || oldName == newName) { return; } // write change - deckLoader->getDeck().deckList.setName(newName); - writeDeckToFile(); + model->renameDeck(r, newName); - // update VDS - refreshBannerCardText(); + // The banner card text updates via the model's dataChanged signal. } void DeckPreviewWidget::actRenameFile() { + const int r = row(); + if (r == -1) { + return; + } + // read input const auto info = QFileInfo(filePath); const QString oldName = info.baseName(); bool ok; - QString newName = QInputDialog::getText(this, "Rename file", tr("New name:"), QLineEdit::Normal, oldName, &ok); + QString newName = QInputDialog::getText(this, tr("Rename file"), tr("New name:"), QLineEdit::Normal, oldName, &ok); if (!ok || newName.isEmpty() || oldName == newName) { return; } - QString newFileName = newName; - if (!info.suffix().isEmpty()) { - newFileName += "." + info.suffix(); - } - // write change - const QString newFilePath = QFileInfo(info.dir(), newFileName).filePath(); - if (!QFile::rename(info.filePath(), newFilePath)) { + if (!model->renameFile(r, newName)) { QMessageBox::critical(this, tr("Error"), tr("Rename failed")); - return; } - deckLoader->getDeck().lastLoadInfo.fileName = newFilePath; - setFilePath(newFilePath); - - // update VDS - updateLastModifiedTime(); - refreshBannerCardText(); + // The file path and banner card text update via the model's signals. } void DeckPreviewWidget::actDeleteFile() { + const int r = row(); + if (r == -1) { + return; + } + // read input auto res = QMessageBox::warning(this, tr("Delete file"), tr("Are you sure you want to delete the selected file?"), QMessageBox::Yes | QMessageBox::No); @@ -509,11 +597,22 @@ void DeckPreviewWidget::actDeleteFile() } // write change - if (!QFile::remove(QFileInfo(filePath).filePath())) { + if (!model->deleteFile(r)) { QMessageBox::critical(this, tr("Error"), tr("Delete failed")); - return; } - // update VDS - this->deleteLater(); + // The folder widget removes this preview once the row is gone. +} + +/** + * Checks if the deck's file format supports tags. + * If not, then prompt the user for file conversion. + * @return whether the resulting file can support adding tags + */ +bool DeckPreviewWidget::promptFileConversionIfRequired() +{ + return DialogConvertDeckToCodFormat::promptIfRequired(this, filePath, [this] { + model->convertToCockatriceFormat(row()); + return true; + }); } diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h index de66c194b..cdeed87c7 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h @@ -2,90 +2,117 @@ * @file deck_preview_widget.h * @ingroup VisualDeckPreviewWidgets */ -//! \todo Document this file. #ifndef DECK_PREVIEW_WIDGET_H #define DECK_PREVIEW_WIDGET_H -#include "../../../deck_loader/deck_loader.h" -#include "../../cards/additional_info/color_identity_widget.h" -#include "../../cards/deck_preview_card_picture_widget.h" -#include "../visual_deck_storage_widget.h" -#include "deck_preview_deck_tags_display_widget.h" - #include +#include #include #include #include -#include +#include +#include +#include +#include #include +#include +class QFrame; +class QLabel; class QMenu; -class VisualDeckStorageWidget; +class QMouseEvent; +class ColorIdentityWidget; +class DeckPreviewCardPictureWidget; class DeckPreviewDeckTagsDisplayWidget; +class VisualDeckStorageModel; +class VisualDeckStorageWidget; +class QVBoxLayout; +struct LoadedDeck; class DeckPreviewWidget final : public QWidget { Q_OBJECT public: - explicit DeckPreviewWidget(QWidget *_parent, + explicit DeckPreviewWidget(QWidget *parent, VisualDeckStorageWidget *_visualDeckStorageWidget, + VisualDeckStorageModel *_model, const QString &_filePath); void retranslateUi(); - QString getColorIdentity(); - [[nodiscard]] QString getDisplayName() const; - VisualDeckStorageWidget *visualDeckStorageWidget; - QVBoxLayout *layout; + /** + * @brief The banner card picture; the parent widget wires its size to the card size setting. + */ + DeckPreviewCardPictureWidget *bannerCardDisplayWidget; + + /** @brief The path of the deck file backing this preview. */ QString filePath; - QDateTime lastModifiedTime; - DeckLoader *deckLoader; - DeckPreviewCardPictureWidget *bannerCardDisplayWidget = nullptr; - ColorIdentityWidget *colorIdentityWidget = nullptr; - DeckPreviewDeckTagsDisplayWidget *deckTagsDisplayWidget = nullptr; - QLabel *bannerCardLabel = nullptr; - QComboBox *bannerCardComboBox = nullptr; - bool filteredBySearch = false; - bool filteredByColor = false; - bool filteredByTags = false; - [[nodiscard]] bool checkVisibility() const; + + void setShareSelectable(bool selectable); + void setShareSelected(bool selected); + [[nodiscard]] bool isShareSelected() const; + [[nodiscard]] bool isShareSelectable() const; signals: void deckLoadRequested(const QString &filePath); void openDeckEditor(const LoadedDeck &deck); + void shareDeckRequested(const QString &filePath); + void shareSelectionToggled(bool selected); public slots: - void setFilePath(const QString &filePath); - void refreshBannerCardText(); - void refreshBannerCardToolTip(); - void updateBannerCardComboBox(const QString ¤tText); - void setBannerCard(int); - void imageClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); - void imageDoubleClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); - void initializeUi(bool deckLoadSuccess); - void resyncWidgets(); + /** + * @brief Re-reads the row's data from the model and syncs every child widget. + * Connected to the model's dataChanged signal. + */ + void syncFromModel(); + + /** + * @brief Reloads the deck file if its modification time is newer than the stored one. + */ void reloadIfModified(); - void updateVisibility(); + void refreshBannerCardToolTip(); void updateColorIdentityVisibility(bool visible); void updateBannerCardComboBoxVisibility(bool visible); void updateTagsVisibility(bool visible); - void resizeEvent(QResizeEvent *event) override; + void setBannerCard(int); + void setTags(const QStringList &tags); protected: void enterEvent(QEnterEvent *event) override; + void resizeEvent(QResizeEvent *event) override; + bool eventFilter(QObject *watched, QEvent *event) override; private: - void updateLastModifiedTime(); - void writeDeckToFile(); + [[nodiscard]] int row() const; + [[nodiscard]] QString getDisplayName() const; + void refreshBannerCardText(); + void updateBannerCardComboBox(const QString ¤tText); + bool promptFileConversionIfRequired(); QMenu *createRightClickMenu(); void addSetBannerCardMenu(QMenu *menu); - -private slots: - void setTags(const QStringList &tags); + void imageClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); + void imageSingleClicked(); + void imageDoubleClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); void actRenameDeck(); void actRenameFile(); void actDeleteFile(); + + VisualDeckStorageWidget *visualDeckStorageWidget; + VisualDeckStorageModel *model; + QVBoxLayout *layout; + ColorIdentityWidget *colorIdentityWidget; + DeckPreviewDeckTagsDisplayWidget *deckTagsDisplayWidget; + QLabel *bannerCardLabel; + QComboBox *bannerCardComboBox; + QList fixedWidthChildren; ///< Children clamped to the picture width on resize. + int lastKnownBannerWidth = -1; ///< The picture width last applied to the children. + QFrame *selectionFrame = nullptr; + bool shareSelectable = false; + bool shareSelected = false; + + void updateSelectionStyle(); + void updateSelectionFrameGeometry(); }; class NoScrollFilter : public QObject diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.cpp new file mode 100644 index 000000000..361747423 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.cpp @@ -0,0 +1,171 @@ +#include "public_deck_preview_widget.h" + +#include "../../../../client/settings/cache_settings.h" +#include "../../cards/additional_info/color_identity_widget.h" +#include "../../cards/deck_preview_card_picture_widget.h" +#include "../../general/layout_containers/flow_widget.h" +#include "../remote_public_decks_model.h" +#include "deck_preview_tag_display_widget.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/utility/card_ref.h" + +#include +#include +#include +#include +#include +#include +#include + +PublicDeckPreviewWidget::PublicDeckPreviewWidget(QWidget *parent, const RemotePublicDecksModel::DeckEntry &entry) + : QWidget(parent) +{ + bannerCardDisplayWidget = new DeckPreviewCardPictureWidget(this); + bannerCardDisplayWidget->setFontSize(24); + + // The whole tile is a single focusable, keyboard-operable control: Tab lands + // on it and Space/Enter opens the deck, mirroring the shared-deck preview tile. + setFocusPolicy(Qt::StrongFocus); + + uploadTimeLabel = new QLabel(this); + uploadTimeLabel->setAlignment(Qt::AlignHCenter); + + colorIdentityWidget = new ColorIdentityWidget(this); + + tagsFlowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); + tagsFlowWidget->setSpacing(3, 3); + + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(bannerCardDisplayWidget); + layout->addWidget(uploadTimeLabel); + layout->addWidget(colorIdentityWidget); + layout->addWidget(tagsFlowWidget); + setLayout(layout); + + connect(&SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::visualDeckStorageShowColorIdentityChanged, this, + &PublicDeckPreviewWidget::updateColorIdentityVisibility); + connect(&SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::visualDeckStorageShowTagsOnDeckPreviewsChanged, this, + &PublicDeckPreviewWidget::updateTagsVisibility); + connect(&SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::visualDeckStorageShowUploadTimeChanged, this, + &PublicDeckPreviewWidget::updateUploadTimeVisibility); + + setEntry(entry); + + connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageClicked, this, + &PublicDeckPreviewWidget::imageClickedEvent); + connect(bannerCardDisplayWidget, &DeckPreviewCardPictureWidget::imageDoubleClicked, this, + &PublicDeckPreviewWidget::imageDoubleClickedEvent); + + // resizeEvent clamps every child to the banner picture's width, so collect them + // once here to keep the resize handler from searching the widget tree on every pass. + fixedWidthChildren = {bannerCardDisplayWidget, uploadTimeLabel, colorIdentityWidget, tagsFlowWidget}; +} + +void PublicDeckPreviewWidget::resizeEvent(QResizeEvent *event) +{ + QWidget::resizeEvent(event); + if (bannerCardDisplayWidget == nullptr) { + return; + } + + const int width = bannerCardDisplayWidget->width(); + if (width == lastKnownBannerWidth) { + return; + } + lastKnownBannerWidth = width; + + for (QWidget *widget : fixedWidthChildren) { + widget->setMaximumWidth(width); + } +} + +void PublicDeckPreviewWidget::setEntry(const RemotePublicDecksModel::DeckEntry &entry) +{ + deckId = entry.id; + + hasColorIdentity = !entry.colorIdentity.isEmpty(); + colorIdentityWidget->setColorIdentity(entry.colorIdentity); + updateColorIdentityVisibility(); + + const ExactCard bannerCard = + entry.bannerCardName.isEmpty() + ? ExactCard() + : CardDatabaseManager::query()->getCard(CardRef{entry.bannerCardName, entry.bannerCardProvider}); + bannerCardDisplayWidget->setCard(bannerCard); + + // The deck name is the overlay text on the banner, like the local preview. + bannerCardDisplayWidget->setOverlayText(entry.name); + // The deck name comes from another user's record, and Qt tooltips are + // rendered as AutoText, so escape and bound it to keep it readable text + // (the overlay painted onto the banner is already a plain painter draw). + setToolTip(entry.name.left(200).toHtmlEscaped()); + setBaseAccessibleName(entry.name); + + tagsFlowWidget->clearLayout(); + for (const QString &tag : entry.tags) { + auto *chip = new DeckPreviewTagDisplayWidget(tagsFlowWidget, tag); + chip->setAttribute(Qt::WA_TransparentForMouseEvents); + tagsFlowWidget->addWidget(chip); + } + hasTags = !entry.tags.isEmpty(); + updateTagsVisibility(); + + uploadTimeLabel->setText(tr("Uploaded %1").arg(entry.uploadTime.toString(Qt::TextDate))); + hasUploadTime = !entry.uploadTime.isNull(); + updateUploadTimeVisibility(); +} + +void PublicDeckPreviewWidget::updateColorIdentityVisibility() +{ + colorIdentityWidget->setVisible( + hasColorIdentity && SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowColorIdentity()); +} + +void PublicDeckPreviewWidget::updateTagsVisibility() +{ + tagsFlowWidget->setVisible( + hasTags && SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowTagsOnDeckPreviews()); +} + +void PublicDeckPreviewWidget::updateUploadTimeVisibility() +{ + uploadTimeLabel->setVisible(hasUploadTime && + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowUploadTime()); +} + +void PublicDeckPreviewWidget::keyPressEvent(QKeyEvent *event) +{ + if (event->key() == Qt::Key_Space || event->key() == Qt::Key_Enter || event->key() == Qt::Key_Return) { + event->accept(); + emit openDeckRequested(deckId); + return; + } + QWidget::keyPressEvent(event); +} + +void PublicDeckPreviewWidget::setBaseAccessibleName(const QString &name) +{ + baseAccessibleName = name; + setAccessibleName(name); +} + +void PublicDeckPreviewWidget::setScaleFactor(int scale) +{ + bannerCardDisplayWidget->setScaleFactor(scale); +} + +void PublicDeckPreviewWidget::imageClickedEvent(QMouseEvent * /*event*/, DeckPreviewCardPictureWidget * /*instance*/) +{ + // Reserved: clicking could show a card popup for the banner card. +} + +void PublicDeckPreviewWidget::imageDoubleClickedEvent(QMouseEvent * /*event*/, + DeckPreviewCardPictureWidget * /*instance*/) +{ + emit openDeckRequested(deckId); +} diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.h new file mode 100644 index 000000000..103120c7f --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/public_deck_preview_widget.h @@ -0,0 +1,74 @@ +/** + * @file public_deck_preview_widget.h + * @ingroup VisualDeckPreviewWidgets + */ + +#ifndef PUBLIC_DECK_PREVIEW_WIDGET_H +#define PUBLIC_DECK_PREVIEW_WIDGET_H + +#include "../remote_public_decks_model.h" + +#include +#include +#include +#include +#include + +class ColorIdentityWidget; +class DeckPreviewCardPictureWidget; +class FlowWidget; +class QLabel; + +/** + * @brief A preview tile for a public deck published by another user. + * + * Renders the banner card picture (looked up by name/provider in the card + * database) with the deck name overlaid, the color identity, the deck's tags + * (read-only) and its upload time, all from the metadata the server stores for + * the deck, so no deck list is downloaded until the user actually opens the + * deck. Double-clicking the banner requests opening it. + */ +class PublicDeckPreviewWidget final : public QWidget +{ + Q_OBJECT + +public: + explicit PublicDeckPreviewWidget(QWidget *parent, const RemotePublicDecksModel::DeckEntry &entry); + + void setEntry(const RemotePublicDecksModel::DeckEntry &entry); + + /** @brief Sets the accessible name announced to assistive technologies. */ + void setBaseAccessibleName(const QString &name); + + /** @brief Scales the banner card picture, mirroring the Visual Deck Storage. */ + void setScaleFactor(int scale); + +signals: + void openDeckRequested(int deckId); + +protected: + void resizeEvent(QResizeEvent *event) override; + void keyPressEvent(QKeyEvent *event) override; + +private slots: + void imageClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); + void imageDoubleClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); + void updateColorIdentityVisibility(); + void updateTagsVisibility(); + void updateUploadTimeVisibility(); + +private: + int deckId = 0; + QString baseAccessibleName; + bool hasColorIdentity = false; + bool hasTags = false; + bool hasUploadTime = false; + int lastKnownBannerWidth = 0; + QList fixedWidthChildren; + DeckPreviewCardPictureWidget *bannerCardDisplayWidget; + ColorIdentityWidget *colorIdentityWidget; + FlowWidget *tagsFlowWidget; + QLabel *uploadTimeLabel; +}; + +#endif // PUBLIC_DECK_PREVIEW_WIDGET_H diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/remote_public_decks_model.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/remote_public_decks_model.cpp new file mode 100644 index 000000000..9b8daf87c --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/remote_public_decks_model.cpp @@ -0,0 +1,226 @@ +#include "remote_public_decks_model.h" + +#include "../../../client/settings/cache_settings.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class QObject; + +RemotePublicDecksModel::RemotePublicDecksModel(AbstractClient *_client, QObject *parent) + : QAbstractListModel(parent), client(_client) +{ + // The ping sweep can drop a pending command without ever emitting finished, + // so loading must not be a latch: time it out and clear it when the client + // goes away, or the tab is stuck on the loading state for the session. + loadingTimeoutTimer = new QTimer(this); + loadingTimeoutTimer->setSingleShot(true); + loadingTimeoutTimer->setInterval( + static_cast((static_cast(SettingsCache::instance().network().getTimeOut()) + 1) * + SettingsCache::instance().network().getKeepAlive() * 1000)); + connect(loadingTimeoutTimer, &QTimer::timeout, this, &RemotePublicDecksModel::onLoadingTimeout); + connect(client, &AbstractClient::statusChanged, this, [this](ClientStatus status) { + if (status == StatusDisconnected) { + loadingTimeoutTimer->stop(); + setLoading(false); + } + }); +} + +int RemotePublicDecksModel::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : visibleIndices.size(); +} + +QVariant RemotePublicDecksModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= visibleIndices.size()) { + return QVariant(); + } + if (role == Qt::DisplayRole || role == Qt::ToolTipRole) { + return decks.at(visibleIndices.at(index.row())).name; + } + return QVariant(); +} + +RemotePublicDecksModel::DeckEntry RemotePublicDecksModel::entryAt(int row) const +{ + if (row < 0 || row >= visibleIndices.size()) { + return DeckEntry{}; + } + return decks.at(visibleIndices.at(row)); +} + +void RemotePublicDecksModel::setSearchText(const QString &text) +{ + searchText = text.trimmed(); + rebuildVisibleIndices(); +} + +void RemotePublicDecksModel::setColorFilter(VisualDeckStorageSortFilterProxyModel::FilterMode mode, + const QSet &colors) +{ + colorFilterMode = mode; + activeColors = colors; + rebuildVisibleIndices(); +} + +void RemotePublicDecksModel::setTagFilter(const QSet &selected, const QSet &excluded) +{ + includedTags = selected; + excludedTags = excluded; + rebuildVisibleIndices(); +} + +QSet RemotePublicDecksModel::allTags() const +{ + QSet all; + for (const DeckEntry &entry : decks) { + all.unite(QSet(entry.tags.cbegin(), entry.tags.cend())); + } + return all; +} + +void RemotePublicDecksModel::rebuildVisibleIndices() +{ + QList newIndices; + newIndices.reserve(decks.size()); + for (int row = 0; row < decks.size(); ++row) { + const DeckEntry &entry = decks.at(row); + + if (!searchText.isEmpty() && !entry.name.contains(searchText, Qt::CaseInsensitive)) { + continue; + } + + if (!activeColors.isEmpty()) { + const QString &identity = entry.colorIdentity; + if (!colorIdentityMatches(colorFilterMode, activeColors, identity)) { + continue; + } + } + + if (!includedTags.isEmpty()) { + const QSet entryTags(entry.tags.cbegin(), entry.tags.cend()); + bool hasAll = std::all_of(includedTags.begin(), includedTags.end(), + [&entryTags](const QString &tag) { return entryTags.contains(tag); }); + if (!hasAll) { + continue; + } + } + + if (!excludedTags.isEmpty() && std::any_of(excludedTags.begin(), excludedTags.end(), + [&entry](const QString &tag) { return entry.tags.contains(tag); })) { + continue; + } + + newIndices.append(row); + } + + beginResetModel(); + visibleIndices = newIndices; + endResetModel(); +} + +void RemotePublicDecksModel::refresh(const QString &userName) +{ + if (loading) { + return; + } + // Every refresh captures its own request id so a reply that lands after its + // loading timeout (the reverse of the ping sweep dropping the command) is + // recognised as stale: it must not stop the newer request's timer or paint + // the grid with out-of-date data. + const int seq = ++requestSequence; + setLoading(true); + loadingTimeoutTimer->start(); + Command_DeckListOtherUser cmd; + cmd.set_user_name(userName.toStdString()); + PendingCommand *pend = client->prepareSessionCommand(cmd); + connect(pend, &PendingCommand::finished, this, + [this, seq](const Response &response, const CommandContainer &commandContainer) { + if (seq != requestSequence) { + return; // a newer refresh superseded this one + } + decksReceived(response, commandContainer); + }); + client->sendCommand(pend); +} + +void RemotePublicDecksModel::onLoadingTimeout() +{ + setLoading(false); + emit loadFailed(tr("The server did not respond in time. Try again.")); +} + +void RemotePublicDecksModel::clear() +{ + decks.clear(); + rebuildVisibleIndices(); +} + +void RemotePublicDecksModel::setLoading(bool value) +{ + if (loading == value) { + return; + } + loading = value; + emit loadingChanged(loading); +} + +void RemotePublicDecksModel::decksReceived(const Response &response, const CommandContainer & /*commandContainer*/) +{ + setLoading(false); + loadingTimeoutTimer->stop(); + if (response.response_code() != Response::RespOk) { + emit loadFailed(tr("Failed to load the user's public decks (server response code %1).") + .arg(QString::number(static_cast(response.response_code())))); + return; + } + + const Response_DeckList &resp = response.GetExtension(Response_DeckList::ext); + decks.clear(); + addFolder(resp.root()); + rebuildVisibleIndices(); +} + +void RemotePublicDecksModel::addFolder(const ServerInfo_DeckStorage_Folder &folder) +{ + const int itemCount = folder.items_size(); + for (int i = 0; i < itemCount; ++i) { + addTreeItem(folder.items(i)); + } +} + +void RemotePublicDecksModel::addTreeItem(const ServerInfo_DeckStorage_TreeItem &item) +{ + if (item.has_folder()) { + addFolder(item.folder()); + return; + } + + const ServerInfo_DeckStorage_File &file = item.file(); + DeckEntry entry; + entry.id = item.id(); + entry.name = QString::fromStdString(item.name()); + entry.uploadTime = QDateTime::fromSecsSinceEpoch(file.creation_time()); + entry.bannerCardName = QString::fromStdString(file.banner_card_name()); + entry.bannerCardProvider = QString::fromStdString(file.banner_card_provider()); + entry.colorIdentity = QString::fromStdString(file.color_identity()); + QStringList tags; + for (const auto &tag : file.tags()) { + tags.append(QString::fromStdString(tag)); + } + entry.tags = tags; + decks.append(entry); +} diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/remote_public_decks_model.h b/cockatrice/src/interface/widgets/visual_deck_storage/remote_public_decks_model.h new file mode 100644 index 000000000..cb1f786e4 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/remote_public_decks_model.h @@ -0,0 +1,135 @@ +/** + * @file remote_public_decks_model.h + * @ingroup DeckStorageWidgets + */ + +#ifndef REMOTE_PUBLIC_DECKS_MODEL_H +#define REMOTE_PUBLIC_DECKS_MODEL_H + +#include "visual_deck_storage_sort_filter_proxy_model.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class AbstractClient; +class CommandContainer; +class QTimer; +class Response; +class ServerInfo_DeckStorage_Folder; +class ServerInfo_DeckStorage_TreeItem; +class QObject; + +/** + * @brief Flat, read-only list of the public decks published by another user. + * + * Fetches the target user's public decks via Command_DeckListOtherUser and + * flattens the response tree into entries carrying the preview metadata stored + * on the server (banner card name/provider and color identity). No deck list is + * downloaded until the user actually opens a deck. + * + * Name and color-identity filtering is applied against this metadata, mirroring + * the Visual Deck Storage's filter semantics, so the grid can be narrowed like + * the local deck storage. + */ +class RemotePublicDecksModel : public QAbstractListModel +{ + Q_OBJECT + +public: + struct DeckEntry + { + int id = 0; + QString name; + QDateTime uploadTime; + QString bannerCardName; + QString bannerCardProvider; + QString colorIdentity; + QStringList tags; + }; + + /** + * @brief The color identity filter mode, shared with the Visual Deck Storage. + */ + using FilterMode = VisualDeckStorageSortFilterProxyModel::FilterMode; + + explicit RemotePublicDecksModel(AbstractClient *client, QObject *parent = nullptr); + + [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; + [[nodiscard]] QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + /** @brief Fetches the public decks of another user, replacing the current contents. */ + void refresh(const QString &userName); + void clear(); + + /** @brief Sets a case-insensitive substring filter on the deck name. */ + void setSearchText(const QString &text); + + /** @brief Sets the active color identity filter and mode. */ + void setColorFilter(FilterMode mode, const QSet &colors); + + /** @brief Filters decks by required (`selected`) and forbidden (`excluded`) tags. */ + void setTagFilter(const QSet &selected, const QSet &excluded); + + /** @brief All tags present across all loaded decks, for building filter chips. */ + [[nodiscard]] QSet allTags() const; + + /** @brief The number of decks after filtering. */ + [[nodiscard]] int filteredCount() const + { + return visibleIndices.size(); + } + + /** @brief The number of decks before filtering. */ + [[nodiscard]] int totalCount() const + { + return decks.size(); + } + + /** @brief True while a refresh request is in flight and the grid has no data yet. */ + [[nodiscard]] bool isLoading() const + { + return loading; + } + + [[nodiscard]] DeckEntry entryAt(int row) const; + +signals: + /** @brief Emitted when a refresh starts, completes, or fails (see loading()). */ + void loadingChanged(bool loading); + + /** @brief Emitted when the last refresh failed; contains a user-facing message. */ + void loadFailed(const QString &message); + +private slots: + void decksReceived(const Response &response, const CommandContainer &commandContainer); + void onLoadingTimeout(); + +private: + void addFolder(const ServerInfo_DeckStorage_Folder &folder); + void addTreeItem(const ServerInfo_DeckStorage_TreeItem &item); + void rebuildVisibleIndices(); + void setLoading(bool value); + + AbstractClient *client; + QTimer *loadingTimeoutTimer; + QList decks; + QList visibleIndices; ///< Row indices into `decks` that pass the current filters. + bool loading = false; + int requestSequence = 0; ///< Monotonically increases per refresh; only the newest request may update the grid. + + QString searchText; + VisualDeckStorageSortFilterProxyModel::FilterMode colorFilterMode = VisualDeckStorageSortFilterProxyModel::Includes; + QSet activeColors; + QSet includedTags; + QSet excludedTags; +}; + +#endif // REMOTE_PUBLIC_DECKS_MODEL_H diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp index cc7f07871..44b7a1a06 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp @@ -1,20 +1,39 @@ #include "visual_deck_storage_folder_display_widget.h" -#include "../../../client/settings/cache_settings.h" +#include "../cards/card_info_picture_widget.h" +#include "../cards/card_size_widget.h" +#include "../cards/deck_preview_card_picture_widget.h" +#include "../general/display/banner_widget.h" +#include "../general/layout_containers/flow_widget.h" #include "deck_preview/deck_preview_widget.h" +#include "visual_deck_storage_model.h" +#include "visual_deck_storage_quick_settings_widget.h" +#include "visual_deck_storage_sort_filter_proxy_model.h" #include "visual_deck_storage_widget.h" -#include -#include -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include VisualDeckStorageFolderDisplayWidget::VisualDeckStorageFolderDisplayWidget( QWidget *parent, VisualDeckStorageWidget *_visualDeckStorageWidget, - QString _filePath, + const QString &_folderPath, bool canBeHidden, bool _showFolders) - : QWidget(parent), showFolders(_showFolders), visualDeckStorageWidget(_visualDeckStorageWidget), filePath(_filePath) + : QWidget(parent), showFolders(_showFolders), folderPath(_folderPath), + visualDeckStorageWidget(_visualDeckStorageWidget) { layout = new QVBoxLayout(this); setLayout(layout); @@ -22,6 +41,9 @@ VisualDeckStorageFolderDisplayWidget::VisualDeckStorageFolderDisplayWidget( header = new BannerWidget(this, ""); header->setClickable(canBeHidden); header->setHidden(!showFolders); + + const QString bannerText = folderPath.isEmpty() ? tr("Deck Storage") : folderPath; + header->setText(bannerText); layout->addWidget(header); container = new QWidget(this); @@ -35,192 +57,307 @@ VisualDeckStorageFolderDisplayWidget::VisualDeckStorageFolderDisplayWidget( flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAlwaysOff); containerLayout->addWidget(flowWidget); - createWidgetsForFiles(); - createWidgetsForFolders(); + visualDeckStorageWidget->settings()->getCardSizeWidget()->enableCtrlScrollResize(this); - refreshUi(); + auto *proxy = visualDeckStorageWidget->proxyModel(); + // A burst of proxy changes (one dataChanged per finished deck load, plus the filter + // invalidations) coalesces into a single reconcile, so a scan of many decks doesn't + // rebuild the flow layout once per deck. + connect(proxy, &QAbstractItemModel::modelReset, this, &VisualDeckStorageFolderDisplayWidget::scheduleReconcile); + connect(proxy, &QAbstractItemModel::rowsInserted, this, &VisualDeckStorageFolderDisplayWidget::scheduleReconcile); + connect(proxy, &QAbstractItemModel::rowsRemoved, this, &VisualDeckStorageFolderDisplayWidget::scheduleReconcile); + connect(proxy, &QAbstractItemModel::dataChanged, this, &VisualDeckStorageFolderDisplayWidget::scheduleReconcile); + connect(proxy, &QAbstractItemModel::layoutChanged, this, &VisualDeckStorageFolderDisplayWidget::scheduleReconcile); + + reconcileTimer = new QTimer(this); + reconcileTimer->setSingleShot(true); + reconcileTimer->setInterval(150); + connect(reconcileTimer, &QTimer::timeout, this, &VisualDeckStorageFolderDisplayWidget::reconcile); + + // Building the whole folder subtree synchronously here would stall the ui thread on large + // collections, so the first reconcile runs as a chunked pass on later event loop turns. + scheduleReconcile(); } -void VisualDeckStorageFolderDisplayWidget::refreshUi() +void VisualDeckStorageFolderDisplayWidget::scheduleReconcile() { - QString bannerText = tr("Deck Storage"); - QString deckPath = SettingsCache::instance().paths().getDeckPath(); - if (filePath != deckPath) { - QString relativePath = filePath; - - if (filePath.startsWith(deckPath)) { - relativePath = filePath.mid(deckPath.length()); // Remove the deckPath prefix - if (relativePath.startsWith('/')) { - relativePath.remove(0, 1); // Remove leading '/' if it exists - } - } - - bannerText = relativePath; + if (deckPassActive) { + // The active pass may be scanning stale model state, so restart it from a clean + // slate once the current chunk yields. + deckPassRestartRequested = true; + return; } - header->setText(bannerText); + reconcileTimer->start(); } /** - * Gets all files in the directory that have an accepted decklist file extension - * - * @param filePath The directory to search through - * @param recursive Whether to search through subdirectories + * @brief Starts a new chunked scan of the source model, yielding to the event loop between chunks. */ -static QStringList getAllFiles(const QString &filePath, bool recursive) +void VisualDeckStorageFolderDisplayWidget::reconcile() { - QStringList allFiles; - - // QDirIterator with QDir::Files ensures only files are listed (no directories) - auto flags = - recursive ? QDirIterator::Subdirectories | QDirIterator::FollowSymlinks : QDirIterator::NoIteratorFlags; - QDirIterator it(filePath, DeckLoader::ACCEPTED_FILE_EXTENSIONS, QDir::Files, flags); - - while (it.hasNext()) { - allFiles << it.next(); // Add each file path to the list - } - - return allFiles; + beginDeckPass(); } -void VisualDeckStorageFolderDisplayWidget::createWidgetsForFiles() +void VisualDeckStorageFolderDisplayWidget::beginDeckPass() { - QList allDecks; - for (const QString &file : getAllFiles(filePath, !showFolders)) { - auto *display = new DeckPreviewWidget(flowWidget, visualDeckStorageWidget, file); + deckPassActive = true; + deckPassRestartRequested = false; + deckPassRow = 0; + visibleDeckCount = 0; + deckPassPresentPaths.clear(); - connect(display, &DeckPreviewWidget::deckLoadRequested, visualDeckStorageWidget, - &VisualDeckStorageWidget::deckLoadRequested); - connect(display, &DeckPreviewWidget::openDeckEditor, visualDeckStorageWidget, - &VisualDeckStorageWidget::openDeckEditor); - connect(visualDeckStorageWidget->settings(), &VisualDeckStorageQuickSettingsWidget::cardSizeChanged, - display->bannerCardDisplayWidget, &CardInfoPictureWidget::setScaleFactor); - display->bannerCardDisplayWidget->setScaleFactor(visualDeckStorageWidget->settings()->getCardSize()); - allDecks.append(display); + continueDeckPass(); +} + +void VisualDeckStorageFolderDisplayWidget::continueDeckPass() +{ + if (!deckPassActive) { + return; } - flowWidget->clearLayout(); // Clear existing widgets in the flow layout + QElapsedTimer passTimer; + passTimer.start(); - for (DeckPreviewWidget *deck : allDecks) { - flowWidget->addWidget(deck); + auto *proxy = visualDeckStorageWidget->proxyModel(); + const int proxyRowCount = proxy->rowCount(); + + // Scan rows of this folder, creating missing previews, until the time budget for this + // event loop turn runs out. The rest continues on the next turn. + while (deckPassRow < proxyRowCount) { + const int row = deckPassRow++; + const QModelIndex index = proxy->index(row, 0); + if (showFolders && index.data(VisualDeckStorageRoles::FolderPathRole).toString() != folderPath) { + continue; + } + const QString filePath = index.data(VisualDeckStorageRoles::FilePathRole).toString(); + deckPassPresentPaths.insert(filePath); + + DeckPreviewWidget *deckPreviewWidget = deckWidgets.value(filePath, nullptr); + if (!deckPreviewWidget) { + deckPreviewWidget = createDeckPreviewWidget(filePath); + flowWidget->addWidget(deckPreviewWidget); + } + + const bool matches = index.data(VisualDeckStorageRoles::FilterMatchRole).toBool(); + deckPreviewWidget->setVisible(matches); + if (!matches) { + // A deck that no longer matches the filters is dropped from the selection so its + // highlight cannot linger on an invisible preview or be counted in the share. + deckPreviewWidget->setShareSelected(false); + } + if (matches) { + ++visibleDeckCount; + } + + if (passTimer.elapsed() >= DECK_PASS_TIME_BUDGET_MS) { + break; + } + } + + if (deckPassRestartRequested) { + beginDeckPass(); + return; + } + + if (deckPassRow < proxyRowCount) { + QMetaObject::invokeMethod(this, &VisualDeckStorageFolderDisplayWidget::continueDeckPass, Qt::QueuedConnection); + return; + } + + finishDeckPass(); +} + +void VisualDeckStorageFolderDisplayWidget::finishDeckPass() +{ + auto *proxy = visualDeckStorageWidget->proxyModel(); + + // Drop previews of decks that no longer exist in the model. + for (auto it = deckWidgets.begin(); it != deckWidgets.end();) { + if (!deckPassPresentPaths.contains(it.key())) { + flowWidget->removeWidget(it.value()); + it.value()->deleteLater(); + it = deckWidgets.erase(it); + } else { + ++it; + } + } + + // Order the flow layout like the proxy sorts its rows. Filtered-out decks stay part of + // the layout, hidden in their sorted place until a filter lets them through again. + QStringList orderedFilePaths; + orderedFilePaths.reserve(proxy->rowCount()); + for (int proxyRow = 0; proxyRow < proxy->rowCount(); ++proxyRow) { + const QString filePath = proxy->index(proxyRow, 0).data(VisualDeckStorageRoles::FilePathRole).toString(); + if (deckWidgets.contains(filePath)) { + orderedFilePaths.append(filePath); + } + } + + // Re-add all widgets so the flow layout order matches the proxy order. Skipped when the + // order is unchanged so that data-only updates don't invalidate the flow layout. + if (orderedFilePaths != lastOrderedFilePaths) { + for (const QString &filePath : orderedFilePaths) { + flowWidget->removeWidget(deckWidgets.value(filePath)); + } + for (const QString &filePath : orderedFilePaths) { + flowWidget->addWidget(deckWidgets.value(filePath)); + } + lastOrderedFilePaths = orderedFilePaths; + } + + createSubFolderWidgets(); + + // Mark completion before evaluating visibility so this pass's own numbers decide whether + // the folder has content. The flag only guards evaluations made *during* a build. + deckPassActive = false; + initialPassCompleted = true; + + refreshVisibility(); +} + +/** + * @brief Creates a deck preview widget and wires it up to the storage widget. + * + * @param filePath The absolute path of the deck file to preview. + */ +DeckPreviewWidget *VisualDeckStorageFolderDisplayWidget::createDeckPreviewWidget(const QString &filePath) +{ + auto *deckPreviewWidget = + new DeckPreviewWidget(flowWidget, visualDeckStorageWidget, visualDeckStorageWidget->model(), filePath); + connect(deckPreviewWidget, &DeckPreviewWidget::deckLoadRequested, visualDeckStorageWidget, + &VisualDeckStorageWidget::deckLoadRequested); + connect(deckPreviewWidget, &DeckPreviewWidget::openDeckEditor, visualDeckStorageWidget, + &VisualDeckStorageWidget::openDeckEditor); + connect(deckPreviewWidget, &DeckPreviewWidget::shareDeckRequested, visualDeckStorageWidget, + &VisualDeckStorageWidget::shareDeckRequested); + connect(deckPreviewWidget, &DeckPreviewWidget::shareSelectionToggled, visualDeckStorageWidget, + &VisualDeckStorageWidget::shareSelectionChanged); + deckPreviewWidget->setShareSelectable(visualDeckStorageWidget->isShareSelectable()); + connect(visualDeckStorageWidget->settings(), &VisualDeckStorageQuickSettingsWidget::cardSizeChanged, + deckPreviewWidget->bannerCardDisplayWidget, &CardInfoPictureWidget::setScaleFactor); + deckPreviewWidget->bannerCardDisplayWidget->setScaleFactor(visualDeckStorageWidget->settings()->getCardSize()); + deckWidgets.insert(filePath, deckPreviewWidget); + return deckPreviewWidget; +} + +void VisualDeckStorageFolderDisplayWidget::setShareSelectable(bool selectable) +{ + const auto previews = flowWidget->findChildren(); + for (DeckPreviewWidget *preview : previews) { + preview->setShareSelectable(selectable); + } + const auto subFolders = findChildren(); + for (VisualDeckStorageFolderDisplayWidget *subFolder : subFolders) { + subFolder->setShareSelectable(selectable); } } /** - * Updates the visibility of this folder and all its DeckPreviewWidgets + * @brief Creates, removes and keeps in sync the subfolder widgets of this folder. * - * @param recursive Also update the visibility of all subfolders and their DeckPreviewWidgets. + * Only direct child folders are created here; each child manages its own + * children, mirroring the folder tree on disk. */ -void VisualDeckStorageFolderDisplayWidget::updateVisibility(bool recursive) -{ - bool atLeastOneWidgetVisible = checkVisibility(); - if (atLeastOneWidgetVisible) { - setVisible(true); - for (DeckPreviewWidget *display : flowWidget->findChildren()) { - display->updateVisibility(); - } - if (recursive) { - for (auto *subFolder : findChildren()) { - subFolder->updateVisibility(false); - } - } - } else { - setVisible(false); - } -} - -bool VisualDeckStorageFolderDisplayWidget::checkVisibility() -{ - bool atLeastOneWidgetVisible = false; - if (flowWidget) { - // Iterate through all DeckPreviewWidgets - for (DeckPreviewWidget *display : flowWidget->findChildren()) { - if (display->checkVisibility()) { - atLeastOneWidgetVisible = true; - } - } - } - for (VisualDeckStorageFolderDisplayWidget *subFolder : findChildren()) { - if (subFolder->checkVisibility()) { - atLeastOneWidgetVisible = true; - } - } - return atLeastOneWidgetVisible; -} - -static QStringList getAllSubFolders(const QString &filePath) -{ - QStringList allFolders; - - // QDirIterator with QDir::Files ensures only files are listed (no directories) - QDirIterator it(filePath, QDir::Dirs | QDir::NoDotAndDotDot); - - while (it.hasNext()) { - allFolders << it.next(); // Add each file path to the list - } - - return allFolders; -} - -void VisualDeckStorageFolderDisplayWidget::createWidgetsForFolders() +void VisualDeckStorageFolderDisplayWidget::createSubFolderWidgets() { if (!showFolders) { return; } - for (const QString &dir : getAllSubFolders(filePath)) { - auto *display = new VisualDeckStorageFolderDisplayWidget(this, visualDeckStorageWidget, dir, true, showFolders); - containerLayout->addWidget(display); + const QStringList children = childFolderPaths(); + + for (auto it = subFolderWidgets.begin(); it != subFolderWidgets.end();) { + if (!children.contains(it.key())) { + containerLayout->removeWidget(it.value()); + it.value()->deleteLater(); + it = subFolderWidgets.erase(it); + } else { + ++it; + } + } + + for (const QString &child : children) { + if (subFolderWidgets.contains(child)) { + continue; + } + + auto *subFolderWidget = + new VisualDeckStorageFolderDisplayWidget(this, visualDeckStorageWidget, child, true, showFolders); + connect(subFolderWidget, &VisualDeckStorageFolderDisplayWidget::contentVisibilityChanged, this, + &VisualDeckStorageFolderDisplayWidget::refreshVisibility); + containerLayout->addWidget(subFolderWidget); + subFolderWidgets.insert(child, subFolderWidget); } } void VisualDeckStorageFolderDisplayWidget::updateShowFolders(bool enabled) { showFolders = enabled; + header->setHidden(!showFolders); if (!showFolders) { - flattenFolderStructure(); - } else { - // if setting was switched from disabled to enabled, we assume that there aren't any existing subfolders - createWidgetsForFiles(); - createWidgetsForFolders(); + for (auto it = subFolderWidgets.begin(); it != subFolderWidgets.end(); ++it) { + containerLayout->removeWidget(it.value()); + it.value()->deleteLater(); + } + subFolderWidgets.clear(); } - header->setHidden(!showFolders); + scheduleReconcile(); } /** - * Steals all DeckPreviewWidgets from this widget's nested subfolders, and deletes those subfolders + * @brief Hides the folder when it contains nothing visible, and reports the change upward. */ -void VisualDeckStorageFolderDisplayWidget::flattenFolderStructure() +void VisualDeckStorageFolderDisplayWidget::refreshVisibility() { - for (auto *subFolder : findChildren()) { - // steal all DeckPreviewWidgets from the subfolder - for (auto *deck : subFolder->getFlowWidget()->findChildren()) { - flowWidget->addWidget(deck); - } - - // delete the subfolder - subFolder->deleteLater(); + const bool shouldBeVisible = hasContent(); + if (isHidden() == !shouldBeVisible) { + return; } + setHidden(!shouldBeVisible); + emit contentVisibilityChanged(); } -QStringList VisualDeckStorageFolderDisplayWidget::gatherAllTagsFromFlowWidget() const +/** + * @brief Whether this folder shows any deck previews or has any visible subfolder. + * + * While the first pass is still building, the folder counts as having content so it + * doesn't flicker or hide prematurely before its previews have been created. + */ +bool VisualDeckStorageFolderDisplayWidget::hasContent() const { - QStringList allTags; + if (!initialPassCompleted || visibleDeckCount > 0) { + return true; + } - if (flowWidget) { - // Iterate through all DeckPreviewWidgets - for (DeckPreviewWidget *display : flowWidget->findChildren()) { - // Get tags from each DeckPreviewWidget - QStringList tags = display->deckLoader->getDeck().deckList.getTags(); - - // Add tags to the list while avoiding duplicates - allTags.append(tags); + for (VisualDeckStorageFolderDisplayWidget *subFolderWidget : subFolderWidgets) { + if (subFolderWidget->hasContent()) { + return true; } } - // Remove duplicates by calling 'removeDuplicates' - allTags.removeDuplicates(); + return false; +} - return allTags; -} \ No newline at end of file +/** + * @brief The direct child folder paths of this folder, sorted by name. + */ +QStringList VisualDeckStorageFolderDisplayWidget::childFolderPaths() const +{ + QStringList children; + const QString prefix = folderPath.isEmpty() ? QString() : folderPath + "/"; + + for (const QString &candidate : visualDeckStorageWidget->model()->getFolderPaths()) { + if (!candidate.startsWith(prefix)) { + continue; + } + const QString rest = candidate.mid(prefix.length()); + if (rest.isEmpty() || rest.contains('/')) { + continue; + } + children.append(candidate); + } + + return children; +} diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.h index a5e3be212..998619c0b 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.h @@ -1,49 +1,109 @@ /** * @file visual_deck_storage_folder_display_widget.h * @ingroup VisualDeckStorageWidgets + * @brief Renders the decks of one folder of the Visual Deck Storage. + * + * This is a pure view: it keeps one persistent DeckPreviewWidget alive per deck + * in its folder, and shows or hides those widgets according to each row's + * FilterMatchRole in the VisualDeckStorageSortFilterProxyModel. Subfolders are + * shown as nested VisualDeckStorageFolderDisplayWidgets when the "show folders" + * setting is enabled. + * + * Reconciling runs as a time-budgeted chunked pass that yields to the event loop + * between chunks, so scanning a large collection never stalls the ui thread. */ -//! \todo Document this file. #ifndef VISUAL_DECK_STORAGE_FOLDER_DISPLAY_WIDGET_H #define VISUAL_DECK_STORAGE_FOLDER_DISPLAY_WIDGET_H -#include "../general/display/banner_widget.h" -#include "../general/layout_containers/flow_widget.h" +#include +#include +#include +#include +#include +#include +#include +class BannerWidget; +class DeckPreviewWidget; +class FlowWidget; +class QTimer; +class QVBoxLayout; class VisualDeckStorageWidget; + class VisualDeckStorageFolderDisplayWidget : public QWidget { Q_OBJECT public: VisualDeckStorageFolderDisplayWidget(QWidget *parent, - VisualDeckStorageWidget *_visualDeckStorageWidget, - QString _filePath, + VisualDeckStorageWidget *visualDeckStorageWidget, + const QString &folderPath, bool canBeHidden, - bool _showFolders); - void refreshUi(); - void createWidgetsForFiles(); - void createWidgetsForFolders(); - void flattenFolderStructure(); - [[nodiscard]] QStringList gatherAllTagsFromFlowWidget() const; - [[nodiscard]] FlowWidget *getFlowWidget() const - { - return flowWidget; - } + bool showFolders); public slots: - void updateVisibility(bool recursive = true); - bool checkVisibility(); + /** + * @brief Starts a new chunked reconcile pass that re-reads the proxy and rebuilds + * the deck previews and subfolder widgets to match. + */ + void reconcile(); + + /** + * @brief Coalesces proxy change signals (a burst of deck loads or filter + * invalidations) into a single reconcile on the next event loop turn. + */ + void scheduleReconcile(); void updateShowFolders(bool enabled); + void setShareSelectable(bool selectable); + +signals: + /** + * @brief Emitted whenever this folder's visible content changes, so parent + * folders can re-evaluate their own visibility. + */ + void contentVisibilityChanged(); private: + void beginDeckPass(); + void continueDeckPass(); + void finishDeckPass(); + [[nodiscard]] DeckPreviewWidget *createDeckPreviewWidget(const QString &filePath); + void createSubFolderWidgets(); + void refreshVisibility(); + [[nodiscard]] bool hasContent() const; + [[nodiscard]] QStringList childFolderPaths() const; + + /** + * @brief The maximum time in milliseconds spent creating deck previews per event loop turn. + * + * Creating all previews of a large folder at once blocks the ui thread for hundreds of + * milliseconds, so the pass is split into chunks that yield to the event loop instead. + */ + static constexpr int DECK_PASS_TIME_BUDGET_MS = 20; + bool showFolders; + QString folderPath; ///< Path relative to the deck folder, empty for the root folder. + int visibleDeckCount = 0; ///< The number of this folder's deck previews not filtered out. QVBoxLayout *layout; - VisualDeckStorageWidget *visualDeckStorageWidget; - QString filePath; - BannerWidget *header; QWidget *container; QVBoxLayout *containerLayout; FlowWidget *flowWidget; + BannerWidget *header; + VisualDeckStorageWidget *visualDeckStorageWidget; + QHash deckWidgets; ///< Deck file path -> preview widget. + QHash subFolderWidgets; ///< Folder path -> subfolder widget. + QTimer *reconcileTimer = nullptr; ///< Coalesces proxy change bursts. + QStringList lastOrderedFilePaths; ///< The deck order last applied to the flow layout. + + /// Whether a chunked reconcile pass is currently running. + bool deckPassActive = false; + /// Set when the model changes mid-pass. Discards progress and restarts the scan once + /// the current chunk finishes so the pass always converges on the latest model state. + bool deckPassRestartRequested = false; + /// Whether the first reconcile pass has run to completion at least once. + bool initialPassCompleted = false; + int deckPassRow = 0; ///< Next proxy row to scan in the active pass. + QSet deckPassPresentPaths; ///< File paths seen so far in the active pass. }; #endif // VISUAL_DECK_STORAGE_FOLDER_DISPLAY_WIDGET_H diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp new file mode 100644 index 000000000..e84631e52 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp @@ -0,0 +1,573 @@ +#include "visual_deck_storage_model.h" + +#include "../../deck_loader/deck_file_format.h" +#include "../../deck_loader/deck_loader.h" +#include "../../deck_loader/loaded_deck.h" +#include "../cards/additional_info/deck_color_identity.h" +#include "libcockatrice/deck_list/deck_list.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class QObject; + +namespace +{ +/** + * @brief The result of a background directory scan: the deck rows in scan order + * plus the sorted list of subfolder paths. + */ +struct DeckScanResult +{ + QList decks; ///< Deck rows in scan order. + QStringList folderPaths; ///< Sorted list of subfolder paths. +}; + +/** + * @brief The result of a background deck file load: the parsed deck plus the + * file's modification time, so the disk stat happens off the UI thread. + */ +struct DeckLoadResult +{ + LoadedDeck deck; ///< The parsed deck. + QDateTime lastModified; ///< File modification time at load. + QString colorIdentity; ///< WUBRG color identity, computed off the UI thread. +}; + +/** + * @brief How long per event loop turn the pending-load drain applies finished loads. + * + * Applying a load updates the row and wakes up the proxies and views, which is + * not free. A time budget instead of a fixed count lets fast machines apply + * many loads in one turn while keeping the ui thread responsive everywhere. + */ +constexpr int LOAD_DRAIN_TIME_BUDGET_MS = 8; + +/** + * @brief The path of \a path relative to the deck root, or empty if \a path + * is not below it. + */ +QString relativePathFromDeckRoot(const QString &path, const QString &deckPath) +{ + if (!path.startsWith(deckPath)) { + return {}; + } + QString relativePath = path.mid(deckPath.length()); + if (relativePath.startsWith('/')) { + relativePath.remove(0, 1); + } + return relativePath; +} + +/** + * @brief The path of \a filePath relative to \a deckPath, or the bare file name + * if \a filePath is not below \a deckPath. + */ +QString relativeFilePathFor(const QString &filePath, const QString &deckPath) +{ + if (filePath.startsWith(deckPath)) { + return filePath.mid(deckPath.length()); + } + + return QFileInfo(filePath).fileName(); +} + +/** + * @brief The directory of \a filePath relative to \a deckPath, or empty if the + * file sits directly in the deck root. + */ +QString folderPathFor(const QString &filePath, const QString &deckPath) +{ + return relativePathFromDeckRoot(QFileInfo(filePath).absolutePath(), deckPath); +} + +/** + * @brief Scans a deck directory on a worker thread, returning discovered deck + * files and subfolder paths. + */ +DeckScanResult scanDeckDirectory(const QString &deckPath) +{ + DeckScanResult result; + + QDirIterator fileIt(deckPath, DeckLoader::ACCEPTED_FILE_EXTENSIONS, QDir::Files, + QDirIterator::Subdirectories | QDirIterator::FollowSymlinks); + while (fileIt.hasNext()) { + const QString filePath = fileIt.next(); + DeckPreviewData data; + data.filePath = filePath; + data.relativeFilePath = relativeFilePathFor(filePath, deckPath); + data.folderPath = folderPathFor(filePath, deckPath); + data.lastModified = QFileInfo(filePath).lastModified(); + result.decks.append(std::move(data)); + } + + QSet seenFolders; + QDirIterator folderIt(deckPath, QDir::Dirs | QDir::NoDotAndDotDot, + QDirIterator::Subdirectories | QDirIterator::FollowSymlinks); + while (folderIt.hasNext()) { + const QString folderPath = relativePathFromDeckRoot(folderIt.next(), deckPath); + if (!folderPath.isEmpty() && !seenFolders.contains(folderPath)) { + seenFolders.insert(folderPath); + result.folderPaths.append(folderPath); + } + } + result.folderPaths.sort(); + + return result; +} +} // namespace + +VisualDeckStorageModel::VisualDeckStorageModel(QObject *parent) : QAbstractListModel(parent) +{ +} + +int VisualDeckStorageModel::rowCount(const QModelIndex &parent) const +{ + return parent.isValid() ? 0 : decks.size(); +} + +QVariant VisualDeckStorageModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= decks.size()) { + return {}; + } + + const DeckPreviewData &data = decks.at(index.row()); + switch (role) { + case Qt::DisplayRole: + case VisualDeckStorageRoles::DisplayNameRole: + return data.displayName; + case VisualDeckStorageRoles::FilePathRole: + return data.filePath; + case VisualDeckStorageRoles::RelativeFilePathRole: + return data.relativeFilePath; + case VisualDeckStorageRoles::FolderPathRole: + return data.folderPath; + case VisualDeckStorageRoles::TagsRole: + return data.tags; + case VisualDeckStorageRoles::ColorIdentityRole: + return data.colorIdentity; + case VisualDeckStorageRoles::LastModifiedRole: + return data.lastModified; + case VisualDeckStorageRoles::LastLoadedRole: + return data.lastLoaded; + case VisualDeckStorageRoles::BannerCardNameRole: + return data.bannerCard.name; + case VisualDeckStorageRoles::BannerCardProviderIdRole: + return data.bannerCard.providerId; + default: + return {}; + } +} + +void VisualDeckStorageModel::setDeckPath(const QString &path) +{ + QString cleanedPath = QDir::cleanPath(path); + if (cleanedPath == ".") { + cleanedPath.clear(); + } + deckPath = cleanedPath; + startScan(); +} + +void VisualDeckStorageModel::refresh() +{ + startScan(); +} + +const DeckPreviewData &VisualDeckStorageModel::dataForRow(int row) const +{ + static const DeckPreviewData emptyData; + if (row < 0 || row >= decks.size()) { + return emptyData; + } + return decks.at(row); +} + +const LoadedDeck &VisualDeckStorageModel::deckForRow(int row) const +{ + return dataForRow(row).deck; +} + +int VisualDeckStorageModel::rowForFilePath(const QString &filePath) const +{ + return rowByFilePath.value(filePath, -1); +} + +/** + * @brief Rebuilds the file path -> row index from scratch after bulk changes. + */ +void VisualDeckStorageModel::reindexFilePaths() +{ + rowByFilePath.clear(); + for (int row = 0; row < decks.size(); ++row) { + const QString &filePath = decks.at(row).filePath; + // First row wins, mirroring what a linear scan would return for duplicates. + if (!rowByFilePath.contains(filePath)) { + rowByFilePath.insert(filePath, row); + } + } +} + +void VisualDeckStorageModel::startScan() +{ + ++scanGeneration; + beginResetModel(); + decks.clear(); + rowByFilePath.clear(); + folderPaths.clear(); + pendingLoads.clear(); + endResetModel(); + + if (deckPath.isEmpty()) { + return; + } + + const QString currentDeckPath = deckPath; + const int generation = scanGeneration; + + // The scan (directory walk + one stat per file) runs on a worker thread so that + // constructing the widget never stalls the UI thread on a large deck folder. + auto *watcher = new QFutureWatcher(this); + connect(watcher, &QFutureWatcher::finished, this, [this, watcher, generation] { + watcher->deleteLater(); + + if (generation != scanGeneration) { + return; // A newer scan started while this one was running; drop the stale result. + } + + const DeckScanResult result = watcher->result(); + folderPaths = result.folderPaths; + + if (result.decks.isEmpty()) { + return; + } + + beginInsertRows(QModelIndex(), 0, result.decks.size() - 1); + decks = result.decks; + reindexFilePaths(); + endInsertRows(); + + for (int row = 0; row < decks.size(); ++row) { + beginLoad(row); + } + }); + + watcher->setFuture( + QtConcurrent::run([currentDeckPath]() -> DeckScanResult { return scanDeckDirectory(currentDeckPath); })); +} + +void VisualDeckStorageModel::beginLoad(int row) +{ + if (row < 0 || row >= decks.size() || decks.at(row).loadInProgress) { + return; + } + + DeckPreviewData &data = decks[row]; + data.loadInProgress = true; + + const QString filePath = data.filePath; + const DeckFileFormat::Format fmt = DeckFileFormat::getFormatFromName(filePath); + const int generation = scanGeneration; + + auto *watcher = new QFutureWatcher>(this); + connect(watcher, &QFutureWatcher>::finished, this, + [this, watcher, filePath, generation] { + watcher->deleteLater(); + + if (generation != scanGeneration) { + return; // The deck list was re-scanned while this load was running; drop the stale result. + } + + // Queue the result and apply a bounded number per event loop turn so that + // finishing hundreds of loads at once cannot stall the UI thread. + const std::optional result = watcher->result(); + PendingDeckLoad pending; + pending.filePath = filePath; + pending.generation = generation; + if (result) { + pending.ok = true; + pending.deck = std::move(result->deck); + pending.lastModified = result->lastModified; + pending.colorIdentity = std::move(result->colorIdentity); + } + pendingLoads.append(std::move(pending)); + schedulePendingLoadDrain(); + }); + + watcher->setFuture(QtConcurrent::run([filePath, fmt]() -> std::optional { + std::optional deck = DeckLoader::loadFromFile(filePath, fmt, false); + if (!deck) { + return std::nullopt; + } + // Color identity walks every card through the database, so compute it here to + // keep the completion handler on the UI thread cheap. + const QString colorIdentity = getDeckColorIdentity(deck->deckList, CardDatabaseManager::query()); + return DeckLoadResult{std::move(*deck), QFileInfo(filePath).lastModified(), colorIdentity}; + })); +} + +void VisualDeckStorageModel::schedulePendingLoadDrain() +{ + if (drainScheduled) { + return; + } + drainScheduled = true; + QMetaObject::invokeMethod(this, &VisualDeckStorageModel::drainPendingLoads, Qt::QueuedConnection); +} + +void VisualDeckStorageModel::drainPendingLoads() +{ + drainScheduled = false; + + QElapsedTimer timer; + timer.start(); + + while (!pendingLoads.isEmpty()) { + PendingDeckLoad pending = pendingLoads.takeFirst(); + + if (pending.generation != scanGeneration) { + continue; + } + + const int row = rowForFilePath(pending.filePath); + if (row == -1) { + continue; + } + + DeckPreviewData &data = decks[row]; + data.loadInProgress = false; + + if (!pending.ok) { + continue; // Leave the row unloaded so it stays visible without deck data. + } + + data.deck = std::move(pending.deck); + data.loadSucceeded = true; + data.lastModified = pending.lastModified; + recomputeDeckMetadata(data, false); + data.colorIdentity = std::move(pending.colorIdentity); + + emit dataChanged(index(row), index(row)); + emit deckLoaded(row); + + // Checked after applying at least one load, so a single slow application + // still makes progress instead of starving the queue. + if (timer.elapsed() >= LOAD_DRAIN_TIME_BUDGET_MS) { + break; + } + } + + if (!pendingLoads.isEmpty()) { + schedulePendingLoadDrain(); + } +} + +/** + * @brief Recomputes all derived metadata of a row from its loaded deck. + */ +void VisualDeckStorageModel::recomputeDeckMetadata(DeckPreviewData &data, bool recomputeColorIdentity) +{ + const DeckList &deckList = data.deck.deckList; + + data.deckName = deckList.getName(); + data.displayName = !data.deckName.isEmpty() ? data.deckName : QFileInfo(data.deck.lastLoadInfo.fileName).fileName(); + data.tags = deckList.getTags(); + data.lastLoaded = QDateTime::fromString(deckList.getLastLoadedTimestamp()); + data.bannerCard = deckList.getBannerCard(); + if (recomputeColorIdentity) { + data.colorIdentity = getDeckColorIdentity(data.deck.deckList, CardDatabaseManager::query()); + } +} + +void VisualDeckStorageModel::setFilePathForRow(int row, const QString &newFilePath) +{ + if (row < 0 || row >= decks.size()) { + return; + } + + DeckPreviewData &data = decks[row]; + rowByFilePath.remove(data.filePath); + data.filePath = newFilePath; + data.relativeFilePath = relativeFilePathFor(newFilePath, deckPath); + data.folderPath = folderPathFor(newFilePath, deckPath); + if (!rowByFilePath.contains(newFilePath)) { + rowByFilePath.insert(newFilePath, row); + } +} + +bool VisualDeckStorageModel::renameDeck(int row, const QString &newName) +{ + if (row < 0 || row >= decks.size() || decks.at(row).deck.isEmpty()) { + return false; + } + + DeckPreviewData &data = decks[row]; + data.deck.deckList.setName(newName); + if (!DeckLoader::saveToFile(data.deck)) { + return false; + } + + recomputeDeckMetadata(data); + emit dataChanged(index(row), index(row), {VisualDeckStorageRoles::DisplayNameRole}); + return true; +} + +bool VisualDeckStorageModel::renameFile(int row, const QString &newBaseName) +{ + if (row < 0 || row >= decks.size() || newBaseName.isEmpty()) { + return false; + } + + DeckPreviewData &data = decks[row]; + const QFileInfo info(data.filePath); + if (newBaseName == info.baseName()) { + return false; + } + + QString newFileName = newBaseName; + if (!info.suffix().isEmpty()) { + newFileName += "." + info.suffix(); + } + + const QString newFilePath = QFileInfo(info.dir(), newFileName).filePath(); + if (!QFile::rename(info.filePath(), newFilePath)) { + return false; + } + + const QString oldFilePath = data.filePath; + data.deck.lastLoadInfo.fileName = newFilePath; + setFilePathForRow(row, newFilePath); + data.lastModified = QFileInfo(newFilePath).lastModified(); + + emit dataChanged(index(row), index(row)); + emit deckFilePathChanged(oldFilePath, newFilePath); + return true; +} + +bool VisualDeckStorageModel::deleteFile(int row) +{ + if (row < 0 || row >= decks.size()) { + return false; + } + + const QString filePath = decks.at(row).filePath; + if (!QFile::remove(QFileInfo(filePath).filePath())) { + return false; + } + + beginRemoveRows(QModelIndex(), row, row); + rowByFilePath.remove(filePath); + decks.removeAt(row); + // Rows after the deleted one shift down by one. + for (auto it = rowByFilePath.begin(); it != rowByFilePath.end(); ++it) { + if (it.value() > row) { + --it.value(); + } + } + endRemoveRows(); + return true; +} + +bool VisualDeckStorageModel::setTags(int row, const QStringList &tags) +{ + if (row < 0 || row >= decks.size() || decks.at(row).deck.isEmpty()) { + return false; + } + + DeckPreviewData &data = decks[row]; + data.deck.deckList.setTags(tags); + if (!DeckLoader::saveToFile(data.deck)) { + return false; + } + + data.tags = tags; + emit dataChanged(index(row), index(row), {VisualDeckStorageRoles::TagsRole}); + return true; +} + +bool VisualDeckStorageModel::setBannerCard(int row, const CardRef &cardRef) +{ + if (row < 0 || row >= decks.size() || decks.at(row).deck.isEmpty()) { + return false; + } + + DeckPreviewData &data = decks[row]; + data.deck.deckList.setBannerCard(cardRef); + if (!DeckLoader::saveToFile(data.deck)) { + return false; + } + + data.bannerCard = cardRef; + emit dataChanged(index(row), index(row), + {VisualDeckStorageRoles::BannerCardNameRole, VisualDeckStorageRoles::BannerCardProviderIdRole}); + return true; +} + +bool VisualDeckStorageModel::convertToCockatriceFormat(int row) +{ + if (row < 0 || row >= decks.size() || decks.at(row).deck.isEmpty()) { + return false; + } + + DeckPreviewData &data = decks[row]; + const QString oldFilePath = data.filePath; + if (!DeckLoader::convertToCockatriceFormat(data.deck)) { + return false; + } + + setFilePathForRow(row, data.deck.lastLoadInfo.fileName); + data.lastModified = QFileInfo(data.filePath).lastModified(); + recomputeDeckMetadata(data); + + emit dataChanged(index(row), index(row)); + if (oldFilePath != data.filePath) { + emit deckFilePathChanged(oldFilePath, data.filePath); + } + return true; +} + +bool VisualDeckStorageModel::reloadIfModified(int row) +{ + if (row < 0 || row >= decks.size()) { + return false; + } + + DeckPreviewData &data = decks[row]; + QFileInfo fileInfo(data.filePath); + const QDateTime newLastModified = fileInfo.lastModified(); + if (!newLastModified.isValid() || newLastModified <= data.lastModified) { + return false; + } + + std::optional result = + DeckLoader::loadFromFile(data.filePath, DeckFileFormat::getFormatFromName(data.filePath), false); + if (!result) { + return false; + } + + data.deck = *result; + data.loadSucceeded = true; + data.lastModified = fileInfo.lastModified(); + recomputeDeckMetadata(data); + + emit dataChanged(index(row), index(row)); + emit deckLoaded(row); + return true; +} diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.h new file mode 100644 index 000000000..57eaebacf --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.h @@ -0,0 +1,194 @@ +/** + * @file visual_deck_storage_model.h + * @ingroup VisualDeckStorageWidgets + * @brief Source model for the Visual Deck Storage: the deck files on disk. + * + * The model owns the deck metadata (name, tags, color identity, banner card, + * modification times) and the parsed deck list, loading each deck file in the + * background. Views read through the roles or the direct accessors, and all + * mutations (rename, tags, banner card, delete, conversion) go through this + * class so the view layer never touches the filesystem directly. + */ + +#ifndef VISUAL_DECK_STORAGE_MODEL_H +#define VISUAL_DECK_STORAGE_MODEL_H + +#include "../../deck_loader/loaded_deck.h" +#include "libcockatrice/utility/card_ref.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class QObject; + +namespace VisualDeckStorageRoles +{ +/** + * @brief Custom roles exposed by the VisualDeckStorageModel. + */ +enum +{ + FilePathRole = Qt::UserRole + 1, /**< Absolute file path of the deck. */ + RelativeFilePathRole, /**< File path relative to the deck folder. */ + FolderPathRole, /**< Directory of the deck relative to the deck folder ("" for root). */ + DisplayNameRole, /**< Deck name, or the file name if the deck has no name. */ + TagsRole, /**< The deck's tags. */ + ColorIdentityRole, /**< The deck's color identity (WUBRG order). */ + LastModifiedRole, /**< QDateTime of the deck file's last modification. */ + LastLoadedRole, /**< QDateTime when the deck was last loaded from the file. */ + BannerCardNameRole, /**< Name of the deck's banner card. */ + BannerCardProviderIdRole, /**< Provider id of the deck's banner card. */ + /** + * @brief Whether the row passes the proxy's current search / tag / color filters. + * + * Not served by this model, but by VisualDeckStorageSortFilterProxyModel on top + * of it. Declared here so every role read through a proxy index stays unique. + */ + FilterMatchRole +}; +} // namespace VisualDeckStorageRoles + +/** + * @brief One deck file as seen by the Visual Deck Storage. + * + * The metadata is computed once when the deck loads and refreshed on reloads + * and mutations, so filters and sorts never re-read the file from disk. + */ +struct DeckPreviewData +{ + QString filePath; ///< Absolute file path. + QString relativeFilePath; ///< File path relative to the deck folder. + QString folderPath; ///< Directory relative to the deck folder ("" for the deck folder itself). + QString deckName; ///< The deck name as stored in the file (may be empty). + QString displayName; ///< Deck name, or the file name if the deck has no name. + QStringList tags; ///< The deck's tags. + QString colorIdentity; ///< The deck's color identity in WUBRG order. + QDateTime lastModified; ///< File modification time at last check. + QDateTime lastLoaded; ///< When the deck was last loaded from the file. + CardRef bannerCard; ///< The deck's banner card (name + provider id). + LoadedDeck deck; ///< The parsed deck; empty until the file has been loaded. + bool loadSucceeded = false; ///< Whether the deck file finished loading successfully. + bool loadInProgress = false; ///< Whether the deck file is currently being loaded. +}; + +/** + * @brief One finished background deck load that has not been applied to the model yet. + */ +struct PendingDeckLoad +{ + QString filePath; ///< Identifies the row the result belongs to. + int generation; ///< Scan generation the load was started in. + bool ok = false; ///< Whether the file parsed successfully. + LoadedDeck deck; ///< The parsed deck, valid when ok. + QDateTime lastModified; ///< File modification time at load, valid when ok. + QString colorIdentity; ///< WUBRG color identity computed off the UI thread, valid when ok. +}; + +/** + * @brief The list model backing the Visual Deck Storage widget tree. + * + * Rows are in filesystem scan order; ordering and filtering are handled by + * VisualDeckStorageSortFilterProxyModel on top of this model. The proxy keeps + * every row and exposes each row's filter result through its FilterMatchRole. + */ +class VisualDeckStorageModel : public QAbstractListModel +{ + Q_OBJECT +public: + explicit VisualDeckStorageModel(QObject *parent = nullptr); + + /// @name Qt model overrides + ///@{ + [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; + [[nodiscard]] QVariant data(const QModelIndex &index, int role) const override; + ///@} + + /** + * @brief Sets the folder to scan for deck files and starts (re)loading. + * Clears the model immediately (modelReset), then populates it asynchronously + * as the background scan discovers deck files. + */ + void setDeckPath(const QString &path); + + /** + * @brief Re-scans the current deck folder, reloading every deck file. + */ + void refresh(); + + [[nodiscard]] QString getDeckPath() const + { + return deckPath; + } + + /** + * @brief The relative paths of all subdirectories of the deck folder, one level at a time. + * Used by the view to build the folder tree. Sorted for deterministic order. + */ + [[nodiscard]] QStringList getFolderPaths() const + { + return folderPaths; + } + + /// @name Data accessors + ///@{ + [[nodiscard]] const DeckPreviewData &dataForRow(int row) const; + [[nodiscard]] const LoadedDeck &deckForRow(int row) const; + [[nodiscard]] int rowForFilePath(const QString &filePath) const; + ///@} + + /// @name Mutations (persist to disk and update the row) + ///@{ + bool renameDeck(int row, const QString &newName); + bool renameFile(int row, const QString &newBaseName); + bool deleteFile(int row); + bool setTags(int row, const QStringList &tags); + bool setBannerCard(int row, const CardRef &cardRef); + bool convertToCockatriceFormat(int row); + bool reloadIfModified(int row); + ///@} + +signals: + /** + * @brief Emitted when a deck file finishes loading. + * @param row The row of the deck that finished loading. + */ + void deckLoaded(int row); + + /** + * @brief Emitted when a deck's file path changes (rename file, conversion). + * @param oldFilePath The previous file path. + * @param newFilePath The new file path. + */ + void deckFilePathChanged(const QString &oldFilePath, const QString &newFilePath); + +private: + void startScan(); + void beginLoad(int row); + static void recomputeDeckMetadata(DeckPreviewData &data, bool recomputeColorIdentity = true); + void reindexFilePaths(); + void schedulePendingLoadDrain(); + +private slots: + void drainPendingLoads(); + +private: + void setFilePathForRow(int row, const QString &newFilePath); + + QString deckPath; + QList decks; + QHash rowByFilePath; ///< Maps each deck's file path to its row for O(1) lookups. + QStringList folderPaths; ///< All subdirectories of the deck folder, sorted. + int scanGeneration = 0; ///< Bumped on every scan so stale results are ignored. + QVector pendingLoads; ///< Finished background loads waiting to be applied. + bool drainScheduled = false; ///< Whether a queued drain pass is already pending. +}; + +#endif // VISUAL_DECK_STORAGE_MODEL_H diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.cpp index cce3ff6ce..9bb6e0fae 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.cpp @@ -1,14 +1,24 @@ #include "visual_deck_storage_quick_settings_widget.h" #include "../../../client/settings/cache_settings.h" -#include "visual_deck_storage_widget.h" +#include "../cards/card_size_widget.h" +#include "../quick_settings/settings_button_widget.h" +#include "libcockatrice/utility/macros.h" #include #include +#include +#include +#include +#include #include +#include +#include +#include #include #include #include +#include VisualDeckStorageQuickSettingsWidget::VisualDeckStorageQuickSettingsWidget(QWidget *parent) : SettingsButtonWidget(parent) @@ -49,6 +59,15 @@ VisualDeckStorageQuickSettingsWidget::VisualDeckStorageQuickSettingsWidget(QWidg &SettingsCache::instance().visualDeckStorage(), &VisualDeckStorageSettings::setVisualDeckStorageShowTagsOnDeckPreviews); + // show upload time on DeckPreviewWidget checkbox + showUploadTimeCheckBox = new QCheckBox(this); + showUploadTimeCheckBox->setChecked( + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowUploadTime()); + connect(showUploadTimeCheckBox, &QCheckBox::QT_STATE_CHANGED, this, + &VisualDeckStorageQuickSettingsWidget::showUploadTimeChanged); + connect(showUploadTimeCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::setVisualDeckStorageShowUploadTime); + // show banner card selector checkbox showBannerCardComboBoxCheckBox = new QCheckBox(this); showBannerCardComboBoxCheckBox->setChecked( @@ -93,7 +112,7 @@ VisualDeckStorageQuickSettingsWidget::VisualDeckStorageQuickSettingsWidget(QWidg unusedColorIdentityOpacityLayout->addWidget(unusedColorIdentitiesOpacitySpinBox); // tooltip selector - auto deckPreviewTooltipWidget = new QWidget(this); + deckPreviewTooltipWidget = new QWidget(this); deckPreviewTooltipLabel = new QLabel(deckPreviewTooltipWidget); deckPreviewTooltipComboBox = new QComboBox(deckPreviewTooltipWidget); @@ -127,6 +146,7 @@ VisualDeckStorageQuickSettingsWidget::VisualDeckStorageQuickSettingsWidget(QWidg this->addSettingsWidget(showTagFilterCheckBox); this->addSettingsWidget(showColorIdentityCheckBox); this->addSettingsWidget(showTagsOnDeckPreviewsCheckBox); + this->addSettingsWidget(showUploadTimeCheckBox); this->addSettingsWidget(showBannerCardComboBoxCheckBox); this->addSettingsWidget(drawUnusedColorIdentitiesCheckBox); this->addSettingsWidget(unusedColorIdentityOpacityWidget); @@ -144,6 +164,7 @@ void VisualDeckStorageQuickSettingsWidget::retranslateUi() showTagFilterCheckBox->setText(tr("Show Tag Filter")); showColorIdentityCheckBox->setText(tr("Show Color Identity")); showTagsOnDeckPreviewsCheckBox->setText(tr("Show Tags On Deck Previews")); + showUploadTimeCheckBox->setText(tr("Show Upload Time")); showBannerCardComboBoxCheckBox->setText(tr("Show Banner Card Selection Option")); drawUnusedColorIdentitiesCheckBox->setText(tr("Draw unused Color Identities")); unusedColorIdentitiesOpacityLabel->setText(tr("Unused Color Identities Opacity")); @@ -154,6 +175,14 @@ void VisualDeckStorageQuickSettingsWidget::retranslateUi() deckPreviewTooltipComboBox->setItemText(1, tr("Filepath")); } +void VisualDeckStorageQuickSettingsWidget::setPublicDecksMode(bool enabled) +{ + const bool hidden = enabled; + showFoldersCheckBox->setVisible(!hidden); + showBannerCardComboBoxCheckBox->setVisible(!hidden); + deckPreviewTooltipWidget->setVisible(!hidden); +} + bool VisualDeckStorageQuickSettingsWidget::getShowFolders() const { return showFoldersCheckBox->isChecked(); @@ -184,6 +213,11 @@ bool VisualDeckStorageQuickSettingsWidget::getShowTagsOnDeckPreviews() const return showTagsOnDeckPreviewsCheckBox->isChecked(); } +bool VisualDeckStorageQuickSettingsWidget::getShowUploadTime() const +{ + return showUploadTimeCheckBox->isChecked(); +} + int VisualDeckStorageQuickSettingsWidget::getUnusedColorIdentitiesOpacity() const { return unusedColorIdentitiesOpacitySpinBox->value(); diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.h index ea4330a15..7bce34d8c 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_quick_settings_widget.h @@ -11,11 +11,14 @@ #include "../quick_settings/settings_button_widget.h" +#include + class CardSizeWidget; class QLabel; class QSpinBox; class QCheckBox; class QComboBox; +class QWidget; class VisualDeckStorageQuickSettingsWidget : public SettingsButtonWidget { @@ -27,10 +30,12 @@ class VisualDeckStorageQuickSettingsWidget : public SettingsButtonWidget QCheckBox *showBannerCardComboBoxCheckBox; QCheckBox *showTagFilterCheckBox; QCheckBox *showTagsOnDeckPreviewsCheckBox; + QCheckBox *showUploadTimeCheckBox; QLabel *unusedColorIdentitiesOpacityLabel; QSpinBox *unusedColorIdentitiesOpacitySpinBox; QLabel *deckPreviewTooltipLabel; QComboBox *deckPreviewTooltipComboBox; + QWidget *deckPreviewTooltipWidget; CardSizeWidget *cardSizeWidget; public: @@ -46,6 +51,15 @@ public: explicit VisualDeckStorageQuickSettingsWidget(QWidget *parent = nullptr); + /** + * @brief Hides the controls that do not apply to the public decks tab. + * + * The public decks tab reuses this widget for its quick settings menu but + * has no folders, banner selection or per-deck tooltip, so those controls + * are hidden while every shared key keeps syncing with SettingsCache. + */ + void setPublicDecksMode(bool enabled); + void retranslateUi(); [[nodiscard]] bool getShowFolders() const; @@ -54,10 +68,20 @@ public: [[nodiscard]] bool getShowBannerCardComboBox() const; [[nodiscard]] bool getShowTagFilter() const; [[nodiscard]] bool getShowTagsOnDeckPreviews() const; + [[nodiscard]] bool getShowUploadTime() const; [[nodiscard]] int getUnusedColorIdentitiesOpacity() const; [[nodiscard]] TooltipType getDeckPreviewTooltip() const; [[nodiscard]] int getCardSize() const; + /** + * @return The card size widget, so card display hosts can resize the cards on + * Ctrl + scroll even though the slider itself lives inside this menu. + */ + [[nodiscard]] CardSizeWidget *getCardSizeWidget() const + { + return cardSizeWidget; + } + signals: void showFoldersChanged(bool enabled); void drawUnusedColorIdentitiesChanged(bool enabled); @@ -65,6 +89,7 @@ signals: void showBannerCardComboBoxChanged(bool enabled); void showTagFilterChanged(bool enabled); void showTagsOnDeckPreviewsChanged(bool enabled); + void showUploadTimeChanged(bool enabled); void unusedColorIdentitiesOpacityChanged(int opacity); void deckPreviewTooltipChanged(TooltipType tooltip); void cardSizeChanged(int scale); 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 07ee55105..2c03666c1 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,23 +1,24 @@ #include "visual_deck_storage_search_widget.h" -#include "../../../client/settings/cache_settings.h" -#include "../../../filters/deck_filter_string.h" #include "../../../filters/syntax_help.h" #include "../../pixel_map_generator.h" #include -#include -#include +#include +#include +#include +#include +#include /** - * @brief Constructs a PrintingSelectorCardSearchWidget for searching cards by set name or set code. + * @brief Constructs a search bar for filtering decks in the Visual Deck Storage. * - * This widget provides a search bar that allows users to search for cards by either their set name - * or set code. It uses a debounced timer to trigger the search action after the user stops typing. + * Provides a search bar that allows users to search decks by filename or search + * expression, with a debounced timer to trigger the search after the user stops typing. * - * @param parent The parent PrintingSelector widget that will handle the search results. + * @param parent The parent widget. */ -VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(VisualDeckStorageWidget *parent) : parent(parent) +VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(QWidget *parent) : QWidget(parent) { layout = new QHBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); @@ -28,7 +29,7 @@ VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(VisualDeckStorageWi searchBar->setClearButtonEnabled(true); searchBar->addAction(loadColorAdjustedPixmap("theme:icons/search"), QLineEdit::LeadingPosition); - auto help = searchBar->addAction(QPixmap("theme:icons/info"), QLineEdit::TrailingPosition); + auto help = searchBar->addAction(themePixmap(QStringLiteral("icons/info")), QLineEdit::TrailingPosition); connect(help, &QAction::triggered, this, [this] { createDeckSearchSyntaxHelpWindow(searchBar); }); layout->addWidget(searchBar); @@ -40,43 +41,5 @@ VisualDeckStorageSearchWidget::VisualDeckStorageSearchWidget(VisualDeckStorageWi searchDebounceTimer->start(300); // 300ms debounce }); - connect(searchDebounceTimer, &QTimer::timeout, parent, &VisualDeckStorageWidget::updateSearchFilter); -} - -/** - * @brief Retrieves the current text in the search bar. - * - * @return The text entered by the user in the search bar. - */ -QString VisualDeckStorageSearchWidget::getSearchText() -{ - return searchBar->text(); -} - -/** - * Converts the filepath into a relative filepath starting from the deck folder. - * If the file isn't in the deck folder, then this will just return the filename. - * - * @param filePath The filepath to convert into a relative filepath - */ -static QString toRelativeFilepath(const QString &filePath) -{ - QString deckPath = SettingsCache::instance().paths().getDeckPath(); - if (filePath.startsWith(deckPath)) { - return filePath.mid(deckPath.length()); - } - - QFileInfo fileInfo(filePath); - QString fileName = fileInfo.fileName(); - return fileName; -} - -void VisualDeckStorageSearchWidget::filterWidgets(QList widgets, const QString &searchText) -{ - auto filterString = DeckFilterString(searchText); - - for (auto widget : widgets) { - QString relativeFilePath = toRelativeFilepath(widget->filePath); - widget->filteredBySearch = !filterString.check(widget, {relativeFilePath}); - } + connect(searchDebounceTimer, &QTimer::timeout, this, [this] { emit searchTextChanged(searchBar->text()); }); } diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.h index 2f3d81aeb..87a8dc9f8 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.h @@ -2,30 +2,34 @@ * @file visual_deck_storage_search_widget.h * @ingroup VisualDeckStorageWidgets */ -//! \todo Document this file. #ifndef VISUAL_DECK_STORAGE_SEARCH_WIDGET_H #define VISUAL_DECK_STORAGE_SEARCH_WIDGET_H -#include "deck_preview/deck_preview_widget.h" - -#include -#include +#include #include +#include + +class QTimer; +class QHBoxLayout; +class QLineEdit; -class VisualDeckStorageWidget; class VisualDeckStorageSearchWidget : public QWidget { Q_OBJECT public: - explicit VisualDeckStorageSearchWidget(VisualDeckStorageWidget *parent); - QString getSearchText(); - void filterWidgets(QList widgets, const QString &searchText); + explicit VisualDeckStorageSearchWidget(QWidget *parent); + +signals: + /** + * Emitted once the debounce timer fires after the user stopped typing. + * @param text The current contents of the search bar. + */ + void searchTextChanged(const QString &text); private: QHBoxLayout *layout; - VisualDeckStorageWidget *parent; QLineEdit *searchBar; QTimer *searchDebounceTimer; }; diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp new file mode 100644 index 000000000..abe3327af --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp @@ -0,0 +1,309 @@ +#include "visual_deck_storage_sort_filter_proxy_model.h" + +#include "../../../client/settings/cache_settings.h" +#include "../../deck_loader/loaded_deck.h" +#include "../../filters/deck_filter_string.h" +#include "visual_deck_storage_model.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +VisualDeckStorageSortFilterProxyModel::VisualDeckStorageSortFilterProxyModel(QObject *parent) + : QSortFilterProxyModel(parent) +{ + setDynamicSortFilter(false); +} + +bool colorIdentityMatches(VisualDeckStorageSortFilterProxyModel::FilterMode mode, + const QSet &colors, + const QString &identity) +{ + switch (mode) { + case VisualDeckStorageSortFilterProxyModel::ExactMatch: { + QSet activeColorSet; + for (const QChar &color : colors) { + activeColorSet.insert(color.toUpper()); + } + + QSet colorIdentitySet; + for (const QChar &color : identity) { + colorIdentitySet.insert(color.toUpper()); + } + + return activeColorSet == colorIdentitySet; + } + case VisualDeckStorageSortFilterProxyModel::Includes: + return std::all_of(colors.begin(), colors.end(), + [&identity](const QChar &color) { return identity.contains(color); }); + case VisualDeckStorageSortFilterProxyModel::Excludes: + return std::none_of(colors.begin(), colors.end(), + [&identity](const QChar &color) { return identity.contains(color); }); + } + return false; +} + +void VisualDeckStorageSortFilterProxyModel::setSourceModel(QAbstractItemModel *model) +{ + if (QAbstractItemModel *oldModel = sourceModel()) { + disconnect(oldModel, &QAbstractItemModel::modelReset, this, + &VisualDeckStorageSortFilterProxyModel::resizeMatchLists); + disconnect(oldModel, &QAbstractItemModel::rowsInserted, this, + &VisualDeckStorageSortFilterProxyModel::resizeMatchLists); + disconnect(oldModel, &QAbstractItemModel::rowsRemoved, this, + &VisualDeckStorageSortFilterProxyModel::resizeMatchLists); + } + + QSortFilterProxyModel::setSourceModel(model); + + if (model) { + connect(model, &QAbstractItemModel::modelReset, this, &VisualDeckStorageSortFilterProxyModel::resizeMatchLists); + connect(model, &QAbstractItemModel::rowsInserted, this, + &VisualDeckStorageSortFilterProxyModel::resizeMatchLists); + connect(model, &QAbstractItemModel::rowsRemoved, this, + &VisualDeckStorageSortFilterProxyModel::resizeMatchLists); + } + + resizeMatchLists(); +} + +void VisualDeckStorageSortFilterProxyModel::setSearchText(const QString &text) +{ + if (searchText == text) { + return; + } + + searchText = text; + updateSearchMatches(); + invalidate(); +} + +void VisualDeckStorageSortFilterProxyModel::setTagFilter(const QSet &newSelectedTags, + const QSet &newExcludedTags) +{ + if (selectedTags == newSelectedTags && excludedTags == newExcludedTags) { + return; + } + + selectedTags = newSelectedTags; + excludedTags = newExcludedTags; + updateTagMatches(); + invalidate(); +} + +void VisualDeckStorageSortFilterProxyModel::setColorFilter(FilterMode mode, const QSet &colors) +{ + if (colorFilterMode == mode && activeColors == colors) { + return; + } + + colorFilterMode = mode; + activeColors = colors; + updateColorMatches(); + invalidate(); +} + +void VisualDeckStorageSortFilterProxyModel::setSortOrder(SortOrder order) +{ + // No equality guard: the initial reapply (with the default order) must still + // trigger sort(0), since without a sort the proxy would show scan order. + sortOrder = order; + sort(0); +} + +void VisualDeckStorageSortFilterProxyModel::reapplyFilters() +{ + const QList oldSearchMatches = searchMatches; + const QList oldTagMatches = tagMatches; + const QList oldColorMatches = colorMatches; + + updateSearchMatches(); + updateTagMatches(); + updateColorMatches(); + + if (searchMatches != oldSearchMatches || tagMatches != oldTagMatches || colorMatches != oldColorMatches) { + invalidate(); + } + + if (sortOrder == ByName || sortOrder == ByLastLoaded) { + // These orders depend on data that only becomes available when a deck finishes loading. + sort(0); + } +} + +void VisualDeckStorageSortFilterProxyModel::resort() +{ + sort(0); +} + +QVariant VisualDeckStorageSortFilterProxyModel::data(const QModelIndex &index, int role) const +{ + if (role == VisualDeckStorageRoles::FilterMatchRole) { + if (!index.isValid()) { + return true; + } + const QModelIndex sourceIndex = mapToSource(index); + return rowMatches(sourceIndex.row()); + } + + return QSortFilterProxyModel::data(index, role); +} + +bool VisualDeckStorageSortFilterProxyModel::rowMatches(int sourceRow) const +{ + // If the match lists aren't sized to the current model yet, don't hide anything. + if (sourceRow < 0 || sourceRow >= searchMatches.size() || sourceRow >= tagMatches.size() || + sourceRow >= colorMatches.size()) { + return true; + } + + return searchMatches.at(sourceRow) && tagMatches.at(sourceRow) && colorMatches.at(sourceRow); +} + +bool VisualDeckStorageSortFilterProxyModel::filterAcceptsRow(int /*sourceRow*/, + const QModelIndex & /*sourceParent*/) const +{ + // Rows are never dropped: the filter result is exposed per row through + // FilterMatchRole, so views can keep their widgets alive and just hide them. + return true; +} + +bool VisualDeckStorageSortFilterProxyModel::lessThan(const QModelIndex &left, const QModelIndex &right) const +{ + const auto *source = deckSourceModel(); + if (!source) { + return false; + } + + const DeckPreviewData &leftData = source->dataForRow(left.row()); + const DeckPreviewData &rightData = source->dataForRow(right.row()); + + switch (sortOrder) { + case ByName: + return leftData.deckName < rightData.deckName; + case Alphabetical: + return QString::localeAwareCompare(QFileInfo(leftData.filePath).fileName(), + QFileInfo(rightData.filePath).fileName()) < 0; + case ByLastModified: + return leftData.lastModified > rightData.lastModified; + case ByLastLoaded: + return leftData.lastLoaded > rightData.lastLoaded; + } + + return false; +} + +void VisualDeckStorageSortFilterProxyModel::resizeMatchLists() +{ + const int count = sourceModel() ? sourceModel()->rowCount() : 0; + searchMatches.resize(count); + searchMatches.fill(true); + tagMatches.resize(count); + tagMatches.fill(true); + colorMatches.resize(count); + colorMatches.fill(true); +} + +void VisualDeckStorageSortFilterProxyModel::updateSearchMatches() +{ + const auto *source = deckSourceModel(); + if (!source) { + searchMatches.clear(); + return; + } + + const int count = source->rowCount(); + searchMatches.resize(count); + if (searchText.isEmpty()) { + searchMatches.fill(true); + return; + } + + const auto &cardsDisplay = SettingsCache::instance().cardsDisplay(); + DeckFilterString filterString( + searchText, CardSearchLanguage{cardsDisplay.getCardLang(), + static_cast(cardsDisplay.getCardSearchLanguage())}); + for (int row = 0; row < count; ++row) { + const DeckPreviewData &data = source->dataForRow(row); + + // isEmpty() is intentional: if a deck fails to load, loadInProgress becomes false + // but the deck remains empty. Using loadInProgress alone would pass failed decks + // to DeckFilterString::check, which requires a non-empty deck. + if (data.deck.isEmpty()) { + searchMatches[row] = true; + continue; + } + + DeckSearchData searchData{ + .deck = &data.deck, + .filePath = data.filePath, + .displayName = data.displayName, + .relativeFilePath = data.relativeFilePath, + }; + searchMatches[row] = filterString.check(searchData); + } +} + +void VisualDeckStorageSortFilterProxyModel::updateTagMatches() +{ + const auto *source = deckSourceModel(); + if (!source) { + tagMatches.clear(); + return; + } + + const int count = source->rowCount(); + tagMatches.resize(count); + + if (selectedTags.isEmpty() && excludedTags.isEmpty()) { + tagMatches.fill(true); + return; + } + + for (int row = 0; row < count; ++row) { + const QStringList deckTags = source->dataForRow(row).tags; + + const bool hasAllSelected = std::all_of(selectedTags.begin(), selectedTags.end(), + [&deckTags](const QString &tag) { return deckTags.contains(tag); }); + const bool hasAnyExcluded = std::any_of(excludedTags.begin(), excludedTags.end(), + [&deckTags](const QString &tag) { return deckTags.contains(tag); }); + + tagMatches[row] = hasAllSelected && !hasAnyExcluded; + } +} + +void VisualDeckStorageSortFilterProxyModel::updateColorMatches() +{ + const auto *source = deckSourceModel(); + if (!source) { + colorMatches.clear(); + return; + } + + const int count = source->rowCount(); + colorMatches.resize(count); + + if (activeColors.isEmpty()) { + colorMatches.fill(true); + return; + } + + for (int row = 0; row < count; ++row) { + const QString colorIdentity = source->dataForRow(row).colorIdentity; + colorMatches[row] = colorIdentityMatches(colorFilterMode, activeColors, colorIdentity); + } +} + +const VisualDeckStorageModel *VisualDeckStorageSortFilterProxyModel::deckSourceModel() const +{ + return qobject_cast(sourceModel()); +} diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.h new file mode 100644 index 000000000..51732adc8 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.h @@ -0,0 +1,119 @@ +/** + * @file visual_deck_storage_sort_filter_proxy_model.h + * @ingroup VisualDeckStorageWidgets + * @brief Sorting and filtering proxy on top of VisualDeckStorageModel. + * + * Owns all search / tag / color filter state and the sort order. Filtering is + * evaluated against the model's data (never against widgets), so it can run + * before any view exists and re-evaluate whenever deck data finishes loading. + * + * Rows are never removed by filtering. Instead, every row carries the + * FilterMatchRole, which views read to show or hide their widgets while keeping + * them alive; all rows stay in the proxy so they keep their sorted position. + */ + +#ifndef VISUAL_DECK_STORAGE_SORT_FILTER_PROXY_MODEL_H +#define VISUAL_DECK_STORAGE_SORT_FILTER_PROXY_MODEL_H + +#include +#include +#include +#include +#include +#include + +class QObject; +class VisualDeckStorageModel; + +class VisualDeckStorageSortFilterProxyModel : public QSortFilterProxyModel +{ + Q_OBJECT +public: + /** + * @brief The order in which decks are sorted. Values must match the + * entries of the sort widget's combo box and the stored settings value. + */ + enum SortOrder + { + ByName, + Alphabetical, + ByLastModified, + ByLastLoaded, + }; + Q_ENUM(SortOrder) + + /** + * @brief How the color identity filter is applied. + */ + enum FilterMode + { + ExactMatch, + Includes, + Excludes + }; + Q_ENUM(FilterMode) + + explicit VisualDeckStorageSortFilterProxyModel(QObject *parent = nullptr); + + [[nodiscard]] QVariant data(const QModelIndex &index, int role) const override; + + void setSourceModel(QAbstractItemModel *model) override; + + /// @name Filter input setters (each re-evaluates the affected matches) + ///@{ + void setSearchText(const QString &text); + void setTagFilter(const QSet &newSelectedTags, const QSet &newExcludedTags); + void setColorFilter(FilterMode mode, const QSet &colors); + ///@} + + /** + * @brief Sets the sort order and applies it immediately. + */ + void setSortOrder(SortOrder order); + + /** + * @brief Re-evaluates all matches against the current model data and + * re-applies filtering and sorting. Called after deck data changes. + */ + void reapplyFilters(); + + /** + * @brief Re-applies the current sort order without touching the filters. + */ + void resort(); + +protected: + bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override; + bool lessThan(const QModelIndex &left, const QModelIndex &right) const override; + +private: + [[nodiscard]] bool rowMatches(int sourceRow) const; + void resizeMatchLists(); + void updateSearchMatches(); + void updateTagMatches(); + void updateColorMatches(); + [[nodiscard]] const VisualDeckStorageModel *deckSourceModel() const; + + QString searchText; + QSet selectedTags; + QSet excludedTags; + FilterMode colorFilterMode = ExactMatch; + QSet activeColors; + SortOrder sortOrder = Alphabetical; + + QList searchMatches; ///< Per-row search match, sized like the source model. + QList tagMatches; ///< Per-row tag match. + QList colorMatches; ///< Per-row color identity match. +}; + +/** + * @brief Whether an identity string matches the active color-identity filter. + * + * The single source of truth for the color identity matching rule, shared by + * the Visual Deck Storage proxy and the remote public decks model. + */ +[[nodiscard]] bool colorIdentityMatches(VisualDeckStorageSortFilterProxyModel::FilterMode mode, + const QSet &colors, + const QString &identity); + +#endif // VISUAL_DECK_STORAGE_SORT_FILTER_PROXY_MODEL_H diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.cpp index e4eedb078..f798a72a9 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.cpp @@ -1,20 +1,14 @@ #include "visual_deck_storage_sort_widget.h" #include "../../../client/settings/cache_settings.h" +#include "visual_deck_storage_widget.h" -#include +#include +#include +#include #include -/** - * @brief Constructs a PrintingSelectorCardSortWidget for searching cards by set name or set code. - * - * This widget provides a search bar that allows users to search for cards by either their set name - * or set code. It uses a debounced timer to trigger the search action after the user stops typing. - * - * @param parent The parent PrintingSelector widget that will handle the search results. - */ -VisualDeckStorageSortWidget::VisualDeckStorageSortWidget(VisualDeckStorageWidget *parent) - : parent(parent), sortOrder(Alphabetical) +VisualDeckStorageSortWidget::VisualDeckStorageSortWidget(VisualDeckStorageWidget *parent) : QWidget(parent) { layout = new QHBoxLayout(this); layout->setContentsMargins(0, 0, 0, 0); @@ -30,12 +24,10 @@ VisualDeckStorageSortWidget::VisualDeckStorageSortWidget(VisualDeckStorageWidget // Set the current sort order sortComboBox->setCurrentIndex(SettingsCache::instance().visualDeckStorage().getVisualDeckStorageSortingOrder()); - sortOrder = static_cast(sortComboBox->currentIndex()); - // Connect sorting change signal to refresh the file list + // Connect sorting change signal to persist the order and refresh the file list connect(sortComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, &VisualDeckStorageSortWidget::updateSortOrder); - connect(this, &VisualDeckStorageSortWidget::sortOrderChanged, parent, &VisualDeckStorageWidget::updateSortOrder); } void VisualDeckStorageSortWidget::retranslateUi() @@ -47,10 +39,13 @@ void VisualDeckStorageSortWidget::retranslateUi() // Clear and repopulate the ComboBox with translated items sortComboBox->clear(); - sortComboBox->addItem(tr("Sort Alphabetically (Deck Name)"), ByName); - sortComboBox->addItem(tr("Sort Alphabetically (Filename)"), Alphabetical); - sortComboBox->addItem(tr("Sort by Last Modified"), ByLastModified); - sortComboBox->addItem(tr("Sort by Last Loaded"), ByLastLoaded); + sortComboBox->addItem(tr("Sort Alphabetically (Deck Name)"), + VisualDeckStorageSortFilterProxyModel::SortOrder::ByName); + sortComboBox->addItem(tr("Sort Alphabetically (Filename)"), + VisualDeckStorageSortFilterProxyModel::SortOrder::Alphabetical); + sortComboBox->addItem(tr("Sort by Last Modified"), + VisualDeckStorageSortFilterProxyModel::SortOrder::ByLastModified); + sortComboBox->addItem(tr("Sort by Last Loaded"), VisualDeckStorageSortFilterProxyModel::SortOrder::ByLastLoaded); // Restore the current index sortComboBox->setCurrentIndex(oldIndex); @@ -59,60 +54,13 @@ void VisualDeckStorageSortWidget::retranslateUi() sortComboBox->blockSignals(false); } +VisualDeckStorageSortFilterProxyModel::SortOrder VisualDeckStorageSortWidget::currentSortOrder() const +{ + return static_cast(sortComboBox->currentIndex()); +} + void VisualDeckStorageSortWidget::updateSortOrder() { - sortOrder = static_cast(sortComboBox->currentIndex()); SettingsCache::instance().visualDeckStorage().setVisualDeckStorageSortingOrder(sortComboBox->currentIndex()); emit sortOrderChanged(); } - -void VisualDeckStorageSortWidget::sortFolder(VisualDeckStorageFolderDisplayWidget *folderWidget) -{ - auto children = - folderWidget->getFlowWidget()->findChildren(QString(), Qt::FindChildOption::FindDirectChildrenOnly); - for (auto widget : children) { - auto deckPreviewWidgets = - widget->findChildren(QString(), Qt::FindChildOption::FindDirectChildrenOnly); - auto newOrder = filterFiles(deckPreviewWidgets); - for (DeckPreviewWidget *previewWidget : newOrder) { - folderWidget->getFlowWidget()->removeWidget(previewWidget); - } - for (DeckPreviewWidget *previewWidget : newOrder) { - folderWidget->getFlowWidget()->addWidget(previewWidget); - } - } -} - -QList VisualDeckStorageSortWidget::filterFiles(QList widgets) -{ - // Sort the widgets list based on the current sort order - std::sort(widgets.begin(), widgets.end(), [this](DeckPreviewWidget *widget1, DeckPreviewWidget *widget2) { - if (!widget1 || !widget2) { - return false; // Handle null pointers gracefully - } - - QFileInfo info1(widget1->filePath); - QFileInfo info2(widget2->filePath); - - switch (sortOrder) { - case ByName: - return widget1->deckLoader->getDeck().deckList.getName() < - widget2->deckLoader->getDeck().deckList.getName(); - case Alphabetical: - return QString::localeAwareCompare(info1.fileName(), info2.fileName()) <= 0; - case ByLastModified: - return info1.lastModified() > info2.lastModified(); - case ByLastLoaded: { - QDateTime time1 = - QDateTime::fromString(widget1->deckLoader->getDeck().deckList.getLastLoadedTimestamp()); - QDateTime time2 = - QDateTime::fromString(widget2->deckLoader->getDeck().deckList.getLastLoadedTimestamp()); - return time1 > time2; - } - } - - return false; // Default case, no sorting applied - }); - - return widgets; -} diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.h index 24eddba33..df3f39eb5 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_widget.h @@ -2,19 +2,19 @@ * @file visual_deck_storage_sort_widget.h * @ingroup VisualDeckStorageWidgets */ -//! \todo Document this file. #ifndef VISUAL_DECK_STORAGE_SORT_WIDGET_H #define VISUAL_DECK_STORAGE_SORT_WIDGET_H -#include "visual_deck_storage_widget.h" +#include "visual_deck_storage_sort_filter_proxy_model.h" -#include -#include #include +#include class VisualDeckStorageWidget; -class VisualDeckStorageFolderDisplayWidget; +class QComboBox; +class QHBoxLayout; + class VisualDeckStorageSortWidget : public QWidget { Q_OBJECT @@ -22,25 +22,23 @@ class VisualDeckStorageSortWidget : public QWidget public: explicit VisualDeckStorageSortWidget(VisualDeckStorageWidget *parent); void retranslateUi(); - void updateSortOrder(); - void sortFolder(VisualDeckStorageFolderDisplayWidget *folderWidget); - QString getSearchText(); - QList filterFiles(QList widgets); + + /** + * @brief The currently selected sort order. + */ + [[nodiscard]] VisualDeckStorageSortFilterProxyModel::SortOrder currentSortOrder() const; signals: + /** + * @brief Emitted when the user picks a different sort order. + */ void sortOrderChanged(); +private slots: + void updateSortOrder(); + private: - enum SortOrder - { - ByName, - Alphabetical, - ByLastModified, - ByLastLoaded, - }; QHBoxLayout *layout; - VisualDeckStorageWidget *parent; - SortOrder sortOrder; // Current sorting option QComboBox *sortComboBox; }; diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp index c4c8d18a8..d6b7aedbb 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.cpp @@ -2,12 +2,17 @@ #include "../general/layout_containers/flow_widget.h" #include "deck_preview/deck_preview_tag_display_widget.h" -#include "visual_deck_storage_widget.h" #include +#include +#include +#include +#include +#include +#include +#include -VisualDeckStorageTagFilterWidget::VisualDeckStorageTagFilterWidget(VisualDeckStorageWidget *_parent) - : QWidget(_parent), parent(_parent) +VisualDeckStorageTagFilterWidget::VisualDeckStorageTagFilterWidget(QWidget *parent) : QWidget(parent) { setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); @@ -18,148 +23,99 @@ VisualDeckStorageTagFilterWidget::VisualDeckStorageTagFilterWidget(VisualDeckSto setFixedHeight(100); - auto *flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); + flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAsNeeded); layout->addWidget(flowWidget); } +void VisualDeckStorageTagFilterWidget::setAllTagsProvider(const std::function()> &provider) +{ + allTagsProvider = provider; +} + void VisualDeckStorageTagFilterWidget::showEvent(QShowEvent *event) { QWidget::showEvent(event); refreshTags(); } -void VisualDeckStorageTagFilterWidget::filterDecksBySelectedTags(const QList &deckPreviews) const -{ - QStringList selectedTags; - QStringList excludedTags; - - // Collect selected and excluded tags - for (DeckPreviewTagDisplayWidget *tagWidget : findChildren()) { - switch (tagWidget->getState()) { - case TagState::Selected: - selectedTags.append(tagWidget->getTagName()); - break; - case TagState::Excluded: - excludedTags.append(tagWidget->getTagName()); - break; - default: - break; - } - } - - // If no tags are selected or excluded, show all - if (selectedTags.isEmpty() && excludedTags.isEmpty()) { - for (DeckPreviewWidget *deckPreview : deckPreviews) { - deckPreview->filteredByTags = false; - } - return; - } - - for (DeckPreviewWidget *deckPreview : deckPreviews) { - QStringList deckTags = deckPreview->deckLoader->getDeck().deckList.getTags(); - - bool hasAllSelected = std::all_of(selectedTags.begin(), selectedTags.end(), - [&deckTags](const QString &tag) { return deckTags.contains(tag); }); - - bool hasAnyExcluded = std::any_of(excludedTags.begin(), excludedTags.end(), - [&deckTags](const QString &tag) { return deckTags.contains(tag); }); - - // Filter out if any excluded tag is present or if any selected tag is missing - deckPreview->filteredByTags = !(hasAllSelected && !hasAnyExcluded); - } -} - void VisualDeckStorageTagFilterWidget::refreshTags() { - QSet allTags = gatherAllTags(); - removeTagsNotInList(allTags); - addTagsIfNotPresent(allTags); - sortTags(); -} - -void VisualDeckStorageTagFilterWidget::removeTagsNotInList(const QSet &tags) -{ - auto *flowWidget = findChild(); + const QSet allTags = allTagsProvider ? allTagsProvider() : QSet(); + // Existing chips survive if their tag is still part of the deck set, or if the chip + // is currently selected/excluded. Everything else is dropped. Dropped chips must NOT + // be re-added to the layout afterwards: they are reparented to nullptr and scheduled + // for a deferred delete (QWidget::setParent(nullptr) also hides them), and the flow + // layout would keep a dangling reference to them once the deletion runs on the next + // event-loop cycle. + QList chips; for (DeckPreviewTagDisplayWidget *tagWidget : findChildren()) { - const QString &tagName = tagWidget->getTagName(); - - // Keep the tag widget if it is either selected or excluded - if (!tags.contains(tagName) && tagWidget->getState() == TagState::NotSelected) { + if (tagWidget->getState() != TagState::NotSelected || allTags.contains(tagWidget->getTagName())) { + chips.append(tagWidget); + } else { flowWidget->removeWidget(tagWidget); + tagWidget->setParent(nullptr); tagWidget->deleteLater(); } } -} -void VisualDeckStorageTagFilterWidget::addTagsIfNotPresent(const QSet &tags) -{ - for (const QString &tag : tags) { - addTagIfNotPresent(tag); + // Add chips for tags that are not shown yet. + QSet existingTags; + for (DeckPreviewTagDisplayWidget *tagWidget : chips) { + existingTags.insert(tagWidget->getTagName()); } -} - -void VisualDeckStorageTagFilterWidget::addTagIfNotPresent(const QString &tag) -{ - // Check if the tag already exists in the flow widget - bool tagExists = false; - for (DeckPreviewTagDisplayWidget *tagWidget : findChildren()) { - if (tagWidget->getTagName() == tag) { - tagExists = true; - break; + for (const QString &tag : allTags) { + if (!existingTags.contains(tag)) { + auto *newTagWidget = new DeckPreviewTagDisplayWidget(this, tag); + connect(newTagWidget, &DeckPreviewTagDisplayWidget::tagClicked, this, + &VisualDeckStorageTagFilterWidget::filterChanged); + flowWidget->addWidget(newTagWidget); + chips.append(newTagWidget); } } - // If the tag doesn't exist, add a new DeckPreviewTagDisplayWidget - if (!tagExists) { - auto *newTagWidget = new DeckPreviewTagDisplayWidget(this, tag); - connect(newTagWidget, &DeckPreviewTagDisplayWidget::tagClicked, parent, - &VisualDeckStorageWidget::updateTagFilter); - connect(newTagWidget, &DeckPreviewTagDisplayWidget::tagClicked, this, - &VisualDeckStorageTagFilterWidget::refreshTags); - auto *flowWidget = findChild(); - flowWidget->addWidget(newTagWidget); - } -} - -void VisualDeckStorageTagFilterWidget::sortTags() -{ - auto *flowWidget = findChild(); - if (!flowWidget) { + // Sort, but skip the full remove/re-add when the order already matches the layout. + // FlowWidget inherits QLayout::removeWidget's linear scan, so rebuilding an unchanged + // order would be quadratic plus a full relayout on every chip click and load batch. + std::sort(chips.begin(), chips.end(), [](DeckPreviewTagDisplayWidget *a, DeckPreviewTagDisplayWidget *b) { + const QString aName = a->getTagName(); + const QString bName = b->getTagName(); + const int compared = aName.compare(bName, Qt::CaseInsensitive); + return compared != 0 ? compared < 0 : aName < bName; + }); + if (chips == currentChipOrder) { return; } - - // Get all tag widgets - QList tagWidgets = findChildren(); - - // Sort widgets by tag name - std::sort(tagWidgets.begin(), tagWidgets.end(), [](DeckPreviewTagDisplayWidget *a, DeckPreviewTagDisplayWidget *b) { - return a->getTagName().toLower() < b->getTagName().toLower(); - }); - - // Clear and re-add widgets in sorted order - for (DeckPreviewTagDisplayWidget *tagWidget : tagWidgets) { + for (DeckPreviewTagDisplayWidget *tagWidget : chips) { flowWidget->removeWidget(tagWidget); } - for (DeckPreviewTagDisplayWidget *tagWidget : tagWidgets) { + for (DeckPreviewTagDisplayWidget *tagWidget : chips) { flowWidget->addWidget(tagWidget); } + currentChipOrder = chips; } -QSet VisualDeckStorageTagFilterWidget::gatherAllTags() const +QStringList VisualDeckStorageTagFilterWidget::selectedTags() const { - QSet allTags; - QList deckWidgets = parent->findChildren(); - - for (DeckPreviewWidget *widget : deckWidgets) { - if (widget->checkVisibility()) { - for (const QString &tag : widget->deckLoader->getDeck().deckList.getTags()) { - allTags.insert(tag); - } + QStringList selected; + for (DeckPreviewTagDisplayWidget *tagWidget : findChildren()) { + if (tagWidget->getState() == TagState::Selected) { + selected.append(tagWidget->getTagName()); } } - return allTags; + return selected; +} + +QStringList VisualDeckStorageTagFilterWidget::excludedTags() const +{ + QStringList excluded; + for (DeckPreviewTagDisplayWidget *tagWidget : findChildren()) { + if (tagWidget->getState() == TagState::Excluded) { + excluded.append(tagWidget->getTagName()); + } + } + return excluded; } QStringList VisualDeckStorageTagFilterWidget::getAllKnownTags() const diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.h index 3290c9e9a..13a27c10a 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_tag_filter_widget.h @@ -2,34 +2,61 @@ * @file visual_deck_storage_tag_filter_widget.h * @ingroup VisualDeckStorageWidgets */ -//! \todo Document this file. #ifndef VISUAL_DECK_STORAGE_TAG_FILTER_WIDGET_H #define VISUAL_DECK_STORAGE_TAG_FILTER_WIDGET_H -#include "deck_preview/deck_preview_widget.h" - +#include +#include #include +#include +#include + +class DeckPreviewTagDisplayWidget; +class FlowWidget; +class QString; +template class QSet; -class VisualDeckStorageWidget; class VisualDeckStorageTagFilterWidget : public QWidget { Q_OBJECT - VisualDeckStorageWidget *parent; - - [[nodiscard]] QSet gatherAllTags() const; - void removeTagsNotInList(const QSet &tags); - void addTagsIfNotPresent(const QSet &tags); - void addTagIfNotPresent(const QString &tag); - void sortTags(); + FlowWidget *flowWidget; + std::function()> allTagsProvider; + QList currentChipOrder; public: - explicit VisualDeckStorageTagFilterWidget(VisualDeckStorageWidget *_parent); + explicit VisualDeckStorageTagFilterWidget(QWidget *parent = nullptr); [[nodiscard]] QStringList getAllKnownTags() const; - void filterDecksBySelectedTags(const QList &deckPreviews) const; + + /** + * @brief Sets a provider for the full set of tags to draw chips from. + */ + void setAllTagsProvider(const std::function()> &provider); + + /** + * @brief The tags currently in "selected" state. + */ + [[nodiscard]] QStringList selectedTags() const; + + /** + * @brief The tags currently in "excluded" state. + */ + [[nodiscard]] QStringList excludedTags() const; + +signals: + /** + * Emitted when a chip's selection or exclusion state changes. + * + * The chip only emits when its state actually changed, so this fires once per + * effective toggle rather than on every click. + */ + void filterChanged(); public slots: + /** + * @brief Rebuilds the tag chips from the currently available tags. + */ void refreshTags(); void showEvent(QShowEvent *event) override; }; 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 664c3cd1f..c16445145 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,26 +1,50 @@ #include "visual_deck_storage_widget.h" #include "../../../client/settings/cache_settings.h" +#include "../../pixel_map_generator.h" #include "../general/tutorial/tutorial_controller.h" #include "../quick_settings/settings_button_widget.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" +#include "libcockatrice/card/database/card_database_loader.h" #include "visual_deck_storage_folder_display_widget.h" +#include "visual_deck_storage_model.h" +#include "visual_deck_storage_quick_settings_widget.h" #include "visual_deck_storage_search_widget.h" +#include "visual_deck_storage_sort_filter_proxy_model.h" #include "visual_deck_storage_sort_widget.h" #include "visual_deck_storage_tag_filter_widget.h" -#include -#include -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include +#include +#include #include +#include #include #include +#include -VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(parent), folderWidget(nullptr) +VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(parent) { - deckListModel = new DeckListModel(this); - deckListModel->setObjectName("visualDeckModel"); + // The model and proxy own all deck data, sorting and filtering. The view widgets below + // only display the proxy's rows and their FilterMatchRole, so nothing touches the + // filesystem outside the model. + storageModel = new VisualDeckStorageModel(this); + storageProxyModel = new VisualDeckStorageSortFilterProxyModel(this); + storageProxyModel->setSourceModel(storageModel); layout = new QVBoxLayout(this); layout->setSpacing(0); @@ -39,10 +63,16 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare searchWidget = new VisualDeckStorageSearchWidget(this); refreshButton = new QToolButton(this); - refreshButton->setIcon(QPixmap("theme:icons/reload")); + refreshButton->setIcon(themePixmap(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->setFixedSize(32, 32); + shareButton->setVisible(false); + connect(shareButton, &QPushButton::clicked, this, &VisualDeckStorageWidget::shareRequested); + quickSettingsWidget = new VisualDeckStorageQuickSettingsWidget(this); connect(quickSettingsWidget, &VisualDeckStorageQuickSettingsWidget::showFoldersChanged, this, &VisualDeckStorageWidget::updateShowFolders); @@ -53,10 +83,14 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare searchAndSortLayout->addWidget(sortWidget); searchAndSortLayout->addWidget(searchWidget); searchAndSortLayout->addWidget(refreshButton); + searchAndSortLayout->addWidget(shareButton); searchAndSortLayout->addWidget(quickSettingsWidget); // tag filter box tagFilterWidget = new VisualDeckStorageTagFilterWidget(this); + tagFilterWidget->setAllTagsProvider([this] { return gatherVisibleTags(); }); + connect(tagFilterWidget, &VisualDeckStorageTagFilterWidget::filterChanged, this, + &VisualDeckStorageWidget::updateTagFilter); updateTagsVisibility(SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowTagFilter()); deckPreviewSelectionAnimationEnabled = @@ -76,6 +110,40 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare layout->addWidget(tagFilterWidget); layout->addWidget(scrollArea); + // The deck data changed (a load finished, or a mutation happened): re-evaluate the filters, + // since search/tag/color matches are computed against the data. Debounced so that a burst of + // load completions triggers a single re-application instead of one O(n) pass per deck. + refreshTimer = new QTimer(this); + refreshTimer->setSingleShot(true); + refreshTimer->setInterval(150); + connect(refreshTimer, &QTimer::timeout, this, [this] { + storageProxyModel->reapplyFilters(); + // A batch of decks finished loading: re-gather the tag chips from the visible decks once + // the burst settles instead of on every individual load. + tagFilterWidget->refreshTags(); + }); + connect(storageModel, &QAbstractItemModel::dataChanged, this, [this] { refreshTimer->start(); }); + connect(storageModel, &VisualDeckStorageModel::deckLoaded, this, [this] { refreshTimer->start(); }); + // A deck's file path changed: re-apply the sort, since orders like "filename" depend on it. + connect(storageModel, &VisualDeckStorageModel::deckFilePathChanged, this, [this] { storageProxyModel->resort(); }); + connect(sortWidget, &VisualDeckStorageSortWidget::sortOrderChanged, this, + &VisualDeckStorageWidget::updateSortOrder); + // The filter widgets only own their ui state. Pushing it into the proxy model + // happens here, so the children stay decoupled from the model layer. + connect(deckPreviewColorIdentityFilterWidget, &DeckPreviewColorIdentityFilterWidget::activeColorsChanged, this, + &VisualDeckStorageWidget::updateColorFilter); + connect(deckPreviewColorIdentityFilterWidget, &DeckPreviewColorIdentityFilterWidget::filterModeChanged, this, + &VisualDeckStorageWidget::updateColorFilter); + connect(searchWidget, &VisualDeckStorageSearchWidget::searchTextChanged, this, + &VisualDeckStorageWidget::updateSearchFilter); + + // The deck content search matches card names in the configured card language; + // re-run it whenever that setting changes so active searches follow immediately. + CardsDisplaySettings *cardsDisplay = &SettingsCache::instance().cardsDisplay(); + const auto reapplySearchForLanguage = [this] { storageProxyModel->reapplyFilters(); }; + connect(cardsDisplay, &CardsDisplaySettings::cardLangChanged, this, reapplySearchForLanguage); + connect(cardsDisplay, &CardsDisplaySettings::cardSearchLanguageChanged, this, reapplySearchForLanguage); + connect(CardDatabaseManager::getInstance(), &CardDatabase::cardDatabaseLoadingFinished, this, &VisualDeckStorageWidget::createRootFolderWidget); @@ -145,7 +213,69 @@ void VisualDeckStorageWidget::retranslateUi() databaseLoadIndicator->setText(tr("Loading database ...")); refreshButton->setToolTip(tr("Refresh loaded files")); + shareButton->setToolTip(tr("Select decks to share")); quickSettingsWidget->setToolTip(tr("Visual Deck Storage Settings")); + + sortWidget->retranslateUi(); +} + +void VisualDeckStorageWidget::setShareSelectable(bool selectable) +{ + if (shareSelectable == selectable) { + return; + } + shareSelectable = selectable; + if (folderWidget != nullptr) { + folderWidget->setShareSelectable(selectable); + } + emit shareSelectionChanged(); +} + +bool VisualDeckStorageWidget::isShareSelectable() const +{ + return shareSelectable; +} + +QStringList VisualDeckStorageWidget::selectedFilePaths() const +{ + QStringList selectedPaths; + if (folderWidget != nullptr) { + const auto previews = folderWidget->findChildren(); + for (DeckPreviewWidget *preview : previews) { + // Filtered-out previews stay alive hidden in their sorted place, so only decks the + // user can actually see are part of the share. + if (preview->isVisible() && preview->isShareSelected()) { + selectedPaths.append(preview->filePath); + } + } + } + return selectedPaths; +} + +void VisualDeckStorageWidget::clearShareSelection() +{ + if (folderWidget != nullptr) { + const auto previews = folderWidget->findChildren(); + for (DeckPreviewWidget *preview : previews) { + preview->setShareSelected(false); + } + } +} + +void VisualDeckStorageWidget::setShareAvailable(bool available) +{ + shareButton->setVisible(available); + shareButton->setEnabled(available); +} + +void VisualDeckStorageWidget::setShareSelectedFiles(const QStringList &paths) +{ + if (folderWidget != nullptr) { + const auto previews = folderWidget->findChildren(); + for (DeckPreviewWidget *preview : previews) { + preview->setShareSelected(paths.contains(preview->filePath)); + } + } } /** @@ -157,72 +287,88 @@ const VisualDeckStorageQuickSettingsWidget *VisualDeckStorageWidget::settings() } /** - * Reapplies all sort and filter options by calling the appropriate update methods. + * Reapplies all sort and filter options by updating the proxy model. */ void VisualDeckStorageWidget::reapplySortAndFilters() { - updateSortOrder(); - updateTagFilter(); - updateColorFilter(); - updateSearchFilter(); + storageProxyModel->setSortOrder(sortWidget->currentSortOrder()); + storageProxyModel->reapplyFilters(); } +/** + * @brief Scans the deck folder and rebuilds the folder tree of deck previews. + */ void VisualDeckStorageWidget::createRootFolderWidget() { - folderWidget = new VisualDeckStorageFolderDisplayWidget(this, this, SettingsCache::instance().paths().getDeckPath(), - false, quickSettingsWidget->getShowFolders()); + storageModel->setDeckPath(SettingsCache::instance().paths().getDeckPath()); + + folderWidget = + new VisualDeckStorageFolderDisplayWidget(this, this, QString(), false, quickSettingsWidget->getShowFolders()); scrollArea->setWidget(folderWidget); // this automatically destroys the old folderWidget scrollArea->widget()->setMaximumWidth(scrollArea->viewport()->width()); scrollArea->widget()->adjustSize(); - /* We have to schedule a QTimer here so that the sorting logic doesn't try to access widgets that haven't been - * processed by the event loop yet. Otherwise, deck sorting will intermittently segfault on some systems. - */ - QTimer::singleShot(0, this, &VisualDeckStorageWidget::reapplySortAndFilters); + // Sort and filter runs against the model data, so it is safe to apply immediately. + reapplySortAndFilters(); } void VisualDeckStorageWidget::updateShowFolders(bool enabled) { if (folderWidget) { folderWidget->updateShowFolders(enabled); - QTimer::singleShot(0, this, &VisualDeckStorageWidget::reapplySortAndFilters); } } void VisualDeckStorageWidget::updateSortOrder() { - if (folderWidget) { - sortWidget->sortFolder(folderWidget); - for (VisualDeckStorageFolderDisplayWidget *subFolderWidget : - folderWidget->findChildren()) { - sortWidget->sortFolder(subFolderWidget); - } - } + storageProxyModel->setSortOrder(sortWidget->currentSortOrder()); } void VisualDeckStorageWidget::updateTagFilter() { - if (folderWidget) { - tagFilterWidget->filterDecksBySelectedTags(folderWidget->findChildren()); - folderWidget->updateVisibility(); - } + const QStringList selected = tagFilterWidget->selectedTags(); + const QStringList excluded = tagFilterWidget->excludedTags(); + storageProxyModel->setTagFilter(QSet(selected.cbegin(), selected.cend()), + QSet(excluded.cbegin(), excluded.cend())); + // The visible deck set changed, so the chips are re-gathered from it. + tagFilterWidget->refreshTags(); } +/** + * @brief The tags of all decks currently accepted by the proxy model. + */ +QSet VisualDeckStorageWidget::gatherVisibleTags() const +{ + QSet allTags; + for (int proxyRow = 0; proxyRow < storageProxyModel->rowCount(); ++proxyRow) { + const QModelIndex index = storageProxyModel->index(proxyRow, 0); + if (!index.data(VisualDeckStorageRoles::FilterMatchRole).toBool()) { + continue; + } + const QStringList deckTags = index.data(VisualDeckStorageRoles::TagsRole).toStringList(); + for (const QString &tag : deckTags) { + allTags.insert(tag); + } + } + return allTags; +} + +/** + * Pushes the color identity filter widget's state into the proxy model. + */ void VisualDeckStorageWidget::updateColorFilter() { - if (folderWidget) { - deckPreviewColorIdentityFilterWidget->filterWidgets(folderWidget->findChildren()); - folderWidget->updateVisibility(); - } + storageProxyModel->setColorFilter(deckPreviewColorIdentityFilterWidget->getFilterMode(), + deckPreviewColorIdentityFilterWidget->getActiveColors()); } -void VisualDeckStorageWidget::updateSearchFilter() +/** + * Pushes the search bar's text into the proxy model. + */ +void VisualDeckStorageWidget::updateSearchFilter(const QString &text) { - if (folderWidget) { - searchWidget->filterWidgets(folderWidget->findChildren(), searchWidget->getSearchText()); - folderWidget->updateVisibility(); - } + storageProxyModel->setSearchText(text); } void VisualDeckStorageWidget::updateTagsVisibility(const bool visible) @@ -238,4 +384,4 @@ void VisualDeckStorageWidget::updateTagsVisibility(const bool visible) void VisualDeckStorageWidget::updateSelectionAnimationEnabled(const bool enabled) { deckPreviewSelectionAnimationEnabled = enabled; -} \ No newline at end of file +} diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h index 3c53b478c..0700c1396 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_widget.h @@ -2,7 +2,6 @@ * @file visual_deck_storage_widget.h * @ingroup VisualDeckStorageWidgets */ -//! \todo Document this file. #ifndef VISUAL_DECK_STORAGE_WIDGET_H #define VISUAL_DECK_STORAGE_WIDGET_H @@ -18,14 +17,28 @@ #include "visual_deck_storage_sort_widget.h" #include "visual_deck_storage_tag_filter_widget.h" -#include +#include +#include +#include +#include +#include -class QSpinBox; +class QLabel; +class QTimer; +class DeckPreviewColorIdentityFilterWidget; +class VisualDeckStorageFolderDisplayWidget; +class VisualDeckStorageQuickSettingsWidget; class VisualDeckStorageSearchWidget; class VisualDeckStorageSortWidget; class VisualDeckStorageTagFilterWidget; -class VisualDeckStorageFolderDisplayWidget; -class DeckPreviewColorIdentityFilterWidget; +class QHBoxLayout; +class QScrollArea; +class QToolButton; +class QVBoxLayout; +class VisualDeckStorageModel; +class VisualDeckStorageSortFilterProxyModel; +struct LoadedDeck; + class VisualDeckStorageWidget final : public QWidget { Q_OBJECT @@ -34,44 +47,71 @@ public: void refreshIfPossible(); void retranslateUi(); void generateTutorialSequence(TutorialSequence &vdsSequence); + void setShareSelectable(bool selectable); + [[nodiscard]] bool isShareSelectable() const; + [[nodiscard]] QStringList selectedFilePaths() const; + void setShareSelectedFiles(const QStringList &paths); + void clearShareSelection(); + void setShareAvailable(bool available); VisualDeckStorageTagFilterWidget *tagFilterWidget; bool deckPreviewSelectionAnimationEnabled; [[nodiscard]] const VisualDeckStorageQuickSettingsWidget *settings() const; + [[nodiscard]] VisualDeckStorageModel *model() const + { + return storageModel; + } + [[nodiscard]] VisualDeckStorageSortFilterProxyModel *proxyModel() const + { + return storageProxyModel; + } public slots: - void createRootFolderWidget(); // Refresh the display of cards based on the current sorting option + /** + * @brief Starts scanning the deck folder and rebuilds the folder tree and previews. + */ + void createRootFolderWidget(); void updateShowFolders(bool enabled); - void updateTagFilter(); - void updateColorFilter(); - void updateSearchFilter(); void updateTagsVisibility(bool visible); void updateSelectionAnimationEnabled(bool enabled); void updateSortOrder(); + void updateTagFilter(); + void updateColorFilter(); + void updateSearchFilter(const QString &text); + +signals: + void deckLoadRequested(const QString &filePath); + void openDeckEditor(const LoadedDeck &deck); + void shareDeckRequested(const QString &filePath); + void shareSelectionChanged(); + void shareRequested(); + +protected: void resizeEvent(QResizeEvent *event) override; void showEvent(QShowEvent *event) override; -signals: - void bannerCardsRefreshed(); - void deckLoadRequested(const QString &filePath); - void openDeckEditor(const LoadedDeck &deck); +private: + void reapplySortAndFilters(); + [[nodiscard]] QSet gatherVisibleTags() const; private: QVBoxLayout *layout; QWidget *searchAndSortContainer; QHBoxLayout *searchAndSortLayout; - DeckListModel *deckListModel; QLabel *databaseLoadIndicator; VisualDeckStorageSortWidget *sortWidget; VisualDeckStorageSearchWidget *searchWidget; DeckPreviewColorIdentityFilterWidget *deckPreviewColorIdentityFilterWidget; QToolButton *refreshButton; + QToolButton *shareButton; VisualDeckStorageQuickSettingsWidget *quickSettingsWidget; QScrollArea *scrollArea; - VisualDeckStorageFolderDisplayWidget *folderWidget; - - void reapplySortAndFilters(); + VisualDeckStorageFolderDisplayWidget *folderWidget = nullptr; + VisualDeckStorageModel *storageModel = nullptr; + VisualDeckStorageSortFilterProxyModel *storageProxyModel = nullptr; + QTimer *refreshTimer = nullptr; ///< Coalesces the re-apply/refresh burst following a batch of deck loads. + bool shareSelectable = false; }; #endif // VISUAL_DECK_STORAGE_WIDGET_H diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 44e188760..189585d84 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -19,8 +19,8 @@ ***************************************************************************/ #include "window_main.h" +#include "../client/latency_status_widget.h" #include "../client/network/update/client/client_update_checker.h" -#include "../client/network/update/client/release_channel.h" #include "../client/settings/cache_settings.h" #include "../client/settings/shortcuts_settings.h" #include "../interface/widgets/dialogs/dlg_edit_tokens.h" @@ -31,41 +31,68 @@ #include "../interface/widgets/dialogs/dlg_tip_of_the_day.h" #include "../interface/widgets/dialogs/dlg_update.h" #include "../interface/widgets/dialogs/dlg_view_log.h" -#include "../interface/widgets/tabs/tab_game.h" +#include "../interface/widgets/onboarding/first_run_wizard.h" +#include "../interface/widgets/settings_page/general_settings_page.h" +#include "../interface/widgets/tabs/tab_server.h" #include "../interface/widgets/tabs/tab_supervisor.h" #include "../main.h" +#include "abstract_client.h" +#include "card_database_update/card_database_update_status_bar.h" +#include "card_database_update/card_update_progress.h" +#include "intents/contexts/context_connect_to_server.h" +#include "intents/contexts/context_join_room.h" +#include "intents/intent.h" +#include "intents/intent_connect_to_server.h" +#include "intents/intent_login.h" +#include "intents/intent_open_server_room_by_name.h" +#include "intents/url_parser.h" +#include "latency_tracker.h" #include "logger.h" +#include "pixel_map_generator.h" #include "version_string.h" #include "widgets/dialogs/dlg_connect.h" #include "widgets/server/handle_public_servers.h" +#include "widgets/settings_page/abstract_settings_page.h" #include "widgets/tabs/api/commander_spellbook/handle_commander_brackets.h" -#include "widgets/utility/get_text_with_max.h" #include #include #include #include #include +#include #include +#include +#include #include #include +#include +#include +#include +#include #include #include #include +#include +#include +#include #include +#include +#include #include +#include #include -#include #include -#include -#include -#include +#include +#include +#include +#include +#include #include #include #include #include #include -#include #include #include #include @@ -77,11 +104,20 @@ #include #include #include +#include #include +#include +#include +#include +#include +#include +#include + +class LocalServerInterface; #define GITHUB_PAGES_URL "https://cockatrice.github.io" -#define GITHUB_CONTRIBUTORS_URL "https://github.com/Cockatrice/Cockatrice/graphs/contributors?type=c" -#define GITHUB_CONTRIBUTE_URL "https://github.com/Cockatrice/Cockatrice#cockatrice" +#define GITHUB_CONTRIBUTORS_URL "https://github.com/Cockatrice/Cockatrice/graphs/contributors" +#define GITHUB_CONTRIBUTE_URL "https://github.com/Cockatrice/Cockatrice#" #define GITHUB_TRANSIFEX_TRANSLATORS_URL "https://github.com/Cockatrice/Cockatrice/wiki/Translator-Hall-of-Fame" #define GITHUB_TRANSLATOR_FAQ_URL "https://github.com/Cockatrice/Cockatrice/wiki/Translation-FAQ" #define GITHUB_ISSUES_URL "https://github.com/Cockatrice/Cockatrice/issues" @@ -234,6 +270,8 @@ void MainWindow::actFullScreen(bool checked) void MainWindow::actSettings() { DlgSettings dlg(this); + auto *generalPage = qobject_cast(dlg.page(DlgSettings::GeneralPage)); + connect(generalPage, &GeneralSettingsPage::cardDatabaseUpdateRequested, this, &MainWindow::actCheckCardUpdates); dlg.exec(); } @@ -260,7 +298,8 @@ void MainWindow::actAbout() GITHUB_TROUBLESHOOTING_URL + "'>" + tr("Troubleshooting") + "
" + "" + tr("F.A.Q.") + "
"), QMessageBox::Ok, this); - mb.setIconPixmap(QPixmap("theme:cockatrice").scaled(64, 64, Qt::KeepAspectRatio, Qt::SmoothTransformation)); + mb.setIconPixmap( + themePixmap(QStringLiteral("cockatrice")).scaled(64, 64, Qt::KeepAspectRatio, Qt::SmoothTransformation)); mb.setTextInteractionFlags(Qt::TextBrowserInteraction); mb.exec(); } @@ -312,7 +351,7 @@ void MainWindow::retranslateUi() aRegister->setText(tr("&Register to server...")); aForgotPassword->setText(tr("&Restore password...")); aSettings->setText(tr("&Settings...")); - aSettings->setIcon(QPixmap("theme:icons/settings")); + aSettings->setIcon(themePixmap(QStringLiteral("icons/settings"))); aExit->setText(tr("&Exit")); #if defined(__APPLE__) /* For OSX */ @@ -340,6 +379,7 @@ void MainWindow::retranslateUi() aStatusBar->setText(tr("Show Status Bar")); aViewLog->setText(tr("View &Debug Log")); aOpenSettingsFolder->setText(tr("Open Settings Folder")); + aFirstRunWizard->setText(tr("Re-run Onboarding Wizard...")); aShow->setText(tr("Show/Hide")); @@ -401,6 +441,8 @@ void MainWindow::createActions() connect(aViewLog, &QAction::triggered, this, &MainWindow::actViewLog); aOpenSettingsFolder = new QAction(this); connect(aOpenSettingsFolder, &QAction::triggered, this, &MainWindow::actOpenSettingsFolder); + aFirstRunWizard = new QAction(this); + connect(aFirstRunWizard, &QAction::triggered, this, [this] { runFirstRunWizard(); }); aShow = new QAction(this); connect(aShow, &QAction::triggered, this, &MainWindow::actShow); @@ -479,6 +521,8 @@ void MainWindow::createMenus() helpMenu->addAction(aStatusBar); helpMenu->addAction(aViewLog); helpMenu->addAction(aOpenSettingsFolder); + helpMenu->addSeparator(); + helpMenu->addAction(aFirstRunWizard); } MainWindow::MainWindow(QWidget *parent) @@ -490,6 +534,8 @@ MainWindow::MainWindow(QWidget *parent) pixmapCacheSizeChanged(SettingsCache::instance().cacheStorage().getPixmapCacheSize()); connectionController = new ConnectionController(this, this); + urlParser = new IntentUrlParser(this, this); + connect(urlParser, &IntentUrlParser::urlChainFinished, this, &MainWindow::onUrlChainFinished); createActions(); createMenus(); @@ -501,6 +547,7 @@ MainWindow::MainWindow(QWidget *parent) connect(tabSupervisor, &TabSupervisor::setMenu, this, &MainWindow::updateTabMenu); connect(tabSupervisor, &TabSupervisor::localGameEnded, this, &MainWindow::localGameEnded); connect(tabSupervisor, &TabSupervisor::showWindowIfHidden, this, &MainWindow::showWindowIfHidden); + connect(tabSupervisor, &TabSupervisor::cockatriceLinkActivated, this, &MainWindow::handleCockatriceLink); connect(connectionController, &ConnectionController::tabSupervisorStartRequested, tabSupervisor, &TabSupervisor::start); connect(connectionController, &ConnectionController::tabSupervisorStopRequested, tabSupervisor, @@ -525,6 +572,19 @@ MainWindow::MainWindow(QWidget *parent) [this](bool show) { statusBar()->setVisible(show); }); statusBar()->setVisible(SettingsCache::instance().userInterface().getShowStatusBar()); + latencyStatus = new LatencyStatusWidget(this); + statusBar()->addPermanentWidget(latencyStatus); + + connect(connectionController, &ConnectionController::pingStatsUpdated, latencyStatus, + &LatencyStatusWidget::updateData); + + cardUpdateStatusBar = new CardDatabaseUpdateStatusBar(this); + statusBar()->addPermanentWidget(cardUpdateStatusBar); + connect(this, &MainWindow::cardDatabaseUpdateProgress, cardUpdateStatusBar, + &CardDatabaseUpdateStatusBar::updateProgress); + connect(this, &MainWindow::cardDatabaseUpdateFinished, cardUpdateStatusBar, + &CardDatabaseUpdateStatusBar::updateFinished); + connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this, &MainWindow::refreshShortcuts); refreshShortcuts(); @@ -540,15 +600,23 @@ MainWindow::MainWindow(QWidget *parent) // run startup check async QTimer::singleShot(0, this, &MainWindow::startupConfigCheck); + QTimer::singleShot(0, this, &MainWindow::applyStartupDestination); } void MainWindow::startupConfigCheck() { + const bool isCleanInstall = SettingsCache::instance().network().getClientVersion() == CLIENT_INFO_NOT_SET; + // checkUnknownSets() is intentionally deferred from the card database load // (which runs in main() before MainWindow exists) so that // cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled have live // receivers when emitted. - CardDatabaseManager::getInstance()->checkUnknownSets(); + // On a clean install the onboarding wizard owns the first-run experience; + // wait until it closes so the legacy "all sets enabled" welcome and the + // Manage Sets dialog don't appear first. + if (!isCleanInstall) { + CardDatabaseManager::getInstance()->checkUnknownSets(); + } if (SettingsCache::instance().debug().getLocalGameOnStartup()) { LocalGameOptions options; @@ -562,13 +630,14 @@ void MainWindow::startupConfigCheck() actCheckCommanderBracketDefinitionUpdates(); - if (SettingsCache::instance().network().getClientVersion() == CLIENT_INFO_NOT_SET) { + if (isCleanInstall) { // no config found, 99% new clean install qCInfo(WindowMainStartupVersionLog) << "Startup: old client version empty, assuming first start after clean install"; - alertForcedOracleRun(VERSION_STRING, false); SettingsCache::instance().downloads().resetToDefaultURLs(); // populate the download urls SettingsCache::instance().network().setClientVersion(VERSION_STRING); + actCheckServerUpdates(); + runFirstRunWizard(true); if (QString(VERSION_STRING).contains("custom", Qt::CaseInsensitive)) { SettingsCache::instance().updates().setCheckUpdatesOnStartup(false); @@ -648,6 +717,118 @@ void MainWindow::startupConfigCheck() } } +void MainWindow::runFirstRunWizard(bool firstRun) +{ + auto *wizard = new FirstRunWizard(this); + wizard->setAttribute(Qt::WA_DeleteOnClose); + + connect(wizard, &FirstRunWizard::cardDatabaseUpdateRequested, this, &MainWindow::actCheckCardUpdatesBackground); + connect(wizard, &FirstRunWizard::manualCardDatabaseSetupRequested, this, &MainWindow::actCheckCardUpdates); + connect(this, &MainWindow::cardDatabaseUpdateFinished, wizard, &FirstRunWizard::onCardDatabaseUpdateFinished); + connect(this, &MainWindow::cardDatabaseUpdateProgress, wizard, &FirstRunWizard::onCardDatabaseUpdateProgress); + connect(wizard, &FirstRunWizard::registerRequested, connectionController, &ConnectionController::registerToServer); + connect(wizard, &FirstRunWizard::connectRequested, connectionController, &ConnectionController::connectToServer); + + if (firstRun) { + // The onboarding wizard owns set handling for a clean install. Suppress + // the legacy set dialogs while it's open and run checkUnknownSets() once + // it closes so the sets end up enabled in the right order. + firstRunWizardActive = true; + connect(wizard, &QDialog::finished, this, [this] { + QTimer::singleShot(0, this, [this] { + CardDatabaseManager::getInstance()->checkUnknownSets(); + firstRunWizardActive = false; + }); + }); + } + + wizard->setModal(true); + wizard->show(); +} + +/** + * Drives the server-based startup destinations (Server lobby, Server Room) through the intent + * system: fetch saved credentials, connect to the configured server, then land on the Lobby or + * join the configured room by name. + */ +void MainWindow::applyStartupDestination() +{ + // An explicit command-line connect takes precedence over the startup destination. + if (!connectTo.isEmpty()) { + return; + } + + // A cockatrice:// link owns the startup connection while its chain runs; + // connecting here would race (and tear down) the link's own connection. + if (skipStartupAutoConnect) { + return; + } + + const int destination = SettingsCache::instance().tabs().getStartupTabIndex(); + if (destination != StartupTab::StartupTabServer && destination != StartupTab::StartupTabServerRoom) { + return; + } + + const QString host = SettingsCache::instance().tabs().getStartupServerHost(); + const QString port = SettingsCache::instance().tabs().getStartupServerPort(); + if (host.isEmpty() || port.isEmpty()) { + qCWarning(WindowMainStartupLog) << "Startup destination needs a configured server"; + return; + } + + auto serverContext = std::make_shared(); + serverContext->hostname = host; + serverContext->port = port; + + auto *credentials = new IntentGetLoginCredentials(serverContext.get()); + auto *connector = new IntentConnectToServer(getRemoteClient(), serverContext.get()); + + connect(credentials, &Intent::finished, connector, &Intent::execute); + connect(credentials, &Intent::failed, this, &MainWindow::startupDestinationFailed); + connect(credentials, &Intent::cancelled, this, [this]() { startupDestinationFailed(tr("Sign-in cancelled")); }); + connect(connector, &Intent::finished, this, + [this, destination, serverContext]() { onStartupDestinationConnected(destination, *serverContext); }); + connect(connector, &Intent::failed, this, &MainWindow::startupDestinationFailed); + + credentials->execute(); +} + +void MainWindow::onStartupDestinationConnected(int destination, const ContextConnectToServer &serverContext) +{ + // The server tab must exist: it is what requests the room list. + if (!tabSupervisor->getTabServer()) { + tabSupervisor->openTabServer(); + } + + if (destination == StartupTab::StartupTabServerRoom) { + auto roomContext = std::make_unique(); + roomContext->serverContext = serverContext; + auto *roomIntent = new IntentOpenServerRoomByName(tabSupervisor, getRemoteClient(), std::move(roomContext), + SettingsCache::instance().tabs().getStartupRoomName()); + roomIntent->setParent(this); + connect(roomIntent, &Intent::failed, this, &MainWindow::startupDestinationFailed); + roomIntent->execute(); + return; + } + + if (tabSupervisor->getTabServer()) { + tabSupervisor->setCurrentWidget(tabSupervisor->getTabServer()); + } else { + qCWarning(WindowMainStartupLog) << "Startup destination: server tab could not be opened"; + } +} + +void MainWindow::startupDestinationFailed(const QString &reason) +{ + qCWarning(WindowMainStartupLog) << "Startup destination failed:" << reason; +} + +bool MainWindow::startupDestinationConnectsToServer() const +{ + const int destination = SettingsCache::instance().tabs().getStartupTabIndex(); + return destination == StartupTab::StartupTabServer || destination == StartupTab::StartupTabServerRoom; +} + void MainWindow::alertForcedOracleRun(const QString &version, bool isUpdate) { if (isUpdate) { @@ -699,7 +880,7 @@ void MainWindow::createTrayIcon() trayIcon = new QSystemTrayIcon(this); trayIcon->setContextMenu(trayIconMenu); - trayIcon->setIcon(QPixmap("theme:cockatrice")); + trayIcon->setIcon(themePixmap(QStringLiteral("cockatrice"))); trayIcon->show(); } @@ -720,12 +901,27 @@ void MainWindow::actShow() void MainWindow::closeEvent(QCloseEvent *event) { // workaround Qt bug where closeEvent gets called twice - static bool bClosingDown = false; if (bClosingDown) { return; } bClosingDown = true; + if (cardUpdateProcess && cardUpdateProcess->state() != QProcess::NotRunning) { + const QString runningFor = cardUpdateStatusBar ? cardUpdateStatusBar->statusText() : QString(); + const QString message = runningFor.isEmpty() + ? tr("A card database update is still running. Quitting now will cancel it.\n" + "Are you sure you want to quit?") + : tr("A card database update is still running: %1.\nQuitting now will cancel it.\n" + "Are you sure you want to quit?") + .arg(runningFor); + if (QMessageBox::question(this, tr("Are you sure?"), message, QMessageBox::Yes | QMessageBox::No, + QMessageBox::No) == QMessageBox::No) { + event->ignore(); + bClosingDown = false; + return; + } + } + if (!tabSupervisor->close()) { event->ignore(); bClosingDown = false; @@ -738,6 +934,18 @@ void MainWindow::closeEvent(QCloseEvent *event) tabSupervisor->deleteLater(); } +bool MainWindow::closeForUpdate() +{ + // A shutdown is already being handled (e.g. this is reached from a nested event loop while + // closeEvent() is blocked on a user prompt). close() would hit the re-entrancy guard and return + // true without any shutdown having happened, so report faithfully instead: the caller must keep + // the process alive until the user resolves whatever is blocking the close. + if (bClosingDown) { + return false; + } + return close(); +} + void MainWindow::changeEvent(QEvent *event) { if (event->type() == QEvent::LanguageChange) { @@ -745,17 +953,7 @@ void MainWindow::changeEvent(QEvent *event) } else if (event->type() == QEvent::ActivationChange) { if (isActiveWindow() && !bHasActivated) { bHasActivated = true; - if (!connectTo.isEmpty()) { - qCInfo(WindowMainStartupAutoconnectLog) << "Command line connect to " << connectTo; - connectionController->connectToServerDirect(connectTo.host(), connectTo.port(), connectTo.userName(), - connectTo.password()); - } else if (SettingsCache::instance().servers().getAutoConnect() && - !SettingsCache::instance().debug().getLocalGameOnStartup()) { - qCInfo(WindowMainStartupAutoconnectLog) << "Attempting auto-connect..."; - DlgConnect dlg(this); - connectionController->connectToServerDirect(dlg.getHost(), static_cast(dlg.getPort()), - dlg.getPlayerName(), dlg.getPassword()); - } + attemptStartupAutoConnect(); } } @@ -776,6 +974,64 @@ void MainWindow::showWindowIfHidden() show(); } +void MainWindow::handleCockatriceLink(const QString &url) +{ + urlParser->handle(url); +} + +void MainWindow::attemptStartupAutoConnect() +{ + if (startupAutoConnectAttempted || skipStartupAutoConnect) { + return; + } + startupAutoConnectAttempted = true; + + if (!connectTo.isEmpty()) { + qCInfo(WindowMainStartupAutoconnectLog) << "Command line connect to " << connectTo; + connectionController->connectToServerDirect(connectTo.host(), connectTo.port(), connectTo.userName(), + connectTo.password()); + } else if (SettingsCache::instance().servers().getAutoConnect() && + !SettingsCache::instance().debug().getLocalGameOnStartup() && !startupDestinationConnectsToServer()) { + qCInfo(WindowMainStartupAutoconnectLog) << "Attempting auto-connect..."; + DlgConnect dlg(this); + connectionController->connectToServerDirect(dlg.getHost(), static_cast(dlg.getPort()), + dlg.getPlayerName(), dlg.getPassword()); + } +} + +void MainWindow::onUrlChainFinished(bool connected) +{ + // A cockatrice:// link owns the startup connection while it runs. When its + // chain ended without connecting (declined, invalid, offline), fall back to + // the startup connection so the activation launch still behaves like a + // normal launch. + if (connected) { + // The launch link connected, so the startup fallback has served its + // purpose: drop the skip so a later mid-session link that ends declined + // or offline cannot silently fire auto-connect or applyStartupDestination + // again. + skipStartupAutoConnect = false; + return; + } + + if (!skipStartupAutoConnect || getRemoteClient()->getStatus() != StatusDisconnected) { + return; + } + + if (startupDestinationConnectsToServer()) { + // Users whose startup tab is a Server / Server Room connect through the + // startup destination, not through auto-connect; retry that instead. + qCInfo(WindowMainStartupAutoconnectLog) << "URL chain ended without a connection; retrying startup destination"; + skipStartupAutoConnect = false; + applyStartupDestination(); + return; + } + + qCInfo(WindowMainStartupAutoconnectLog) << "URL chain ended without a connection; retrying startup connect"; + skipStartupAutoConnect = false; + attemptStartupAutoConnect(); +} + void MainWindow::cardDatabaseLoadingFailed() { if (askedForDbUpdater) { @@ -805,7 +1061,7 @@ void MainWindow::cardDatabaseLoadingFailed() void MainWindow::cardDatabaseNewSetsFound(int numUnknownSets, QStringList unknownSetsNames) { - if (SettingsCache::instance().updates().getAlwaysEnableNewSets()) { + if (firstRunWizardActive || SettingsCache::instance().updates().getAlwaysEnableNewSets()) { CardDatabaseManager::getInstance()->enableAllUnknownSets(); const auto reloadOk1 = QtConcurrent::run([] { CardDatabaseManager::getInstance()->reloadCardDatabasesAndNotify(); }); @@ -848,6 +1104,12 @@ void MainWindow::cardDatabaseNewSetsFound(int numUnknownSets, QStringList unknow void MainWindow::cardDatabaseAllNewSetsEnabled() { + if (firstRunWizardActive || CardDatabaseManager::getInstance()->getCardList().isEmpty()) { + // The onboarding wizard owns the first-run messaging on a clean install, + // and with no card data there are no sets to have enabled. + return; + } + QMessageBox::information( this, tr("Welcome"), tr("Hi! It seems like you're running this version of Cockatrice for the first time.\nAll the sets in the card " @@ -870,16 +1132,28 @@ void MainWindow::actCheckCardUpdatesBackground() void MainWindow::createCardUpdateProcess(bool background) { if (cardUpdateProcess) { - QMessageBox::information(this, tr("Information"), tr("A card database update is already running.")); + qCDebug(CardDatabaseUpdateLog) << "A card database update is already running; asking it to come to the front"; + if (requestCardUpdateRaise()) { + return; + } + if (background) { + // A hidden run has nothing to raise: keep the user informed without blocking. + announceCardUpdateRunning(); + return; + } + QMessageBox::information(this, tr("Information"), + tr("The card database updater is already open.\n" + "Look for the Oracle window - it may be behind this one.")); return; } cardUpdateProcess = new QProcess(this); connect(cardUpdateProcess, &QProcess::errorOccurred, this, &MainWindow::cardUpdateError); - connect(cardUpdateProcess, qOverload(&QProcess::finished), this, &MainWindow::cardUpdateFinished); + connect(cardUpdateProcess, &QProcess::readyReadStandardOutput, this, &MainWindow::cardUpdateProgressOutput); + connect(cardUpdateProcess, &QProcess::readyReadStandardError, this, &MainWindow::cardUpdateStandardError); // full "run the update" command; leave empty if not present QString updaterCmd; @@ -913,7 +1187,7 @@ void MainWindow::createCardUpdateProcess(bool background) if (dir.exists(binaryName)) { updaterCmd = dir.absoluteFilePath(binaryName); - } else { // try and find the directory oracle is stored in the build directory + } else { // try and find the directory Oracle is stored in the build directory QDir findLocalDir(dir); findLocalDir.cdUp(); findLocalDir.cd(getCardUpdaterBinaryName()); @@ -924,22 +1198,92 @@ void MainWindow::createCardUpdateProcess(bool background) } if (updaterCmd.isEmpty()) { + qCWarning(CardDatabaseUpdateLog) << "Unable to locate the card database updater; looked for" + << dir.absoluteFilePath(binaryName); QMessageBox::warning(this, tr("Error"), tr("Unable to run the card database updater: ") + dir.absoluteFilePath(binaryName)); exitCardDatabaseUpdate(); + emit cardDatabaseUpdateFinished(false); return; } + cardUpdateOutputBuffer.clear(); + cardUpdateErrorOutput.clear(); + cardUpdateErrorPartial.clear(); + cardUpdateLoggedStage = CardUpdateStage::Unknown; + cardUpdateProgressLogTimer.start(); + + qCInfo(CardDatabaseUpdateLog) << "Starting the card database updater" << updaterCmd << "background:" << background; + cardUpdateStatusBar->updateStarted(); + if (!background) { cardUpdateProcess->start(updaterCmd, QStringList()); } else { cardUpdateProcess->start(updaterCmd, QStringList("-b")); statusBar()->showMessage(tr("Card database update running.")); + if (trayIcon && SettingsCache::instance().updates().getCardDatabaseUpdateTrayNotification()) { + trayIcon->showMessage(tr("Card database update started"), + tr("Cockatrice is updating the card database in the background.")); + } + } +} + +void MainWindow::cardUpdateProgressOutput() +{ + if (!cardUpdateProcess) { + return; + } + cardUpdateOutputBuffer.append(cardUpdateProcess->readAllStandardOutput()); + while (true) { + const int newline = cardUpdateOutputBuffer.indexOf('\n'); + if (newline < 0) { + break; + } + const QByteArray line = cardUpdateOutputBuffer.left(newline); + cardUpdateOutputBuffer.remove(0, newline + 1); + // Protocol emitted by `oracle`: "PROGRESS " + const std::optional progress = CardUpdateProgress::fromProtocolLine(line); + if (!progress.has_value()) { + continue; + } + // Log stage changes and, while a stage runs, one line per second at most, so the + // debug log shows liveness without flooding it. + if (progress->stage != cardUpdateLoggedStage || cardUpdateProgressLogTimer.elapsed() >= 1000) { + qCDebug(CardDatabaseUpdateLog) + << "Card database update" << progress->stageToken() << progress->done << "/" << progress->total; + cardUpdateLoggedStage = progress->stage; + cardUpdateProgressLogTimer.restart(); + } + emit cardDatabaseUpdateProgress(progress.value()); + } +} + +void MainWindow::cardUpdateStandardError() +{ + if (!cardUpdateProcess) { + return; + } + const QByteArray chunk = cardUpdateProcess->readAllStandardError(); + if (chunk.isEmpty()) { + return; + } + cardUpdateErrorOutput.append(chunk); + cardUpdateErrorPartial.append(chunk); + int newline; + while ((newline = cardUpdateErrorPartial.indexOf('\n')) >= 0) { + const QByteArray line = cardUpdateErrorPartial.left(newline).trimmed(); + cardUpdateErrorPartial.remove(0, newline + 1); + if (!line.isEmpty()) { + qCWarning(CardDatabaseUpdateLog) << "Card database updater:" << line; + } } } void MainWindow::exitCardDatabaseUpdate() { + if (!cardUpdateProcess) { + return; + } cardUpdateProcess->deleteLater(); cardUpdateProcess = nullptr; statusBar()->clearMessage(); @@ -956,7 +1300,6 @@ void MainWindow::cardUpdateError(QProcess::ProcessError err) break; case QProcess::Crashed: error = tr("The process crashed some time after starting successfully."); - error += "\n\nError output:\n" + cardUpdateProcess->readAllStandardError(); break; case QProcess::Timedout: error = tr("Timed out. The process took too long to respond. The last waitFor...() function timed out."); @@ -975,16 +1318,67 @@ void MainWindow::cardUpdateError(QProcess::ProcessError err) break; } + if (!cardUpdateErrorOutput.isEmpty()) { + error += "\n\n" + tr("Error output:") + "\n" + QString::fromUtf8(cardUpdateErrorOutput); + cardUpdateErrorOutput.clear(); + } + + qCWarning(CardDatabaseUpdateLog) << "The card database updater exited with an error:" << error; exitCardDatabaseUpdate(); QMessageBox::warning(this, tr("Error"), tr("The card database updater exited with an error:\n%1").arg(error)); + emit cardDatabaseUpdateFinished(false); } -void MainWindow::cardUpdateFinished(int, QProcess::ExitStatus exitStatus) +void MainWindow::cardUpdateFinished(int exitCode, QProcess::ExitStatus exitStatus) { + if (!cardUpdateProcess) { + return; + } + cardUpdateProgressOutput(); // drain any progress lines not yet parsed + + const bool success = (exitStatus == QProcess::NormalExit) && (exitCode == 0); if (exitStatus == QProcess::NormalExit) { SettingsCache::instance().updates().setLastCardUpdateCheck(QDateTime::currentDateTime().date()); } + qCInfo(CardDatabaseUpdateLog) << "The card database updater finished with exit code" << exitCode << "exit status" + << static_cast(exitStatus) << "success:" << success; exitCardDatabaseUpdate(); + notifyCardUpdateFinishedFromTray(success); + emit cardDatabaseUpdateFinished(success); +} + +bool MainWindow::requestCardUpdateRaise() +{ + const QString serverName = scopedLocalServerName(QStringLiteral("CockatriceOracleRaise")); + QLocalSocket socket; + socket.connectToServer(serverName); + if (!socket.waitForConnected(250)) { + return false; + } + socket.write("RAISE\n"); + const bool acked = socket.waitForBytesWritten(250); + socket.disconnectFromServer(); + return acked; +} + +void MainWindow::announceCardUpdateRunning() +{ + if (SettingsCache::instance().userInterface().getShowStatusBar()) { + statusBar()->showMessage(tr("Card database update running.")); + } else if (trayIcon && SettingsCache::instance().updates().getCardDatabaseUpdateTrayNotification()) { + trayIcon->showMessage(tr("Card database update running"), + tr("Cockatrice is updating the card database in the background.")); + } +} + +void MainWindow::notifyCardUpdateFinishedFromTray(bool success) +{ + if (SettingsCache::instance().userInterface().getShowStatusBar() || !trayIcon || + !SettingsCache::instance().updates().getCardDatabaseUpdateTrayNotification()) { + return; + } + trayIcon->showMessage(tr("Card database update finished"), + success ? tr("The card database is up to date.") : tr("The card database update failed.")); } void MainWindow::actCheckServerUpdates() diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index fa6c79915..bf68ed72a 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -25,40 +25,61 @@ #ifndef WINDOW_H #define WINDOW_H +#include "../client/lag_monitor.h" +#include "../client/network/update/client/release_channel.h" +#include "card_database_update/card_update_progress.h" #include "connection_controller/remote_connection_controller.h" -#include "widgets/dialogs/dlg_local_game_options.h" +#include +#include +#include #include +#include #include -#include -#include -#include +#include +#include +#include +#include +#include +#include #include -#include +#include + +class QAction; +class QWidget; +struct LocalGameOptions; inline Q_LOGGING_CATEGORY(WindowMainLog, "window_main"); inline Q_LOGGING_CATEGORY(WindowMainStartupLog, "window_main.startup"); inline Q_LOGGING_CATEGORY(WindowMainStartupVersionLog, "window_main.startup.version"); inline Q_LOGGING_CATEGORY(WindowMainStartupShortcutsLog, "window_main.startup.shortcuts"); inline Q_LOGGING_CATEGORY(WindowMainStartupAutoconnectLog, "window_main.startup.autoconnect"); +inline Q_LOGGING_CATEGORY(CardDatabaseUpdateLog, "card_database.update"); -class Release; -class DlgConnect; class DlgViewLog; class GameReplay; -class HandlePublicServers; -class LocalClient; class LocalServer; -class QThread; +class LatencyStatusWidget; +class CardDatabaseUpdateStatusBar; class RemoteClient; -class ServerInfo_User; class TabSupervisor; class WndSets; class DlgTipOfTheDay; +struct ContextConnectToServer; +class IntentUrlParser; class MainWindow : public QMainWindow { Q_OBJECT +signals: + /** @brief Emitted after the card-database update subprocess exits. */ + void cardDatabaseUpdateFinished(bool success); + + /** @brief Emitted while the card-database update subprocess runs. + * @p progress.done/@p progress.total are byte counts for the first two stages + * and set indices for "import". */ + void cardDatabaseUpdateProgress(const CardUpdateProgress &progress); + public slots: void actCheckCardUpdates(); void actCheckCardUpdatesBackground(); @@ -66,6 +87,7 @@ public slots: void actCheckClientUpdates(); void actConnect(); void actExit(); + void handleCockatriceLink(const QString &url); private slots: void updateTabMenu(const QList &newMenuList); void statusChanged(ClientStatus _status); @@ -83,9 +105,12 @@ private slots: void actOpenSettingsFolder(); void actShow(); void showWindowIfHidden(); + void onUrlChainFinished(bool connected); void cardUpdateError(QProcess::ProcessError err); void cardUpdateFinished(int exitCode, QProcess::ExitStatus exitStatus); + void cardUpdateProgressOutput(); + void cardUpdateStandardError(); void refreshShortcuts(); void cardDatabaseLoadingFailed(); void cardDatabaseNewSetsFound(int numUnknownSets, QStringList unknownSetsNames); @@ -105,6 +130,13 @@ private slots: void startupConfigCheck(); void alertForcedOracleRun(const QString &version, bool isUpdate); + void applyStartupDestination(); + void onStartupDestinationConnected(int destination, const ContextConnectToServer &serverContext); + void startupDestinationFailed(const QString &reason); + [[nodiscard]] bool startupDestinationConnectsToServer() const; + + void attemptStartupAutoConnect(); + private: static const QString appName; static const QStringList fileNameFilters; @@ -114,12 +146,24 @@ private: void createTrayIcon(); int getNextCustomSetPrefix(QDir dataDir); + + void runFirstRunWizard(bool firstRun = false); + inline QString getCardUpdaterBinaryName() { return "oracle"; } void createCardUpdateProcess(bool background = false); void exitCardDatabaseUpdate(); + /** + * @brief Tries to bring the open card database updater window to the front. + * @return true when the updater acknowledged the request + */ + bool requestCardUpdateRaise(); + /** @brief Tells the user an update is running without blocking, honoring a hidden status bar. */ + void announceCardUpdateRunning(); + /** @brief Posts a tray notification when the status bar cannot show progress. */ + void notifyCardUpdateFinishedFromTray(bool success); void startLocalGame(const LocalGameOptions &options); @@ -129,15 +173,28 @@ private: QAction *aConnect, *aDisconnect, *aRegister, *aForgotPassword, *aSinglePlayer, *aWatchReplay, *aFullScreen; QAction *aManageSets, *aEditTokens, *aOpenCustomFolder, *aOpenCustomsetsFolder, *aAddCustomSet, *aReloadCardDatabase; - QAction *aTips, *aUpdate, *aCheckCardUpdates, *aCheckCardUpdatesBackground, *aStatusBar, *aViewLog, - *aOpenSettingsFolder; + QAction *aTips, *aUpdate, *aCheckCardUpdates, *aCheckCardUpdatesBackground, *aFirstRunWizard, *aStatusBar, + *aViewLog, *aOpenSettingsFolder; TabSupervisor *tabSupervisor; + IntentUrlParser *urlParser; WndSets *wndSets; ConnectionController *connectionController; LocalServer *localServer; + LagMonitor lagMonitor; ///< watches the main thread for event loop stalls + LatencyStatusWidget *latencyStatus = nullptr; ///< status bar widget with live round-trip stats and history graph bool bHasActivated, askedForDbUpdater; + bool bClosingDown = false; ///< guards closeEvent() against re-entrancy + bool skipStartupAutoConnect = false; + bool startupAutoConnectAttempted = false; + bool firstRunWizardActive = false; QProcess *cardUpdateProcess; + QByteArray cardUpdateOutputBuffer; + QByteArray cardUpdateErrorOutput; ///< diagnostic output collected from the updater's stderr + QByteArray cardUpdateErrorPartial; ///< incomplete trailing stderr line, kept for line-based logging + CardUpdateStage cardUpdateLoggedStage = CardUpdateStage::Unknown; ///< stage of the last logged progress update + QElapsedTimer cardUpdateProgressLogTimer; ///< throttles progress logging to the running stage + CardDatabaseUpdateStatusBar *cardUpdateStatusBar = nullptr; ///< status bar widget tracking the card database update DlgViewLog *logviewDialog; GameReplay *replay; DlgTipOfTheDay *tip; @@ -149,6 +206,16 @@ public: { connectTo = QUrl(QString("cockatrice://%1").arg(url)); } + // When set, the window's own startup connection (--connect or auto-connect + // on first activation) is skipped. Used for activation launches: the intent + // chain triggered by a cockatrice:// URL owns the connection, and letting + // auto-connect race against it caused two connectToServer calls to tear + // each other down. onUrlChainFinished() clears this and retries the startup + // connection when the link's chain ended without connecting. + void setSkipStartupAutoConnect(bool skip) + { + skipStartupAutoConnect = skip; + } ~MainWindow() override; RemoteClient *getRemoteClient() const @@ -161,6 +228,13 @@ public: return tabSupervisor; } + /** + * @brief Closes the window so an update installer can replace the running binaries. + * Returns true only if the shutdown actually ran (settings flushed, tabs shut down); + * false if the close was vetoed by the user or is already in progress. + */ + bool closeForUpdate(); + protected: void closeEvent(QCloseEvent *event) override; void changeEvent(QEvent *event) override; diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp index 814da9808..b9cc8dabc 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -25,34 +25,55 @@ #include "client/sound_engine.h" #include "client/url_scheme_event_filter.h" #include "database/interface/settings_card_preference_provider.h" +#include "interface/intents/intent.h" #include "interface/intents/intent_open_local_deck.h" -#include "interface/intents/url_parser.h" #include "interface/logger.h" #include "interface/pixel_map_generator.h" #include "interface/theme_manager.h" -#include "interface/widgets/dialogs/dlg_settings.h" -#include "interface/widgets/tabs/tab_supervisor.h" #include "interface/window_main.h" +#include "libcockatrice/card/database/card_database.h" +#include "libcockatrice/rng/rng_abstract.h" #include "single_instance_manager.h" #include "version_string.h" #include +#include +#include +#include +#include +#include #include -#include #include -#include +#include +#include +#include +#include #include #include -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include +#include +#include +#include #include #include -#include #include -#include #include #include #include +#include +#include +#include +#include +#include QTranslator *translator, *qtTranslator; RNG_Abstract *rng; @@ -89,6 +110,7 @@ static void CockatriceLogger(QtMsgType type, const QMessageLogContext &ctx, cons #include #include #include + #pragma comment(lib, "DbgHelp.lib") // Link the DbgHelp library // clang-format on @@ -139,26 +161,13 @@ void installNewTranslator() { QString lang = SettingsCache::instance().personal().getLang(); - QString qtNameHint = "qt_" + lang; - QString qtTranslationPath = QLibraryInfo::path(QLibraryInfo::TranslationsPath); - - bool qtTranslationLoaded = qtTranslator->load(qtNameHint, qtTranslationPath); - if (!qtTranslationLoaded) { - qCWarning(QtTranslatorDebug) << "Unable to load qt translation" << qtNameHint << "at" << qtTranslationPath; - } else { - qCInfo(QtTranslatorDebug) << "Loaded qt translation" << qtNameHint << "at" << qtTranslationPath; - } + // Qt's own strings and ours need a translator each: QTranslator discards its previous contents + // on every load, so loading both files into one translator silently drops the first of them. + TranslationLoader::loadQt(*qtTranslator, lang); qApp->installTranslator(qtTranslator); - QString appNameHint = translationPrefix + "_" + lang; - bool appTranslationLoaded = qtTranslator->load(appNameHint, translationPath); - if (!appTranslationLoaded) { - qCWarning(QtTranslatorDebug) << "Unable to load" << translationPrefix << "translation" << appNameHint << "at" - << translationPath; - } else { - qCInfo(QtTranslatorDebug) << "Loaded" << translationPrefix << "translation" << appNameHint << "at" - << translationPath; - } + TranslationLoader::loadApplication(*translator, translationPrefix, lang, + TranslationLoader::applicationTranslationPaths(translationPath)); qApp->installTranslator(translator); } @@ -176,6 +185,18 @@ QString const generateClientID() return strClientID; } +static QString redactActivationUrl(const QString &url) +{ + // Activation URLs carry secrets in their query string (e.g. the deck share + // token); log only the scheme and the action (cockatrice://opendeck), never + // the parameters. + if (!url.startsWith(QStringLiteral("cockatrice://"))) { + return url; + } + const QUrl parsed(url); + return parsed.scheme() + "://" + parsed.host(); +} + int main(int argc, char *argv[]) { #ifdef Q_OS_WIN @@ -230,7 +251,7 @@ int main(int argc, char *argv[]) // These values are only used by the settings loader/saver // Wrong or outdated values are kept to not break things QCoreApplication::setOrganizationName("Cockatrice"); - QCoreApplication::setOrganizationDomain("cockatrice.de"); + QCoreApplication::setOrganizationDomain("cockatrice.github.io"); QCoreApplication::setApplicationName("Cockatrice"); QCoreApplication::setApplicationVersion(VERSION_STRING); @@ -249,7 +270,7 @@ int main(int argc, char *argv[]) // Command-line parser QCommandLineParser parser; - parser.setApplicationDescription("Cockatrice"); + parser.setApplicationDescription("Cockatrice Client"); parser.addHelpOption(); parser.addVersionOption(); @@ -272,10 +293,17 @@ int main(int argc, char *argv[]) SingleInstanceManager instance; if (hasActivationFiles) { + QStringList redactedFiles; + redactedFiles.reserve(startupFiles.size()); + for (const QString &file : startupFiles) { + redactedFiles.append(redactActivationUrl(file)); + } + qCInfo(MainLog) << "Activation launch, files:" << redactedFiles; // Activation launch: hand off to the primary instance if one is // running, otherwise become the primary ourselves. Do this before // constructing the main window so a hand-off exits cheaply. if (!instance.tryRun(startupFiles)) { + qInfo() << "Handed off to a running instance, exiting"; // Sent successfully → exit return 0; } @@ -292,7 +320,7 @@ int main(int argc, char *argv[]) } } - rng = new RNG_SFMT; + rng = new RNG_SFMT(CryptoUtil::randomUInt64()); themeManager = new ThemeManager; soundEngine = new SoundEngine; @@ -324,10 +352,30 @@ int main(int argc, char *argv[]) MainWindow ui; + // A URL launch must own the connection: the intent chain triggered by the + // URL connects to the server named in the URL, so the window's own startup + // auto-connect must not race against it (two connectToServer calls tear + // each other down via doDisconnectFromServer). + bool hasUrlActivation = std::any_of(startupFiles.begin(), startupFiles.end(), [](const QString &file) { + return file.startsWith(QStringLiteral("cockatrice://")); + }); +#ifdef Q_OS_MAC + // On macOS the launch can arrive through the URL scheme instead of as a + // positional argument (captured in pendingMacUrls); count those too or the + // window would auto-connect into the link's own connection attempt. + hasUrlActivation = hasUrlActivation || + std::any_of(pendingMacUrls.cbegin(), pendingMacUrls.cend(), + [](const QString &url) { return url.startsWith(QStringLiteral("cockatrice://")); }); +#endif + ui.setSkipStartupAutoConnect(hasUrlActivation); + auto handleActivation = [&ui](const QString &file) { if (file.startsWith("cockatrice://")) { - auto urlParser = new IntentUrlParser(&ui, &ui); - urlParser->handle(file); + qCInfo(MainLog) << "Handling URL activation:" << redactActivationUrl(file); + // Route through the window's persistent url parser: it serializes + // link chains so activations handed over while another chain is + // still connecting do not connect concurrently. + ui.handleCockatriceLink(file); } else if (QFileInfo(file).exists()) { auto openDeckIntent = new IntentOpenLocalDeck(ui.getTabSupervisor(), file); QObject::connect(openDeckIntent, &Intent::failed, &ui, [&ui](const QString &reason) { @@ -348,8 +396,11 @@ int main(int argc, char *argv[]) } qCInfo(MainLog) << "MainWindow constructor finished"; - ui.setWindowIcon(QPixmap("theme:cockatrice")); - // set name of the app desktop file; used by wayland to load the window icon + ui.setWindowIcon(themePixmap(QStringLiteral("cockatrice"))); + // Base name of the desktop file installed next to the binary, without the ".desktop" + // extension. Wayland reads the window icon from it, and xdg-desktop-portal looks up + // org.freedesktop.host.portal.Registry by this app ID, so it has to match the file name + // exactly, down to the case. QGuiApplication::setDesktopFileName("cockatrice"); SettingsCache::instance().network().setClientID(generateClientID()); @@ -390,6 +441,7 @@ int main(int argc, char *argv[]) PingPixmapGenerator::clear(); CountryPixmapGenerator::clear(); UserLevelPixmapGenerator::clear(); + ManaSymbolPixmapGenerator::clear(); return ret; } diff --git a/cockatrice/src/main.h b/cockatrice/src/main.h index ef75a41f4..3d772915b 100644 --- a/cockatrice/src/main.h +++ b/cockatrice/src/main.h @@ -10,13 +10,11 @@ #include inline Q_LOGGING_CATEGORY(MainLog, "main"); -inline Q_LOGGING_CATEGORY(QtTranslatorDebug, "qt_translator"); class CardDatabase; class QString; class QSystemTrayIcon; class QTranslator; -class SoundEngine; extern CardDatabase *db; diff --git a/cockatrice/src/single_instance_manager.cpp b/cockatrice/src/single_instance_manager.cpp index aca23160c..08294d72c 100644 --- a/cockatrice/src/single_instance_manager.cpp +++ b/cockatrice/src/single_instance_manager.cpp @@ -1,6 +1,23 @@ #include "single_instance_manager.h" -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +// Sent by the primary instance after it has read a forwarded payload. Without +// an acknowledgment, a second instance cannot tell a live primary apart from a +// stale socket left behind by a process that is still shutting down. +const QByteArray ACK_MESSAGE = QByteArrayLiteral("COCKATRICE_ACK"); +} // namespace SingleInstanceManager::SingleInstanceManager(QObject *parent) : QObject(parent) { @@ -8,21 +25,19 @@ SingleInstanceManager::SingleInstanceManager(QObject *parent) : QObject(parent) bool SingleInstanceManager::tryRun(const QStringList &filesToSend) { - // Scope the socket name to the current user. On Linux the default abstract - // namespace is system-wide, so a plain name would let one user's instance + // Scope the socket name to the current user so one user's instance cannot // hijack another user's session. - QString userName = qEnvironmentVariable("USER"); - if (userName.isEmpty()) { - userName = qEnvironmentVariable("USERNAME"); - } - if (userName.isEmpty()) { - userName = QDir::home().dirName(); - } - serverName = QStringLiteral("CockatriceSingleInstance-%1").arg(userName); + serverName = scopedLocalServerName(QStringLiteral("CockatriceSingleInstance")); - // Hand off to an already-running primary instance if one exists. - if (forwardToPrimary(filesToSend)) { - return false; + // Hand off to an already-running primary instance if one exists. Never steal + // the socket of a busy primary: it is alive and will act on the payload. + switch (forwardToPrimary(filesToSend)) { + case ForwardResult::Delivered: + return false; + case ForwardResult::PrimaryBusy: + return false; + case ForwardResult::NoPrimary: + break; } // No primary instance is currently reachable, so become the primary. @@ -35,12 +50,18 @@ bool SingleInstanceManager::tryRun(const QStringList &filesToSend) // Another instance may have started while we were probing; hand off to it // instead of stealing its socket. - if (forwardToPrimary(filesToSend)) { - return false; + switch (forwardToPrimary(filesToSend)) { + case ForwardResult::Delivered: + return false; + case ForwardResult::PrimaryBusy: + return false; + case ForwardResult::NoPrimary: + break; } - // The socket is stale (left over by a crashed instance): remove it and - // retry. If that still fails, another instance just took the name. + // The socket is stale (left over by a crashed instance), so no primary is + // holding it: remove it and retry. If that still fails, another instance + // just took the name. QLocalServer::removeServer(serverName); if (server->listen(serverName)) { return true; @@ -50,12 +71,12 @@ bool SingleInstanceManager::tryRun(const QStringList &filesToSend) return false; } -bool SingleInstanceManager::forwardToPrimary(const QStringList &filesToSend) +SingleInstanceManager::ForwardResult SingleInstanceManager::forwardToPrimary(const QStringList &filesToSend) { QLocalSocket socket; socket.connectToServer(serverName); if (!socket.waitForConnected(200)) { - return false; + return ForwardResult::NoPrimary; } // Serialize payload with length prefix @@ -72,7 +93,23 @@ bool SingleInstanceManager::forwardToPrimary(const QStringList &filesToSend) socket.flush(); socket.waitForBytesWritten(1000); - return true; + // A plain launch has nothing for the primary to act on, so there is nothing + // to acknowledge. Waiting here would block the new instance for seconds if + // the primary is busy in a modal dialog, so only the activation path (which + // needs the ACK to avoid stealing a live primary's socket) waits below. + if (filesToSend.isEmpty()) { + return ForwardResult::Delivered; + } + + // Only report a successful hand-off once the primary has acknowledged that + // it actually read the payload. A socket that connects but is still working + // on an earlier payload is alive but busy, not dead: give it more room + // before giving up, so a slow handler does not make a live primary look + // dead (which would lead to stealing its socket). + if (!socket.waitForReadyRead(1000) && !socket.waitForReadyRead(4000)) { + return ForwardResult::PrimaryBusy; + } + return socket.readAll() == ACK_MESSAGE ? ForwardResult::Delivered : ForwardResult::PrimaryBusy; } void SingleInstanceManager::handleNewConnection() @@ -111,16 +148,52 @@ void SingleInstanceManager::handleNewConnection() QStringList files; payloadStream >> files; - emit filesReceived(files); + // Acknowledge receipt as soon as the payload is parsed, before the + // primary starts handling it. The handlers run synchronously and can + // take longer than the sender's readiness timeout (e.g. a modal + // confirmation box), which would otherwise make a live primary look + // dead and cause duplicate handling. + socket->write(ACK_MESSAGE); + socket->flush(); - // Reset buffer (single message use-case) + // Drop the payload from the buffer before handing it off, so a + // re-entrant event loop cannot re-parse and re-emit the same files. buffer->clear(); *expectedSize = 0; - socket->disconnectFromServer(); + // Take the guard while the socket is still alive: QPointer has to be + // built from a live pointer, and by the time the handlers below return + // the socket may already be gone. + QPointer guardedSocket(socket); + + // Hand the payload over synchronously, exactly as before, so the + // "Join game?" confirmation still appears. A handler can spin a nested + // event loop (that modal box is one), and the sending instance exits as + // soon as it has the ACK above, so while that loop runs the peer closes + // and this socket is disconnected. + emit filesReceived(files); + + // The handlers run synchronously, so returning from the emit means the + // readyRead frame has unwound and it is safe to touch the socket again. + // This teardown must therefore NOT be queued: a queued call would be + // picked up by the nested loop above while the frame is still on the + // stack, which is precisely the use-after-free this guards against. + // The guard covers the one remaining way the socket can die under us -- + // the primary being torn down (e.g. quit) while the dialog was open -- + // since QLocalServer owns and destroys its pending connections. + if (guardedSocket) { + guardedSocket->disconnectFromServer(); + guardedSocket->deleteLater(); + } return; } }); - connect(socket, &QLocalSocket::disconnected, socket, &QLocalSocket::deleteLater); + // Deliberately no `disconnected -> deleteLater()` here. The handlers above run + // synchronously and may spin a nested event loop, during which the sender exits + // and this socket is disconnected. A deleteLater() triggered from there is not + // deferred to the outer loop: Qt frees the object as soon as that nested loop + // unwinds, which is still inside this readyRead emission, so Qt's own signal + // dispatch is left holding a freed socket. The socket is instead closed and + // deleted once the handlers have returned, on the code path above. } diff --git a/cockatrice/src/single_instance_manager.h b/cockatrice/src/single_instance_manager.h index 55bff0e80..727994208 100644 --- a/cockatrice/src/single_instance_manager.h +++ b/cockatrice/src/single_instance_manager.h @@ -1,10 +1,12 @@ #ifndef COCKATRICE_SINGLE_INSTANCE_MANAGER_H #define COCKATRICE_SINGLE_INSTANCE_MANAGER_H -#include -#include -#include -#include +#include +#include +#include +#include + +class QLocalServer; class SingleInstanceManager : public QObject { @@ -23,7 +25,14 @@ private slots: void handleNewConnection(); private: - bool forwardToPrimary(const QStringList &filesToSend); + enum class ForwardResult + { + Delivered, // a live primary acknowledged the payload + NoPrimary, // no connectable primary socket exists + PrimaryBusy // a primary exists but did not acknowledge in time + }; + + ForwardResult forwardToPrimary(const QStringList &filesToSend); QString serverName; QLocalServer *server = nullptr; diff --git a/cockatrice/themes/CMakeLists.txt b/cockatrice/themes/CMakeLists.txt index 577977551..70344d95a 100644 --- a/cockatrice/themes/CMakeLists.txt +++ b/cockatrice/themes/CMakeLists.txt @@ -2,7 +2,7 @@ # # add themes subfolders -set(defthemes Default Fabric Fusion Leather Plasma VelvetMarble) +set(defthemes Fabric Fusion Leather Plasma VelvetMarble System) if(UNIX) if(APPLE) diff --git a/cockatrice/themes/Default/palette-default-dark.toml b/cockatrice/themes/Default/palette-default-dark.toml deleted file mode 100644 index 3ee174a2f..000000000 --- a/cockatrice/themes/Default/palette-default-dark.toml +++ /dev/null @@ -1,63 +0,0 @@ -[Palette] -WindowText = #ffffffff -Button = #ff383838 -Light = #ff737373 -Midlight = #ff525252 -Dark = #ff161616 -Mid = #ff252525 -Text = #ffffffff -BrightText = #ffb4dd8b -ButtonText = #ffffffff -Base = #ff2b2b2b -Window = #ff1c1c1c -Shadow = #ff000000 -HighlightedText = #ff000000 -Link = #ffcde4b6 -LinkVisited = #ff99d999 -AlternateBase = #ff242424 -ToolTipBase = #ffffffdc -ToolTipText = #ff000000 -PlaceholderText = #6effffff - -[Palette.Disabled] -WindowText = #ff9d9d9d -Button = #ff1c1c1c -Light = #ff737373 -Midlight = #ff525252 -Dark = #ff161616 -Mid = #ff252525 -Text = #ff9d9d9d -BrightText = #ffb4dd8b -ButtonText = #ff787878 -Base = #ff1c1c1c -Window = #ff1c1c1c -Shadow = #ff000000 -HighlightedText = #ff9d9d9d -Link = #ff308cc6 -LinkVisited = #ffb450ff -AlternateBase = #ff242424 -ToolTipBase = #ffffffdc -ToolTipText = #ff000000 -PlaceholderText = #46ffffff - -[Palette.Inactive] -WindowText = #ffffffff -Button = #ff383838 -Light = #ff737373 -Midlight = #ff525252 -Dark = #ff161616 -Mid = #ff252525 -Text = #ffffffff -BrightText = #ffb4dd8b -ButtonText = #ffffffff -Base = #ff2b2b2b -Window = #ff1c1c1c -Shadow = #ff000000 -HighlightedText = #ffffffff -Link = #ffcde4b6 -LinkVisited = #ff99d999 -AlternateBase = #ff242424 -ToolTipBase = #ffffffdc -ToolTipText = #ff000000 -PlaceholderText = #6effffff - diff --git a/cockatrice/themes/Default/theme.cfg b/cockatrice/themes/Default/theme.cfg deleted file mode 100644 index d2016a238..000000000 --- a/cockatrice/themes/Default/theme.cfg +++ /dev/null @@ -1,5 +0,0 @@ -[Appearance] -ColorScheme = Light - -[Style] -Name = Default diff --git a/cockatrice/themes/Fabric/backgrounds/home-dark.jpg b/cockatrice/themes/Fabric/backgrounds/home-dark.jpg new file mode 100644 index 000000000..f69e5923e Binary files /dev/null and b/cockatrice/themes/Fabric/backgrounds/home-dark.jpg differ diff --git a/cockatrice/themes/Fabric/backgrounds/home-light.jpg b/cockatrice/themes/Fabric/backgrounds/home-light.jpg new file mode 100644 index 000000000..800e369e7 Binary files /dev/null and b/cockatrice/themes/Fabric/backgrounds/home-light.jpg differ diff --git a/cockatrice/themes/Fabric/palette-default-dark.toml b/cockatrice/themes/Fabric/palette-default-dark.toml new file mode 100644 index 000000000..cad18921a --- /dev/null +++ b/cockatrice/themes/Fabric/palette-default-dark.toml @@ -0,0 +1,74 @@ +# Fabric — dark identity palette (navy-cloth chrome) +[Palette] +WindowText = #e8ecf2 +Button = #2b374a +Light = #3a4759 +Midlight = #333f50 +Dark = #141a23 +Mid = #252c38 +Text = #e8ecf2 +BrightText = #8ec2ff +ButtonText = #f2f4f8 +Base = #222c3d +Window = #18202e +Shadow = #0b0f15 +Highlight = #4a82e6 +HighlightedText = #ffffff +Link = #7fb0f2 +LinkVisited = #a98ad9 +AlternateBase = #1d2636 +ToolTipBase = #2b3546 +ToolTipText = #f2f4f8 +PlaceholderText = #6ee8ecf2 +Accent = #4a82e6 + +[Palette.Disabled] +WindowText = #9d9d9d +Button = #18202e +Light = #3a4759 +Midlight = #333f50 +Dark = #141a23 +Mid = #252c38 +Text = #9d9d9d +BrightText = #8ec2ff +ButtonText = #787878 +Base = #18202e +Window = #18202e +Shadow = #0b0f15 +Highlight = #222d40 +HighlightedText = #9d9d9d +Link = #308cc6 +LinkVisited = #b450ff +AlternateBase = #1d2636 +ToolTipBase = #2b3546 +ToolTipText = #f2f4f8 +PlaceholderText = #6ee8ecf2 +Accent = #9d9d9d + +[Palette.Inactive] +WindowText = #e8ecf2 +Button = #2b374a +Light = #3a4759 +Midlight = #333f50 +Dark = #141a23 +Mid = #252c38 +Text = #e8ecf2 +BrightText = #8ec2ff +ButtonText = #f2f4f8 +Base = #222c3d +Window = #18202e +Shadow = #0b0f15 +Highlight = #222d40 +HighlightedText = #ffffff +Link = #7fb0f2 +LinkVisited = #a98ad9 +AlternateBase = #1d2636 +ToolTipBase = #2b3546 +ToolTipText = #f2f4f8 +PlaceholderText = #6ee8ecf2 +Accent = #4a82e6 + +[AppColors] +AccentStrong = #3a6fd6 +AccentSoft = #8fb8f2 + diff --git a/cockatrice/themes/Fabric/palette-default-light.toml b/cockatrice/themes/Fabric/palette-default-light.toml new file mode 100644 index 000000000..f5151af3e --- /dev/null +++ b/cockatrice/themes/Fabric/palette-default-light.toml @@ -0,0 +1,74 @@ +# Fabric — light identity palette (navy-cloth chrome) +[Palette] +WindowText = #1c2330 +Button = #c8d6ea +Light = #ffffff +Midlight = #b9c6dc +Dark = #8fa0bd +Mid = #a3b3cd +Text = #1c2330 +BrightText = #1d4fa0 +ButtonText = #141c29 +Base = #f6f8fc +Window = #dbe3ef +Shadow = #5a6a85 +Highlight = #2f6fd6 +HighlightedText = #ffffff +Link = #2555b0 +LinkVisited = #6a4bb8 +AlternateBase = #e9eef7 +ToolTipBase = #e9eff8 +ToolTipText = #141c29 +PlaceholderText = #7a1c2330 +Accent = #2f6fd6 + +[Palette.Disabled] +WindowText = #787878 +Button = #dbe3ef +Light = #ffffff +Midlight = #b9c6dc +Dark = #8fa0bd +Mid = #a3b3cd +Text = #787878 +BrightText = #1d4fa0 +ButtonText = #969696 +Base = #dbe3ef +Window = #dbe3ef +Shadow = #5a6a85 +Highlight = #cdd8e9 +HighlightedText = #787878 +Link = #0000ff +LinkVisited = #ff00ff +AlternateBase = #e9eef7 +ToolTipBase = #e9eff8 +ToolTipText = #141c29 +PlaceholderText = #7a1c2330 +Accent = #787878 + +[Palette.Inactive] +WindowText = #1c2330 +Button = #c8d6ea +Light = #ffffff +Midlight = #b9c6dc +Dark = #8fa0bd +Mid = #a3b3cd +Text = #1c2330 +BrightText = #1d4fa0 +ButtonText = #141c29 +Base = #f6f8fc +Window = #dbe3ef +Shadow = #5a6a85 +Highlight = #cdd8e9 +HighlightedText = #000000 +Link = #2555b0 +LinkVisited = #6a4bb8 +AlternateBase = #e9eef7 +ToolTipBase = #e9eff8 +ToolTipText = #141c29 +PlaceholderText = #7a1c2330 +Accent = #2f6fd6 + +[AppColors] +AccentStrong = #3a6fd6 +AccentSoft = #8fb8f2 + diff --git a/cockatrice/themes/Fabric/theme.cfg b/cockatrice/themes/Fabric/theme.cfg new file mode 100644 index 000000000..55b916e71 --- /dev/null +++ b/cockatrice/themes/Fabric/theme.cfg @@ -0,0 +1,5 @@ +[Appearance] +ColorScheme = System + +[Style] +Name = Fusion \ No newline at end of file diff --git a/cockatrice/themes/Fabric/zones/handzone-light.png b/cockatrice/themes/Fabric/zones/handzone-light.png new file mode 100644 index 000000000..a87f9e292 Binary files /dev/null and b/cockatrice/themes/Fabric/zones/handzone-light.png differ diff --git a/cockatrice/themes/Fabric/zones/handzone.png b/cockatrice/themes/Fabric/zones/handzone.png index 25d22b070..a5b72286c 100644 Binary files a/cockatrice/themes/Fabric/zones/handzone.png and b/cockatrice/themes/Fabric/zones/handzone.png differ diff --git a/cockatrice/themes/Fabric/zones/playerzone-light.png b/cockatrice/themes/Fabric/zones/playerzone-light.png new file mode 100644 index 000000000..05da3ea9b Binary files /dev/null and b/cockatrice/themes/Fabric/zones/playerzone-light.png differ diff --git a/cockatrice/themes/Fabric/zones/playerzone.png b/cockatrice/themes/Fabric/zones/playerzone.png index 728fccdfa..7fa977249 100644 Binary files a/cockatrice/themes/Fabric/zones/playerzone.png and b/cockatrice/themes/Fabric/zones/playerzone.png differ diff --git a/cockatrice/themes/Fabric/zones/stackzone-light.png b/cockatrice/themes/Fabric/zones/stackzone-light.png new file mode 100644 index 000000000..dcd6dc045 Binary files /dev/null and b/cockatrice/themes/Fabric/zones/stackzone-light.png differ diff --git a/cockatrice/themes/Fabric/zones/stackzone.png b/cockatrice/themes/Fabric/zones/stackzone.png index 89e2080f8..9046aa049 100644 Binary files a/cockatrice/themes/Fabric/zones/stackzone.png and b/cockatrice/themes/Fabric/zones/stackzone.png differ diff --git a/cockatrice/themes/Fabric/zones/tablezone-light.png b/cockatrice/themes/Fabric/zones/tablezone-light.png new file mode 100644 index 000000000..cb297e7e1 Binary files /dev/null and b/cockatrice/themes/Fabric/zones/tablezone-light.png differ diff --git a/cockatrice/themes/Fabric/zones/tablezone.png b/cockatrice/themes/Fabric/zones/tablezone.png index 6a2ce68e1..8048a4cc1 100644 Binary files a/cockatrice/themes/Fabric/zones/tablezone.png and b/cockatrice/themes/Fabric/zones/tablezone.png differ diff --git a/cockatrice/themes/Fusion/palette-default-dark.toml b/cockatrice/themes/Fusion/palette-default-dark.toml index c1d83a4cd..b180e9b63 100644 --- a/cockatrice/themes/Fusion/palette-default-dark.toml +++ b/cockatrice/themes/Fusion/palette-default-dark.toml @@ -11,15 +11,15 @@ ButtonText = #ffffffff Base = #ff2d2d2d Window = #ff1e1e1e Shadow = #ff000000 -Highlight = #ff148c3c +Highlight = #ff139740 HighlightedText = #ffffffff -Link = #ff00f652 -LinkVisited = #ff00d346 +Link = #ffc9fd62 +LinkVisited = #ff9ad43e AlternateBase = #ff353535 ToolTipBase = #ff3c3c3c ToolTipText = #ffd4d4d4 PlaceholderText = #80ffffff -Accent = #ff00d346 +Accent = #ffc9fd62 [Palette.Disabled] WindowText = #ff9d9d9d @@ -34,7 +34,7 @@ ButtonText = #ff9d9d9d Base = #ff1e1e1e Window = #ff1e1e1e Shadow = #ff000000 -Highlight = #ff148c3c +Highlight = #ff139740 HighlightedText = #ffffffff Link = #ff308cc6 LinkVisited = #ffff00ff @@ -59,11 +59,15 @@ Window = #ff1e1e1e Shadow = #ff000000 Highlight = #ff1e1e1e HighlightedText = #ffffffff -Link = #ff00f652 -LinkVisited = #ff00d346 +Link = #ffc9fd62 +LinkVisited = #ff9ad43e AlternateBase = #ff353535 ToolTipBase = #ff3c3c3c ToolTipText = #ffd4d4d4 PlaceholderText = #80ffffff Accent = #ff1e1e1e + +[AppColors] +AccentStrong = #ff139740 +AccentSoft = #ffc9fd62 diff --git a/cockatrice/themes/Fusion/palette-default-light.toml b/cockatrice/themes/Fusion/palette-default-light.toml index 86c41be78..5a1163a2b 100644 --- a/cockatrice/themes/Fusion/palette-default-light.toml +++ b/cockatrice/themes/Fusion/palette-default-light.toml @@ -11,15 +11,15 @@ ButtonText = #ff000000 Base = #ffffffff Window = #fff0f0f0 Shadow = #ff696969 -Highlight = #ff148c3c +Highlight = #ff139740 HighlightedText = #ffffffff -Link = #ff0d5f28 -LinkVisited = #ff08401b +Link = #ff0e6b31 +LinkVisited = #ff0a521f AlternateBase = #ffe9e7e3 ToolTipBase = #ffffffdc ToolTipText = #ff000000 PlaceholderText = #80000000 -Accent = #ff107532 +Accent = #ff139740 [Palette.Disabled] WindowText = #ff787878 @@ -34,7 +34,7 @@ ButtonText = #ff787878 Base = #fff0f0f0 Window = #fff0f0f0 Shadow = #ff000000 -Highlight = #ff148c3c +Highlight = #ff139740 HighlightedText = #ffffffff Link = #ff0000ff LinkVisited = #ffff00ff @@ -59,11 +59,15 @@ Window = #fff0f0f0 Shadow = #ff696969 Highlight = #fff0f0f0 HighlightedText = #ff000000 -Link = #ff0d5f28 -LinkVisited = #ff08401b +Link = #ff0e6b31 +LinkVisited = #ff0a521f AlternateBase = #ffe9e7e3 ToolTipBase = #ffffffdc ToolTipText = #ff000000 PlaceholderText = #80000000 Accent = #fff0f0f0 + +[AppColors] +AccentStrong = #ff139740 +AccentSoft = #ffc9fd62 diff --git a/cockatrice/themes/Leather/backgrounds/home-dark.jpg b/cockatrice/themes/Leather/backgrounds/home-dark.jpg new file mode 100644 index 000000000..8c2c3d1df Binary files /dev/null and b/cockatrice/themes/Leather/backgrounds/home-dark.jpg differ diff --git a/cockatrice/themes/Leather/backgrounds/home-light.jpg b/cockatrice/themes/Leather/backgrounds/home-light.jpg new file mode 100644 index 000000000..3fb49538a Binary files /dev/null and b/cockatrice/themes/Leather/backgrounds/home-light.jpg differ diff --git a/cockatrice/themes/Leather/palette-default-dark.toml b/cockatrice/themes/Leather/palette-default-dark.toml new file mode 100644 index 000000000..b0db0e90a --- /dev/null +++ b/cockatrice/themes/Leather/palette-default-dark.toml @@ -0,0 +1,74 @@ +# Leather — dark identity palette (black-brown chrome with brass accents) +[Palette] +WindowText = #eee9e2 +Button = #332d26 +Light = #4a4239 +Midlight = #3d3730 +Dark = #131009 +Mid = #26221c +Text = #eee9e2 +BrightText = #e3c27e +ButtonText = #f5f1ea +Base = #26221d +Window = #1d1a16 +Shadow = #0d0b07 +Highlight = #4a5f8f +HighlightedText = #ffffff +Link = #cfa263 +LinkVisited = #9a7db8 +AlternateBase = #211d19 +ToolTipBase = #37302a +ToolTipText = #f5f1ea +PlaceholderText = #6eeee9e2 +Accent = #c9995a + +[Palette.Disabled] +WindowText = #9d9d9d +Button = #1d1a16 +Light = #4a4239 +Midlight = #3d3730 +Dark = #131009 +Mid = #26221c +Text = #9d9d9d +BrightText = #e3c27e +ButtonText = #787878 +Base = #1d1a16 +Window = #1d1a16 +Shadow = #0d0b07 +Highlight = #2d2822 +HighlightedText = #9d9d9d +Link = #308cc6 +LinkVisited = #b450ff +AlternateBase = #211d19 +ToolTipBase = #37302a +ToolTipText = #f5f1ea +PlaceholderText = #6eeee9e2 +Accent = #9d9d9d + +[Palette.Inactive] +WindowText = #eee9e2 +Button = #332d26 +Light = #4a4239 +Midlight = #3d3730 +Dark = #131009 +Mid = #26221c +Text = #eee9e2 +BrightText = #e3c27e +ButtonText = #f5f1ea +Base = #26221d +Window = #1d1a16 +Shadow = #0d0b07 +Highlight = #2d2822 +HighlightedText = #ffffff +Link = #cfa263 +LinkVisited = #9a7db8 +AlternateBase = #211d19 +ToolTipBase = #37302a +ToolTipText = #f5f1ea +PlaceholderText = #6eeee9e2 +Accent = #c9995a + +[AppColors] +AccentStrong = #b7823b +AccentSoft = #e4c58f + diff --git a/cockatrice/themes/Leather/palette-default-light.toml b/cockatrice/themes/Leather/palette-default-light.toml new file mode 100644 index 000000000..ff122db76 --- /dev/null +++ b/cockatrice/themes/Leather/palette-default-light.toml @@ -0,0 +1,74 @@ +# Leather — light identity palette (black-brown chrome with brass accents) +[Palette] +WindowText = #2a2114 +Button = #dfcbb0 +Light = #ffffff +Midlight = #c6b08c +Dark = #a18a64 +Mid = #b29a74 +Text = #2a2114 +BrightText = #7a531c +ButtonText = #1f1710 +Base = #fdf8ee +Window = #f0e2cb +Shadow = #5d4d33 +Highlight = #34508c +HighlightedText = #ffffff +Link = #8a5e1e +LinkVisited = #6b5190 +AlternateBase = #f5ebd7 +ToolTipBase = #fff7e8 +ToolTipText = #1f1710 +PlaceholderText = #7a2a2114 +Accent = #a5712f + +[Palette.Disabled] +WindowText = #787878 +Button = #f0e2cb +Light = #ffffff +Midlight = #c6b08c +Dark = #a18a64 +Mid = #b29a74 +Text = #787878 +BrightText = #7a531c +ButtonText = #969696 +Base = #f0e2cb +Window = #f0e2cb +Shadow = #5d4d33 +Highlight = #ecd9bb +HighlightedText = #787878 +Link = #0000ff +LinkVisited = #ff00ff +AlternateBase = #f5ebd7 +ToolTipBase = #fff7e8 +ToolTipText = #1f1710 +PlaceholderText = #7a2a2114 +Accent = #787878 + +[Palette.Inactive] +WindowText = #2a2114 +Button = #dfcbb0 +Light = #ffffff +Midlight = #c6b08c +Dark = #a18a64 +Mid = #b29a74 +Text = #2a2114 +BrightText = #7a531c +ButtonText = #1f1710 +Base = #fdf8ee +Window = #f0e2cb +Shadow = #5d4d33 +Highlight = #ecd9bb +HighlightedText = #000000 +Link = #8a5e1e +LinkVisited = #6b5190 +AlternateBase = #f5ebd7 +ToolTipBase = #fff7e8 +ToolTipText = #1f1710 +PlaceholderText = #7a2a2114 +Accent = #a5712f + +[AppColors] +AccentStrong = #b7823b +AccentSoft = #e4c58f + diff --git a/cockatrice/themes/Leather/theme.cfg b/cockatrice/themes/Leather/theme.cfg new file mode 100644 index 000000000..55b916e71 --- /dev/null +++ b/cockatrice/themes/Leather/theme.cfg @@ -0,0 +1,5 @@ +[Appearance] +ColorScheme = System + +[Style] +Name = Fusion \ No newline at end of file diff --git a/cockatrice/themes/Leather/zones/handzone-light.png b/cockatrice/themes/Leather/zones/handzone-light.png new file mode 100644 index 000000000..b2c9bba34 Binary files /dev/null and b/cockatrice/themes/Leather/zones/handzone-light.png differ diff --git a/cockatrice/themes/Leather/zones/handzone.png b/cockatrice/themes/Leather/zones/handzone.png index aba879683..5f803df74 100644 Binary files a/cockatrice/themes/Leather/zones/handzone.png and b/cockatrice/themes/Leather/zones/handzone.png differ diff --git a/cockatrice/themes/Leather/zones/playerzone-light.png b/cockatrice/themes/Leather/zones/playerzone-light.png new file mode 100644 index 000000000..a26f96a5e Binary files /dev/null and b/cockatrice/themes/Leather/zones/playerzone-light.png differ diff --git a/cockatrice/themes/Leather/zones/playerzone.png b/cockatrice/themes/Leather/zones/playerzone.png index 4f42ee41d..cccbb3fd3 100644 Binary files a/cockatrice/themes/Leather/zones/playerzone.png and b/cockatrice/themes/Leather/zones/playerzone.png differ diff --git a/cockatrice/themes/Leather/zones/stackzone-light.png b/cockatrice/themes/Leather/zones/stackzone-light.png new file mode 100644 index 000000000..117c738f7 Binary files /dev/null and b/cockatrice/themes/Leather/zones/stackzone-light.png differ diff --git a/cockatrice/themes/Leather/zones/stackzone.png b/cockatrice/themes/Leather/zones/stackzone.png index c02e2284a..d9cff892b 100644 Binary files a/cockatrice/themes/Leather/zones/stackzone.png and b/cockatrice/themes/Leather/zones/stackzone.png differ diff --git a/cockatrice/themes/Leather/zones/tablezone-light.png b/cockatrice/themes/Leather/zones/tablezone-light.png new file mode 100644 index 000000000..912cbfb94 Binary files /dev/null and b/cockatrice/themes/Leather/zones/tablezone-light.png differ diff --git a/cockatrice/themes/Leather/zones/tablezone.png b/cockatrice/themes/Leather/zones/tablezone.png index 152f4f7e9..8af4c853d 100644 Binary files a/cockatrice/themes/Leather/zones/tablezone.png and b/cockatrice/themes/Leather/zones/tablezone.png differ diff --git a/cockatrice/themes/Plasma/backgrounds/home-dark.jpg b/cockatrice/themes/Plasma/backgrounds/home-dark.jpg new file mode 100644 index 000000000..230d78e8e Binary files /dev/null and b/cockatrice/themes/Plasma/backgrounds/home-dark.jpg differ diff --git a/cockatrice/themes/Plasma/backgrounds/home-light.jpg b/cockatrice/themes/Plasma/backgrounds/home-light.jpg new file mode 100644 index 000000000..2552325eb Binary files /dev/null and b/cockatrice/themes/Plasma/backgrounds/home-light.jpg differ diff --git a/cockatrice/themes/Plasma/palette-default-dark.toml b/cockatrice/themes/Plasma/palette-default-dark.toml new file mode 100644 index 000000000..222456f4f --- /dev/null +++ b/cockatrice/themes/Plasma/palette-default-dark.toml @@ -0,0 +1,74 @@ +# Plasma — dark identity palette (electric violet chrome with cyan sparks) +[Palette] +WindowText = #eeebff +Button = #2a2440 +Light = #453d63 +Midlight = #383252 +Dark = #0e0c16 +Mid = #211e31 +Text = #eeebff +BrightText = #7fd7ff +ButtonText = #f6f4ff +Base = #1e1a2e +Window = #151220 +Shadow = #08070e +Highlight = #6a4df0 +HighlightedText = #ffffff +Link = #37c7e8 +LinkVisited = #9b7aff +AlternateBase = #1a1726 +ToolTipBase = #2c2745 +ToolTipText = #f6f4ff +PlaceholderText = #6eeeebff +Accent = #6a4df0 + +[Palette.Disabled] +WindowText = #9d9d9d +Button = #151220 +Light = #453d63 +Midlight = #383252 +Dark = #0e0c16 +Mid = #211e31 +Text = #9d9d9d +BrightText = #7fd7ff +ButtonText = #787878 +Base = #151220 +Window = #151220 +Shadow = #08070e +Highlight = #211c32 +HighlightedText = #9d9d9d +Link = #308cc6 +LinkVisited = #b450ff +AlternateBase = #1a1726 +ToolTipBase = #2c2745 +ToolTipText = #f6f4ff +PlaceholderText = #6eeeebff +Accent = #9d9d9d + +[Palette.Inactive] +WindowText = #eeebff +Button = #2a2440 +Light = #453d63 +Midlight = #383252 +Dark = #0e0c16 +Mid = #211e31 +Text = #eeebff +BrightText = #7fd7ff +ButtonText = #f6f4ff +Base = #1e1a2e +Window = #151220 +Shadow = #08070e +Highlight = #211c32 +HighlightedText = #ffffff +Link = #37c7e8 +LinkVisited = #9b7aff +AlternateBase = #1a1726 +ToolTipBase = #2c2745 +ToolTipText = #f6f4ff +PlaceholderText = #6eeeebff +Accent = #6a4df0 + +[AppColors] +AccentStrong = #5b3ee0 +AccentSoft = #a28bf7 + diff --git a/cockatrice/themes/Plasma/palette-default-light.toml b/cockatrice/themes/Plasma/palette-default-light.toml new file mode 100644 index 000000000..d54db31d2 --- /dev/null +++ b/cockatrice/themes/Plasma/palette-default-light.toml @@ -0,0 +1,74 @@ +# Plasma — light identity palette (electric violet chrome with cyan sparks) +[Palette] +WindowText = #1d1830 +Button = #cfc6f0 +Light = #ffffff +Midlight = #b7adde +Dark = #9589c8 +Mid = #a49ad2 +Text = #1d1830 +BrightText = #10406e +ButtonText = #120d26 +Base = #f9f7ff +Window = #e3dcf7 +Shadow = #554c85 +Highlight = #5a3ee2 +HighlightedText = #ffffff +Link = #0f8fb5 +LinkVisited = #6a45d6 +AlternateBase = #ece8fa +ToolTipBase = #f5f3ff +ToolTipText = #120d26 +PlaceholderText = #7a1d1830 +Accent = #5a3ee2 + +[Palette.Disabled] +WindowText = #787878 +Button = #e3dcf7 +Light = #ffffff +Midlight = #b7adde +Dark = #9589c8 +Mid = #a49ad2 +Text = #787878 +BrightText = #10406e +ButtonText = #969696 +Base = #e3dcf7 +Window = #e3dcf7 +Shadow = #554c85 +Highlight = #d6ccf3 +HighlightedText = #787878 +Link = #0000ff +LinkVisited = #ff00ff +AlternateBase = #ece8fa +ToolTipBase = #f5f3ff +ToolTipText = #120d26 +PlaceholderText = #7a1d1830 +Accent = #787878 + +[Palette.Inactive] +WindowText = #1d1830 +Button = #cfc6f0 +Light = #ffffff +Midlight = #b7adde +Dark = #9589c8 +Mid = #a49ad2 +Text = #1d1830 +BrightText = #10406e +ButtonText = #120d26 +Base = #f9f7ff +Window = #e3dcf7 +Shadow = #554c85 +Highlight = #d6ccf3 +HighlightedText = #000000 +Link = #0f8fb5 +LinkVisited = #6a45d6 +AlternateBase = #ece8fa +ToolTipBase = #f5f3ff +ToolTipText = #120d26 +PlaceholderText = #7a1d1830 +Accent = #5a3ee2 + +[AppColors] +AccentStrong = #5b3ee0 +AccentSoft = #a28bf7 + diff --git a/cockatrice/themes/Plasma/theme.cfg b/cockatrice/themes/Plasma/theme.cfg new file mode 100644 index 000000000..55b916e71 --- /dev/null +++ b/cockatrice/themes/Plasma/theme.cfg @@ -0,0 +1,5 @@ +[Appearance] +ColorScheme = System + +[Style] +Name = Fusion \ No newline at end of file diff --git a/cockatrice/themes/Plasma/zones/handzone-light.png b/cockatrice/themes/Plasma/zones/handzone-light.png new file mode 100644 index 000000000..89fa7e6d4 Binary files /dev/null and b/cockatrice/themes/Plasma/zones/handzone-light.png differ diff --git a/cockatrice/themes/Plasma/zones/handzone.png b/cockatrice/themes/Plasma/zones/handzone.png index 543571d8a..82debb405 100644 Binary files a/cockatrice/themes/Plasma/zones/handzone.png and b/cockatrice/themes/Plasma/zones/handzone.png differ diff --git a/cockatrice/themes/Plasma/zones/playerzone-light.png b/cockatrice/themes/Plasma/zones/playerzone-light.png new file mode 100644 index 000000000..9034818f2 Binary files /dev/null and b/cockatrice/themes/Plasma/zones/playerzone-light.png differ diff --git a/cockatrice/themes/Plasma/zones/playerzone.png b/cockatrice/themes/Plasma/zones/playerzone.png index eebcd1f84..4623524f6 100644 Binary files a/cockatrice/themes/Plasma/zones/playerzone.png and b/cockatrice/themes/Plasma/zones/playerzone.png differ diff --git a/cockatrice/themes/Plasma/zones/stackzone-light.png b/cockatrice/themes/Plasma/zones/stackzone-light.png new file mode 100644 index 000000000..2607b3389 Binary files /dev/null and b/cockatrice/themes/Plasma/zones/stackzone-light.png differ diff --git a/cockatrice/themes/Plasma/zones/stackzone.png b/cockatrice/themes/Plasma/zones/stackzone.png index 1845c7091..144d991f3 100644 Binary files a/cockatrice/themes/Plasma/zones/stackzone.png and b/cockatrice/themes/Plasma/zones/stackzone.png differ diff --git a/cockatrice/themes/Plasma/zones/tablezone-light.png b/cockatrice/themes/Plasma/zones/tablezone-light.png new file mode 100644 index 000000000..dcf2ef95e Binary files /dev/null and b/cockatrice/themes/Plasma/zones/tablezone-light.png differ diff --git a/cockatrice/themes/Plasma/zones/tablezone.png b/cockatrice/themes/Plasma/zones/tablezone.png index 8f998d4bb..4dc36ec2d 100644 Binary files a/cockatrice/themes/Plasma/zones/tablezone.png and b/cockatrice/themes/Plasma/zones/tablezone.png differ diff --git a/cockatrice/themes/System/palette-default-dark.toml b/cockatrice/themes/System/palette-default-dark.toml new file mode 100644 index 000000000..9dfe6e19f --- /dev/null +++ b/cockatrice/themes/System/palette-default-dark.toml @@ -0,0 +1,7 @@ +# The System theme derives all of its colors from the platform palette +# (e.g. qt6ct's custom color scheme on Linux), so it must NOT ship palette +# overrides. Only the identity app colors are provided here, so buttons keep +# their classic greens while the actual palette stays native to the OS. +[AppColors] +AccentStrong = #ff148c3c +AccentSoft = #ff78c850 diff --git a/cockatrice/themes/System/palette-default-light.toml b/cockatrice/themes/System/palette-default-light.toml new file mode 100644 index 000000000..9dfe6e19f --- /dev/null +++ b/cockatrice/themes/System/palette-default-light.toml @@ -0,0 +1,7 @@ +# The System theme derives all of its colors from the platform palette +# (e.g. qt6ct's custom color scheme on Linux), so it must NOT ship palette +# overrides. Only the identity app colors are provided here, so buttons keep +# their classic greens while the actual palette stays native to the OS. +[AppColors] +AccentStrong = #ff148c3c +AccentSoft = #ff78c850 diff --git a/cockatrice/themes/System/theme.cfg b/cockatrice/themes/System/theme.cfg new file mode 100644 index 000000000..23fffccbb --- /dev/null +++ b/cockatrice/themes/System/theme.cfg @@ -0,0 +1,5 @@ +[Appearance] +ColorScheme = System + +[Style] +Name = System diff --git a/cockatrice/themes/VelvetMarble/backgrounds/home-dark.jpg b/cockatrice/themes/VelvetMarble/backgrounds/home-dark.jpg new file mode 100644 index 000000000..b55f2524e Binary files /dev/null and b/cockatrice/themes/VelvetMarble/backgrounds/home-dark.jpg differ diff --git a/cockatrice/themes/VelvetMarble/backgrounds/home-light.jpg b/cockatrice/themes/VelvetMarble/backgrounds/home-light.jpg new file mode 100644 index 000000000..12a2fd2cc Binary files /dev/null and b/cockatrice/themes/VelvetMarble/backgrounds/home-light.jpg differ diff --git a/cockatrice/themes/VelvetMarble/palette-default-dark.toml b/cockatrice/themes/VelvetMarble/palette-default-dark.toml new file mode 100644 index 000000000..496943931 --- /dev/null +++ b/cockatrice/themes/VelvetMarble/palette-default-dark.toml @@ -0,0 +1,74 @@ +# VelvetMarble — dark identity palette (charcoal-velvet chrome with slate marble) +[Palette] +WindowText = #e8eaee +Button = #262a31 +Light = #3a3f47 +Midlight = #31363d +Dark = #0e1013 +Mid = #1f2329 +Text = #e8eaee +BrightText = #c3d8f2 +ButtonText = #f2f3f6 +Base = #1b1e23 +Window = #131519 +Shadow = #08090b +Highlight = #8fa6c0 +HighlightedText = #101318 +Link = #a9c6e8 +LinkVisited = #b9a8d9 +AlternateBase = #171a1f +ToolTipBase = #2d323a +ToolTipText = #f2f3f6 +PlaceholderText = #6ee8eaee +Accent = #8fa6c0 + +[Palette.Disabled] +WindowText = #9d9d9d +Button = #131519 +Light = #3a3f47 +Midlight = #31363d +Dark = #0e1013 +Mid = #1f2329 +Text = #9d9d9d +BrightText = #c3d8f2 +ButtonText = #787878 +Base = #131519 +Window = #131519 +Shadow = #08090b +Highlight = #1f2229 +HighlightedText = #9d9d9d +Link = #308cc6 +LinkVisited = #b450ff +AlternateBase = #171a1f +ToolTipBase = #2d323a +ToolTipText = #f2f3f6 +PlaceholderText = #6ee8eaee +Accent = #9d9d9d + +[Palette.Inactive] +WindowText = #e8eaee +Button = #262a31 +Light = #3a3f47 +Midlight = #31363d +Dark = #0e1013 +Mid = #1f2329 +Text = #e8eaee +BrightText = #c3d8f2 +ButtonText = #f2f3f6 +Base = #1b1e23 +Window = #131519 +Shadow = #08090b +Highlight = #1f2229 +HighlightedText = #ffffff +Link = #a9c6e8 +LinkVisited = #b9a8d9 +AlternateBase = #171a1f +ToolTipBase = #2d323a +ToolTipText = #f2f3f6 +PlaceholderText = #6ee8eaee +Accent = #8fa6c0 + +[AppColors] +AccentStrong = #54687e +AccentSoft = #9db0c4 + diff --git a/cockatrice/themes/VelvetMarble/palette-default-light.toml b/cockatrice/themes/VelvetMarble/palette-default-light.toml new file mode 100644 index 000000000..5323fd320 --- /dev/null +++ b/cockatrice/themes/VelvetMarble/palette-default-light.toml @@ -0,0 +1,74 @@ +# VelvetMarble — light identity palette (charcoal-velvet chrome with slate marble) +[Palette] +WindowText = #1b1d21 +Button = #c7d1dd +Light = #ffffff +Midlight = #b3becb +Dark = #93a0b1 +Mid = #a1adbc +Text = #1b1d21 +BrightText = #26465f +ButtonText = #121418 +Base = #f7f9fb +Window = #d9e0ea +Shadow = #57626f +Highlight = #54687e +HighlightedText = #ffffff +Link = #2e4d6b +LinkVisited = #5d4a78 +AlternateBase = #e6ebf2 +ToolTipBase = #f2f4f6 +ToolTipText = #121418 +PlaceholderText = #7a1b1d21 +Accent = #54687e + +[Palette.Disabled] +WindowText = #787878 +Button = #d9e0ea +Light = #ffffff +Midlight = #b3becb +Dark = #93a0b1 +Mid = #a1adbc +Text = #787878 +BrightText = #26465f +ButtonText = #969696 +Base = #d9e0ea +Window = #d9e0ea +Shadow = #57626f +Highlight = #ccd5e3 +HighlightedText = #787878 +Link = #0000ff +LinkVisited = #ff00ff +AlternateBase = #e6ebf2 +ToolTipBase = #f2f4f6 +ToolTipText = #121418 +PlaceholderText = #7a1b1d21 +Accent = #787878 + +[Palette.Inactive] +WindowText = #1b1d21 +Button = #c7d1dd +Light = #ffffff +Midlight = #b3becb +Dark = #93a0b1 +Mid = #a1adbc +Text = #1b1d21 +BrightText = #26465f +ButtonText = #121418 +Base = #f7f9fb +Window = #d9e0ea +Shadow = #57626f +Highlight = #ccd5e3 +HighlightedText = #000000 +Link = #2e4d6b +LinkVisited = #5d4a78 +AlternateBase = #e6ebf2 +ToolTipBase = #f2f4f6 +ToolTipText = #121418 +PlaceholderText = #7a1b1d21 +Accent = #54687e + +[AppColors] +AccentStrong = #54687e +AccentSoft = #9db0c4 + diff --git a/cockatrice/themes/VelvetMarble/theme.cfg b/cockatrice/themes/VelvetMarble/theme.cfg new file mode 100644 index 000000000..55b916e71 --- /dev/null +++ b/cockatrice/themes/VelvetMarble/theme.cfg @@ -0,0 +1,5 @@ +[Appearance] +ColorScheme = System + +[Style] +Name = Fusion \ No newline at end of file diff --git a/cockatrice/themes/VelvetMarble/zones/handzone-light.png b/cockatrice/themes/VelvetMarble/zones/handzone-light.png new file mode 100644 index 000000000..b21e78e61 Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/handzone-light.png differ diff --git a/cockatrice/themes/VelvetMarble/zones/handzone.jpg b/cockatrice/themes/VelvetMarble/zones/handzone.jpg deleted file mode 100644 index 2ec9b37fe..000000000 Binary files a/cockatrice/themes/VelvetMarble/zones/handzone.jpg and /dev/null differ diff --git a/cockatrice/themes/VelvetMarble/zones/handzone.png b/cockatrice/themes/VelvetMarble/zones/handzone.png new file mode 100644 index 000000000..3e2b7ebea Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/handzone.png differ diff --git a/cockatrice/themes/VelvetMarble/zones/playerzone-light.png b/cockatrice/themes/VelvetMarble/zones/playerzone-light.png new file mode 100644 index 000000000..630aac6eb Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/playerzone-light.png differ diff --git a/cockatrice/themes/VelvetMarble/zones/playerzone.jpg b/cockatrice/themes/VelvetMarble/zones/playerzone.jpg deleted file mode 100644 index dd13cab78..000000000 Binary files a/cockatrice/themes/VelvetMarble/zones/playerzone.jpg and /dev/null differ diff --git a/cockatrice/themes/VelvetMarble/zones/playerzone.png b/cockatrice/themes/VelvetMarble/zones/playerzone.png new file mode 100644 index 000000000..dcf21ba38 Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/playerzone.png differ diff --git a/cockatrice/themes/VelvetMarble/zones/stackzone-light.png b/cockatrice/themes/VelvetMarble/zones/stackzone-light.png new file mode 100644 index 000000000..62027827c Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/stackzone-light.png differ diff --git a/cockatrice/themes/VelvetMarble/zones/stackzone.jpg b/cockatrice/themes/VelvetMarble/zones/stackzone.jpg deleted file mode 100644 index b63aa0902..000000000 Binary files a/cockatrice/themes/VelvetMarble/zones/stackzone.jpg and /dev/null differ diff --git a/cockatrice/themes/VelvetMarble/zones/stackzone.png b/cockatrice/themes/VelvetMarble/zones/stackzone.png new file mode 100644 index 000000000..a47f7bf9d Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/stackzone.png differ diff --git a/cockatrice/themes/VelvetMarble/zones/tablezone-light.png b/cockatrice/themes/VelvetMarble/zones/tablezone-light.png new file mode 100644 index 000000000..5a9014f9d Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/tablezone-light.png differ diff --git a/cockatrice/themes/VelvetMarble/zones/tablezone.jpg b/cockatrice/themes/VelvetMarble/zones/tablezone.jpg deleted file mode 100644 index 9f511491a..000000000 Binary files a/cockatrice/themes/VelvetMarble/zones/tablezone.jpg and /dev/null differ diff --git a/cockatrice/themes/VelvetMarble/zones/tablezone.png b/cockatrice/themes/VelvetMarble/zones/tablezone.png new file mode 100644 index 000000000..1523d0821 Binary files /dev/null and b/cockatrice/themes/VelvetMarble/zones/tablezone.png differ diff --git a/doc/carddatabase_v4/cards.xsd b/doc/carddatabase_v4/cards.xsd index 59ca3e560..df52d6327 100644 --- a/doc/carddatabase_v4/cards.xsd +++ b/doc/carddatabase_v4/cards.xsd @@ -29,12 +29,17 @@ - - + + + + + + + @@ -49,6 +54,13 @@ + + + + + + + diff --git a/doc/doxygen/README.md b/doc/doxygen/README.md index 988147159..230422303 100644 --- a/doc/doxygen/README.md +++ b/doc/doxygen/README.md @@ -1,3 +1,17 @@ +## Building Doxygen Documentation + +We use [Doxygen](https://www.doxygen.nl) for our code documentation. +After installing the tool, you can build a HTML webpage into the `/docs` folder according to our [configuration](https://github.com/Cockatrice/Cockatrice/blob/master/Doxyfile): +```bash +doxygen Doxyfile +``` +Open the `index.html` file in the `/docs/html/` folder to browse the result locally. +There is also an online version we host at the Cockatrice webpage: [cockatrice.github.io/docs](https://cockatrice.github.io/docs) + +Doxygen's own documenation can be found at [www.doxygen.nl/manual](https://www.doxygen.nl/manual). + +
+ ## Doxygen Documentation Theme Required changes to the `Doxyfile` config from the [theme docs](https://jothepro.github.io/doxygen-awesome-css/index.html#autotoc_md16) to make [doxygen-awesome-css](https://github.com/jothepro/doxygen-awesome-css) work: diff --git a/doc/doxygen/extra-pages/developer_documentation/loading_card_pictures.md b/doc/doxygen/extra-pages/developer_documentation/loading_card_pictures.md deleted file mode 100644 index b606f9e4b..000000000 --- a/doc/doxygen/extra-pages/developer_documentation/loading_card_pictures.md +++ /dev/null @@ -1,52 +0,0 @@ -@page loading_card_pictures Loading Card Pictures - -Pictures associated with CardInfo%s are retrieved either from on-disk or the network through the CardPictureLoader. - -In most cases, you don't need to concern yourself with the internals of CardPictureLoader. - -Simply using one of the ways described in @ref displaying_cards is enough to automatically queue a request to the -CardPictureLoader when the chosen widget is shown, emitting signals to refresh the widget when the request is finished. - -# How requests are triggered - -CardPictureLoader::getPixmap() is called exactly two times in the code base, in CardInfoPictureWidget::loadPixmap(), the -base class -for all widget based card picture display, and AbstractCardItem::paintPicture(), the base class for all QGraphicsItem -based card picture -display. See @ref displaying_cards for more information on the difference between these two display methods. - -Because both of these calls are made in the paintEvent() methods of their respective classes, this means that requests -are issued as soon as but not before the widget is shown on screen. - -It is also possible to "warm up" the cache by issuing card picture load requests to the CardPictureLoader without using -a display widget and waiting for it to be shown by calling CardPictureLoader::cacheCardPixmaps() with a list of -ExactCard%s. - -# The QPixmapCache and QNetworkDiskCache - -Cockatrice uses the QPixmapCache from the Qt GUI module to store card pictures in-memory and the QNetworkDiskCache from -the Qt Network module to cache network requests for card pictures on-disk. - -What this means is that the CardPictureLoader will first attempt to look up a card in the QPixmapCache according to the -ExactCard::getPixmapCacheKey() method of an ExactCard object. If it does not find it in the in-memory cache, it will -issue a load request, which will first look for local images on-disk and then consult the QNetworkDiskCache and if -found, use the stored binary data from the network cache to populate the in-memory pixmap cache under the card's cache -key. If it is not found, it will then proceed with issuing a network request. - -The size of both of these caches can be configured by the user in the "Card Sources" settings page. - -# PixmapCacheKeys and ProviderIDs - -TODO - -# The Redirect Cache - -TODO - -# Local Image Loading - -TODO - -# URL Generation and Resolution - -TODO \ No newline at end of file diff --git a/doc/doxygen/extra-pages/developer_documentation/network/game_event_handler.md b/doc/doxygen/extra-pages/developer_documentation/network/game_event_handler.md deleted file mode 100644 index fbb298d3a..000000000 --- a/doc/doxygen/extra-pages/developer_documentation/network/game_event_handler.md +++ /dev/null @@ -1,170 +0,0 @@ -@page game_event_handler GameEventHandler - -## Overview - -`GameEventHandler` is the central coordinator for **game-wide commands and events**. -It acts as the bridge between the networking layer (protobuf messages received from -the server) and the local game model and UI. - -Unlike `PlayerEventHandler`, which is responsible for player-scoped state and zones, -`GameEventHandler` handles: - -- Global game flow (turns, phases, host changes) -- Player and spectator lifecycle events -- Chat and logging events -- Dispatching incoming events to the appropriate subsystem -- Sending locally initiated commands to the server - -In short, it is the **top-level event dispatcher** for an active game. - ---- - -## Responsibilities - -`GameEventHandler` has four primary responsibilities: - -### 1. Sending game-wide commands - -UI actions that affect the game as a whole (e.g. advancing the turn, changing phases, -sending chat messages) are translated into protobuf commands and sent to the server -via this class. - -Examples include: - -- Advancing or reversing the turn order -- Conceding or unconceding -- Changing the active phase -- Leaving the game -- Sending chat messages - -These commands are wrapped in `PendingCommand` instances so their lifecycle and -responses can be tracked. - ---- - -### 2. Processing incoming game events - -Incoming server messages arrive as a `GameEventContainer`. -`GameEventHandler` is responsible for: - -- Emitting lifecycle signals before and after processing -- Dispatching each event to the correct handler -- Forwarding player-scoped events to `PlayerEventHandler` instances -- Handling spectator-only and game-global events directly - -This design keeps networking concerns isolated from game logic and UI code. - ---- - -### 3. Managing global game state transitions - -Certain events affect the entire game state and require coordinated updates across -multiple subsystems. Examples include: - -- Full game state synchronization -- Active player or phase changes -- Game host changes -- Game closure -- Turn reversal - -`GameEventHandler` ensures these events are processed in a consistent order and -that all interested systems are notified via Qt signals. - ---- - -### 4. Emitting UI and logging signals - -Rather than directly manipulating UI widgets, `GameEventHandler` emits -high-level signals that are consumed by: - -- Game widgets -- Player and spectator lists -- Message and event logs -- Status indicators (ready state, deck selection, connection state) - -This keeps the handler independent of concrete UI implementations. - ---- - -## Relationship to PlayerEventHandler - -`GameEventHandler` and `PlayerEventHandler` work together but have distinct roles: - -| GameEventHandler | PlayerEventHandler | -|------------------|--------------------| -| Global game state | Per-player state | -| Turn / phase flow | Zones and cards | -| Player join/leave | Player actions | -| Spectator events | Player-specific events | -| Chat dispatch | Card and zone updates | - -When a server event is associated with a specific player, `GameEventHandler` -routes it to the corresponding `PlayerEventHandler`. Events without a player -context are handled directly. - ---- - -## Event Processing Flow - -A typical incoming event flow looks like this: - -1. `AbstractClient` receives a `GameEventContainer` -2. `GameEventHandler::processGameEventContainer()` is called -3. `containerProcessingStarted()` is emitted -4. Each event is: - - Handled directly **or** - - Forwarded to a `PlayerEventHandler` -5. Logging and UI signals are emitted as needed -6. `containerProcessingDone()` is emitted - -This structured flow makes it easy to: - -- Suppress UI updates during replays -- Handle reconnections cleanly -- Detect flood protection or error states - ---- - -## Command Lifecycle - -Outgoing commands follow a similar structured path: - -1. UI action triggers a `handle*()` method -2. A protobuf command is constructed -3. The command is wrapped in a `PendingCommand` -4. `sendGameCommand()` sends it to the server -5. `commandFinished()` receives the response -6. Errors or flood conditions are handled centrally - -This approach avoids duplicated error handling across UI code. - ---- - -## Design Goals - -`GameEventHandler` is designed to be: - -- **Centralized** – one entry point for all game-wide events -- **UI-agnostic** – communicates via signals, not widgets -- **Predictable** – well-defined event ordering and lifecycle -- **Composable** – works in concert with `PlayerEventHandler` -- **Testable** – logic is separated from rendering - ---- - -## Related Classes - -- @ref GameEventHandler -- @ref PlayerEventHandler -- `GameEventContainer` -- `PendingCommand` -- `AbstractClient` -- `AbstractGame` - ---- - -## See Also - -- @ref GameLogic -- @ref PlayerEventHandler -- @ref GameState diff --git a/doc/doxygen/extra-pages/developer_documentation/network/player_event_handler.md b/doc/doxygen/extra-pages/developer_documentation/network/player_event_handler.md deleted file mode 100644 index 6878758da..000000000 --- a/doc/doxygen/extra-pages/developer_documentation/network/player_event_handler.md +++ /dev/null @@ -1,199 +0,0 @@ -@page player_event_handler PlayerEventHandler - -## Overview - -`PlayerEventHandler` is responsible for applying **player-scoped game events** -to a single `Player` instance. These events modify the player’s board state, -zones, cards, counters, arrows, and associated UI and logging output. - -Each `PlayerEventHandler` instance is bound **1:1 to a Player** and is invoked -exclusively by @ref GameEventHandler after basic routing and validation of -incoming server events. - -This class represents the lowest-level authoritative application of game state -changes on the client. - ---- - -## Scope and Authority - -`PlayerEventHandler` operates under the following guarantees: - -- All incoming events are **authoritative** and already validated by the server -- Events refer to valid players, zones, and card identifiers -- Ordering is guaranteed by the server and preserved by `GameEventHandler` - -As a result, the handler does **not** perform rule validation or permission -checks. Its responsibility is to *apply* state, not *decide* legality. - ---- - -## Responsibilities - -### 1. Applying player-specific state changes - -The primary responsibility of `PlayerEventHandler` is to mutate player-owned -state, including: - -- Cards (`CardItem`) -- Zones (`CardZoneLogic`) -- Counters (card-level and player-level) -- Attachments and arrows -- Zone configuration flags (reveal / peek behavior) - -Many handlers update both **logical state** and **visual/UI state** in tandem. - ---- - -### 2. Coordinating complex card movement - -Some events—most notably card movement—require coordinated updates across -multiple systems. For example, `eventMoveCard()`: - -- Removes the card from the source zone -- Updates ownership, visibility, and identity -- Handles attachments and arrow cleanup -- Emits undo-draw or move logs -- Inserts the card into the destination zone -- Updates menus, hover state, and graphics - -This makes `PlayerEventHandler` intentionally stateful and tightly coupled to -the board implementation. - ---- - -### 3. Emitting logging signals - -Rather than writing directly to logs or widgets, `PlayerEventHandler` emits -structured Qt signals describing *what happened*, including: - -- Chat messages -- Card movement -- Shuffles and randomization -- Reveals and peeks -- Counter and attribute changes - -These signals are consumed by logging systems and UI components, keeping -presentation concerns out of the handler. - -Logging signals may be emitted **before or after mutation**, depending on -whether later changes would invalidate log data (e.g. card identity). - ---- - -### 4. Handling cross-player interactions - -Although bound to a single player, some events necessarily affect other -players’ state, including: - -- Moving cards between players -- Attaching cards to another player’s permanents -- Creating arrows targeting other players or cards - -In these cases, `PlayerEventHandler` performs the minimal required mutation -while respecting ownership boundaries enforced elsewhere. - ---- - -## Event Dispatch Model - -`PlayerEventHandler` exposes a single public entry point: - -- `processGameEvent()` - -This method: - -1. Receives a generic `GameEvent` -2. Switches on `GameEventType` -3. Extracts the appropriate protobuf extension -4. Forwards the event to a typed handler - -This keeps the event routing centralized and makes it easy to audit coverage -when new game events are introduced. - -Unhandled events are logged as warnings. - ---- - -## Event Categories - -For clarity, event handlers are grouped conceptually into: - -- **Chat and randomization** - - Chat messages - - Shuffles - - Dice rolls - -- **Arrows and targeting** - - Create / delete arrows - -- **Card and token creation** - - Token generation - - Counter creation - -- **Card attributes and counters** - - Tapped state - - Power/toughness - - Annotations - - Card- and player-level counters - -- **Zone-level operations** - - Card movement - - Zone dumps - - Card destruction - - Attachments - -- **Draw and reveal** - - Drawing cards - - Reveals, peeks, and reveal windows - -- **Zone configuration** - - Always-reveal and always-look-at-top-card flags - -This grouping mirrors the structure of the header file and reflects the -conceptual responsibilities of the class. - ---- - -## Relationship to GameEventHandler - -`PlayerEventHandler` is never instantiated or invoked directly by UI code. -Instead: - -1. `GameEventHandler` receives a `GameEventContainer` -2. Player-scoped events are routed to the appropriate `PlayerEventHandler` -3. Global or spectator events are handled elsewhere - -This separation keeps game-wide logic decoupled from player board state and -makes reconnection and replay handling simpler. - ---- - -## Design Intent - -`PlayerEventHandler` is designed to be: - -- **Authoritative** – applies server state exactly as received -- **Stateful** – maintains consistency across cards, zones, and UI -- **Explicit** – one handler per event type -- **UI-aware** – updates views and menus as part of state mutation -- **Auditable** – easy to trace what code handles which event - ---- - -## Related Classes - -- @ref PlayerEventHandler -- @ref GameEventHandler -- `Player` -- `CardItem` -- `CardZoneLogic` -- `GameEvent` -- `GameEventContext` - ---- - -## See Also - -- @ref GameLogicPlayers -- @ref GameLogic diff --git a/doc/doxygen/images/cockatrice_logo.svg b/doc/doxygen/images/cockatrice_logo.svg new file mode 100644 index 000000000..89ba62dcf --- /dev/null +++ b/doc/doxygen/images/cockatrice_logo.svg @@ -0,0 +1,232 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/doc/doxygen/images/classic_database_display.png b/doc/doxygen/images/screenshots/classic_database_display.png similarity index 100% rename from doc/doxygen/images/classic_database_display.png rename to doc/doxygen/images/screenshots/classic_database_display.png diff --git a/doc/doxygen/images/classic_database_display_add_buttons.png b/doc/doxygen/images/screenshots/classic_database_display_add_buttons.png similarity index 100% rename from doc/doxygen/images/classic_database_display_add_buttons.png rename to doc/doxygen/images/screenshots/classic_database_display_add_buttons.png diff --git a/doc/doxygen/images/classic_deck_editor.png b/doc/doxygen/images/screenshots/classic_deck_editor.png similarity index 100% rename from doc/doxygen/images/classic_deck_editor.png rename to doc/doxygen/images/screenshots/classic_deck_editor.png diff --git a/doc/doxygen/images/deck_dock_deck_list.png b/doc/doxygen/images/screenshots/deck_dock_deck_list.png similarity index 100% rename from doc/doxygen/images/deck_dock_deck_list.png rename to doc/doxygen/images/screenshots/deck_dock_deck_list.png diff --git a/doc/doxygen/images/deck_dock_deck_list_buttons.png b/doc/doxygen/images/screenshots/deck_dock_deck_list_buttons.png similarity index 100% rename from doc/doxygen/images/deck_dock_deck_list_buttons.png rename to doc/doxygen/images/screenshots/deck_dock_deck_list_buttons.png diff --git a/doc/doxygen/images/deck_dock_deck_list_group_by.png b/doc/doxygen/images/screenshots/deck_dock_deck_list_group_by.png similarity index 100% rename from doc/doxygen/images/deck_dock_deck_list_group_by.png rename to doc/doxygen/images/screenshots/deck_dock_deck_list_group_by.png diff --git a/doc/doxygen/images/deckeditordeckdockwidget.png b/doc/doxygen/images/screenshots/deckeditordeckdockwidget.png similarity index 100% rename from doc/doxygen/images/deckeditordeckdockwidget.png rename to doc/doxygen/images/screenshots/deckeditordeckdockwidget.png diff --git a/doc/doxygen/images/printing_selector.png b/doc/doxygen/images/screenshots/printing_selector.png similarity index 100% rename from doc/doxygen/images/printing_selector.png rename to doc/doxygen/images/screenshots/printing_selector.png diff --git a/doc/doxygen/images/printing_selector_disable.png b/doc/doxygen/images/screenshots/printing_selector_disable.png similarity index 100% rename from doc/doxygen/images/printing_selector_disable.png rename to doc/doxygen/images/screenshots/printing_selector_disable.png diff --git a/doc/doxygen/images/printing_selector_enable.png b/doc/doxygen/images/screenshots/printing_selector_enable.png similarity index 100% rename from doc/doxygen/images/printing_selector_enable.png rename to doc/doxygen/images/screenshots/printing_selector_enable.png diff --git a/doc/doxygen/images/printing_selector_navigation.png b/doc/doxygen/images/screenshots/printing_selector_navigation.png similarity index 100% rename from doc/doxygen/images/printing_selector_navigation.png rename to doc/doxygen/images/screenshots/printing_selector_navigation.png diff --git a/doc/doxygen/images/printing_selector_options.png b/doc/doxygen/images/screenshots/printing_selector_options.png similarity index 100% rename from doc/doxygen/images/printing_selector_options.png rename to doc/doxygen/images/screenshots/printing_selector_options.png diff --git a/doc/doxygen/images/printing_selector_pre_providerid.png b/doc/doxygen/images/screenshots/printing_selector_pre_providerid.png similarity index 100% rename from doc/doxygen/images/printing_selector_pre_providerid.png rename to doc/doxygen/images/screenshots/printing_selector_pre_providerid.png diff --git a/doc/doxygen/images/release_channel_beta.png b/doc/doxygen/images/screenshots/release_channel_beta.png similarity index 100% rename from doc/doxygen/images/release_channel_beta.png rename to doc/doxygen/images/screenshots/release_channel_beta.png diff --git a/doc/doxygen/images/vde_deck_analytics.png b/doc/doxygen/images/screenshots/vde_deck_analytics.png similarity index 100% rename from doc/doxygen/images/vde_deck_analytics.png rename to doc/doxygen/images/screenshots/vde_deck_analytics.png diff --git a/doc/doxygen/images/vde_flat_layout_color_grouped.png b/doc/doxygen/images/screenshots/vde_flat_layout_color_grouped.png similarity index 100% rename from doc/doxygen/images/vde_flat_layout_color_grouped.png rename to doc/doxygen/images/screenshots/vde_flat_layout_color_grouped.png diff --git a/doc/doxygen/images/vde_flat_layout_type_grouped.png b/doc/doxygen/images/screenshots/vde_flat_layout_type_grouped.png similarity index 100% rename from doc/doxygen/images/vde_flat_layout_type_grouped.png rename to doc/doxygen/images/screenshots/vde_flat_layout_type_grouped.png diff --git a/doc/doxygen/images/vde_overlap_layout_type_grouped.png b/doc/doxygen/images/screenshots/vde_overlap_layout_type_grouped.png similarity index 100% rename from doc/doxygen/images/vde_overlap_layout_type_grouped.png rename to doc/doxygen/images/screenshots/vde_overlap_layout_type_grouped.png diff --git a/doc/doxygen/images/vde_sample_hand.png b/doc/doxygen/images/screenshots/vde_sample_hand.png similarity index 100% rename from doc/doxygen/images/vde_sample_hand.png rename to doc/doxygen/images/screenshots/vde_sample_hand.png diff --git a/doc/doxygen/extra-pages/developer_documentation/card_database_schema_and_parsing.md b/doc/doxygen/pages/developer_documentation/card_database_schema_and_parsing.md similarity index 100% rename from doc/doxygen/extra-pages/developer_documentation/card_database_schema_and_parsing.md rename to doc/doxygen/pages/developer_documentation/card_database_schema_and_parsing.md diff --git a/doc/doxygen/extra-pages/developer_documentation/displaying_cards.md b/doc/doxygen/pages/developer_documentation/displaying_cards.md similarity index 100% rename from doc/doxygen/extra-pages/developer_documentation/displaying_cards.md rename to doc/doxygen/pages/developer_documentation/displaying_cards.md diff --git a/doc/doxygen/extra-pages/developer_documentation/index.md b/doc/doxygen/pages/developer_documentation/index.md similarity index 100% rename from doc/doxygen/extra-pages/developer_documentation/index.md rename to doc/doxygen/pages/developer_documentation/index.md diff --git a/doc/doxygen/pages/developer_documentation/loading_card_pictures.md b/doc/doxygen/pages/developer_documentation/loading_card_pictures.md new file mode 100644 index 000000000..c4c335b00 --- /dev/null +++ b/doc/doxygen/pages/developer_documentation/loading_card_pictures.md @@ -0,0 +1,210 @@ +@page loading_card_pictures Loading Card Pictures + +Pictures associated with CardInfo%s are retrieved either from on-disk or the network through the CardPictureLoader. + +In most cases, you don't need to concern yourself with the internals of CardPictureLoader. + +Simply using one of the ways described in @ref displaying_cards is enough to automatically queue a request to the +CardPictureLoader when the chosen widget is shown, emitting signals to refresh the widget when the request is finished. + +# How requests are triggered + +CardPictureLoader::getPixmap() is called exactly two times in the code base, in CardInfoPictureWidget::loadPixmap(), the +base class +for all widget based card picture display, and AbstractCardItem::paintPicture(), the base class for all QGraphicsItem +based card picture +display. See @ref displaying_cards for more information on the difference between these two display methods. + +Because both of these calls are made in the paintEvent() methods of their respective classes, this means that requests +are issued as soon as but not before the widget is shown on screen. + +It is also possible to "warm up" the cache by issuing card picture load requests to the CardPictureLoader without using +a display widget and waiting for it to be shown by calling CardPictureLoader::cacheCardPixmaps() with a list of +ExactCard%s. + +# The QPixmapCache and QNetworkDiskCache + +Cockatrice uses the QPixmapCache from the Qt GUI module to store card pictures in-memory and the QNetworkDiskCache from +the Qt Network module to cache network requests for card pictures on-disk. + +What this means is that the CardPictureLoader will first attempt to look up a card in the QPixmapCache according to the +ExactCard::getPixmapCacheKey() method of an ExactCard object. If it does not find it in the in-memory cache, it will +issue a load request, which will first look for local images on-disk and then consult the QNetworkDiskCache and if +found, use the stored binary data from the network cache to populate the in-memory pixmap cache under the card's cache +key. If it is not found, it will then proceed with issuing a network request. + +The size of both of these caches can be configured by the user on the "Storage" settings page. + +# PixmapCacheKeys and ProviderIDs + +Every card picture that is loaded ends up in the QPixmapCache under a key that identifies the exact printing it belongs +to. The key is produced by ExactCard::getPixmapCacheKey() and has the following shape: + +```text +card__ +``` + +For example, the _Example Card_ printing with provider ID `0b23cdc8-d413-4fb1-8470-474221b10fe2` is stored +under `card_Example Card_0b23cdc8-d413-4fb1-8470-474221b10fe2`. If the printing has no provider ID, the key +drops the suffix and falls back to `card_`. + +The **provider ID** is the Scryfall UUID of the printing. Oracle maps the `scryfallId` of every printing to the `uuid` +property when building the card database, and deck files persist it as the `uuid` attribute of each card entry. Because +the provider ID is part of the pixmap cache key, two different printings of the same card never share a cache entry. +This is exactly what allows the printing selector and exact-card lookups to display the picture of the precise printing +a card was added as. + +The base key holds the full-size image. When a widget asks for a scaled version, the scaled pixmap is stored under an +additional key of the form `_x`, with the size adjusted for the device pixel ratio of the screen, +so each widget size is only ever scaled once. + +The cache key is also used for bookkeeping outside of the pixmap cache itself: + +- CardPictureLoaderWorker keeps a set of keys that are currently being loaded so the same card is never queued twice. +- CardPictureLoader tracks, per key, the last time loading failed. A failed load stores a NULL pixmap under the key; as + long as that marker is present, subsequent requests for the card show the "failed" card back and are only re-queued + after the retry interval of 300 seconds has passed. +- When the CardInfo of a loaded card is destroyed, its cache entries and failure markers are removed. + +# The Redirect Cache + +Many picture URLs - in particular the Gatherer and Scryfall URLs from the default set of templates - redirect to a CDN +or to a different host. To avoid following the same redirect for every single card, CardPictureLoaderWorker remembers +redirects and applies them without an extra network round trip. + +The redirect cache is a hash map from original URL to redirect URL plus timestamp. It is persisted to a `cache.ini` +file (Qt's INI format, under the `redirects` array) inside the redirect cache directory +(`SettingsCache::instance().paths().getRedirectCachePath()`, i.e. `/redirects/`). The cache is loaded when the worker +starts, pruned of entries older than the configured TTL, and written back to disk when the application quits. + +Entries are added whenever a network reply reports a redirection (see below) and are consulted before any request is +made: both CardPictureLoaderWorker::queueRequest() and CardPictureLoaderWorker::makeRequest() check for a cached +redirect first and jump straight to the final URL. + +The TTL is the "Redirect Cache TTL" setting on the "Storage" settings page and defaults to 30 days. Lowering it makes +Cockatrice re-resolve redirects sooner, which can help when a download URL changed its redirect target. + +Because Cockatrice tracks redirects itself, the QNetworkAccessManager is configured with Qt's `ManualRedirectPolicy`. +Redirects found in a reply are handled manually: + +- A recursive redirect (a URL redirecting to itself) is treated as a failed load. +- Otherwise the redirect is recorded in the redirect cache and the request is re-issued against the target URL. +- A successful reply with one of the redirect status codes 301, 302, 303, 305, 307 or 308 is handled the same way. + +Clearing the network cache (CardPictureLoader::clearNetworkCache()) also clears the redirect cache. + +# Local Image Loading + +Before any network request is issued, CardPictureLoaderWorker hands the ExactCard to CardPictureLoaderLocal, which +tries to find a matching picture on disk. If a local picture is found, it is used and no network request is made. + +CardPictureLoaderLocal searches three locations: + +- The **CUSTOM folder** (`/CUSTOM/`). Every file in it is indexed recursively by its base name + (both `baseName` and `completeBaseName`, so a file named `ExampleCard.jpg` is indexed as `ExampleCard`). The index is rebuilt + every 10 seconds, so new files are picked up without restarting the + client (changing the configured pictures directory only reassigns the search paths; the next timer tick rebuilds the index). +- The **set-named subfolders** of the pictures directory: `//` and + `/downloadedPics//`. +- The **root of the `downloadedPics` folder** (`/downloadedPics/`). The export naming schemes without a + set-folder part write their files straight into `downloadedPics/`, so this is where flat-scheme downloads and the + local overrides described below are matched. + +For each candidate folder, the loader generates file-name variants from the card's corrected name, set code, collector +number and provider ID using the import naming schemes (Card Name + Provider ID, Card Name + Set + Collector, +Set + Collector + Card Name, Card Name + Set, Card Name), each tried with both `_` and `-` as separator. A file is +accepted when its name without the extension *equals* the variant exactly - the extension itself is free - and the +first variant that yields a readable image wins. For example, the file `Example Card_EXM_43.png` in the `EXM` set +folder matches the card with corrected name `Example Card`, set code `EXM` and collector number `43`. + +\attention The file-name variants use the *corrected* card name, so split cards are stored under their joined name: the +"Example // Card" card is matched by a file named `ExampleCard.*`. + +The naming schemes are also documented in the user-facing page @ref custom_card_pictures, which additionally covers +the CUSTOM folder workflow, `picurl` and download URL templates. + +When the filesystem cache method is selected on the "Storage" settings page, downloaded images are additionally written +into `/downloadedPics/` using the configured export naming scheme (as `.png` files). The two export +schemes with a set-folder part (`Set Folder / Name + Provider ID` and `Set Folder / Name + Set Name + Collector`) write +into `downloadedPics//`; the three flat schemes write directly into `downloadedPics/`. Automatic cache writes +never overwrite an existing file, so a provider outage can permanently leave an outdated image in that folder until it +is deleted manually - the user-facing troubleshooting guide @ref fixing_card_pictures covers how to do this. Explicit +image overrides (see below) are the exception and always overwrite. + +# Local Image Overrides + +Beyond the generic on-disk lookup above, individual printings can be given explicit artwork that wins over every other +source without touching the CUSTOM folder or any download URL. This is the "Image Overrides" submenu of the context menu +that opens when you right-click a card in the deck editor's printing selector. + +- **Load Custom Image...** asks for a picture file and installs it for the card through + CardPictureLoader::saveCardImageToLocalStorage() with `allowOverwrite == true`. +- **One entry per alternate printing** (labeled ` `): selecting one hands the card to + CardPictureLoader::installPrintingOverride(), which resolves that printing's artwork - enqueueing a load and waiting + for the `CardInfo::pixmapUpdated` signal if it is not cached yet - and persists it for the card. +- **Clear Custom Image** calls CardPictureLoader::deleteAllLocalOverrides() to remove every stored override image of the + card, after which normal resolution resumes. The entry is only enabled while CardPictureLoader::hasLocalOverrides() + reports at least one stored file. + +Overrides are stored as `.png` files in `downloadedPics/` under the export naming scheme configured on the "Storage" +settings page - which is exactly why the local matcher also looks into the `downloadedPics/` root (see above). They are +written with `allowOverwrite == true`, so an override always replaces whatever the filesystem cache previously saved for +that spelling; only *automatic* cache writes are prevented from clobbering it. Overriding a card with its own current +printing is a no-op (the UI omits it from the menu), and an override whose artwork fails to resolve surfaces the +"failed" card back instead of a silent no-op while any override already on disk is left in place and re-displayed. + +# URL Generation and Resolution + +When no local image is available and downloading is enabled, the network loader starts working through a list of +candidate URLs. This list is managed by CardPictureToLoad and is built in two steps. + +First, CardPictureToLoad::extractSetsSorted() collects all sets the card has printings in and sorts them by set +priority. Unless the user disabled per-printing art ("Override all card art with personal set preference (Pre-ProviderID +change behavior)"), the set that +matches the requested printing's provider ID is moved to the front, so the exact printing is always attempted first. + +For each set, CardPictureToLoad::populateSetUrls() builds an ordered URL list: + +1. A custom URL defined for that printing via the `picurl` property in the card database, if present. +2. The configured download URL templates, in priority order (Deck Editor → "URL Download Priority"). + +URL templates are transformed into concrete URLs by CardPictureToLoad::transformUrl(), which substitutes reference +points. `!name!`, `!setcode!` and friends substitute card and printing data, while the `!set:!` and +`!prop:!` reference points resolve a property of the printing or of the card respectively. The canonical list +of all reference points with examples, including the `_fill_with_` and `_substr_` modifiers, lives in +@ref custom_card_pictures. + +The `!set:...!` and `!prop:...!` reference points also support two modifiers: + +- `_fill_with_` pads the value with the given text, right-aligned, e.g. `!set:num_fill_with_000!` turns collector + number `1` into `001`. If the value is longer than the fill text, the template is invalidated. +- `_substr__` extracts a substring, e.g. `!set:num_substr_2_2!` takes two characters starting at the + third. If the substring would extend past the end of the value, the template is invalidated. + +Substituted values are percent-encoded. If a template asks for a property the card or printing does not have (or one of +the modifiers invalidates it), the template yields no URL and is skipped; the next template is tried instead. + +\attention Custom URLs should start with `http://` or `https://`. The scheme is not validated before the URL is handed +to QNetworkAccessManager, so a template without an absolute scheme may silently fail to download; prefer HTTPS where the +provider allows it. + +The resolution order is: for the current set, try each URL in the list; when all URLs for a set are exhausted, move to +the next set; when every set is exhausted, the load fails. A failed load is reported through the NULL-pixmap mechanism +described in the PixmapCacheKeys and ProviderIDs section above. + +Several mechanisms influence the resolution process: + +- **Rate limiting.** The worker allows roughly 10 requests per second globally. A server that answers with HTTP 429 + gets its per-host allowance halved; the first 429 for a host is waited out (honoring the `Retry-After` header if + present) and the same URL retried, while a second 429 makes the loader fall through to the other configured sources. + When all sources are exhausted the request is deferred with some random jitter and retried once the back-off expires. +- **Redirects.** Replies with a redirect status (301, 302, 303, 305, 307, 308) are followed and recorded in the + redirect cache as described in the Redirect Cache section above. +- **Blacklisted images.** Gatherer returns the card back image for cards it does not know. A few known MD5 hashes of + that image are blacklisted, so such a "successful" download is treated as not found instead of being shown. +- **WebP.** Images detected as WebP (RIFF/WEBP header) are decoded through QMovie instead of QImageReader. +- **Downloads disabled.** When "Download card pictures on the fly" is disabled and the network cache method is active, + requests use Qt's `AlwaysCache` policy so that only previously cached images are served. + +A user-facing reference for writing download URL templates, including more worked examples, is available at +@ref custom_card_pictures. diff --git a/doc/doxygen/extra-pages/developer_documentation/logging.md b/doc/doxygen/pages/developer_documentation/logging.md similarity index 100% rename from doc/doxygen/extra-pages/developer_documentation/logging.md rename to doc/doxygen/pages/developer_documentation/logging.md diff --git a/doc/doxygen/extra-pages/developer_documentation/network/client/game_event_handler.md b/doc/doxygen/pages/developer_documentation/network/client/game_event_handler.md similarity index 93% rename from doc/doxygen/extra-pages/developer_documentation/network/client/game_event_handler.md rename to doc/doxygen/pages/developer_documentation/network/client/game_event_handler.md index fbb298d3a..051823c6c 100644 --- a/doc/doxygen/extra-pages/developer_documentation/network/client/game_event_handler.md +++ b/doc/doxygen/pages/developer_documentation/network/client/game_event_handler.md @@ -90,13 +90,13 @@ This keeps the handler independent of concrete UI implementations. `GameEventHandler` and `PlayerEventHandler` work together but have distinct roles: -| GameEventHandler | PlayerEventHandler | -|------------------|--------------------| -| Global game state | Per-player state | -| Turn / phase flow | Zones and cards | -| Player join/leave | Player actions | -| Spectator events | Player-specific events | -| Chat dispatch | Card and zone updates | +| GameEventHandler | PlayerEventHandler | +| ----------------- | ---------------------- | +| Global game state | Per-player state | +| Turn / phase flow | Zones and cards | +| Player join/leave | Player actions | +| Spectator events | Player-specific events | +| Chat dispatch | Card and zone updates | When a server event is associated with a specific player, `GameEventHandler` routes it to the corresponding `PlayerEventHandler`. Events without a player diff --git a/doc/doxygen/extra-pages/developer_documentation/network/client/index.md b/doc/doxygen/pages/developer_documentation/network/client/index.md similarity index 100% rename from doc/doxygen/extra-pages/developer_documentation/network/client/index.md rename to doc/doxygen/pages/developer_documentation/network/client/index.md diff --git a/doc/doxygen/extra-pages/developer_documentation/network/client/player_event_handler.md b/doc/doxygen/pages/developer_documentation/network/client/player_event_handler.md similarity index 100% rename from doc/doxygen/extra-pages/developer_documentation/network/client/player_event_handler.md rename to doc/doxygen/pages/developer_documentation/network/client/player_event_handler.md diff --git a/doc/doxygen/extra-pages/developer_documentation/network/index.md b/doc/doxygen/pages/developer_documentation/network/index.md similarity index 100% rename from doc/doxygen/extra-pages/developer_documentation/network/index.md rename to doc/doxygen/pages/developer_documentation/network/index.md diff --git a/doc/doxygen/extra-pages/developer_documentation/network/protocol/index.md b/doc/doxygen/pages/developer_documentation/network/protocol/index.md similarity index 100% rename from doc/doxygen/extra-pages/developer_documentation/network/protocol/index.md rename to doc/doxygen/pages/developer_documentation/network/protocol/index.md diff --git a/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_command_container.md b/doc/doxygen/pages/developer_documentation/network/protocol/protocol_command_container.md similarity index 100% rename from doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_command_container.md rename to doc/doxygen/pages/developer_documentation/network/protocol/protocol_command_container.md diff --git a/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_game_command.md b/doc/doxygen/pages/developer_documentation/network/protocol/protocol_game_command.md similarity index 100% rename from doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_game_command.md rename to doc/doxygen/pages/developer_documentation/network/protocol/protocol_game_command.md diff --git a/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_overview.md b/doc/doxygen/pages/developer_documentation/network/protocol/protocol_overview.md similarity index 100% rename from doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_overview.md rename to doc/doxygen/pages/developer_documentation/network/protocol/protocol_overview.md diff --git a/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_response.md b/doc/doxygen/pages/developer_documentation/network/protocol/protocol_response.md similarity index 100% rename from doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_response.md rename to doc/doxygen/pages/developer_documentation/network/protocol/protocol_response.md diff --git a/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_server_message.md b/doc/doxygen/pages/developer_documentation/network/protocol/protocol_server_message.md similarity index 100% rename from doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_server_message.md rename to doc/doxygen/pages/developer_documentation/network/protocol/protocol_server_message.md diff --git a/doc/doxygen/extra-pages/developer_documentation/primer_cards.md b/doc/doxygen/pages/developer_documentation/primer_cards.md similarity index 100% rename from doc/doxygen/extra-pages/developer_documentation/primer_cards.md rename to doc/doxygen/pages/developer_documentation/primer_cards.md diff --git a/doc/doxygen/extra-pages/developer_documentation/querying_the_card_database.md b/doc/doxygen/pages/developer_documentation/querying_the_card_database.md similarity index 100% rename from doc/doxygen/extra-pages/developer_documentation/querying_the_card_database.md rename to doc/doxygen/pages/developer_documentation/querying_the_card_database.md diff --git a/doc/doxygen/extra-pages/index.md b/doc/doxygen/pages/index.md similarity index 77% rename from doc/doxygen/extra-pages/index.md rename to doc/doxygen/pages/index.md index b3363d3f4..9dafe1882 100644 --- a/doc/doxygen/extra-pages/index.md +++ b/doc/doxygen/pages/index.md @@ -1,5 +1,9 @@ @mainpage Documentation +
+ +
+ Welcome to the Cockatrice code documentation. - @subpage user_reference diff --git a/doc/doxygen/pages/user_documentation/card_pictures/custom_card_pictures.md b/doc/doxygen/pages/user_documentation/card_pictures/custom_card_pictures.md new file mode 100644 index 000000000..bcea6db8b --- /dev/null +++ b/doc/doxygen/pages/user_documentation/card_pictures/custom_card_pictures.md @@ -0,0 +1,135 @@ +@page custom_card_pictures Custom Card Pictures + +There are four ways to make Cockatrice use custom artwork for your cards: + +- Placing image files in the **CUSTOM pictures folder**. +- Providing a **custom card database** that points each printing at a picture URL via the `picurl` property. +- Writing your **own download URL templates**. +- Setting an **image override** for a single card from inside the deck editor. + +Each of these is described below. If pictures are missing or wrong, see @ref fixing_card_pictures instead. + +# Custom Pictures Folder (CUSTOM) + +Any image file placed in the CUSTOM folder is used as the card picture, and no download is attempted for cards that +match a file there. + +- The folder is `/CUSTOM/`. The pictures directory is configured on the 'General' settings tab, + under 'Directories' → 'Pictures directory'. +- Any image format Qt can decode is accepted (PNG, JPG/JPEG, WebP, GIF, BMP, ...); the file extension is not filtered, + so even an extension-less file is picked up if the decoder recognizes its content. +- Files are indexed by their name, so you can organize them into subfolders freely. +- New or changed files are picked up automatically within a few seconds — no client restart is required. + +The file name must match the card using one of the naming schemes below. Both `_` and `-` are accepted as separators, +and the file extension is ignored when matching: + +| Scheme | Example file name | +| --------------------------- | ------------------------------------------------------- | +| Card Name | `Example Card.png` | +| Card Name + Set | `Example Card_DDL.png` | +| Card Name + Set + Collector | `Example Card_DDL_43.png` | +| Set + Collector + Card Name | `DDL_43_Example Card.png` | +| Card Name + Provider ID | `Example Card_0b23cdc8-d413-4fb1-8470-474221b10fe2.png` | + +The name used for matching is the *corrected* card name. Correction removes the split-card separator ` // ` and the +characters reserved in Windows file names (`* < > : " \ ?` and control characters), and turns `/` into a space, so the +"Example // Card" card is matched by a file named `ExampleCard.png`, not `Example // Card.png`. Most other punctuation +(commas, apostrophes, `!`, ...) is left untouched. + +\attention A file in the CUSTOM folder always wins over downloaded pictures, even if it is the wrong image. Delete the +file if you want to see the downloaded artwork again. + +The naming conventions are the same as those recognized in the set-named subfolders and in `downloadedPics`, and are +documented for developers in @ref loading_card_pictures. + +# Custom Card Database (picurl) + +If you maintain your own card database (see the +[Custom Cards & Sets](https://github.com/Cockatrice/Cockatrice/wiki/Custom-Cards-&-Sets) wiki), each printing's `` +tag can carry a `picurl` attribute containing a full URL for that printing's picture: + +```xml + +``` + +Cockatrice tries this URL **before** the configured download URL templates, so it is the most direct way to provide +custom artwork for a specific printing. + +- The URL should start with `http://` or `https://`; the scheme is not validated, so make sure it is absolute or the + download may silently fail. +- When you change a `picurl` for a card whose picture was already downloaded and cached, delete the stored images + (Storage tab → 'Delete Saved Images' / 'Delete Cached Images') so Cockatrice fetches the new URL. + +# Custom Download URL Templates + +The built-in download URLs are templates: Cockatrice replaces reference points in the URL with information about the +card and its printing. You can write your own templates in 'Cockatrice → Settings' (Ctrl + Shift + P by default), on +the 'Deck Editor' tab, in the 'URL Download Priority' section. + +The following reference points are available: + +| Reference point | Description | Example | +| ------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | +| `!name!` | Card name | `Example Card` | +| `!name_lower!` | Card name, lower case | `example card` | +| `!corrected_name!` | Corrected card name | `ExampleCard` (instead of "Example // Card") | +| `!corrected_name_lower!` | Corrected card name, lower case | `examplecard` | +| `!sflang!` | Scryfall language code for the current client language; defaults to English when the language has no localized images | `en`, `zhs` | +| `!setcode!` / `!setcode_lower!` | Set code | `EXM` / `exm` | +| `!setname!` / `!setname_lower!` | Full set name | `Exemplary Set` / `exemplary set` | +| `!set:!` | A property of this printing, e.g. `muid` (Gatherer multiverse ID), `uuid` (Scryfall UUID), `num` (collector number), `rarity` | `373549` | +| `!prop:!` | A property of the card, e.g. `side` (front/back), `colors`, `cmc`, `coloridentity`, `type`, `pt`, and the format legality statuses | `front` | + +The `!set:...!` and `!prop:...!` reference points support two modifiers: + +- `_fill_with_` pads the value with the given text, right-aligned, e.g. `!set:num_fill_with_000!` turns collector + number `1` into `001`. If the value is longer than the fill text, the template is skipped. +- `_substr__` extracts a substring, e.g. `!set:num_substr_2_2!` takes two characters starting at the + third. If the substring would extend past the end of the value, the template is skipped. + +Substituted values are URL-encoded. A template that asks for a property the card or printing does not have is skipped, +and the next template in the list is tried instead. + +\attention Custom URLs should start with `http://` or `https://`. As with `picurl`, the scheme is not validated before +the URL is handed to QNetworkAccessManager, so use an absolute URL or the download may silently fail. + +Some working examples: + +```text +https://cards.scryfall.io/large/!prop:side!/!set:uuid_substr_0_1!/!set:uuid_substr_1_1!/!set:uuid!.jpg +https://api.scryfall.com/cards/!set:uuid!?format=image&face=!prop:side! +https://api.scryfall.com/cards/multiverse/!set:muid!?format=image +https://gatherer.wizards.com/Handlers/Image.ashx?multiverseid=!set:muid!&type=card +https://gatherer.wizards.com/Handlers/Image.ashx?name=!name!&type=card +``` + +See the [Custom Picture Download URLs](https://github.com/Cockatrice/Cockatrice/wiki/Custom-Picture-Download-URLs) +wiki for more examples and ideas. + +\attention Keep in mind that templates using `!name!` or `!set:muid!` resolve by name or multiverse ID, not by the +exact printing. Only the Scryfall `!set:uuid!` templates always return the exact printing requested. See +@ref fixing_card_pictures for more on this. + +# Image Overrides + +The quickest way to give one card custom art is an image override: right-click the card in the deck editor's printing +selector and open the **Image Overrides** submenu of the context menu. + +- **Load Custom Image...** — choose a picture file (the dialog suggests PNG, JPG/JPEG and WebP); it becomes that card's + artwork immediately. +- **One entry per alternate printing** of the card, labeled ` ` (hovering an entry previews that + printing's artwork). Selecting one makes the card use that exact printing's picture, so e.g. a basic land can be shown + with any of its artworks. +- **Clear Custom Image** — removes the stored override and returns the card to normal resolution. It is only available + while the card has a stored override. + +Overrides are stored as `.png` files in `/downloadedPics/`, under the "Naming scheme" configured on +the Storage settings page, and are matched the same way as downloaded images. Because local files are checked before any +URL is requested, an override always wins over downloaded artwork and `picurl` for that card. The override exists only on +the machine it was created on - it is not part of the deck file - so a card with a stored override shows normally on +another computer. + +\attention If you also keep a matching file in the CUSTOM folder, that file is matched before the override. When you +change an override, use **Clear Custom Image** so the stored `.png` is replaced; manually deleting the file in +`downloadedPics/` has the same effect. diff --git a/doc/doxygen/extra-pages/user_documentation/deck_management/creating_decks.md b/doc/doxygen/pages/user_documentation/deck_management/creating_decks.md similarity index 100% rename from doc/doxygen/extra-pages/user_documentation/deck_management/creating_decks.md rename to doc/doxygen/pages/user_documentation/deck_management/creating_decks.md diff --git a/doc/doxygen/extra-pages/user_documentation/deck_management/editing_decks.md b/doc/doxygen/pages/user_documentation/deck_management/editing_decks.md similarity index 100% rename from doc/doxygen/extra-pages/user_documentation/deck_management/editing_decks.md rename to doc/doxygen/pages/user_documentation/deck_management/editing_decks.md diff --git a/doc/doxygen/extra-pages/user_documentation/deck_management/editing_decks_classic.md b/doc/doxygen/pages/user_documentation/deck_management/editing_decks_classic.md similarity index 100% rename from doc/doxygen/extra-pages/user_documentation/deck_management/editing_decks_classic.md rename to doc/doxygen/pages/user_documentation/deck_management/editing_decks_classic.md diff --git a/doc/doxygen/extra-pages/user_documentation/deck_management/editing_decks_printings.md b/doc/doxygen/pages/user_documentation/deck_management/editing_decks_printings.md similarity index 100% rename from doc/doxygen/extra-pages/user_documentation/deck_management/editing_decks_printings.md rename to doc/doxygen/pages/user_documentation/deck_management/editing_decks_printings.md diff --git a/doc/doxygen/extra-pages/user_documentation/deck_management/editing_decks_visual.md b/doc/doxygen/pages/user_documentation/deck_management/editing_decks_visual.md similarity index 100% rename from doc/doxygen/extra-pages/user_documentation/deck_management/editing_decks_visual.md rename to doc/doxygen/pages/user_documentation/deck_management/editing_decks_visual.md diff --git a/doc/doxygen/extra-pages/user_documentation/deck_management/exporting_decks.md b/doc/doxygen/pages/user_documentation/deck_management/exporting_decks.md similarity index 100% rename from doc/doxygen/extra-pages/user_documentation/deck_management/exporting_decks.md rename to doc/doxygen/pages/user_documentation/deck_management/exporting_decks.md diff --git a/doc/doxygen/extra-pages/user_documentation/deck_management/importing_decks.md b/doc/doxygen/pages/user_documentation/deck_management/importing_decks.md similarity index 100% rename from doc/doxygen/extra-pages/user_documentation/deck_management/importing_decks.md rename to doc/doxygen/pages/user_documentation/deck_management/importing_decks.md diff --git a/doc/doxygen/extra-pages/user_documentation/index.md b/doc/doxygen/pages/user_documentation/index.md similarity index 62% rename from doc/doxygen/extra-pages/user_documentation/index.md rename to doc/doxygen/pages/user_documentation/index.md index d7e9d529d..f4ecbe8a1 100644 --- a/doc/doxygen/extra-pages/user_documentation/index.md +++ b/doc/doxygen/pages/user_documentation/index.md @@ -11,6 +11,19 @@ - @subpage beta_release +## Settings + +- @subpage settings_folder + +## Card Pictures + +- @subpage custom_card_pictures + +## Troubleshooting + +- @subpage fixing_card_pictures +- @subpage enabling_debug_logs + ## Syntax Help - @subpage search_syntax_help diff --git a/doc/doxygen/extra-pages/user_documentation/settings/beta.md b/doc/doxygen/pages/user_documentation/settings/beta.md similarity index 100% rename from doc/doxygen/extra-pages/user_documentation/settings/beta.md rename to doc/doxygen/pages/user_documentation/settings/beta.md diff --git a/doc/doxygen/pages/user_documentation/settings/settings_folder.md b/doc/doxygen/pages/user_documentation/settings/settings_folder.md new file mode 100644 index 000000000..5b3426e45 --- /dev/null +++ b/doc/doxygen/pages/user_documentation/settings/settings_folder.md @@ -0,0 +1,43 @@ +@page settings_folder Settings Folder and Backups + +Cockatrice keeps all of its preferences in a settings folder on your computer, one `.ini` file per +group of settings (appearance, sound, decks, and so on). Open it from the main window via +Help → Open Settings Folder. + +# The Per-File Migration + +Older versions of Cockatrice stored every setting in a single `global.ini` file. The settings were +split into per-group `.ini` files so that unrelated groups no longer share one file. The first time +you start a version that uses the new layout, your settings are moved into the new files +automatically and nothing is lost. The old `global.ini` is kept next to the new files as +`global.ini.old`. + +# Automatic Backups + +Both migrations overwrite files in the settings folder in place, so before either one runs, +Cockatrice copies the whole folder to a timestamped backup folder: + +``` +/settings-backup-20261002-193000/ +``` + +The backup folder sits next to the settings folder, not inside it. Every backup is kept, so if +settings ever end up wrong you can compare against, or copy files back from, the earlier snapshot. +Backups are only created when a migration actually has work to do, so a normal start creates +nothing. + +# Downgrading + +Settings migrations only run forwards. If you ever go back to an older version of Cockatrice, that +version does not read the new per-group files, so it will start with default settings. + +Before downgrading, copy your settings folder somewhere safe, for example: + +- Linux: `~/.local/share/Cockatrice/settings/` +- macOS: `~/Library/Application Support/Cockatrice/settings/` +- Windows: `%LOCALAPPDATA%\Cockatrice\settings\` + +Portable installs keep the settings folder in the `data` folder next to the executable. + +Copying that folder back after downgrading restores your settings. The automatic backup folder from +the migration is there as a second copy of the same data if you need it. \ No newline at end of file diff --git a/doc/doxygen/pages/user_documentation/troubleshooting/enabling_debug_logs.md b/doc/doxygen/pages/user_documentation/troubleshooting/enabling_debug_logs.md new file mode 100644 index 000000000..27c4272f5 --- /dev/null +++ b/doc/doxygen/pages/user_documentation/troubleshooting/enabling_debug_logs.md @@ -0,0 +1,142 @@ +@page enabling_debug_logs Enabling Debug Logs + +Cockatrice ships with a "diagnostics mode" that prints detailed debug messages about what the client is doing. This is +extremely useful when asking for help, because it shows exactly what happened: which URL the card picture loader tried, +whether it found or missed a file on disk, whether a download succeeded or failed, which redirects were followed, and +much more. Each of these messages belongs to a category, and you can enable or disable categories individually. + +Don't worry, this sounds more technical than it is. You only need to do two things: create one small text file, and tell +Cockatrice where it is. There are no installation steps and you can undo everything later (see [When you are +done](#when-you-are-done)). + +# Step 1: Create the file + +Open a plain text editor (Notepad on Windows, TextEdit on macOS, or any text editor on Linux) and paste the following +content: + +```ini +[Rules] +# The default log level is info +*.debug = false + +# Turn on debug level logs for the card picture loader and all its sub categories +card_picture_loader.* = true +``` + +Save the file with the exact name `qtlogging.ini` in a place you can find again, for example your Documents folder. + +\attention The file name matters: it must be `qtlogging.ini`, not `qtlogging.ini.txt`. If your text editor adds a +`.txt` extension automatically, you need to stop it from doing so (see below). On macOS, TextEdit must be switched to +plain text mode first via 'Format → Make Plain Text'. + +The file contains one rule per line. The `*.debug = false` rule turns off debug messages everywhere by default, and the +`card_picture_loader.* = true` line then re-enables them for the card picture loader. The `.*` at the end means "this +category and all of its sub categories". To enable a different category instead, just replace that line with the +category name of your choice, for example `card_database.loading = true` or `window_main.startup = true`. + +# Step 2: Tell Cockatrice where the file is + +Cockatrice does not know about the file yet. You have to point it there by setting an environment variable called +`QT_LOGGING_CONF` to the full location of your file. How to do this depends on your operating system: + +**Windows** + +1. Press the Windows key, type "environment variables", and open "Edit the system environment variables". +2. Click "Environment Variables...", then under "User variables" click "New...". +3. Set "Variable name" to `QT_LOGGING_CONF` and "Variable value" to the full path of your file, for example + `C:\Users\YourName\Documents\qtlogging.ini`. +4. Confirm all dialogs, then close and reopen Cockatrice. + +Alternatively, if Cockatrice is installed in a folder you can write to, you can simply place the `qtlogging.ini` file +directly next to the Cockatrice executable (in the same folder as `cockatrice.exe`) and skip the environment variable +altogether. Note that this copy may be replaced when you update the client. + +**macOS** + +Open the Terminal app (it is in 'Applications → Utilities') and run the following two commands, replacing the path +with the full location of your file: + +```shell +launchctl setenv QT_LOGGING_CONF /path/to/qtlogging.ini +open -a Cockatrice +``` + +You can also use `export QT_LOGGING_CONF=/path/to/qtlogging.ini` to set the variable for the current terminal session. + +The setting stays active until you log out or restart your Mac. If you have multiple users on the same Mac, be aware +that this setting only applies to your user account. + +**Linux** + +For a quick test, open a terminal and start Cockatrice with the file on the command line, replacing the path with the +full location of your file: + +```shell +QT_LOGGING_CONF=/path/to/qtlogging.ini cockatrice +``` + +If this works and you want it to apply every time you start Cockatrice, add the following line to your `~/.profile` +file and log in again: + +```shell +export QT_LOGGING_CONF="/path/to/qtlogging.ini" +``` + +# Step 3: See the logs + +Now that debug logging is enabled, open Cockatrice and trigger the behavior you are investigating, for example by +opening a deck, reloading the card database, or starting a game. + +The easiest way to see the logs is to use the built-in log viewer inside Cockatrice itself: open 'Help → View Debug +Log'. A window appears that shows the log messages live and keeps the most recent entries. It even has a 'Copy to +clipboard' button so you can paste the output into a bug report or a Discord message. This works the same on every +operating system. + +If you prefer to capture everything to a file instead, start Cockatrice with the `--debug-output` option: + +```shell +cockatrice --debug-output +``` + +Cockatrice then writes the full log to a file called `qdebug.txt` in the folder it was started from. + +# Which categories are available? + +Every message Cockatrice logs belongs to a category. The following table lists the most useful ones for troubleshooting, +grouped by area. Enable a category by adding a line like `category = true` to your `qtlogging.ini` file (or use a `.*` +suffix, e.g. `card_picture_loader.*`, to include all sub categories). + +| What you want to see | Categories | +| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| Card picture loading (URLs, local file hits/misses, downloads, redirects) | `card_picture_loader.*` | +| Card database loading and parsing | `card_database`, `card_database.loading`, `card_database.loading.success_or_failure`, `cockatrice_xml.*` | +| Card database update (running Oracle, progress, exit status, errors) | `card_database.update` | +| Card, set, and deck information | `card_info`, `card_list`, `deck_loader` | +| Startup sequence and update checks | `window_main.startup.*`, `release_channel`, `spoiler_background_updater` | +| User interface and themes | `theme_manager`, `sound_engine`, `flow_layout`, `flow_widget.*`, `pixel_map_generator`, `card_info_picture_widget` | +| Networking and servers | `local_client`, `remote_client`, `tapped_out_interface`, `servers_settings` | +| In-game logic | `player`, `game_scene.*`, `card_zone.*`, `view_zone`, `game_event_handler` | +| Dialogs and tabs | `dlg_settings`, `dlg_update`, `dlg_tip_of_the_day`, `tab_game`, `tab_message`, `tab_supervisor` | +| Settings and shortcuts | `settings_cache`, `shortcuts_settings` | +| Deck and card filtering | `filter_string`, `deck_filter_string`, `syntax_help` | + +For example, to investigate why a card database update seems to fail, enable the card database categories: + +```ini +[Rules] +*.debug = false + +card_database = true +card_database.loading = true +card_database.loading.success_or_failure = true +card_database.update = true +cockatrice_xml.* = true +``` + +# When you are done + +To turn the diagnostics back off, just reverse what you did: remove the `QT_LOGGING_CONF` environment variable (or +unset it again via `launchctl unsetenv QT_LOGGING_CONF` on macOS) and/or delete the `qtlogging.ini` file, then restart +Cockatrice. Leaving it on is harmless, but the extra logging can make the client slightly slower. + +For the full details on how Cockatrice logging works, including the complete list of categories, see @ref logging. diff --git a/doc/doxygen/pages/user_documentation/troubleshooting/fixing_card_pictures.md b/doc/doxygen/pages/user_documentation/troubleshooting/fixing_card_pictures.md new file mode 100644 index 000000000..7f761c020 --- /dev/null +++ b/doc/doxygen/pages/user_documentation/troubleshooting/fixing_card_pictures.md @@ -0,0 +1,118 @@ +@page fixing_card_pictures Fixing Card Pictures + +This guide collects the common causes for card pictures not showing up, showing the wrong printing, or showing the +card back instead of the artwork, and how to fix them. + +Work through the steps in order. In most cases the problem is caused by an outdated client, misconfigured download +URLs, stale local images, or a corrupted cache. + +# Update Your Client + +Picture handling bugs are fixed on a regular basis, so the first thing to try is updating your client. + +Use the update check in 'Help → Check for Updates' (or look for the update prompt shown on startup). + +If a fix has not yet made it into the latest stable release, it may already be available in the beta release. The beta +ships very frequently and usually receives a follow-up fix within a day or two if something breaks. + +See @subpage beta_release for instructions on how to switch to the beta channel. + +# Check Your Download URLs + +Card pictures are downloaded from a list of URL templates. Each template is tried in order until one produces a valid +image, so the order matters: URLs at the top of the list are tried first. + +The list can be found in 'Cockatrice → Settings' (or Ctrl + Shift + P by default), on the 'Deck Editor' tab, in the +'URL Download Priority' section. Make sure 'Download card pictures on the fly' is enabled and that the list contains +valid URLs. If you suspect the list has been modified or corrupted, press 'Reset Download URLs' to restore the +defaults. + +For information on how to add your own custom URL templates, see the 'How to add a custom URL' link in the same +settings section, or @ref custom_card_pictures for a full reference of the URL reference points, the CUSTOM +pictures folder, and custom card databases. + +# Check Your Local Picture Folder + +Before any network request is made, Cockatrice looks for local image files. If a matching file is found on disk it is +shown instead of anything downloaded, even if it is the wrong image. + +The pictures directory is configured on the 'General' settings tab, under 'Directories' → 'Pictures directory'. +Cockatrice checks the following locations, in order: + +- The custom pictures folder (recursively indexed by file name). +- `//` +- `/downloadedPics//` +- `/downloadedPics/` (for export naming schemes without a set folder) + +A file only matches when its name without the extension equals one of the recognized scheme patterns exactly. + +The following import naming schemes are recognized (using both `_` and `-` as separators). The canonical table with +concrete example file names is on @ref custom_card_pictures: + +| Scheme | Pattern | +| --------------------------- | -------------------------- | +| Card Name + Provider ID | `{name}_{providerId}` | +| Card Name + Set + Collector | `{name}_{set}_{collector}` | +| Set + Collector + Card Name | `{set}_{collector}_{name}` | +| Card Name + Set | `{name}_{set}` | +| Card Name | `{name}` | + +If a picture you downloaded or placed manually is wrong, stale, or corrupted, delete the offending file. Pay special +attention to the `downloadedPics` subfolder: this is where the filesystem caching method writes downloaded images, and +after a provider outage it can permanently contain the wrong printing until you delete it manually. + +If a card persistently shows artwork you assigned yourself, you may have an **image override** set for it. Right-click +the card in the deck editor's printing selector and use 'Image Overrides' → 'Clear Custom Image' to remove it (or +delete the stored `.png` in `downloadedPics/`). See @ref custom_card_pictures for details. + +See @ref loading_card_pictures for details on how local images are loaded. + +# Clear Caches + +Cockatrice caches card pictures in three places. All of them can be managed on the 'Storage' settings tab: + +- **Network cache** — downloaded images stored on disk. Press 'Delete Cached Images' to clear it. +- **Filesystem / image backup** — downloaded images written directly to `downloadedPics`. Press 'Delete Saved Images' + to clear it. +- **In-memory (pixmap) cache** — images currently held in RAM. Press 'Clear In-Memory Images' to clear it. + +If a provider outage caused the wrong pictures to be downloaded and cached, clearing the network cache (and the +'Delete Saved Images' button if you use the filesystem caching method) will force Cockatrice to download the correct +images again. The redirect cache TTL (also on the Storage tab) controls how long previously seen redirects for +download URLs are remembered; lowering it can help if a URL used to redirect somewhere else. + +# Restart the Client + +After updating the client, changing the download URLs, moving or deleting local image files, or clearing caches, it is +recommended to restart Cockatrice so that all changes are fully picked up. + +# Check Logs + +Before changing any settings, check the existing logs first. Rate limit errors and most download errors are already +logged at warn level, so you may find the cause without enabling debug mode. + +Open 'Help → View Debug Log' and look for error or warning messages related to card picture loading. If you need more +detail than the default log level provides, see below. + +# Enable Picture Loader Debug Logs + +If the steps above did not solve the problem, you can turn on a "diagnostics mode" that prints what the picture loader +is actually doing: which URL it is trying, whether it found or missed a file on disk, whether the download succeeded or +failed, and which redirects it followed. This information is extremely useful when asking for help. + +See @subpage enabling_debug_logs for a step-by-step guide on how to enable the logs, including instructions +for Windows, macOS, and Linux. + +# Provider Accuracy + +Cards in Cockatrice are identified by a provider ID, which is the Scryfall UUID of a specific printing. Decks store +this ID, which is why the exact printing a card was added as can be looked up again. + +The Scryfall URL templates built into Cockatrice use this provider ID directly (`!set:uuid!`), so they always download +the exact printing that was requested. + +The Gatherer URL templates, on the other hand, do **not** use the provider ID. They resolve pictures by multiverse ID +(`!set:muid!`) or by card name (`!name!`) only. As a result they may return a different printing than the one the +provider ID refers to, or no picture at all for cards Gatherer does not know. If you need pictures to match the exact +printing of a card, make sure the Scryfall URLs are at the top of your download URL priority list and consider removing +or demoting the Gatherer URLs. diff --git a/format.sh b/format.sh index 83dee9e28..9e3a6069b 100755 --- a/format.sh +++ b/format.sh @@ -11,17 +11,18 @@ set -o pipefail # go to the project root directory, this file should be located in the project root directory olddir="$PWD" cd "${BASH_SOURCE%/*}/" || exit 2 # could not find path, this could happen with special links etc. +phys_root="$(pwd -P)" # defaults include=("cockatrice/src" \ libcockatrice_* \ "oracle/src" \ "servatrice/src" \ +"cmake/pch" \ "tests") exclude=("libcockatrice_rng/libcockatrice/rng/sfmt/" \ "libcockatrice_utility/libcockatrice/utility/peglib.h" \ "oracle/src/lzma/" \ -"oracle/src/qt-json/" \ "oracle/src/zip/" \ "servatrice/src/smtp/") exts=("cpp" "h" "proto") @@ -168,14 +169,14 @@ EOM echo "error in parsing arguments of $0: $1 is an unrecognized option" >&2 exit 2 # input error fi - if [[ ! $1 ]] || next_dir=$(cd "$olddir" && cd -- "$1" && pwd); then + if [[ ! $1 ]] || next_dir=$(cd "$olddir" && cd -- "$1" && pwd -P); then if ! [[ $set_include ]]; then include=() # remove default includes set_include=1 fi if [[ $1 ]]; then - if [[ $next_dir != $PWD/* ]]; then - echo "error in parsing arguments of $0: $next_dir is not in $PWD" >&2 + if [[ "$next_dir" != "$phys_root" && "$next_dir" != "$phys_root"/* ]]; then + echo "error in parsing arguments of $0: $next_dir is not in $phys_root" >&2 exit 2 # input error fi include+=("$next_dir") diff --git a/gcovr.cfg b/gcovr.cfg new file mode 100644 index 000000000..ac893b6a4 --- /dev/null +++ b/gcovr.cfg @@ -0,0 +1,63 @@ +# gcovr configuration for Cockatrice line coverage. +# +# Used by .ci/compile.sh --coverage and by hand: +# +# cmake -S . -B build/cover -DCMAKE_BUILD_TYPE=Debug -DWITH_SERVER=1 -DTEST=1 -DENABLE_COVERAGE=ON +# cmake --build build/cover -j 24 +# ctest --test-dir build/cover -LE bench -j 24 +# gcovr build/cover --root . --object-directory build/cover --txt --decisions +# +# The point of most of this file is subtraction. A raw report is dominated by code no test can +# reach: vendored third-party trees, build directories left over from earlier experiments, and +# the client's main() and window classes, which have no headless test. Those all dilute the one +# number worth watching, so they are filtered out here rather than in every invocation. + +# root is deliberately not set here. This file is normally invoked from the build directory +# (compile.sh runs there, because it configures with `cmake ..`), so a `root` of `.` would +# resolve to the build tree and every filter below would exclude everything. compile.sh passes +# --root explicitly instead. + +# Our own code only. This keeps generated Qt MOC output, protobuf output and anything CMake +# built under the source tree out of the report. +filter = ^cockatrice/ +filter = ^libcockatrice_[^/]+/ +filter = ^servatrice/ +filter = ^oracle/ + +# The tests are the vehicle, not the subject. They are compiled with the same instrumentation, +# so leaving them in would add their near-perfect coverage to the numerator and quietly report a +# healthier project than we have: test code is only ever executed by the run that is measuring +# it. Helpers under tests/ are excluded on the same grounds. +exclude = ^tests/ + +# Vendored third-party code is not ours to cover and is never going to move the number. +exclude = ^vcpkg/ +exclude = ^doc/doxygen/theme/ +exclude = ^libcockatrice_rng/libcockatrice/rng/sfmt/ +exclude = ^libcockatrice_utility/libcockatrice/utility/peglib\.h$ +exclude = ^oracle/src/lzma/ +exclude = ^oracle/src/qt-json/ +exclude = ^oracle/src/zip/ +exclude = ^servatrice/src/smtp/ + +# Qt's own moc/pch/uic output is generated into the build tree, not the source tree, so the +# filters above already keep it out. These cover CLion's directories, which sit in-tree and are +# picked up by the object-directory walk. +exclude = .*cmake-build-.* +exclude = .*\.cache/ + +# Process entry points and the client's window chrome. Each of these needs a display, an +# interactive terminal, or a live database before it does anything a test could observe, so no +# headless run will ever move them. Left in, they contribute thousands of lines at 0% and drag +# the total down by a fixed amount that hides movement in the code we can actually test. The +# window header is excluded with its .cpp because gcovr attributes its inline template code +# there, which is where most of its line count lives. +exclude = ^cockatrice/src/main\.cpp$ +exclude = ^cockatrice/src/interface/window_main\. +exclude = ^oracle/src/main\.cpp$ +exclude = ^servatrice/src/main\.cpp$ + +exclude-throw-branches = yes +exclude-function-lines = yes + +print-summary = yes diff --git a/iwyu.sh b/iwyu.sh new file mode 100755 index 000000000..279200adf --- /dev/null +++ b/iwyu.sh @@ -0,0 +1,226 @@ +#!/bin/bash + +# This script runs include-what-you-use on the given source files or +# directories, applies its suggestions (forward declarations, minimal and +# canonical #includes), then reformats the touched files with clang-format. +# +# Uses include-what-you-use, iwyu_tool.py, fix_includes.py, clang-format and +# python3. Requires a build directory configured with +# -DCMAKE_EXPORT_COMPILE_COMMANDS=ON to provide compile_commands.json. +# +# The Qt 6 mapping (qt6.imp) makes IWYU prefer capital includes like +# #include over #include and keeps forward declarations +# where possible. Regenerate it with tools/generate_qt_mappings.py whenever the +# installed Qt version changes. +# +# Usage: +# ./iwyu.sh [-b ] [-m ] [-j ] [-n] +# +# Options: +# -b, --build-dir DIR build dir with compile_commands.json (default: build) +# -m, --mapping FILE IWYU mapping file (default: qt6.imp) +# -j, --jobs N IWYU parallel jobs (default: 2) +# -n, --dry-run only print IWYU output, do not modify any file + +set -o pipefail + +# go to the project root directory, this file should be located in the project root directory +cd "${BASH_SOURCE%/*}/" || exit 2 # could not find path, this could happen with special links etc. + +# defaults +build_dir="build" +mapping="qt6.imp" +jobs=2 +dry_run=0 + +# 3rd party code that must not be touched by IWYU or clang-format +excludes=("libcockatrice_rng/libcockatrice/rng/sfmt/" \ +"libcockatrice_utility/libcockatrice/utility/peglib.h" \ +"oracle/src/lzma/" \ +"oracle/src/qt-json/" \ +"oracle/src/zip/" \ +"servatrice/src/smtp/") + +usage() { + cat <] [-m ] [-j ] [-n] + +Runs include-what-you-use on the given files/directories, applies the +suggested include changes in place, and reformats touched files. + +Options: + -b, --build-dir DIR build dir with compile_commands.json (default: build) + -m, --mapping FILE IWYU mapping file (default: qt6.imp) + -j, --jobs N IWYU parallel jobs (default: 2) + -n, --dry-run only print IWYU output, do not modify any file + -h, --help show this help +EOM +} + +# parse options +while [[ $* ]]; do + case "$1" in + '-b'|'--build-dir') + build_dir=$2 + shift 2 + ;; + '-m'|'--mapping') + mapping=$2 + shift 2 + ;; + '-j'|'--jobs') + jobs=$2 + shift 2 + ;; + '-n'|'--dry-run') + dry_run=1 + shift + ;; + '-h'|'--help') + usage + exit 0 + ;; + *) + targets+=("$1") + shift + ;; + esac +done + +# find the IWYU tools, preferring PATH and falling back to the LLVM dir +find_tool() { + local tool=$1 + local path + if path=$(command -v "$tool" 2>/dev/null); then + echo "$path" + elif [ -x "/usr/lib/llvm/22/bin/$tool" ]; then + echo "/usr/lib/llvm/22/bin/$tool" + else + return 1 + fi +} + +find_tool include-what-you-use > /dev/null || { echo "error: include-what-you-use not found"; exit 1; } +IWYU_TOOL=$(find_tool iwyu_tool.py) || { echo "error: iwyu_tool.py not found"; exit 1; } +FIX_INCLUDES=$(find_tool fix_includes.py) || { echo "error: fix_includes.py not found"; exit 1; } +command -v clang-format >/dev/null 2>&1 || { echo "error: clang-format not found"; exit 1; } + +if [ "${#targets[@]}" -eq 0 ]; then + echo "error: specify at least one source file or directory" >&2 + usage + exit 1 +fi + +if [ ! -f "$mapping" ]; then + echo "error: mapping file '$mapping' not found" >&2 + exit 1 +fi + +if [ ! -f "$build_dir/compile_commands.json" ]; then + echo "error: '$build_dir/compile_commands.json' not found" >&2 + echo "configure the build with -DCMAKE_EXPORT_COMPILE_COMMANDS=ON first" >&2 + exit 1 +fi + +tmpdir=$(mktemp -d /tmp/iwyu.XXXXXX) +trap 'rm -rf "$tmpdir"' EXIT + +# filter the compilation database: drop excluded 3rd party code and strip the +# GCC-only flags that clang/include-what-you-use rejects +root="$PWD" +python3 - "$build_dir/compile_commands.json" "$root" "${excludes[@]}" <<'PYEOF' > "$tmpdir/compile_commands.json" +import json +import os +import re +import sys + +cc_path, root = sys.argv[1], sys.argv[2] +excludes = sys.argv[3:] +root = os.path.normpath(root) +excl_norm = [os.path.normpath(os.path.join(root, e)) for e in excludes] + +out = [] +for entry in json.load(open(cc_path)): + path = os.path.normpath(entry['file']) + if not path.startswith(root + os.sep): + continue + # skip generated code: protobuf output and moc autogen files + if re.search(r'\.pb\.(h|cc)$', path) or '_autogen/' in path: + continue + if any(path.startswith(e + os.sep) or path == e for e in excl_norm): + continue + command = entry.get('command', '') + command = re.sub(r'-mno-direct-extern-access', '', command) + command = re.sub(r'--coverage', '', command) + out.append(dict(entry, command=command)) + +json.dump(out, sys.stdout) +PYEOF + +mapping_abs="$PWD/$mapping" +if [ "$dry_run" = 1 ]; then + python3 "$IWYU_TOOL" -p "$tmpdir" -o iwyu -j "$jobs" "${targets[@]}" \ + -- -Xiwyu "--mapping_file=$mapping_abs" +else + before=$(git diff --name-only 2>/dev/null | sort -u) + + python3 "$IWYU_TOOL" -p "$tmpdir" -o iwyu -j "$jobs" "${targets[@]}" \ + -- -Xiwyu "--mapping_file=$mapping_abs" | + python3 "$FIX_INCLUDES" --nosafe_headers --nocomments --noreorder --basedir="$root" + + # fix_includes can emit absolute paths for headers that the compiler resolved + # through an absolute -I entry. Those are machine-specific and break every + # other checkout and CI, so rewrite them into the project-relative quoted + # form the codebase uses. Must run before clang-format so the regroup/sort + # pass sees the final paths. + python3 - "$root" <<'PYEOF' +import os +import re +import subprocess +import sys + +root = os.path.normpath(sys.argv[1]) +pattern = re.compile(r'^(\s*#\s*include\s*)"(/)') + +# Collect from git so untracked scratch files are left alone. +changed = subprocess.run( + ['git', 'diff', '--name-only', '--diff-filter=ACMR'], + capture_output=True, text=True, check=True).stdout.split() + +for rel in changed: + if not rel.endswith(('.h', '.cpp', '.cc', '.cxx')): + continue + path = os.path.join(root, rel) + if not os.path.isfile(path): + continue + src = open(path, encoding='utf-8').read() + if '"' + os.sep not in src: + continue + out = [] + for line in src.split('\n'): + m = pattern.match(line) + if not m: + out.append(line) + continue + target = os.path.normpath(line[m.end(1) + 1:].rstrip('"')) + if not target.startswith(root + os.sep): + out.append(line) + continue + spelled = os.path.relpath(target, os.path.dirname(path)) + out.append(f'{m.group(1)}"{spelled}"') + new = '\n'.join(out) + if new != src: + open(path, 'w', encoding='utf-8').write(new) +PYEOF + + # reformat every file this IWYU pass modified (fix_includes also touches + # headers pulled in by the requested files) + touched=$(comm -13 <(printf '%s\n' "$before") <(git diff --name-only 2>/dev/null | sort -u)) + for file in $touched; do + case "$file" in + *.h|*.cpp) + clang-format -i "$file" + ;; + esac + done +fi diff --git a/libcockatrice_card/CMakeLists.txt b/libcockatrice_card/CMakeLists.txt index 7d3d47eea..b3583522c 100644 --- a/libcockatrice_card/CMakeLists.txt +++ b/libcockatrice_card/CMakeLists.txt @@ -5,6 +5,8 @@ set(CMAKE_AUTORCC ON) set(HEADERS libcockatrice/card/card_info.h libcockatrice/card/card_info_comparator.h + libcockatrice/card/card_localization.h + libcockatrice/card/lazy_properties_hash.h libcockatrice/card/database/card_database.h libcockatrice/card/database/card_database_loader.h libcockatrice/card/database/card_database_manager.h @@ -26,6 +28,8 @@ add_library( ${MOC_SOURCES} libcockatrice/card/card_info.cpp libcockatrice/card/card_info_comparator.cpp + libcockatrice/card/card_localization.cpp + libcockatrice/card/lazy_properties_hash.cpp libcockatrice/card/database/card_database.cpp libcockatrice/card/database/card_database_cache.cpp libcockatrice/card/database/card_database_loader.cpp @@ -56,3 +60,5 @@ target_link_libraries( PUBLIC libcockatrice_utility PUBLIC ${QT_CORE_MODULE} ) + +target_precompile_headers(libcockatrice_card PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") diff --git a/libcockatrice_card/libcockatrice/card/card_info.cpp b/libcockatrice_card/libcockatrice/card/card_info.cpp index f03503550..bd33f18e7 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.cpp +++ b/libcockatrice_card/libcockatrice/card/card_info.cpp @@ -1,84 +1,49 @@ #include "card_info.h" #include "game_specific_terms.h" +#include "libcockatrice/card/lazy_properties_hash.h" #include "printing/printing_info.h" #include "relation/card_relation.h" #include "set/card_set.h" -#include -#include -#include -#include +#include #include #include #include -#include #include -class CardRelation; -class CardSet; -class CardInfo; - using CardInfoPtr = QSharedPointer; -namespace -{ -QByteArray serializeProperties(const QHash &props) -{ - QByteArray blob; - QDataStream out(&blob, QIODevice::WriteOnly); - out.setVersion(QDataStream::Qt_6_4); - out << props; - return blob; -} -} // namespace - -void CardInfo::ensurePropertiesLoaded() const -{ - QMutexLocker lock(&propertiesMutex); - if (propertiesLoaded) { - return; - } - if (!propertiesBlob.isEmpty()) { - QDataStream in(propertiesBlob); - in.setVersion(QDataStream::Qt_6_4); - in >> propertiesCache; - } - propertiesLoaded = true; -} - const QHash &CardInfo::getPropertiesHash() const { - ensurePropertiesLoaded(); - return propertiesCache; + return properties.getProperties(); } void CardInfo::setProperty(const QString &_name, const QString &_value) { - ensurePropertiesLoaded(); - if (propertiesCache.value(_name) == _value) { + bool changed = properties.insert(_name, _value); + if (!changed) { return; } - propertiesCache.insert(_name, _value); - propertiesBlob = serializeProperties(propertiesCache); + emit cardInfoChanged(smartThis); } CardInfo::CardInfo(const QString &_name, const QString &_text, bool _isToken, - QHash _properties, + const QHash &_properties, const QList &_relatedCards, const QList &_reverseRelatedCards, SetToPrintingsMap _sets, - const UiAttributes _uiAttributes) - : name(_name), text(_text), isToken(_isToken), relatedCards(_relatedCards), - reverseRelatedCards(_reverseRelatedCards), setsToPrintings(std::move(_sets)), uiAttributes(_uiAttributes) + const UiAttributes _uiAttributes, + QMap _localizedNames, + QMap _localizedTexts) + : name(_name), text(_text), isToken(_isToken), localizedNames(std::move(_localizedNames)), + localizedTexts(std::move(_localizedTexts)), properties(LazyPropertiesHash(_properties)), + relatedCards(_relatedCards), reverseRelatedCards(_reverseRelatedCards), setsToPrintings(std::move(_sets)), + uiAttributes(_uiAttributes) { - propertiesCache = std::move(_properties); - propertiesBlob = serializeProperties(propertiesCache); - propertiesLoaded = true; - simpleName = CardInfo::simplifyName(name); refreshCachedSets(); @@ -87,15 +52,18 @@ CardInfo::CardInfo(const QString &_name, CardInfo::CardInfo(const QString &_name, const QString &_text, bool _isToken, - QByteArray _propertiesBlob, + const QByteArray &_propertiesBlob, const QList &_relatedCards, const QList &_reverseRelatedCards, SetToPrintingsMap _sets, const UiAttributes _uiAttributes, QString _simpleName, - QSet _altNames) + QSet _altNames, + QMap _localizedNames, + QMap _localizedTexts) : name(_name), simpleName(std::move(_simpleName)), text(_text), isToken(_isToken), - propertiesBlob(std::move(_propertiesBlob)), relatedCards(_relatedCards), + localizedNames(std::move(_localizedNames)), localizedTexts(std::move(_localizedTexts)), + properties(LazyPropertiesHash(_propertiesBlob)), relatedCards(_relatedCards), reverseRelatedCards(_reverseRelatedCards), setsToPrintings(std::move(_sets)), uiAttributes(_uiAttributes), altNames(std::move(_altNames)) { @@ -113,14 +81,16 @@ CardInfoPtr CardInfo::newInstance(const QString &_name) CardInfoPtr CardInfo::newInstance(const QString &_name, const QString &_text, bool _isToken, - QHash _properties, + const QHash &_properties, const QList &_relatedCards, const QList &_reverseRelatedCards, SetToPrintingsMap _sets, - const UiAttributes _uiAttributes) + const UiAttributes _uiAttributes, + QMap _localizedNames, + QMap _localizedTexts) { - CardInfoPtr ptr(new CardInfo(_name, _text, _isToken, std::move(_properties), _relatedCards, _reverseRelatedCards, - _sets, _uiAttributes)); + CardInfoPtr ptr(new CardInfo(_name, _text, _isToken, _properties, _relatedCards, _reverseRelatedCards, _sets, + _uiAttributes, std::move(_localizedNames), std::move(_localizedTexts))); ptr->setSmartPointer(ptr); for (const auto &printings : _sets) { @@ -143,11 +113,13 @@ CardInfoPtr CardInfo::newInstance(const QString &_name, const UiAttributes _uiAttributes, QString _simpleName, QSet _altNames, - bool _appendToSets) + bool _appendToSets, + QMap _localizedNames, + QMap _localizedTexts) { CardInfoPtr ptr(new CardInfo(_name, _text, _isToken, std::move(_propertiesBlob), _relatedCards, _reverseRelatedCards, _sets, _uiAttributes, std::move(_simpleName), - std::move(_altNames))); + std::move(_altNames), std::move(_localizedNames), std::move(_localizedTexts))); ptr->setSmartPointer(ptr); if (_appendToSets) { @@ -203,15 +175,13 @@ void CardInfo::addToSet(const CardSetPtr &_set, const PrintingInfo &_info) void CardInfo::combineLegalities(const QHash &props) { - ensurePropertiesLoaded(); QHashIterator it(props); while (it.hasNext()) { it.next(); if (it.key().startsWith("format-")) { - propertiesCache.insert(it.key(), it.value()); + properties.insert(it.key(), it.value()); } } - propertiesBlob = serializeProperties(propertiesCache); emit cardInfoChanged(smartThis); } diff --git a/libcockatrice_card/libcockatrice/card/card_info.h b/libcockatrice_card/libcockatrice/card/card_info.h index a5c208893..356da0546 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.h +++ b/libcockatrice_card/libcockatrice/card/card_info.h @@ -2,17 +2,22 @@ #define CARD_INFO_H #include "format/format_legality_rules.h" +#include "lazy_properties_hash.h" #include "printing/printing_info.h" -#include +#include +#include #include #include #include #include #include -#include +#include +#include #include -#include +#include +#include +#include #include inline Q_LOGGING_CATEGORY(CardInfoLog, "card_info"); @@ -20,7 +25,6 @@ inline Q_LOGGING_CATEGORY(CardInfoLog, "card_info"); class CardInfo; class CardSet; class CardRelation; -class ICardDatabaseParser; typedef QSharedPointer CardInfoPtr; typedef QSharedPointer CardSetPtr; @@ -75,19 +79,11 @@ private: QString simpleName; ///< Simplified name for fuzzy matching. QString text; ///< Text description or rules text of the card. bool isToken; ///< Whether this card is a token or not. - // Properties are stored as a pre-serialized blob (cheap to load) and the - // QHash is materialized on first query, so database load avoids - // constructing thousands of QStrings per card. - mutable QByteArray propertiesBlob; ///< Serialized properties (load form). - mutable QHash propertiesCache; ///< Materialized properties (query form). - mutable bool propertiesLoaded = false; ///< Whether propertiesCache is valid. - mutable QMutex propertiesMutex; ///< Guards lazy materialization. - /** - * @brief Materializes propertiesCache from propertiesBlob if not already done. - * Safe to call from const getters (members are mutable). - */ - void ensurePropertiesLoaded() const; + QMap localizedNames; ///< Localized card names, keyed by language code. + QMap localizedTexts; ///< Localized rules text, keyed by language code. + + LazyPropertiesHash properties; ///< Key-value store of dynamic card properties. QList relatedCards; ///< Forward references to related cards. QList reverseRelatedCards; ///< Cards that refer back to this card. @@ -110,15 +106,19 @@ public: * @param _reverseRelatedCards Backward references to related cards. * @param _sets Map of set names to printing information. * @param _uiAttributes Attributes that affect display and game logic + * @param _localizedNames Localized card names, keyed by language code. + * @param _localizedTexts Localized rules text, keyed by language code. */ explicit CardInfo(const QString &_name, const QString &_text, bool _isToken, - QHash _properties, + const QHash &_properties, const QList &_relatedCards, const QList &_reverseRelatedCards, SetToPrintingsMap _sets, - UiAttributes _uiAttributes); + UiAttributes _uiAttributes, + QMap _localizedNames = {}, + QMap _localizedTexts = {}); /** * @brief Constructs a CardInfo from a cache snapshot with precomputed derived @@ -140,17 +140,21 @@ public: * @param _uiAttributes Attributes that affect display and game logic. * @param _simpleName Precomputed simplified name. * @param _altNames Precomputed alternate names. + * @param _localizedNames Localized card names, keyed by language code. + * @param _localizedTexts Localized rules text, keyed by language code. */ explicit CardInfo(const QString &_name, const QString &_text, bool _isToken, - QByteArray _propertiesBlob, + const QByteArray &_propertiesBlob, const QList &_relatedCards, const QList &_reverseRelatedCards, SetToPrintingsMap _sets, UiAttributes _uiAttributes, QString _simpleName, - QSet _altNames); + QSet _altNames, + QMap _localizedNames = {}, + QMap _localizedTexts = {}); /** * @brief Copy constructor for CardInfo. @@ -161,7 +165,8 @@ public: */ CardInfo(const CardInfo &other) : QObject(other.parent()), name(other.name), simpleName(other.simpleName), text(other.text), - isToken(other.isToken), propertiesBlob(other.propertiesBlob), relatedCards(other.relatedCards), + isToken(other.isToken), localizedNames(other.localizedNames), localizedTexts(other.localizedTexts), + properties(other.properties), relatedCards(other.relatedCards), reverseRelatedCards(other.reverseRelatedCards), reverseRelatedCardsToMe(other.reverseRelatedCardsToMe), setsToPrintings(other.setsToPrintings), uiAttributes(other.uiAttributes), setsNames(other.setsNames), altNames(other.altNames) @@ -189,16 +194,20 @@ public: * @param _reverseRelatedCards Reverse relationships. * @param _sets Printing information per set. * @param _uiAttributes Attributes that affect display and game logic + * @param _localizedNames Localized card names, keyed by language code. + * @param _localizedTexts Localized rules text, keyed by language code. * @return Shared pointer to the new CardInfo instance. */ static CardInfoPtr newInstance(const QString &_name, const QString &_text, bool _isToken, - QHash _properties, + const QHash &_properties, const QList &_relatedCards, const QList &_reverseRelatedCards, SetToPrintingsMap _sets, - UiAttributes _uiAttributes); + UiAttributes _uiAttributes, + QMap _localizedNames = {}, + QMap _localizedTexts = {}); /** * @brief Creates a new instance from a cache snapshot with precomputed @@ -218,6 +227,8 @@ public: * its CardSets. Pass false when building cards in parallel so the * (non-thread-safe) set membership is populated in a later * single-threaded pass. + * @param _localizedNames Localized card names, keyed by language code. + * @param _localizedTexts Localized rules text, keyed by language code. * @return Shared pointer to the new CardInfo instance. */ static CardInfoPtr newInstance(const QString &_name, @@ -230,7 +241,9 @@ public: UiAttributes _uiAttributes, QString _simpleName, QSet _altNames, - bool _appendToSets = true); + bool _appendToSets = true, + QMap _localizedNames = {}, + QMap _localizedTexts = {}); /** * @brief Clones the current CardInfo instance. @@ -280,6 +293,96 @@ public: text = _text; emit cardInfoChanged(smartThis); } + + /** + * @brief Returns the card name in the given language, falling back to the + * English name when no localization is available. + * + * @param lang Language code (e.g. "de", "ja", "zhs"). + * @return The localized name, or the English name as fallback. + */ + [[nodiscard]] const QString &getLocalizedName(const QString &lang) const + { + const auto it = localizedNames.constFind(lang); + return it != localizedNames.constEnd() ? it.value() : name; + } + + /** + * @brief Returns the rules text in the given language, falling back to the + * English text when no localization is available. + * + * @param lang Language code (e.g. "de", "ja", "zhs"). + * @return The localized text, or the English text as fallback. + */ + [[nodiscard]] const QString &getLocalizedText(const QString &lang) const + { + const auto it = localizedTexts.constFind(lang); + return it != localizedTexts.constEnd() ? it.value() : text; + } + + /** + * @brief Returns the localized card names keyed by language code. + * + * Only languages that have an entry are present; there is no English + * fallback in this map. + */ + [[nodiscard]] const QMap &getLocalizedNames() const + { + return localizedNames; + } + + /** + * @brief Returns the localized rules text keyed by language code. + * + * Only languages that have an entry are present; there is no English + * fallback in this map. + */ + [[nodiscard]] const QMap &getLocalizedTexts() const + { + return localizedTexts; + } + + /** + * @brief Sets the card name for the given language. + * + * @param lang Language code. + * @param _localizedName The localized card name. + */ + void setLocalizedName(const QString &lang, const QString &_localizedName) + { + if (localizedNames.value(lang) == _localizedName) { + return; + } + localizedNames.insert(lang, _localizedName); + emit cardInfoChanged(smartThis); + } + + /** + * @brief Sets the rules text for the given language. + * + * @param lang Language code. + * @param _localizedText The localized rules text. + */ + void setLocalizedText(const QString &lang, const QString &_localizedText) + { + if (localizedTexts.value(lang) == _localizedText) { + return; + } + localizedTexts.insert(lang, _localizedText); + emit cardInfoChanged(smartThis); + } + + /** + * @brief Returns the language codes for which this card has a localized + * name or rules text. + */ + [[nodiscard]] QStringList localizationLanguages() const + { + QStringList languages = localizedNames.keys(); + languages.append(localizedTexts.keys()); + languages.removeDuplicates(); + return languages; + } [[nodiscard]] bool getIsToken() const { return isToken; diff --git a/libcockatrice_card/libcockatrice/card/card_info_comparator.cpp b/libcockatrice_card/libcockatrice/card/card_info_comparator.cpp index cc0314e21..d0800e596 100644 --- a/libcockatrice_card/libcockatrice/card/card_info_comparator.cpp +++ b/libcockatrice_card/libcockatrice/card/card_info_comparator.cpp @@ -1,5 +1,11 @@ #include "card_info_comparator.h" +#include "libcockatrice/card/card_info.h" + +#include +#include +#include + CardInfoComparator::CardInfoComparator(const QStringList &properties, Qt::SortOrder order) : m_properties(properties), m_order(order) { diff --git a/libcockatrice_card/libcockatrice/card/card_info_comparator.h b/libcockatrice_card/libcockatrice/card/card_info_comparator.h index b6df8c7a3..b184f5d55 100644 --- a/libcockatrice_card/libcockatrice/card/card_info_comparator.h +++ b/libcockatrice_card/libcockatrice/card/card_info_comparator.h @@ -9,6 +9,9 @@ #include "card_info.h" +#include +#include +#include #include #include diff --git a/libcockatrice_card/libcockatrice/card/card_localization.cpp b/libcockatrice_card/libcockatrice/card/card_localization.cpp new file mode 100644 index 000000000..4f91bac78 --- /dev/null +++ b/libcockatrice_card/libcockatrice/card/card_localization.cpp @@ -0,0 +1,40 @@ +#include "card_localization.h" + +#include +#include +#include +#include +#include + +namespace CardLocalization +{ +const QStringList &supportedLanguages() +{ + static const QStringList languages = {"cs", "de", "es", "fr", "it", "ja", "ko", "pt", "ru", "zhs", "zht", "he"}; + return languages; +} + +QString languageDisplayName(const QString &lang) +{ + static const QHash displayNames = { + {"cs", "Česky (Czech)"}, + {"de", "Deutsch (German)"}, + {"es", "Español (Spanish)"}, + {"fr", "Français (French)"}, + {"it", "Italiano (Italian)"}, + {"ja", "日本語 (Japanese)"}, + {"ko", "한국어 (Korean)"}, + {"pt", "Português (Portuguese)"}, + {"ru", "Русский (Russian)"}, + {"he", "עברית (Hebrew)"}, + {"zhs", "简体中文 (Chinese Simplified)"}, + {"zht", "繁體中文 (Chinese Traditional)"}, + }; + const QString displayName = displayNames.value(lang); + if (!displayName.isEmpty()) { + return displayName; + } + const QString nativeName = QLocale(lang).nativeLanguageName(); + return nativeName.isEmpty() ? lang : nativeName; +} +} // namespace CardLocalization \ No newline at end of file diff --git a/libcockatrice_card/libcockatrice/card/card_localization.h b/libcockatrice_card/libcockatrice/card/card_localization.h new file mode 100644 index 000000000..ee2aaa9b7 --- /dev/null +++ b/libcockatrice_card/libcockatrice/card/card_localization.h @@ -0,0 +1,81 @@ +#ifndef CARD_LOCALIZATION_H +#define CARD_LOCALIZATION_H + +#include +#include +#include +#include + +/** + * @brief The card languages card search should run against. + */ +enum class SearchLanguageMode +{ + English, ///< Only search the English card names and texts. + Selected, ///< Search the selected card language (untranslated cards still match in English). + Both ///< Search both the English and the selected card language names and texts. +}; + +/** + * @brief The card language and matching mode searches run against. + * + * Bundles the card language code configured in the settings with the + * SearchLanguageMode, so entry points take one value instead of two related + * parameters. + */ +struct CardSearchLanguage +{ + QString language; ///< Card language code (e.g. "de"); empty means the English fallback. + SearchLanguageMode mode = SearchLanguageMode::English; ///< How the language participates in the search. + + /** + * @brief Whether only the English card data is searched. + * + * @return True when no card language is selected or English itself is selected. + */ + [[nodiscard]] bool isEnglishOnly() const + { + return language.isEmpty() || language == QLatin1String("en"); + } + + bool operator==(const CardSearchLanguage &) const = default; + bool operator!=(const CardSearchLanguage &) const = default; +}; + +/** + * @namespace CardLocalization + * @ingroup Cards + * + * @brief Shared language metadata for localized card text and images. + * + * Lists the language codes Cockatrice can display localized card data for and + * provides human-readable names. The list is shared between Oracle (which + * imports the selected language's card data) and the client settings UI (which + * offers the language choice). + */ +namespace CardLocalization +{ +/** + * @brief Language codes for which localized card data can be imported/displayed. + * + * Matches the languages Scryfall can serve localized card images for. "en" is + * always available as the default/fallback and is not listed here. + * + * @return The list of supported language codes. + */ +[[nodiscard]] const QStringList &supportedLanguages(); + +/** + * @brief Human-readable name for a language code. + * + * Follows the same "native name (English name)" format the UI language list + * uses (e.g. "日本語 (Japanese)"), so the English fallback is always visible. + * + * @param lang Language code (e.g. "de", "ja", "zhs"). + * @return The language's native name with its English name in parentheses, or + * the code itself if it cannot be resolved. + */ +[[nodiscard]] QString languageDisplayName(const QString &lang); +} // namespace CardLocalization + +#endif // CARD_LOCALIZATION_H \ No newline at end of file diff --git a/libcockatrice_card/libcockatrice/card/database/card_database.cpp b/libcockatrice_card/libcockatrice/card/database/card_database.cpp index 209b9d2c2..469c8d02c 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database.cpp @@ -1,17 +1,27 @@ #include "card_database.h" #include "../relation/card_relation.h" -#include "card_database_manager.h" -#include "parser/cockatrice_xml_4.h" +#include "libcockatrice/card/database/card_database_data.h" +#include "libcockatrice/card/database/card_database_loader.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/database/parser/card_database_parser.h" +#include "libcockatrice/card/format/format_legality_rules.h" +#include "libcockatrice/card/printing/printing_info.h" +#include "libcockatrice/card/set/card_set.h" +#include "libcockatrice/card/set/card_set_list.h" -#include #include -#include -#include -#include -#include +#include +#include +#include +#include +#include #include +class ICardDatabasePathProvider; +class ICardPreferenceProvider; +class ICardSetPriorityController; + CardDatabase::CardDatabase(QObject *parent, ICardPreferenceProvider *prefs, ICardDatabasePathProvider *pathProvider, @@ -23,7 +33,7 @@ CardDatabase::CardDatabase(QObject *parent, qRegisterMetaType("CardDatabaseData"); // create loader and wire it up - loader = new CardDatabaseLoader(this, this, pathProvider, prefs, setPriorityController); + loader = new CardDatabaseLoader(this, this, pathProvider, setPriorityController); // re-emit loader signals (so other code doesn't need to know about internals) connect(loader, &CardDatabaseLoader::loadingFinished, this, &CardDatabase::cardDatabaseLoadingFinished); connect(loader, &CardDatabaseLoader::loadingFailed, this, &CardDatabase::cardDatabaseLoadingFailed); @@ -68,7 +78,9 @@ void CardDatabase::loadCardDatabases() void CardDatabase::reloadCardDatabasesAndNotify() { - loadCardDatabases(); + // An explicit reload bypasses the binary cache so the files on disk are + // always re-read, even when their size/mtime hash still matches the cache. + loadStatus = loader->loadCardDatabases(/* ignoreCache */ true); if (loadStatus == Ok) { checkUnknownSets(); diff --git a/libcockatrice_card/libcockatrice/card/database/card_database.h b/libcockatrice_card/libcockatrice/card/database/card_database.h index b10e3c726..9f0d67b67 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database.h +++ b/libcockatrice_card/libcockatrice/card/database/card_database.h @@ -2,18 +2,26 @@ #define CARDDATABASE_H #include "../set/card_set_list.h" -#include "card_database_data.h" #include "card_database_loader.h" -#include "card_database_querier.h" + +// IWYU pragma: keep +// CardDatabaseData is passed by value to the swapInDatabaseData() slot, and +// CardInfoPtr is passed by value to the cardAdded()/cardRemoved() signals, so +// the moc-generated code needs the complete types. +#include "card_database_data.h" +#include "libcockatrice/card/card_info.h" #include -#include -#include -#include #include -#include -#include -#include +#include +#include +#include +#include + +class CardDatabaseQuerier; +class ICardDatabasePathProvider; +class ICardPreferenceProvider; +class ICardSetPriorityController; inline Q_LOGGING_CATEGORY(CardDatabaseLog, "card_database"); @@ -169,8 +177,12 @@ public slots: void addFormat(const FormatRulesPtr &format); - /** @brief Loads card databases from configured paths. */ + /** @brief Loads card databases from configured paths, using the binary cache when valid. */ void loadCardDatabases(); + /** + * @brief Reloads card databases from disk, bypassing the binary cache, + * and notifies observers of the fresh state. + */ void reloadCardDatabasesAndNotify(); /** @brief Saves custom tokens to file. diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp index 3985889b7..4ab4d1fca 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp @@ -4,20 +4,37 @@ #include "../format/format_legality_rules.h" #include "../printing/printing_info.h" #include "../relation/card_relation.h" -#include "../relation/card_relation_type.h" #include "../set/card_set.h" #include "card_database_loader.h" +#include "libcockatrice/card/database/card_database_data.h" +#include "libcockatrice/card/lazy_properties_hash.h" #include +#include #include +#include +#include #include #include +#include +#include +#include +#include +#include #include +#include +#include +#include +#include + +class ICardSetPriorityController; +enum class CardRelationType; +template class QHash; namespace { constexpr quint32 CACHE_MAGIC = 0x43445243; // "CDRC" -constexpr quint32 CACHE_VERSION = 2; +constexpr quint32 CACHE_VERSION = 3; // ---- Primitives ----------------------------------------------------------- @@ -71,6 +88,35 @@ QDate readDate(QDataStream &in) return d; } +void writeStringMap(QDataStream &out, const QMap &map) +{ + out << static_cast(map.size()); + for (auto it = map.constBegin(); it != map.constEnd(); ++it) { + writeString(out, it.key()); + writeString(out, it.value()); + } +} + +QMap readStringMap(QDataStream &in) +{ + QMap map; + quint32 count = 0; + in >> count; + if (in.status() != QDataStream::Ok) { + return map; + } + for (quint32 i = 0; i < count; ++i) { + QString key = readString(in); + QString value = readString(in); + if (in.status() != QDataStream::Ok) { + map.clear(); + return map; + } + map.insert(key, value); + } + return map; +} + // ---- CardRelation ---------------------------------------------------------- void writeRelation(QDataStream &out, const CardRelation *rel) @@ -119,7 +165,7 @@ PrintingInfo readPrinting(QDataStream &in, const SetNameMap &sets) QByteArray propsBlob = readHashBlob(in); auto set = sets.value(setName); - return PrintingInfo(set, propsBlob); + return PrintingInfo(set, LazyPropertiesHash(propsBlob)); } // ---- CardSet --------------------------------------------------------------- @@ -195,6 +241,10 @@ void writeCard(QDataStream &out, const CardInfoPtr &card) for (const CardRelation *rel : reverse) { writeRelation(out, rel); } + + // localized card data + writeStringMap(out, card->getLocalizedNames()); + writeStringMap(out, card->getLocalizedTexts()); } CardInfoPtr readCard(QDataStream &in, const SetNameMap &sets) @@ -268,8 +318,19 @@ CardInfoPtr readCard(QDataStream &in, const SetNameMap &sets) reverse.append(readRelation(in)); } - return CardInfo::newInstance(name, text, isToken, propertiesBlob, related, reverse, cardSets, ui, simpleName, - altNames, false); + const QMap localizedNames = readStringMap(in); + if (in.status() != QDataStream::Ok) { + return nullptr; + } + const QMap localizedTexts = readStringMap(in); + if (in.status() != QDataStream::Ok) { + return nullptr; + } + + CardInfoPtr card = CardInfo::newInstance(name, text, isToken, propertiesBlob, related, reverse, cardSets, ui, + simpleName, altNames, false, localizedNames, localizedTexts); + + return card; } // ---- FormatRules ----------------------------------------------------------- diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_cache.h b/libcockatrice_card/libcockatrice/card/database/card_database_cache.h index 40ff96541..178b3ac6e 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_cache.h +++ b/libcockatrice_card/libcockatrice/card/database/card_database_cache.h @@ -1,10 +1,11 @@ #ifndef CARDDATABASE_CACHE_H #define CARDDATABASE_CACHE_H -#include "card_database_data.h" +#include -#include -#include +class ICardSetPriorityController; +class QByteArray; +struct CardDatabaseData; namespace CardDatabaseCache { diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp index 47f7eee6d..194083615 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp @@ -2,6 +2,11 @@ #include "card_database.h" #include "card_database_cache.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/database/card_database_data.h" +#include "libcockatrice/card/set/card_set.h" +#include "libcockatrice/interfaces/interface_card_database_path_provider.h" +#include "libcockatrice/interfaces/interface_card_set_priority_controller.h" #include "parser/card_database_parser.h" #include "parser/cockatrice_xml_3.h" #include "parser/cockatrice_xml_4.h" @@ -9,29 +14,37 @@ #include #include #include +#include #include +#include #include #include #include +#include +#include +#include +#include #include +#include +#include + +class ICardPreferenceProvider; CardDatabaseLoader::CardDatabaseLoader(QObject *parent, CardDatabase *db, ICardDatabasePathProvider *_pathProvider, - ICardPreferenceProvider *_preferenceProvider, ICardSetPriorityController *_priorityController) : QObject(parent), database(db), pathProvider(_pathProvider), priorityController(_priorityController) { // instantiate available parsers here - availableParsers << new CockatriceXml4Parser(_preferenceProvider, _priorityController); + availableParsers << new CockatriceXml4Parser(_priorityController); availableParsers << new CockatriceXml3Parser(_priorityController); // The load path parses into a snapshot and never emits per-card signals; // the finished snapshot is swapped into the live database on the GUI thread. // when SettingsCache's path changes, trigger reloads - connect(pathProvider, &ICardDatabasePathProvider::cardDatabasePathChanged, this, - &CardDatabaseLoader::loadCardDatabases); + connect(pathProvider, &ICardDatabasePathProvider::cardDatabasePathChanged, this, [this] { loadCardDatabases(); }); } CardDatabaseLoader::~CardDatabaseLoader() @@ -76,12 +89,12 @@ LoadStatus CardDatabaseLoader::loadCardDatabase(const QString &path, CardDatabas return tempLoadStatus; } -LoadStatus CardDatabaseLoader::loadCardDatabases() +LoadStatus CardDatabaseLoader::loadCardDatabases(bool ignoreCache) { - return doLoadCardDatabases(); + return doLoadCardDatabases(ignoreCache); } -LoadStatus CardDatabaseLoader::doLoadCardDatabases() +LoadStatus CardDatabaseLoader::doLoadCardDatabases(bool ignoreCache) { QMutexLocker locker(reloadDatabaseMutex); @@ -99,9 +112,14 @@ LoadStatus CardDatabaseLoader::doLoadCardDatabases() LoadStatus loadStatus = NotLoaded; // Try the binary cache first: a cache hit avoids re-parsing the (large) XML. + // An explicit reload skips the lookup so the files on disk are always + // re-read, even when their size/mtime hash still matches the cache. const QStringList customPaths = collectCustomDatabasePaths(); const QByteArray sourceHash = computeSourceHash(customPaths); - if (loadFromCache(data, sourceHash)) { + if (ignoreCache) { + qCInfo(CardDatabaseLoadingLog) << "Ignoring binary cache, re-parsing source files"; + } + if (!ignoreCache && loadFromCache(data, sourceHash)) { qCInfo(CardDatabaseLoadingLog) << "Loaded card database from binary cache"; loadStatus = Ok; } else { @@ -166,6 +184,12 @@ QByteArray CardDatabaseLoader::computeSourceHash(const QStringList &customPaths) hash.addData(QCoreApplication::applicationVersion().toUtf8()); hash.addData(QByteArray(1, '\0')); + // Include parser schema version to invalidate caches when parser behavior changes. + // Increment this if the parser logic changes in a way that affects cached data. + static const char SCHEMA_VERSION[] = "carddb-parse-schema-v1"; + hash.addData(QByteArray(SCHEMA_VERSION, sizeof(SCHEMA_VERSION) - 1)); + hash.addData(QByteArray(1, '\0')); + const QStringList inputs = QStringList() << pathProvider->getCardDatabasePath() << pathProvider->getTokenDatabasePath() << pathProvider->getSpoilerCardDatabasePath() << customPaths; @@ -180,6 +204,17 @@ QByteArray CardDatabaseLoader::computeSourceHash(const QStringList &customPaths) hash.addData(QByteArray(1, '\0')); } } + + // The parsers drop printings of disabled sets at parse time, so the cached + // snapshot depends on enablement: fold the enabled set names in so that + // toggling a set invalidates the cache and forces a re-parse. + if (priorityController) { + const QStringList enabledSets = priorityController->getEnabledSetNames(); + for (const QString &setName : enabledSets) { + hash.addData(setName.toUtf8()); + hash.addData(QByteArray(1, '\0')); + } + } return hash.result(); } diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_loader.h b/libcockatrice_card/libcockatrice/card/database/card_database_loader.h index 6dcb06bf2..fffd31d2f 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_loader.h +++ b/libcockatrice_card/libcockatrice/card/database/card_database_loader.h @@ -2,12 +2,21 @@ #define COCKATRICE_CARD_DATABASE_LOADER_H #include +#include #include #include -#include -#include -#include -#include +#include +#include +#include +#include + +class ICardDatabasePathProvider; +class ICardSetPriorityController; + +// IWYU pragma: keep +// CardDatabaseData is passed by value to the databaseDataReady() signal, so the +// moc-generated code needs the complete type. +#include "card_database_data.h" inline Q_LOGGING_CATEGORY(CardDatabaseLoadingLog, "card_database.loading"); inline Q_LOGGING_CATEGORY(CardDatabaseLoadingSuccessOrFailureLog, "card_database.loading.success_or_failure"); @@ -49,12 +58,10 @@ public: * @param parent QObject parent. * @param db Pointer to the CardDatabase to populate (non-owning). * @param pathProvider Provider for card database file paths. - * @param preferenceProvider Optional card preference provider for pinned printings. */ explicit CardDatabaseLoader(QObject *parent, CardDatabase *db, ICardDatabasePathProvider *pathProvider, - ICardPreferenceProvider *preferenceProvider, ICardSetPriorityController *_priorityController); /** @brief Destructor cleans up allocated parsers. */ @@ -66,9 +73,13 @@ public slots: * * Runs synchronously on the calling thread. The caller should ensure * that any signal receivers are already connected before invoking this. + * @param ignoreCache Skip the binary cache lookup and re-parse the source + * files from disk, refreshing the cache afterwards. Used for + * explicit reloads so that edits the source hash cannot see are + * still picked up. * @return Status of the main database load. */ - LoadStatus loadCardDatabases(); + LoadStatus loadCardDatabases(bool ignoreCache = false); /** * @brief Loads a single card database file into the given snapshot. @@ -121,9 +132,10 @@ private: /** * @brief Performs the actual load work synchronously on the calling thread. + * @param ignoreCache Skip the binary cache lookup and re-parse from disk. * @return Status of the main database load. */ - LoadStatus doLoadCardDatabases(); + LoadStatus doLoadCardDatabases(bool ignoreCache); /** * @brief Collects custom card database paths recursively. diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_manager.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_manager.cpp index 614b4a7f8..b06c258fa 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_manager.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database_manager.cpp @@ -1,9 +1,16 @@ #include "card_database_manager.h" +#include "libcockatrice/card/database/card_database.h" + #include #include #include +class CardDatabaseQuerier; +class ICardDatabasePathProvider; +class ICardPreferenceProvider; +class ICardSetPriorityController; + ICardPreferenceProvider *CardDatabaseManager::cardPreferenceProvider = new NoopCardPreferenceProvider(); ICardDatabasePathProvider *CardDatabaseManager::pathProvider = new NoopCardDatabasePathProvider(); ICardSetPriorityController *CardDatabaseManager::setPriorityController = new NoopCardSetPriorityController(); diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_manager.h b/libcockatrice_card/libcockatrice/card/database/card_database_manager.h index c7de66a25..074154a41 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_manager.h +++ b/libcockatrice_card/libcockatrice/card/database/card_database_manager.h @@ -2,7 +2,12 @@ #define CARD_DATABASE_ACCESSOR_H #pragma once -#include "card_database.h" + +class CardDatabase; +class CardDatabaseQuerier; +class ICardDatabasePathProvider; +class ICardPreferenceProvider; +class ICardSetPriorityController; /** * @class CardDatabaseManager diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp index 174943333..2fad5212d 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp @@ -4,14 +4,32 @@ #include "../printing/exact_card.h" #include "../set/card_set_comparator.h" #include "card_database.h" +#include "libcockatrice/card/set/card_set.h" +#include "libcockatrice/interfaces/interface_card_preference_provider.h" +#include "libcockatrice/utility/card_ref.h" -#include +#include +#include +#include +#include +#include CardDatabaseQuerier::CardDatabaseQuerier(QObject *_parent, const CardDatabase *_db, const ICardPreferenceProvider *prefs) : QObject(_parent), db(_db), prefs(prefs) { + // Invalidate the cached count maps whenever the database contents change. + connect(db, &CardDatabase::cardAdded, this, &CardDatabaseQuerier::invalidateCaches); + connect(db, &CardDatabase::cardRemoved, this, &CardDatabaseQuerier::invalidateCaches); + connect(db, &CardDatabase::cardDatabaseReset, this, &CardDatabaseQuerier::invalidateCaches); +} + +void CardDatabaseQuerier::invalidateCaches() +{ + mainCardTypeCountsCache.clear(); + subCardTypeCountsCache.clear(); + formatsCountCache.clear(); } /** @@ -304,44 +322,43 @@ QString CardDatabaseQuerier::getPreferredPrintingProviderId(const QString &cardN QStringList CardDatabaseQuerier::getAllMainCardTypes() const { - QSet types; - for (const auto &card : db->cards.values()) { - types.insert(card->getMainCardType()); - } - return types.values(); + return getAllMainCardTypesWithCount().keys(); } QMap CardDatabaseQuerier::getAllMainCardTypesWithCount() const { - QMap typeCounts; - - for (const auto &card : db->cards.values()) { - QString type = card->getMainCardType(); - typeCounts[type]++; + // An empty cache is always recomputed correctly: a database with no cards + // produces an empty map, so the cache is only ever empty when it needs a + // (trivially cheap) rebuild. + if (mainCardTypeCountsCache.isEmpty()) { + for (const auto &card : db->cards.values()) { + QString type = card->getMainCardType(); + mainCardTypeCountsCache[type]++; + } } - return typeCounts; + return mainCardTypeCountsCache; } QMap CardDatabaseQuerier::getAllSubCardTypesWithCount() const { - QMap typeCounts; + if (subCardTypeCountsCache.isEmpty()) { + for (const auto &card : db->cards.values()) { + QString type = card->getCardType(); - for (const auto &card : db->cards.values()) { - QString type = card->getCardType(); + QStringList parts = type.split(" — "); - QStringList parts = type.split(" — "); + if (parts.size() > 1) { // Ensure there are subtypes + QStringList subtypes = parts[1].split(" ", Qt::SkipEmptyParts); - if (parts.size() > 1) { // Ensure there are subtypes - QStringList subtypes = parts[1].split(" ", Qt::SkipEmptyParts); - - for (const QString &subtype : subtypes) { - typeCounts[subtype]++; + for (const QString &subtype : subtypes) { + subCardTypeCountsCache[subtype]++; + } } } } - return typeCounts; + return subCardTypeCountsCache; } FormatRulesPtr CardDatabaseQuerier::getFormat(const QString &formatName) const @@ -351,18 +368,18 @@ FormatRulesPtr CardDatabaseQuerier::getFormat(const QString &formatName) const QMap CardDatabaseQuerier::getAllFormatsWithCount() const { - QMap formatCounts; + if (formatsCountCache.isEmpty()) { + for (const auto &card : db->cards.values()) { + QStringList allProps = card->getProperties(); - for (const auto &card : db->cards.values()) { - QStringList allProps = card->getProperties(); - - for (const QString &prop : allProps) { - if (prop.startsWith("format-")) { - QString formatName = prop.mid(QStringLiteral("format-").size()); - formatCounts[formatName]++; + for (const QString &prop : allProps) { + if (prop.startsWith("format-")) { + QString formatName = prop.mid(QStringLiteral("format-").size()); + formatsCountCache[formatName]++; + } } } } - return formatCounts; + return formatsCountCache; } diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_querier.h b/libcockatrice_card/libcockatrice/card/database/card_database_querier.h index ff8d7958b..a1409b3cc 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_querier.h +++ b/libcockatrice_card/libcockatrice/card/database/card_database_querier.h @@ -3,12 +3,18 @@ #include "../card_info.h" #include "../printing/exact_card.h" +#include "libcockatrice/card/printing/printing_info.h" +#include +#include #include -#include -#include +#include +#include +#include class CardDatabase; +class ICardPreferenceProvider; +struct CardRef; /** * @class CardDatabaseQuerier @@ -220,6 +226,16 @@ public: private: const CardDatabase *db; //!< Card database used for all lookups. const ICardPreferenceProvider *prefs; //!< Preference provider for preferred printings. + + // Count maps are expensive to compute (they iterate the whole database) and are + // queried every time a filter widget is built, so cache them and invalidate on + // any database mutation. Only the main thread reads or writes these. + mutable QMap mainCardTypeCountsCache; + mutable QMap subCardTypeCountsCache; + mutable QMap formatsCountCache; + +private slots: + void invalidateCaches(); }; #endif // COCKATRICE_CARD_DATABASE_QUERIER_H diff --git a/libcockatrice_card/libcockatrice/card/database/parser/card_database_parser.cpp b/libcockatrice_card/libcockatrice/card/database/parser/card_database_parser.cpp index 00ed9377d..1374533ca 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/card_database_parser.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/card_database_parser.cpp @@ -1,6 +1,11 @@ #include "card_database_parser.h" -#include +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/database/card_database_data.h" + +#include + +class ICardSetPriorityController; SetNameMap ICardDatabaseParser::sets; diff --git a/libcockatrice_card/libcockatrice/card/database/parser/card_database_parser.h b/libcockatrice_card/libcockatrice/card/database/parser/card_database_parser.h index 6d9c91260..b82d9aed7 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/card_database_parser.h +++ b/libcockatrice_card/libcockatrice/card/database/parser/card_database_parser.h @@ -2,10 +2,17 @@ #define CARDDATABASE_PARSER_H #include "../../card_info.h" -#include "../card_database_data.h" +#include "libcockatrice/card/database/parser/card_database_parser.h" +#include "libcockatrice/card/set/card_set.h" -#include +#include +#include #include +#include + +class ICardSetPriorityController; +class QIODevice; +struct CardDatabaseData; #define COCKATRICE_XML_XSI_NAMESPACE "http://www.w3.org/2001/XMLSchema-instance" diff --git a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp index f3aac7809..b17eb8b5e 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp @@ -2,13 +2,33 @@ #include "../../relation/card_relation.h" #include "../../relation/card_relation_type.h" +#include "libcockatrice/card/database/card_database_data.h" +#include "libcockatrice/card/database/parser/card_database_parser.h" +#include "libcockatrice/card/lazy_properties_hash.h" +#include "libcockatrice/card/printing/printing_info.h" +#include "libcockatrice/card/set/card_set.h" +#include #include +#include +#include #include #include +#include +#include +#include +#include +#include +#include +#include #include +#include +#include +#include #include +class ICardSetPriorityController; + #define COCKATRICE_XML3_TAGNAME "cockatrice_carddatabase" #define COCKATRICE_XML3_TAGVER 3 #define COCKATRICE_XML3_SCHEMALOCATION \ @@ -248,7 +268,7 @@ void CockatriceXml3Parser::loadCardsFromXml(QXmlStreamReader &xml) if (attrs.hasAttribute("rarity")) { printingProps.insert("rarity", attrs.value("rarity").toString()); } - PrintingInfo setInfo(set, printingProps); + PrintingInfo setInfo(set, LazyPropertiesHash(printingProps)); _sets[setName].append(setInfo); } // related cards diff --git a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.h b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.h index 83b736257..cb76e960d 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.h +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.h @@ -2,9 +2,14 @@ #define COCKATRICE_XML3_H #include "card_database_parser.h" +#include "libcockatrice/card/card_info.h" #include -#include +#include +#include + +class ICardSetPriorityController; +class QXmlStreamReader; inline Q_LOGGING_CATEGORY(CockatriceXml3Log, "cockatrice_xml.xml_3_parser"); diff --git a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp index 8649bbfaf..4d975e533 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp @@ -1,22 +1,44 @@ #include "cockatrice_xml_4.h" #include "../../relation/card_relation.h" +#include "libcockatrice/card/database/card_database_data.h" +#include "libcockatrice/card/database/parser/card_database_parser.h" +#include "libcockatrice/card/lazy_properties_hash.h" +#include "libcockatrice/card/printing/printing_info.h" +#include "libcockatrice/card/relation/card_relation_type.h" +#include "libcockatrice/card/set/card_set.h" +#include "libcockatrice/interfaces/interface_card_preference_provider.h" #include +#include +#include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include +#include #include +#include +#include #include +class ICardSetPriorityController; + #define COCKATRICE_XML4_TAGNAME "cockatrice_carddatabase" #define COCKATRICE_XML4_TAGVER 4 #define COCKATRICE_XML4_SCHEMALOCATION \ "https://raw.githubusercontent.com/Cockatrice/Cockatrice/master/doc/carddatabase_v4/cards.xsd" -CockatriceXml4Parser::CockatriceXml4Parser(ICardPreferenceProvider *_cardPreferenceProvider, - ICardSetPriorityController *_cardSetPriorityController) - : ICardDatabaseParser(_cardSetPriorityController), cardPreferenceProvider(_cardPreferenceProvider) +CockatriceXml4Parser::CockatriceXml4Parser(ICardSetPriorityController *_cardSetPriorityController) + : ICardDatabaseParser(_cardSetPriorityController) { } @@ -261,7 +283,6 @@ QHash CockatriceXml4Parser::loadCardPropertiesFromXml(QXmlStre void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) { - bool includeRebalancedCards = cardPreferenceProvider->getIncludeRebalancedCards(); while (!xml.atEnd()) { if (xml.readNext() == QXmlStreamReader::EndElement) { break; @@ -273,6 +294,8 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) QString name = QString(""); QString text = QString(""); QHash properties; + QMap localizedNames; + QMap localizedTexts; QList relatedCards, reverseRelatedCards; auto _sets = SetToPrintingsMap(); int tableRow = 0; @@ -298,6 +321,44 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) // generic properties } else if (xmlName == "prop") { properties = loadCardPropertiesFromXml(xml); + // localized card data + } else if (xmlName == "localizations") { + while (!xml.atEnd()) { + if (xml.readNextStartElement()) { + const QString elementName = xml.name().toString(); + if (elementName == "localization") { + const QString lang = xml.attributes().value("lang").toString(); + QString localizedName; + QString localizedText; + while (!xml.atEnd()) { + if (xml.readNext() == QXmlStreamReader::EndElement) { + break; + } + if (xml.isStartElement()) { + const QString childName = xml.name().toString(); + QString value = xml.readElementText(QXmlStreamReader::IncludeChildElements); + if (childName == "name") { + localizedName = value; + } else if (childName == "text") { + localizedText = value; + } + } + } + if (!lang.isEmpty()) { + if (!localizedName.isEmpty()) { + localizedNames.insert(lang, localizedName); + } + if (!localizedText.isEmpty()) { + localizedTexts.insert(lang, localizedText); + } + } + } else { + xml.skipCurrentElement(); + } + } else { + break; + } + } // positioning info } else if (xmlName == "tablerow") { tableRow = xml.readElementText(QXmlStreamReader::IncludeChildElements).toInt(); @@ -322,18 +383,8 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) } printingProps.insert(attrName, attr.value().toString()); } - PrintingInfo printingInfo(set, printingProps); - - // This is very much a hack and not the right place to - // put this check, as it requires a reload of Cockatrice - // to be apply. - // - // However, this is also true of the `set->getEnabled()` - // check above (which is currently bugged as well), so - // we'll fix both at the same time. - if (includeRebalancedCards || printingInfo.getProperty("isRebalanced") != "true") { - _sets[setName].append(printingInfo); - } + PrintingInfo printingInfo(set, LazyPropertiesHash(printingProps)); + _sets[setName].append(printingInfo); } // related cards } else if (xmlName == "related" || xmlName == "reverse-related") { @@ -399,8 +450,9 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) .landscapeOrientation = landscapeOrientation, .tableRow = tableRow, .upsideDownArt = upsideDown}; - CardInfoPtr newCard = CardInfo::newInstance(name, text, isToken, properties, relatedCards, - reverseRelatedCards, _sets, attributes); + CardInfoPtr newCard = + CardInfo::newInstance(name, text, isToken, properties, relatedCards, reverseRelatedCards, _sets, + attributes, std::move(localizedNames), std::move(localizedTexts)); if (targetData) { // Mirror CardDatabase::addCard: if a card with this name already // exists, merge the new printings into it instead of replacing. @@ -517,6 +569,26 @@ static QXmlStreamWriter &operator<<(QXmlStreamWriter &xml, const CardInfoPtr &in } xml.writeEndElement(); + // localized card data + const QStringList localizedLanguages = info->localizationLanguages(); + if (!localizedLanguages.isEmpty()) { + xml.writeStartElement("localizations"); + const QMap &localizedNames = info->getLocalizedNames(); + const QMap &localizedTexts = info->getLocalizedTexts(); + for (const QString &lang : localizedLanguages) { + xml.writeStartElement("localization"); + xml.writeAttribute("lang", lang); + if (localizedNames.contains(lang)) { + xml.writeTextElement("name", localizedNames.value(lang)); + } + if (localizedTexts.contains(lang)) { + xml.writeTextElement("text", localizedTexts.value(lang)); + } + xml.writeEndElement(); + } + xml.writeEndElement(); + } + // sets for (const auto &printings : info->getSets()) { for (const PrintingInfo &set : printings) { diff --git a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.h b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.h index 55f7c5a3b..390282796 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.h +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.h @@ -2,10 +2,15 @@ #define COCKATRICE_XML4_H #include "card_database_parser.h" +#include "libcockatrice/card/card_info.h" +#include #include -#include -#include +#include +#include + +class ICardSetPriorityController; +class QXmlStreamReader; inline Q_LOGGING_CATEGORY(CockatriceXml4Log, "cockatrice_xml.xml_4_parser"); @@ -21,7 +26,6 @@ inline Q_LOGGING_CATEGORY(CockatriceXml4Log, "cockatrice_xml.xml_4_parser"); * @note Differences from v3: * - Card properties are stored in blocks as a QHash. * - Sets can include a element. - * - Supports user preferences via ICardPreferenceProvider (e.g., skipping rebalanced cards). * - Related cards support persistent relations and multiple attach types (e.g., transform). * - More robust serialization; easier to extend schema in the future. */ @@ -29,8 +33,7 @@ class CockatriceXml4Parser : public ICardDatabaseParser { Q_OBJECT public: - explicit CockatriceXml4Parser(ICardPreferenceProvider *cardPreferenceProvider, - ICardSetPriorityController *cardSetPriorityController); + explicit CockatriceXml4Parser(ICardSetPriorityController *cardSetPriorityController); ~CockatriceXml4Parser() override = default; /** @@ -65,8 +68,6 @@ public: const QString &sourceVersion = "unknown") override; private: - ICardPreferenceProvider *cardPreferenceProvider; ///< Interface to handle user preferences - /** * @brief Loads a generic block from a element. * @param xml The open QXmlStreamReader positioned at a element. @@ -77,7 +78,6 @@ private: /** * @brief Load all elements from the XML stream. * @param xml The open QXmlStreamReader positioned at the element. - * Honors the user's preference regarding rebalanced cards. */ void loadCardsFromXml(QXmlStreamReader &xml); diff --git a/libcockatrice_card/libcockatrice/card/format/format_legality_rules.cpp b/libcockatrice_card/libcockatrice/card/format/format_legality_rules.cpp index a6b3bb038..64785759e 100644 --- a/libcockatrice_card/libcockatrice/card/format/format_legality_rules.cpp +++ b/libcockatrice_card/libcockatrice/card/format/format_legality_rules.cpp @@ -1,6 +1,9 @@ #include "format_legality_rules.h" +#include +#include #include +#include bool cardMatchesCondition(const CardInfo &card, const CardCondition &cond) { diff --git a/libcockatrice_card/libcockatrice/card/format/format_legality_rules.h b/libcockatrice_card/libcockatrice/card/format/format_legality_rules.h index 8778ab17e..15ba46728 100644 --- a/libcockatrice_card/libcockatrice/card/format/format_legality_rules.h +++ b/libcockatrice_card/libcockatrice/card/format/format_legality_rules.h @@ -1,11 +1,12 @@ #ifndef COCKATRICE_FORMAT_LEGALITY_RULES_H #define COCKATRICE_FORMAT_LEGALITY_RULES_H -#include +#include #include #include class CardInfo; + using CardInfoPtr = QSharedPointer; struct CardCondition diff --git a/libcockatrice_card/libcockatrice/card/import/card_name_normalizer.cpp b/libcockatrice_card/libcockatrice/card/import/card_name_normalizer.cpp index 91ebd6647..975f7f2d6 100644 --- a/libcockatrice_card/libcockatrice/card/import/card_name_normalizer.cpp +++ b/libcockatrice_card/libcockatrice/card/import/card_name_normalizer.cpp @@ -2,8 +2,14 @@ #include "../database/card_database_manager.h" #include "../printing/exact_card.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include +#include #include +#include +#include +#include /** * @brief Resolves the complete display name of a card. diff --git a/libcockatrice_card/libcockatrice/card/lazy_properties_hash.cpp b/libcockatrice_card/libcockatrice/card/lazy_properties_hash.cpp new file mode 100644 index 000000000..a12de469b --- /dev/null +++ b/libcockatrice_card/libcockatrice/card/lazy_properties_hash.cpp @@ -0,0 +1,97 @@ +#include "lazy_properties_hash.h" + +#include +#include +#include + +LazyPropertiesHash::LazyPropertiesHash() : isMaterialized(true) +{ +} + +LazyPropertiesHash::LazyPropertiesHash(const QByteArray &blob) : blob(blob) +{ +} + +LazyPropertiesHash::LazyPropertiesHash(const QHash &properties) + : properties(properties), isMaterialized(true) +{ +} + +LazyPropertiesHash::LazyPropertiesHash(const LazyPropertiesHash &other) +{ + // since we do not allow dematerialization, we only need to lock if not materialized yet + if (other.isMaterialized) { + blob = other.blob; + properties = other.properties; + isMaterialized = true; + } else { + QMutexLocker lock(&other.propertiesMutex); + blob = other.blob; + properties = other.properties; + isMaterialized = false; + } +} + +LazyPropertiesHash &LazyPropertiesHash::operator=(const LazyPropertiesHash &other) +{ + if (this == &other) { + return *this; + } + + // since we do not allow dematerialization, we only need to lock if not materialized yet + if (other.isMaterialized) { + blob = other.blob; + properties = other.properties; + isMaterialized = true; + } else { + QMutexLocker lock(&other.propertiesMutex); + blob = other.blob; + properties = other.properties; + isMaterialized = false; + } + + return *this; +} + +void LazyPropertiesHash::ensureMaterialized() const +{ + QMutexLocker lock(&propertiesMutex); + + if (isMaterialized) { + return; + } + + if (!blob.isEmpty()) { + QDataStream in(blob); + in.setVersion(QDataStream::Qt_6_4); + in >> properties; + } + + blob.clear(); + + isMaterialized = true; +} + +QString LazyPropertiesHash::value(const QString &key) const +{ + ensureMaterialized(); + return properties.value(key); +} + +bool LazyPropertiesHash::insert(const QString &key, const QString &value) +{ + ensureMaterialized(); + + if (value == properties.value(key)) { + return false; + } + + properties.insert(key, value); + return true; +} + +const QHash &LazyPropertiesHash::getProperties() const +{ + ensureMaterialized(); + return properties; +} diff --git a/libcockatrice_card/libcockatrice/card/lazy_properties_hash.h b/libcockatrice_card/libcockatrice/card/lazy_properties_hash.h new file mode 100644 index 000000000..04f5d0251 --- /dev/null +++ b/libcockatrice_card/libcockatrice/card/lazy_properties_hash.h @@ -0,0 +1,76 @@ +#ifndef COCKATRICE_LAZY_PROPERTIES_HASH_H +#define COCKATRICE_LAZY_PROPERTIES_HASH_H + +#include +#include +#include +#include + +/** + * @brief A property map that can lazily deserialize blobs to avoid loading overhead. + * + * Properties are stored as a pre-serialized blob (cheap to load), and the QString is materialized on the first query, + * so the database load avoids constructing thousands of QString per card. + * + * Once the properties are materialized, it cannot be unmaterialized. + * If you want to reset the properties to an unmaterialized state, you should create a new LazyPropertiesHash. + */ +class LazyPropertiesHash +{ + + mutable QByteArray blob; ///< Serialized properties (load form). + mutable QHash properties; ///< Materialized properties (query form). + mutable QMutex propertiesMutex; ///< Guards lazy materialization. + mutable bool isMaterialized = false; ///< Whether propertiesCache is valid. + + /** + * @brief Materializes properties from blob if not already done. Clears blob afterward. + * Safe to call from const getters (members are mutable). + */ + void ensureMaterialized() const; + +public: + /** + * @brief Default constructor. + */ + LazyPropertiesHash(); + + /** + * @brief Creates an unmaterialized LazyPropertiesHash + * @param blob The pre-serialized blob + */ + explicit LazyPropertiesHash(const QByteArray &blob); + + /** + * @brief Creates an already-materialized LazyPropertiesHash + * @param properties The properties + */ + explicit LazyPropertiesHash(const QHash &properties); + + // Override copy constructor and copy-assignment because mutex isn't copiable + LazyPropertiesHash(const LazyPropertiesHash &other); + LazyPropertiesHash &operator=(const LazyPropertiesHash &other); + + /** + * @brief Gets the value from the materialized properties hash + * @param key The key + * @return The value, or an empty string if the key is not present + */ + QString value(const QString &key) const; + + /** + * @brief Inserts a value into the materialized properties hash + * @param key The key + * @param value The value to insert + * @return True if a new value was inserted; false if the new value is the same as the existing value + */ + bool insert(const QString &key, const QString &value); + + /** + * @brief Gets a view of the materialized properties hash. + * @return The properties hash + */ + const QHash &getProperties() const; +}; + +#endif // COCKATRICE_LAZY_PROPERTIES_HASH_H diff --git a/libcockatrice_card/libcockatrice/card/printing/exact_card.cpp b/libcockatrice_card/libcockatrice/card/printing/exact_card.cpp index 993b5b96e..19e902b27 100644 --- a/libcockatrice_card/libcockatrice/card/printing/exact_card.cpp +++ b/libcockatrice_card/libcockatrice/card/printing/exact_card.cpp @@ -3,6 +3,10 @@ #include "../card_info.h" #include "printing_info.h" +#include +#include +#include + /** * Default constructor. * This will set the CardInfoPtr to null. diff --git a/libcockatrice_card/libcockatrice/card/printing/exact_card.h b/libcockatrice_card/libcockatrice/card/printing/exact_card.h index 74fc75da3..ea49072fb 100644 --- a/libcockatrice_card/libcockatrice/card/printing/exact_card.h +++ b/libcockatrice_card/libcockatrice/card/printing/exact_card.h @@ -2,6 +2,11 @@ #define EXACT_CARD_H #include "../card_info.h" +#include "libcockatrice/card/printing/printing_info.h" + +#include +#include +#include /** * @class ExactCard diff --git a/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp b/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp index 49086f8b7..654efac5e 100644 --- a/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp +++ b/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp @@ -2,40 +2,14 @@ #include "../set/card_set.h" -#include -#include - -PrintingInfo::PrintingInfo(const CardSetPtr &_set, const QHash &_properties) - : set(_set), propertiesCache(_properties), propertiesLoaded(true) +PrintingInfo::PrintingInfo(const CardSetPtr &_set, const LazyPropertiesHash &_properties) + : set(_set), properties(_properties) { } -PrintingInfo::PrintingInfo(const CardSetPtr &_set, const QByteArray &_blob) : set(_set), propertiesBlob(_blob) -{ -} - -void PrintingInfo::ensurePropertiesLoaded() const -{ - QMutexLocker lock(propertiesMutex.data()); - if (propertiesLoaded) { - return; - } - propertiesCache.clear(); - if (!propertiesBlob.isEmpty()) { - QDataStream in(propertiesBlob); - in.setVersion(QDataStream::Qt_6_4); - in >> propertiesCache; - } - propertiesLoaded = true; -} - void PrintingInfo::setProperty(const QString &_name, const QString &_value) { - ensurePropertiesLoaded(); - if (propertiesCache.value(_name) == _value) { - return; - } - propertiesCache.insert(_name, _value); + properties.insert(_name, _value); } /** diff --git a/libcockatrice_card/libcockatrice/card/printing/printing_info.h b/libcockatrice_card/libcockatrice/card/printing/printing_info.h index 70093b686..038960e86 100644 --- a/libcockatrice_card/libcockatrice/card/printing/printing_info.h +++ b/libcockatrice_card/libcockatrice/card/printing/printing_info.h @@ -2,12 +2,14 @@ #define COCKATRICE_PRINTING_INFO_H #include "../set/card_set.h" +#include "libcockatrice/card/lazy_properties_hash.h" +#include #include #include -#include #include -#include +#include +#include class PrintingInfo; @@ -34,15 +36,7 @@ public: * @param _set The set this printing belongs to (defaults to null). * @param _properties The printing properties (defaults to empty) */ - explicit PrintingInfo(const CardSetPtr &_set = nullptr, const QHash &_properties = {}); - - /** - * @brief Constructs a PrintingInfo associated with a specific set. - * - * @param _set The set this printing belongs to (defaults to null). - * @param _blob The serialized properties (as written by the cache writer). - */ - explicit PrintingInfo(const CardSetPtr &_set, const QByteArray &_blob); + explicit PrintingInfo(const CardSetPtr &_set = nullptr, const LazyPropertiesHash &_properties = {}); /** * @brief Destroys the PrintingInfo. @@ -76,18 +70,8 @@ public: } private: - CardSetPtr set; ///< The set this variation belongs to. - - // Properties are stored as a pre-serialized blob (cheap to load) and the - // QHash is materialized on first query. This avoids constructing - // thousands of QStrings per card at database-load time. - mutable QByteArray propertiesBlob; ///< Serialized properties (load form). - mutable QHash propertiesCache; ///< Materialized properties (query form). - mutable bool propertiesLoaded = false; ///< Whether propertiesCache is valid. - mutable QSharedPointer propertiesMutex = - QSharedPointer::create(); ///< Guards lazy materialization. - - void ensurePropertiesLoaded() const; + CardSetPtr set; ///< The set this variation belongs to. + LazyPropertiesHash properties; ///< Key-value store for variation-specific attributes. public: /** @@ -112,8 +96,7 @@ public: [[nodiscard]] const QHash &getPropertiesHash() const { - ensurePropertiesLoaded(); - return propertiesCache; + return properties.getProperties(); } /** @@ -124,7 +107,7 @@ public: */ [[nodiscard]] QString getProperty(const QString &propertyName) const { - return getPropertiesHash().value(propertyName); + return properties.value(propertyName); } /** @@ -150,6 +133,18 @@ public: * @return The flavorName, or empty if it isn't present. */ [[nodiscard]] QString getFlavorName() const; + + /** + * @brief Returns the artist name credited for this printing's artwork. + * + * Requires a card database generated with artist data. + * + * @return The artist name, or empty if it isn't present. + */ + [[nodiscard]] QString getArtist() const + { + return getProperty("artist"); + } }; #endif // COCKATRICE_PRINTING_INFO_H diff --git a/libcockatrice_card/libcockatrice/card/relation/card_relation.cpp b/libcockatrice_card/libcockatrice/card/relation/card_relation.cpp index 8903c892d..410b25682 100644 --- a/libcockatrice_card/libcockatrice/card/relation/card_relation.cpp +++ b/libcockatrice_card/libcockatrice/card/relation/card_relation.cpp @@ -1,7 +1,5 @@ #include "card_relation.h" -#include "card_relation_type.h" - CardRelation::CardRelation(const QString &_name, CardRelationType _attachType, bool _isCreateAllExclusion, diff --git a/libcockatrice_card/libcockatrice/card/relation/card_relation.h b/libcockatrice_card/libcockatrice/card/relation/card_relation.h index a1864f5b2..3a1366f2b 100644 --- a/libcockatrice_card/libcockatrice/card/relation/card_relation.h +++ b/libcockatrice_card/libcockatrice/card/relation/card_relation.h @@ -5,6 +5,7 @@ #include #include +#include /** * @class CardRelation diff --git a/libcockatrice_card/libcockatrice/card/set/card_set.cpp b/libcockatrice_card/libcockatrice/card/set/card_set.cpp index 92060f6c4..e873eeeab 100644 --- a/libcockatrice_card/libcockatrice/card/set/card_set.cpp +++ b/libcockatrice_card/libcockatrice/card/set/card_set.cpp @@ -1,5 +1,7 @@ #include "card_set.h" +#include "libcockatrice/interfaces/interface_card_set_priority_controller.h" + #include #include diff --git a/libcockatrice_card/libcockatrice/card/set/card_set.h b/libcockatrice_card/libcockatrice/card/set/card_set.h index 8e16e01df..538d78852 100644 --- a/libcockatrice_card/libcockatrice/card/set/card_set.h +++ b/libcockatrice_card/libcockatrice/card/set/card_set.h @@ -5,12 +5,14 @@ #include #include #include -#include class CardInfo; +class ICardSetPriorityController; + using CardInfoPtr = QSharedPointer; class CardSet; + using CardSetPtr = QSharedPointer; /** diff --git a/libcockatrice_card/libcockatrice/card/set/card_set_list.cpp b/libcockatrice_card/libcockatrice/card/set/card_set_list.cpp index 8d6aa7365..2e030adab 100644 --- a/libcockatrice_card/libcockatrice/card/set/card_set_list.cpp +++ b/libcockatrice_card/libcockatrice/card/set/card_set_list.cpp @@ -1,5 +1,12 @@ #include "card_set_list.h" +#include "libcockatrice/card/set/card_set.h" + +#include +#include +#include +#include + class CardSetList::KeyCompareFunctor { public: diff --git a/libcockatrice_card/libcockatrice/card/set/card_set_list.h b/libcockatrice_card/libcockatrice/card/set/card_set_list.h index 81d605374..b9c7b2a7a 100644 --- a/libcockatrice_card/libcockatrice/card/set/card_set_list.h +++ b/libcockatrice_card/libcockatrice/card/set/card_set_list.h @@ -4,6 +4,8 @@ #include "card_set.h" #include +#include +#include /** * @class CardSetList diff --git a/libcockatrice_deck_list/CMakeLists.txt b/libcockatrice_deck_list/CMakeLists.txt index 33494b9a9..e61a5702c 100644 --- a/libcockatrice_deck_list/CMakeLists.txt +++ b/libcockatrice_deck_list/CMakeLists.txt @@ -11,6 +11,8 @@ set(HEADERS libcockatrice/deck_list/deck_list_history_manager.h libcockatrice/deck_list/deck_list_node_tree.h libcockatrice/deck_list/deck_list_memento.h + libcockatrice/deck_list/deck_list_plain_text_parser.h + libcockatrice/deck_list/playmat_resolver.h libcockatrice/deck_list/sideboard_plan.h ) @@ -26,6 +28,8 @@ add_library( libcockatrice/deck_list/deck_list.cpp libcockatrice/deck_list/deck_list_history_manager.cpp libcockatrice/deck_list/deck_list_node_tree.cpp + libcockatrice/deck_list/deck_list_plain_text_parser.cpp + libcockatrice/deck_list/playmat_resolver.cpp libcockatrice/deck_list/sideboard_plan.cpp ) @@ -33,4 +37,9 @@ add_dependencies(libcockatrice_deck_list libcockatrice_protocol) target_include_directories(libcockatrice_deck_list PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) -target_link_libraries(libcockatrice_deck_list PUBLIC libcockatrice_protocol libcockatrice_utility ${QT_CORE_MODULE}) +target_link_libraries( + libcockatrice_deck_list PUBLIC libcockatrice_interfaces libcockatrice_protocol libcockatrice_utility + ${QT_CORE_MODULE} +) + +target_precompile_headers(libcockatrice_deck_list PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp index d41713302..821b53dff 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp @@ -1,31 +1,154 @@ #include "deck_list.h" #include "deck_list_memento.h" -#include "tree/abstract_deck_list_node.h" +#include "deck_list_plain_text_parser.h" +#include "libcockatrice/deck_list/deck_list_node_tree.h" +#include "libcockatrice/deck_list/sideboard_plan.h" +#include "libcockatrice/utility/playmat_params.h" #include "tree/deck_list_card_node.h" #include "tree/inner_deck_list_node.h" -#include +#include #include -#include -#include #include +#include +#include #include +#include +#include +#include +#include #include - -#if QT_VERSION < 0x050600 -// qHash on QRegularExpression was added in 5.6, FIX IT -uint qHash(const QRegularExpression &key, uint seed) noexcept -{ - return qHash(key.pattern(), seed); // call qHash on pattern QString instead -} -#endif +#include +#include +#include static const QString CURRENT_SIDEBOARD_PLAN_KEY = ""; +/** + * @brief Parses a floating point XML attribute into a clamped playmat parameter. + * + * Falls back to @p fallback when the attribute is missing or malformed, so + * malformed deck files cannot produce degenerate art rectangles (e.g. a zoom + * of 0 dividing by zero). + * + * @param valueString Raw attribute text. + * @param fallback Value used when the text cannot be parsed. + * @param min Lower clamp bound. + * @param max Upper clamp bound. + * @return The parsed value clamped to [min, max], or @p fallback. + */ +static double parseClampedParam(const QString &valueString, double fallback, double min, double max) +{ + bool ok = false; + const double value = valueString.toDouble(&ok); + if (!ok) { + return fallback; + } + return qBound(min, value, max); +} + +/** + * @brief Reads a `bannerCard` element from the XML stream. + * + * @param xml Reader positioned at the element. + * @return The referenced card. + */ +static CardRef readBannerCard(QXmlStreamReader *xml) +{ + QString providerId = xml->attributes().value("providerId").toString(); + QString cardName = xml->readElementText(); + return {cardName, providerId}; +} + +/** + * @brief Reads a `playmatCard` element from the XML stream. + * + * Attribute values are read before readElementText consumes the element, and + * the params are clamped to the same ranges as the settings dialog and the + * remote player-properties path so malformed deck files cannot produce + * degenerate art rectangles (e.g. a zoom of 0 dividing by zero). + * + * @param xml Reader positioned at the element. + * @return The referenced card plus its clamped positioning parameters. + */ +static PlaymatInfo readPlaymatCard(QXmlStreamReader *xml) +{ + QString providerId = xml->attributes().value("providerId").toString(); + QString marginLStr = xml->attributes().value("marginPctL").toString(); + QString marginRStr = xml->attributes().value("marginPctR").toString(); + QString vOffStr = xml->attributes().value("verticalOffset").toString(); + QString zoomStr = xml->attributes().value("zoom").toString(); + QString cardName = xml->readElementText(); + + return { + .card = {cardName, providerId}, + .params = {.marginPctL = parseClampedParam(marginLStr, 0.07, 0.0, 0.95), + .marginPctR = parseClampedParam(marginRStr, 0.07, 0.0, 0.95), + .verticalOffset = parseClampedParam(vOffStr, 0.33, 0.0, 1.0), + .zoom = parseClampedParam(zoomStr, 1.0, 0.1, 4.0)}, + }; +} + bool DeckList::Metadata::isEmpty() const { - return name.isEmpty() && comments.isEmpty() && bannerCard.isEmpty() && tags.isEmpty(); + return name.isEmpty() && comments.isEmpty() && bannerCard.isEmpty() && tags.isEmpty() && playmat.card.isEmpty(); +} + +bool DeckList::Metadata::readElement(QXmlStreamReader *xml, const QString &childName) +{ + if (childName == "lastLoadedTimestamp") { + lastLoadedTimestamp = xml->readElementText(); + } else if (childName == "deckname") { + name = xml->readElementText(); + } else if (childName == "format") { + gameFormat = xml->readElementText(); + } else if (childName == "comments") { + comments = xml->readElementText(); + } else if (childName == "bannerCard") { + bannerCard = readBannerCard(xml); + } else if (childName == "playmatCard") { + playmat = readPlaymatCard(xml); + } else if (childName == "tags") { + tags.clear(); // Clear existing tags + while (xml->readNextStartElement()) { + if (xml->name().toString() == "tag") { + tags.append(xml->readElementText()); + } + } + } else { + return false; + } + return true; +} + +void DeckList::Metadata::write(QXmlStreamWriter *xml) const +{ + xml->writeTextElement("lastLoadedTimestamp", lastLoadedTimestamp); + xml->writeTextElement("deckname", name); + xml->writeTextElement("format", gameFormat); + xml->writeStartElement("bannerCard"); + xml->writeAttribute("providerId", bannerCard.providerId); + xml->writeCharacters(bannerCard.name); + xml->writeEndElement(); + if (!playmat.card.isEmpty()) { + xml->writeStartElement("playmatCard"); + xml->writeAttribute("providerId", playmat.card.providerId); + xml->writeAttribute("marginPctL", QString::number(playmat.params.marginPctL, 'f', 4)); + xml->writeAttribute("marginPctR", QString::number(playmat.params.marginPctR, 'f', 4)); + xml->writeAttribute("verticalOffset", QString::number(playmat.params.verticalOffset, 'f', 4)); + xml->writeAttribute("zoom", QString::number(playmat.params.zoom, 'f', 4)); + xml->writeCharacters(playmat.card.name); + xml->writeEndElement(); + } + xml->writeTextElement("comments", comments); + + // Write tags + xml->writeStartElement("tags"); + for (const QString &tag : tags) { + xml->writeTextElement("tag", tag); + } + xml->writeEndElement(); } DeckList::DeckList() @@ -62,26 +185,10 @@ bool DeckList::readElement(QXmlStreamReader *xml) { const QString childName = xml->name().toString(); if (xml->isStartElement()) { - if (childName == "lastLoadedTimestamp") { - metadata.lastLoadedTimestamp = xml->readElementText(); - } else if (childName == "deckname") { - metadata.name = xml->readElementText(); - } else if (childName == "format") { - metadata.gameFormat = xml->readElementText(); - } else if (childName == "comments") { - metadata.comments = xml->readElementText(); - } else if (childName == "bannerCard") { - QString providerId = xml->attributes().value("providerId").toString(); - QString cardName = xml->readElementText(); - metadata.bannerCard = {cardName, providerId}; - } else if (childName == "tags") { - metadata.tags.clear(); // Clear existing tags - while (xml->readNextStartElement()) { - if (xml->name().toString() == "tag") { - metadata.tags.append(xml->readElementText()); - } - } - } else if (childName == "zone") { + if (metadata.readElement(xml, childName)) { + return true; + } + if (childName == "zone") { tree.readZoneElement(xml); } else if (childName == "sideboard_plan") { SideboardPlan newSideboardPlan; @@ -95,31 +202,12 @@ bool DeckList::readElement(QXmlStreamReader *xml) return true; } -static void writeMetadata(QXmlStreamWriter *xml, const DeckList::Metadata &metadata) -{ - xml->writeTextElement("lastLoadedTimestamp", metadata.lastLoadedTimestamp); - xml->writeTextElement("deckname", metadata.name); - xml->writeTextElement("format", metadata.gameFormat); - xml->writeStartElement("bannerCard"); - xml->writeAttribute("providerId", metadata.bannerCard.providerId); - xml->writeCharacters(metadata.bannerCard.name); - xml->writeEndElement(); - xml->writeTextElement("comments", metadata.comments); - - // Write tags - xml->writeStartElement("tags"); - for (const QString &tag : metadata.tags) { - xml->writeTextElement("tag", tag); - } - xml->writeEndElement(); -} - void DeckList::write(QXmlStreamWriter *xml) const { xml->writeStartElement("cockatrice_deck"); xml->writeAttribute("version", "1"); - writeMetadata(xml, metadata); + metadata.write(xml); // Write zones tree.write(xml); @@ -132,6 +220,27 @@ void DeckList::write(QXmlStreamWriter *xml) const xml->writeEndElement(); // Close "cockatrice_deck" } +bool DeckList::seekToNextElement(QXmlStreamReader *xml) +{ + while (!xml->atEnd()) { + xml->readNext(); + if (xml->isStartElement()) { + return true; + } + } + return false; +} + +void DeckList::readDeckBody(QXmlStreamReader *xml) +{ + while (!xml->atEnd()) { + xml->readNext(); + if (!readElement(xml)) { + break; + } + } +} + bool DeckList::loadFromXml(QXmlStreamReader *xml) { if (xml->error()) { @@ -140,19 +249,11 @@ bool DeckList::loadFromXml(QXmlStreamReader *xml) } cleanList(); - while (!xml->atEnd()) { - xml->readNext(); - if (xml->isStartElement()) { - if (xml->name().toString() != "cockatrice_deck") { - return false; - } - while (!xml->atEnd()) { - xml->readNext(); - if (!readElement(xml)) { - break; - } - } + while (seekToNextElement(xml)) { + if (xml->name().toString() != "cockatrice_deck") { + return false; } + readDeckBody(xml); } refreshDeckHash(); if (xml->error()) { @@ -208,160 +309,12 @@ bool DeckList::loadFromStream_Plain(QTextStream &in, bool preserveMetadata, const std::function &cardNameNormalizer) { - const QRegularExpression reCardLine(R"(^\s*[\w\[\(\{].*$)", QRegularExpression::UseUnicodePropertiesOption); - const QRegularExpression reEmpty("^\\s*$"); - const QRegularExpression reComment(R"([\w\[\(\{].*$)", QRegularExpression::UseUnicodePropertiesOption); - const QRegularExpression reSBMark("^\\s*sb:\\s*(.+)", QRegularExpression::CaseInsensitiveOption); - const QRegularExpression reSBComment("^sideboard\\b.*$", QRegularExpression::CaseInsensitiveOption); - const QRegularExpression reDeckComment("^((main)?deck(list)?|mainboard)\\b", - QRegularExpression::CaseInsensitiveOption); - - // Regex for advanced card parsing - const QRegularExpression reMultiplier(R"(^[xX\(\[]*(\d+)[xX\*\)\]]* ?(.+))"); - - // Regex for extracting set code and collector number with attached symbols - const QRegularExpression reHyphenFormat(R"(\((\w{3,})\)\s+(\w{3,})-(\d+[^\w\s]*))"); - const QRegularExpression reRegularFormat(R"(\((\w{3,})\)\s+(\d+[^\w\s]*))"); - - cleanList(preserveMetadata); - - auto inputs = in.readAll().trimmed().split('\n'); - auto max_line = inputs.size(); - - // Start at the first empty line before the first card line - auto deckStart = inputs.indexOf(reCardLine); - if (deckStart == -1) { - if (inputs.indexOf(reComment) == -1) { - return false; // Input is empty - } - deckStart = max_line; - } else { - deckStart = inputs.lastIndexOf(reEmpty, deckStart); - if (deckStart == -1) { - deckStart = 0; - } + if (!preserveMetadata) { + metadata = {}; } - - // find sideboard position, if marks are used this won't be needed - int sBStart = -1; - if (inputs.indexOf(reSBMark, deckStart) == -1) { - sBStart = inputs.indexOf(reSBComment, deckStart); - if (sBStart == -1) { - sBStart = inputs.indexOf(reEmpty, deckStart + 1); - if (sBStart == -1) { - sBStart = max_line; - } - auto nextCard = inputs.indexOf(reCardLine, sBStart + 1); - if (inputs.indexOf(reEmpty, nextCard + 1) != -1) { - sBStart = max_line; - } - } - } - - int index = 0; - QRegularExpressionMatch match; - - // Parse name and comments - while (index < deckStart) { - const auto ¤t = inputs.at(index++); - if (!current.contains(reEmpty)) { - match = reComment.match(current); - metadata.name = match.captured(); - break; - } - } - while (index < deckStart) { - const auto ¤t = inputs.at(index++); - if (!current.contains(reEmpty)) { - match = reComment.match(current); - metadata.comments += match.captured() + '\n'; - } - } - metadata.comments.chop(1); - - // Discard empty lines - while (index < max_line && inputs.at(index).contains(reEmpty)) { - ++index; - } - - // Discard line if it starts with deck or mainboard, all cards until the sideboard starts are in the mainboard - if (inputs.at(index).contains(reDeckComment)) { - ++index; - } - - // Parse decklist - for (; index < max_line; ++index) { - // check if line is a card - match = reCardLine.match(inputs.at(index)); - if (!match.hasMatch()) { - continue; - } - - QString cardName = match.captured().simplified(); - bool sideboard = false; - - // Sideboard detection - if (sBStart < 0) { - match = reSBMark.match(cardName); - if (match.hasMatch()) { - sideboard = true; - cardName = match.captured(1); - } - } else { - if (index == sBStart) { - continue; - } - sideboard = index > sBStart; - } - - // Extract set code, collector number, and foil - QString setCode; - QString collectorNumber; - bool isFoil = false; - - // Check for foil status at the end of the card name - if (cardName.endsWith("*F*", Qt::CaseInsensitive)) { - isFoil = true; - cardName.chop(3); // Remove the "*F*" from the card name - } - Q_UNUSED(isFoil); - - // Attempt to match the hyphen-separated format (PLST-2094) - match = reHyphenFormat.match(cardName); - if (match.hasMatch()) { - setCode = match.captured(2).toUpper(); - collectorNumber = match.captured(3); - cardName = cardName.left(match.capturedStart()).trimmed(); - } else { - // Attempt to match the regular format (PLST) 2094 - match = reRegularFormat.match(cardName); - if (match.hasMatch()) { - setCode = match.captured(1).toUpper(); - collectorNumber = match.captured(2); - cardName = cardName.left(match.capturedStart()).trimmed(); - } - } - - // check if a specific amount is mentioned - int amount = 1; - match = reMultiplier.match(cardName); - if (match.hasMatch()) { - amount = match.captured(1).toInt(); - cardName = match.captured(2); - } - - // Normalize the card name - cardName = cardNameNormalizer(cardName); - - // Determine the zone (mainboard/sideboard) - QString zoneName = sideboard ? DECK_ZONE_SIDE : DECK_ZONE_MAIN; - - // make new entry in decklist - tree.addCard(cardName, amount, zoneName, -1, setCode, collectorNumber); - } - + bool ok = DeckListPlainText::parse(in, cardNameNormalizer, metadata, tree); refreshDeckHash(); - return true; + return ok; } bool DeckList::loadFromFile_Plain(QIODevice *device, const std::function &cardNameNormalizer) @@ -373,6 +326,10 @@ bool DeckList::loadFromFile_Plain(QIODevice *device, const std::functiongetName() == DECK_ZONE_MAYBEBOARD) { + return; + } if (prefixSideboardCards && node->getName() == DECK_ZONE_SIDE) { stream << "SB: "; } diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h index e792c85dc..6796e9c33 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h @@ -12,20 +12,24 @@ #include "deck_list_memento.h" #include "deck_list_node_tree.h" +#include "libcockatrice/protocol/pb/move_card_to_zone.pb.h" +#include "libcockatrice/utility/card_ref.h" #include "sideboard_plan.h" #include "tree/inner_deck_list_node.h" +#include #include -#include -#include -#include +#include +#include +#include +#include +#include -class AbstractDecklistNode; class DecklistCardNode; -class CardDatabase; class QIODevice; class QTextStream; -class InnerDecklistNode; +class QXmlStreamReader; +class QXmlStreamWriter; /** * @class DeckList @@ -70,11 +74,29 @@ public: CardRef bannerCard; ///< Optional representative card for the deck. QStringList tags; ///< User-defined tags for deck classification. QString lastLoadedTimestamp; ///< Timestamp string of last load. + PlaymatInfo playmat; ///< Optional playmat background for table+stack zones. /** * @brief Checks if all values (except for lastLoadedTimestamp) in the metadata is empty. */ bool isEmpty() const; + + /** + * @brief Reads a single deck metadata element from a Cockatrice deck XML stream. + * + * @param xml Reader positioned at the element. + * @param childName Name of the current element. + * @return true if a metadata element was consumed, false if @p childName is + * not a metadata element. + */ + bool readElement(QXmlStreamReader *xml, const QString &childName); + + /** + * @brief Writes the deck metadata section of a Cockatrice deck XML file. + * + * @param xml Writer to append the metadata elements to. + */ + void write(QXmlStreamWriter *xml) const; }; private: @@ -88,6 +110,22 @@ private: */ mutable QString cachedDeckHash; + /** @name XML load helpers */ + ///@{ + /** + * @brief Advances to the next element in the XML stream. + * @param xml Reader to advance past non-element tokens. + * @return true when a start element was reached, false at end of stream. + */ + bool seekToNextElement(QXmlStreamReader *xml); + + /** + * @brief Reads the contents of a `cockatrice_deck` element into this deck. + * @param xml Reader positioned at the deck element, stopped at its end. + */ + void readDeckBody(QXmlStreamReader *xml); + ///@} + public: /** @name Metadata setters */ ///@{ @@ -115,6 +153,10 @@ public: { metadata.bannerCard = _bannerCard; } + void setPlaymat(const PlaymatInfo &_playmat = {}) + { + metadata.playmat = _playmat; + } void setLastLoadedTimestamp(const QString &_lastLoadedTimestamp = QString()) { metadata.lastLoadedTimestamp = _lastLoadedTimestamp; @@ -170,6 +212,10 @@ public: { return metadata.bannerCard; } + PlaymatInfo getPlaymat() const + { + return metadata.playmat; + } QString getLastLoadedTimestamp() const { return metadata.lastLoadedTimestamp; diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.cpp index acf4707ab..cba926fd5 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.cpp @@ -1,5 +1,10 @@ #include "deck_list_history_manager.h" +#include "libcockatrice/deck_list/deck_list.h" +#include "libcockatrice/deck_list/deck_list_memento.h" + +#include + void DeckListHistoryManager::save(const DeckListMemento &memento) { undoStack.push(memento); @@ -14,42 +19,32 @@ void DeckListHistoryManager::clear() emit undoRedoStateChanged(); } -void DeckListHistoryManager::undo(DeckList *deck) +void DeckListHistoryManager::restoreAndSwap(QStack &source, + QStack &target, + DeckList *deck) { - if (undoStack.isEmpty()) { + if (source.isEmpty()) { return; } - // Peek at the memento we are going to restore - const DeckListMemento &mementoToRestore = undoStack.top(); + // The reason is read before the source is popped. + const QString reason = source.top().getReason(); - // Save current state for redo - DeckListMemento currentState = deck->createMemento(mementoToRestore.getReason()); - redoStack.push(currentState); + // Save the current state so the opposite direction can return to it. + target.push(deck->createMemento(reason)); - // Pop the last state from undo stack and restore it - DeckListMemento memento = undoStack.pop(); - deck->restoreMemento(memento); + // Apply the state we are moving to. + deck->restoreMemento(source.pop()); emit undoRedoStateChanged(); } +void DeckListHistoryManager::undo(DeckList *deck) +{ + restoreAndSwap(undoStack, redoStack, deck); +} + void DeckListHistoryManager::redo(DeckList *deck) { - if (redoStack.isEmpty()) { - return; - } - - // Peek at the memento we are going to restore - const DeckListMemento &mementoToRestore = redoStack.top(); - - // Save current state for undo - DeckListMemento currentState = deck->createMemento(mementoToRestore.getReason()); - undoStack.push(currentState); - - // Pop the next state from redo stack and restore it - DeckListMemento memento = redoStack.pop(); - deck->restoreMemento(memento); - - emit undoRedoStateChanged(); + restoreAndSwap(redoStack, undoStack, deck); } diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.h index e6bd27e2d..dce9e0af8 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_history_manager.h @@ -1,11 +1,13 @@ #ifndef COCKATRICE_DECK_LIST_HISTORY_MANAGER_H #define COCKATRICE_DECK_LIST_HISTORY_MANAGER_H -#include "deck_list.h" #include "deck_list_memento.h" #include #include +#include + +class DeckList; class DeckListHistoryManager : public QObject { @@ -47,6 +49,13 @@ public: } private: + /** + * @brief Moves one state from @p source to @p target, applying it to @p deck. + * + * Used by both undo (undoStack -> redoStack) and redo (redoStack -> undoStack). + */ + void restoreAndSwap(QStack &source, QStack &target, DeckList *deck); + QStack undoStack; QStack redoStack; }; diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp index 196416cde..5c26030bf 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp @@ -1,9 +1,99 @@ #include "deck_list_node_tree.h" +#include "libcockatrice/deck_list/tree/abstract_deck_list_node.h" +#include "libcockatrice/deck_list/tree/inner_deck_list_node.h" #include "tree/deck_list_card_node.h" +#include +#include #include #include +#include +#include +#include +#include +#include +#include + +static constexpr int MAX_DECK_SIZE = 1e5; + +namespace +{ + +/** + * @brief Expands card nodes into one lowercase name entry per copy. + * + * @param nodes The card nodes to expand. + * @param prefix Optional prefix prepended to every entry (e.g. "SB:" for the sideboard). + * @return One entry per copy, in node order. + */ +QStringList cardNodesToCopies(const QList &nodes, const QString &prefix = {}) +{ + QStringList result; + for (auto node : nodes) { + for (int i = 0; i < node->getNumber(); ++i) { + result.append(prefix + node->getName().toLower()); + } + } + return result; +} + +/** + * @brief Packs the first five bytes of a SHA-1 digest into a compact base-32 number. + * + * The bytes are placed in most-significant-byte-first order (byte 0 shifted by 32, + * byte 4 unshifted) so decks that only differ in their low-order hash bytes still + * produce distinct identifiers. + * + * @return The 8-character base-32 representation of the packed number. + */ +QString encodeDeckHash(const QByteArray &digest) +{ + quint64 number = 0; + for (int i = 0; i < 5; ++i) { + number |= static_cast(static_cast(digest[i])) << (32 - 8 * i); + } + return QString::number(number, 32).rightJustified(8, '0'); +} + +/** + * @brief Collects every card node in @p node's subtree, in tree order. + * + * @return The collected card nodes. + */ +QList collectCardsRecursive(const InnerDecklistNode *node) +{ + QList result; + for (int i = 0; i < node->size(); i++) { + if (auto *card = dynamic_cast(node->at(i))) { + result.append(card); + } else if (auto *inner = dynamic_cast(node->at(i))) { + result.append(collectCardsRecursive(inner)); + } + } + return result; +} + +/** + * @brief Invokes @p func on every card in @p node's subtree. + * + * Cards nested in custom zones are reported with their top-level @p boardZone + * so that callers can classify cards by board (main/side/maybeboard/tokens). + */ +void forEachCardInNode(InnerDecklistNode *boardZone, + InnerDecklistNode *node, + const std::function &func) +{ + for (int i = 0; i < node->size(); i++) { + if (auto *card = dynamic_cast(node->at(i))) { + func(boardZone, card); + } else if (auto *inner = dynamic_cast(node->at(i))) { + forEachCardInNode(boardZone, inner, func); + } + } +} + +} // namespace DecklistNodeTree::DecklistNodeTree() : root(new InnerDecklistNode()) { @@ -42,12 +132,7 @@ QList DecklistNodeTree::getCardNodes(const QSet result; for (auto *zoneNode : getZoneNodes(restrictToZones)) { - for (auto *cardNode : *zoneNode) { - auto *cardCardNode = dynamic_cast(cardNode); - if (cardCardNode) { - result.append(cardCardNode); - } - } + result.append(collectCardsRecursive(zoneNode)); } return result; @@ -75,25 +160,11 @@ QString DecklistNodeTree::computeDeckHash() const auto mainDeckNodes = getCardNodes({DECK_ZONE_MAIN}); auto sideDeckNodes = getCardNodes({DECK_ZONE_SIDE}); - static auto nodesToCardList = [](const QList &nodes, const QString &prefix = {}) { - QStringList result; - for (auto node : nodes) { - for (int i = 0; i < node->getNumber(); ++i) { - result.append(prefix + node->getName().toLower()); - } - } - return result; - }; - - QStringList cardList = nodesToCardList(mainDeckNodes) + nodesToCardList(sideDeckNodes, "SB:"); + QStringList cardList = cardNodesToCopies(mainDeckNodes) + cardNodesToCopies(sideDeckNodes, "SB:"); cardList.sort(); QByteArray deckHashArray = QCryptographicHash::hash(cardList.join(";").toUtf8(), QCryptographicHash::Sha1); - quint64 number = (((quint64)(unsigned char)deckHashArray[0]) << 32) + - (((quint64)(unsigned char)deckHashArray[1]) << 24) + - (((quint64)(unsigned char)deckHashArray[2] << 16)) + - (((quint64)(unsigned char)deckHashArray[3]) << 8) + (quint64)(unsigned char)deckHashArray[4]; - return QString::number(number, 32).rightJustified(8, '0'); + return encodeDeckHash(deckHashArray); } void DecklistNodeTree::write(QXmlStreamWriter *xml) const @@ -107,7 +178,7 @@ void DecklistNodeTree::readZoneElement(QXmlStreamReader *xml) { QString zoneName = xml->attributes().value("name").toString(); InnerDecklistNode *newZone = getZoneObjFromName(zoneName); - newZone->readElement(xml); + totalCards += newZone->readElement(xml, MAX_DECK_SIZE - totalCards); } DecklistCardNode *DecklistNodeTree::addCard(const QString &cardName, @@ -119,6 +190,8 @@ DecklistCardNode *DecklistNodeTree::addCard(const QString &cardName, const QString &cardProviderId, const bool formatLegal) { + amount = qMin(amount, MAX_DECK_SIZE - totalCards); + totalCards += amount; auto *zoneNode = getZoneObjFromName(zoneName); auto *node = new DecklistCardNode(cardName, amount, zoneNode, position, cardSetName, cardSetCollectorNumber, cardProviderId, formatLegal); @@ -138,11 +211,7 @@ bool DecklistNodeTree::deleteNode(AbstractDecklistNode *node, InnerDecklistNode int index = rootNode->indexOf(node); if (index != -1) { delete rootNode->takeAt(index); - - if (rootNode->empty()) { - deleteNode(rootNode, rootNode->getParent()); - } - + pruneEmptyBoardZone(rootNode); return true; } @@ -158,30 +227,175 @@ bool DecklistNodeTree::deleteNode(AbstractDecklistNode *node, InnerDecklistNode return false; } +void DecklistNodeTree::pruneEmptyBoardZone(InnerDecklistNode *container) +{ + if (container->isEmpty() && container->getParent() == root) { + deleteNode(container, container->getParent()); + } +} + void DecklistNodeTree::forEachCard(const std::function &func) const { - // Support for this is only possible if the internal structure - // doesn't get more complicated. for (int i = 0; i < root->size(); i++) { - InnerDecklistNode *node = dynamic_cast(root->at(i)); - for (int j = 0; j < node->size(); j++) { - DecklistCardNode *card = dynamic_cast(node->at(j)); - func(node, card); + if (auto *zone = dynamic_cast(root->at(i))) { + forEachCardInNode(zone, zone, func); } } } /** * Gets the InnerDecklistNode that is the root node for the given zone, creating a new node if it doesn't exist. + * + * Top-level zones take precedence, then deck-unique custom zones nested under boards + * are resolved. Unknown names create a new top-level zone (legacy behavior). */ -InnerDecklistNode *DecklistNodeTree::getZoneObjFromName(const QString &zoneName) const +InnerDecklistNode *DecklistNodeTree::getZoneObjFromName(const QString &zoneName) { for (int i = 0; i < root->size(); i++) { auto *node = dynamic_cast(root->at(i)); - if (node->getName() == zoneName) { + if (node && node->getName() == zoneName) { return node; } } + if (auto *customZone = findCustomZoneByName(zoneName)) { + return customZone; + } + return new InnerDecklistNode(zoneName, root); } + +InnerDecklistNode *DecklistNodeTree::findBoardZone(const QString &boardZoneName) const +{ + return dynamic_cast(root->findChild(boardZoneName)); +} + +InnerDecklistNode *DecklistNodeTree::findOrCreateBoardZone(const QString &boardZoneName) +{ + auto *boardZone = findBoardZone(boardZoneName); + if (!boardZone && + (boardZoneName == DECK_ZONE_MAYBEBOARD || boardZoneName == DECK_ZONE_MAIN || boardZoneName == DECK_ZONE_SIDE)) { + // The boards are lazy zones: they only exist once cards or custom zones need them. + boardZone = new InnerDecklistNode(boardZoneName, root); + } + return boardZone; +} + +InnerDecklistNode *DecklistNodeTree::addCustomZone(const QString &boardZoneName, const QString &zoneName) +{ + if (hasZoneName(zoneName)) { + return nullptr; + } + + auto *boardZone = findOrCreateBoardZone(boardZoneName); + + if (!boardZone) { + return nullptr; + } + + return new InnerDecklistNode(zoneName, boardZone); +} + +bool DecklistNodeTree::renameCustomZone(const QString &oldZoneName, const QString &newZoneName) +{ + if (hasZoneName(newZoneName)) { + return false; + } + + auto *zone = findCustomZoneByName(oldZoneName); + if (!zone) { + return false; + } + + zone->setName(newZoneName); + return true; +} + +bool DecklistNodeTree::moveCustomZone(const QString &zoneName, const QString &newBoardZoneName) +{ + auto *zone = findCustomZoneByName(zoneName); + if (!zone) { + return false; + } + + auto *currentBoardZone = zone->getParent(); + if (currentBoardZone && currentBoardZone->getName() == newBoardZoneName) { + return true; + } + + auto *newBoardZone = findOrCreateBoardZone(newBoardZoneName); + if (!newBoardZone) { + return false; + } + + currentBoardZone->removeOne(zone); + newBoardZone->append(zone); + zone->setParent(newBoardZone); + return true; +} + +bool DecklistNodeTree::removeCustomZone(const QString &zoneName) +{ + auto *zone = findCustomZoneByName(zoneName); + if (!zone) { + return false; + } + + // Detach and delete without pruning the board zone. + auto *boardZone = zone->getParent(); + boardZone->removeOne(zone); + delete zone; + return true; +} + +QList DecklistNodeTree::getCustomZones(const QString &boardZoneName) const +{ + QList result; + + auto *boardZone = findBoardZone(boardZoneName); + if (!boardZone) { + return result; + } + + for (int i = 0; i < boardZone->size(); i++) { + if (auto *customZone = dynamic_cast(boardZone->at(i))) { + result.append(customZone); + } + } + + return result; +} + +InnerDecklistNode *DecklistNodeTree::findCustomZoneByName(const QString &zoneName) const +{ + for (int i = 0; i < root->size(); i++) { + auto *boardZone = dynamic_cast(root->at(i)); + if (!boardZone) { + continue; + } + + for (int j = 0; j < boardZone->size(); j++) { + auto *customZone = dynamic_cast(boardZone->at(j)); + if (customZone && customZone->getName() == zoneName) { + return customZone; + } + } + } + + return nullptr; +} + +bool DecklistNodeTree::hasZoneName(const QString &zoneName) const +{ + // The standard zones are reserved names even before they are created lazily. + if (zoneName == DECK_ZONE_MAIN || zoneName == DECK_ZONE_SIDE || zoneName == DECK_ZONE_MAYBEBOARD || + zoneName == DECK_ZONE_TOKENS) { + return true; + } + + if (root->findChild(zoneName)) { + return true; + } + + return findCustomZoneByName(zoneName) != nullptr; +} diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h index 8ef0b18a5..08b4b0d73 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.h @@ -1,15 +1,22 @@ #ifndef COCKATRICE_DECKLIST_NODE_TREE_H #define COCKATRICE_DECKLIST_NODE_TREE_H -#include "libcockatrice/utility/card_ref.h" #include "tree/deck_list_card_node.h" #include "tree/inner_deck_list_node.h" +#include #include +#include +#include + +class AbstractDecklistNode; +class QXmlStreamReader; +class QXmlStreamWriter; class DecklistNodeTree { InnerDecklistNode *root; ///< Root of the deck tree (zones + cards). + int totalCards = 0; public: /** @brief Constructs an empty DecklistNodeTree. */ @@ -78,15 +85,82 @@ public: bool deleteNode(AbstractDecklistNode *node, InnerDecklistNode *rootNode = nullptr); /** - * @brief Apply a function to every card in the deck tree. This can modify the cards. + * @brief Creates a new custom zone nested under a board zone. * - * @param func Function taking (zone node, card node). + * Custom zone names must be unique across the whole deck so that cards can be + * added to a custom zone without specifying its board zone. + * + * @param boardZoneName Name of the board zone (e.g. DECK_ZONE_MAIN). + * @param zoneName Name of the custom zone. + * @return The created zone node, or nullptr if the name is already in use. + */ + InnerDecklistNode *addCustomZone(const QString &boardZoneName, const QString &zoneName); + + /** + * @brief Renames a custom zone. + * @return true on success, false if the zone was not found or the new name is taken. + */ + bool renameCustomZone(const QString &oldZoneName, const QString &newZoneName); + + /** + * @brief Moves a custom zone (and all its cards) to another board zone. + * @return true on success, false if the zone or the new board zone was not found. + */ + bool moveCustomZone(const QString &zoneName, const QString &newBoardZoneName); + + /** + * @brief Removes a custom zone and all its cards. + * @return true if the zone was found and removed. + */ + bool removeCustomZone(const QString &zoneName); + + /** + * @brief Gets all custom zones nested under a board zone. + * @param boardZoneName Name of the board zone. + * @return The custom zones, in insertion order. + */ + QList getCustomZones(const QString &boardZoneName) const; + + /** + * @brief Checks whether a zone name is taken anywhere in the deck. + * + * Covers the standard board names and any top-level or nested custom zone. + * @param zoneName The checked name. + * @return true if the name is reserved or already in use. + */ + bool hasZoneName(const QString &zoneName) const; + + /** + * @brief Finds a custom zone anywhere in the deck by name. + * + * Walks the children of every top-level zone, so a zone nested under any + * board (and not just the standard ones) is found. + * @param zoneName The zone name to find. + * @return The matching zone node, or nullptr if none exists. + */ + InnerDecklistNode *findCustomZoneByName(const QString &zoneName) const; + + /** + * @brief Applies a function to every card in the deck tree. This can modify the cards. + * + * @param func Function taking (top-level board zone node, card node). Cards nested + * in custom zones are reported with their board zone. */ void forEachCard(const std::function &func) const; private: // Helpers for traversing the tree - InnerDecklistNode *getZoneObjFromName(const QString &zoneName) const; + InnerDecklistNode *getZoneObjFromName(const QString &zoneName); + InnerDecklistNode *findBoardZone(const QString &boardZoneName) const; + InnerDecklistNode *findOrCreateBoardZone(const QString &boardZoneName); + + /** + * @brief Recursively removes @p container when it is an empty board zone. + * + * Empty custom zones are kept while empty board zones get pruned, so a + * board zone disappears once its last card or custom zone goes away. + */ + void pruneEmptyBoardZone(InnerDecklistNode *container); }; #endif // COCKATRICE_DECKLIST_NODE_TREE_H diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.cpp new file mode 100644 index 000000000..61af36540 --- /dev/null +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.cpp @@ -0,0 +1,179 @@ +#include "deck_list_plain_text_parser.h" + +#include "deck_list_node_tree.h" +#include "libcockatrice/deck_list/deck_list.h" +#include "tree/inner_deck_list_node.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace DeckListPlainText +{ + +bool parse(QTextStream &in, + const std::function &cardNameNormalizer, + DeckList::Metadata &metadata, + DecklistNodeTree &tree) +{ + tree.clear(); + + static const QRegularExpression reCardLine(R"(^\s*[\w\[\(\{].*$)", QRegularExpression::UseUnicodePropertiesOption); + static const QRegularExpression reEmpty("^\\s*$"); + static const QRegularExpression reComment(R"([\w\[\(\{].*$)", QRegularExpression::UseUnicodePropertiesOption); + static const QRegularExpression reSBMark("^\\s*sb:\\s*(.+)", QRegularExpression::CaseInsensitiveOption); + static const QRegularExpression reSBComment("^sideboard\\b.*$", QRegularExpression::CaseInsensitiveOption); + static const QRegularExpression reDeckComment("^((main)?deck(list)?|mainboard)\\b", + QRegularExpression::CaseInsensitiveOption); + + // Regex for advanced card parsing + static const QRegularExpression reMultiplier(R"(^[xX\(\[]*(\d+)[xX\*\)\]]* ?(.+))"); + + // Regex for extracting set code and collector number with attached symbols + static const QRegularExpression reHyphenFormat(R"(\((\w{3,})\)\s+(\w{3,})-(\d+[^\w\s]*))"); + static const QRegularExpression reRegularFormat(R"(\((\w{3,})\)\s+(\d+[^\w\s]*))"); + + auto inputs = in.readAll().trimmed().split('\n'); + auto max_line = inputs.size(); + + // Start at the first empty line before the first card line + auto deckStart = inputs.indexOf(reCardLine); + if (deckStart == -1) { + if (inputs.indexOf(reComment) == -1) { + return false; // Input is empty + } + deckStart = max_line; + } else { + deckStart = inputs.lastIndexOf(reEmpty, deckStart); + if (deckStart == -1) { + deckStart = 0; + } + } + + // find sideboard position, if marks are used this won't be needed + int sBStart = -1; + if (inputs.indexOf(reSBMark, deckStart) == -1) { + sBStart = inputs.indexOf(reSBComment, deckStart); + if (sBStart == -1) { + sBStart = inputs.indexOf(reEmpty, deckStart + 1); + if (sBStart == -1) { + sBStart = max_line; + } + auto nextCard = inputs.indexOf(reCardLine, sBStart + 1); + if (inputs.indexOf(reEmpty, nextCard + 1) != -1) { + sBStart = max_line; + } + } + } + + int index = 0; + QRegularExpressionMatch match; + + // Parse name and comments + while (index < deckStart) { + const auto ¤t = inputs.at(index++); + if (!current.contains(reEmpty)) { + match = reComment.match(current); + metadata.name = match.captured(); + break; + } + } + while (index < deckStart) { + const auto ¤t = inputs.at(index++); + if (!current.contains(reEmpty)) { + match = reComment.match(current); + metadata.comments += match.captured() + '\n'; + } + } + metadata.comments.chop(1); + + // Discard empty lines + while (index < max_line && inputs.at(index).contains(reEmpty)) { + ++index; + } + + // Discard line if it starts with deck or mainboard, all cards until the sideboard starts are in the mainboard + if (inputs.at(index).contains(reDeckComment)) { + ++index; + } + + // Parse decklist + for (; index < max_line; ++index) { + // check if line is a card + match = reCardLine.match(inputs.at(index)); + if (!match.hasMatch()) { + continue; + } + + QString cardName = match.captured().simplified(); + bool sideboard = false; + + // Sideboard detection + if (sBStart < 0) { + match = reSBMark.match(cardName); + if (match.hasMatch()) { + sideboard = true; + cardName = match.captured(1); + } + } else { + if (index == sBStart) { + continue; + } + sideboard = index > sBStart; + } + + // Extract set code, collector number, and foil + QString setCode; + QString collectorNumber; + bool isFoil = false; + + // Check for foil status at the end of the card name + if (cardName.endsWith("*F*", Qt::CaseInsensitive)) { + isFoil = true; + cardName.chop(3); // Remove the "*F*" from the card name + } + Q_UNUSED(isFoil); + + // Attempt to match the hyphen-separated format (PLST-2094) + match = reHyphenFormat.match(cardName); + if (match.hasMatch()) { + setCode = match.captured(2).toUpper(); + collectorNumber = match.captured(3); + cardName = cardName.left(match.capturedStart()).trimmed(); + } else { + // Attempt to match the regular format (PLST) 2094 + match = reRegularFormat.match(cardName); + if (match.hasMatch()) { + setCode = match.captured(1).toUpper(); + collectorNumber = match.captured(2); + cardName = cardName.left(match.capturedStart()).trimmed(); + } + } + + // check if a specific amount is mentioned + int amount = 1; + match = reMultiplier.match(cardName); + if (match.hasMatch()) { + amount = match.captured(1).toInt(); + cardName = match.captured(2); + } + + // Normalize the card name + cardName = cardNameNormalizer(cardName); + + // Determine the zone (mainboard/sideboard) + QString zoneName = sideboard ? DECK_ZONE_SIDE : DECK_ZONE_MAIN; + + // make new entry in decklist + tree.addCard(cardName, amount, zoneName, -1, setCode, collectorNumber); + } + + return true; +} + +} // namespace DeckListPlainText \ No newline at end of file diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.h b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.h new file mode 100644 index 000000000..0845f7935 --- /dev/null +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.h @@ -0,0 +1,34 @@ +#ifndef COCKATRICE_DECK_LIST_PLAIN_TEXT_PARSER_H +#define COCKATRICE_DECK_LIST_PLAIN_TEXT_PARSER_H + +#include "deck_list.h" + +#include + +class QTextStream; +class DecklistNodeTree; +class QString; + +namespace DeckListPlainText +{ + +/** + * @brief Parses a plain-text deck list into a tree and its metadata. + * + * Clears the tree first, then fills both from the text. + * + * @param in The text to load + * @param cardNameNormalizer Function that takes the parsed card name string + * in the text and returns the name to store + * @param metadata Deck metadata written by the parser + * @param tree Deck tree the parser adds cards to + * @return False if the input was empty, true otherwise. + */ +bool parse(QTextStream &in, + const std::function &cardNameNormalizer, + DeckList::Metadata &metadata, + DecklistNodeTree &tree); + +} // namespace DeckListPlainText + +#endif // COCKATRICE_DECK_LIST_PLAIN_TEXT_PARSER_H \ No newline at end of file diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/playmat_resolver.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/playmat_resolver.cpp new file mode 100644 index 000000000..d1d44cca5 --- /dev/null +++ b/libcockatrice_deck_list/libcockatrice/deck_list/playmat_resolver.cpp @@ -0,0 +1,60 @@ +#include "playmat_resolver.h" + +#include "libcockatrice/deck_list/deck_list.h" +#include "libcockatrice/interfaces/interface_interface_settings_provider.h" +#include "libcockatrice/utility/card_ref.h" + +#include +#include +#include + +PlaymatInfo resolveEffectivePlaymat(const DeckList &deck, + const PlaymatInfo &force, + const QList &fallbackList, + PlaymatFallbackMode fallbackMode, + int rotationIndex) +{ + if (!force.card.isEmpty()) { + return force; + } + + const PlaymatInfo &deckPlaymat = deck.getPlaymat(); + if (!deckPlaymat.card.isEmpty()) { + return deckPlaymat; + } + + if (fallbackList.isEmpty()) { + return {}; + } + + switch (fallbackMode) { + case PlaymatFallbackModeFixed: + return fallbackList.first(); + case PlaymatFallbackModeRoundRobin: + return fallbackList.at(rotationIndex % fallbackList.size()); + case PlaymatFallbackModeRandom: + return fallbackList.at(QRandomGenerator::global()->bounded(fallbackList.size())); + } + + return {}; +} + +PlaymatInfo resolvePlaymatForDeck(const DeckList &deck, + const QList &fallbackList, + PlaymatMode mode, + PlaymatFallbackMode fallbackBehavior, + int rotationIndex) +{ + switch (mode) { + case PlaymatModeOverrideDeck: { + const DeckList emptyDeck; + return resolveEffectivePlaymat(emptyDeck, {}, fallbackList, fallbackBehavior, rotationIndex); + } + case PlaymatModeFallback: + return resolveEffectivePlaymat(deck, {}, fallbackList, fallbackBehavior, rotationIndex); + case PlaymatModeDeckOnly: + return deck.getPlaymat(); + } + + return {}; +} diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/playmat_resolver.h b/libcockatrice_deck_list/libcockatrice/deck_list/playmat_resolver.h new file mode 100644 index 000000000..411ca81fd --- /dev/null +++ b/libcockatrice_deck_list/libcockatrice/deck_list/playmat_resolver.h @@ -0,0 +1,50 @@ +#ifndef COCKATRICE_PLAYMAT_RESOLVER_H +#define COCKATRICE_PLAYMAT_RESOLVER_H + +#include "libcockatrice/utility/playmat_params.h" + +#include + +class DeckList; +template class QList; + +/** + * @brief Resolves the effective playmat for a deck per the resolution chain: + * force override > deck-configured playmat > fallback list > none. + * + * @param deck The deck to resolve a playmat for. + * @param force An optional user-level override; wins over everything. Pass an + * empty @ref PlaymatInfo::card to skip it. + * @param fallbackList User-level fallback playmats, consulted only when the + * deck has no configured playmat. + * @param fallbackMode How @p fallbackList is consulted (ignored when empty). + * @param rotationIndex In/out cursor for @c PlaymatFallbackModeRoundRobin; + * advanced once per call. Unused for the other modes. + * @return The effective playmat; an empty @ref PlaymatInfo::card when + * nothing in the chain resolves. + */ +PlaymatInfo resolveEffectivePlaymat(const DeckList &deck, + const PlaymatInfo &force, + const QList &fallbackList, + PlaymatFallbackMode fallbackMode, + int rotationIndex); + +/** + * @brief Resolves the playmat to display for a deck according to the user's + * collection mode (@ref PlaymatMode), combining the deck with the + * given fallback list. + * + * @param deck The deck to resolve a playmat for. + * @param fallbackList User-level fallback playmats. + * @param mode How the collection interacts with the deck-configured playmat. + * @param fallbackBehavior How @p fallbackList is picked from. + * @param rotationIndex Cursor for @c PlaymatFallbackModeRoundRobin. + * @return The effective playmat; an empty @ref PlaymatInfo::card when nothing resolves. + */ +PlaymatInfo resolvePlaymatForDeck(const DeckList &deck, + const QList &fallbackList, + PlaymatMode mode, + PlaymatFallbackMode fallbackBehavior, + int rotationIndex); + +#endif // COCKATRICE_PLAYMAT_RESOLVER_H diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/sideboard_plan.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/sideboard_plan.cpp index a76fed619..3a47bf1a5 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/sideboard_plan.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/sideboard_plan.cpp @@ -1,6 +1,38 @@ #include "sideboard_plan.h" +#include "libcockatrice/protocol/pb/move_card_to_zone.pb.h" + +#include +#include #include +#include +#include + +namespace +{ + +void readMoveCardToZone(QXmlStreamReader *xml, QList &moveList) +{ + MoveCard_ToZone move; + while (!xml->atEnd()) { + xml->readNext(); + const QString childName = xml->name().toString(); + if (xml->isStartElement()) { + if (childName == "card_name") { + move.set_card_name(xml->readElementText().toStdString()); + } else if (childName == "start_zone") { + move.set_start_zone(xml->readElementText().toStdString()); + } else if (childName == "target_zone") { + move.set_target_zone(xml->readElementText().toStdString()); + } + } else if (xml->isEndElement() && (childName == "move_card_to_zone")) { + moveList.append(move); + return; + } + } +} + +} // namespace SideboardPlan::SideboardPlan(const QString &_name, const QList &_moveList) : name(_name), moveList(_moveList) @@ -21,23 +53,7 @@ bool SideboardPlan::readElement(QXmlStreamReader *xml) if (childName == "name") { name = xml->readElementText(); } else if (childName == "move_card_to_zone") { - MoveCard_ToZone m; - while (!xml->atEnd()) { - xml->readNext(); - const QString childName2 = xml->name().toString(); - if (xml->isStartElement()) { - if (childName2 == "card_name") { - m.set_card_name(xml->readElementText().toStdString()); - } else if (childName2 == "start_zone") { - m.set_start_zone(xml->readElementText().toStdString()); - } else if (childName2 == "target_zone") { - m.set_target_zone(xml->readElementText().toStdString()); - } - } else if (xml->isEndElement() && (childName2 == "move_card_to_zone")) { - moveList.append(m); - break; - } - } + readMoveCardToZone(xml, moveList); } } else if (xml->isEndElement() && (childName == "sideboard_plan")) { return true; diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/sideboard_plan.h b/libcockatrice_deck_list/libcockatrice/deck_list/sideboard_plan.h index fff4ff2f3..ee29991d6 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/sideboard_plan.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/sideboard_plan.h @@ -2,6 +2,7 @@ #define COCKATRICE_SIDEBOARD_PLAN_H #include +#include #include class QXmlStreamWriter; diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.cpp index 705dfae4c..5c7b4bf84 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.cpp @@ -1,5 +1,10 @@ #include "abstract_deck_list_card_node.h" +#include "libcockatrice/deck_list/tree/abstract_deck_list_node.h" + +#include +#include + bool AbstractDecklistCardNode::compare(AbstractDecklistNode *other) const { switch (sortMethod) { @@ -34,17 +39,6 @@ bool AbstractDecklistCardNode::compareName(AbstractDecklistNode *other) const } } -bool AbstractDecklistCardNode::readElement(QXmlStreamReader *xml) -{ - while (!xml->atEnd()) { - xml->readNext(); - if (xml->isEndElement() && xml->name().toString() == "card") { - return false; - } - } - return true; -} - void AbstractDecklistCardNode::writeElement(QXmlStreamWriter *xml) { xml->writeEmptyElement("card"); @@ -60,4 +54,4 @@ void AbstractDecklistCardNode::writeElement(QXmlStreamWriter *xml) if (!getCardProviderId().isEmpty()) { xml->writeAttribute("uuid", getCardProviderId()); } -} \ No newline at end of file +} diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.h b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.h index df903a168..ebfd9a0b6 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.h @@ -13,6 +13,10 @@ #include "abstract_deck_list_node.h" +#include + +class InnerDecklistNode; + /** * @class AbstractDecklistCardNode * @ingroup DeckModels @@ -134,15 +138,6 @@ public: */ bool compareName(AbstractDecklistNode *other) const; - /** - * @brief Deserialize this node’s properties from XML. - * @param xml QXmlStreamReader positioned at the element. - * @return true if parsing succeeded. - * - * This supports loading deck files from Cockatrice’s XML format. - */ - bool readElement(QXmlStreamReader *xml) override; - /** * @brief Serialize this node’s properties to XML. * @param xml Writer to append this node’s XML element. diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_node.h b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_node.h index a39f0e7b2..406c6fb4d 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_node.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_node.h @@ -17,7 +17,9 @@ #ifndef COCKATRICE_ABSTRACT_DECK_LIST_NODE_H #define COCKATRICE_ABSTRACT_DECK_LIST_NODE_H -#include +#include + +class QXmlStreamWriter; /** * @enum DeckSortMethod @@ -142,6 +144,12 @@ public: return parent; } + /** @param newParent Reparent this node. The new parent takes ownership. */ + void setParent(InnerDecklistNode *newParent) + { + parent = newParent; + } + /** * @brief Compute the depth of this node in the tree. * @return Distance from the root (root = 0, children = 1, etc.). @@ -173,11 +181,10 @@ public: /** * @name XML serialization - * These methods support reading and writing decks from/to + * This method supports writing this node and its children to the * Cockatrice deck XML format. * @{ */ - virtual bool readElement(QXmlStreamReader *xml) = 0; virtual void writeElement(QXmlStreamWriter *xml) = 0; /// @} }; diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/deck_list_card_node.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/tree/deck_list_card_node.cpp index 1791c09a6..803737f1e 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/deck_list_card_node.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/deck_list_card_node.cpp @@ -1,5 +1,9 @@ #include "deck_list_card_node.h" +#include "libcockatrice/deck_list/tree/abstract_deck_list_card_node.h" + +class InnerDecklistNode; + DecklistCardNode::DecklistCardNode(DecklistCardNode *other, InnerDecklistNode *_parent) : AbstractDecklistCardNode(_parent), name(other->getName()), number(other->getNumber()), cardSetShortName(other->getCardSetShortName()), cardSetNumber(other->getCardCollectorNumber()), diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/deck_list_card_node.h b/libcockatrice_deck_list/libcockatrice/deck_list/tree/deck_list_card_node.h index 3bab5405a..c96a764af 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/deck_list_card_node.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/deck_list_card_node.h @@ -14,7 +14,11 @@ #include "abstract_deck_list_card_node.h" +#include #include +#include + +class InnerDecklistNode; /** * @class DecklistCardNode diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp index 602ea6aec..b7b1e0b8f 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp @@ -1,6 +1,18 @@ #include "inner_deck_list_node.h" #include "deck_list_card_node.h" +#include "libcockatrice/deck_list/tree/abstract_deck_list_card_node.h" +#include "libcockatrice/deck_list/tree/abstract_deck_list_node.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include InnerDecklistNode::InnerDecklistNode(InnerDecklistNode *other, InnerDecklistNode *_parent) : AbstractDecklistNode(_parent), name(other->getName()) @@ -28,6 +40,8 @@ QString InnerDecklistNode::visibleNameFromName(const QString &_name) return QObject::tr("Sideboard"); } else if (_name == DECK_ZONE_TOKENS) { return QObject::tr("Tokens"); + } else if (_name == DECK_ZONE_MAYBEBOARD) { + return QObject::tr("Maybeboard"); } else { return _name; } @@ -41,6 +55,13 @@ void InnerDecklistNode::setSortMethod(DeckSortMethod method) } } +const QList &InnerDecklistNode::boardZoneNames() +{ + static const QList names = {QString(DECK_ZONE_MAIN), QString(DECK_ZONE_SIDE), + QString(DECK_ZONE_MAYBEBOARD)}; + return names; +} + QString InnerDecklistNode::getVisibleName() const { return visibleNameFromName(name); @@ -85,6 +106,9 @@ AbstractDecklistNode *InnerDecklistNode::findCardChildByNameProviderIdAndNumber( int InnerDecklistNode::height() const { + if (isEmpty()) { + return 1; + } return at(0)->height() + 1; } @@ -139,27 +163,35 @@ bool InnerDecklistNode::compareName(AbstractDecklistNode *other) const } } -bool InnerDecklistNode::readElement(QXmlStreamReader *xml) +int InnerDecklistNode::readCardElement(QXmlStreamReader *xml, int remainingBudget) { + const int amount = qMin(xml->attributes().value("number").toString().toInt(), remainingBudget); + new DecklistCardNode(xml->attributes().value("name").toString(), amount, this, -1, + xml->attributes().value("setShortName").toString(), + xml->attributes().value("collectorNumber").toString(), + xml->attributes().value("uuid").toString()); + return amount; +} + +int InnerDecklistNode::readElement(QXmlStreamReader *xml, int limit) +{ + int totalCards = 0; while (!xml->atEnd()) { xml->readNext(); const QString childName = xml->name().toString(); + const int remainingBudget = limit - totalCards; if (xml->isStartElement()) { if (childName == "zone") { auto *newZone = new InnerDecklistNode(xml->attributes().value("name").toString(), this); - newZone->readElement(xml); + totalCards += newZone->readElement(xml, remainingBudget); } else if (childName == "card") { - auto *newCard = new DecklistCardNode( - xml->attributes().value("name").toString(), xml->attributes().value("number").toString().toInt(), - this, -1, xml->attributes().value("setShortName").toString(), - xml->attributes().value("collectorNumber").toString(), xml->attributes().value("uuid").toString()); - newCard->readElement(xml); + totalCards += readCardElement(xml, remainingBudget); } } else if (xml->isEndElement() && (childName == "zone")) { - return false; + return totalCards; } } - return true; + return totalCards; } void InnerDecklistNode::writeElement(QXmlStreamWriter *xml) @@ -172,31 +204,35 @@ void InnerDecklistNode::writeElement(QXmlStreamWriter *xml) xml->writeEndElement(); // zone } -QVector> InnerDecklistNode::sort(Qt::SortOrder order) +QVector> InnerDecklistNode::indexedSnapshot() const +{ + QVector> snapshot(size()); + for (int i = size() - 1; i >= 0; --i) { + snapshot[i].first = i; + snapshot[i].second = at(i); + } + return snapshot; +} + +QVector> InnerDecklistNode::applySortedOrder(const QVector> &sorted) { QVector> result(size()); - - // Initialize temporary list with contents of current list - QVector> tempList(size()); for (int i = size() - 1; i >= 0; --i) { - tempList[i].first = i; - tempList[i].second = at(i); + result[i].first = sorted[i].first; + result[i].second = i; + replace(i, sorted[i].second); } + return result; +} + +QVector> InnerDecklistNode::sort(Qt::SortOrder order) +{ + auto snapshot = indexedSnapshot(); - // Sort temporary list auto cmp = [order](const auto &a, const auto &b) { return (order == Qt::AscendingOrder) ? (b.second->compare(a.second)) : (a.second->compare(b.second)); }; + std::sort(snapshot.begin(), snapshot.end(), cmp); - std::sort(tempList.begin(), tempList.end(), cmp); - - // Map old indexes to new indexes and - // copy temporary list to the current one - for (int i = size() - 1; i >= 0; --i) { - result[i].first = tempList[i].first; - result[i].second = i; - replace(i, tempList[i].second); - } - - return result; -} \ No newline at end of file + return applySortedOrder(snapshot); +} diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h index 81c37dffa..6086f9efa 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h @@ -18,12 +18,23 @@ #include "abstract_deck_list_node.h" +#include +#include +#include +#include +#include +#include + +class QXmlStreamReader; + /** @brief Constant for the "main" deck zone name. */ #define DECK_ZONE_MAIN "main" /** @brief Constant for the "sideboard" zone name. */ #define DECK_ZONE_SIDE "side" /** @brief Constant for the "tokens" zone name. */ #define DECK_ZONE_TOKENS "tokens" +/** @brief Constant for the "maybeboard" zone name. */ +#define DECK_ZONE_MAYBEBOARD "maybeboard" /** * @class InnerDecklistNode @@ -116,6 +127,13 @@ public: */ static QString visibleNameFromName(const QString &_name); + /** + * @brief The standard board zone names, in display order. + * + * @return main, side and maybeboard. + */ + static const QList &boardZoneNames(); + /** * @brief Get this node’s display-friendly name. * @return Human-readable name (zone/group name). @@ -211,18 +229,45 @@ public: */ QVector> sort(Qt::SortOrder order = Qt::AscendingOrder); +private: + /** + * @brief Snapshots the current children as (old index, node) pairs. + */ + QVector> indexedSnapshot() const; + + /** + * @brief Replaces this node's children with @p sorted and maps old indexes to new ones. + * + * @return A list of (old index, new index) pairs for each reordered child. + */ + QVector> applySortedOrder(const QVector> &sorted); + +public: /** * @brief Deserialize this node and its children from XML. * @param xml Reader positioned at this element. - * @return true if parsing succeeded. + * @param limit The maximum amount of cards to read + * @return the amount of cards found */ - bool readElement(QXmlStreamReader *xml) override; + int readElement(QXmlStreamReader *xml, int limit); /** * @brief Serialize this node and its children to XML. * @param xml Writer to append elements to. */ void writeElement(QXmlStreamWriter *xml) override; + +private: + /** + * @brief Reads a single `card` element and appends it to this node. + * + * The card's quantity is capped at @p remainingBudget so a malicious or + * oversized deck file cannot push the total card count past the deck size + * limit. + * + * @return The amount of cards actually added. + */ + int readCardElement(QXmlStreamReader *xml, int remainingBudget); }; #endif // COCKATRICE_INNER_DECK_LIST_NODE_H diff --git a/libcockatrice_filters/CMakeLists.txt b/libcockatrice_filters/CMakeLists.txt index 905533df8..2d45b8e5b 100644 --- a/libcockatrice_filters/CMakeLists.txt +++ b/libcockatrice_filters/CMakeLists.txt @@ -18,3 +18,5 @@ add_dependencies(libcockatrice_filters libcockatrice_card) target_include_directories(libcockatrice_filters PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(libcockatrice_filters PUBLIC libcockatrice_card ${QT_CORE_MODULE}) + +target_precompile_headers(libcockatrice_filters PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") diff --git a/libcockatrice_filters/libcockatrice/filters/filter_card.cpp b/libcockatrice_filters/libcockatrice/filters/filter_card.cpp index 5fdce7ae0..26cddd53c 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_card.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_card.cpp @@ -1,6 +1,9 @@ #include "filter_card.h" #include +#include +#include +#include QJsonObject CardFilter::toJson() const { diff --git a/libcockatrice_filters/libcockatrice/filters/filter_card.h b/libcockatrice_filters/libcockatrice/filters/filter_card.h index bb1e0ef53..e6a37ee40 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_card.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_card.h @@ -7,8 +7,10 @@ #ifndef CARDFILTER_H #define CARDFILTER_H +#include #include #include +#include class CardFilter : public QObject { diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp index 25e8e97db..bc137b0f6 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp @@ -1,11 +1,35 @@ #include "filter_string.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/card_localization.h" +#include "libcockatrice/card/database/card_database.h" +#include "libcockatrice/card/printing/printing_info.h" +#include "libcockatrice/card/set/card_set.h" +#include "libcockatrice/card/set/card_set_list.h" + #include +#include +#include #include +#include +#include #include +#include +#include #include +#include +#include +#include +#include #include +#include #include +#include +#include +#include +#include +#include +#include static peg::parser search(R"( Start <- QueryPartList @@ -19,7 +43,8 @@ SomewhatComplexQueryPart <- [(] QueryPartList [)] / QueryPart QueryPart <- NotQuery / SetQuery / RarityQuery / CMCQuery / FormatQuery / PowerQuery / ToughnessQuery / ColorQuery / TypeQuery / OracleQuery / FieldQuery / GenericQuery NotQuery <- ('NOT' ws/'-') SomewhatComplexQueryPart -SetQuery <- ('e'/'set') [:] FlexStringValue +SetQuery <- ('e'/'set') SetQueryValue +SetQueryValue <- ([=:] FlexStringValue) / (<[!][=]?> FlexStringValue) / SetExpression OracleQuery <- 'o' [:] MatcherString @@ -64,6 +89,8 @@ RegexMatcherString <- ('\\/' / !'/' .)+ FlexStringValue <- CompactStringSet / String / [(] StringList [)] CompactStringSet <- StringListString ([,+] StringListString)+ +SetExpression <- NumericOperator ws? String + NumericExpression <- NumericOperator ws? NumericValue NumericOperator <- [=:] / <[> NumericValue <- [0-9]+ @@ -71,6 +98,36 @@ NumericValue <- [0-9]+ static std::once_flag init; +// The peglib parser rules (and therefore their rule actions) are set up once per +// process, so a rule action cannot capture per-instance state. The card language +// plain-text name and text queries search in is therefore handed to the GenericQuery +// and OracleQuery rule actions through this thread-local context, which is live only +// while a FilterString is being parsed. The rule actions copy it into the filter +// closures they produce, so card evaluation never reads process-global state. +thread_local CardSearchLanguage searchLanguageContext; + +namespace +{ +bool matchesInSearchLanguage(const QString &english, + const QString &localized, + const CardSearchLanguage &searchLanguage, + const StringMatcher &matcher) +{ + if (searchLanguage.mode == SearchLanguageMode::English) { + return matcher(english); + } + + if (searchLanguage.mode == SearchLanguageMode::Both) { + if (!searchLanguage.isEnglishOnly() && matcher(localized)) { + return true; + } + return matcher(english); + } + + return searchLanguage.isEnglishOnly() ? matcher(english) : matcher(localized); +} +} // namespace + static void setupParserRules() { auto passthru = [](const peg::SemanticValues &sv) -> Filter { @@ -100,13 +157,35 @@ static void setupParserRules() const auto matcher = std::any_cast(sv[0]); return [=](const CardData &x) -> bool { return matcher(x->getCardType()); }; }; - search["SetQuery"] = [](const peg::SemanticValues &sv) -> Filter { - auto matcher = std::any_cast(sv[0]); - return [=](const CardData &x) -> bool { - QList sets = x->getSets().keys(); + search["SetQueryValue"] = [](const peg::SemanticValues &sv) -> Filter { + if (sv.choice() == 0) { + auto matcher = std::any_cast(sv[0]); + return [=](const CardData &x) -> bool { + QList sets = x->getSets().keys(); - auto matchesSet = [&matcher](const QString &set) { return matcher(set); }; - return std::any_of(sets.begin(), sets.end(), matchesSet); + auto matchesSet = [&matcher](const QString &set) { return matcher(set); }; + return std::any_of(sets.begin(), sets.end(), matchesSet); + }; + } + if (sv.choice() == 1) { + auto matcher = std::any_cast(sv[0]); + return [=](const CardData &x) -> bool { + QList sets = x->getSets().keys(); + + auto matchesSet = [&matcher](const QString &set) { return matcher(set); }; + return std::none_of(sets.begin(), sets.end(), matchesSet); + }; + } + + auto matcher = std::any_cast(sv[0]); + return [=](const CardData &x) -> bool { + const auto &sets = x->getSets().values(); + auto matchesSet = [&](const PrintingInfo &printing) { + return printing.getSet()->getEnabled() && matcher(printing.getSet()->getReleaseDate().toJulianDay()); + }; + return std::any_of(sets.begin(), sets.end(), [&](const auto &printings) { + return std::any_of(printings.begin(), printings.end(), matchesSet); + }); }; }; search["Rarity"] = [](const peg::SemanticValues &sv) -> QString { @@ -247,40 +326,54 @@ static void setupParserRules() return QString::fromStdString(std::string(sv.sv())); }; - search["NumericExpression"] = [](const peg::SemanticValues &sv) -> NumberMatcher { - const auto arg = std::any_cast(sv[1]); - const auto op = std::any_cast(sv[0]); + search["NumericOperator"] = [](const peg::SemanticValues &sv) -> NumberComparer { + const auto op = QString::fromStdString(std::string(sv.sv())); if (op == ">") { - return [=](const int s) { return s > arg; }; + return [=](const int s, const int arg) { return s > arg; }; } if (op == ">=") { - return [=](const int s) { return s >= arg; }; + return [=](const int s, const int arg) { return s >= arg; }; } if (op == "<") { - return [=](const int s) { return s < arg; }; + return [=](const int s, const int arg) { return s < arg; }; } if (op == "<=") { - return [=](const int s) { return s <= arg; }; + return [=](const int s, const int arg) { return s <= arg; }; } if (op == "=") { - return [=](const int s) { return s == arg; }; + return [=](const int s, const int arg) { return s == arg; }; } if (op == ":") { - return [=](const int s) { return s == arg; }; + return [=](const int s, const int arg) { return s == arg; }; } if (op == "!=") { - return [=](const int s) { return s != arg; }; + return [=](const int s, const int arg) { return s != arg; }; } - return [](int) { return false; }; + return [](int, int) { return false; }; }; search["NumericValue"] = [](const peg::SemanticValues &sv) -> int { return QString::fromStdString(std::string(sv.sv())).toInt(); }; - search["NumericOperator"] = [](const peg::SemanticValues &sv) -> QString { - return QString::fromStdString(std::string(sv.sv())); + search["NumericExpression"] = [](const peg::SemanticValues &sv) -> NumberMatcher { + const auto comparer = std::any_cast(sv[0]); + const auto arg = std::any_cast(sv[1]); + return [=](int s) { return comparer(s, arg); }; + }; + + search["SetExpression"] = [](const peg::SemanticValues &sv) -> NumberMatcher { + const auto comparer = std::any_cast(sv[0]); + const auto setCode = std::any_cast(sv[1]); + const auto allSets = CardDatabaseManager::getInstance()->getSetList(); + for (auto &set : allSets) { + if (set->getShortName() == setCode) { + const int releaseDate = set->getReleaseDate().toJulianDay(); + return [=](int s) { return comparer(s, releaseDate); }; + } + } + return [](int) { return false; }; }; search["NormalMatcher"] = [](const peg::SemanticValues &sv) -> StringMatcher { @@ -303,7 +396,11 @@ static void setupParserRules() search["OracleQuery"] = [](const peg::SemanticValues &sv) -> Filter { const auto matcher = std::any_cast(sv[0]); - return [=](const CardData &x) { return matcher(x->getText()); }; + const CardSearchLanguage searchLanguage = searchLanguageContext; + return [=](const CardData &x) { + return matchesInSearchLanguage(x->getText(), x->getLocalizedText(searchLanguage.language), searchLanguage, + matcher); + }; }; search["ColorQuery"] = [](const peg::SemanticValues &sv) -> Filter { @@ -380,7 +477,11 @@ static void setupParserRules() }; search["GenericQuery"] = [](const peg::SemanticValues &sv) -> Filter { const auto matcher = std::any_cast(sv[0]); - return [=](const CardData &x) { return matcher(x->getName()); }; + const CardSearchLanguage searchLanguage = searchLanguageContext; + return [=](const CardData &x) { + return matchesInSearchLanguage(x->getName(), x->getLocalizedName(searchLanguage.language), searchLanguage, + matcher); + }; }; search["Color"] = [](const peg::SemanticValues &sv) -> char { return "WUBRGU"[sv.choice()]; }; @@ -395,7 +496,7 @@ FilterString::FilterString() _error = "Not initialized"; } -FilterString::FilterString(const QString &expr) +FilterString::FilterString(const QString &expr, const CardSearchLanguage &searchLanguage) { QByteArray ba = expr.simplified().toUtf8(); @@ -408,6 +509,8 @@ FilterString::FilterString(const QString &expr) return; } + searchLanguageContext = searchLanguage; + search.set_logger([&](size_t /*ln*/, size_t col, const std::string &msg) { _error = QString("Error at position %1: %2").arg(col).arg(QString::fromStdString(msg)); }); diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.h b/libcockatrice_filters/libcockatrice/filters/filter_string.h index 71a99f7b5..2abe95201 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.h @@ -7,14 +7,13 @@ #ifndef FILTER_STRING_H #define FILTER_STRING_H -#include "filter_tree.h" - #include -#include #include #include #include -#include +#include + +struct CardSearchLanguage; inline Q_LOGGING_CATEGORY(FilterStringLog, "filter_string"); @@ -22,11 +21,13 @@ typedef CardInfoPtr CardData; typedef std::function Filter; typedef std::function StringMatcher; typedef std::function NumberMatcher; +typedef std::function NumberComparer; namespace peg { template struct AstBase; struct EmptyType; + typedef AstBase Ast; } // namespace peg @@ -34,7 +35,7 @@ class FilterString { public: FilterString(); - explicit FilterString(const QString &exp); + explicit FilterString(const QString &exp, const CardSearchLanguage &searchLanguage = {}); [[nodiscard]] bool check(const CardData &card) const { if (card.isNull()) { diff --git a/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp b/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp index 8502db50b..742b44e97 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_tree.cpp @@ -1,8 +1,16 @@ #include "filter_tree.h" #include "filter_card.h" +#include "libcockatrice/card/card_localization.h" +#include "libcockatrice/card/printing/printing_info.h" +#include "libcockatrice/card/set/card_set.h" +#include #include +#include +#include +#include +#include template FilterTreeNode *FilterTreeBranch::nodeAt(int i) const { @@ -100,14 +108,16 @@ FilterTreeNode *FilterItemList::termNode(const QString &term) return childNodes.at(i); } -bool FilterItemList::testTypeAnd(const CardInfoPtr info, CardFilter::Attr attr) const +bool FilterItemList::testTypeAnd(const CardInfoPtr info, + CardFilter::Attr attr, + const CardSearchLanguage &searchLanguage) const { for (auto i = childNodes.constBegin(); i != childNodes.constEnd(); i++) { if (!(*i)->isEnabled()) { continue; } - if (!(*i)->acceptCardAttr(info, attr)) { + if (!(*i)->acceptCardAttr(info, attr, searchLanguage)) { return false; } } @@ -115,13 +125,17 @@ bool FilterItemList::testTypeAnd(const CardInfoPtr info, CardFilter::Attr attr) return true; } -bool FilterItemList::testTypeAndNot(const CardInfoPtr info, CardFilter::Attr attr) const +bool FilterItemList::testTypeAndNot(const CardInfoPtr info, + CardFilter::Attr attr, + const CardSearchLanguage &searchLanguage) const { // if any one in the list is true, return false - return !testTypeOr(info, attr); + return !testTypeOr(info, attr, searchLanguage); } -bool FilterItemList::testTypeOr(const CardInfoPtr info, CardFilter::Attr attr) const +bool FilterItemList::testTypeOr(const CardInfoPtr info, + CardFilter::Attr attr, + const CardSearchLanguage &searchLanguage) const { bool noChildEnabledChild = true; @@ -134,7 +148,7 @@ bool FilterItemList::testTypeOr(const CardInfoPtr info, CardFilter::Attr attr) c noChildEnabledChild = false; } - if ((*i)->acceptCardAttr(info, attr)) { + if ((*i)->acceptCardAttr(info, attr, searchLanguage)) { return true; } } @@ -142,20 +156,58 @@ bool FilterItemList::testTypeOr(const CardInfoPtr info, CardFilter::Attr attr) c return noChildEnabledChild; } -bool FilterItemList::testTypeOrNot(const CardInfoPtr info, CardFilter::Attr attr) const +bool FilterItemList::testTypeOrNot(const CardInfoPtr info, + CardFilter::Attr attr, + const CardSearchLanguage &searchLanguage) const { // if any one in the list is false, return true - return !testTypeAnd(info, attr); + return !testTypeAnd(info, attr, searchLanguage); } -bool FilterItem::acceptName(const CardInfoPtr info) const +bool FilterItem::acceptName(const CardInfoPtr info, const CardSearchLanguage &searchLanguage) const { - return info->getName().contains(term, Qt::CaseInsensitive); + const QString &englishName = info->getName(); + const QString &localizedName = info->getLocalizedName(searchLanguage.language); + + switch (searchLanguage.mode) { + case SearchLanguageMode::English: + return englishName.contains(term, Qt::CaseInsensitive); + case SearchLanguageMode::Both: + if (englishName.contains(term, Qt::CaseInsensitive)) { + return true; + } + return !searchLanguage.isEnglishOnly() && localizedName.contains(term, Qt::CaseInsensitive); + case SearchLanguageMode::Selected: + if (searchLanguage.isEnglishOnly()) { + return englishName.contains(term, Qt::CaseInsensitive); + } + return localizedName.contains(term, Qt::CaseInsensitive); + } + + return false; } -bool FilterItem::acceptNameExact(const CardInfoPtr info) const +bool FilterItem::acceptNameExact(const CardInfoPtr info, const CardSearchLanguage &searchLanguage) const { - return info->getName() == term; + const QString &englishName = info->getName(); + const QString &localizedName = info->getLocalizedName(searchLanguage.language); + + switch (searchLanguage.mode) { + case SearchLanguageMode::English: + return englishName == term; + case SearchLanguageMode::Both: + if (englishName == term) { + return true; + } + return !searchLanguage.isEnglishOnly() && localizedName == term; + case SearchLanguageMode::Selected: + if (searchLanguage.isEnglishOnly()) { + return englishName == term; + } + return localizedName == term; + } + + return false; } bool FilterItem::acceptType(const CardInfoPtr info) const @@ -213,9 +265,27 @@ bool FilterItem::acceptColor(const CardInfoPtr info) const return match_count == converted_term.length(); } -bool FilterItem::acceptText(const CardInfoPtr info) const +bool FilterItem::acceptText(const CardInfoPtr info, const CardSearchLanguage &searchLanguage) const { - return info->getText().contains(term, Qt::CaseInsensitive); + const QString &englishText = info->getText(); + const QString &localizedText = info->getLocalizedText(searchLanguage.language); + + switch (searchLanguage.mode) { + case SearchLanguageMode::English: + return englishText.contains(term, Qt::CaseInsensitive); + case SearchLanguageMode::Both: + if (englishText.contains(term, Qt::CaseInsensitive)) { + return true; + } + return !searchLanguage.isEnglishOnly() && localizedText.contains(term, Qt::CaseInsensitive); + case SearchLanguageMode::Selected: + if (searchLanguage.isEnglishOnly()) { + return englishText.contains(term, Qt::CaseInsensitive); + } + return localizedText.contains(term, Qt::CaseInsensitive); + } + + return false; } bool FilterItem::acceptSet(const CardInfoPtr info) const @@ -402,19 +472,21 @@ bool FilterItem::relationCheck(int cardInfo) const return result; } -bool FilterItem::acceptCardAttr(const CardInfoPtr info, CardFilter::Attr attr) const +bool FilterItem::acceptCardAttr(const CardInfoPtr info, + CardFilter::Attr attr, + const CardSearchLanguage &searchLanguage) const { switch (attr) { case CardFilter::AttrName: - return acceptName(info); + return acceptName(info, searchLanguage); case CardFilter::AttrNameExact: - return acceptNameExact(info); + return acceptNameExact(info, searchLanguage); case CardFilter::AttrType: return acceptType(info); case CardFilter::AttrColor: return acceptColor(info); case CardFilter::AttrText: - return acceptText(info); + return acceptText(info, searchLanguage); case CardFilter::AttrSet: return acceptSet(info); case CardFilter::AttrManaCost: @@ -484,18 +556,18 @@ FilterTreeNode *FilterTree::termNode(const CardFilter *f) return termNode(f->attr(), f->type(), f->term()); } -bool FilterTree::testAttr(const CardInfoPtr info, const LogicMap *lm) const +bool FilterTree::testAttr(const CardInfoPtr info, const LogicMap *lm, const CardSearchLanguage &searchLanguage) const { const FilterItemList *fil; bool status = true; fil = lm->findTypeList(CardFilter::TypeAnd); - if (fil && fil->isEnabled() && !fil->testTypeAnd(info, lm->attr)) { + if (fil && fil->isEnabled() && !fil->testTypeAnd(info, lm->attr, searchLanguage)) { return false; } fil = lm->findTypeList(CardFilter::TypeAndNot); - if (fil && fil->isEnabled() && !fil->testTypeAndNot(info, lm->attr)) { + if (fil && fil->isEnabled() && !fil->testTypeAndNot(info, lm->attr, searchLanguage)) { return false; } @@ -504,23 +576,23 @@ bool FilterTree::testAttr(const CardInfoPtr info, const LogicMap *lm) const status = false; // if this is true we can return because it is OR'd with the OrNot list - if (fil->testTypeOr(info, lm->attr)) { + if (fil->testTypeOr(info, lm->attr, searchLanguage)) { return true; } } fil = lm->findTypeList(CardFilter::TypeOrNot); - if (fil && fil->isEnabled() && fil->testTypeOrNot(info, lm->attr)) { + if (fil && fil->isEnabled() && fil->testTypeOrNot(info, lm->attr, searchLanguage)) { return true; } return status; } -bool FilterTree::acceptsCard(const CardInfoPtr info) const +bool FilterTree::acceptsCard(const CardInfoPtr info, const CardSearchLanguage &searchLanguage) const { for (auto i = childNodes.constBegin(); i != childNodes.constEnd(); i++) { - if ((*i)->isEnabled() && !testAttr(info, *i)) { + if ((*i)->isEnabled() && !testAttr(info, *i, searchLanguage)) { return false; } } diff --git a/libcockatrice_filters/libcockatrice/filters/filter_tree.h b/libcockatrice_filters/libcockatrice/filters/filter_tree.h index aac1777e0..0defd466b 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_tree.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_tree.h @@ -8,12 +8,16 @@ #define FILTERTREE_H #include "filter_card.h" +#include "libcockatrice/card/card_info.h" #include #include -#include +#include +#include #include +struct CardSearchLanguage; + class FilterTreeNode { private: @@ -119,6 +123,7 @@ public: class FilterItemList; class FilterTree; + class LogicMap : public FilterTreeBranch { @@ -141,6 +146,7 @@ public: }; class FilterItem; + class FilterItemList : public FilterTreeBranch { private: @@ -167,10 +173,14 @@ public: return CardFilter::typeName(type); } - [[nodiscard]] bool testTypeAnd(CardInfoPtr info, CardFilter::Attr attr) const; - [[nodiscard]] bool testTypeAndNot(CardInfoPtr info, CardFilter::Attr attr) const; - [[nodiscard]] bool testTypeOr(CardInfoPtr info, CardFilter::Attr attr) const; - [[nodiscard]] bool testTypeOrNot(CardInfoPtr info, CardFilter::Attr attr) const; + [[nodiscard]] bool + testTypeAnd(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; + [[nodiscard]] bool + testTypeAndNot(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; + [[nodiscard]] bool + testTypeOr(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; + [[nodiscard]] bool + testTypeOrNot(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; }; class FilterItem : public FilterTreeNode @@ -207,20 +217,21 @@ public: return true; } - [[nodiscard]] bool acceptName(CardInfoPtr info) const; - [[nodiscard]] bool acceptNameExact(CardInfoPtr info) const; + [[nodiscard]] bool acceptName(CardInfoPtr info, const CardSearchLanguage &searchLanguage) const; + [[nodiscard]] bool acceptNameExact(CardInfoPtr info, const CardSearchLanguage &searchLanguage) const; [[nodiscard]] bool acceptType(CardInfoPtr info) const; [[nodiscard]] bool acceptMainType(CardInfoPtr info) const; [[nodiscard]] bool acceptSubType(CardInfoPtr info) const; [[nodiscard]] bool acceptColor(CardInfoPtr info) const; - [[nodiscard]] bool acceptText(CardInfoPtr info) const; + [[nodiscard]] bool acceptText(CardInfoPtr info, const CardSearchLanguage &searchLanguage) const; [[nodiscard]] bool acceptSet(CardInfoPtr info) const; [[nodiscard]] bool acceptManaCost(CardInfoPtr info) const; [[nodiscard]] bool acceptCmc(CardInfoPtr info) const; [[nodiscard]] bool acceptPowerToughness(CardInfoPtr info, CardFilter::Attr attr) const; [[nodiscard]] bool acceptLoyalty(CardInfoPtr info) const; [[nodiscard]] bool acceptRarity(CardInfoPtr info) const; - [[nodiscard]] bool acceptCardAttr(CardInfoPtr info, CardFilter::Attr attr) const; + [[nodiscard]] bool + acceptCardAttr(CardInfoPtr info, CardFilter::Attr attr, const CardSearchLanguage &searchLanguage) const; [[nodiscard]] bool acceptFormat(CardInfoPtr info) const; [[nodiscard]] bool relationCheck(int cardInfo) const; }; @@ -240,7 +251,7 @@ private: LogicMap *attrLogicMap(CardFilter::Attr attr); FilterItemList *attrTypeList(CardFilter::Attr attr, CardFilter::Type type); - bool testAttr(CardInfoPtr info, const LogicMap *lm) const; + bool testAttr(CardInfoPtr info, const LogicMap *lm, const CardSearchLanguage &searchLanguage) const; void nodeChanged() const override { @@ -279,7 +290,7 @@ public: return 0; } - [[nodiscard]] bool acceptsCard(CardInfoPtr info) const; + [[nodiscard]] bool acceptsCard(CardInfoPtr info, const CardSearchLanguage &searchLanguage) const; void removeFiltersByAttr(CardFilter::Attr filterType); void removeFilter(const CardFilter *toRemove); void clear(); diff --git a/libcockatrice_interfaces/CMakeLists.txt b/libcockatrice_interfaces/CMakeLists.txt index f606f6207..3b17569b1 100644 --- a/libcockatrice_interfaces/CMakeLists.txt +++ b/libcockatrice_interfaces/CMakeLists.txt @@ -30,4 +30,6 @@ add_library(libcockatrice_interfaces STATIC ${MOC_SOURCES}) target_include_directories(libcockatrice_interfaces PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) -target_link_libraries(libcockatrice_interfaces PUBLIC ${QT_CORE_MODULE}) +target_link_libraries(libcockatrice_interfaces PUBLIC libcockatrice_utility ${QT_CORE_MODULE}) + +target_precompile_headers(libcockatrice_interfaces PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_card_preference_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_card_preference_provider.h index a6cf941fb..2510919ca 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_card_preference_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_card_preference_provider.h @@ -8,7 +8,6 @@ class ICardPreferenceProvider public: virtual ~ICardPreferenceProvider() = default; [[nodiscard]] virtual QString getCardPreferenceOverride(const QString &cardName) const = 0; - [[nodiscard]] virtual bool getIncludeRebalancedCards() const = 0; }; #endif // COCKATRICE_INTERFACE_CARD_PREFERENCE_PROVIDER_H diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_card_set_priority_controller.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_card_set_priority_controller.h index 36e748006..6b3e684d7 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_card_set_priority_controller.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_card_set_priority_controller.h @@ -2,6 +2,7 @@ #define COCKATRICE_INTERFACE_CARD_SET_PRIORITY_CONTROLLER_H #include +#include class ICardSetPriorityController { @@ -46,6 +47,16 @@ public: */ virtual SetOptions getSetOptions(QString shortName) const = 0; + /** + * @brief Returns the short names of every set currently marked as enabled. + * + * Callers fold this into the card database cache source hash so that + * toggling set enablement invalidates the cache. + * + * @return The enabled set short names, sorted alphabetically. + */ + virtual QStringList getEnabledSetNames() const = 0; + virtual void saveSets(const QVector &data) = 0; }; diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h index 3ee3d2aef..a5ab4fc37 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h @@ -1,6 +1,8 @@ #ifndef COCKATRICE_INTERFACE_CARDS_DISPLAY_SETTINGS_PROVIDER_H #define COCKATRICE_INTERFACE_CARDS_DISPLAY_SETTINGS_PROVIDER_H +#include + class ICardsDisplaySettingsProvider { public: @@ -12,9 +14,9 @@ public: [[nodiscard]] virtual bool getBumpSetsWithCardsInDeckToTop() const = 0; [[nodiscard]] virtual int getPrintingSelectorSortOrder() const = 0; [[nodiscard]] virtual int getPrintingSelectorCardSize() const = 0; - [[nodiscard]] virtual bool getIncludeRebalancedCards() const = 0; [[nodiscard]] virtual bool getPrintingSelectorNavigationButtonsVisible() const = 0; [[nodiscard]] virtual bool getTapAnimation() const = 0; + [[nodiscard]] virtual bool getArrowDrawAnimation() const = 0; [[nodiscard]] virtual bool getAutoRotateSidewaysLayoutCards() const = 0; [[nodiscard]] virtual bool getScaleCards() const = 0; [[nodiscard]] virtual int getStackCardOverlapPercent() const = 0; @@ -22,9 +24,12 @@ public: [[nodiscard]] virtual int getVisualDeckStorageCardSize() const = 0; [[nodiscard]] virtual int getVisualDatabaseDisplayCardSize() const = 0; [[nodiscard]] virtual int getVisualDeckEditorCardSize() const = 0; + [[nodiscard]] virtual bool getVisualDeckEditorShowCardCounts() const = 0; [[nodiscard]] virtual int getEDHRecCardSize() const = 0; [[nodiscard]] virtual int getArchidektPreviewSize() const = 0; [[nodiscard]] virtual int getSampleHandSize() const = 0; + [[nodiscard]] virtual QString getCardLang() const = 0; + [[nodiscard]] virtual int getCardSearchLanguage() const = 0; }; #endif // COCKATRICE_INTERFACE_CARDS_DISPLAY_SETTINGS_PROVIDER_H diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_chat_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_chat_settings_provider.h index cd9ad29e1..04dad9243 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_chat_settings_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_chat_settings_provider.h @@ -20,6 +20,8 @@ public: [[nodiscard]] virtual bool getShowMessagePopup() const = 0; [[nodiscard]] virtual bool getShowMentionPopup() const = 0; [[nodiscard]] virtual bool getRoomHistory() const = 0; + [[nodiscard]] virtual bool getIgnoreAllPrivateMessages() const = 0; + [[nodiscard]] virtual bool getUseGameTime() const = 0; [[nodiscard]] virtual QString getHighlightWords() const = 0; }; diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_interface_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_interface_settings_provider.h index 07e60b28f..bc2118cb3 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_interface_settings_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_interface_settings_provider.h @@ -1,7 +1,40 @@ #ifndef COCKATRICE_INTERFACE_INTERFACE_SETTINGS_PROVIDER_H #define COCKATRICE_INTERFACE_INTERFACE_SETTINGS_PROVIDER_H +#include #include +#include +#include + +/** + * @brief Whether playmats are rendered in-game, and for whom. + */ +enum PlaymatVisibility +{ + PlaymatVisibilityNone = 0, ///< Don't use playmats. + PlaymatVisibilityOwnOnly = 1, ///< Show the local player's playmat only. + PlaymatVisibilityAll = 2 ///< Show playmats for all players. +}; + +/** + * @brief How the user-level playmat collection interacts with the deck-configured playmat. + */ +enum PlaymatMode +{ + PlaymatModeOverrideDeck = 0, ///< Always use the collection, ignoring any deck-configured playmat. + PlaymatModeFallback = 1, ///< Prefer the deck-configured playmat; fall back to the collection when absent. + PlaymatModeDeckOnly = 2 ///< Use only the deck-configured playmat, ignoring the collection. +}; + +/** + * @brief How the user-level fallback playmat list is consulted when a deck has no playmat configured. + */ +enum PlaymatFallbackMode +{ + PlaymatFallbackModeFixed = 0, ///< Always use the first entry of the fallback list. + PlaymatFallbackModeRoundRobin = 1, ///< Cycle through the list, advancing one entry per resolution. + PlaymatFallbackModeRandom = 2 ///< Pick a random entry per resolution. +}; class IInterfaceSettingsProvider { @@ -31,6 +64,7 @@ public: [[nodiscard]] virtual int getMinPlayersForMultiColumnLayout() const = 0; [[nodiscard]] virtual int getRewindBufferingMs() const = 0; [[nodiscard]] virtual qreal getFastForwardSpeed() const = 0; + [[nodiscard]] virtual bool getSkipEmptySections() const = 0; [[nodiscard]] virtual bool getLeftJustified() const = 0; [[nodiscard]] virtual int getZoneViewGroupByIndex() const = 0; [[nodiscard]] virtual int getZoneViewSortByIndex() const = 0; @@ -38,6 +72,24 @@ public: [[nodiscard]] virtual bool getShowStatusBar() const = 0; [[nodiscard]] virtual bool getShowShortcuts() const = 0; [[nodiscard]] virtual bool getShowGameSelectorFilterToolbar() const = 0; + [[nodiscard]] virtual bool getLifeCounterAnimationsEnabled() const = 0; + [[nodiscard]] virtual bool getBattlefieldFlashEnabled() const = 0; + [[nodiscard]] virtual QStringList getUserListExpandedSections() const = 0; + + /** @brief Who gets playmats rendered: @ref PlaymatVisibility. */ + [[nodiscard]] virtual int getPlaymatVisibility() const = 0; + + /** @brief User-level playmat collection. Used either as a forced playmat + * (mode == @ref PlaymatModeOverrideDeck) or as a fallback when a deck has none + * (mode == @ref PlaymatModeFallback). */ + [[nodiscard]] virtual QList getPlaymatFallbackList() const = 0; + + /** @brief How the fallback list is applied: @ref PlaymatMode. */ + [[nodiscard]] virtual int getPlaymatMode() const = 0; + + /** @brief How the fallback list is picked from when mode is @ref PlaymatModeFallback: + * @ref PlaymatFallbackMode. */ + [[nodiscard]] virtual int getPlaymatFallbackBehavior() const = 0; }; #endif // COCKATRICE_INTERFACE_INTERFACE_SETTINGS_PROVIDER_H diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_tabs_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_tabs_settings_provider.h index 4403de569..054c4cd72 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_tabs_settings_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_tabs_settings_provider.h @@ -1,11 +1,17 @@ #ifndef COCKATRICE_INTERFACE_TABS_SETTINGS_PROVIDER_H #define COCKATRICE_INTERFACE_TABS_SETTINGS_PROVIDER_H +#include + class ITabsSettingsProvider { public: virtual ~ITabsSettingsProvider() = default; + [[nodiscard]] virtual int getStartupTabIndex() const = 0; + [[nodiscard]] virtual QString getStartupServerHost() const = 0; + [[nodiscard]] virtual QString getStartupServerPort() const = 0; + [[nodiscard]] virtual QString getStartupRoomName() const = 0; [[nodiscard]] virtual bool getTabVisualDeckStorageOpen() const = 0; [[nodiscard]] virtual bool getTabServerOpen() const = 0; [[nodiscard]] virtual bool getTabAccountOpen() const = 0; @@ -13,6 +19,9 @@ public: [[nodiscard]] virtual bool getTabReplaysOpen() const = 0; [[nodiscard]] virtual bool getTabAdminOpen() const = 0; [[nodiscard]] virtual bool getTabLogOpen() const = 0; + [[nodiscard]] virtual bool getTabReportOpen() const = 0; + [[nodiscard]] virtual bool getTabModerationOpen() const = 0; + [[nodiscard]] virtual bool getTabCardArtRulesOpen() const = 0; }; #endif // COCKATRICE_INTERFACE_TABS_SETTINGS_PROVIDER_H diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/noop_card_preference_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/noop_card_preference_provider.h index 3781f4b63..f846d51e8 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/noop_card_preference_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/noop_card_preference_provider.h @@ -9,11 +9,6 @@ public: { return {}; } - - [[nodiscard]] bool getIncludeRebalancedCards() const override - { - return true; - } }; #endif // COCKATRICE_NOOP_CARD_PREFERENCE_PROVIDER_H diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/noop_card_set_priority_controller.h b/libcockatrice_interfaces/libcockatrice/interfaces/noop_card_set_priority_controller.h index 8cc2011bd..94e1f0c9a 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/noop_card_set_priority_controller.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/noop_card_set_priority_controller.h @@ -34,6 +34,11 @@ public: return {0, true, true}; } + QStringList getEnabledSetNames() const override + { + return {}; + } + void saveSets(const QVector & /* data */) override { } diff --git a/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt b/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt index 840f14b08..a73e36dbb 100644 --- a/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt +++ b/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt @@ -29,3 +29,5 @@ add_library( target_include_directories(libcockatrice_models_database PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(libcockatrice_models_database PUBLIC libcockatrice_card libcockatrice_filters ${QT_CORE_MODULE}) + +target_precompile_headers(libcockatrice_models_database PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") diff --git a/libcockatrice_models/libcockatrice/models/database/card/card_completer_proxy_model.cpp b/libcockatrice_models/libcockatrice/models/database/card/card_completer_proxy_model.cpp index 387eb454f..b99b8b0f7 100644 --- a/libcockatrice_models/libcockatrice/models/database/card/card_completer_proxy_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card/card_completer_proxy_model.cpp @@ -1,11 +1,24 @@ #include "card_completer_proxy_model.h" +#include +#include +#include +#include +#include +#include + +class QObject; + CardCompleterProxyModel::CardCompleterProxyModel(QObject *parent) : QSortFilterProxyModel(parent) { } bool CardCompleterProxyModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const { + if (!sourceModel()) { + return false; + } + if (filterRegularExpression().pattern().isEmpty()) { return true; } @@ -13,6 +26,5 @@ bool CardCompleterProxyModel::filterAcceptsRow(int sourceRow, const QModelIndex QModelIndex index = sourceModel()->index(sourceRow, 0, sourceParent); QString data = index.data(Qt::DisplayRole).toString(); - // Ensure substring matching return data.contains(filterRegularExpression()); } diff --git a/libcockatrice_models/libcockatrice/models/database/card/card_completer_proxy_model.h b/libcockatrice_models/libcockatrice/models/database/card/card_completer_proxy_model.h index 91dc06335..8aa671af7 100644 --- a/libcockatrice_models/libcockatrice/models/database/card/card_completer_proxy_model.h +++ b/libcockatrice_models/libcockatrice/models/database/card/card_completer_proxy_model.h @@ -8,6 +8,9 @@ #define CARD_COMPLETER_PROXY_MODEL_H #include +#include + +class QObject; class CardCompleterProxyModel : public QSortFilterProxyModel { diff --git a/libcockatrice_models/libcockatrice/models/database/card/card_search_model.cpp b/libcockatrice_models/libcockatrice/models/database/card/card_search_model.cpp index d1fbbac2f..5bde6e3fc 100644 --- a/libcockatrice_models/libcockatrice/models/database/card/card_search_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card/card_search_model.cpp @@ -3,6 +3,10 @@ #include "../card_database_display_model.h" #include "../card_database_model.h" +#include +#include +#include +#include #include #include @@ -23,8 +27,14 @@ QVariant CardSearchModel::data(const QModelIndex &index, int role) const return QVariant(); } + const SearchResult &result = searchResults.at(index.row()); + if (role == Qt::DisplayRole) { - return searchResults.at(index.row()).card->getName(); + return result.card->getName(); + } + + if (role == CardInfoRole) { + return QVariant::fromValue(result.card); } return QVariant(); @@ -36,43 +46,90 @@ void CardSearchModel::updateSearchResults(const QString &query) searchResults.clear(); if (query.isEmpty() || !sourceModel) { + endResetModel(); return; } - // Set the filter for the display model - sourceModel->setCardName(query); + CardDatabaseModel *sourceDbModel = qobject_cast(sourceModel->sourceModel()); + if (!sourceDbModel) { + endResetModel(); + return; + } - // Collect matching cards and compute Levenshtein distance - for (int i = 0; i < sourceModel->rowCount(); ++i) { - QModelIndex modelIndex = sourceModel->index(i, 0); - QModelIndex sourceIndex = sourceModel->mapToSource(modelIndex); - CardDatabaseModel *sourceDbModel = qobject_cast(sourceModel->sourceModel()); + const QString lowerQuery = query.toLower(); - if (!sourceDbModel || !sourceIndex.isValid()) { - return; - } - - CardInfoPtr card = sourceDbModel->getCard(sourceIndex.row()); + QList prefixMatches; + QList containsMatches; + // Iterate the raw database model directly so results are always complete and fresh + const int rowCount = sourceDbModel->rowCount(); + for (int i = 0; i < rowCount; ++i) { + CardInfoPtr card = sourceDbModel->getCard(i); if (!card) { continue; } - int distance = levenshteinDistance(query.toLower(), card->getName().toLower()); - searchResults.append({card, distance}); + // The completer suggestions match against the same languages the card + // search uses, so typing a localized name finds the card. In Both mode + // either language can match. + for (const QString &matchName : searchableNames(card)) { + const QString lowerName = matchName.toLower(); + if (!lowerName.contains(lowerQuery)) { + continue; + } + + const int distance = levenshteinDistance(lowerQuery, lowerName); + + if (lowerName.startsWith(lowerQuery)) { + prefixMatches.append({card, distance}); + } else { + containsMatches.append({card, distance}); + } + break; + } } - // Sort by Levenshtein distance (lower distance = better match) - std::sort(searchResults.begin(), searchResults.end(), - [](const SearchResult &a, const SearchResult &b) { return a.distance < b.distance; }); + auto sortByDistanceThenLength = [this](const SearchResult &a, const SearchResult &b) { + if (a.distance != b.distance) { + return a.distance < b.distance; + } + return sortableName(a.card).size() < sortableName(b.card).size(); + }; - // Keep only the top 5 results + std::sort(prefixMatches.begin(), prefixMatches.end(), sortByDistanceThenLength); + std::sort(containsMatches.begin(), containsMatches.end(), sortByDistanceThenLength); + + // Prefix matches always come first, then contains-only matches + searchResults.reserve(prefixMatches.size() + containsMatches.size()); + searchResults.append(prefixMatches); + searchResults.append(containsMatches); + + // Keep only the top 10 results if (searchResults.size() > 10) { searchResults = searchResults.mid(0, 10); } - emit dataChanged(index(0, 0), index(rowCount() - 1, 0)); - emit layoutChanged(); - endResetModel(); } + +QStringList CardSearchModel::searchableNames(const CardInfoPtr &card) const +{ + if (searchLanguage.isEnglishOnly()) { + return {card->getName()}; + } + + const QString localizedName = card->getLocalizedName(searchLanguage.language); + if (searchLanguage.mode == SearchLanguageMode::Selected) { + return {localizedName}; + } + + return {card->getName(), localizedName}; +} + +QString CardSearchModel::sortableName(const CardInfoPtr &card) const +{ + if (searchLanguage.isEnglishOnly()) { + return card->getName(); + } + return card->getLocalizedName(searchLanguage.language); +} diff --git a/libcockatrice_models/libcockatrice/models/database/card/card_search_model.h b/libcockatrice_models/libcockatrice/models/database/card/card_search_model.h index bc4be7a0e..08d0e8636 100644 --- a/libcockatrice_models/libcockatrice/models/database/card/card_search_model.h +++ b/libcockatrice_models/libcockatrice/models/database/card/card_search_model.h @@ -7,14 +7,30 @@ #ifndef CARD_SEARCH_MODEL_H #define CARD_SEARCH_MODEL_H -#include "../card_database_display_model.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/card_localization.h" #include +#include +#include +#include +#include +#include +#include +#include + +class CardDatabaseDisplayModel; +class QObject; class CardSearchModel : public QAbstractListModel { Q_OBJECT public: + enum CardSearchRoles + { + CardInfoRole = Qt::UserRole + 1, + }; + explicit CardSearchModel(CardDatabaseDisplayModel *sourceModel, QObject *parent = nullptr); [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; @@ -22,6 +38,14 @@ public: void updateSearchResults(const QString &query); // Update results based on input + void setSearchLanguage(const CardSearchLanguage &searchLang) + { + if (searchLanguage == searchLang) { + return; + } + searchLanguage = searchLang; + } + private: struct SearchResult { @@ -29,8 +53,15 @@ private: int distance; }; + /** @brief The names a card is searched by with the current search language. */ + [[nodiscard]] QStringList searchableNames(const CardInfoPtr &card) const; + + /** @brief The name used to break distance ties when sorting suggestions. */ + [[nodiscard]] QString sortableName(const CardInfoPtr &card) const; + CardDatabaseDisplayModel *sourceModel; QList searchResults; + CardSearchLanguage searchLanguage; }; #endif // CARD_SEARCH_MODEL_H diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp index 724ee61f2..39bb8314c 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.cpp @@ -1,6 +1,23 @@ #include "card_database_display_model.h" #include "card_database_model.h" +#include "libcockatrice/card/card_localization.h" +#include "libcockatrice/filters/filter_string.h" +#include "libcockatrice/filters/filter_tree.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class QObject; CardDatabaseDisplayModel::CardDatabaseDisplayModel(QObject *parent) : QSortFilterProxyModel(parent), isToken(ShowAll), filterString(nullptr) @@ -179,7 +196,7 @@ bool CardDatabaseDisplayModel::filterAcceptsRow(int sourceRow, const QModelIndex } if (filterString != nullptr) { - if (filterTree != nullptr && !filterTree->acceptsCard(info)) { + if (filterTree != nullptr && !filterTree->acceptsCard(info, searchLanguage)) { return false; } return filterString->check(info); @@ -190,8 +207,14 @@ bool CardDatabaseDisplayModel::filterAcceptsRow(int sourceRow, const QModelIndex bool CardDatabaseDisplayModel::rowMatchesCardName(CardInfoPtr info) const { - if (!cardName.isEmpty() && !info->getName().contains(cardName, Qt::CaseInsensitive)) { - return false; + if (!cardName.isEmpty()) { + const bool matchesEnglish = info->getName().contains(cardName, Qt::CaseInsensitive); + const bool matchesLocalized = + !searchLanguage.isEnglishOnly() && + info->getLocalizedName(searchLanguage.language).contains(cardName, Qt::CaseInsensitive); + if (!matchesEnglish && !matchesLocalized) { + return false; + } } if (!cardNameSet.isEmpty() && !cardNameSet.contains(info->getName())) { @@ -199,7 +222,7 @@ bool CardDatabaseDisplayModel::rowMatchesCardName(CardInfoPtr info) const } if (filterTree != nullptr) { - return filterTree->acceptsCard(info); + return filterTree->acceptsCard(info, searchLanguage); } return true; @@ -235,6 +258,28 @@ void CardDatabaseDisplayModel::setFilterTree(FilterTree *_filterTree) invalidate(); } +void CardDatabaseDisplayModel::setStringFilter(const QString &_src) +{ + searchText = _src; + delete filterString; + filterString = new FilterString(_src, searchLanguage); + dirty(); +} + +void CardDatabaseDisplayModel::setSearchLanguage(const CardSearchLanguage &searchLang) +{ + if (searchLanguage == searchLang) { + return; + } + + searchLanguage = searchLang; + + if (filterString != nullptr) { + setStringFilter(searchText); + } + dirty(); +} + void CardDatabaseDisplayModel::filterTreeChanged() { invalidate(); diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h index c3145c356..d12dd1fb4 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h +++ b/libcockatrice_models/libcockatrice/models/database/card_database_display_model.h @@ -8,11 +8,21 @@ #ifndef COCKATRICE_CARD_DATABASE_DISPLAY_MODEL_H #define COCKATRICE_CARD_DATABASE_DISPLAY_MODEL_H +#include "libcockatrice/card/card_info.h" + +#include +#include #include +#include #include +#include #include +#include class FilterTree; +class QObject; +template class QMap; + class CardDatabaseDisplayModel : public QSortFilterProxyModel { Q_OBJECT @@ -32,6 +42,8 @@ private: FilterString *filterString; int loadedRowCount; QTimer dirtyTimer; + CardSearchLanguage searchLanguage; + QString searchText; /** The translation table that will be used for sanitizeCardName. */ static QMap characterTranslation; @@ -55,17 +67,13 @@ public: cardName = sanitizeCardName(_cardName, characterTranslation); dirty(); } - void setStringFilter(const QString &_src) - { - delete filterString; - filterString = new FilterString(_src); - dirty(); - } + void setStringFilter(const QString &_src); void setCardNameSet(const QSet &_cardNameSet) { cardNameSet = _cardNameSet; dirty(); } + void setSearchLanguage(const CardSearchLanguage &searchLang); void dirty() { diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_model.cpp b/libcockatrice_models/libcockatrice/models/database/card_database_model.cpp index 7b248a982..1ede554f8 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card_database_model.cpp @@ -1,8 +1,16 @@ #include "card_database_model.h" -#include +#include "libcockatrice/card/printing/printing_info.h" +#include "libcockatrice/card/set/card_set.h" + +#include +#include +#include +#include #include +class QObject; + #define CARDDBMODEL_COLUMNS 6 CardDatabaseModel::CardDatabaseModel(CardDatabase *_db, bool _showOnlyCardsFromEnabledSets, QObject *parent) @@ -111,19 +119,50 @@ bool CardDatabaseModel::checkCardHasAtLeastOneEnabledSet(const CardInfoPtr &card void CardDatabaseModel::cardDatabaseEnabledSetsChanged() { - // remove all the cards no more present in at least one enabled set + // Build the new card list in a single pass. + QList newCardList; + newCardList.reserve(cardList.size()); for (const CardInfoPtr &card : cardList) { - if (!checkCardHasAtLeastOneEnabledSet(card)) { - cardRemoved(card); + if (checkCardHasAtLeastOneEnabledSet(card)) { + newCardList.append(card); + } + } + for (const CardInfoPtr &card : db->getCardList()) { + if (!cardListSet.contains(card) && checkCardHasAtLeastOneEnabledSet(card)) { + newCardList.append(card); } } - // re-check all the card currently not shown, maybe their part of a newly-enabled set - for (const CardInfoPtr &card : db->getCardList()) { - if (!cardListSet.contains(card)) { - cardAdded(card); + if (newCardList == cardList) { + return; + } + + // Rebuild the whole list inside a single model reset instead of emitting + // per-card insert/remove notifications. With tens of thousands of cards the + // per-card path is the dominant cost of constructing a CardDatabaseModel. + QSet oldCardListSet = cardListSet; + QSet newCardListSet(newCardList.begin(), newCardList.end()); + + beginResetModel(); + + // Disconnect cards that are no longer shown. + for (const CardInfoPtr &card : cardList) { + if (!newCardListSet.contains(card)) { + disconnect(card.data(), nullptr, this, nullptr); } } + + cardList = newCardList; + cardListSet = newCardListSet; + + // Connect cards that are now shown for the first time. + for (const CardInfoPtr &card : cardList) { + if (!oldCardListSet.contains(card)) { + connect(card.data(), &CardInfo::cardInfoChanged, this, &CardDatabaseModel::cardInfoChanged); + } + } + + endResetModel(); } void CardDatabaseModel::cardAdded(const CardInfoPtr &card) diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_model.h b/libcockatrice_models/libcockatrice/models/database/card_database_model.h index ad84de6f4..208240109 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_model.h +++ b/libcockatrice_models/libcockatrice/models/database/card_database_model.h @@ -7,10 +7,17 @@ #ifndef CARDDATABASEMODEL_H #define CARDDATABASEMODEL_H +#include "libcockatrice/card/card_info.h" + #include #include +#include #include -#include +#include +#include + +class CardDatabase; +class QObject; class CardDatabaseModel : public QAbstractListModel { diff --git a/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.cpp b/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.cpp index 8fd5311f2..1bc1b9e38 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.cpp @@ -1,6 +1,19 @@ #include "card_sets_model.h" +#include "libcockatrice/card/database/card_database.h" +#include "libcockatrice/card/set/card_set.h" +#include "libcockatrice/interfaces/interface_card_set_priority_controller.h" + +#include +#include +#include +#include +#include +#include +#include #include +#include +#include SetsModel::SetsModel(CardDatabase *_db, QObject *parent) : QAbstractTableModel(parent), sets(_db->getSetList()) { diff --git a/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.h b/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.h index 2adf71def..354414dc8 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.h +++ b/libcockatrice_models/libcockatrice/models/database/card_set/card_sets_model.h @@ -7,13 +7,24 @@ #ifndef SETSMODEL_H #define SETSMODEL_H +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/set/card_set_list.h" + #include +#include #include +#include +#include #include #include -#include +#include +#include +#include +#include +#include -class SetsProxyModel; +class CardDatabase; +class QObject; class SetsMimeData : public QMimeData { diff --git a/libcockatrice_models/libcockatrice/models/database/token/token_display_model.cpp b/libcockatrice_models/libcockatrice/models/database/token/token_display_model.cpp index 4282c6dc8..e08183576 100644 --- a/libcockatrice_models/libcockatrice/models/database/token/token_display_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/token/token_display_model.cpp @@ -1,6 +1,12 @@ #include "token_display_model.h" #include "../card_database_model.h" +#include "card_database_display_model.h" +#include "libcockatrice/card/card_info.h" + +#include + +class QObject; TokenDisplayModel::TokenDisplayModel(QObject *parent) : CardDatabaseDisplayModel(parent) { diff --git a/libcockatrice_models/libcockatrice/models/database/token/token_display_model.h b/libcockatrice_models/libcockatrice/models/database/token/token_display_model.h index c8f767189..a8625368f 100644 --- a/libcockatrice_models/libcockatrice/models/database/token/token_display_model.h +++ b/libcockatrice_models/libcockatrice/models/database/token/token_display_model.h @@ -9,6 +9,11 @@ #include "../card_database_display_model.h" +#include +#include + +class QObject; + class TokenDisplayModel : public CardDatabaseDisplayModel { Q_OBJECT diff --git a/libcockatrice_models/libcockatrice/models/database/token/token_edit_model.cpp b/libcockatrice_models/libcockatrice/models/database/token/token_edit_model.cpp index 89a1f71b1..9636ed665 100644 --- a/libcockatrice_models/libcockatrice/models/database/token/token_edit_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/token/token_edit_model.cpp @@ -2,9 +2,13 @@ #include "../card_database_display_model.h" #include "../card_database_model.h" +#include "libcockatrice/card/set/card_set.h" +#include #include +class QObject; + TokenEditModel::TokenEditModel(QObject *parent) : CardDatabaseDisplayModel(parent) { } diff --git a/libcockatrice_models/libcockatrice/models/database/token/token_edit_model.h b/libcockatrice_models/libcockatrice/models/database/token/token_edit_model.h index 92dfaadd7..2f3398899 100644 --- a/libcockatrice_models/libcockatrice/models/database/token/token_edit_model.h +++ b/libcockatrice_models/libcockatrice/models/database/token/token_edit_model.h @@ -9,6 +9,11 @@ #include "../card_database_display_model.h" +#include +#include + +class QObject; + class TokenEditModel : public CardDatabaseDisplayModel { Q_OBJECT diff --git a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt index d4aee3686..324bed102 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt +++ b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt @@ -7,7 +7,8 @@ set(HEADERS deck_list_model.h deck_list_sort_filter_proxy_model.h) qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) add_library( - libcockatrice_models_deck_list STATIC ${MOC_SOURCES} deck_list_model.cpp deck_list_sort_filter_proxy_model.cpp + libcockatrice_models_deck_list STATIC ${MOC_SOURCES} deck_list_model.cpp deck_list_model_custom_zones.cpp + deck_list_sort_filter_proxy_model.cpp ) target_include_directories(libcockatrice_models_deck_list PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) @@ -15,3 +16,5 @@ target_include_directories(libcockatrice_models_deck_list PUBLIC ${CMAKE_CURRENT target_link_libraries( libcockatrice_models_deck_list PUBLIC libcockatrice_card libcockatrice_deck_list ${QT_CORE_MODULE} ) + +target_precompile_headers(libcockatrice_models_deck_list PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp index 9b43281c1..4b4088972 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp @@ -1,6 +1,27 @@ #include "deck_list_model.h" +#include "deck_list_model_custom_zones.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/format/format_legality_rules.h" +#include "libcockatrice/card/printing/exact_card.h" +#include "libcockatrice/card/printing/printing_info.h" +#include "libcockatrice/card/set/card_set.h" +#include "libcockatrice/deck_list/deck_list.h" +#include "libcockatrice/deck_list/deck_list_node_tree.h" +#include "libcockatrice/deck_list/tree/abstract_deck_list_node.h" +#include "libcockatrice/deck_list/tree/deck_list_card_node.h" + +#include +#include +#include +#include +#include +#include #include +#include +#include + +class QObject; DeckListModel::DeckListModel(QObject *parent) : QAbstractItemModel(parent), lastKnownColumn(1), lastKnownOrder(Qt::AscendingOrder) @@ -28,6 +49,16 @@ DeckListModel::~DeckListModel() delete root; } +void DeckListModel::setDisplayLanguage(const QString &lang) +{ + if (displayLang == lang) { + return; + } + displayLang = lang; + emit layoutAboutToBeChanged(); + emit layoutChanged(); +} + /** * @brief Extract the value from the card that is used for the group criteria. * @param info Pointer to card information. @@ -66,7 +97,8 @@ void DeckListModel::rebuildTree() for (int j = 0; j < currentZone->size(); j++) { auto *currentCard = dynamic_cast(currentZone->at(j)); - //! \todo Better sanity checking. + // Non-card children are custom zones; they are mirrored in a single + // pass below so each is mirrored exactly once. if (currentCard == nullptr) { continue; } @@ -82,8 +114,19 @@ void DeckListModel::rebuildTree() new DecklistModelCardNode(currentCard, groupNode); } + + // Custom zones nested under the board zone are mirrored as-is, with their + // cards as direct children (no further grouping). + DeckListModelCustomZones::mirrorCustomZones(currentZone, node); } + // The shadow tree was built in deck file order. Apply the active sort while + // the reset is still open so every consumer (tree view and visual editor) + // sees the canonical order from the start. sortShadowTree emits no signals, + // which is only valid before endResetModel closes the reset. + root->setSortMethod(lastKnownColumn == 0 ? DeckSortMethod::ByNumber : DeckSortMethod::ByName); + sortShadowTree(root, lastKnownOrder); + endResetModel(); refreshCardFormatLegalities(); @@ -154,6 +197,9 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const case DeckRoles::IsLegalRole: return true; + case DeckRoles::IsCustomZoneRole: + return DeckListModelCustomZones::isCustomZone(group); + default: return {}; } @@ -166,8 +212,15 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const switch (index.column()) { case DeckListModelColumns::CARD_AMOUNT: return card->getNumber(); - case DeckListModelColumns::CARD_NAME: + case DeckListModelColumns::CARD_NAME: { + if (role == Qt::DisplayRole) { + CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(card->getName()); + if (info) { + return info->getLocalizedName(displayLang); + } + } return card->getName(); + } case DeckListModelColumns::CARD_SET: return card->getCardSetShortName(); case DeckListModelColumns::CARD_COLLECTOR_NUMBER: @@ -190,6 +243,10 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const return card->getFormatLegality(); } + case DeckRoles::IsCustomZoneRole: { + return false; + } + default: { return {}; } @@ -327,6 +384,13 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent) return false; } + // Custom zone rows are managed through the deck tree, never removed as model rows. + for (int i = 0; i < count; i++) { + if (DeckListModelCustomZones::isCustomZone(node->at(row + i))) { + return false; + } + } + beginRemoveRows(parent, row, row + count - 1); for (int i = 0; i < count; i++) { AbstractDecklistNode *toDelete = node->takeAt(row); @@ -337,7 +401,8 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent) } endRemoveRows(); - if (node->empty() && (node != root)) { + // Empty criteria groups get pruned, but custom zones stay until explicitly deleted. + if (node->empty() && (node != root) && !DeckListModelCustomZones::isCustomZone(node)) { removeRows(parent.row(), 1, parent.parent()); } else { emitRecursiveUpdates(parent); @@ -351,7 +416,8 @@ bool DeckListModel::removeRows(int row, int count, const QModelIndex &parent) InnerDecklistNode *DeckListModel::createNodeIfNeeded(const QString &name, InnerDecklistNode *parent) { - auto *newNode = dynamic_cast(parent->findChild(name)); + // Group lookups must not resolve a mirrored custom zone that shares the name. + auto *newNode = DeckListModelCustomZones::findGroupChild(parent, name); if (!newNode) { beginInsertRows(nodeToIndex(parent), parent->size(), parent->size()); newNode = new InnerDecklistNode(name, parent); @@ -365,24 +431,44 @@ DecklistModelCardNode *DeckListModel::findCardNode(const QString &cardName, const QString &providerId, const QString &cardNumber) const { - InnerDecklistNode *zoneNode = dynamic_cast(root->findChild(zoneName)); - if (!zoneNode) { - return nullptr; - } - CardInfoPtr info = CardDatabaseManager::query()->getCardInfo(cardName); if (!info) { return nullptr; } - QString groupCriteria = extractGroupCriteriaValue(info, activeGroupCriteria); - InnerDecklistNode *groupNode = dynamic_cast(zoneNode->findChild(groupCriteria)); - if (!groupNode) { - return nullptr; + // 1. Board zone lookup: search the criteria groups, then the custom zones + // nested under the board. + if (auto *zoneNode = dynamic_cast(root->findChild(zoneName))) { + QString groupCriteria = extractGroupCriteriaValue(info, activeGroupCriteria); + if (auto *groupNode = DeckListModelCustomZones::findGroupChild(zoneNode, groupCriteria)) { + if (auto *card = dynamic_cast( + groupNode->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber))) { + return card; + } + } + + for (auto *child : *zoneNode) { + if (!DeckListModelCustomZones::isCustomZone(child)) { + continue; + } + auto *customZone = dynamic_cast(child); + if (!customZone) { + continue; + } + if (auto *card = dynamic_cast( + customZone->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber))) { + return card; + } + } } - return dynamic_cast( - groupNode->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber)); + // 2. Custom zone lookup by name (custom zone names are deck-unique). + if (auto *customZone = DeckListModelCustomZones::findSubZoneByName(root, zoneName)) { + return dynamic_cast( + customZone->findCardChildByNameProviderIdAndNumber(cardName, providerId, cardNumber)); + } + + return nullptr; } QModelIndex DeckListModel::findCard(const QString &cardName, @@ -423,29 +509,95 @@ QModelIndex DeckListModel::addCard(const ExactCard &card, const QString &zoneNam return {}; } - InnerDecklistNode *zoneNode = createNodeIfNeeded(zoneName, root); - CardInfoPtr cardInfo = card.getCardPtr(); PrintingInfo printingInfo = card.getPrinting(); - QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria); - InnerDecklistNode *groupNode = createNodeIfNeeded(groupCriteria, zoneNode); + InnerDecklistNode *cardParent = nullptr; - const QModelIndex parentIndex = nodeToIndex(groupNode); - auto *cardNode = dynamic_cast(groupNode->findCardChildByNameProviderIdAndNumber( + auto *boardNode = dynamic_cast(root->findChild(zoneName)); + auto *customZoneNode = boardNode ? nullptr : DeckListModelCustomZones::findSubZoneByName(root, zoneName); + + // Mirroring flattens nested deck sub-zones into shadow rows, so a shadow row + // index is only usable as a deck-tree position while both sides have the same + // direct-children shape. When they diverge, the card is appended to the deck + // zone instead of being written out of range. + InnerDecklistNode *deckCardParent = nullptr; + bool customZoneNeedsAppend = false; + + if (boardNode) { + // Board zone: cards are grouped by the active criteria. + QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria); + cardParent = createNodeIfNeeded(groupCriteria, boardNode); + } else if (customZoneNode) { + // Custom zone: cards live flat inside the zone. + cardParent = customZoneNode; + auto *listRoot = deckList->getTree()->getRoot(); + for (int i = 0; i < listRoot->size(); ++i) { + auto *boardZone = dynamic_cast(listRoot->at(i)); + if (!boardZone) { + continue; + } + deckCardParent = dynamic_cast(boardZone->findChild(zoneName)); + if (deckCardParent) { + break; + } + } + // A deck custom zone holding nested sub-zones mirrors with flattened rows, + // so a shadow row index does not map onto its direct children. + if (deckCardParent) { + for (int i = 0; i < deckCardParent->size(); ++i) { + if (dynamic_cast(deckCardParent->at(i))) { + customZoneNeedsAppend = true; + break; + } + } + } + } else { + // Not present in the shadow tree. The deck tree may still hold a custom + // zone that has not been mirrored (callers can add a zone and then a + // card without a rebuild). Check before falling back to creating a + // top-level zone the deck does not actually have. + auto *listRoot = deckList->getTree()->getRoot(); + bool hasDeckZone = false; + for (int i = 0; i < listRoot->size(); ++i) { + if (auto *boardZone = dynamic_cast(listRoot->at(i))) { + // Only real zones count: a card sitting directly under the board + // shares the name comparison but is not a zone, and treating it as + // one would recurse forever without mirroring anything. + if (dynamic_cast(boardZone->findChild(zoneName))) { + hasDeckZone = true; + break; + } + } + } + + if (hasDeckZone) { + rebuildTree(); + return addCard(card, zoneName); + } + + // Unknown zone: create a top-level zone (legacy behavior). + QString groupCriteria = extractGroupCriteriaValue(cardInfo, activeGroupCriteria); + auto *newZone = createNodeIfNeeded(zoneName, root); + cardParent = createNodeIfNeeded(groupCriteria, newZone); + } + + const QModelIndex parentIndex = nodeToIndex(cardParent); + auto *cardNode = dynamic_cast(cardParent->findCardChildByNameProviderIdAndNumber( card.getName(), printingInfo.getUuid(), printingInfo.getProperty("num"))); const auto cardSetName = printingInfo.getSet().isNull() ? "" : printingInfo.getSet()->getCorrectedShortName(); bool cardNodeAdded = false; if (!cardNode) { // Determine the correct index - int insertRow = findSortedInsertRow(groupNode, cardInfo); + int insertRow = findSortedInsertRow(cardParent, cardInfo); + int deckInsertRow = customZoneNeedsAppend ? -1 : insertRow; - auto *decklistCard = deckList->addCard(cardInfo->getName(), zoneName, insertRow, cardSetName, + auto *decklistCard = deckList->addCard(cardInfo->getName(), zoneName, deckInsertRow, cardSetName, printingInfo.getProperty("num"), printingInfo.getProperty("uuid")); beginInsertRows(parentIndex, insertRow, insertRow); - cardNode = new DecklistModelCardNode(decklistCard, groupNode, insertRow); + cardNode = new DecklistModelCardNode(decklistCard, cardParent, insertRow); endInsertRows(); cardNodeAdded = true; @@ -576,21 +728,41 @@ QModelIndex DeckListModel::nodeToIndex(AbstractDecklistNode *node) const return createIndex(node->getParent()->indexOf(node), 0, node); } +/** + * @brief Sorts a freshly built shadow subtree without emitting model signals. + * + * Used by rebuildTree while the model reset is still open (emitting layout + * changes during a reset is invalid). Reorders every node just like + * sortHelper does, but ignores the movement mapping because there are no + * persistent indices established yet. + */ +void DeckListModel::sortShadowTree(InnerDecklistNode *node, Qt::SortOrder order) +{ + // The mapping is not needed: fresh shadow nodes have no persistent indices yet. + (void)DeckListModelCustomZones::sortWithCustomZonesLast(root, node, order); + + for (int i = node->size() - 1; i >= 0; --i) { + if (auto *subNode = dynamic_cast(node->at(i))) { + sortShadowTree(subNode, order); + } + } +} + void DeckListModel::sortHelper(InnerDecklistNode *node, Qt::SortOrder order) { - // Sort children of node and save the information needed to - // update the list of persistent indexes. - QVector> sortResult = node->sort(order); + // Sort children (custom zones always sorted after groups within a board) and + // use the movement mapping to update the list of persistent indices. + const auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(root, node, order); QModelIndexList from, to; int columns = columnCount(); - for (int i = sortResult.size() - 1; i >= 0; --i) { - const int fromRow = sortResult[i].first; - const int toRow = sortResult[i].second; - AbstractDecklistNode *temp = node->at(toRow); + for (const auto &move : mapping) { + const int preSortRow = move.first; + const int finalRow = move.second; + AbstractDecklistNode *temp = node->at(finalRow); for (int j = 0; j < columns; ++j) { - from << createIndex(fromRow, j, temp); - to << createIndex(toRow, j, temp); + from << createIndex(preSortRow, j, temp); + to << createIndex(finalRow, j, temp); } } changePersistentIndexList(from, to); @@ -704,6 +876,15 @@ QList DeckListModel::getZones() const return zones; } +QStringList DeckListModel::getCustomZoneNames(const QString &boardZoneName) const +{ + QStringList zoneNames; + for (const auto *customZone : deckList->getTree()->getCustomZones(boardZoneName)) { + zoneNames.append(customZone->getName()); + } + return zoneNames; +} + static int maxAllowedForLegality(const FormatRules &format, const QString &legality) { for (const AllowedCount &c : format.allowedCounts) { diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h index 209ec8c42..7334d48ef 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h @@ -1,16 +1,26 @@ #ifndef DECKLISTMODEL_H #define DECKLISTMODEL_H +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/deck_list/tree/inner_deck_list_node.h" +#include "libcockatrice/utility/card_ref.h" + #include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.h> #include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/deck_list_card_node.h> #include #include -#include -#include +#include +#include +#include +#include +#include +#include +#include -class CardDatabase; -class QPrinter; -class QTextCursor; +class AbstractDecklistNode; +class DeckList; +class ExactCard; +class QObject; /** * @namespace DeckRoles @@ -30,7 +40,8 @@ enum { IsCardRole = Qt::UserRole + 1, /**< Indicates whether the item represents a card. */ DepthRole, /**< Depth level within the deck's grouping hierarchy. */ - IsLegalRole /**< Whether the card is legal in the current deck format. */ + IsLegalRole, /**< Whether the card is legal in the current deck format. */ + IsCustomZoneRole /**< Whether the item represents a custom zone nested under a board zone. */ }; } // namespace DeckRoles @@ -284,6 +295,16 @@ public: explicit DeckListModel(QObject *parent, const QSharedPointer &deckList); ~DeckListModel() override; + /** + * @brief Selects the language code for localized card names in the display role. + * + * The model never reads global settings itself; callers wire this to the card + * language setting (including reacting to its changes) rather than the model + * querying it. + * @param lang Language code; "en" shows the canonical English names. + */ + void setDisplayLanguage(const QString &lang); + /** * @brief Returns the root index of the model. * @return QModelIndex representing the root node. @@ -391,12 +412,21 @@ public: */ [[nodiscard]] QList getZones() const; + /** + * @brief Gets the names of the custom zones nested under the given board zone. + * + * @param boardZoneName The board zone to query (main/side/maybeboard) + * @return The custom zone names, in deck order + */ + [[nodiscard]] QStringList getCustomZoneNames(const QString &boardZoneName) const; + private: QSharedPointer deckList; /**< Pointer to the decklist providing the underlying data. */ InnerDecklistNode *root; /**< Root node of the model tree. */ DeckListModelGroupCriteria::Type activeGroupCriteria = DeckListModelGroupCriteria::MAIN_TYPE; int lastKnownColumn; /**< Last column used for sorting. */ Qt::SortOrder lastKnownOrder; /**< Last known sort order. */ + QString displayLang = "en"; /**< Language code for localized card names in the display role. */ InnerDecklistNode *createNodeIfNeeded(const QString &name, InnerDecklistNode *parent); QModelIndex nodeToIndex(AbstractDecklistNode *node) const; @@ -427,6 +457,7 @@ private: void emitRecursiveUpdates(const QModelIndex &index); void sortHelper(InnerDecklistNode *node, Qt::SortOrder order); + void sortShadowTree(InnerDecklistNode *node, Qt::SortOrder order); template T getNode(const QModelIndex &index) const { diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp new file mode 100644 index 000000000..419c38b04 --- /dev/null +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp @@ -0,0 +1,156 @@ +#include "deck_list_model_custom_zones.h" + +#include "deck_list_model.h" +#include "libcockatrice/deck_list/tree/abstract_deck_list_node.h" +#include "libcockatrice/deck_list/tree/inner_deck_list_node.h" + +#include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/deck_list_card_node.h> +#include +#include +#include +#include + +namespace DeckListModelCustomZones +{ + +bool isCustomZone(const AbstractDecklistNode *node) +{ + return dynamic_cast(node) != nullptr; +} + +namespace +{ + +/** + * @brief Flattens every card under @p zone into @p shadowZone, preserving order. + * + * Custom zones mirror as a single row level: cards nested in sub-zones of any + * depth are added as direct children of the mirrored zone so no card is left + * without a model row. + */ +void flattenCards(const InnerDecklistNode *zone, InnerDecklistNode *shadowZone) +{ + for (int k = 0; k < zone->size(); k++) { + if (auto *zoneCard = dynamic_cast(zone->at(k))) { + new DecklistModelCardNode(zoneCard, shadowZone); + } else if (auto *subZone = dynamic_cast(zone->at(k))) { + flattenCards(subZone, shadowZone); + } + } +} + +} // namespace + +void mirrorCustomZones(const InnerDecklistNode *deckBoardZone, InnerDecklistNode *shadowBoardZone) +{ + for (int j = 0; j < deckBoardZone->size(); j++) { + auto *customZone = dynamic_cast(deckBoardZone->at(j)); + if (!customZone) { + continue; + } + + auto *shadowZone = new DecklistModelSubZoneNode(customZone->getName(), shadowBoardZone); + flattenCards(customZone, shadowZone); + } +} + +InnerDecklistNode *findGroupChild(InnerDecklistNode *parent, const QString &name) +{ + for (int i = 0; i < parent->size(); i++) { + AbstractDecklistNode *child = parent->at(i); + if (isCustomZone(child)) { + continue; + } + auto *group = dynamic_cast(child); + if (group && group->getName() == name) { + return group; + } + } + return nullptr; +} + +DecklistModelSubZoneNode *findSubZoneByName(InnerDecklistNode *root, const QString &zoneName) +{ + for (int i = 0; i < root->size(); i++) { + auto *boardZone = dynamic_cast(root->at(i)); + if (!boardZone) { + continue; + } + + for (int j = 0; j < boardZone->size(); j++) { + auto *customZone = dynamic_cast(boardZone->at(j)); + if (customZone && customZone->getName() == zoneName) { + return customZone; + } + } + } + + return nullptr; +} + +namespace +{ + +/** + * @brief Sorts a node's children and returns the (preSortRow, finalRow) mapping. + */ +QList> plainSort(InnerDecklistNode *node, Qt::SortOrder order) +{ + const QVector> sortResult = node->sort(order); + + QList> mapping; + mapping.reserve(node->size()); + for (int i = 0; i < node->size(); ++i) { + mapping.append({sortResult[i].first, i}); + } + return mapping; +} + +/** + * @brief Sorts a board zone's children, then stably moves custom zones to the end. + * + * @return The (preSortRow, finalRow) mapping covering both the sort and the shift. + */ +QList> boardSort(InnerDecklistNode *node, Qt::SortOrder order) +{ + const QVector> sortResult = node->sort(order); + + QVector groups; + QVector customZones; + QHash preSortRowOf; + + groups.reserve(node->size()); + customZones.reserve(node->size()); + + for (int i = 0; i < node->size(); ++i) { + AbstractDecklistNode *child = node->at(i); + preSortRowOf.insert(child, sortResult[i].first); + if (isCustomZone(child)) { + customZones.append(child); + } else { + groups.append(child); + } + } + + QVector ordered = groups + customZones; + for (int i = 0; i < ordered.size(); ++i) { + node->replace(i, ordered[i]); + } + + QList> mapping; + mapping.reserve(ordered.size()); + for (int i = 0; i < ordered.size(); ++i) { + mapping.append({preSortRowOf.value(ordered[i]), i}); + } + return mapping; +} + +} // namespace + +QList> sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order) +{ + const bool isBoardZone = (node != root) && (node->getParent() == root); + return isBoardZone ? boardSort(node, order) : plainSort(node, order); +} + +} // namespace DeckListModelCustomZones diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h new file mode 100644 index 000000000..53bdeaa1e --- /dev/null +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h @@ -0,0 +1,101 @@ +#ifndef DECK_LIST_MODEL_CUSTOM_ZONES_H +#define DECK_LIST_MODEL_CUSTOM_ZONES_H + +#include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.h> +#include +#include +#include +#include + +class AbstractDecklistNode; + +/** + * @class DecklistModelSubZoneNode + * @ingroup DeckModels + * @brief Model node representing a custom zone nested under a board zone. + * + * Custom zones group cards by user-defined names (e.g. "Removal", "Utility") + * inside a board zone. They are mirrored from the underlying deck tree so that + * they can be told apart from criteria group nodes by type. + */ +class DecklistModelSubZoneNode : public InnerDecklistNode +{ +public: + using InnerDecklistNode::InnerDecklistNode; +}; + +/** + * @namespace DeckListModelCustomZones + * @ingroup DeckModels + * @brief Tree-level helpers for the deck list model's custom-zone shadow nodes. + * + * The deck list model keeps a second "shadow" tree of InnerDecklistNode that + * mirrors the canonical deck tree for grouping and sorting. Custom zones add a + * layer of bookkeeping to that shadow tree: they must be mirrored alongside + * criteria groups, always sort after the groups within a board, and be + * resolvable by deck-unique name. + * + * This namespace centralizes every "what is / where is a custom zone" decision + * so the model itself only wires the results into Qt model signals. + */ +namespace DeckListModelCustomZones +{ + +/** + * @brief Whether the given node is a custom zone (as opposed to a criteria group). + */ +[[nodiscard]] bool isCustomZone(const AbstractDecklistNode *node); + +/** + * @brief Finds a criteria-group child of @p parent by name, skipping custom zones. + * + * The shadow tree keeps criteria groups and mirrored custom zones as siblings + * under a board zone, and `InnerDecklistNode::findChild` matches both by name. + * Group lookups must not resolve a custom zone that happens to share the group + * name (e.g. a zone called "Creature"), so this searches only non-custom + * children. + * + * @param parent The shadow node whose children are searched. + * @param name The group name to find. + * @return The matching group node, or nullptr if none exists. + */ +[[nodiscard]] InnerDecklistNode *findGroupChild(InnerDecklistNode *parent, const QString &name); + +/** + * @brief Mirrors the custom zones of a deck board zone into its shadow board node. + * + * Each custom zone becomes a DecklistModelSubZoneNode under @p shadowBoardZone + * with its cards as direct (un-grouped) children. + * + * @param deckBoardZone The board zone in the canonical deck tree. + * @param shadowBoardZone The matching board zone in the model's shadow tree. + */ +void mirrorCustomZones(const InnerDecklistNode *deckBoardZone, InnerDecklistNode *shadowBoardZone); + +/** + * @brief Finds a custom zone in the shadow tree by deck-unique name. + * @param root Root of the shadow tree. + * @param zoneName The custom zone name to find. + * @return The matching custom zone node, or nullptr if not found. + */ +[[nodiscard]] DecklistModelSubZoneNode *findSubZoneByName(InnerDecklistNode *root, const QString &zoneName); + +/** + * @brief Sorts a shadow node's children, keeping a board's custom zones last. + * + * Sorting alone would interleave custom zones with criteria groups by name, but + * custom zones must always stay after the groups within a board, regardless of + * name. This applies the sort and, for board zones, stably moves the custom + * zones to the end. + * + * @param root Root of the shadow tree (used to classify board zones). + * @param node The shadow node whose children are reordered. + * @param order Sort order to apply. + * @return A list of (preSortRow, finalRow) pairs describing how each node moved. + */ +[[nodiscard]] QList> +sortWithCustomZonesLast(InnerDecklistNode *root, InnerDecklistNode *node, Qt::SortOrder order); + +} // namespace DeckListModelCustomZones + +#endif // DECK_LIST_MODEL_CUSTOM_ZONES_H diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.cpp b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.cpp index 3d433153a..dcb294ec8 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.cpp +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.cpp @@ -1,6 +1,16 @@ #include "deck_list_sort_filter_proxy_model.h" #include "deck_list_model.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/database/card_database_manager.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/printing/exact_card.h" + +#include +#include +#include +#include +#include bool DeckListSortFilterProxyModel::lessThan(const QModelIndex &left, const QModelIndex &right) const { diff --git a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.h b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.h index 1565417f0..522ed1e23 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.h +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_sort_filter_proxy_model.h @@ -7,8 +7,13 @@ #ifndef COCKATRICE_DECK_LIST_SORT_FILTER_PROXY_MODEL_H #define COCKATRICE_DECK_LIST_SORT_FILTER_PROXY_MODEL_H +#include #include -#include +#include +#include +#include + +class QObject; class DeckListSortFilterProxyModel : public QSortFilterProxyModel { diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt index c4a8e4648..53ad524b4 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt @@ -2,9 +2,9 @@ set(CMAKE_AUTOMOC ON) set(CMAKE_AUTOUIC ON) set(CMAKE_AUTORCC ON) -set(HEADERS abstract_client.h) +set(HEADERS abstract_client.h latency_tracker.h) -set(SOURCES abstract_client.cpp) +set(SOURCES abstract_client.cpp latency_tracker.cpp) qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) @@ -19,3 +19,5 @@ target_link_libraries( PUBLIC ${COCKATRICE_QT_VERSION_NAME}::Network ${COCKATRICE_QT_VERSION_NAME}::WebSockets libcockatrice_protocol libcockatrice_network_server_remote ) + +target_precompile_headers(libcockatrice_network_client_abstract PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp index 916f4351b..d66653fb1 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp @@ -1,6 +1,26 @@ #include "abstract_client.h" +#include "libcockatrice/protocol/pb/admin_commands.pb.h" +#include "libcockatrice/protocol/pb/developer_commands.pb.h" +#include "libcockatrice/protocol/pb/game_event_container.pb.h" +#include "libcockatrice/protocol/pb/moderator_commands.pb.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/room_commands.pb.h" +#include "libcockatrice/protocol/pb/room_event.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_user.pb.h" +#include "libcockatrice/protocol/pb/session_commands.pb.h" +#include "libcockatrice/protocol/pb/session_event.pb.h" + +#include +#include +#include +#include +#include +#include +#include #include +#include +#include #include #include #include @@ -28,6 +48,7 @@ AbstractClient::AbstractClient(QObject *parent) qRegisterMetaType("Response"); qRegisterMetaType("Response::ResponseCode"); qRegisterMetaType("ClientStatus"); + qRegisterMetaType("LatencyTracker::Stats"); qRegisterMetaType("RoomEvent"); qRegisterMetaType("GameEventContainer"); qRegisterMetaType("Event_ServerIdentification"); @@ -71,6 +92,8 @@ void AbstractClient::processProtocolItem(const ServerMessage &item) } pendingCommands.remove(cmdId); + recordLatency(*pend); + pend->processResponse(response); pend->deleteLater(); break; @@ -161,9 +184,62 @@ void AbstractClient::queuePendingCommand(PendingCommand *pend) pendingCommands.insert(cmdId, pend); + pend->startTiming(); sendCommandContainer(pend->getCommandContainer()); } +namespace +{ +constexpr int STATS_EMIT_INTERVAL_MS = 1000; +// Game actions are what players perceive as lag. Surface unusually slow ones +// without requiring debug logging to be enabled. +constexpr qint64 SLOW_GAME_COMMAND_WARN_MS = 1500; +} // namespace + +void AbstractClient::recordLatency(PendingCommand &pend) +{ + const qint64 elapsed = pend.elapsedMs(); + if (elapsed < 0) { + return; + } + + latencyTracker.addSample(elapsed); + + if (AbstractClientLog().isDebugEnabled()) { + qCDebug(AbstractClientLog).noquote() + << "command RTT:" << elapsed << "ms (cmd_id" << pend.getCommandContainer().cmd_id() << ")"; + } + + if (elapsed >= SLOW_GAME_COMMAND_WARN_MS && pend.getCommandContainer().game_command_size() > 0) { + qCWarning(AbstractClientLog).noquote() + << "slow game command round trip:" << elapsed << "ms | " << getSafeDebugString(pend.getCommandContainer()); + } + + // Emit aggregated stats at most once per StatsEmitIntervalMs so that the + // per-command hot path stays free of signal traffic. The keepalive ping + // guarantees a fresh sample roughly every second while connected. + if (!statsEmitClockStarted || statsEmitClock.elapsed() >= STATS_EMIT_INTERVAL_MS) { + statsEmitClock.start(); + statsEmitClockStarted = true; + const LatencyTracker::Stats stats = latencyTracker.stats(); + + QList samples; + samples.reserve(stats.sampleCount); + for (qint64 sample : latencyTracker.recentSamples()) { + samples.append(static_cast(sample)); + } + + emit pingStatsUpdated(stats, samples); + } +} + +void AbstractClient::clearLatencyStats() +{ + latencyTracker.clear(); + statsEmitClockStarted = false; + emit pingStatsUpdated(LatencyTracker::Stats{}, {}); +} + PendingCommand *AbstractClient::prepareSessionCommand(const ::google::protobuf::Message &cmd) { CommandContainer cont; @@ -196,3 +272,24 @@ PendingCommand *AbstractClient::prepareAdminCommand(const ::google::protobuf::Me c->GetReflection()->MutableMessage(c, cmd.GetDescriptor()->FindExtensionByName("ext"))->CopyFrom(cmd); return new PendingCommand(cont); } + +PendingCommand *AbstractClient::prepareDeveloperCommand(const ::google::protobuf::Message &cmd) +{ + CommandContainer cont; + DeveloperCommand *c = cont.add_developer_command(); + // A developer command message may also be usable through other command + // families, so select the extension scoped to DeveloperCommand rather than + // guessing by name. + const ::google::protobuf::Descriptor *cmdDescriptor = cmd.GetDescriptor(); + const ::google::protobuf::Descriptor *developerDescriptor = DeveloperCommand::descriptor(); + const ::google::protobuf::FieldDescriptor *developerExtension = nullptr; + for (int i = 0; i < cmdDescriptor->extension_count(); ++i) { + if (cmdDescriptor->extension(i)->containing_type() == developerDescriptor) { + developerExtension = cmdDescriptor->extension(i); + break; + } + } + Q_ASSERT(developerExtension != nullptr); + c->GetReflection()->MutableMessage(c, developerExtension)->CopyFrom(cmd); + return new PendingCommand(cont); +} diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h index 982aa6bf3..442e96eb2 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h +++ b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h @@ -7,10 +7,33 @@ #ifndef ABSTRACTCLIENT_H #define ABSTRACTCLIENT_H +#include "latency_tracker.h" + +#include +#include +#include #include -#include -#include +#include +#include +#include + +// IWYU pragma: keep +// ServerInfo_User appears in signal signatures, including inside QList, so the +// moc-generated code needs the complete type. +#include #include +#include + +namespace google +{ +namespace protobuf +{ +class Message; +} // namespace protobuf +} // namespace google +template class QList; + +inline Q_LOGGING_CATEGORY(AbstractClientLog, "abstract_client"); class PendingCommand; class CommandContainer; @@ -30,7 +53,6 @@ class Event_NotifyUser; class Event_ConnectionClosed; class Event_ServerShutdown; class Event_ReplayAdded; -class FeatureSet; enum ClientStatus { @@ -54,6 +76,18 @@ signals: void statusChanged(ClientStatus _status); void maxPingTime(int seconds, int maxSeconds); + /** + * @brief Aggregated round-trip statistics and a chronological snapshot of + * the rolling window, emitted at most once per second. + * + * All values in the stats struct are in milliseconds; sampleCount is the + * number of samples currently in the rolling window. The samples list is + * ordered oldest first so graphs can redraw without polling the tracker + * across threads. Emitted from the client thread. The connection to UI + * objects is automatically queued across threads. + */ + void pingStatsUpdated(const LatencyTracker::Stats &stats, const QList &samplesMs); + // Room events void roomEventReceived(const RoomEvent &event); // Game events @@ -85,6 +119,11 @@ private: int nextCmdId; mutable QMutex clientMutex; ClientStatus status; + LatencyTracker latencyTracker; + QElapsedTimer statsEmitClock; + bool statsEmitClockStarted = false; + + void recordLatency(PendingCommand &pend); private slots: void queuePendingCommand(PendingCommand *pend); protected slots: @@ -113,6 +152,16 @@ public: void sendCommand(const CommandContainer &cont); void sendCommand(PendingCommand *pend); + /** + * @brief Drops all recorded round-trip samples and resets the stats + * emission throttle, emitting zeroed stats so that UI listeners can + * clear their display. + * + * Must be called from the client thread (as RemoteClient's disconnect + * path does). The tracker is deliberately lock-free. + */ + void clearLatencyStats(); + bool getServerSupportsPasswordHash() const { return serverSupportsPasswordHash; @@ -140,6 +189,7 @@ public: static PendingCommand *prepareRoomCommand(const ::google::protobuf::Message &cmd, int roomId); static PendingCommand *prepareModeratorCommand(const ::google::protobuf::Message &cmd); static PendingCommand *prepareAdminCommand(const ::google::protobuf::Message &cmd); + static PendingCommand *prepareDeveloperCommand(const ::google::protobuf::Message &cmd); QMap clientFeatures; }; diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/latency_tracker.cpp b/libcockatrice_network/libcockatrice/network/client/abstract/latency_tracker.cpp new file mode 100644 index 000000000..bc6230621 --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/client/abstract/latency_tracker.cpp @@ -0,0 +1,63 @@ +#include "latency_tracker.h" + +#include +#include +#include +#include +#include + +void LatencyTracker::addSample(qint64 ms) +{ + samples[static_cast(head)] = ms; + head = (head + 1) % WindowSize; + if (count < WindowSize) { + ++count; + } +} + +QList LatencyTracker::recentSamples() const +{ + QList result; + result.reserve(count); + for (int i = count; i > 0; --i) { + const int index = (head + WindowSize - i) % WindowSize; + result.append(samples[static_cast(index)]); + } + return result; +} + +LatencyTracker::Stats LatencyTracker::stats() const +{ + if (count == 0) { + return {}; + } + + QList sorted(samples.cbegin(), samples.cbegin() + count); + std::sort(sorted.begin(), sorted.end()); + + Stats s; + s.sampleCount = count; + s.lastMs = samples[static_cast((head + WindowSize - 1) % WindowSize)]; + s.maxMs = sorted.last(); + + const int n = count; + if (n % 2 == 1) { + s.medianMs = sorted[n / 2]; + } else { + s.medianMs = (sorted[n / 2 - 1] + sorted[n / 2]) / 2; + } + + // Nearest-rank percentile: smallest value in the list such that at least + // 95% of the samples are <= it. + const int p95Index = qMax(0, qCeil(0.95 * static_cast(n)) - 1); + s.p95Ms = sorted[p95Index]; + + return s; +} + +void LatencyTracker::clear() +{ + samples.fill(0); + head = 0; + count = 0; +} diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/latency_tracker.h b/libcockatrice_network/libcockatrice/network/client/abstract/latency_tracker.h new file mode 100644 index 000000000..cae11ea4d --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/client/abstract/latency_tracker.h @@ -0,0 +1,53 @@ +/** + * @file latency_tracker.h + * @ingroup Client + */ + +#ifndef LATENCY_TRACKER_H +#define LATENCY_TRACKER_H + +#include +#include +#include +#include +#include + +/** + * @brief Fixed-capacity rolling window of network round-trip time samples. + * + * The hot path (addSample) is a single array store and is intentionally free of + * allocations, locks, or signal emissions so that recording one sample per + * completed command cannot affect gameplay performance. Aggregate statistics + * are only computed on demand in stats(), which callers should throttle. + */ +class LatencyTracker +{ +public: + static constexpr int WindowSize = 64; + + struct Stats + { + qint64 lastMs = 0; ///< most recently added sample + qint64 medianMs = 0; ///< median over the current window + qint64 p95Ms = 0; ///< 95th percentile over the current window + qint64 maxMs = 0; ///< maximum over the current window + int sampleCount = 0; ///< number of samples currently in the window + }; + + void addSample(qint64 ms); + Stats stats() const; + + /// Snapshot of the current window in chronological order (oldest first). + QList recentSamples() const; + + void clear(); + +private: + std::array samples{}; + int head = 0; ///< index where the next sample will be written + int count = 0; ///< number of valid samples, capped at WindowSize +}; + +Q_DECLARE_METATYPE(LatencyTracker::Stats) + +#endif diff --git a/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt index 2ac12e1fe..08c12b7f8 100644 --- a/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt @@ -19,3 +19,5 @@ target_link_libraries( PUBLIC ${COCKATRICE_QT_VERSION_NAME}::Network ${COCKATRICE_QT_VERSION_NAME}::WebSockets libcockatrice_network_client_abstract ) + +target_precompile_headers(libcockatrice_network_client_local PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") diff --git a/libcockatrice_network/libcockatrice/network/client/local/local_client.cpp b/libcockatrice_network/libcockatrice/network/client/local/local_client.cpp index eefa3a2f3..c1c1cc976 100644 --- a/libcockatrice_network/libcockatrice/network/client/local/local_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/local/local_client.cpp @@ -1,9 +1,17 @@ #include "local_client.h" #include "../../server/local/local_server_interface.h" +#include "abstract_client.h" +#include "libcockatrice/protocol/pb/commands.pb.h" +#include "libcockatrice/protocol/pb/server_message.pb.h" +#include +#include #include #include +#include + +class QObject; LocalClient::LocalClient(LocalServerInterface *_lsi, const QString &_playerName, diff --git a/libcockatrice_network/libcockatrice/network/client/local/local_client.h b/libcockatrice_network/libcockatrice/network/client/local/local_client.h index 9e1c89ddd..3ae570255 100644 --- a/libcockatrice_network/libcockatrice/network/client/local/local_client.h +++ b/libcockatrice_network/libcockatrice/network/client/local/local_client.h @@ -10,6 +10,11 @@ #include "../abstract/abstract_client.h" #include +#include +#include + +class QObject; +class ServerMessage; inline Q_LOGGING_CATEGORY(LocalClientLog, "local_client"); diff --git a/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt index cb68d0c37..c32ca0aa8 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt @@ -19,3 +19,5 @@ target_link_libraries( PUBLIC ${COCKATRICE_QT_VERSION_NAME}::Network ${COCKATRICE_QT_VERSION_NAME}::WebSockets libcockatrice_network_client_abstract libcockatrice_interfaces libcockatrice_utility libcockatrice_protocol ) + +target_precompile_headers(libcockatrice_network_client_remote PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp index 7e20f2722..feda9e3f4 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp @@ -1,5 +1,8 @@ #include "remote_client.h" +#include "abstract_client.h" +#include "libcockatrice/protocol/pb/commands.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_user.pb.h" #include "version_string.h" #include @@ -7,13 +10,21 @@ #include #include #include +#include +#include +#include +#include +#include #include #include +#include #include +#include +#include +#include #include #include #include -#include #include #include #include @@ -22,6 +33,10 @@ #include #include #include +#include + +class Event_ConnectionClosed; +class QObject; static const unsigned int protocolVersion = 14; @@ -543,6 +558,7 @@ void RemoteClient::doDisconnectFromServer() delete i; } pendingCommands.clear(); + clearLatencyStats(); setStatus(StatusDisconnected); if (websocket->isValid()) { @@ -558,6 +574,15 @@ void RemoteClient::ping() PendingCommand *pend = i.next().value(); if (pend->tick() > maxTimeout) { i.remove(); + // Answer the command instead of dropping it silently: the connection may still + // be alive (other traffic keeps lastDataReceived fresh) while this single + // command stalled, and awaiters such as TabServer's room-join dedup can only + // release their in-flight markers when `finished` fires. A silent drop wedges + // them permanently - e.g. a room whose join timing never surfaces again. + Response response; + response.set_response_code(Response::RespNotConnected); + response.set_cmd_id(pend->getCommandContainer().cmd_id()); + pend->processResponse(response); pend->deleteLater(); } } diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h index 862dac06e..4d8c24fd2 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h @@ -8,11 +8,23 @@ #define REMOTECLIENT_H #include "../abstract/abstract_client.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/session_commands.pb.h" +#include +#include #include +#include +#include #include -#include -#include +#include +#include + +class Event_ConnectionClosed; +class Event_ServerIdentification; +class INetworkSettingsProvider; +class QObject; +template class QList; inline Q_LOGGING_CATEGORY(RemoteClientLog, "remote_client"); diff --git a/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt index 494ae1294..020753d7f 100644 --- a/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt @@ -15,3 +15,5 @@ add_dependencies(libcockatrice_network_server_local libcockatrice_protocol) target_include_directories(libcockatrice_network_server_local PUBLIC .) target_link_libraries(libcockatrice_network_server_local PUBLIC ${COCKATRICE_QT_MODULES} libcockatrice_protocol) + +target_precompile_headers(libcockatrice_network_server_local PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") diff --git a/libcockatrice_network/libcockatrice/network/server/local/local_server.cpp b/libcockatrice_network/libcockatrice/network/server/local/local_server.cpp index 8f9d82aa4..ebfc9bdeb 100644 --- a/libcockatrice_network/libcockatrice/network/server/local/local_server.cpp +++ b/libcockatrice_network/libcockatrice/network/server/local/local_server.cpp @@ -1,8 +1,17 @@ #include "local_server.h" +#include "../remote/server.h" +#include "../remote/server_database_interface.h" +#include "../remote/server_protocolhandler.h" +#include "libcockatrice/protocol/pb/serverinfo_user.pb.h" #include "local_server_interface.h" #include <../remote/server_room.h> +#include +#include +#include + +class QObject; LocalServer::LocalServer(QObject *parent) : Server(parent) { diff --git a/libcockatrice_network/libcockatrice/network/server/local/local_server.h b/libcockatrice_network/libcockatrice/network/server/local/local_server.h index 0112afa72..ee8d487d2 100644 --- a/libcockatrice_network/libcockatrice/network/server/local/local_server.h +++ b/libcockatrice_network/libcockatrice/network/server/local/local_server.h @@ -9,8 +9,11 @@ #include <../remote/server.h> #include <../remote/server_database_interface.h> +#include +#include class LocalServerInterface; +class QObject; class LocalServer : public Server { diff --git a/libcockatrice_network/libcockatrice/network/server/local/local_server_interface.cpp b/libcockatrice_network/libcockatrice/network/server/local/local_server_interface.cpp index bb86198af..b7c8d4bfa 100644 --- a/libcockatrice_network/libcockatrice/network/server/local/local_server_interface.cpp +++ b/libcockatrice_network/libcockatrice/network/server/local/local_server_interface.cpp @@ -1,8 +1,10 @@ #include "local_server_interface.h" +#include "../remote/server_protocolhandler.h" #include "local_server.h" -#include +class CommandContainer; +class Server_DatabaseInterface; LocalServerInterface::LocalServerInterface(LocalServer *_server, Server_DatabaseInterface *_databaseInterface) : Server_ProtocolHandler(_server, _databaseInterface, _server) diff --git a/libcockatrice_network/libcockatrice/network/server/local/local_server_interface.h b/libcockatrice_network/libcockatrice/network/server/local/local_server_interface.h index 44e3b3e53..8aadb40ca 100644 --- a/libcockatrice_network/libcockatrice/network/server/local/local_server_interface.h +++ b/libcockatrice_network/libcockatrice/network/server/local/local_server_interface.h @@ -8,8 +8,13 @@ #define LOCALSERVERINTERFACE_H #include <../remote/server_protocolhandler.h> +#include +#include class LocalServer; +class CommandContainer; +class ServerMessage; +class Server_DatabaseInterface; class LocalServerInterface : public Server_ProtocolHandler { diff --git a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt index 9fb63c221..5840f80c0 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt @@ -10,7 +10,12 @@ set(HEADERS game/server_card.h game/server_cardzone.h game/server_counter.h + game/game_config.h + game/server_deck_validation_strategy.h game/server_game.h + game/server_game_lifecycle_strategy.h + game/server_match_result_strategy.h + game/server_match_game_factory.h game/server_player.h game/server_spectator.h server.h @@ -57,3 +62,5 @@ target_link_libraries( libcockatrice_network_server_remote PUBLIC libcockatrice_protocol libcockatrice_utility libcockatrice_rng libcockatrice_deck_list ${COCKATRICE_QT_MODULES} ) + +target_precompile_headers(libcockatrice_network_server_remote PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/game_config.h b/libcockatrice_network/libcockatrice/network/server/remote/game/game_config.h new file mode 100644 index 000000000..baf946632 --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/game_config.h @@ -0,0 +1,26 @@ +#ifndef GAME_CONFIG_H +#define GAME_CONFIG_H + +#include +#include +#include + +struct GameConfig +{ + ServerInfo_User creatorInfo; + int gameId = -1; + QString description; + QString password; + int maxPlayers = 2; + QList gameTypes; + bool onlyBuddies = false; + bool onlyRegistered = false; + bool spectatorsAllowed = false; + bool spectatorsNeedPassword = false; + bool spectatorsCanTalk = true; + bool spectatorsSeeEverything = true; + int startingLifeTotal = 20; + bool shareDecklistsOnLoad = true; +}; + +#endif diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp index 5c0fdf944..5b8d901bb 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.cpp @@ -4,10 +4,16 @@ #include "../server_abstractuserinterface.h" #include "../server_database_interface.h" #include "../server_room.h" -#include "server_card.h" +#include "game/server_abstract_player.h" +#include "libcockatrice/protocol/pb/event_leave.pb.h" +#include "libcockatrice/protocol/pb/game_commands.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_playerproperties.pb.h" #include "server_game.h" -#include "server_player.h" +#include "server_response_containers.h" +#include "serverinfo_user_container.h" +#include +#include #include #include #include @@ -37,6 +43,7 @@ #include #include #include +#include #include #include #include @@ -49,6 +56,7 @@ #include #include #include +#include Server_AbstractParticipant::Server_AbstractParticipant(Server_Game *_game, int _playerId, @@ -144,6 +152,13 @@ Response::ResponseCode Server_AbstractParticipant::cmdSetSideboardLock(const Com return Response::RespFunctionNotAllowed; } +Response::ResponseCode Server_AbstractParticipant::cmdSetPlaymat(const Command_SetPlaymat & /*cmd*/, + ResponseContainer & /*rc*/, + GameEventStorage & /*ges*/) +{ + return Response::RespFunctionNotAllowed; +} + Response::ResponseCode Server_AbstractParticipant::cmdConcede(const Command_Concede & /*cmd*/, ResponseContainer & /*rc*/, GameEventStorage & /*ges*/) @@ -525,6 +540,9 @@ Server_AbstractParticipant::processGameCommand(const GameCommand &command, Respo case GameCommand::REVERSE_TURN: return cmdReverseTurn(command.GetExtension(Command_ReverseTurn::ext), rc, ges); break; + case GameCommand::SET_PLAYMAT: + return cmdSetPlaymat(command.GetExtension(Command_SetPlaymat::ext), rc, ges); + break; default: return Response::RespInvalidCommand; } diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h index a24fa5799..195da9b7f 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_participant.h @@ -5,8 +5,8 @@ #include "server_arrowtarget.h" #include -#include #include +#include class Server_Game; class Server_AbstractUserInterface; @@ -17,7 +17,6 @@ class GameEventContainer; class GameEventStorage; class ResponseContainer; class GameCommand; - class Command_KickFromGame; class Command_LeaveGame; class Command_GameSay; @@ -52,6 +51,7 @@ class Command_SetSideboardPlan; class Command_DeckSelect; class Command_SetSideboardLock; class Command_ChangeZoneProperties; +class Command_SetPlaymat; class Server_AbstractParticipant : public Server_ArrowTarget, public ServerInfo_User_Container { @@ -124,6 +124,8 @@ public: cmdSetSideboardPlan(const Command_SetSideboardPlan &cmd, ResponseContainer &rc, GameEventStorage &ges); virtual Response::ResponseCode cmdSetSideboardLock(const Command_SetSideboardLock &cmd, ResponseContainer &rc, GameEventStorage &ges); + virtual Response::ResponseCode + cmdSetPlaymat(const Command_SetPlaymat &cmd, ResponseContainer &rc, GameEventStorage &ges); virtual Response::ResponseCode cmdGameSay(const Command_GameSay &cmd, ResponseContainer &rc, GameEventStorage &ges); virtual Response::ResponseCode cmdShuffle(const Command_Shuffle &cmd, ResponseContainer &rc, GameEventStorage &ges); virtual Response::ResponseCode diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp index 5bf27eebb..47c810d0d 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.cpp @@ -1,14 +1,33 @@ #include "server_abstract_player.h" +#include "game/server_abstract_participant.h" +#include "game/server_arrowtarget.h" +#include "libcockatrice/protocol/pb/card_attributes.pb.h" +#include "libcockatrice/protocol/pb/color.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_arrow.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_card.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_cardcounter.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_playerproperties.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_zone.pb.h" +#include "libcockatrice/utility/card_ref.h" +#include "libcockatrice/utility/playmat_params.h" #include "server_arrow.h" #include "server_card.h" #include "server_cardzone.h" #include "server_game.h" #include "server_move_card_struct.h" +#include "server_response_containers.h" -#include -#include +#include +#include +#include +#include +#include +#include +#include #include +#include +#include #include #include #include @@ -50,8 +69,11 @@ #include #include #include -#include #include +#include +#include + +class Server_AbstractUserInterface; Server_AbstractPlayer::Server_AbstractPlayer(Server_Game *_game, int _playerId, @@ -66,6 +88,15 @@ Server_AbstractPlayer::Server_AbstractPlayer(Server_Game *_game, Server_AbstractPlayer::~Server_AbstractPlayer() = default; +int Server_AbstractPlayer::getCardCount() const +{ + int result = 0; + for (auto *zone : zones) { + result += zone->getCards().size(); + } + return result; +} + void Server_AbstractPlayer::prepareDestroy() { delete deck; @@ -1631,7 +1662,9 @@ void Server_AbstractPlayer::getInfo(ServerInfo_Player *info, { getProperties(*info->mutable_properties(), withUserInfo); - if (deck) { + // Deck lists are only shared with other players when the game is in Open Decklists mode, + // so a player joining an open lobby can see every deck that was loaded before they joined. + if (deck && (recipient == this || game->getShareDecklistsOnLoad())) { info->set_deck_list(deck->writeToString_Native().toStdString()); } @@ -1651,5 +1684,13 @@ void Server_AbstractPlayer::getPlayerProperties(ServerInfo_PlayerProperties &res result.set_ready_start(readyStart); if (deck) { result.set_deck_hash(deck->getDeckHash().toStdString()); + const auto &playmat = deck->getPlaymat(); + auto *playmatParams = result.mutable_playmat_params(); + playmatParams->set_card_name(playmat.card.name.toStdString()); + playmatParams->set_card_provider_id(playmat.card.providerId.toStdString()); + playmatParams->set_margin_pct_l(playmat.params.marginPctL); + playmatParams->set_margin_pct_r(playmat.params.marginPctR); + playmatParams->set_vertical_offset(playmat.params.verticalOffset); + playmatParams->set_zoom(playmat.params.zoom); } } diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.h index 85fbc0557..eff5034b4 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_abstract_player.h @@ -1,25 +1,32 @@ #ifndef ABSTRACT_PLAYER_H #define ABSTRACT_PLAYER_H -#include "../serverinfo_user_container.h" +#include "libcockatrice/protocol/pb/response.pb.h" #include "server_abstract_participant.h" #include #include +#include class CardToMove; class DeckList; class Server_Arrow; class Server_Card; class Server_CardZone; -class Server_Counter; struct MoveCardStruct; +class GameEventStorage; +class ServerInfo_User; +class Server_AbstractUserInterface; +class Server_Game; +enum CardAttribute : int; +template class QList; class Server_AbstractPlayer : public Server_AbstractParticipant { Q_OBJECT private: class MoveCardCompareFunctor; + QMap arrows; void sendCreateTokenEvents(Server_CardZone *zone, Server_Card *card, int xCoord, int yCoord, GameEventStorage &ges); @@ -43,6 +50,8 @@ public: Server_AbstractUserInterface *_handler); ~Server_AbstractPlayer() override; void prepareDestroy() override; + /// Total cards across all of this player's zones. The caller must hold the game's mutex. + int getCardCount() const; const DeckList *getDeckList() const { return deck; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_arrow.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_arrow.cpp index 2ad38b977..b5ccff8fe 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_arrow.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_arrow.cpp @@ -1,10 +1,16 @@ #include "server_arrow.h" +#include "game/server_abstract_player.h" +#include "game/server_arrowtarget.h" +#include "libcockatrice/protocol/pb/color.pb.h" #include "server_card.h" #include "server_cardzone.h" #include "server_player.h" +#include +#include #include +#include Server_Arrow::Server_Arrow(int _id, Server_Card *_startCard, diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_arrowtarget.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_arrowtarget.h index 5a080018d..c4da725c2 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_arrowtarget.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_arrowtarget.h @@ -2,6 +2,7 @@ #define SERVER_ARROWTARGET_H #include +#include class Server_ArrowTarget : public QObject { diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_card.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_card.cpp index 8c7feadba..c79f13634 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_card.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_card.cpp @@ -19,16 +19,22 @@ ***************************************************************************/ #include "server_card.h" +#include "game/server_abstract_player.h" +#include "libcockatrice/protocol/pb/card_attributes.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_cardcounter.pb.h" +#include "libcockatrice/utility/card_ref.h" #include "server_cardzone.h" -#include "server_player.h" +#include +#include #include +#include #include #include #include #include #include -#include +#include Server_Card::Server_Card(const CardRef &cardRef, int _id, int _coord_x, int _coord_y, Server_CardZone *_zone) : zone(_zone), id(_id), coord_x(_coord_x), coord_y(_coord_y), cardRef(cardRef), tapped(false), attacking(false), diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_card.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_card.h index a2698ad61..cffd9eb40 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_card.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_card.h @@ -22,15 +22,18 @@ #include "server_arrowtarget.h" +#include #include #include -#include -#include +#include #include +#include class Server_CardZone; class Event_SetCardCounter; class Event_SetCardAttr; +class ServerInfo_Card; +enum CardAttribute : int; class Server_Card : public Server_ArrowTarget { diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_cardzone.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_cardzone.cpp index 1db4f9c4c..5f40aa480 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_cardzone.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_cardzone.cpp @@ -19,13 +19,21 @@ ***************************************************************************/ #include "server_cardzone.h" +#include "libcockatrice/protocol/pb/serverinfo_zone.pb.h" #include "server_abstract_player.h" #include "server_card.h" #include +#include +#include +#include #include +#include +#include #include #include +#include +#include Server_CardZone::Server_CardZone(Server_AbstractPlayer *_player, const QString &_name, diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_cardzone.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_cardzone.h index 77fea54ca..9ed39a159 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_cardzone.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_cardzone.h @@ -20,15 +20,16 @@ #ifndef SERVER_CARDZONE_H #define SERVER_CARDZONE_H +#include #include #include #include +#include #include class Server_Card; class Server_AbstractPlayer; class Server_AbstractParticipant; -class Server_Game; class GameEventStorage; class Server_CardZone diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_counter.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_counter.cpp index b18e11c2b..f3dcb0fb1 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_counter.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_counter.cpp @@ -1,6 +1,9 @@ #include "server_counter.h" +#include "libcockatrice/protocol/pb/color.pb.h" + #include +#include Server_Counter::Server_Counter(int _id, const QString &_name, const color &_counterColor, int _radius, int _count) : id(_id), name(_name), counterColor(_counterColor), radius(_radius), count(_count) diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_deck_validation_strategy.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_deck_validation_strategy.h new file mode 100644 index 000000000..8298214b4 --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_deck_validation_strategy.h @@ -0,0 +1,45 @@ +#ifndef SERVER_DECK_VALIDATION_STRATEGY_H +#define SERVER_DECK_VALIDATION_STRATEGY_H + +#include + +class DeckList; +class Server_Game; +class Server_Player; +class ResponseContainer; + +/** + * @brief Strategy for validating a player's deck before it is loaded into a game. + * + * Subclasses decide whether a deck may be accepted; the default implementation + * accepts every deck. + */ +class Server_DeckValidationStrategy +{ +public: + virtual ~Server_DeckValidationStrategy() = default; + + /** + * @brief Validate @p deck for @p player in @p game. + * + * @p rc is an out parameter used to attach the response details for a rejected + * deck (e.g. an error response extension via ResponseContainer::setResponseExtension). + * @return Response::RespOk when the deck is accepted, an error code otherwise. + */ + virtual Response::ResponseCode + validate(Server_Game *game, Server_Player *player, DeckList *deck, ResponseContainer &rc) = 0; +}; + +/** + * @brief Default deck validation strategy that accepts every deck. + */ +class Server_DefaultDeckValidationStrategy : public Server_DeckValidationStrategy +{ +public: + Response::ResponseCode validate(Server_Game *, Server_Player *, DeckList *, ResponseContainer &) override + { + return Response::RespOk; + } +}; + +#endif diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp index 4761199e5..330d473b2 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp @@ -23,20 +23,44 @@ #include "../server_database_interface.h" #include "../server_protocolhandler.h" #include "../server_room.h" +#include "game/game_config.h" +#include "game/server_abstract_participant.h" +#include "game/server_arrowtarget.h" +#include "game/server_deck_validation_strategy.h" +#include "game/server_game_lifecycle_strategy.h" +#include "game/server_match_result_strategy.h" #include "libcockatrice/protocol/pb/command_move_card.pb.h" +#include "libcockatrice/protocol/pb/game_event.pb.h" +#include "libcockatrice/protocol/pb/game_event_container.pb.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/server_message.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_game.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_gametype.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_playerproperties.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_replay.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_replay_match.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_user.pb.h" +#include "libcockatrice/protocol/pb/session_event.pb.h" #include "server_abstract_player.h" +#include "server_abstractuserinterface.h" #include "server_arrow.h" #include "server_card.h" #include "server_cardzone.h" #include "server_player.h" +#include "server_response_containers.h" #include "server_spectator.h" #include +#include +#include +#include #include +#include +#include +#include #include #include -#include -#include +#include #include #include #include @@ -52,35 +76,26 @@ #include #include #include +#include +#include +#include -Server_Game::Server_Game(const ServerInfo_User &_creatorInfo, - int _gameId, - const QString &_description, - const QString &_password, - int _maxPlayers, - const QList &_gameTypes, - bool _onlyBuddies, - bool _onlyRegistered, - bool _spectatorsAllowed, - bool _spectatorsNeedPassword, - bool _spectatorsCanTalk, - bool _spectatorsSeeEverything, - int _startingLifeTotal, - bool _shareDecklistsOnLoad, - Server_Room *_room) - : QObject(), room(_room), nextPlayerId(0), hostId(0), creatorInfo(new ServerInfo_User(_creatorInfo)), - gameStarted(false), gameClosed(false), gameId(_gameId), password(_password), maxPlayers(_maxPlayers), - gameTypes(_gameTypes), activePlayer(-1), activePhase(-1), onlyBuddies(_onlyBuddies), - onlyRegistered(_onlyRegistered), spectatorsAllowed(_spectatorsAllowed), - spectatorsNeedPassword(_spectatorsNeedPassword), spectatorsCanTalk(_spectatorsCanTalk), - spectatorsSeeEverything(_spectatorsSeeEverything), startingLifeTotal(_startingLifeTotal), - shareDecklistsOnLoad(_shareDecklistsOnLoad), inactivityCounter(0), startTimeOfThisGame(0), secondsElapsed(0), - firstGameStarted(false), turnOrderReversed(false), startTime(QDateTime::currentDateTime()), pingClock(nullptr), +Server_Game::Server_Game(const GameConfig &config, Server_Room *_room) + : QObject(), room(_room), nextPlayerId(0), hostId(0), creatorInfo(new ServerInfo_User(config.creatorInfo)), + gameStarted(false), gameClosed(false), gameId(config.gameId), description(config.description.simplified()), + password(config.password), maxPlayers(config.maxPlayers), gameTypes(config.gameTypes), activePlayer(-1), + activePhase(-1), onlyBuddies(config.onlyBuddies), onlyRegistered(config.onlyRegistered), + spectatorsAllowed(config.spectatorsAllowed), spectatorsNeedPassword(config.spectatorsNeedPassword), + spectatorsCanTalk(config.spectatorsCanTalk), spectatorsSeeEverything(config.spectatorsSeeEverything), + startingLifeTotal(config.startingLifeTotal), shareDecklistsOnLoad(config.shareDecklistsOnLoad), + inactivityCounter(0), startTimeOfThisGame(0), secondsElapsed(0), firstGameStarted(false), + turnOrderReversed(false), startTime(QDateTime::currentDateTime()), pingClock(nullptr), + deckValidationStrategy(new Server_DefaultDeckValidationStrategy), + lifecycleStrategy(new Server_DefaultLifecycleStrategy), matchResultStrategy(new Server_NullMatchResultStrategy), gameMutex() { currentReplay = new GameReplay; currentReplay->set_replay_id(room->getServer()->getDatabaseInterface()->getNextReplayId()); - description = _description.simplified(); connect(this, &Server_Game::sigStartGameIfReady, this, &Server_Game::doStartGameIfReady, Qt::QueuedConnection); @@ -250,6 +265,17 @@ int Server_Game::getPlayerCount() const return participants.size() - getSpectatorCount(); } +int Server_Game::getCardsInGame() const +{ + QMutexLocker locker(&gameMutex); + + int result = 0; + for (auto *player : getPlayers()) { + result += player->getCardCount(); + } + return result; +} + int Server_Game::getSpectatorCount() const { QMutexLocker locker(&gameMutex); @@ -342,7 +368,15 @@ void Server_Game::doStartGameIfReady(bool forceStartGame) } } + // Only actual starts are timed. The early returns above are no-ops. + QElapsedTimer startupTimer; + startupTimer.start(); players = getPlayers(); // players could have been kicked, get new list of players + if (lifecycleStrategy->onGameStarting(this) == Server_GameLifecycleStrategy::StartAction::Handled) { + locker.unlock(); + return; + } + for (Server_AbstractPlayer *player : players.values()) { player->setupZones(); } @@ -380,6 +414,7 @@ void Server_Game::doStartGameIfReady(bool forceStartGame) activePlayer = -1; nextTurn(); + room->getServer()->observeGameStartDurationMs(startupTimer.nsecsElapsed() / 1000000); locker.unlock(); @@ -400,10 +435,12 @@ void Server_Game::stopGameIfFinished() QMutexLocker locker(&gameMutex); int playing = 0; + Server_AbstractPlayer *lastPlayer = nullptr; auto players = getPlayers(); for (auto *player : players.values()) { if (!player->getConceded()) { ++playing; + lastPlayer = player; } } if (playing > 1) { @@ -419,6 +456,16 @@ void Server_Game::stopGameIfFinished() sendGameStateToPlayers(); + bool matchDecided = matchResultStrategy->onGameFinished(this, playing, lastPlayer); + if (matchDecided) { + locker.unlock(); + + sendGameEventContainer(prepareGameEvent(Event_GameClosed(), -1)); + gameClosed = true; + deleteLater(); + return; + } + locker.unlock(); ServerInfo_Game gameInfo; @@ -444,7 +491,7 @@ Response::ResponseCode Server_Game::checkJoin(ServerInfo_User *user, if (asJudge && !(user->user_level() & ServerInfo_User::IsJudge)) { return Response::RespUserLevelTooLow; } - if (!(overrideRestrictions && (user->user_level() & ServerInfo_User::IsModerator))) { + if (!(overrideRestrictions && (user->user_level() & (ServerInfo_User::IsModerator | ServerInfo_User::IsJudge)))) { if ((_password != password) && !(spectator && !spectatorsNeedPassword)) { return Response::RespWrongPassword; } @@ -526,10 +573,7 @@ void Server_Game::addPlayer(Server_AbstractUserInterface *userInterface, if (broadcastUpdate) { ServerInfo_Game gameInfo; - gameInfo.set_room_id(room->getId()); - gameInfo.set_game_id(gameId); - gameInfo.set_player_count(getPlayerCount()); - gameInfo.set_spectators_count(getSpectatorCount()); + getInfo(gameInfo); emit gameInfoChanged(gameInfo); } @@ -588,10 +632,7 @@ void Server_Game::removeParticipant(Server_AbstractParticipant *participant, Eve } ServerInfo_Game gameInfo; - gameInfo.set_room_id(room->getId()); - gameInfo.set_game_id(gameId); - gameInfo.set_player_count(getPlayerCount()); - gameInfo.set_spectators_count(getSpectatorCount()); + getInfo(gameInfo); emit gameInfoChanged(gameInfo); } @@ -847,6 +888,12 @@ void Server_Game::getInfo(ServerInfo_Game &result) const result.set_player_count(getPlayerCount()); result.set_started(gameStarted); result.mutable_creator_info()->CopyFrom(*getCreatorInfo()); + const Server_AbstractParticipant *host = participants.value(hostId, nullptr); + if (host != nullptr) { + host->copyUserInfo(*result.mutable_host_info(), false); + } else { + result.mutable_host_info()->CopyFrom(*getCreatorInfo()); + } result.set_only_buddies(onlyBuddies); result.set_only_registered(onlyRegistered); result.set_spectators_allowed(getSpectatorsAllowed()); @@ -901,3 +948,8 @@ void Server_Game::returnCardsFromPlayer(GameEventStorage &ges, Server_AbstractPl } } } + +void Server_Game::setDeckValidationStrategy(Server_DeckValidationStrategy *strategy) +{ + deckValidationStrategy.reset(strategy); +} diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h index e0e7896b7..c7f8d0929 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h @@ -21,16 +21,25 @@ #define SERVERGAME_H #include "../server_response_containers.h" +#include "libcockatrice/protocol/pb/game_event_context.pb.h" +// IWYU pragma: keep +// ServerInfo_Game is passed by value to the gameInfoChanged() signal, so the +// moc-generated code needs the complete type. #include +#include #include -#include #include +#include +#include #include -#include +#include +#include +#include #include #include #include +#include class QTimer; class GameEventContainer; @@ -42,6 +51,17 @@ class ServerInfo_User; class ServerInfo_Game; class Server_AbstractUserInterface; class Event_GameStateChanged; +class Server_DeckValidationStrategy; +class Server_GameLifecycleStrategy; +class Server_MatchResultStrategy; +namespace google +{ +namespace protobuf +{ +class Message; +} // namespace protobuf +} // namespace google +struct GameConfig; class Server_Game : public QObject { @@ -78,6 +98,12 @@ private: QList replayList; GameReplay *currentReplay; + QScopedPointer deckValidationStrategy; + + QScopedPointer lifecycleStrategy; + + QScopedPointer matchResultStrategy; + void createGameStateChangedEvent(Event_GameStateChanged *event, Server_AbstractParticipant *recipient, bool omniscient, @@ -92,21 +118,7 @@ private slots: public: mutable QRecursiveMutex gameMutex; - Server_Game(const ServerInfo_User &_creatorInfo, - int _gameId, - const QString &_description, - const QString &_password, - int _maxPlayers, - const QList &_gameTypes, - bool _onlyBuddies, - bool _onlyRegistered, - bool _spectatorsAllowed, - bool _spectatorsNeedPassword, - bool _spectatorsCanTalk, - bool _spectatorsSeeEverything, - int _startingLifeTotal, - bool _shareDecklistsOnLoad, - Server_Room *parent); + Server_Game(const GameConfig &config, Server_Room *parent); ~Server_Game() override; Server_Room *getRoom() const { @@ -126,6 +138,8 @@ public: return gameStarted; } int getPlayerCount() const; + /// Total cards across all players' zones. Takes gameMutex itself. + int getCardsInGame() const; int getSpectatorCount() const; QMap getPlayers() const; Server_AbstractPlayer *getPlayer(int id) const; @@ -221,6 +235,20 @@ public: GameEventStorageItem::SendToOthers, int privatePlayerId = -1); void returnCardsFromPlayer(GameEventStorage &ges, Server_AbstractPlayer *player); + + /** @brief Get the current deck validation strategy (non-owning). */ + Server_DeckValidationStrategy *getDeckValidationStrategy() const + { + return deckValidationStrategy.data(); + } + /** @brief Replace the deck validation strategy; takes ownership of @p strategy. */ + void setDeckValidationStrategy(Server_DeckValidationStrategy *strategy); + + /** @brief Get the current game lifecycle strategy (non-owning). */ + Server_GameLifecycleStrategy *getLifecycleStrategy() const + { + return lifecycleStrategy.data(); + } }; #endif diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game_lifecycle_strategy.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game_lifecycle_strategy.h new file mode 100644 index 000000000..b71ce486d --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game_lifecycle_strategy.h @@ -0,0 +1,43 @@ +#ifndef SERVER_GAME_LIFECYCLE_STRATEGY_H +#define SERVER_GAME_LIFECYCLE_STRATEGY_H + +class Server_Game; + +/** + * @brief Strategy hook invoked around a game's lifecycle transitions. + * + * Subclasses can intercept the game start to perform custom setup (e.g. draft or + * tournament initialization); the default implementation never intercepts. + */ +class Server_GameLifecycleStrategy +{ +public: + virtual ~Server_GameLifecycleStrategy() = default; + + /** @brief How the game start should proceed after this hook returns. */ + enum class StartAction + { + ProceedNormal, ///< Continue with the normal game start flow. + Handled, ///< The strategy handled the start; abort the normal flow. + }; + + /** + * @brief Called when a game is about to start. + * @return How the start flow should proceed. + */ + virtual StartAction onGameStarting(Server_Game *game) = 0; +}; + +/** + * @brief Default lifecycle strategy that never intercepts the game start. + */ +class Server_DefaultLifecycleStrategy : public Server_GameLifecycleStrategy +{ +public: + StartAction onGameStarting(Server_Game *) override + { + return StartAction::ProceedNormal; + } +}; + +#endif diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_match_game_factory.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_match_game_factory.h new file mode 100644 index 000000000..d5cf81cb6 --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_match_game_factory.h @@ -0,0 +1,29 @@ +#ifndef SERVER_MATCH_GAME_FACTORY_H +#define SERVER_MATCH_GAME_FACTORY_H + +#include + +class GameConfig; +class Server_Game; +class Server_AbstractUserInterface; + +/** + * @brief Abstract factory for creating match games, forward-looking for tournament modes. + * + * Implementations own the rules of how a match game is constructed, how player + * interfaces are resolved, and how games are registered with a room. + */ +class Server_MatchGameFactory +{ +public: + virtual ~Server_MatchGameFactory() = default; + + /** @brief Create a match game from @p config, storing the assigned game id in @p outGameId. */ + virtual Server_Game *createMatchGame(const GameConfig &config, int &outGameId) = 0; + /** @brief Resolve the user interface for @p playerName. */ + virtual Server_AbstractUserInterface *getUserInterface(const QString &playerName) = 0; + /** @brief Register @p game with the room that owns it. */ + virtual void addGameToRoom(Server_Game *game) = 0; +}; + +#endif diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_match_result_strategy.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_match_result_strategy.h new file mode 100644 index 000000000..51c696db1 --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_match_result_strategy.h @@ -0,0 +1,38 @@ +#ifndef SERVER_MATCH_RESULT_STRATEGY_H +#define SERVER_MATCH_RESULT_STRATEGY_H + +class Server_AbstractPlayer; +class Server_Game; + +/** + * @brief Strategy hook invoked when a game has finished to decide the match result. + * + * Subclasses can report the match outcome (e.g. to a tournament backend) and decide + * whether the game should be closed permanently; the default implementation never + * closes the game, preserving the normal return-to-lobby behavior. + */ +class Server_MatchResultStrategy +{ +public: + virtual ~Server_MatchResultStrategy() = default; + + /** + * @brief Called when a game has finished. + * @return Whether the game has been decided and should be closed. + */ + virtual bool onGameFinished(Server_Game *game, int playing, Server_AbstractPlayer *lastPlayer) = 0; +}; + +/** + * @brief Default match result strategy that never closes the game. + */ +class Server_NullMatchResultStrategy : public Server_MatchResultStrategy +{ +public: + bool onGameFinished(Server_Game *, int, Server_AbstractPlayer *) override + { + return false; + } +}; + +#endif diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp index d502fc7d6..a4c904c01 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp @@ -1,23 +1,34 @@ #include "server_player.h" #include "../server.h" -#include "../server_abstractuserinterface.h" #include "../server_database_interface.h" #include "../server_room.h" +#include "game/server_abstract_participant.h" +#include "game/server_abstract_player.h" +#include "libcockatrice/deck_list/tree/inner_deck_list_node.h" +#include "libcockatrice/protocol/pb/color.pb.h" +#include "libcockatrice/protocol/pb/move_card_to_zone.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_card.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_counter.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_playerproperties.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_zone.pb.h" +#include "libcockatrice/utility/card_ref.h" +#include "libcockatrice/utility/playmat_params.h" #include "server_card.h" #include "server_cardzone.h" #include "server_counter.h" #include "server_game.h" #include "server_move_card_struct.h" +#include "server_response_containers.h" -#include -#include -#include +#include +#include +#include +#include +#include +#include #include -#include -#include #include -#include #include #include #include @@ -27,6 +38,7 @@ #include #include #include +#include #include #include #include @@ -42,13 +54,15 @@ #include #include #include -#include #include #include -#include #include #include #include +#include +#include + +class Server_AbstractUserInterface; Server_Player::Server_Player(Server_Game *_game, int _playerId, @@ -250,6 +264,14 @@ Server_Player::cmdDeckSelect(const Command_DeckSelect &cmd, ResponseContainer &r Event_PlayerPropertiesChanged event; event.mutable_player_properties()->set_sideboard_locked(true); event.mutable_player_properties()->set_deck_hash(deck->getDeckHash().toStdString()); + const auto &playmat = deck->getPlaymat(); + auto *playmatParams = event.mutable_player_properties()->mutable_playmat_params(); + playmatParams->set_card_name(playmat.card.name.left(MAX_NAME_LENGTH).toStdString()); + playmatParams->set_card_provider_id(playmat.card.providerId.left(MAX_NAME_LENGTH).toStdString()); + playmatParams->set_margin_pct_l(playmat.params.marginPctL); + playmatParams->set_margin_pct_r(playmat.params.marginPctR); + playmatParams->set_vertical_offset(playmat.params.verticalOffset); + playmatParams->set_zoom(playmat.params.zoom); ges.enqueueGameEvent(event, playerId); Context_DeckSelect context; @@ -594,6 +616,46 @@ Server_Player::cmdReverseTurn(const Command_ReverseTurn &cmd, ResponseContainer return Server_AbstractParticipant::cmdReverseTurn(cmd, rc, ges); } +Response::ResponseCode +Server_Player::cmdSetPlaymat(const Command_SetPlaymat &cmd, ResponseContainer &rc, GameEventStorage &ges) +{ + Q_UNUSED(rc); + + if (!deck) { + return Response::RespContextError; + } + + const auto &pp = cmd.playmat_params(); + const auto rawName = QString::fromStdString(pp.card_name()); + const auto rawProviderId = QString::fromStdString(pp.card_provider_id()); + if (rawName.length() > MAX_NAME_LENGTH || rawProviderId.length() > MAX_NAME_LENGTH) { + return Response::RespInvalidData; + } + PlaymatInfo playmat; + playmat.card.name = rawName; + playmat.card.providerId = rawProviderId; + playmat.params.marginPctL = qBound(0.0, pp.margin_pct_l(), 0.95); + playmat.params.marginPctR = qBound(0.0, pp.margin_pct_r(), 0.95); + playmat.params.verticalOffset = qBound(0.0, pp.vertical_offset(), 1.0); + playmat.params.zoom = qBound(0.1, pp.zoom(), 4.0); + deck->setPlaymat(playmat); + + Event_PlayerPropertiesChanged event; + auto *props = event.mutable_player_properties(); + props->set_sideboard_locked(sideboardLocked); + props->set_deck_hash(deck->getDeckHash().toStdString()); + auto *playmatParams = props->mutable_playmat_params(); + playmatParams->set_card_name(playmat.card.name.toStdString()); + playmatParams->set_card_provider_id(playmat.card.providerId.toStdString()); + playmatParams->set_margin_pct_l(playmat.params.marginPctL); + playmatParams->set_margin_pct_r(playmat.params.marginPctR); + playmatParams->set_vertical_offset(playmat.params.verticalOffset); + playmatParams->set_zoom(playmat.params.zoom); + ges.enqueueGameEvent(event, playerId); + + return Response::RespOk; +} + void Server_Player::getInfo(ServerInfo_Player *info, Server_AbstractParticipant *recipient, bool omniscient, diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.h index 5925ed3c2..5d514a344 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.h @@ -1,8 +1,19 @@ #ifndef PLAYER_H #define PLAYER_H +#include "libcockatrice/protocol/pb/response.pb.h" #include "server_abstract_player.h" +#include +#include +#include + +class GameEventStorage; +class ServerInfo_User; +class Server_AbstractUserInterface; +class Server_Counter; +class Server_Game; + class Server_Player : public Server_AbstractPlayer { Q_OBJECT @@ -62,6 +73,8 @@ public: cmdSetActivePhase(const Command_SetActivePhase &cmd, ResponseContainer &rc, GameEventStorage &ges) override; Response::ResponseCode cmdReverseTurn(const Command_ReverseTurn & /*cmd*/, ResponseContainer & /*rc*/, GameEventStorage &ges) override; + Response::ResponseCode + cmdSetPlaymat(const Command_SetPlaymat &cmd, ResponseContainer &rc, GameEventStorage &ges) override; Response::ResponseCode cmdChangeZoneProperties(const Command_ChangeZoneProperties &cmd, ResponseContainer &rc, GameEventStorage &ges) override; diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_spectator.cpp b/libcockatrice_network/libcockatrice/network/server/remote/game/server_spectator.cpp index bc873e386..d1c369949 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_spectator.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_spectator.cpp @@ -1,5 +1,11 @@ #include "server_spectator.h" +#include "game/server_abstract_participant.h" + +class ServerInfo_User; +class Server_AbstractUserInterface; +class Server_Game; + Server_Spectator::Server_Spectator(Server_Game *_game, int _playerId, const ServerInfo_User &_userInfo, diff --git a/libcockatrice_network/libcockatrice/network/server/remote/game/server_spectator.h b/libcockatrice_network/libcockatrice/network/server/remote/game/server_spectator.h index 4d3928a63..2a8995ec3 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_spectator.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_spectator.h @@ -3,6 +3,12 @@ #include "server_abstract_participant.h" +#include + +class ServerInfo_User; +class Server_AbstractUserInterface; +class Server_Game; + class Server_Spectator : public Server_AbstractParticipant { Q_OBJECT diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server.cpp index 7abccfca8..73b13e23e 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server.cpp @@ -19,16 +19,36 @@ ***************************************************************************/ #include "server.h" +#include "game/server_abstract_participant.h" #include "game/server_game.h" -#include "game/server_player.h" +#include "libcockatrice/protocol/pb/commands.pb.h" +#include "libcockatrice/protocol/pb/game_commands.pb.h" +#include "libcockatrice/protocol/pb/game_event_container.pb.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/room_commands.pb.h" +#include "libcockatrice/protocol/pb/room_event.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_ban.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_game.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_room.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_user.pb.h" +#include "server_abstractuserinterface.h" #include "server_database_interface.h" #include "server_protocolhandler.h" #include "server_remoteuserinterface.h" +#include "server_response_containers.h" #include "server_room.h" +#include "serverinfo_user_container.h" -#include +#include #include +#include +#include +#include +#include +#include #include +#include +#include #include #include #include @@ -36,6 +56,10 @@ #include #include #include +#include +#include +#include +#include Server::Server(QObject *parent) : QObject(parent), nextLocalGameId(0), tcpUserCount(0), webSocketUserCount(0) { diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server.h b/libcockatrice_network/libcockatrice/network/server/remote/server.h index 2fca46593..99247bb67 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server.h @@ -3,30 +3,32 @@ #include "server_player_reference.h" +#include +#include #include #include +#include #include #include -#include -#include -#include +#include +#include +#include class Server_DatabaseInterface; -class Server_Game; class Server_Room; class Server_ProtocolHandler; class Server_AbstractUserInterface; -class GameReplay; class IslMessage; class SessionEvent; class RoomEvent; -class DeckList; class ServerInfo_Game; class ServerInfo_Room; class Response; class GameEventContainer; class CommandContainer; class Command_JoinGame; +class QThread; +class ServerInfo_User; enum AuthenticationResult { @@ -38,7 +40,8 @@ enum AuthenticationResult UsernameInvalid, RegistrationRequired, UserIsInactive, - ClientIdRequired + ClientIdRequired, + PasswordChangeRequired }; class Server : public QObject @@ -91,6 +94,10 @@ public: { return QString(); } + virtual SessionEvent *getLoginSessionEvent() const + { + return nullptr; + } virtual QString getRequiredFeatures() const { return QString(); @@ -175,6 +182,11 @@ public: { return false; } + /// Called once per actual game start with how long bringing every player's + /// zones online took, so servers can spot deck sizes that wedge threads. + virtual void observeGameStartDurationMs(qint64 /* elapsedMs */) + { + } Server_DatabaseInterface *getDatabaseInterface() const; int getNextLocalGameId() diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.cpp index 641be1eed..25aa415fc 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.cpp @@ -1,7 +1,13 @@ #include "server_abstractuserinterface.h" +#include "game/server_abstract_participant.h" #include "game/server_game.h" -#include "game/server_player.h" +#include "libcockatrice/protocol/pb/game_event_container.pb.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/room_event.pb.h" +#include "libcockatrice/protocol/pb/server_message.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_user.pb.h" +#include "libcockatrice/protocol/pb/session_event.pb.h" #include "server.h" #include "server_player_reference.h" #include "server_response_containers.h" @@ -9,8 +15,13 @@ #include #include +#include +#include +#include #include -#include +#include +#include +#include void Server_AbstractUserInterface::sendProtocolItemByType(ServerMessage::MessageType type, const ::google::protobuf::Message &item) @@ -69,6 +80,10 @@ void Server_AbstractUserInterface::sendResponseContainer(const ResponseContainer } } +void Server_AbstractUserInterface::onLogin(ResponseContainer &) +{ +} + void Server_AbstractUserInterface::playerRemovedFromGame(Server_Game *game) { qDebug() << "Server_AbstractUserInterface::playerRemovedFromGame(): gameId =" << game->getGameId(); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.h b/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.h index b11260003..a19475e34 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.h @@ -5,6 +5,8 @@ #include #include +#include +#include #include #include @@ -12,9 +14,15 @@ class SessionEvent; class GameEventContainer; class RoomEvent; class ResponseContainer; - class Server; class Server_Game; +namespace google +{ +namespace protobuf +{ +class Message; +} // namespace protobuf +} // namespace google class Server_AbstractUserInterface : public ServerInfo_User_Container { @@ -42,6 +50,7 @@ public: void playerRemovedFromGame(Server_Game *game); void playerAddedToGame(int gameId, int roomId, int playerId); void joinPersistentGames(ResponseContainer &rc); + virtual void onLogin(ResponseContainer &rc); QMap> getGames() const { diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h b/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h index b43dbde42..e0be7990b 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h @@ -1,9 +1,22 @@ #ifndef SERVER_DATABASE_INTERFACE_H #define SERVER_DATABASE_INTERFACE_H +#include "libcockatrice/protocol/pb/serverinfo_user.pb.h" #include "server.h" +#include #include +#include +#include +#include +#include + +class DeckList; +class GameReplay; +class ServerInfo_Game; +class Server_ProtocolHandler; +template class QList; +template class QSet; class Server_DatabaseInterface : public QObject { @@ -172,6 +185,9 @@ public: { return false; } + virtual void setForcePasswordChange(const QString & /* user */, bool /* force */) + { + } }; #endif diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp index c3686ddfa..fc5cfa148 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -1,19 +1,43 @@ #include "server_protocolhandler.h" +#include "game/game_config.h" +#include "game/server_abstract_participant.h" #include "game/server_game.h" -#include "game/server_player.h" +#include "libcockatrice/protocol/pb/admin_commands.pb.h" +#include "libcockatrice/protocol/pb/developer_commands.pb.h" +#include "libcockatrice/protocol/pb/game_commands.pb.h" +#include "libcockatrice/protocol/pb/game_event_container.pb.h" +#include "libcockatrice/protocol/pb/moderator_commands.pb.h" +#include "libcockatrice/protocol/pb/room_commands.pb.h" +#include "libcockatrice/protocol/pb/room_event.pb.h" +#include "libcockatrice/protocol/pb/server_message.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_chat_message.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_game.pb.h" +#include "libcockatrice/protocol/pb/session_commands.pb.h" +#include "libcockatrice/protocol/pb/session_event.pb.h" #include "server_database_interface.h" +#include "server_response_containers.h" #include "server_room.h" +#include "serverinfo_user_container.h" +#include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include -#include +#include #include #include #include #include -#include #include #include #include @@ -27,6 +51,9 @@ #include #include #include +#include +#include +#include Server_ProtocolHandler::Server_ProtocolHandler(Server *_server, Server_DatabaseInterface *_databaseInterface, @@ -133,6 +160,22 @@ void Server_ProtocolHandler::sendProtocolItem(const RoomEvent &item) Response::ResponseCode Server_ProtocolHandler::processSessionCommandContainer(const CommandContainer &cont, ResponseContainer &rc) { + const auto isPreAuthSessionCommand = [](SessionCommand::SessionCommandType type) { + switch (type) { + case SessionCommand::PING: + case SessionCommand::LOGIN: + case SessionCommand::REGISTER: + case SessionCommand::ACTIVATE: + case SessionCommand::FORGOT_PASSWORD_REQUEST: + case SessionCommand::FORGOT_PASSWORD_RESET: + case SessionCommand::FORGOT_PASSWORD_CHALLENGE: + case SessionCommand::REQUEST_PASSWORD_SALT: + return true; + default: + return false; + } + }; + Response::ResponseCode finalResponseCode = Response::RespOk; for (int i = cont.session_command_size() - 1; i >= 0; --i) { Response::ResponseCode resp = Response::RespInvalidCommand; @@ -141,33 +184,38 @@ Response::ResponseCode Server_ProtocolHandler::processSessionCommandContainer(co if (num != SessionCommand::PING) { // don't log ping commands logDebugMessage(getSafeDebugString(sc)); } - switch ((SessionCommand::SessionCommandType)num) { - case SessionCommand::PING: - resp = cmdPing(sc.GetExtension(Command_Ping::ext), rc); - break; - case SessionCommand::LOGIN: - resp = cmdLogin(sc.GetExtension(Command_Login::ext), rc); - break; - case SessionCommand::MESSAGE: - resp = cmdMessage(sc.GetExtension(Command_Message::ext), rc); - break; - case SessionCommand::GET_GAMES_OF_USER: - resp = cmdGetGamesOfUser(sc.GetExtension(Command_GetGamesOfUser::ext), rc); - break; - case SessionCommand::GET_USER_INFO: - resp = cmdGetUserInfo(sc.GetExtension(Command_GetUserInfo::ext), rc); - break; - case SessionCommand::LIST_ROOMS: - resp = cmdListRooms(sc.GetExtension(Command_ListRooms::ext), rc); - break; - case SessionCommand::JOIN_ROOM: - resp = cmdJoinRoom(sc.GetExtension(Command_JoinRoom::ext), rc); - break; - case SessionCommand::LIST_USERS: - resp = cmdListUsers(sc.GetExtension(Command_ListUsers::ext), rc); - break; - default: - resp = processExtendedSessionCommand(num, sc, rc); + const auto commandType = static_cast(num); + if (authState == NotLoggedIn && !isPreAuthSessionCommand(commandType)) { + resp = Response::RespLoginNeeded; + } else { + switch (commandType) { + case SessionCommand::PING: + resp = cmdPing(sc.GetExtension(Command_Ping::ext), rc); + break; + case SessionCommand::LOGIN: + resp = cmdLogin(sc.GetExtension(Command_Login::ext), rc); + break; + case SessionCommand::MESSAGE: + resp = cmdMessage(sc.GetExtension(Command_Message::ext), rc); + break; + case SessionCommand::GET_GAMES_OF_USER: + resp = cmdGetGamesOfUser(sc.GetExtension(Command_GetGamesOfUser::ext), rc); + break; + case SessionCommand::GET_USER_INFO: + resp = cmdGetUserInfo(sc.GetExtension(Command_GetUserInfo::ext), rc); + break; + case SessionCommand::LIST_ROOMS: + resp = cmdListRooms(sc.GetExtension(Command_ListRooms::ext), rc); + break; + case SessionCommand::JOIN_ROOM: + resp = cmdJoinRoom(sc.GetExtension(Command_JoinRoom::ext), rc); + break; + case SessionCommand::LIST_USERS: + resp = cmdListUsers(sc.GetExtension(Command_ListUsers::ext), rc); + break; + default: + resp = processExtendedSessionCommand(num, sc, rc); + } } if (resp != Response::RespOk) { finalResponseCode = resp; @@ -366,6 +414,33 @@ Response::ResponseCode Server_ProtocolHandler::processAdminCommandContainer(cons return finalResponseCode; } +Response::ResponseCode Server_ProtocolHandler::processDeveloperCommandContainer(const CommandContainer &cont, + ResponseContainer &rc) +{ + if (!userInfo) { + return Response::RespLoginNeeded; + } + if (!(userInfo->user_level() & ServerInfo_User::IsDeveloper)) { + return Response::RespLoginNeeded; + } + + resetIdleTimer(); + + Response::ResponseCode finalResponseCode = Response::RespOk; + for (int i = cont.developer_command_size() - 1; i >= 0; --i) { + Response::ResponseCode resp = Response::RespInvalidCommand; + const DeveloperCommand &sc = cont.developer_command(i); + const int num = getPbExtension(sc); + logDebugMessage(getSafeDebugString(sc)); + + resp = processExtendedDeveloperCommand(num, sc, rc); + if (resp != Response::RespOk) { + finalResponseCode = resp; + } + } + return finalResponseCode; +} + void Server_ProtocolHandler::processCommandContainer(const CommandContainer &cont) { // Command processing must be disabled after prepareDestroy() has been called. @@ -388,6 +463,8 @@ void Server_ProtocolHandler::processCommandContainer(const CommandContainer &con finalResponseCode = processModeratorCommandContainer(cont, responseContainer); } else if (cont.admin_command_size()) { finalResponseCode = processAdminCommandContainer(cont, responseContainer); + } else if (cont.developer_command_size()) { + finalResponseCode = processDeveloperCommandContainer(cont, responseContainer); } else { finalResponseCode = Response::RespInvalidCommand; } @@ -432,11 +509,12 @@ void Server_ProtocolHandler::pingClockTimeout() prepareDestroy(); } - // PrivLevel users, Moderators, and Admins are not subject to the server idle timeout policy + // PrivLevel users, Moderators, Admins, and Developers are not subject to the server idle timeout policy const bool hasPrivLevel = userInfo && QString::fromStdString(userInfo->privlevel()).toLower() != "none"; - const bool isModOrAdmin = - userInfo && (userInfo->user_level() & (ServerInfo_User::IsModerator | ServerInfo_User::IsAdmin)); - if (!hasPrivLevel && !isModOrAdmin) { + const bool isStaff = + userInfo && (userInfo->user_level() & + (ServerInfo_User::IsModerator | ServerInfo_User::IsAdmin | ServerInfo_User::IsDeveloper)); + if (!hasPrivLevel && !isStaff) { if ((server->getIdleClientTimeout() > 0) && (idleClientWarningSent)) { if (timeRunning - lastActionReceived > server->getIdleClientTimeout()) { prepareDestroy(); @@ -542,6 +620,8 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd return Response::RespClientIdRequired; case UserIsInactive: return Response::RespAccountNotActivated; + case PasswordChangeRequired: + return Response::RespPasswordChangeRequired; default: authState = res; usingRealPassword = needsHash; @@ -562,6 +642,11 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd event.set_message(server->getLoginMessage().toStdString()); rc.enqueuePostResponseItem(ServerMessage::SESSION_EVENT, prepareSessionEvent(event)); + SessionEvent *loginEvent = server->getLoginSessionEvent(); + if (loginEvent) { + rc.enqueuePostResponseItem(ServerMessage::SESSION_EVENT, loginEvent); + } + auto *re = new Response_Login; re->mutable_user_info()->CopyFrom(copyUserInfo(true)); @@ -587,6 +672,7 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd joinPersistentGames(rc); databaseInterface->removeForgotPassword(userName); + onLogin(rc); rc.setResponseExtension(re); return Response::RespOk; } @@ -894,10 +980,22 @@ Server_ProtocolHandler::cmdCreateGame(const Command_CreateGame &cmd, Server_Room // When server doesn't permit registered users to exist, do not honor only-reg setting bool onlyRegisteredUsers = cmd.only_registered() && (server->permitUnregisteredUsers()); - auto *game = new Server_Game(copyUserInfo(false), gameId, description, QString::fromStdString(cmd.password()), - cmd.max_players(), gameTypes, cmd.only_buddies(), onlyRegisteredUsers, - cmd.spectators_allowed(), cmd.spectators_need_password(), cmd.spectators_can_talk(), - cmd.spectators_see_everything(), startingLifeTotal, shareDecklistsOnLoad, room); + GameConfig config{.creatorInfo = copyUserInfo(false), + .gameId = gameId, + .description = description, + .password = QString::fromStdString(cmd.password()), + .maxPlayers = static_cast(cmd.max_players()), + .gameTypes = gameTypes, + .onlyBuddies = cmd.only_buddies(), + .onlyRegistered = onlyRegisteredUsers, + .spectatorsAllowed = cmd.spectators_allowed(), + .spectatorsNeedPassword = cmd.spectators_need_password(), + .spectatorsCanTalk = cmd.spectators_can_talk(), + .spectatorsSeeEverything = cmd.spectators_see_everything(), + .startingLifeTotal = startingLifeTotal, + .shareDecklistsOnLoad = shareDecklistsOnLoad}; + + auto *game = new Server_Game(config, room); game->addPlayer(this, rc, asSpectator, asJudge, false); room->addGame(game); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h index 0d05b91c8..a12706757 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h @@ -4,33 +4,24 @@ #include "server.h" #include "server_abstractuserinterface.h" +#include +#include #include +#include #include -#include +#include -class Features; class Server_DatabaseInterface; -class Server_Player; -class ServerInfo_User; class Server_Room; -class QTimer; -class FeatureSet; - class ServerMessage; -class Response; -class SessionEvent; -class GameEventContainer; -class RoomEvent; class ResponseContainer; - class CommandContainer; class SessionCommand; class ModeratorCommand; class AdminCommand; - +class DeveloperCommand; class Command_Ping; class Command_Login; -class Command_Register; class Command_Message; class Command_ListUsers; class Command_GetGamesOfUser; @@ -98,6 +89,12 @@ private: { return Response::RespFunctionNotAllowed; } + Response::ResponseCode processDeveloperCommandContainer(const CommandContainer &cont, ResponseContainer &rc); + virtual Response::ResponseCode + processExtendedDeveloperCommand(int /* cmdType */, const DeveloperCommand & /* cmd */, ResponseContainer & /* rc */) + { + return Response::RespFunctionNotAllowed; + } void resetIdleTimer(); private slots: @@ -129,7 +126,7 @@ public: return timeRunning - lastDataReceived; } bool addSaidMessageSize(int size); - void processCommandContainer(const CommandContainer &cont); + virtual void processCommandContainer(const CommandContainer &cont); void sendProtocolItem(const Response &item); void sendProtocolItem(const SessionEvent &item); diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_remoteuserinterface.h b/libcockatrice_network/libcockatrice/network/server/remote/server_remoteuserinterface.h index d68656b9d..3bdf04220 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_remoteuserinterface.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_remoteuserinterface.h @@ -3,6 +3,9 @@ #include "server_abstractuserinterface.h" +class Server; +class ServerInfo_User_Container; + class Server_RemoteUserInterface : public Server_AbstractUserInterface { public: diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_response_containers.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_response_containers.cpp index 22fb199fb..4c4f351c9 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_response_containers.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_response_containers.cpp @@ -1,8 +1,13 @@ #include "server_response_containers.h" #include "game/server_game.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 +#include +#include GameEventStorageItem::GameEventStorageItem(const ::google::protobuf::Message &_event, int _playerId, diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_response_containers.h b/libcockatrice_network/libcockatrice/network/server/remote/server_response_containers.h index 118b32d38..32b7a80a2 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_response_containers.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_response_containers.h @@ -1,10 +1,14 @@ #ifndef SERVER_RESPONSE_CONTAINERS_H #define SERVER_RESPONSE_CONTAINERS_H +#include #include #include +#include #include +class GameEvent; + namespace google { namespace protobuf diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp b/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp index 1f29e62fb..614de8d63 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_room.cpp @@ -1,11 +1,26 @@ #include "server_room.h" #include "game/server_game.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/room_event.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_game.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_gametype.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_user.pb.h" +#include "server.h" +#include "server_abstractuserinterface.h" #include "server_protocolhandler.h" +#include "server_response_containers.h" +#include "serverinfo_user_container.h" #include -#include +#include +#include +#include +#include +#include #include +#include +#include #include #include #include @@ -16,6 +31,10 @@ #include #include #include +#include +#include +#include +#include Server_Room::Server_Room(int _id, int _chatHistorySize, diff --git a/libcockatrice_network/libcockatrice/network/server/remote/server_room.h b/libcockatrice_network/libcockatrice/network/server/remote/server_room.h index 3d9988f20..5933894d5 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_room.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_room.h @@ -1,16 +1,16 @@ #ifndef SERVER_ROOM_H #define SERVER_ROOM_H -#include "serverinfo_user_container.h" - +#include #include -#include #include #include +#include +#include #include #include +#include -class Server_DatabaseInterface; class Server_ProtocolHandler; class RoomEvent; class ServerInfo_User; @@ -18,10 +18,17 @@ class ServerInfo_Room; class ServerInfo_Game; class Server_Game; class Server; - class Command_JoinGame; class ResponseContainer; class Server_AbstractUserInterface; +class ServerInfo_User_Container; +namespace google +{ +namespace protobuf +{ +class Message; +} // namespace protobuf +} // namespace google class Server_Room : public QObject { diff --git a/libcockatrice_protocol/CMakeLists.txt b/libcockatrice_protocol/CMakeLists.txt index 7dc0a0360..55184045a 100644 --- a/libcockatrice_protocol/CMakeLists.txt +++ b/libcockatrice_protocol/CMakeLists.txt @@ -24,3 +24,5 @@ target_include_directories( libcockatrice_protocol PUBLIC ${CMAKE_CURRENT_SOURCE_DIR} ${CMAKE_CURRENT_BINARY_DIR} # points to the generated headers ) + +target_precompile_headers(libcockatrice_protocol PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") diff --git a/libcockatrice_protocol/libcockatrice/protocol/debug_pb_message.cpp b/libcockatrice_protocol/libcockatrice/protocol/debug_pb_message.cpp index 4d4889323..f892c3f15 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/debug_pb_message.cpp +++ b/libcockatrice_protocol/libcockatrice/protocol/debug_pb_message.cpp @@ -1,11 +1,13 @@ #include "debug_pb_message.h" -#include #include #include #include +#include #include #include +#include +#include // FastFieldValuePrinter is added in protobuf 3.4, going out of our way to add the old FieldValuePrinter is not worth it #if GOOGLE_PROTOBUF_VERSION > 3004000 diff --git a/libcockatrice_protocol/libcockatrice/protocol/featureset.cpp b/libcockatrice_protocol/libcockatrice/protocol/featureset.cpp index 3e687ef56..0f8fd4210 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/featureset.cpp +++ b/libcockatrice_protocol/libcockatrice/protocol/featureset.cpp @@ -1,6 +1,7 @@ #include "featureset.h" #include +#include FeatureSet::FeatureSet() { diff --git a/libcockatrice_protocol/libcockatrice/protocol/get_pb_extension.cpp b/libcockatrice_protocol/libcockatrice/protocol/get_pb_extension.cpp index a693ea5a7..b9d2f18d0 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/get_pb_extension.cpp +++ b/libcockatrice_protocol/libcockatrice/protocol/get_pb_extension.cpp @@ -2,6 +2,7 @@ #include #include +#include int getPbExtension(const ::google::protobuf::Message &message) { diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index 20a4cb08d..bc4814d5e 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -15,9 +15,17 @@ set(PROTO_FILES command_deck_del.proto command_deck_del_dir.proto command_deck_download.proto + command_deck_download_public.proto command_deck_list.proto + command_deck_list_other_user.proto command_deck_new_dir.proto command_deck_select.proto + command_deck_set_visibility.proto + command_deck_share_create.proto + command_deck_share_download.proto + command_deck_share_list.proto + command_deck_share_list_mine.proto + command_deck_share_remove.proto command_deck_upload.proto command_del_counter.proto command_delete_arrow.proto @@ -25,6 +33,7 @@ set(PROTO_FILES command_dump_zone.proto command_flip_card.proto command_game_say.proto + command_get_server_stats.proto command_inc_card_counter.proto command_inc_counter.proto command_kick_from_game.proto @@ -35,10 +44,20 @@ set(PROTO_FILES command_ready_start.proto command_replay_delete_match.proto command_replay_download.proto + command_replay_download_by_game_id.proto command_replay_get_code.proto command_replay_list.proto command_replay_modify_match.proto command_replay_submit_code.proto + command_report.proto + command_report_add_comment.proto + command_report_assign.proto + command_report_details.proto + command_report_list.proto + command_report_my_list.proto + command_report_resolve.proto + command_report_stats.proto + command_report_user_info.proto command_reveal_cards.proto command_reverse_turn.proto command_roll_die.proto @@ -46,6 +65,7 @@ set(PROTO_FILES command_set_card_attr.proto command_set_card_counter.proto command_set_counter.proto + command_set_playmat.proto command_set_sideboard_lock.proto command_set_sideboard_plan.proto command_shuffle.proto @@ -60,6 +80,7 @@ set(PROTO_FILES context_ready_start.proto context_set_sideboard_lock.proto context_undo_draw.proto + developer_commands.proto event_add_to_list.proto event_attach_card.proto event_change_zone_properties.proto @@ -79,7 +100,6 @@ set(PROTO_FILES event_game_log_notice.proto event_game_say.proto event_game_state_changed.proto - event_game_state_changed.proto event_join.proto event_join_room.proto event_kicked.proto @@ -125,11 +145,16 @@ set(PROTO_FILES response_card_art_rule_entry.proto response_deck_download.proto response_deck_list.proto + response_deck_share_create.proto + response_deck_share_download.proto + response_deck_share_list.proto + response_deck_share_list_mine.proto response_deck_upload.proto response_dump_zone.proto response_forgotpasswordrequest.proto response_get_admin_notes.proto response_get_games_of_user.proto + response_get_server_stats.proto response_get_user_info.proto response_join_room.proto response_list_users.proto @@ -137,8 +162,19 @@ set(PROTO_FILES response_password_salt.proto response_register.proto response_replay_download.proto + response_replay_download_by_game_id.proto response_replay_get_code.proto response_replay_list.proto + response_report_details.proto + response_report_list.proto + response_report_my_list.proto + response_report_stats.proto + response_report_user_info.proto + response_moderator_last_logins.proto + response_remove_user_avatar.proto + response_reset_user_password.proto + response_user_alts.proto + response_user_sessions.proto response_viewlog_history.proto response_warn_history.proto response_warn_list.proto @@ -151,16 +187,22 @@ set(PROTO_FILES serverinfo_cardcounter.proto serverinfo_chat_message.proto serverinfo_counter.proto + serverinfo_deck_share_item.proto + serverinfo_deck_share_summary.proto serverinfo_deckstorage.proto serverinfo_game.proto serverinfo_gametype.proto + serverinfo_moderator_login.proto serverinfo_player.proto serverinfo_playerping.proto serverinfo_playerproperties.proto serverinfo_replay.proto serverinfo_replay_match.proto + serverinfo_report.proto serverinfo_room.proto serverinfo_user.proto + serverinfo_user_alt.proto + serverinfo_user_session.proto serverinfo_warning.proto serverinfo_zone.proto session_commands.proto diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/admin_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/admin_commands.proto index 8faaec2d2..f1f85e376 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/admin_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/admin_commands.proto @@ -5,6 +5,7 @@ message AdminCommand { SHUTDOWN_SERVER = 1001; RELOAD_CONFIG = 1002; ADJUST_MOD = 1003; + RESET_USER_PASSWORD = 1016; } extensions 100 to max; } @@ -36,4 +37,12 @@ message Command_AdjustMod { required string user_name = 1; optional bool should_be_mod = 2; optional bool should_be_judge = 3; + optional bool should_be_developer = 4; +} + +message Command_ResetUserPassword { + extend AdminCommand { + optional Command_ResetUserPassword ext = 1016; + } + optional string user_name = 1; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_download_public.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_download_public.proto new file mode 100644 index 000000000..a5592ef51 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_download_public.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_DeckDownloadPublic { + extend SessionCommand { + optional Command_DeckDownloadPublic ext = 1031; + } + optional uint32 deck_id = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_list_other_user.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_list_other_user.proto new file mode 100644 index 000000000..2459608e2 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_list_other_user.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_DeckListOtherUser { + extend SessionCommand { + optional Command_DeckListOtherUser ext = 1029; + } + optional string user_name = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_set_visibility.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_set_visibility.proto new file mode 100644 index 000000000..3ada87973 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_set_visibility.proto @@ -0,0 +1,13 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_DeckSetVisibility { + extend SessionCommand { + optional Command_DeckSetVisibility ext = 1030; + } + // Set the public visibility of a single deck (mutually exclusive with folder_path). + optional uint32 deck_id = 1; + // Set the public visibility of a folder (all decks under it inherit). + optional string folder_path = 2; + optional bool is_public = 3; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_create.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_create.proto new file mode 100644 index 000000000..9cebf8eae --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_create.proto @@ -0,0 +1,24 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message DeckShareItem { + // Reference an existing deck in the sharer's personal deck storage. + // Mutually exclusive with deck_list. + optional uint32 deck_id = 1; + // Inline deck content in the native format. + // Mutually exclusive with deck_id. + optional string deck_list = 2; + // Color identity of the deck (e.g. "WUBRG"), computed by the sharing client. + optional string color_identity = 3; +} + +message Command_DeckShareCreate { + extend SessionCommand { + optional Command_DeckShareCreate ext = 1026; + } + optional string name = 1; + repeated DeckShareItem items = 2; + // Path of a folder in the sharer's personal deck storage. When set, all + // decks in that folder are shared (resolved by the server). + optional string folder_path = 3; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_download.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_download.proto new file mode 100644 index 000000000..251a662b3 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_download.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_DeckShareDownload { + extend SessionCommand { + optional Command_DeckShareDownload ext = 1028; + } + optional string token = 1; + optional uint32 item_id = 2; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_list.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_list.proto new file mode 100644 index 000000000..b75fd65f9 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_list.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_DeckShareList { + extend SessionCommand { + optional Command_DeckShareList ext = 1027; + } + optional string token = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_list_mine.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_list_mine.proto new file mode 100644 index 000000000..75bc30714 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_list_mine.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "session_commands.proto"; + +// Requests the list of share bundles created by the calling user, so they can +// be reviewed and revoked before they expire. +message Command_DeckShareListMine { + extend SessionCommand { + optional Command_DeckShareListMine ext = 1032; + } +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_remove.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_remove.proto new file mode 100644 index 000000000..348996da9 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_share_remove.proto @@ -0,0 +1,11 @@ +syntax = "proto2"; +import "session_commands.proto"; + +// Revokes one of the calling user's own share bundles. The referenced items +// are removed by cascade. +message Command_DeckShareRemove { + extend SessionCommand { + optional Command_DeckShareRemove ext = 1033; + } + optional uint32 share_id = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_upload.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_upload.proto index 63d9c80ef..1a5d44e9e 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_upload.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_deck_upload.proto @@ -8,4 +8,10 @@ message Command_DeckUpload { optional string path = 1; // to upload a new deck optional uint32 deck_id = 2; // to replace an existing deck optional string deck_list = 3; + optional bool is_public = 4; // mark the deck public on upload (publish) + // The server derives the banner card and tags from deck_list, so clients only + // need to send the color identity, which cannot be computed server-side. + reserved 5, 6, 8; + reserved "banner_card_name", "banner_card_provider", "tags"; + optional string color_identity = 7; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_get_server_stats.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_get_server_stats.proto new file mode 100644 index 000000000..33c56293b --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_get_server_stats.proto @@ -0,0 +1,8 @@ +syntax = "proto2"; +import "developer_commands.proto"; + +message Command_GetServerStats { + extend DeveloperCommand { + optional Command_GetServerStats ext = 1000; + } +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_replay_download_by_game_id.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_replay_download_by_game_id.proto new file mode 100644 index 000000000..8fa61a517 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_replay_download_by_game_id.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "moderator_commands.proto"; + +message Command_ReplayDownloadByGameId { + extend ModeratorCommand { + optional Command_ReplayDownloadByGameId ext = 1203; + } + required sint32 game_id = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_report.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report.proto new file mode 100644 index 000000000..3a8c1548e --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report.proto @@ -0,0 +1,13 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_Report { + extend SessionCommand { + optional Command_Report ext = 1200; + } + optional string reported_user = 1; + optional int32 game_id = 2; + optional string category = 3; + optional string description = 4; + optional string chat_log = 5; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_add_comment.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_add_comment.proto new file mode 100644 index 000000000..1fc5696e5 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_add_comment.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_ReportAddComment { + extend SessionCommand { + optional Command_ReportAddComment ext = 1205; + } + required int32 report_id = 1; + required string comment = 2; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_assign.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_assign.proto new file mode 100644 index 000000000..2b72205f8 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_assign.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "moderator_commands.proto"; + +message Command_ReportAssign { + extend ModeratorCommand { + optional Command_ReportAssign ext = 1201; + } + required int32 report_id = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_details.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_details.proto new file mode 100644 index 000000000..e3ae8cfb4 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_details.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_ReportDetails { + extend SessionCommand { + optional Command_ReportDetails ext = 1206; + } + required int32 report_id = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_list.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_list.proto new file mode 100644 index 000000000..6993de1a7 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_list.proto @@ -0,0 +1,11 @@ +syntax = "proto2"; +import "moderator_commands.proto"; + +message Command_ReportList { + extend ModeratorCommand { + optional Command_ReportList ext = 1200; + } + optional bool unresolved_only = 1; + optional uint32 offset = 2 [default = 0]; + optional uint32 limit = 3 [default = 100]; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_my_list.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_my_list.proto new file mode 100644 index 000000000..7ee18a65d --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_my_list.proto @@ -0,0 +1,8 @@ +syntax = "proto2"; +import "session_commands.proto"; + +message Command_ReportMyList { + extend SessionCommand { + optional Command_ReportMyList ext = 1204; + } +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_resolve.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_resolve.proto new file mode 100644 index 000000000..312ca0f9d --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_resolve.proto @@ -0,0 +1,11 @@ +syntax = "proto2"; +import "moderator_commands.proto"; + +message Command_ReportResolve { + extend ModeratorCommand { + optional Command_ReportResolve ext = 1202; + } + required int32 report_id = 1; + optional string resolution_note = 2; + optional bool dismissed = 3; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_stats.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_stats.proto new file mode 100644 index 000000000..b3c6eb1a9 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_stats.proto @@ -0,0 +1,8 @@ +syntax = "proto2"; +import "moderator_commands.proto"; + +message Command_ReportStats { + extend ModeratorCommand { + optional Command_ReportStats ext = 1205; + } +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_user_info.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_user_info.proto new file mode 100644 index 000000000..8f83cb5a3 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_report_user_info.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "moderator_commands.proto"; + +message Command_ReportUserInfo { + extend ModeratorCommand { + optional Command_ReportUserInfo ext = 1204; + } + optional string user_name = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/command_set_playmat.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/command_set_playmat.proto new file mode 100644 index 000000000..ad9a29a44 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/command_set_playmat.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "game_commands.proto"; +import "serverinfo_playerproperties.proto"; +message Command_SetPlaymat { + extend GameCommand { + optional Command_SetPlaymat ext = 1035; + } + optional ServerInfo_PlayerProperties.PlaymatParams playmat_params = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/commands.proto index b6eaf6733..964407819 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/commands.proto @@ -4,6 +4,7 @@ import "game_commands.proto"; import "room_commands.proto"; import "moderator_commands.proto"; import "admin_commands.proto"; +import "developer_commands.proto"; message CommandContainer { optional uint64 cmd_id = 1; @@ -16,4 +17,5 @@ message CommandContainer { repeated RoomCommand room_command = 102; repeated ModeratorCommand moderator_command = 103; repeated AdminCommand admin_command = 104; + repeated DeveloperCommand developer_command = 105; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/developer_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/developer_commands.proto new file mode 100644 index 000000000..bed47d44c --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/developer_commands.proto @@ -0,0 +1,8 @@ +syntax = "proto2"; +message DeveloperCommand { + enum DeveloperCommandType { + GET_SERVER_STATS = 1000; + VIEWLOG_HISTORY = 1001; + } + extensions 100 to max; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/event_notify_user.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/event_notify_user.proto index 3a90d278b..b722dfc71 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/event_notify_user.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/event_notify_user.proto @@ -9,6 +9,8 @@ message Event_NotifyUser { WARNING = 2; IDLEWARNING = 3; CUSTOM = 4; + REPORT_RESOLVED = 5; + REPORT_COMMENT = 6; } extend SessionEvent { diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto index 8ecf7ec9c..2e5b88978 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto @@ -175,6 +175,11 @@ message GameCommand { /// Server: Server_Player::cmdReverseTurn /// Client: reflected via subsequent turn events REVERSE_TURN = 1034; + + /// Set the player's playmat independently of the deck. + /// Server: Server_Player::cmdSetPlaymat + /// Client: reflected via player properties changed event + SET_PLAYMAT = 1035; } extensions 100 to max; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto index ca46e4dd7..4f1e80c27 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto @@ -1,4 +1,5 @@ syntax = "proto2"; +import "developer_commands.proto"; message ModeratorCommand { enum ModeratorCommandType { BAN_FROM_SERVER = 1000; @@ -14,6 +15,17 @@ message ModeratorCommand { ADD_CARD_ART_RULE = 1010; REMOVE_CARD_ART_RULE = 1011; LIST_CARD_ART_RULES = 1012; + GET_USER_SESSIONS = 1013; + GET_USER_ALTS = 1014; + GET_MODERATOR_LAST_LOGINS = 1015; + RESET_USER_PASSWORD = 1016; + REMOVE_USER_AVATAR = 1017; + REPORT_LIST = 1200; + REPORT_ASSIGN = 1201; + REPORT_RESOLVE = 1202; + REPLAY_DOWNLOAD_BY_GAME_ID = 1203; + REPORT_USER_INFO = 1204; + REPORT_STATS = 1205; } extensions 100 to max; } @@ -69,6 +81,9 @@ message Command_ViewLogHistory { extend ModeratorCommand { optional Command_ViewLogHistory ext = 1005; } + extend DeveloperCommand { + optional Command_ViewLogHistory dev_ext = 1001; + } optional string user_name = 1; // user that created message optional string ip_address = 2; // ip address of user that created message optional string game_name = 3; // client id of user that created the message @@ -135,3 +150,31 @@ message Command_ListCardArtRules { optional Command_ListCardArtRules ext = 1012; } } + +message Command_GetUserSessions { + extend ModeratorCommand { + optional Command_GetUserSessions ext = 1013; + } + optional string user_name = 1; + optional uint32 limit = 2 [default = 110]; +} + +message Command_GetUserAlts { + extend ModeratorCommand { + optional Command_GetUserAlts ext = 1014; + } + optional string user_name = 1; +} + +message Command_GetModeratorLastLogins { + extend ModeratorCommand { + optional Command_GetModeratorLastLogins ext = 1015; + } +} + +message Command_RemoveUserAvatar { + extend ModeratorCommand { + optional Command_RemoveUserAvatar ext = 1017; + } + optional string user_name = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto index e1f415ce6..caf9febde 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto @@ -53,6 +53,7 @@ message Response { RespClientUpdateRequired = 35; // Client is missing features that the server is requiring RespServerFull = 36; // Server user limit reached RespEmailBlackListed = 37; // Server has blocked the email address provided for registration for some reason + RespPasswordChangeRequired = 38; // Server requires the user to change their password before proceeding } // Type of response, used to route handling on the client @@ -76,9 +77,14 @@ message Response { FORGOT_PASSWORD_REQUEST = 1016; // Response to password reset request PASSWORD_SALT = 1017; // Response containing password salt GET_ADMIN_NOTES = 1018; // Response with admin notes + GET_SERVER_STATS = 1019; // Response with server status statistics REPLAY_LIST = 1100; // Response listing replays REPLAY_DOWNLOAD = 1101; // Response for replay download REPLAY_GET_CODE = 1102; // Response containing replay code + DECK_SHARE_CREATE = 1103; // Response to deck share creation + DECK_SHARE_LIST = 1104; // Response listing shared decks + DECK_SHARE_DOWNLOAD = 1105; // Response for shared deck download + DECK_SHARE_LIST_MINE = 1106; // Response listing the caller's own shares CARD_ART_RULE_LIST = 1200; // Response containing a list of card art rules } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_create.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_create.proto new file mode 100644 index 000000000..574c02eb6 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_create.proto @@ -0,0 +1,11 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_DeckShareCreate { + extend Response { + optional Response_DeckShareCreate ext = 1103; + } + optional string token = 1; + optional uint64 expires_at = 2; + optional uint32 item_count = 3; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_download.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_download.proto new file mode 100644 index 000000000..def0ccbe6 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_download.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_DeckShareDownload { + extend Response { + optional Response_DeckShareDownload ext = 1105; + } + optional string deck = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_list.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_list.proto new file mode 100644 index 000000000..3edffa8ac --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_list.proto @@ -0,0 +1,12 @@ +syntax = "proto2"; +import "response.proto"; +import "serverinfo_deck_share_item.proto"; + +message Response_DeckShareList { + extend Response { + optional Response_DeckShareList ext = 1104; + } + optional string name = 1; + optional uint64 expires_at = 2; + repeated ServerInfo_DeckShareItem items = 3; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_list_mine.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_list_mine.proto new file mode 100644 index 000000000..e3cbf83b9 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_deck_share_list_mine.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "response.proto"; +import "serverinfo_deck_share_summary.proto"; + +message Response_DeckShareListMine { + extend Response { + optional Response_DeckShareListMine ext = 1106; + } + repeated ServerInfo_DeckShareSummary shares = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_get_server_stats.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_get_server_stats.proto new file mode 100644 index 000000000..bb8ff3c43 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_get_server_stats.proto @@ -0,0 +1,41 @@ +syntax = "proto2"; +import "response.proto"; + +message CommandStats { + optional uint32 kind_index = 1; // 0=session, 1=room, 2=game, 3=moderator, 4=admin, 5=developer + optional uint32 extension_number = 2; // protobuf extension number within the kind + optional string command_name = 3; // e.g. "session/Command_Ping" + optional uint64 count = 4; // number of times observed + optional uint64 total_ms = 5; // cumulative processing milliseconds +} + +message Response_GetServerStats { + extend Response { + optional Response_GetServerStats ext = 1220; + } + + optional uint64 users_count = 1; + optional uint64 mods_count = 2; + optional uint64 games_count = 3; + + // Traffic recorded during the last status update tick + optional uint64 tx_bytes = 4; + optional uint64 rx_bytes = 5; + + optional uint64 uptime_secs = 6; + optional uint64 timest = 7; // unix timestamp of the snapshot + + // Live metrics from MetricsRegistry (reset on server restart) + optional uint64 cards_in_games = 8; + optional uint64 eventloop_stalls_total = 9; + optional uint64 eventloop_last_stall_ms = 10; + optional uint64 eventloop_max_stall_ms = 11; + optional uint64 total_commands = 12; + optional uint64 total_command_time_ms = 13; + optional int32 active_command_types = 14; + optional uint64 game_start_count = 15; + optional uint64 game_start_total_ms = 16; + + // Per-command breakdown (only types with count > 0) + repeated CommandStats command_stats = 20; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_moderator_last_logins.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_moderator_last_logins.proto new file mode 100644 index 000000000..6288880c4 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_moderator_last_logins.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "response.proto"; +import "serverinfo_moderator_login.proto"; + +message Response_ModeratorLastLogins { + extend Response { + optional Response_ModeratorLastLogins ext = 1217; + } + repeated ServerInfo_ModeratorLogin logins = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_remove_user_avatar.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_remove_user_avatar.proto new file mode 100644 index 000000000..e5697d4e0 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_remove_user_avatar.proto @@ -0,0 +1,9 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_RemoveUserAvatar { + extend Response { + optional Response_RemoveUserAvatar ext = 1219; + } + optional string user_name = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_replay_download_by_game_id.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_replay_download_by_game_id.proto new file mode 100644 index 000000000..77a5feb81 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_replay_download_by_game_id.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_ReplayDownloadByGameId { + extend Response { + optional Response_ReplayDownloadByGameId ext = 1203; + } + optional bytes replay_data = 1; + optional sint32 replay_id = 2; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_details.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_details.proto new file mode 100644 index 000000000..463beaefc --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_details.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "response.proto"; +import "serverinfo_report.proto"; + +message Response_ReportDetails { + extend Response { + optional Response_ReportDetails ext = 1214; + } + optional ServerInfo_Report report = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_list.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_list.proto new file mode 100644 index 000000000..73d9fbef8 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_list.proto @@ -0,0 +1,11 @@ +syntax = "proto2"; +import "response.proto"; +import "serverinfo_report.proto"; + +message Response_ReportList { + extend Response { + optional Response_ReportList ext = 1210; + } + repeated ServerInfo_Report reports = 1; + optional uint32 total_count = 2; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_my_list.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_my_list.proto new file mode 100644 index 000000000..fdd02a3f1 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_my_list.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "response.proto"; +import "serverinfo_report.proto"; + +message Response_ReportMyList { + extend Response { + optional Response_ReportMyList ext = 1213; + } + repeated ServerInfo_Report reports = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_stats.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_stats.proto new file mode 100644 index 000000000..8bd2cf766 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_stats.proto @@ -0,0 +1,36 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_ReportStats { + extend Response { + optional Response_ReportStats ext = 1212; + } + + optional int32 total_reports = 1; + optional int32 total_pending = 2; + optional int32 total_assigned = 3; + optional int32 total_resolved = 4; + + optional int32 reports_last_24h = 5; + optional int32 reports_last_7d = 6; + optional int32 reports_last_30d = 7; + + optional double avg_resolution_hours = 8; + + optional int32 reports_this_week = 9; + optional int32 reports_last_week = 10; + + repeated ReportCategoryCount category_counts = 11; + repeated ReportTopUser top_reported_users = 12; + repeated ReportTopUser top_reporters = 13; +} + +message ReportCategoryCount { + optional string category = 1; + optional int32 count = 2; +} + +message ReportTopUser { + optional string user_name = 1; + optional int32 count = 2; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_user_info.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_user_info.proto new file mode 100644 index 000000000..5f9e8ec38 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_report_user_info.proto @@ -0,0 +1,21 @@ +syntax = "proto2"; +import "response.proto"; +import "serverinfo_report.proto"; + +message Response_ReportUserInfo { + extend Response { + optional Response_ReportUserInfo ext = 1211; + } + + optional string user_name = 1; + optional int32 total_reports = 2; + optional int32 total_bans = 3; + optional int32 total_warns = 4; + optional int64 registration_time = 5; + optional bool is_admin = 6; + optional bool is_active = 7; + optional string admin_notes = 8; + repeated ServerInfo_Report recent_reports = 9; + // Last known login of the user, epoch seconds; 0 = unknown. + optional int64 last_login = 10; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_reset_user_password.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_reset_user_password.proto new file mode 100644 index 000000000..aa379e573 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_reset_user_password.proto @@ -0,0 +1,12 @@ +syntax = "proto2"; +import "response.proto"; + +message Response_ResetUserPassword { + extend Response { + optional Response_ResetUserPassword ext = 1218; + } + optional string user_name = 1; + // The generated temporary password, shown to the moderator who + // requested the reset. The affected user must change it on first login. + optional string temporary_password = 2; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_user_alts.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_user_alts.proto new file mode 100644 index 000000000..6bc48d6cb --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_user_alts.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "response.proto"; +import "serverinfo_user_alt.proto"; + +message Response_UserAlts { + extend Response { + optional Response_UserAlts ext = 1216; + } + repeated ServerInfo_UserAlt alts = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_user_sessions.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_user_sessions.proto new file mode 100644 index 000000000..7174fd3f7 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_user_sessions.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; +import "response.proto"; +import "serverinfo_user_session.proto"; + +message Response_UserSessions { + extend Response { + optional Response_UserSessions ext = 1215; + } + repeated ServerInfo_UserSession sessions = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response_warn_list.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response_warn_list.proto index d48352529..cddd08a51 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/response_warn_list.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response_warn_list.proto @@ -8,4 +8,7 @@ message Response_WarnList { repeated string warning = 1; optional string user_name = 2; optional string user_clientid = 3; + // Recommended starting intervention level per warning category, + // aligned by index with `warning`. Absent or shorter lists default to 1. + repeated uint32 warning_il = 4; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deck_share_item.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deck_share_item.proto new file mode 100644 index 000000000..bac025419 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deck_share_item.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; + +message ServerInfo_DeckShareItem { + optional uint32 id = 1; + optional string name = 2; + repeated string tags = 3; + optional string banner_card = 4; + optional string game_format = 5; + optional string color_identity = 6; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deck_share_summary.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deck_share_summary.proto new file mode 100644 index 000000000..43273a54f --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deck_share_summary.proto @@ -0,0 +1,10 @@ +syntax = "proto2"; + +// A share bundle created by a user, as reported by a "list my shares" query. +message ServerInfo_DeckShareSummary { + optional uint32 id = 1; + optional string name = 2; + optional uint64 creation_time = 3; + optional uint64 expires_at = 4; + optional uint32 item_count = 5; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deckstorage.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deckstorage.proto index 16e3f28e3..b04d676d8 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deckstorage.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_deckstorage.proto @@ -1,10 +1,21 @@ syntax = "proto2"; message ServerInfo_DeckStorage_File { optional uint32 creation_time = 1; + optional bool is_public = 2; + // Preview metadata computed by the uploading client, so other clients can + // render this deck (e.g. in a visual storage grid) without downloading the + // full deck list. Empty for decks uploaded before the metadata columns. + optional string banner_card_name = 3; + optional string banner_card_provider = 4; + optional string color_identity = 5; + // Tag names associated with the deck. Empty for decks uploaded before the + // tags column existed. + repeated string tags = 6; } message ServerInfo_DeckStorage_Folder { repeated ServerInfo_DeckStorage_TreeItem items = 1; + optional bool is_public = 2; } message ServerInfo_DeckStorage_TreeItem { diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_game.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_game.proto index 9a56e034c..9989ae18a 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_game.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_game.proto @@ -62,4 +62,7 @@ message ServerInfo_Game { // whether the game is closed. Closed games are finished and can't be interacted with optional bool closed = 52; + + // the current host of the game, which may differ from the creator after a host transfer + optional ServerInfo_User host_info = 53; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_moderator_login.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_moderator_login.proto new file mode 100644 index 000000000..21db2d3fa --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_moderator_login.proto @@ -0,0 +1,11 @@ +syntax = "proto2"; + +/** + * The last login date of a staff member (moderator/judge/admin). + * Used by the moderation "Moderator Last Logins" tool. + */ +message ServerInfo_ModeratorLogin { + optional string user_name = 1; // staff account name + optional uint64 last_login = 2; // last known login, epoch seconds; 0 = unknown + optional uint32 user_level = 3; // ServerInfo_User::UserLevelFlag mask (moderator/judge/admin) +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_playerproperties.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_playerproperties.proto index cdd0ee42c..ae19e4018 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_playerproperties.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_playerproperties.proto @@ -2,6 +2,15 @@ syntax = "proto2"; import "serverinfo_user.proto"; message ServerInfo_PlayerProperties { + message PlaymatParams { + optional string card_name = 1; + optional string card_provider_id = 2; + optional double margin_pct_l = 3 [default = 0.07]; + optional double margin_pct_r = 4 [default = 0.07]; + optional double vertical_offset = 5 [default = 0.33]; + optional double zoom = 6 [default = 1.0]; + } + optional sint32 player_id = 1; optional ServerInfo_User user_info = 2; optional bool spectator = 3; @@ -11,4 +20,5 @@ message ServerInfo_PlayerProperties { optional sint32 ping_seconds = 7; optional bool sideboard_locked = 8; optional bool judge = 9; + optional PlaymatParams playmat_params = 10; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_report.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_report.proto new file mode 100644 index 000000000..a145fd855 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_report.proto @@ -0,0 +1,36 @@ +syntax = "proto2"; + +message ServerInfo_ReportComment { + optional string author_name = 1; + optional string comment_text = 2; + optional int64 comment_time = 3; + optional bool is_moderator = 4; +} + +message ServerInfo_Report { + optional int32 report_id = 1; + + optional string reporter_name = 2; + optional string reported_user_name = 3; + + optional int32 game_id = 4; + optional int32 replay_id = 5; + optional int32 room_id = 6; + + optional string category = 7; + optional string status = 8; + + optional string description = 9; + + optional int64 report_time = 10; + + optional string assigned_mod_name = 11; + + optional int64 resolution_time = 12; + + repeated ServerInfo_ReportComment comments = 13; + + optional string chat_log = 14; + + optional string resolution_note = 15; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user.proto index 98cc3ce6a..ea3f56705 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user.proto @@ -8,6 +8,7 @@ message ServerInfo_User { IsModerator = 4; IsAdmin = 8; IsJudge = 16; + IsDeveloper = 32; }; message PawnColorsOverride { optional string left_side = 1; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto new file mode 100644 index 000000000..191dd5063 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_alt.proto @@ -0,0 +1,16 @@ +syntax = "proto2"; + +/** + * An account that shares an IP address, client id or eMail address with + * the account being investigated. Used by the moderation "Get User Alts" tool. + */ +message ServerInfo_UserAlt { + optional string user_name = 1; // account name + optional string email = 2; // registration eMail + optional string clientid = 3; // client id + optional uint64 registration_time = 4; // account registration, epoch seconds + optional uint64 last_login = 5; // last known login, epoch seconds; 0 = unknown + optional uint32 warn_count = 6; // number of warnings on record + optional uint32 ban_count = 7; // number of bans on record + optional bool is_active = 8; // account is not deactivated/banned +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_session.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_session.proto new file mode 100644 index 000000000..7ce240050 --- /dev/null +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/serverinfo_user_session.proto @@ -0,0 +1,14 @@ +syntax = "proto2"; + +/** + * A single login session of a user on the server, as stored in the + * sessions table. Used by the moderation "Get User Sessions" tool. + */ +message ServerInfo_UserSession { + optional string user_name = 1; // account that was logged in + optional string ip_address = 2; // IP address used for the session + optional string clientid = 3; // client id used for the session + optional uint64 start_time = 4; // session start, epoch seconds + optional uint64 end_time = 5; // session end, epoch seconds; 0 = still active + optional string connection_type = 6; // "tcp" or "websocket" +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto index 9d207c711..4b7fe9c85 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto @@ -28,12 +28,25 @@ message SessionCommand { FORGOT_PASSWORD_CHALLENGE = 1023; REQUEST_PASSWORD_SALT = 1024; SET_CARD_ART_PARAMS = 1025; + DECK_SHARE_CREATE = 1026; + DECK_SHARE_LIST = 1027; + DECK_SHARE_DOWNLOAD = 1028; + DECK_LIST_OTHER_USER = 1029; + DECK_SET_VISIBILITY = 1030; + DECK_DOWNLOAD_PUBLIC = 1031; + DECK_SHARE_LIST_MINE = 1032; + DECK_SHARE_REMOVE = 1033; REPLAY_LIST = 1100; REPLAY_DOWNLOAD = 1101; REPLAY_MODIFY_MATCH = 1102; REPLAY_DELETE_MATCH = 1103; REPLAY_GET_CODE = 1104; REPLAY_SUBMIT_CODE = 1105; + REPORT = 1200; + // 1201-1203 reserved: removed during squash + REPORT_MY_LIST = 1204; + REPORT_ADD_COMMENT = 1205; + REPORT_DETAILS = 1206; } extensions 100 to max; } diff --git a/libcockatrice_protocol/libcockatrice/protocol/pending_command.cpp b/libcockatrice_protocol/libcockatrice/protocol/pending_command.cpp index 4a9943d33..4a23fb6ef 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pending_command.cpp +++ b/libcockatrice_protocol/libcockatrice/protocol/pending_command.cpp @@ -1,5 +1,8 @@ #include "pending_command.h" +class CommandContainer; +class Response; + PendingCommand::PendingCommand(const CommandContainer &_commandContainer, QVariant _extraData) : commandContainer(_commandContainer), extraData(_extraData), ticks(0) { @@ -29,3 +32,13 @@ int PendingCommand::tick() { return ++ticks; } + +void PendingCommand::startTiming() +{ + startTime.start(); +} + +qint64 PendingCommand::elapsedMs() const +{ + return startTime.isValid() ? startTime.nsecsElapsed() / 1000000 : -1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pending_command.h b/libcockatrice_protocol/libcockatrice/protocol/pending_command.h index dbe57e7fc..9ed8a863c 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pending_command.h +++ b/libcockatrice_protocol/libcockatrice/protocol/pending_command.h @@ -7,9 +7,14 @@ #ifndef PENDING_COMMAND_H #define PENDING_COMMAND_H +#include +#include #include +#include #include -#include +#include + +class Response; class PendingCommand : public QObject { @@ -21,6 +26,7 @@ private: CommandContainer commandContainer; QVariant extraData; int ticks; + QElapsedTimer startTime; public: explicit PendingCommand(const CommandContainer &_commandContainer, QVariant _extraData = QVariant()); @@ -29,6 +35,18 @@ public: QVariant getExtraData() const; void processResponse(const Response &response); int tick(); + + /** + * @brief Starts the round-trip timer. Called by the client thread right + * before the command container is handed to the transport layer. + */ + void startTiming(); + + /** + * @return Milliseconds elapsed since startTiming(), or -1 if the timer was + * never started. + */ + qint64 elapsedMs() const; }; #endif diff --git a/libcockatrice_rng/CMakeLists.txt b/libcockatrice_rng/CMakeLists.txt index b988c9d2f..6128e3f6e 100644 --- a/libcockatrice_rng/CMakeLists.txt +++ b/libcockatrice_rng/CMakeLists.txt @@ -11,6 +11,10 @@ add_library( libcockatrice/rng/sfmt/SFMT.c ) +# SFMT is vendored and its own headers trip UBSan's alignment and vptr checks. +cockatrice_disable_sanitizers_for(libcockatrice/rng/sfmt/SFMT.c) +cockatrice_disable_coverage_for(libcockatrice/rng/sfmt/SFMT.c) + target_include_directories(libcockatrice_rng PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(libcockatrice_rng PUBLIC ${QT_CORE_MODULE}) diff --git a/libcockatrice_rng/libcockatrice/rng/rng_abstract.cpp b/libcockatrice_rng/libcockatrice/rng/rng_abstract.cpp index 82404d351..34a778ca9 100644 --- a/libcockatrice_rng/libcockatrice/rng/rng_abstract.cpp +++ b/libcockatrice_rng/libcockatrice/rng/rng_abstract.cpp @@ -1,6 +1,8 @@ #include "rng_abstract.h" #include +#include +#include QVector RNG_Abstract::makeNumbersVector(int n, int min, int max) { diff --git a/libcockatrice_rng/libcockatrice/rng/rng_abstract.h b/libcockatrice_rng/libcockatrice/rng/rng_abstract.h index 903e6ef1a..49fb89612 100644 --- a/libcockatrice_rng/libcockatrice/rng/rng_abstract.h +++ b/libcockatrice_rng/libcockatrice/rng/rng_abstract.h @@ -3,6 +3,7 @@ #include #include +#include class RNG_Abstract : public QObject { diff --git a/libcockatrice_rng/libcockatrice/rng/rng_sfmt.cpp b/libcockatrice_rng/libcockatrice/rng/rng_sfmt.cpp index 5b38deb3f..13e6fe680 100644 --- a/libcockatrice_rng/libcockatrice/rng/rng_sfmt.cpp +++ b/libcockatrice_rng/libcockatrice/rng/rng_sfmt.cpp @@ -1,9 +1,13 @@ #include "rng_sfmt.h" -#include -#include -#include +#include "libcockatrice/rng/rng_abstract.h" +#include "libcockatrice/rng/sfmt/SFMT.h" + +#include #include +#include + +class QObject; // This is from gcc sources, namely from fixincludes/inclhack.def // On C++11 systems, could be included instead. @@ -11,10 +15,11 @@ #define UINT64_MAX (~(uint64_t)0) #endif -RNG_SFMT::RNG_SFMT(QObject *parent) : RNG_Abstract(parent) +RNG_SFMT::RNG_SFMT(uint64_t seed, QObject *parent) : RNG_Abstract(parent) { - // initialize the random number generator with a 32bit integer seed (timestamp) - sfmt_init_gen_rand(&sfmt, QDateTime::currentDateTime().toSecsSinceEpoch()); + // initialize the random number generator with a 64bit seed, e.g. from a CSPRNG + uint32_t seedArray[2] = {static_cast(seed), static_cast(seed >> 32)}; + sfmt_init_by_array(&sfmt, seedArray, 2); } /** diff --git a/libcockatrice_rng/libcockatrice/rng/rng_sfmt.h b/libcockatrice_rng/libcockatrice/rng/rng_sfmt.h index 7e9f53df3..e178812ab 100644 --- a/libcockatrice_rng/libcockatrice/rng/rng_sfmt.h +++ b/libcockatrice_rng/libcockatrice/rng/rng_sfmt.h @@ -5,7 +5,10 @@ #include "sfmt/SFMT.h" #include -#include +#include +#include + +class QObject; /** * This class encapsulates a state of the art PRNG and can be used @@ -36,7 +39,7 @@ private: unsigned int cdf(unsigned int min, unsigned int max); public: - explicit RNG_SFMT(QObject *parent = nullptr); + explicit RNG_SFMT(uint64_t seed, QObject *parent = nullptr); unsigned int rand(int min, int max) override; }; diff --git a/libcockatrice_settings/CMakeLists.txt b/libcockatrice_settings/CMakeLists.txt index 80a465b6d..5a2fa42a9 100644 --- a/libcockatrice_settings/CMakeLists.txt +++ b/libcockatrice_settings/CMakeLists.txt @@ -71,3 +71,5 @@ target_include_directories( ) target_link_libraries(libcockatrice_settings PUBLIC libcockatrice_card libcockatrice_utility ${QT_CORE_MODULE}) + +target_precompile_headers(libcockatrice_settings PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") diff --git a/libcockatrice_settings/libcockatrice/settings/appearance_settings.cpp b/libcockatrice_settings/libcockatrice/settings/appearance_settings.cpp index 45a02299e..14969dae6 100644 --- a/libcockatrice_settings/libcockatrice/settings/appearance_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/appearance_settings.cpp @@ -1,5 +1,11 @@ #include "appearance_settings.h" +#include "libcockatrice/settings/settings_manager.h" + +#include + +class QObject; + AppearanceSettings::AppearanceSettings(const QString &settingPath, QObject *parent) : SettingsManager(settingPath + "appearance.ini", "appearance", QString(), parent) { @@ -69,3 +75,25 @@ void AppearanceSettings::setHomeTabDisplayCardName(bool _displayCardName) setValue(_displayCardName, "homeTabDisplayCardName"); emit homeTabDisplayCardNameChanged(); } + +bool AppearanceSettings::getHomeTabBackgroundDim() const +{ + return getValue("homeTabBackgroundDim", QString(), QString(), true).toBool(); +} + +void AppearanceSettings::setHomeTabBackgroundDim(bool _dimBackground) +{ + setValue(_dimBackground, "homeTabBackgroundDim"); + emit homeTabBackgroundDimChanged(); +} + +int AppearanceSettings::getHomeTabButtonColorSourceIndex() const +{ + return getValue("homeTabButtonColorSource", "", "", 0).toInt(); +} + +void AppearanceSettings::setHomeTabButtonColorSourceIndex(int index) +{ + setValue(index, "homeTabButtonColorSource"); + emit homeTabButtonColorChanged(); +} diff --git a/libcockatrice_settings/libcockatrice/settings/appearance_settings.h b/libcockatrice_settings/libcockatrice/settings/appearance_settings.h index d9b326bee..9d15caddd 100644 --- a/libcockatrice_settings/libcockatrice/settings/appearance_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/appearance_settings.h @@ -9,6 +9,11 @@ #include "settings_manager.h" +#include +#include + +class QObject; + class AppearanceSettings : public SettingsManager { Q_OBJECT @@ -27,6 +32,10 @@ public: void setHomeTabBackgroundShuffleFrequency(int _frequency); [[nodiscard]] bool getHomeTabDisplayCardName() const; void setHomeTabDisplayCardName(bool _displayCardName); + [[nodiscard]] bool getHomeTabBackgroundDim() const; + void setHomeTabBackgroundDim(bool _dimBackground); + [[nodiscard]] int getHomeTabButtonColorSourceIndex() const; + void setHomeTabButtonColorSourceIndex(int index); signals: void themeNameChanged(); @@ -34,6 +43,8 @@ signals: void homeTabBackgroundSourceChanged(); void homeTabBackgroundShuffleFrequencyChanged(); void homeTabDisplayCardNameChanged(); + void homeTabBackgroundDimChanged(); + void homeTabButtonColorChanged(); public: explicit AppearanceSettings(const QString &settingPath, QObject *parent = nullptr); diff --git a/libcockatrice_settings/libcockatrice/settings/cache_storage_settings.cpp b/libcockatrice_settings/libcockatrice/settings/cache_storage_settings.cpp index c800cc024..aaea87791 100644 --- a/libcockatrice_settings/libcockatrice/settings/cache_storage_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/cache_storage_settings.cpp @@ -1,5 +1,11 @@ #include "cache_storage_settings.h" +#include "libcockatrice/settings/settings_manager.h" + +#include + +class QObject; + CacheStorageSettings::CacheStorageSettings(const QString &settingPath, QObject *parent) : SettingsManager(settingPath + "cache_storage.ini", "cache_storage", QString(), parent) { diff --git a/libcockatrice_settings/libcockatrice/settings/cache_storage_settings.h b/libcockatrice_settings/libcockatrice/settings/cache_storage_settings.h index 4a4e73461..a4c8a197d 100644 --- a/libcockatrice_settings/libcockatrice/settings/cache_storage_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/cache_storage_settings.h @@ -3,7 +3,11 @@ #include "settings_manager.h" +#include #include +#include + +class QObject; // In MB (Increments of 64) #define PIXMAPCACHE_SIZE_DEFAULT 2048 diff --git a/libcockatrice_settings/libcockatrice/settings/card_database_settings.cpp b/libcockatrice_settings/libcockatrice/settings/card_database_settings.cpp index b55c97909..acac1125f 100644 --- a/libcockatrice_settings/libcockatrice/settings/card_database_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/card_database_settings.cpp @@ -1,6 +1,16 @@ #include "card_database_settings.h" +#include "libcockatrice/interfaces/interface_card_set_priority_controller.h" +#include "libcockatrice/settings/settings_manager.h" + +#include #include +#include +#include +#include +#include + +class QObject; CardDatabaseSettings::CardDatabaseSettings(const QString &settingPath, QObject *parent) : SettingsManager(settingPath + "cardDatabase.ini", QString(), QString(), parent) @@ -78,6 +88,20 @@ ICardSetPriorityController::SetOptions CardDatabaseSettings::getSetOptions(QStri return setOptionsCache.value(shortName); } +QStringList CardDatabaseSettings::getEnabledSetNames() const +{ + QMutexLocker lock(&setOptionsMutex); + ensureSetOptionsLoaded(); + QStringList names; + for (auto it = setOptionsCache.constBegin(); it != setOptionsCache.constEnd(); ++it) { + if (it.value().enabled) { + names << it.key(); + } + } + names.sort(); + return names; +} + void CardDatabaseSettings::saveSets(const QVector &data) { batchWrite([&](QSettings &s) { diff --git a/libcockatrice_settings/libcockatrice/settings/card_database_settings.h b/libcockatrice_settings/libcockatrice/settings/card_database_settings.h index 4553f197f..5bdf17aef 100644 --- a/libcockatrice_settings/libcockatrice/settings/card_database_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/card_database_settings.h @@ -12,7 +12,12 @@ #include #include +#include +#include #include +#include + +class QObject; class CardDatabaseSettings : public SettingsManager, public ICardSetPriorityController { @@ -30,6 +35,8 @@ public: SetOptions getSetOptions(QString shortName) const override; + QStringList getEnabledSetNames() const override; + void saveSets(const QVector &data) override; private: diff --git a/libcockatrice_settings/libcockatrice/settings/card_override_settings.cpp b/libcockatrice_settings/libcockatrice/settings/card_override_settings.cpp index a61a4693b..5a853a927 100644 --- a/libcockatrice_settings/libcockatrice/settings/card_override_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/card_override_settings.cpp @@ -1,5 +1,12 @@ #include "card_override_settings.h" +#include "libcockatrice/settings/settings_manager.h" +#include "libcockatrice/utility/card_ref.h" + +#include + +class QObject; + CardOverrideSettings::CardOverrideSettings(const QString &settingPath, QObject *parent) : SettingsManager(settingPath + "cardPreferenceOverrides.ini", "cards", QString(), parent) { diff --git a/libcockatrice_settings/libcockatrice/settings/card_override_settings.h b/libcockatrice_settings/libcockatrice/settings/card_override_settings.h index cd515d4da..a9e8b2d69 100644 --- a/libcockatrice_settings/libcockatrice/settings/card_override_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/card_override_settings.h @@ -9,8 +9,11 @@ #include "settings_manager.h" -#include -#include +#include +#include + +class QObject; +struct CardRef; class CardOverrideSettings : public SettingsManager { diff --git a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp index 6ec1af962..f047973f0 100644 --- a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp @@ -1,5 +1,13 @@ #include "cards_display_settings.h" +#include "libcockatrice/card/card_localization.h" +#include "libcockatrice/settings/settings_manager.h" + +#include +#include + +class QObject; + CardsDisplaySettings::CardsDisplaySettings(const QString &settingPath, QObject *parent) : SettingsManager(settingPath + "cards_display.ini", "cards", QString(), parent) { @@ -35,11 +43,6 @@ int CardsDisplaySettings::getPrintingSelectorCardSize() const return getValue("printingSelector", "cards", "cardSize", 100).toInt(); } -bool CardsDisplaySettings::getIncludeRebalancedCards() const -{ - return getValue("includerebalancedcards", QString(), QString(), true).toBool(); -} - bool CardsDisplaySettings::getPrintingSelectorNavigationButtonsVisible() const { return getValue("navigationButtonsVisible", "cards", "printingSelector", true).toBool(); @@ -50,6 +53,11 @@ bool CardsDisplaySettings::getTapAnimation() const return getValue("tapAnimation", QString(), QString(), true).toBool(); } +bool CardsDisplaySettings::getArrowDrawAnimation() const +{ + return getValue("arrowDrawAnimation", QString(), QString(), true).toBool(); +} + bool CardsDisplaySettings::getAutoRotateSidewaysLayoutCards() const { return getValue("autoRotateSidewaysLayoutCards", QString(), QString(), true).toBool(); @@ -85,6 +93,11 @@ int CardsDisplaySettings::getVisualDeckEditorCardSize() const return getValue("visualDeckEditor", "cards", "cardSize", 100).toInt(); } +bool CardsDisplaySettings::getVisualDeckEditorShowCardCounts() const +{ + return getValue("visualDeckEditorShowCardCounts", QString(), QString(), true).toBool(); +} + int CardsDisplaySettings::getEDHRecCardSize() const { return getValue("edhrec", "cards", "cardSize", 100).toInt(); @@ -100,6 +113,16 @@ int CardsDisplaySettings::getSampleHandSize() const return getValue("sampleHandSize", "cards", "cardSize", 7).toInt(); } +QString CardsDisplaySettings::getCardLang() const +{ + return getValue("cardLang", QString(), QString(), "en").toString(); +} + +int CardsDisplaySettings::getCardSearchLanguage() const +{ + return getValue("cardSearchLanguage", QString(), QString(), static_cast(SearchLanguageMode::English)).toInt(); +} + void CardsDisplaySettings::setDisplayCardNames(bool _displayCardNames) { setValue(_displayCardNames, "displayCardNames"); @@ -139,15 +162,6 @@ void CardsDisplaySettings::setPrintingSelectorCardSize(int _printingSelectorCard emit printingSelectorCardSizeChanged(); } -void CardsDisplaySettings::setIncludeRebalancedCards(bool _includeRebalancedCards) -{ - if (_includeRebalancedCards == getIncludeRebalancedCards()) { - return; - } - setValue(_includeRebalancedCards, "includerebalancedcards"); - emit includeRebalancedCardsChanged(_includeRebalancedCards); -} - void CardsDisplaySettings::setPrintingSelectorNavigationButtonsVisible(bool _navigationButtonsVisible) { setValue(_navigationButtonsVisible, "navigationButtonsVisible", "cards", "printingSelector"); @@ -159,6 +173,11 @@ void CardsDisplaySettings::setTapAnimation(bool _tapAnimation) setValue(_tapAnimation, "tapAnimation"); } +void CardsDisplaySettings::setArrowDrawAnimation(bool _arrowDrawAnimation) +{ + setValue(_arrowDrawAnimation, "arrowDrawAnimation"); +} + void CardsDisplaySettings::setAutoRotateSidewaysLayoutCards(bool _autoRotateSidewaysLayoutCards) { setValue(_autoRotateSidewaysLayoutCards, "autoRotateSidewaysLayoutCards"); @@ -197,6 +216,15 @@ void CardsDisplaySettings::setVisualDeckEditorCardSize(int _cardSize) emit visualDeckEditorCardSizeChanged(); } +void CardsDisplaySettings::setVisualDeckEditorShowCardCounts(bool _showCardCounts) +{ + if (_showCardCounts == getVisualDeckEditorShowCardCounts()) { + return; + } + setValue(_showCardCounts, "visualDeckEditorShowCardCounts"); + emit visualDeckEditorShowCardCountsChanged(_showCardCounts); +} + void CardsDisplaySettings::setEDHRecCardSize(int _edhrecCardSize) { setValue(_edhrecCardSize, "edhrec", "cards", "cardSize"); @@ -214,3 +242,25 @@ void CardsDisplaySettings::setSampleHandSize(int _sampleHandSize) setValue(_sampleHandSize, "sampleHandSize", "cards", "cardSize"); emit sampleHandSizeChanged(_sampleHandSize); } + +void CardsDisplaySettings::setCardLang(const QString &_cardLang) +{ + if (_cardLang == getCardLang()) { + return; + } + setValue(_cardLang, "cardLang"); + // Flush to disk immediately: the Oracle tool is a separate process that + // reads this value to decide which foreignData to import, so it must not + // observe a stale (pre-change) value. + sync(); + emit cardLangChanged(_cardLang); +} + +void CardsDisplaySettings::setCardSearchLanguage(int _cardSearchLanguage) +{ + if (_cardSearchLanguage == getCardSearchLanguage()) { + return; + } + setValue(_cardSearchLanguage, "cardSearchLanguage"); + emit cardSearchLanguageChanged(_cardSearchLanguage); +} diff --git a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h index 15a3e3ff4..961550d3c 100644 --- a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h @@ -3,7 +3,11 @@ #include "settings_manager.h" +#include #include +#include + +class QObject; class CardsDisplaySettings : public SettingsManager, public ICardsDisplaySettingsProvider { @@ -17,9 +21,9 @@ public: [[nodiscard]] bool getBumpSetsWithCardsInDeckToTop() const override; [[nodiscard]] int getPrintingSelectorSortOrder() const override; [[nodiscard]] int getPrintingSelectorCardSize() const override; - [[nodiscard]] bool getIncludeRebalancedCards() const override; [[nodiscard]] bool getPrintingSelectorNavigationButtonsVisible() const override; [[nodiscard]] bool getTapAnimation() const override; + [[nodiscard]] bool getArrowDrawAnimation() const override; [[nodiscard]] bool getAutoRotateSidewaysLayoutCards() const override; [[nodiscard]] bool getScaleCards() const override; [[nodiscard]] int getStackCardOverlapPercent() const override; @@ -27,9 +31,12 @@ public: [[nodiscard]] int getVisualDeckStorageCardSize() const override; [[nodiscard]] int getVisualDatabaseDisplayCardSize() const override; [[nodiscard]] int getVisualDeckEditorCardSize() const override; + [[nodiscard]] bool getVisualDeckEditorShowCardCounts() const override; [[nodiscard]] int getEDHRecCardSize() const override; [[nodiscard]] int getArchidektPreviewSize() const override; [[nodiscard]] int getSampleHandSize() const override; + [[nodiscard]] QString getCardLang() const override; + [[nodiscard]] int getCardSearchLanguage() const override; void setDisplayCardNames(bool _displayCardNames); void setRoundCardCorners(bool _roundCardCorners); @@ -37,9 +44,9 @@ public: void setBumpSetsWithCardsInDeckToTop(bool _bumpSetsWithCardsInDeckToTop); void setPrintingSelectorSortOrder(int _printingSelectorSortOrder); void setPrintingSelectorCardSize(int _printingSelectorCardSize); - void setIncludeRebalancedCards(bool _includeRebalancedCards); void setPrintingSelectorNavigationButtonsVisible(bool _navigationButtonsVisible); void setTapAnimation(bool _tapAnimation); + void setArrowDrawAnimation(bool _arrowDrawAnimation); void setAutoRotateSidewaysLayoutCards(bool _autoRotateSidewaysLayoutCards); void setCardScaling(bool _scaleCards); void setStackCardOverlapPercent(int _verticalCardOverlapPercent); @@ -47,9 +54,12 @@ public: void setVisualDeckStorageCardSize(int _cardSize); void setVisualDatabaseDisplayCardSize(int _cardSize); void setVisualDeckEditorCardSize(int _cardSize); + void setVisualDeckEditorShowCardCounts(bool _showCardCounts); void setEDHRecCardSize(int _edhrecCardSize); void setArchidektPreviewCardSize(int _archidektPreviewCardSize); void setSampleHandSize(int _sampleHandSize); + void setCardLang(const QString &_cardLang); + void setCardSearchLanguage(int _cardSearchLanguage); signals: void displayCardNamesChanged(); @@ -58,14 +68,16 @@ signals: void bumpSetsWithCardsInDeckToTopChanged(); void printingSelectorSortOrderChanged(); void printingSelectorCardSizeChanged(); - void includeRebalancedCardsChanged(bool _includeRebalancedCards); void printingSelectorNavigationButtonsVisibleChanged(); void visualDeckStorageCardSizeChanged(); void visualDatabaseDisplayCardSizeChanged(); void visualDeckEditorCardSizeChanged(); + void visualDeckEditorShowCardCountsChanged(bool showCardCounts); void edhRecCardSizeChanged(); void archidektPreviewSizeChanged(); void sampleHandSizeChanged(int amount); + void cardLangChanged(const QString &lang); + void cardSearchLanguageChanged(int cardSearchLanguage); public: explicit CardsDisplaySettings(const QString &settingPath, QObject *parent = nullptr); diff --git a/libcockatrice_settings/libcockatrice/settings/chat_settings.cpp b/libcockatrice_settings/libcockatrice/settings/chat_settings.cpp index 5e1102473..d9ae0ddac 100644 --- a/libcockatrice_settings/libcockatrice/settings/chat_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/chat_settings.cpp @@ -1,5 +1,11 @@ #include "chat_settings.h" +#include "libcockatrice/settings/settings_manager.h" + +#include + +class QObject; + ChatSettings::ChatSettings(const QString &settingPath, QObject *parent) : SettingsManager(settingPath + "chat.ini", "chat", QString(), parent) { @@ -65,6 +71,16 @@ bool ChatSettings::getRoomHistory() const return getValue("roomHistory", QString(), QString(), true).toBool(); } +bool ChatSettings::getIgnoreAllPrivateMessages() const +{ + return getValue("ignoreAllPrivateMessages", QString(), QString(), false).toBool(); +} + +bool ChatSettings::getUseGameTime() const +{ + return getValue("useGameTime", QString(), QString(), false).toBool(); +} + QString ChatSettings::getHighlightWords() const { return getValue("highlightWords").toString(); @@ -131,6 +147,16 @@ void ChatSettings::setRoomHistory(bool _roomHistory) setValue(_roomHistory, "roomHistory"); } +void ChatSettings::setIgnoreAllPrivateMessages(bool _ignoreAllPrivateMessages) +{ + setValue(_ignoreAllPrivateMessages, "ignoreAllPrivateMessages"); +} + +void ChatSettings::setUseGameTime(bool _useGameTime) +{ + setValue(_useGameTime, "useGameTime"); +} + void ChatSettings::setHighlightWords(const QString &_highlightWords) { setValue(_highlightWords, "highlightWords"); diff --git a/libcockatrice_settings/libcockatrice/settings/chat_settings.h b/libcockatrice_settings/libcockatrice/settings/chat_settings.h index 7cf4be3f6..e00da4527 100644 --- a/libcockatrice_settings/libcockatrice/settings/chat_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/chat_settings.h @@ -3,7 +3,11 @@ #include "settings_manager.h" +#include #include +#include + +class QObject; class ChatSettings : public SettingsManager, public IChatSettingsProvider { @@ -23,6 +27,8 @@ public: [[nodiscard]] bool getShowMessagePopup() const override; [[nodiscard]] bool getShowMentionPopup() const override; [[nodiscard]] bool getRoomHistory() const override; + [[nodiscard]] bool getIgnoreAllPrivateMessages() const override; + [[nodiscard]] bool getUseGameTime() const override; [[nodiscard]] QString getHighlightWords() const override; void setChatMention(bool _chatMention); @@ -37,6 +43,8 @@ public: void setShowMessagePopups(bool _showMessagePopups); void setShowMentionPopups(bool _showMentionPopups); void setRoomHistory(bool _roomHistory); + void setIgnoreAllPrivateMessages(bool _ignoreAllPrivateMessages); + void setUseGameTime(bool _useGameTime); void setHighlightWords(const QString &_highlightWords); signals: diff --git a/libcockatrice_settings/libcockatrice/settings/commander_bracket_settings.cpp b/libcockatrice_settings/libcockatrice/settings/commander_bracket_settings.cpp index 0c5f5521e..eb692fe81 100644 --- a/libcockatrice_settings/libcockatrice/settings/commander_bracket_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/commander_bracket_settings.cpp @@ -1,6 +1,16 @@ #include "commander_bracket_settings.h" +#include "libcockatrice/settings/settings_manager.h" + +#include +#include #include +#include +#include +#include +#include + +class QObject; QVariantList CommanderBracketSettings::defaultDefinitions() { diff --git a/libcockatrice_settings/libcockatrice/settings/commander_bracket_settings.h b/libcockatrice_settings/libcockatrice/settings/commander_bracket_settings.h index 5a29f5dc7..ad012fc30 100644 --- a/libcockatrice_settings/libcockatrice/settings/commander_bracket_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/commander_bracket_settings.h @@ -4,9 +4,11 @@ #include "settings_manager.h" #include -#include #include #include +#include + +class QObject; struct CommanderBracketDefinition { diff --git a/libcockatrice_settings/libcockatrice/settings/debug_settings.cpp b/libcockatrice_settings/libcockatrice/settings/debug_settings.cpp index 5bf6eca30..c36e7187e 100644 --- a/libcockatrice_settings/libcockatrice/settings/debug_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/debug_settings.cpp @@ -1,6 +1,11 @@ #include "debug_settings.h" -#include +#include "libcockatrice/settings/settings_manager.h" + +#include +#include + +class QObject; DebugSettings::DebugSettings(const QString &settingPath, QObject *parent) : SettingsManager(settingPath + "debug.ini", "debug", QString(), parent) diff --git a/libcockatrice_settings/libcockatrice/settings/debug_settings.h b/libcockatrice_settings/libcockatrice/settings/debug_settings.h index acb5cf313..9521a7738 100644 --- a/libcockatrice_settings/libcockatrice/settings/debug_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/debug_settings.h @@ -8,6 +8,11 @@ #define DEBUG_SETTINGS_H #include "settings_manager.h" +#include +#include + +class QObject; + class DebugSettings : public SettingsManager { Q_OBJECT diff --git a/libcockatrice_settings/libcockatrice/settings/deck_editor_settings.cpp b/libcockatrice_settings/libcockatrice/settings/deck_editor_settings.cpp index 65296a450..ee570ed67 100644 --- a/libcockatrice_settings/libcockatrice/settings/deck_editor_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/deck_editor_settings.cpp @@ -1,5 +1,11 @@ #include "deck_editor_settings.h" +#include "libcockatrice/settings/settings_manager.h" + +#include + +class QObject; + DeckEditorSettings::DeckEditorSettings(const QString &settingPath, QObject *parent) : SettingsManager(settingPath + "deck_editor.ini", "deckeditor", QString(), parent) { @@ -25,6 +31,11 @@ int DeckEditorSettings::getDefaultDeckEditorType() const return getValue("defaultDeckEditorType", QString(), QString(), 1).toInt(); } +int DeckEditorSettings::getVdeStartupTab() const +{ + return getValue("vdeStartupTab", QString(), QString(), VdeStartupTabContext).toInt(); +} + void DeckEditorSettings::setOpenDeckInNewTab(bool _openDeckInNewTab) { setValue(_openDeckInNewTab, "openDeckInNewTab"); @@ -47,6 +58,12 @@ void DeckEditorSettings::setDefaultDeckEditorType(int _defaultDeckEditorType) setValue(_defaultDeckEditorType, "defaultDeckEditorType"); } +void DeckEditorSettings::setVdeStartupTab(int _vdeStartupTab) +{ + setValue(_vdeStartupTab, "vdeStartupTab"); + emit vdeStartupTabChanged(_vdeStartupTab); +} + int DeckEditorSettings::getCommanderSpellbookIntegrationEnabled() const { return getValue("commanderspellbookintegrationenabled", QString(), QString(), diff --git a/libcockatrice_settings/libcockatrice/settings/deck_editor_settings.h b/libcockatrice_settings/libcockatrice/settings/deck_editor_settings.h index 70f91be9b..aa024910d 100644 --- a/libcockatrice_settings/libcockatrice/settings/deck_editor_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/deck_editor_settings.h @@ -3,7 +3,11 @@ #include "settings_manager.h" +#include #include +#include + +class QObject; enum commanderSpellbookIntegrationEnabledIndex { @@ -13,6 +17,13 @@ enum commanderSpellbookIntegrationEnabledIndex commanderSpellbookIntegrationEnabledIndexUnprompted, }; +enum VdeStartupTab +{ + VdeStartupTabContext, ///< Match the opened deck: new decks show the database display, existing decks the deck view + VdeStartupTabDeckDisplay, ///< Always open the Visual Deck View + VdeStartupTabDatabaseDisplay, ///< Always open the Visual Database Display +}; + class DeckEditorSettings : public SettingsManager, public IDeckEditorSettingsProvider { Q_OBJECT @@ -23,6 +34,7 @@ public: [[nodiscard]] bool getBannerCardComboBoxVisible() const override; [[nodiscard]] bool getTagsWidgetVisible() const override; [[nodiscard]] int getDefaultDeckEditorType() const override; + [[nodiscard]] int getVdeStartupTab() const; [[nodiscard]] int getCommanderSpellbookIntegrationEnabled() const; [[nodiscard]] bool getCommanderSpellbookIntegrationUseOfficialBracketNames() const; @@ -30,6 +42,7 @@ public: void setBannerCardComboBoxVisible(bool _bannerCardComboBoxVisible); void setTagsWidgetVisible(bool _tagsWidgetVisible); void setDefaultDeckEditorType(int _defaultDeckEditorType); + void setVdeStartupTab(int _vdeStartupTab); void setCommanderSpellbookIntegrationEnabled(int _commanderSpellbookIntegrationEnabled); void setCommanderSpellbookIntegrationUseOfficialBracketNames(bool _useOfficialBracketNames); @@ -38,6 +51,7 @@ signals: void tagsWidgetVisibleChanged(bool visible); void commanderSpellbookIntegrationEnabledChanged(int enabled); void commanderSpellbookIntegrationUseOfficialBracketNamesChanged(bool useOfficialBracketNames); + void vdeStartupTabChanged(int vdeStartupTab); public: explicit DeckEditorSettings(const QString &settingPath, QObject *parent = nullptr); diff --git a/libcockatrice_settings/libcockatrice/settings/download_settings.cpp b/libcockatrice_settings/libcockatrice/settings/download_settings.cpp index cfa1c054e..84a6a7509 100644 --- a/libcockatrice_settings/libcockatrice/settings/download_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/download_settings.cpp @@ -2,13 +2,40 @@ #include "settings_manager.h" +#include +#include +#include +#include +#include + +class QObject; + const QStringList DownloadSettings::DEFAULT_DOWNLOAD_URLS = { "https://cards.scryfall.io/large/!prop:side!/!set:uuid_substr_0_1!/!set:uuid_substr_1_1!/!set:uuid!.jpg", - "https://api.scryfall.com/cards/!set:uuid!?format=image&face=!prop:side!", - "https://api.scryfall.com/cards/multiverse/!set:muid!?format=image", + "https://api.scryfall.com/cards/!set:uuid!?format=image&face=!prop:side!&lang=!sflang!", + "https://api.scryfall.com/cards/multiverse/!set:muid!?format=image&lang=!sflang!", "https://gatherer.wizards.com/Handlers/Image.ashx?multiverseid=!set:muid!&type=card", "https://gatherer.wizards.com/Handlers/Image.ashx?name=!name!&type=card"}; +const QString DownloadSettings::SCRYFALL_NAMED_LOCALIZED_URL = + "https://api.scryfall.com/cards/named?fuzzy=!localizedName!&lang=!sflang!&format=image&face=!prop:side!"; + +// Developer-set ceilings for the per-host request allowance. Users may lower a host's +// allowance via the download settings, but can never raise it above these values. Hosts +// not listed default to DEFAULT_HOST_REQUEST_LIMIT. A cap of UNLIMITED_HOST_QUOTA marks a +// host that is never throttled per host and skips the dispatch pacing (429 backoff still applies). +const QHash DownloadSettings::DEVELOPER_HOST_CAPS = { + // The Scryfall API enforces 10 requests/second; stay one under so a burst can't trip 429s. + {"api.scryfall.com", 9}, + // The Scryfall image CDN has no documented per-client rate limit. + {"cards.scryfall.io", UNLIMITED_HOST_QUOTA}, +}; + +const QHash &DownloadSettings::getDeveloperHostCaps() +{ + return DEVELOPER_HOST_CAPS; +} + DownloadSettings::DownloadSettings(const QString &settingPath, QObject *parent = nullptr) : SettingsManager(settingPath + "downloads.ini", "downloads", QString(), parent) { @@ -29,6 +56,18 @@ void DownloadSettings::resetToDefaultURLs() setValue(QVariant::fromValue(DEFAULT_DOWNLOAD_URLS), "urls"); } +bool DownloadSettings::addLocalizedScryfallUrl() +{ + const QStringList urls = getAllURLs(); + if (urls.contains(SCRYFALL_NAMED_LOCALIZED_URL)) { + return false; + } + QStringList updated = urls; + updated.prepend(SCRYFALL_NAMED_LOCALIZED_URL); + setDownloadUrls(updated); + return true; +} + bool DownloadSettings::getPicDownload() const { return getValue("pictureDownload", QString(), QString(), true).toBool(); @@ -50,3 +89,57 @@ void DownloadSettings::setDownloadSpoilerStatus(bool _spoilerStatus) setValue(_spoilerStatus, "downloadSpoilers"); emit downloadSpoilerStatusChanged(); } + +QHash DownloadSettings::getHostRequestLimits() const +{ + auto settings = getSettings(); + if (!defaultGroup.isEmpty()) { + settings.beginGroup(defaultGroup); + } + settings.beginGroup("hostRequestLimits"); + + QHash hostRequestLimits; + const QStringList hosts = settings.childKeys(); + for (const QString &host : hosts) { + hostRequestLimits.insert(host, settings.value(host).toInt()); + } + + settings.endGroup(); + if (!defaultGroup.isEmpty()) { + settings.endGroup(); + } + return hostRequestLimits; +} + +void DownloadSettings::setHostRequestLimits(const QHash &hostRequestLimits) +{ + auto settings = getSettings(); + if (!defaultGroup.isEmpty()) { + settings.beginGroup(defaultGroup); + } + + // Drop the legacy single-key form (an opaque @Variant blob) written by earlier builds so each + // host is stored as a plain, hand-editable key in its own subgroup. + settings.remove("hostRequestLimits"); + settings.beginGroup("hostRequestLimits"); + settings.remove(QString()); + for (auto it = hostRequestLimits.cbegin(); it != hostRequestLimits.cend(); ++it) { + settings.setValue(it.key(), it.value()); + } + settings.endGroup(); + + if (!defaultGroup.isEmpty()) { + settings.endGroup(); + } + settings.sync(); + emit hostRequestLimitsChanged(); +} + +int DownloadSettings::clampHostRequestLimit(const QString &host, int requested) const +{ + const int devCap = DEVELOPER_HOST_CAPS.value(host, DEFAULT_HOST_REQUEST_LIMIT); + if (devCap == UNLIMITED_HOST_QUOTA) { + return qMax(MIN_HOST_REQUEST_LIMIT, requested); + } + return qBound(MIN_HOST_REQUEST_LIMIT, requested, devCap); +} diff --git a/libcockatrice_settings/libcockatrice/settings/download_settings.h b/libcockatrice_settings/libcockatrice/settings/download_settings.h index a3a6f4ca9..f7cf482e1 100644 --- a/libcockatrice_settings/libcockatrice/settings/download_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/download_settings.h @@ -9,27 +9,77 @@ #include "settings_manager.h" +#include +#include +#include +#include + +class QObject; + class DownloadSettings : public SettingsManager { Q_OBJECT friend class SettingsCache; static const QStringList DEFAULT_DOWNLOAD_URLS; + static const QString SCRYFALL_NAMED_LOCALIZED_URL; + static const QHash DEVELOPER_HOST_CAPS; public: + /** @brief Per-host request allowance (requests/second) when no developer cap applies. */ + static constexpr int DEFAULT_HOST_REQUEST_LIMIT = 10; + /** @brief Floor for any per-host request allowance. */ + static constexpr int MIN_HOST_REQUEST_LIMIT = 1; + /** + * @brief Upper bound offered to the user when lowering an unlocked host's allowance. + * + * Unlocked hosts have no developer cap, so `clampHostRequestLimit` puts no upper bound on + * them; this only bounds what the settings dialog offers, and matches the widest paced + * allowance a user is documented to be able to hand-edit in `downloads.ini`. Choosing 0 + * below this restores the "unlimited" fast path. + */ + static constexpr int UNLOCKED_HOST_LIMIT_MAX = 50; + /** @brief Developer cap marking a host as never throttled per host or by the dispatch pacing. */ + static constexpr int UNLIMITED_HOST_QUOTA = -1; + + /** + * @brief Developer-set per-host allowance ceilings (requests/second), keyed by host. + * + * Hosts not present default to DEFAULT_HOST_REQUEST_LIMIT. An entry of + * UNLIMITED_HOST_QUOTA marks a host that users may still lower, but that is never + * throttled per host by default. Users can never raise a host's allowance above its + * developer cap. + */ + static const QHash &getDeveloperHostCaps(); + explicit DownloadSettings(const QString &, QObject *); QStringList getAllURLs() const; void setDownloadUrls(const QStringList &downloadURLs); void resetToDefaultURLs(); + [[nodiscard]] bool addLocalizedScryfallUrl(); [[nodiscard]] bool getPicDownload() const; void setPicDownload(bool _picDownload); [[nodiscard]] bool getDownloadSpoilersStatus() const; void setDownloadSpoilerStatus(bool _spoilerStatus); + /** @brief User-set per-host request allowances (requests/second). Missing hosts use the developer default. */ + QHash getHostRequestLimits() const; + void setHostRequestLimits(const QHash &hostRequestLimits); + + /** + * @brief Clamps the user's requested allowance for a host against its developer cap. + * @param host The host to clamp for + * @param requested The user-requested allowance in requests/second + * @return The effective allowance. Users may lower a host's allowance but never raise it + * above the developer cap; hosts with UNLIMITED_HOST_QUOTA have no upper bound. + */ + [[nodiscard]] int clampHostRequestLimit(const QString &host, int requested) const; + signals: void picDownloadChanged(); void downloadSpoilerStatusChanged(); + void hostRequestLimitsChanged(); }; #endif // COCKATRICE_DOWNLOADSETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/game_filters_settings.cpp b/libcockatrice_settings/libcockatrice/settings/game_filters_settings.cpp index ad972b433..79d1bc9e2 100644 --- a/libcockatrice_settings/libcockatrice/settings/game_filters_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/game_filters_settings.cpp @@ -1,7 +1,15 @@ #include "game_filters_settings.h" +#include "libcockatrice/settings/settings_manager.h" + +#include #include +#include +#include #include +#include + +class QObject; GameFiltersSettings::GameFiltersSettings(const QString &settingPath, QObject *parent) : SettingsManager(settingPath + "gamefilters.ini", "filter_games", QString(), parent) @@ -104,14 +112,14 @@ QString GameFiltersSettings::getGameNameFilter() const return getValue("gameNameFilter").toString(); } -void GameFiltersSettings::setCreatorNameFilters(QStringList creatorName) +void GameFiltersSettings::setHostNameFilters(QStringList hostName) { - setValue(creatorName, "creatorNameFilter"); + setValue(hostName, "hostNameFilter"); } -QStringList GameFiltersSettings::getCreatorNameFilters() const +QStringList GameFiltersSettings::getHostNameFilters() const { - return getValue("creatorNameFilter").toStringList(); + return getValue("hostNameFilter").toStringList(); } void GameFiltersSettings::setMinPlayers(int min) diff --git a/libcockatrice_settings/libcockatrice/settings/game_filters_settings.h b/libcockatrice_settings/libcockatrice/settings/game_filters_settings.h index 24f582007..ec5e3fdc2 100644 --- a/libcockatrice_settings/libcockatrice/settings/game_filters_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/game_filters_settings.h @@ -10,6 +10,13 @@ #include "settings_manager.h" +#include +#include +#include +#include + +class QObject; + class GameFiltersSettings : public SettingsManager { Q_OBJECT @@ -24,7 +31,7 @@ public: bool isHideNotBuddyCreatedGames() const; bool isHideOpenDecklistGames() const; QString getGameNameFilter() const; - QStringList getCreatorNameFilters() const; + QStringList getHostNameFilters() const; int getMinPlayers() const; int getMaxPlayers() const; QTime getMaxGameAge() const; @@ -42,7 +49,7 @@ public: void setHidePasswordProtectedGames(bool hide); void setHideNotBuddyCreatedGames(bool hide); void setGameNameFilter(QString gameName); - void setCreatorNameFilters(QStringList creatorName); + void setHostNameFilters(QStringList hostName); void setMinPlayers(int min); void setMaxPlayers(int max); void setMaxGameAge(const QTime &maxGameAge); diff --git a/libcockatrice_settings/libcockatrice/settings/game_settings.cpp b/libcockatrice_settings/libcockatrice/settings/game_settings.cpp index dfd67c98a..c8c6463e3 100644 --- a/libcockatrice_settings/libcockatrice/settings/game_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/game_settings.cpp @@ -1,5 +1,11 @@ #include "game_settings.h" +#include "libcockatrice/settings/settings_manager.h" + +#include + +class QObject; + GameSettings::GameSettings(const QString &settingPath, QObject *parent) : SettingsManager(settingPath + "game.ini", QString(), QString(), parent) { diff --git a/libcockatrice_settings/libcockatrice/settings/game_settings.h b/libcockatrice_settings/libcockatrice/settings/game_settings.h index e0a484bac..3e64234fc 100644 --- a/libcockatrice_settings/libcockatrice/settings/game_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/game_settings.h @@ -3,7 +3,11 @@ #include "settings_manager.h" +#include #include +#include + +class QObject; class GameSettings : public SettingsManager, public IGameSettingsProvider { diff --git a/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp b/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp index 7cfd2579b..fe934fd8f 100644 --- a/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp @@ -1,7 +1,47 @@ #include "interface_settings.h" +#include "libcockatrice/settings/settings_manager.h" +#include "libcockatrice/utility/card_ref.h" +#include "libcockatrice/utility/playmat_params.h" + +#include #include #include +#include +#include +#include + +class QObject; + +namespace +{ +const QChar PLAYMAT_FIELD_SEP = QChar(0x1F); ///< Separator between PlaymatInfo fields. + +QString encodePlaymatInfo(const PlaymatInfo &res) +{ + return res.card.name + PLAYMAT_FIELD_SEP + res.card.providerId + PLAYMAT_FIELD_SEP + + QString::number(res.params.marginPctL, 'f', 4) + PLAYMAT_FIELD_SEP + + QString::number(res.params.marginPctR, 'f', 4) + PLAYMAT_FIELD_SEP + + QString::number(res.params.verticalOffset, 'f', 4) + PLAYMAT_FIELD_SEP + + QString::number(res.params.zoom, 'f', 4); +} + +PlaymatInfo decodePlaymatInfo(const QString &encoded) +{ + const QStringList fields = encoded.split(PLAYMAT_FIELD_SEP); + if (fields.size() != 6) { + return {}; + } + PlaymatInfo res; + res.card.name = fields.at(0); + res.card.providerId = fields.at(1); + res.params.marginPctL = fields.at(2).toDouble(); + res.params.marginPctR = fields.at(3).toDouble(); + res.params.verticalOffset = fields.at(4).toDouble(); + res.params.zoom = fields.at(5).toDouble(); + return res; +} +} // namespace InterfaceSettings::InterfaceSettings(const QString &settingPath, QObject *parent) : SettingsManager(settingPath + "interface.ini", "interface", QString(), parent) @@ -10,7 +50,7 @@ InterfaceSettings::InterfaceSettings(const QString &settingPath, QObject *parent bool InterfaceSettings::getUseTearOffMenus() const { - return getValue("useTearOffMenus", QString(), QString(), true).toBool(); + return getValue("useTearOffMenus", QString(), QString(), false).toBool(); } int InterfaceSettings::getCardViewInitialRowsMax() const @@ -123,6 +163,11 @@ qreal InterfaceSettings::getFastForwardSpeed() const return getValue("fastForwardSpeed", "replay", QString(), 10).toReal(); } +bool InterfaceSettings::getSkipEmptySections() const +{ + return getValue("skipEmptySections", "replay", QString(), false).toBool(); +} + bool InterfaceSettings::getLeftJustified() const { return getValue("leftJustified", QString(), QString(), false).toBool(); @@ -171,6 +216,51 @@ bool InterfaceSettings::getTutorialCompleted() const return getValue("tutorialCompleted", QString(), QString(), false).toBool(); } +int InterfaceSettings::getPlaymatVisibility() const +{ + return qBound(0, getValue("playmatvisibility", QString(), QString(), 2).toInt(), 2); +} + +QList InterfaceSettings::getPlaymatFallbackList() const +{ + const QStringList entries = getValue("playmatFallbackList", QString(), QString(), QStringList()).toStringList(); + QList result; + result.reserve(entries.size()); + for (const QString &entry : entries) { + const PlaymatInfo res = decodePlaymatInfo(entry); + if (!res.card.isEmpty()) { + result.append(res); + } + } + return result; +} + +int InterfaceSettings::getPlaymatMode() const +{ + return qBound(0, getValue("playmatMode", QString(), QString(), 1).toInt(), 2); +} + +int InterfaceSettings::getPlaymatFallbackBehavior() const +{ + return qBound(0, getValue("playmatFallbackBehavior", QString(), QString(), 0).toInt(), 2); +} + +bool InterfaceSettings::getLifeCounterAnimationsEnabled() const +{ + return getValue("lifeCounterAnimationsEnabled", QString(), QString(), true).toBool(); +} + +bool InterfaceSettings::getBattlefieldFlashEnabled() const +{ + return getValue("battlefieldFlashEnabled", QString(), QString(), true).toBool(); +} + +QStringList InterfaceSettings::getUserListExpandedSections() const +{ + return getValue("userListExpandedSections", QString(), QString(), QStringList({"buddy", "online", "ignore"})) + .toStringList(); +} + void InterfaceSettings::setUseTearOffMenus(bool _useTearOffMenus) { setValue(_useTearOffMenus, "useTearOffMenus"); @@ -295,6 +385,11 @@ void InterfaceSettings::setFastForwardSpeed(qreal _value) setValue(_value, "fastForwardSpeed", "replay"); } +void InterfaceSettings::setSkipEmptySections(bool _value) +{ + setValue(_value, "skipEmptySections", "replay"); +} + void InterfaceSettings::setLeftJustified(bool _leftJustified) { setValue(_leftJustified, "leftJustified"); @@ -337,3 +432,60 @@ void InterfaceSettings::setTutorialCompleted(bool value) { setValue(value, "tutorialCompleted"); } + +void InterfaceSettings::setPlaymatVisibility(int _visibility) +{ + if (getPlaymatVisibility() == _visibility) { + return; + } + setValue(_visibility, "playmatvisibility"); + emit playmatVisibilityChanged(_visibility); +} + +void InterfaceSettings::setPlaymatFallbackList(const QList &_fallbackList) +{ + QStringList entries; + entries.reserve(_fallbackList.size()); + for (const PlaymatInfo &res : _fallbackList) { + entries.append(encodePlaymatInfo(res)); + } + setValue(entries, "playmatFallbackList"); + emit playmatSettingsChanged(); +} + +void InterfaceSettings::setPlaymatMode(int _mode) +{ + const int mode = qBound(0, _mode, 2); + if (getPlaymatMode() == mode) { + return; + } + setValue(mode, "playmatMode"); + emit playmatSettingsChanged(); +} + +void InterfaceSettings::setPlaymatFallbackBehavior(int _behavior) +{ + const int behavior = qBound(0, _behavior, 2); + if (getPlaymatFallbackBehavior() == behavior) { + return; + } + setValue(behavior, "playmatFallbackBehavior"); + emit playmatSettingsChanged(); +} + +void InterfaceSettings::setLifeCounterAnimationsEnabled(bool _lifeCounterAnimationsEnabled) +{ + setValue(_lifeCounterAnimationsEnabled, "lifeCounterAnimationsEnabled"); + emit lifeCounterAnimationsEnabledChanged(_lifeCounterAnimationsEnabled); +} + +void InterfaceSettings::setBattlefieldFlashEnabled(bool _battlefieldFlashEnabled) +{ + setValue(_battlefieldFlashEnabled, "battlefieldFlashEnabled"); + emit battlefieldFlashEnabledChanged(_battlefieldFlashEnabled); +} + +void InterfaceSettings::setUserListExpandedSections(const QStringList §ions) +{ + setValue(sections, "userListExpandedSections"); +} diff --git a/libcockatrice_settings/libcockatrice/settings/interface_settings.h b/libcockatrice_settings/libcockatrice/settings/interface_settings.h index 9833bcbb4..ac1d108ee 100644 --- a/libcockatrice_settings/libcockatrice/settings/interface_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/interface_settings.h @@ -3,7 +3,15 @@ #include "settings_manager.h" +#include +#include +#include #include +#include + +class QObject; +struct PlaymatInfo; +template class QList; class InterfaceSettings : public SettingsManager, public IInterfaceSettingsProvider { @@ -34,6 +42,7 @@ public: [[nodiscard]] int getMinPlayersForMultiColumnLayout() const override; [[nodiscard]] int getRewindBufferingMs() const override; [[nodiscard]] qreal getFastForwardSpeed() const override; + [[nodiscard]] bool getSkipEmptySections() const override; [[nodiscard]] bool getLeftJustified() const override; [[nodiscard]] int getZoneViewGroupByIndex() const override; [[nodiscard]] int getZoneViewSortByIndex() const override; @@ -42,6 +51,13 @@ public: [[nodiscard]] bool getShowShortcuts() const override; [[nodiscard]] bool getShowGameSelectorFilterToolbar() const override; [[nodiscard]] bool getTutorialCompleted() const; + [[nodiscard]] int getPlaymatVisibility() const override; + [[nodiscard]] QList getPlaymatFallbackList() const override; + [[nodiscard]] int getPlaymatMode() const override; + [[nodiscard]] int getPlaymatFallbackBehavior() const override; + [[nodiscard]] bool getLifeCounterAnimationsEnabled() const override; + [[nodiscard]] bool getBattlefieldFlashEnabled() const override; + [[nodiscard]] QStringList getUserListExpandedSections() const override; void setUseTearOffMenus(bool _useTearOffMenus); void setCardViewInitialRowsMax(int _cardViewInitialRowsMax); @@ -66,6 +82,7 @@ public: void setMinPlayersForMultiColumnLayout(int _minPlayersForMultiColumnLayout); void setRewindBufferingMs(int _rewindBufferingMs); void setFastForwardSpeed(qreal _value); + void setSkipEmptySections(bool _value); void setLeftJustified(bool _leftJustified); void setZoneViewGroupByIndex(int _zoneViewGroupByIndex); void setZoneViewSortByIndex(int _zoneViewSortByIndex); @@ -74,6 +91,13 @@ public: void setShowShortcuts(bool _showShortcuts); void setShowGameSelectorFilterToolbar(bool _showGameSelectorFilterToolbar); void setTutorialCompleted(bool value); + void setPlaymatVisibility(int _visibility); + void setPlaymatFallbackList(const QList &_fallbackList); + void setPlaymatMode(int _mode); + void setPlaymatFallbackBehavior(int _behavior); + void setLifeCounterAnimationsEnabled(bool _lifeCounterAnimationsEnabled); + void setBattlefieldFlashEnabled(bool _battlefieldFlashEnabled); + void setUserListExpandedSections(const QStringList §ions); signals: void useTearOffMenusChanged(bool state); @@ -85,6 +109,10 @@ signals: void tallyTypeChanged(int type); void showStatusBarChanged(bool state); void showGameSelectorFilterToolbarChanged(bool state); + void playmatVisibilityChanged(int visibility); + void playmatSettingsChanged(); + void lifeCounterAnimationsEnabledChanged(bool state); + void battlefieldFlashEnabledChanged(bool state); public: explicit InterfaceSettings(const QString &settingPath, QObject *parent = nullptr); diff --git a/libcockatrice_settings/libcockatrice/settings/layouts_settings.cpp b/libcockatrice_settings/libcockatrice/settings/layouts_settings.cpp index e914dc2d8..aba620026 100644 --- a/libcockatrice_settings/libcockatrice/settings/layouts_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/layouts_settings.cpp @@ -1,5 +1,11 @@ #include "layouts_settings.h" +#include "libcockatrice/settings/settings_manager.h" + +#include + +class QObject; + const static QString STATE_PROP = "state"; const static QString GEOMETRY_PROP = "geometry"; const static QString SIZE_PROP = "widgetSize"; diff --git a/libcockatrice_settings/libcockatrice/settings/layouts_settings.h b/libcockatrice_settings/libcockatrice/settings/layouts_settings.h index d5f26b61b..91b0a2ea6 100644 --- a/libcockatrice_settings/libcockatrice/settings/layouts_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/layouts_settings.h @@ -9,7 +9,11 @@ #include "settings_manager.h" -#include +#include +#include +#include + +class QObject; class LayoutsSettings : public SettingsManager { diff --git a/libcockatrice_settings/libcockatrice/settings/message_settings.cpp b/libcockatrice_settings/libcockatrice/settings/message_settings.cpp index 50da39df6..ca77246ef 100644 --- a/libcockatrice_settings/libcockatrice/settings/message_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/message_settings.cpp @@ -1,5 +1,11 @@ #include "message_settings.h" +#include "libcockatrice/settings/settings_manager.h" + +#include + +class QObject; + MessageSettings::MessageSettings(const QString &settingPath, QObject *parent) : SettingsManager(settingPath + "messages.ini", "messages", QString(), parent) { diff --git a/libcockatrice_settings/libcockatrice/settings/message_settings.h b/libcockatrice_settings/libcockatrice/settings/message_settings.h index 8276aa39d..83513d562 100644 --- a/libcockatrice_settings/libcockatrice/settings/message_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/message_settings.h @@ -9,6 +9,11 @@ #include "settings_manager.h" +#include +#include + +class QObject; + class MessageSettings : public SettingsManager { Q_OBJECT diff --git a/libcockatrice_settings/libcockatrice/settings/network_settings.cpp b/libcockatrice_settings/libcockatrice/settings/network_settings.cpp index 2d34d47ad..6d0fa9d1c 100644 --- a/libcockatrice_settings/libcockatrice/settings/network_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/network_settings.cpp @@ -1,5 +1,11 @@ #include "network_settings.h" +#include "libcockatrice/settings/settings_manager.h" + +#include + +class QObject; + NetworkSettings::NetworkSettings(const QString &settingPath, QObject *parent) : SettingsManager(settingPath + "network.ini", "network", QString(), parent) { diff --git a/libcockatrice_settings/libcockatrice/settings/network_settings.h b/libcockatrice_settings/libcockatrice/settings/network_settings.h index 446bb51d3..4367f9cb3 100644 --- a/libcockatrice_settings/libcockatrice/settings/network_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/network_settings.h @@ -9,6 +9,11 @@ #include "settings_manager.h" +#include +#include + +class QObject; + class NetworkSettings : public SettingsManager { Q_OBJECT diff --git a/libcockatrice_settings/libcockatrice/settings/paths_settings.cpp b/libcockatrice_settings/libcockatrice/settings/paths_settings.cpp index 67032e38f..5f5725395 100644 --- a/libcockatrice_settings/libcockatrice/settings/paths_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/paths_settings.cpp @@ -1,7 +1,10 @@ #include "paths_settings.h" -#include -#include +#include "libcockatrice/settings/settings_manager.h" + +#include + +class QObject; PathsSettings::PathsSettings(const QString &settingPath, QObject *parent) : SettingsManager(settingPath + "paths.ini", "paths", QString(), parent) diff --git a/libcockatrice_settings/libcockatrice/settings/paths_settings.h b/libcockatrice_settings/libcockatrice/settings/paths_settings.h index ab1ea05c8..399337bbb 100644 --- a/libcockatrice_settings/libcockatrice/settings/paths_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/paths_settings.h @@ -3,7 +3,11 @@ #include "settings_manager.h" +#include #include +#include + +class QObject; class PathsSettings : public SettingsManager, public IPathsSettingsProvider { diff --git a/libcockatrice_settings/libcockatrice/settings/personal_settings.cpp b/libcockatrice_settings/libcockatrice/settings/personal_settings.cpp index d1cb74d09..682144485 100644 --- a/libcockatrice_settings/libcockatrice/settings/personal_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/personal_settings.cpp @@ -1,5 +1,12 @@ #include "personal_settings.h" +#include "libcockatrice/settings/settings_manager.h" + +#include +#include + +class QObject; + PersonalSettings::PersonalSettings(const QString &settingPath, QObject *parent) : SettingsManager(settingPath + "personal.ini", "personal", QString(), parent) { diff --git a/libcockatrice_settings/libcockatrice/settings/personal_settings.h b/libcockatrice_settings/libcockatrice/settings/personal_settings.h index 04eab2a8a..0819af41b 100644 --- a/libcockatrice_settings/libcockatrice/settings/personal_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/personal_settings.h @@ -3,9 +3,12 @@ #include "settings_manager.h" -#include -#include +#include #include +#include + +class QObject; +template class QList; class PersonalSettings : public SettingsManager, public IPersonalSettingsProvider { diff --git a/libcockatrice_settings/libcockatrice/settings/recents_settings.cpp b/libcockatrice_settings/libcockatrice/settings/recents_settings.cpp index 5f16af179..4f2c06c88 100644 --- a/libcockatrice_settings/libcockatrice/settings/recents_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/recents_settings.cpp @@ -1,5 +1,12 @@ #include "recents_settings.h" +#include "libcockatrice/settings/settings_manager.h" + +#include +#include + +class QObject; + #define MAX_RECENT_DECK_COUNT 10 RecentsSettings::RecentsSettings(const QString &settingPath, QObject *parent) diff --git a/libcockatrice_settings/libcockatrice/settings/recents_settings.h b/libcockatrice_settings/libcockatrice/settings/recents_settings.h index 01b2a37bc..dc5853908 100644 --- a/libcockatrice_settings/libcockatrice/settings/recents_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/recents_settings.h @@ -9,6 +9,12 @@ #include "settings_manager.h" +#include +#include +#include + +class QObject; + class RecentsSettings : public SettingsManager { Q_OBJECT diff --git a/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp b/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp index 811b0c842..c5c4b2592 100644 --- a/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp @@ -1,8 +1,17 @@ #include "servers_settings.h" +#include "libcockatrice/settings/settings_manager.h" + #include +#include +#include +#include +#include +#include #include +class QObject; + ServersSettings::ServersSettings(const QString &settingPath, QObject *parent) : SettingsManager(settingPath + "servers.ini", "server", QString(), parent) { @@ -171,7 +180,12 @@ void ServersSettings::addNewServer(const QString &saveName, bool savePassword, const QString &site) { - if (updateExistingServer(saveName, serv, port, username, password, savePassword, site)) { + // Match the exact host-plus-port server the caller is adding, so a link or + // public-server list entry cannot clobber the port (and credentials) of an + // unrelated entry that happens to share the same hostname. + const int existingIndex = findServerIndex(serv, port); + if (existingIndex >= 0) { + updateServerFields(existingIndex, saveName, username, password, savePassword, site); return; } @@ -271,22 +285,7 @@ bool ServersSettings::updateExistingServer(QString saveName, for (int i = 0; i <= size; ++i) { if (serv == getValue(QString("server%1").arg(i), "server", "server_details").toString()) { setValue(port, QString("port%1").arg(i), "server", "server_details"); - if (!username.isEmpty()) { - setValue(username, QString("username%1").arg(i), "server", "server_details"); - } - - if (savePassword && !password.isEmpty()) { - setValue(password, QString("password%1").arg(i), "server", "server_details"); - } else { - setValue(QString(), QString("password%1").arg(i), "server", "server_details"); - } - - if (!site.isEmpty()) { - setValue(site, QString("site%1").arg(i), "server", "server_details"); - } - - setValue(savePassword, QString("savePassword%1").arg(i), "server", "server_details"); - setValue(saveName, QString("saveName%1").arg(i), "server", "server_details"); + updateServerFields(i, saveName, username, password, savePassword, site); return true; } @@ -294,6 +293,31 @@ bool ServersSettings::updateExistingServer(QString saveName, return false; } +void ServersSettings::updateServerFields(int index, + const QString &saveName, + const QString &username, + const QString &password, + bool savePassword, + const QString &site) +{ + if (!username.isEmpty()) { + setValue(username, QString("username%1").arg(index), "server", "server_details"); + } + + if (savePassword && !password.isEmpty()) { + setValue(password, QString("password%1").arg(index), "server", "server_details"); + } else { + setValue(QString(), QString("password%1").arg(index), "server", "server_details"); + } + + if (!site.isEmpty()) { + setValue(site, QString("site%1").arg(index), "server", "server_details"); + } + + setValue(savePassword, QString("savePassword%1").arg(index), "server", "server_details"); + setValue(saveName, QString("saveName%1").arg(index), "server", "server_details"); +} + int ServersSettings::findServerIndex(const QString &host, const QString &port) const { int size = getValue("totalServers", "server", "server_details").toInt(); @@ -310,6 +334,21 @@ int ServersSettings::findServerIndex(const QString &host, const QString &port) c return -1; } +int ServersSettings::findHostIndex(const QString &host) const +{ + int size = getValue("totalServers", "server", "server_details").toInt(); + + for (int i = 0; i <= size; ++i) { + QString storedHost = getValue(QString("server%1").arg(i), "server", "server_details").toString(); + + if (storedHost.compare(host, Qt::CaseInsensitive) == 0) { + return i; + } + } + + return -1; +} + bool ServersSettings::hasUsername(const QString &host, const QString &port) const { int index = findServerIndex(host, port); diff --git a/libcockatrice_settings/libcockatrice/settings/servers_settings.h b/libcockatrice_settings/libcockatrice/settings/servers_settings.h index f9803a158..40648baa9 100644 --- a/libcockatrice_settings/libcockatrice/settings/servers_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/servers_settings.h @@ -10,7 +10,12 @@ #include "settings_manager.h" #include -#include +#include +#include +#include + +class QObject; + #define SERVERSETTINGS_DEFAULT_HOST "server.cockatrice.us" #define SERVERSETTINGS_DEFAULT_PORT "4748" @@ -61,7 +66,14 @@ public: QString password, bool savePassword, QString site = QString()); + void updateServerFields(int index, + const QString &saveName, + const QString &username, + const QString &password, + bool savePassword, + const QString &site); int findServerIndex(const QString &host, const QString &port) const; + int findHostIndex(const QString &host) const; bool hasUsername(const QString &host, const QString &port) const; bool hasCredentials(const QString &host, const QString &port) const; bool hasLoginData(const QString &host, const QString &port) const; diff --git a/libcockatrice_settings/libcockatrice/settings/settings_manager.h b/libcockatrice_settings/libcockatrice/settings/settings_manager.h index 3d3c95d3e..8d3110106 100644 --- a/libcockatrice_settings/libcockatrice/settings/settings_manager.h +++ b/libcockatrice_settings/libcockatrice/settings/settings_manager.h @@ -7,9 +7,12 @@ #ifndef SETTINGSMANAGER_H #define SETTINGSMANAGER_H +#include #include -#include +#include #include +#include +#include class SettingsManager : public QObject { diff --git a/libcockatrice_settings/libcockatrice/settings/settings_migration.cpp b/libcockatrice_settings/libcockatrice/settings/settings_migration.cpp index 37dc9a0a0..c3f299f40 100644 --- a/libcockatrice_settings/libcockatrice/settings/settings_migration.cpp +++ b/libcockatrice_settings/libcockatrice/settings/settings_migration.cpp @@ -1,12 +1,54 @@ #include "settings_migration.h" +#include +#include +#include +#include +#include #include +#include +#include +#include +#include +#include #include +#include #include +#include #include +#include +#include +#include +#include + +inline Q_LOGGING_CATEGORY(SettingsMigrationLog, "settings_migration"); static const QString MIGRATION_SENTINEL_KEY = QStringLiteral("migration/perfile_complete"); +/** + * True when global.ini holds keys that migrateSettingsFromGlobalIni() would still move, i.e. the + * exact condition that function uses to decide whether it has work to do. If a user runs an older + * Cockatrice build that writes to global.ini (non-sentinel keys) and then upgrades back, those new + * keys would otherwise be silently ignored, so they are re-migrated instead. + */ +static bool hasPendingGlobalIniSettings(const QString &settingsPath) +{ + if (!QFile::exists(settingsPath + "global.ini")) { + return false; + } + + QSettings globalIni(settingsPath + "global.ini", QSettings::IniFormat); + if (!globalIni.value(MIGRATION_SENTINEL_KEY, false).toBool()) { + return true; + } + + // The sentinel is set, so any key besides it was written after the migration, i.e. by an + // older build sharing this file. Those are exactly the keys to re-migrate. + QStringList otherKeys = globalIni.allKeys(); + otherKeys.removeAll(MIGRATION_SENTINEL_KEY); + return !otherKeys.isEmpty(); +} + static void migrateTabsSettings(const QString &settingsPath, QSettings &globalIni) { if (!globalIni.contains("tabs/visualDeckStorage") && !globalIni.contains("tabs/server") && @@ -217,7 +259,6 @@ static void migrateCardsDisplaySettings(const QString &settingsPath, QSettings & {"cards/roundcardcorners", "cards/roundCardCorners"}, {"cards/overrideallcardartwithpersonalpreference", "cards/overrideAllCardArtWithPersonalPreference"}, {"cards/bumpsetswithcardsindecktotop", "cards/bumpSetsWithCardsInDeckToTop"}, - {"cards/includerebalancedcards", "cards/includerebalancedcards"}, {"cards/tapanimation", "cards/tapAnimation"}, {"cards/autorotatesidewayslayoutcards", "cards/autoRotateSidewaysLayoutCards"}, {"cards/scaleCards", "cards/scaleCards"}, @@ -374,7 +415,11 @@ static void migrateAppearanceSettings(const QString &settingsPath, QSettings &gl QSettings appearanceIni(settingsPath + "appearance.ini", QSettings::IniFormat); for (auto it = appearanceKeyMap.constBegin(); it != appearanceKeyMap.constEnd(); ++it) { if (globalIni.contains(it.key())) { - appearanceIni.setValue(it.value(), globalIni.value(it.key())); + QVariant value = globalIni.value(it.key()); + if (it.key() == "theme/name" && value.toString() == "Default") { + value = "System"; + } + appearanceIni.setValue(it.value(), value); } } } @@ -525,9 +570,15 @@ static void migrateLegacySets(const QString &settingsPath) QSettings cardDbIni(settingsPath + "cardDatabase.ini", QSettings::IniFormat); for (const auto &shortName : groups) { legacySetting.beginGroup(shortName); - cardDbIni.setValue("sets/" + shortName + "/sortKey", legacySetting.value("sortkey")); - cardDbIni.setValue("sets/" + shortName + "/enabled", legacySetting.value("enabled")); - cardDbIni.setValue("sets/" + shortName + "/isKnown", legacySetting.value("isknown")); + if (legacySetting.contains("sortkey")) { + cardDbIni.setValue("sets/" + shortName + "/sortKey", legacySetting.value("sortkey")); + } + if (legacySetting.contains("enabled")) { + cardDbIni.setValue("sets/" + shortName + "/enabled", legacySetting.value("enabled")); + } + if (legacySetting.contains("isknown")) { + cardDbIni.setValue("sets/" + shortName + "/isKnown", legacySetting.value("isknown")); + } legacySetting.endGroup(); } legacySetting.endGroup(); @@ -654,21 +705,11 @@ bool SettingsMigration::migrateLegacySettings(const QString &settingsPath) bool SettingsMigration::migrateSettingsFromGlobalIni(const QString &settingsPath) { - if (!QFile::exists(settingsPath + "global.ini")) { + if (!hasPendingGlobalIniSettings(settingsPath)) { return false; } QSettings globalIni(settingsPath + "global.ini", QSettings::IniFormat); - if (globalIni.value(MIGRATION_SENTINEL_KEY, false).toBool()) { - // If a user runs an older Cockatrice build that writes to global.ini (non-sentinel keys) - // and then upgrades back, those new keys will be silently ignored. Re-migrate them. - globalIni.sync(); - auto allKeys = globalIni.allKeys(); - allKeys.removeAll(MIGRATION_SENTINEL_KEY); - if (allKeys.isEmpty()) { - return false; - } - } migrateTabsSettings(settingsPath, globalIni); migrateSoundSettings(settingsPath, globalIni); @@ -696,3 +737,78 @@ bool SettingsMigration::migrateSettingsFromGlobalIni(const QString &settingsPath return true; } + +bool SettingsMigration::isMigrationPending(const QString &settingsPath) +{ + if (hasPendingGlobalIniSettings(settingsPath)) { + return true; + } + + QSettings personalIni(settingsPath + "personal.ini", QSettings::IniFormat); + if (personalIni.value("migration/legacy_complete", false).toBool()) { + return false; + } + + // migrateLegacySettings() also runs on an empty profile, where it only writes its sentinel. + // Reporting that as pending would leave a backup folder behind on every fresh install, so + // only count it when there really are legacy keys left to move. + return !QSettings().allKeys().isEmpty(); +} + +QString SettingsMigration::backupSettingsDirectory(const QString &settingsPath) +{ + QDir settingsDir(QDir::cleanPath(settingsPath)); + if (!settingsDir.exists()) { + return QString(); + } + + const QDir::Filters fileFilters = QDir::Files | QDir::Hidden | QDir::System | QDir::NoDotAndDotDot; + + // A profile holding nothing but empty files has nothing to lose, and backing it up would only + // leave clutter next to the settings folder. + const QFileInfoList files = settingsDir.entryInfoList(fileFilters); + const bool hasContent = + std::any_of(files.cbegin(), files.cend(), [](const QFileInfo &file) { return file.size() > 0; }); + if (!hasContent) { + return QString(); + } + + // Keep the backup a sibling of the settings folder rather than a child: a folder that is never + // copied into its own backup, and one the "Open settings folder" action does not have to skip. + QDir backupParentDir(settingsDir.absolutePath()); + if (!backupParentDir.cdUp()) { + qCWarning(SettingsMigrationLog) << "[SettingsMigration] Could not resolve the parent of the settings folder:" + << settingsDir.absolutePath(); + return QString(); + } + + const QString backupName = + QStringLiteral("settings-backup-") + QDateTime::currentDateTime().toString("yyyyMMdd-HHmmss"); + QString backupPath = backupParentDir.absoluteFilePath(backupName); + // Two migrations inside the same second must not merge into a single folder. + for (int suffix = 1; QFileInfo::exists(backupPath); ++suffix) { + backupPath = backupParentDir.absoluteFilePath(backupName + '-' + QString::number(suffix)); + } + + if (!QDir().mkpath(backupPath)) { + qCWarning(SettingsMigrationLog) << "[SettingsMigration] Could not create the settings backup folder:" + << backupPath; + return QString(); + } + + QDir backupDir(backupPath); + QDirIterator it(settingsDir.absolutePath(), fileFilters, QDirIterator::Subdirectories); + while (it.hasNext()) { + it.next(); + + const QString target = backupDir.absoluteFilePath(settingsDir.relativeFilePath(it.filePath())); + QDir().mkpath(QFileInfo(target).absolutePath()); + if (!QFile::copy(it.filePath(), target)) { + // A partially copied backup still beats none: report it and let the migration run. + qCWarning(SettingsMigrationLog) << "[SettingsMigration] Could not back up settings file:" << it.filePath(); + } + } + + qCInfo(SettingsMigrationLog) << "[SettingsMigration] Settings backed up to" << backupPath; + return backupPath; +} diff --git a/libcockatrice_settings/libcockatrice/settings/settings_migration.h b/libcockatrice_settings/libcockatrice/settings/settings_migration.h index f596dba75..78f9c8a3f 100644 --- a/libcockatrice_settings/libcockatrice/settings/settings_migration.h +++ b/libcockatrice_settings/libcockatrice/settings/settings_migration.h @@ -8,6 +8,19 @@ class SettingsMigration public: static bool migrateSettingsFromGlobalIni(const QString &settingsPath); static bool migrateLegacySettings(const QString &settingsPath); + + /** + * Returns true when at least one of the two migrations would still do work, i.e. when a + * backup of the settings directory is worth taking before they run. + */ + static bool isMigrationPending(const QString &settingsPath); + + /** + * Copies the whole settings directory to a timestamped sibling folder, so a migration that + * overwrites per-domain files can be undone by hand. Returns the backup path, or an empty + * string when there was nothing to back up or the copy failed. Never aborts the migration. + */ + static QString backupSettingsDirectory(const QString &settingsPath); }; #endif // SETTINGS_MIGRATION_H diff --git a/libcockatrice_settings/libcockatrice/settings/sound_settings.cpp b/libcockatrice_settings/libcockatrice/settings/sound_settings.cpp index 82b724a07..5de7f9105 100644 --- a/libcockatrice_settings/libcockatrice/settings/sound_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/sound_settings.cpp @@ -1,5 +1,11 @@ #include "sound_settings.h" +#include "libcockatrice/settings/settings_manager.h" + +#include + +class QObject; + SoundSettings::SoundSettings(const QString &settingPath, QObject *parent) : SettingsManager(settingPath + "sound.ini", "sound", QString(), parent) { diff --git a/libcockatrice_settings/libcockatrice/settings/sound_settings.h b/libcockatrice_settings/libcockatrice/settings/sound_settings.h index ffaed2a7c..df6332cae 100644 --- a/libcockatrice_settings/libcockatrice/settings/sound_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/sound_settings.h @@ -3,7 +3,11 @@ #include "settings_manager.h" +#include #include +#include + +class QObject; class SoundSettings : public SettingsManager, public ISoundSettingsProvider { diff --git a/libcockatrice_settings/libcockatrice/settings/tabs_settings.cpp b/libcockatrice_settings/libcockatrice/settings/tabs_settings.cpp index 1838f667e..7ed6c4433 100644 --- a/libcockatrice_settings/libcockatrice/settings/tabs_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/tabs_settings.cpp @@ -1,10 +1,37 @@ #include "tabs_settings.h" +#include "libcockatrice/settings/settings_manager.h" + +#include +#include + +class QObject; + TabsSettings::TabsSettings(const QString &settingPath, QObject *parent) : SettingsManager(settingPath + "tabs.ini", "tabs", QString(), parent) { } +int TabsSettings::getStartupTabIndex() const +{ + return getValue("startupTab", QString(), QString(), StartupTab::StartupTabHome).toInt(); +} + +QString TabsSettings::getStartupServerHost() const +{ + return getValue("startupServerHost", QString(), QString(), QString()).toString(); +} + +QString TabsSettings::getStartupServerPort() const +{ + return getValue("startupServerPort", QString(), QString(), QString()).toString(); +} + +QString TabsSettings::getStartupRoomName() const +{ + return getValue("startupRoomName", QString(), QString(), QString()).toString(); +} + bool TabsSettings::getTabVisualDeckStorageOpen() const { return getValue("visualDeckStorage", QString(), QString(), true).toBool(); @@ -40,6 +67,57 @@ bool TabsSettings::getTabLogOpen() const return getValue("log", QString(), QString(), true).toBool(); } +void TabsSettings::setStartupTabIndex(int value) +{ + if (getStartupTabIndex() == value) { + return; + } + setValue(value, "startupTab"); + emit startupTabIndexChanged(value); +} + +void TabsSettings::setStartupServerHost(const QString &host) +{ + if (getStartupServerHost() == host) { + return; + } + setValue(host, "startupServerHost"); + emit startupServerHostChanged(host); +} + +void TabsSettings::setStartupServerPort(const QString &port) +{ + if (getStartupServerPort() == port) { + return; + } + setValue(port, "startupServerPort"); + emit startupServerPortChanged(port); +} + +void TabsSettings::setStartupRoomName(const QString &roomName) +{ + if (getStartupRoomName() == roomName) { + return; + } + setValue(roomName, "startupRoomName"); + emit startupRoomNameChanged(roomName); +} + +bool TabsSettings::getTabReportOpen() const +{ + return getValue("report", QString(), QString(), false).toBool(); +} + +bool TabsSettings::getTabModerationOpen() const +{ + return getValue("moderation", QString(), QString(), false).toBool(); +} + +bool TabsSettings::getTabCardArtRulesOpen() const +{ + return getValue("cardArtRules", QString(), QString(), false).toBool(); +} + void TabsSettings::setTabVisualDeckStorageOpen(bool value) { setValue(value, "visualDeckStorage"); @@ -74,3 +152,18 @@ void TabsSettings::setTabLogOpen(bool value) { setValue(value, "log"); } + +void TabsSettings::setTabReportOpen(bool value) +{ + setValue(value, "report"); +} + +void TabsSettings::setTabModerationOpen(bool value) +{ + setValue(value, "moderation"); +} + +void TabsSettings::setTabCardArtRulesOpen(bool value) +{ + setValue(value, "cardArtRules"); +} diff --git a/libcockatrice_settings/libcockatrice/settings/tabs_settings.h b/libcockatrice_settings/libcockatrice/settings/tabs_settings.h index c8e952b87..5411a6721 100644 --- a/libcockatrice_settings/libcockatrice/settings/tabs_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/tabs_settings.h @@ -3,7 +3,30 @@ #include "settings_manager.h" +#include #include +#include + +class QObject; + +/** + * @brief The tab the application selects after launch. + * + * The destination is a request: tabs that were not open before (Deck Editor, Server Room, …) + * are opened as part of the startup flow. Destinations that require a server connection use the + * intent system to satisfy their pre-conditions. + */ +enum StartupTab +{ + StartupTabHome, ///< The Home tab + StartupTabVisualDeckStorage, ///< The visual deck storage tab + StartupTabDeckStorage, ///< The deck storage tab + StartupTabReplays, ///< The game replays tab + StartupTabDeckEditor, ///< A fresh classic deck editor tab + StartupTabVisualDeckEditor, ///< A fresh visual deck editor tab + StartupTabServer, ///< The server lobby: connect and select the server tab + StartupTabServerRoom ///< A server room: connect and join the room by name +}; class TabsSettings : public SettingsManager, public ITabsSettingsProvider { @@ -11,6 +34,10 @@ class TabsSettings : public SettingsManager, public ITabsSettingsProvider friend class SettingsCache; public: + [[nodiscard]] int getStartupTabIndex() const override; + [[nodiscard]] QString getStartupServerHost() const override; + [[nodiscard]] QString getStartupServerPort() const override; + [[nodiscard]] QString getStartupRoomName() const override; [[nodiscard]] bool getTabVisualDeckStorageOpen() const override; [[nodiscard]] bool getTabServerOpen() const override; [[nodiscard]] bool getTabAccountOpen() const override; @@ -18,7 +45,14 @@ public: [[nodiscard]] bool getTabReplaysOpen() const override; [[nodiscard]] bool getTabAdminOpen() const override; [[nodiscard]] bool getTabLogOpen() const override; + [[nodiscard]] bool getTabReportOpen() const override; + [[nodiscard]] bool getTabModerationOpen() const override; + [[nodiscard]] bool getTabCardArtRulesOpen() const override; + void setStartupTabIndex(int value); + void setStartupServerHost(const QString &host); + void setStartupServerPort(const QString &port); + void setStartupRoomName(const QString &roomName); void setTabVisualDeckStorageOpen(bool value); void setTabServerOpen(bool value); void setTabAccountOpen(bool value); @@ -26,6 +60,15 @@ public: void setTabReplaysOpen(bool value); void setTabAdminOpen(bool value); void setTabLogOpen(bool value); + void setTabReportOpen(bool value); + void setTabModerationOpen(bool value); + void setTabCardArtRulesOpen(bool value); + +signals: + void startupTabIndexChanged(int index); + void startupServerHostChanged(const QString &host); + void startupServerPortChanged(const QString &port); + void startupRoomNameChanged(const QString &roomName); public: explicit TabsSettings(const QString &settingPath, QObject *parent = nullptr); diff --git a/libcockatrice_settings/libcockatrice/settings/updates_settings.cpp b/libcockatrice_settings/libcockatrice/settings/updates_settings.cpp index 166bc0aa8..9ea22ed7c 100644 --- a/libcockatrice_settings/libcockatrice/settings/updates_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/updates_settings.cpp @@ -1,6 +1,13 @@ #include "updates_settings.h" +#include "libcockatrice/settings/settings_manager.h" + +#include #include +#include +#include + +class QObject; UpdatesSettings::UpdatesSettings(const QString &settingPath, QObject *parent) : SettingsManager(settingPath + "updates.ini", "updates", QString(), parent) @@ -53,6 +60,11 @@ bool UpdatesSettings::getNotifyAboutNewVersion() const return getValue("newVersionNotification", QString(), QString(), true).toBool(); } +bool UpdatesSettings::getCardDatabaseUpdateTrayNotification() const +{ + return getValue("cardDatabaseUpdateTrayNotification", QString(), QString(), true).toBool(); +} + int UpdatesSettings::getUpdateReleaseChannelIndex() const { return getValue("updateReleaseChannel", QString(), QString(), 0).toInt(); @@ -98,6 +110,11 @@ void UpdatesSettings::setNotifyAboutNewVersion(bool _notifyaboutnewversion) setValue(_notifyaboutnewversion, "newVersionNotification"); } +void UpdatesSettings::setCardDatabaseUpdateTrayNotification(bool value) +{ + setValue(value, "cardDatabaseUpdateTrayNotification"); +} + void UpdatesSettings::setUpdateReleaseChannelIndex(int value) { setValue(value, "updateReleaseChannel"); diff --git a/libcockatrice_settings/libcockatrice/settings/updates_settings.h b/libcockatrice_settings/libcockatrice/settings/updates_settings.h index 6df0f8ce4..490dca14e 100644 --- a/libcockatrice_settings/libcockatrice/settings/updates_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/updates_settings.h @@ -3,8 +3,12 @@ #include "settings_manager.h" -#include +#include #include +#include + +class QDate; +class QObject; class UpdatesSettings : public SettingsManager, public IUpdatesSettingsProvider { @@ -21,6 +25,7 @@ public: [[nodiscard]] bool getAlwaysEnableNewSets() const override; [[nodiscard]] bool getNotifyAboutUpdates() const override; [[nodiscard]] bool getNotifyAboutNewVersion() const override; + [[nodiscard]] bool getCardDatabaseUpdateTrayNotification() const; [[nodiscard]] int getUpdateReleaseChannelIndex() const override; void setCheckUpdatesOnStartup(bool value); @@ -31,6 +36,7 @@ public: void setAlwaysEnableNewSets(bool value); void setNotifyAboutUpdates(bool _notifyaboutupdate); void setNotifyAboutNewVersion(bool _notifyaboutnewversion); + void setCardDatabaseUpdateTrayNotification(bool value); void setUpdateReleaseChannelIndex(int value); public: diff --git a/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.cpp b/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.cpp index 1b21af58e..4667a278c 100644 --- a/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.cpp @@ -1,5 +1,12 @@ #include "visual_deck_storage_settings.h" +#include "libcockatrice/settings/settings_manager.h" + +#include +#include + +class QObject; + namespace { QStringList defaultTags = { @@ -130,6 +137,11 @@ bool VisualDeckStorageSettings::getVisualDeckStorageShowTagsOnDeckPreviews() con return getValue("showTagsOnDeckPreviews", "interface", "visualDeckStorage", true).toBool(); } +bool VisualDeckStorageSettings::getVisualDeckStorageShowUploadTime() const +{ + return getValue("showUploadTime", "interface", "visualDeckStorage", true).toBool(); +} + bool VisualDeckStorageSettings::getVisualDeckStorageDrawUnusedColorIdentities() const { return getValue("drawUnusedColorIdentities", "interface", "visualDeckStorage", true).toBool(); @@ -220,6 +232,12 @@ void VisualDeckStorageSettings::setVisualDeckStorageShowTagsOnDeckPreviews(bool emit visualDeckStorageShowTagsOnDeckPreviewsChanged(_showTags); } +void VisualDeckStorageSettings::setVisualDeckStorageShowUploadTime(bool value) +{ + setValue(value, "showUploadTime", "interface", "visualDeckStorage"); + emit visualDeckStorageShowUploadTimeChanged(value); +} + void VisualDeckStorageSettings::setVisualDeckStorageDrawUnusedColorIdentities(bool _draw) { setValue(_draw, "drawUnusedColorIdentities", "interface", "visualDeckStorage"); diff --git a/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.h b/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.h index fd2a76663..12b96fd8b 100644 --- a/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.h @@ -3,8 +3,12 @@ #include "settings_manager.h" +#include #include #include +#include + +class QObject; class VisualDeckStorageSettings : public SettingsManager, public IVisualDeckStorageSettingsProvider { @@ -20,6 +24,7 @@ public: [[nodiscard]] bool getVisualDeckStorageShowColorIdentity() const override; [[nodiscard]] bool getVisualDeckStorageShowBannerCardComboBox() const override; [[nodiscard]] bool getVisualDeckStorageShowTagsOnDeckPreviews() const override; + [[nodiscard]] bool getVisualDeckStorageShowUploadTime() const; [[nodiscard]] bool getVisualDeckStorageDrawUnusedColorIdentities() const override; [[nodiscard]] int getVisualDeckStorageUnusedColorIdentitiesOpacity() const override; [[nodiscard]] int getVisualDeckStorageTooltipType() const override; @@ -38,6 +43,7 @@ public: void setVisualDeckStorageShowColorIdentity(bool value); void setVisualDeckStorageShowBannerCardComboBox(bool _showBannerCardComboBox); void setVisualDeckStorageShowTagsOnDeckPreviews(bool _showTags); + void setVisualDeckStorageShowUploadTime(bool value); void setVisualDeckStorageDrawUnusedColorIdentities(bool _draw); void setVisualDeckStorageUnusedColorIdentitiesOpacity(int _opacity); void setVisualDeckStorageTooltipType(int value); @@ -54,6 +60,7 @@ signals: void visualDeckStorageShowColorIdentityChanged(bool _visible); void visualDeckStorageShowBannerCardComboBoxChanged(bool _visible); void visualDeckStorageShowTagsOnDeckPreviewsChanged(bool _visible); + void visualDeckStorageShowUploadTimeChanged(bool _visible); void visualDeckStorageDrawUnusedColorIdentitiesChanged(bool _visible); void visualDeckStorageUnusedColorIdentitiesOpacityChanged(bool value); void visualDeckStorageInGameChanged(bool enabled); diff --git a/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index 79f5a11e4..5e6fa986d 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -5,16 +5,28 @@ set(CMAKE_AUTOMOC ON) set(CMAKE_AUTOUIC ON) set(CMAKE_AUTORCC ON) -set(UTILITY_SOURCES libcockatrice/utility/expression.cpp libcockatrice/utility/levenshtein.cpp - libcockatrice/utility/passwordhasher.cpp libcockatrice/utility/server_rate_limiter.cpp +set(UTILITY_SOURCES + libcockatrice/utility/cryptoutil.cpp + libcockatrice/utility/expression.cpp + libcockatrice/utility/levenshtein.cpp + libcockatrice/utility/passwordhasher.cpp + libcockatrice/utility/report_categories.cpp + libcockatrice/utility/server_rate_limiter.cpp + libcockatrice/utility/translation_loader.cpp + libcockatrice/utility/warning_categories.cpp ) set(UTILITY_HEADERS + libcockatrice/utility/card_ref.h libcockatrice/utility/color.h + libcockatrice/utility/cryptoutil.h libcockatrice/utility/expression.h libcockatrice/utility/levenshtein.h + libcockatrice/utility/local_server_name.h libcockatrice/utility/macros.h libcockatrice/utility/passwordhasher.h + libcockatrice/utility/playmat_params.h + libcockatrice/utility/report_categories.h libcockatrice/utility/string_limits.h libcockatrice/utility/dice_limits.h libcockatrice/utility/counter_limits.h @@ -22,14 +34,20 @@ set(UTILITY_HEADERS libcockatrice/utility/zone_names.h libcockatrice/utility/days_years_between.h libcockatrice/utility/server_rate_limiter.h + libcockatrice/utility/translation_loader.h + libcockatrice/utility/warning_categories.h ) add_library(libcockatrice_utility STATIC ${UTILITY_SOURCES} ${UTILITY_HEADERS}) target_include_directories(libcockatrice_utility PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) -target_link_libraries(libcockatrice_utility PUBLIC libcockatrice_rng ${QT_CORE_MODULE}) +find_package(OpenSSL REQUIRED) + +target_link_libraries(libcockatrice_utility PUBLIC libcockatrice_rng OpenSSL::Crypto ${QT_CORE_MODULE}) set(ORACLE_LIBS) include_directories(${${COCKATRICE_QT_VERSION_NAME}Core_INCLUDE_DIRS}) + +target_precompile_headers(libcockatrice_utility PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") diff --git a/libcockatrice_utility/libcockatrice/utility/cryptoutil.cpp b/libcockatrice_utility/libcockatrice/utility/cryptoutil.cpp new file mode 100644 index 000000000..fb59022de --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/cryptoutil.cpp @@ -0,0 +1,26 @@ +#include "cryptoutil.h" + +#include +#include + +namespace CryptoUtil +{ +QByteArray randomBytes(int count) +{ + QByteArray bytes(count, '\0'); + if (RAND_bytes(reinterpret_cast(bytes.data()), count) != 1) { + // Randomness failure is fatal: never fall back to a predictable source. + qFatal("CryptoUtil::randomBytes: RAND_bytes failed"); + } + return bytes; +} + +quint64 randomUInt64() +{ + quint64 value; + if (RAND_bytes(reinterpret_cast(&value), sizeof(value)) != 1) { + qFatal("CryptoUtil::randomUInt64: RAND_bytes failed"); + } + return value; +} +} // namespace CryptoUtil diff --git a/libcockatrice_utility/libcockatrice/utility/cryptoutil.h b/libcockatrice_utility/libcockatrice/utility/cryptoutil.h new file mode 100644 index 000000000..dba9dc37d --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/cryptoutil.h @@ -0,0 +1,13 @@ +#ifndef CRYPTOUTIL_H +#define CRYPTOUTIL_H + +#include +#include + +namespace CryptoUtil +{ +QByteArray randomBytes(int count); +quint64 randomUInt64(); +} // namespace CryptoUtil + +#endif diff --git a/libcockatrice_utility/libcockatrice/utility/expression.cpp b/libcockatrice_utility/libcockatrice/utility/expression.cpp index 718b0fe18..85d1cdddc 100644 --- a/libcockatrice_utility/libcockatrice/utility/expression.cpp +++ b/libcockatrice_utility/libcockatrice/utility/expression.cpp @@ -5,7 +5,12 @@ #include #include #include +#include #include +#include +#include +#include +#include peg::parser math(R"( EXPRESSION <- P0 diff --git a/libcockatrice_utility/libcockatrice/utility/levenshtein.cpp b/libcockatrice_utility/libcockatrice/utility/levenshtein.cpp index f39d325fb..f7d8459cb 100644 --- a/libcockatrice_utility/libcockatrice/utility/levenshtein.cpp +++ b/libcockatrice_utility/libcockatrice/utility/levenshtein.cpp @@ -1,5 +1,7 @@ #include "levenshtein.h" +#include +#include #include #include diff --git a/libcockatrice_utility/libcockatrice/utility/local_server_name.h b/libcockatrice_utility/libcockatrice/utility/local_server_name.h new file mode 100644 index 000000000..a5797fba1 --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/local_server_name.h @@ -0,0 +1,28 @@ +#ifndef COCKATRICE_LOCAL_SERVER_NAME_H +#define COCKATRICE_LOCAL_SERVER_NAME_H + +#include +#include +#include + +/** + * @brief Builds a QLocalServer/QLocalSocket name scoped to the current user. + * + * On Linux the default local socket namespace is system-wide, so an unscoped name + * would let one user's process talk to — or hijack — another user's session. + * @param prefix application specific socket name prefix + * @return the scoped socket name + */ +inline QString scopedLocalServerName(const QString &prefix) +{ + QString userName = qEnvironmentVariable("USER"); + if (userName.isEmpty()) { + userName = qEnvironmentVariable("USERNAME"); + } + if (userName.isEmpty()) { + userName = QDir::home().dirName(); + } + return prefix + QLatin1Char('-') + userName; +} + +#endif // COCKATRICE_LOCAL_SERVER_NAME_H diff --git a/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp b/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp index c40c5f94f..b4a412fa6 100644 --- a/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp +++ b/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp @@ -1,7 +1,9 @@ #include "passwordhasher.h" +#include +#include #include -#include +#include QString PasswordHasher::computeHash(const QString &password, const QString &salt) { @@ -21,12 +23,28 @@ QString PasswordHasher::generateRandomSalt(const int len) static const char alphanum[] = "0123456789" "ABCDEFGHIJKLMNOPQRSTUVWXYZ" "abcdefghijklmnopqrstuvwxyz"; + const int size = sizeof(alphanum) - 1; + + // Two bytes per character, corrected for modulo bias via rejection sampling. + const int bucketSize = 65536 / size; + const int limit = bucketSize * size; QString ret; - int size = sizeof(alphanum) - 1; - + ret.reserve(len); + QByteArray random = CryptoUtil::randomBytes(len * 2); + int bytesUsed = 0; for (int i = 0; i < len; ++i) { - ret.append(alphanum[rng->rand(0, size)]); + unsigned int value; + do { + if (bytesUsed >= random.size()) { + random = CryptoUtil::randomBytes(len * 2); + bytesUsed = 0; + } + value = static_cast(static_cast(random.at(bytesUsed))) << 8 | + static_cast(static_cast(random.at(bytesUsed + 1))); + bytesUsed += 2; + } while (value >= limit); + ret.append(alphanum[value / bucketSize]); } return ret; @@ -34,5 +52,5 @@ QString PasswordHasher::generateRandomSalt(const int len) QString PasswordHasher::generateActivationToken() { - return QCryptographicHash::hash(generateRandomSalt().toUtf8(), QCryptographicHash::Md5).toBase64().left(16); + return QString(CryptoUtil::randomBytes(16).toBase64().left(16)); } diff --git a/libcockatrice_utility/libcockatrice/utility/passwordhasher.h b/libcockatrice_utility/libcockatrice/utility/passwordhasher.h index 811ecef15..8ad83f089 100644 --- a/libcockatrice_utility/libcockatrice/utility/passwordhasher.h +++ b/libcockatrice_utility/libcockatrice/utility/passwordhasher.h @@ -1,7 +1,7 @@ #ifndef PASSWORDHASHER_H #define PASSWORDHASHER_H -#include +#include class PasswordHasher { diff --git a/libcockatrice_utility/libcockatrice/utility/peglib.h b/libcockatrice_utility/libcockatrice/utility/peglib.h index e7e558dff..a67a86549 100644 --- a/libcockatrice_utility/libcockatrice/utility/peglib.h +++ b/libcockatrice_utility/libcockatrice/utility/peglib.h @@ -1,4 +1,4 @@ -// +// // peglib.h // // Copyright (c) 2022 Yuji Hirose. All rights reserved. @@ -7,6 +7,9 @@ #pragma once +#define CPPPEGLIB_VERSION "1.16.0" +#define CPPPEGLIB_VERSION_NUM "0x011000" + /* * Configuration */ @@ -46,6 +49,8 @@ namespace peg { +struct GrammarBlob; + /*----------------------------------------------------------------------------- * scope_exit *---------------------------------------------------------------------------*/ @@ -335,6 +340,14 @@ inline std::string resolve_escape_sequence(const char *s, size_t n) { r += ']'; i++; break; + case '^': + r += '^'; + i++; + break; + case '-': + r += '-'; + i++; + break; case '\\': r += '\\'; i++; @@ -361,6 +374,46 @@ inline std::string resolve_escape_sequence(const char *s, size_t n) { return r; } +/* + * Predefined character classes (ASCII semantics) + */ +inline const std::vector> * +predefined_character_class(std::string_view name) { + static const std::map>> + table = { + {"alnum", {{'0', '9'}, {'A', 'Z'}, {'a', 'z'}}}, + {"alpha", {{'A', 'Z'}, {'a', 'z'}}}, + {"ascii", {{0x00, 0x7F}}}, + {"blank", {{'\t', '\t'}, {' ', ' '}}}, + {"cntrl", {{0x00, 0x1F}, {0x7F, 0x7F}}}, + {"digit", {{'0', '9'}}}, + {"graph", {{0x21, 0x7E}}}, + {"lower", {{'a', 'z'}}}, + {"print", {{0x20, 0x7E}}}, + {"punct", {{0x21, 0x2F}, {0x3A, 0x40}, {0x5B, 0x60}, {0x7B, 0x7E}}}, + {"space", {{'\t', '\r'}, {' ', ' '}}}, + {"upper", {{'A', 'Z'}}}, + {"word", {{'0', '9'}, {'A', 'Z'}, {'_', '_'}, {'a', 'z'}}}, + {"xdigit", {{'0', '9'}, {'A', 'F'}, {'a', 'f'}}}, + }; + auto it = table.find(name); + return it != table.end() ? &it->second : nullptr; +} + +// Ranges must be sorted and non-overlapping. +inline std::vector> complement_character_ranges( + const std::vector> &ranges) { + std::vector> r; + char32_t next = 0; + for (const auto &[lo, hi] : ranges) { + if (lo > next) { r.emplace_back(next, lo - 1); } + next = hi + 1; + } + if (next <= 0x10FFFF) { r.emplace_back(next, 0x10FFFF); } + return r; +} + /*----------------------------------------------------------------------------- * token_to_number_ - This function should be removed eventually *---------------------------------------------------------------------------*/ @@ -448,6 +501,7 @@ public: size_t items_count() const { return items_count_; } friend struct ComputeFirstSet; + friend struct GrammarBlob; private: struct Info { @@ -506,7 +560,7 @@ inline constexpr unsigned int str2tag(std::string_view sv) { namespace udl { -inline constexpr unsigned int operator"" _(const char *s, size_t l) { +inline constexpr unsigned int operator""_(const char *s, size_t l) { return str2tag_core(s, l, 0); } @@ -724,6 +778,25 @@ inline bool fail(size_t len) { return len == static_cast(-1); } using Log = std::function; +/* + * ErrorReport - structured error information passed to an ErrorReporter. + * Unlike Log, nothing is flattened into a display string, so applications + * can map errors to their own error types, localize messages, or feed + * diagnostics to IDEs. + */ +struct ErrorReport { + size_t line = 0; // 1-based + size_t col = 1; // 1-based + size_t position = 0; // byte offset in the input + std::string unexpected_token; // heuristic token at the error position + std::vector expected_literals; + std::vector expected_rules; // rules starting with '_' excluded + std::string message; // custom error_message if any (placeholders resolved) + std::string label; // rule name or recovery label the error belongs to +}; + +using ErrorReporter = std::function; + /* * ErrorInfo */ @@ -752,7 +825,11 @@ struct ErrorInfo { expected_tokens.emplace_back(error_literal, error_rule); } - void output_log(const Log &log, const char *s, size_t n); + void output_log(const Log &log, const char *s, size_t n) { + output_log(log, nullptr, s, n); + } + void output_log(const Log &log, const ErrorReporter &reporter, const char *s, + size_t n); private: int cast_char(char c) const { return static_cast(c); } @@ -808,6 +885,131 @@ using TracerLeave = std::function; +// Packrat memoization table: open-addressing hash map keyed by the fused +// (position * rule count + rule id) index. The insert-heavy access pattern +// makes node-based containers a bottleneck, so keys and lengths live in one +// flat array of 16-byte POD slots probed linearly; semantic values go into a +// parallel array that is never allocated when no cached result carries a +// value. Erased slots become tombstones (erase only happens during +// left-recursion cache invalidation). +class PackratCache { +public: + explicit PackratCache(size_t expected_entries) { + while (initial_capacity_ < expected_entries) { + initial_capacity_ *= 2; + } + } + + bool find(size_t key, size_t &len, std::any &val) const { + if (slots_.empty()) { return false; } + auto mask = slots_.size() - 1; + auto i = mix(key) & mask; + while (true) { + auto &slot = slots_[i]; + if (slot.key == key) { + len = slot.len; + if (!vals_.empty()) { + val = vals_[i]; + } else { + val.reset(); + } + return true; + } + if (slot.key == kEmpty) { return false; } + i = (i + 1) & mask; + } + } + + void insert_or_assign(size_t key, size_t len, const std::any &val) { + if (slots_.empty() || (used_ + 1) * 4 > slots_.size() * 3) { grow(); } + auto mask = slots_.size() - 1; + auto i = mix(key) & mask; + auto insert_pos = kEmpty; + while (true) { + auto &slot = slots_[i]; + if (slot.key == key) { + insert_pos = i; + break; + } + if (slot.key == kTombstone) { + if (insert_pos == kEmpty) { insert_pos = i; } + } else if (slot.key == kEmpty) { + if (insert_pos == kEmpty) { insert_pos = i; } + if (slots_[insert_pos].key == kEmpty) { used_++; } + break; + } + i = (i + 1) & mask; + } + auto &dest = slots_[insert_pos]; + dest.key = key; + dest.len = len; + if (val.has_value()) { + if (vals_.empty()) { vals_.resize(slots_.size()); } + vals_[insert_pos] = val; + } else if (!vals_.empty()) { + vals_[insert_pos].reset(); + } + } + + void erase(size_t key) { + if (slots_.empty()) { return; } + auto mask = slots_.size() - 1; + auto i = mix(key) & mask; + while (true) { + auto &slot = slots_[i]; + if (slot.key == key) { + slot.key = kTombstone; + if (!vals_.empty()) { vals_[i].reset(); } + return; + } + if (slot.key == kEmpty) { return; } + i = (i + 1) & mask; + } + } + +private: + static constexpr size_t kEmpty = static_cast(-1); + static constexpr size_t kTombstone = static_cast(-2); + + struct Slot { + size_t key = kEmpty; + size_t len = 0; + }; + + static size_t mix(size_t key) { + // Mix in 64 bits so `h >> 32` stays well-defined where size_t is 32-bit + // (wasm32); on 64-bit targets this is bit-identical to the size_t mix. + auto h = static_cast(key) * 0x9E3779B97F4A7C15ull; + return static_cast(h ^ (h >> 32)); + } + + void grow() { + auto new_cap = slots_.empty() ? initial_capacity_ : slots_.size() * 2; + std::vector old_slots = std::move(slots_); + std::vector old_vals = std::move(vals_); + slots_.assign(new_cap, Slot{}); + if (!old_vals.empty()) { vals_.assign(new_cap, std::any()); } + used_ = 0; + auto mask = new_cap - 1; + for (size_t j = 0; j < old_slots.size(); j++) { + auto &slot = old_slots[j]; + if (slot.key == kEmpty || slot.key == kTombstone) { continue; } + auto i = mix(slot.key) & mask; + while (slots_[i].key != kEmpty) { + i = (i + 1) & mask; + } + slots_[i] = slot; + if (!old_vals.empty()) { vals_[i] = std::move(old_vals[j]); } + used_++; + } + } + + size_t initial_capacity_ = 1024; + std::vector slots_; + std::vector vals_; + size_t used_ = 0; // occupied + tombstone slots +}; + class Context { public: const char *path; @@ -817,11 +1019,18 @@ public: ErrorInfo error_info; bool recovered = false; - std::vector> value_stack; + std::vector> value_stack; size_t value_stack_size = 0; std::vector rule_stack; - std::vector>> args_stack; + + // One frame per rule reference: the macro arguments in scope, and the + // instantiation they identify (0 for anything but a left-recursive macro). + struct ArgsFrame { + std::vector> args; + size_t macro_inst = 0; + }; + std::vector args_stack; size_t in_token_boundary_count = 0; @@ -836,68 +1045,115 @@ public: const size_t def_count; const bool enablePackratParsing; + const std::vector *packrat_index; // def_id -> cache slot or -1 + size_t packrat_cached_count; // number of memoized rules std::vector cache_registered; std::vector cache_success; + // Innermost active start position per rule; re-entry guard for rules that + // are not memoized (replaces the per-position bitvector for them). + std::vector active_pos; - std::map, std::tuple> - cache_values; + PackratCache cache_values; // Left recursion support struct LRMemo { size_t len = static_cast(-1); std::any val; }; - std::map, LRMemo> lr_memo; + + // A left-recursive rule instance: the definition plus, for a macro, the + // instantiation it was invoked with (0 for a plain rule). Two + // instantiations of the same macro grow independent seeds. + using LRRule = std::pair; + using LRKey = std::pair; + + std::map lr_memo; // Rules whose lr_memo was hit during the current parse scope. // Used to track LR cycle membership. - std::set lr_refs_hit; + std::set lr_refs_hit; // Rules currently in their seeding/growing phase at a given position. // Protected from having their lr_memo erased by inner growers. - std::set> lr_active_seeds; + std::set lr_active_seeds; + + // Interned macro instantiations: (definition, resolved arguments) -> id. + std::map, size_t> macro_inst_ids; + size_t next_macro_inst_ = 1; + + // Map a def_id to its slot in the cache tables, or -1 for guard-only + // rules (not memoized). + int32_t cache_slot(size_t def_id) const { + if (!packrat_index) { return static_cast(def_id); } + return def_id < packrat_index->size() ? (*packrat_index)[def_id] : -1; + } void clear_packrat_cache(const char *pos, size_t def_id) { if (!enablePackratParsing) { return; } + auto slot = cache_slot(def_id); + if (slot < 0) { return; } auto col = static_cast(pos - s); - auto idx = def_count * col + def_id; + auto idx = packrat_cached_count * col + static_cast(slot); if (idx < cache_registered.size()) { cache_registered[idx] = false; cache_success[idx] = false; } - cache_values.erase(std::make_pair(col, def_id)); + cache_values.erase(idx); } void write_packrat_cache(const char *pos, size_t def_id, size_t len, const std::any &val) { if (!enablePackratParsing) { return; } + auto slot = cache_slot(def_id); + if (slot < 0) { return; } auto col = pos - s; - auto idx = def_count * static_cast(col) + def_id; + auto idx = packrat_cached_count * static_cast(col) + + static_cast(slot); if (idx >= cache_registered.size()) { return; } cache_registered[idx] = true; cache_success[idx] = true; - auto key = std::pair(col, def_id); - cache_values[key] = std::pair(len, val); + cache_values.insert_or_assign(idx, len, val); } TracerEnter tracer_enter; TracerLeave tracer_leave; + const bool has_tracer; std::any trace_data; const bool verbose_trace; + // Byte-wise tolower frozen at parse start, so case-insensitive matching + // avoids a locale-sensitive libc call per input byte. + unsigned char tolower_table[256]; + Log log; + ErrorReporter error_reporter; Context(const char *path, const char *s, size_t l, size_t def_count, std::shared_ptr whitespaceOpe, std::shared_ptr wordOpe, bool enablePackratParsing, TracerEnter tracer_enter, TracerLeave tracer_leave, std::any trace_data, bool verbose_trace, - Log log) + Log log, ErrorReporter error_reporter = nullptr, + const std::vector *packrat_index = nullptr, + size_t packrat_cached_count = 0) : path(path), s(s), l(l), whitespaceOpe(whitespaceOpe), wordOpe(wordOpe), def_count(def_count), enablePackratParsing(enablePackratParsing), - cache_registered(enablePackratParsing ? def_count * (l + 1) : 0), - cache_success(enablePackratParsing ? def_count * (l + 1) : 0), + packrat_index(packrat_index), + packrat_cached_count(packrat_index ? packrat_cached_count : def_count), + cache_registered( + enablePackratParsing ? this->packrat_cached_count * (l + 1) : 0), + cache_success( + enablePackratParsing ? this->packrat_cached_count * (l + 1) : 0), + active_pos(enablePackratParsing ? def_count : 0, nullptr), + cache_values(enablePackratParsing ? (packrat_index ? l / 8 + 16 : l / 2) + : 0), tracer_enter(tracer_enter), tracer_leave(tracer_leave), - trace_data(trace_data), verbose_trace(verbose_trace), log(log) { + has_tracer(tracer_enter && tracer_leave), trace_data(trace_data), + verbose_trace(verbose_trace), log(log), error_reporter(error_reporter) { + + for (size_t i = 0; i < 256; i++) { + tolower_table[i] = + static_cast(std::tolower(static_cast(i))); + } push_args({}); } @@ -918,11 +1174,6 @@ public: }; std::vector *packrat_stats = nullptr; - // Per-rule packrat filter: if set, only rules with filter[def_id]=true - // use full memoization (cache_values map). Others use bitvector-only - // re-entry guard. - const std::vector *packrat_rule_filter = nullptr; - template void packrat(const char *a_s, size_t def_id, size_t &len, std::any &val, T fn) { @@ -931,23 +1182,47 @@ public: return; } + auto slot = cache_slot(def_id); + if (slot < 0) { + // Guard-only rule: no memoization. Recursion at the same position is + // caught by the per-rule active-position guard. + if (active_pos[def_id] == a_s) { + if (packrat_stats && def_id < packrat_stats->size()) { + (*packrat_stats)[def_id].hits++; + } + len = static_cast(-1); + return; + } + if (packrat_stats && def_id < packrat_stats->size()) { + (*packrat_stats)[def_id].misses++; + } + auto save = active_pos[def_id]; + active_pos[def_id] = a_s; + fn(val); + active_pos[def_id] = save; + return; + } + auto col = a_s - s; - auto idx = def_count * static_cast(col) + def_id; + auto idx = packrat_cached_count * static_cast(col) + + static_cast(slot); if (cache_registered[idx]) { if (packrat_stats && def_id < packrat_stats->size()) { (*packrat_stats)[def_id].hits++; } if (cache_success[idx]) { - auto key = std::pair(col, def_id); - std::tie(len, val) = cache_values[key]; + if (!cache_values.find(idx, len, val)) { + len = 0; + val.reset(); + } return; } else { len = static_cast(-1); return; } } else { - // Pre-register as failure (re-entry guard for all rules) + // Pre-register as failure (re-entry guard + failure memoization) cache_registered[idx] = true; cache_success[idx] = false; @@ -957,15 +1232,7 @@ public: fn(val); - bool full_memo = - !packrat_rule_filter || (def_id < packrat_rule_filter->size() && - (*packrat_rule_filter)[def_id]); - if (full_memo) { - if (success(len)) { write_packrat_cache(a_s, def_id, len, val); } - } else { - // Guard-only: undo registration so future calls re-parse - cache_registered[idx] = false; - } + if (success(len)) { write_packrat_cache(a_s, def_id, len, val); } return; } } @@ -974,7 +1241,7 @@ public: SemanticValues &push_semantic_values_scope() { assert(value_stack_size <= value_stack.size()); if (value_stack_size == value_stack.size()) { - value_stack.emplace_back(std::make_shared(this)); + value_stack.emplace_back(std::make_unique(this)); } else { auto &vs = *value_stack[value_stack_size]; if (!vs.empty()) { @@ -996,14 +1263,31 @@ public: void pop_semantic_values_scope() { value_stack_size--; } // Arguments - void push_args(std::vector> &&args) { - args_stack.emplace_back(std::move(args)); + void push_args(std::vector> &&args, + size_t macro_inst = 0) { + args_stack.push_back({std::move(args), macro_inst}); } void pop_args() { args_stack.pop_back(); } const std::vector> &top_args() const { - return args_stack[args_stack.size() - 1]; + return args_stack[args_stack.size() - 1].args; + } + + size_t top_macro_inst() const { + return args_stack[args_stack.size() - 1].macro_inst; + } + + // Identify a macro invocation by what its resolved arguments denote (see + // macro_inst_key). `Sum(A)` inside `Sum(N)`'s own body resolves A back to + // the argument the outer call was given, so both invocations intern to the + // same id and the inner one finds the outer's seed — which is what makes + // growing terminate. + size_t intern_macro_inst(std::vector &&key) { + auto [it, inserted] = + macro_inst_ids.emplace(std::move(key), next_macro_inst_); + if (inserted) { next_macro_inst_++; } + return it->second; } // Snapshot/Rollback @@ -1169,8 +1453,8 @@ private: lower_heap.reset(new char[id_len]); lower = lower_heap.get(); } - std::transform(s, s + id_len, lower, [](unsigned char ch) { - return static_cast(std::tolower(ch)); + std::transform(s, s + id_len, lower, [&c](unsigned char ch) { + return static_cast(c.tolower_table[ch]); }); std::string_view lower_sv(lower, id_len); @@ -1241,7 +1525,8 @@ public: const auto &fs = first_sets_[id]; if (!fs.any_char && !fs.can_be_empty && !fs.chars.test(static_cast(*s))) { - if (c.log && (fs.first_literal || fs.first_rule)) { + if ((c.log || c.error_reporter) && + (fs.first_literal || fs.first_rule)) { if (c.error_info.error_pos <= s) { if (c.error_info.error_pos < s || !(id > 0)) { c.error_info.error_pos = s; @@ -1517,6 +1802,8 @@ public: void accept(Visitor &v) override; friend struct ComputeFirstSet; + friend struct GrammarBlob; + friend struct OpeSignature; bool is_ascii_only() const { return is_ascii_only_; } const std::bitset<256> &ascii_bitset() const { return ascii_bitset_; } @@ -1799,6 +2086,10 @@ public: std::shared_ptr atom_; std::shared_ptr binop_; BinOpeInfo info_; + // Owned backing storage for info_ keys when this node is built by + // GrammarBlob::deserialize. Grammars parsed from source leave this empty and + // point info_ keys into the retained grammar text instead. + std::vector info_keys_; const Definition &rule_; private: @@ -2179,11 +2470,35 @@ struct DetectLeftRecursion : public TraversalVisitor { const char *error_s = nullptr; - std::shared_ptr resolve_macro_arg(size_t iarg) const; + // What a bare parameter reference denotes, plus the frame it was found at + // -- see visit_in_defining_scope. + struct ResolvedArg { + std::shared_ptr ope; + size_t depth = 0; + }; + ResolvedArg resolve_macro_arg(size_t iarg) const; + void visit_in_defining_scope(const ResolvedArg &arg); + + // A macro's body depends on its arguments, so "already visited" has to be + // per instantiation, not per name: in `A <- W('z') / W(A)`, visiting W with + // 'z' says nothing about W with A. Instantiations are identified by their + // resolved arguments, the same way as at parse time. + size_t intern_macro_inst(const Reference &ope); + + // A macro that instantiates itself with a growing argument + // (`M(s) <- M(s / 'x')`) has no finite set of instantiations. Stop + // descending instead of looping forever; the rule is then reported as + // non-left-recursive, which is what this analysis did for every macro + // before it became instantiation-aware. The bound is on nesting depth in + // general, not self-recursion specifically, so it also caps any other + // chain of nested macro calls -- generously, for real grammars. + static const size_t max_macro_inst_depth = 32; private: std::string name_; - std::unordered_set refs_; + std::set> refs_; + std::map, size_t> macro_inst_ids_; + size_t next_macro_inst_ = 1; bool done_ = false; std::vector> *> macro_args_stack_; }; @@ -2221,6 +2536,104 @@ struct ComputeCanBeEmpty : public TraversalVisitor { void visit(Cut &) override { result = false; } }; +// Structural signature of an Ope. Two alternatives whose first k elements +// have equal signatures consume the same text, so their (k+1)-th elements +// start at the same position. Opes whose state cannot be serialized get +// their address instead: that only ever reads as "these differ", which +// costs an optimization rather than adding one. +struct OpeSignature : public Ope::Visitor { + using Ope::Visitor::visit; + std::string s; + + void visit(Sequence &ope) override { group("seq", ope.opes_); } + void visit(PrioritizedChoice &ope) override { group("cho", ope.opes_); } + void visit(Repetition &ope) override { + s += "(rep " + std::to_string(ope.min_) + " " + + (ope.max_ == std::numeric_limits::max() + ? std::string("inf") + : std::to_string(ope.max_)); + wrap(*ope.ope_); + } + void visit(AndPredicate &ope) override { unary("and", *ope.ope_); } + void visit(NotPredicate &ope) override { unary("not", *ope.ope_); } + void visit(CaptureScope &ope) override { unary("cps", *ope.ope_); } + void visit(Capture &ope) override { unary("cap", *ope.ope_); } + void visit(TokenBoundary &ope) override { unary("tok", *ope.ope_); } + void visit(Ignore &ope) override { unary("ign", *ope.ope_); } + void visit(Whitespace &ope) override { unary("wsp", *ope.ope_); } + void visit(Recovery &ope) override { unary("rec", *ope.ope_); } + // A rule is named, never expanded — that is what keeps a recursive + // grammar's signature finite. WeakHolder only ever wraps a Holder, so + // descending through it lands on a name too. + void visit(Holder &ope) override { s += "(hld " + ope.name() + ")"; } + void visit(WeakHolder &ope) override { + if (auto p = ope.weak_.lock()) { + unary("wek", *p); + } else { + opaque(&ope); + } + } + void visit(Reference &ope) override { + s += "(ref " + ope.name_; + for (auto &arg : ope.args_) { + s += ' '; + arg->accept(*this); + } + s += ')'; + } + void visit(LiteralString &ope) override { + s += "(lit " + std::to_string(ope.ignore_case_) + " " + ope.lit_ + ")"; + } + void visit(CharacterClass &ope) override { + s += "(cls " + std::to_string(ope.negated_) + " " + + std::to_string(ope.ignore_case_); + for (const auto &[lo, hi] : ope.ranges_) { + s += " " + std::to_string(static_cast(lo)) + "-" + + std::to_string(static_cast(hi)); + } + s += ')'; + } + void visit(Character &ope) override { + s += "(chr " + std::to_string(static_cast(ope.ch_)) + ")"; + } + void visit(AnyCharacter &) override { s += "(any)"; } + void visit(Dictionary &ope) override { opaque(&ope); } + void visit(User &ope) override { opaque(&ope); } + void visit(BackReference &ope) override { opaque(&ope); } + void visit(PrecedenceClimbing &ope) override { opaque(&ope); } + void visit(Cut &ope) override { opaque(&ope); } + + static std::string get(Ope &ope) { + OpeSignature vis; + ope.accept(vis); + return std::move(vis.s); + } + +private: + void group(const char *tag, const std::vector> &v) { + s += '('; + s += tag; + for (const auto &op : v) { + s += ' '; + op->accept(*this); + } + s += ')'; + } + void unary(const char *tag, Ope &inner) { + s += '('; + s += tag; + wrap(inner); + } + void wrap(Ope &inner) { + s += ' '; + inner.accept(*this); + s += ')'; + } + void opaque(const void *p) { + s += "(opq " + std::to_string(reinterpret_cast(p)) + ")"; + } +}; + struct HasEmptyElement : public TraversalVisitor { using TraversalVisitor::visit; @@ -2571,6 +2984,7 @@ struct SetupFirstSets : public TraversalVisitor { if (cc && cc->is_ascii_only()) { ope.span_bitset_ = &cc->ascii_bitset(); } } void visit(Reference &ope) override; + void visit(Holder &ope) override; private: ComputeFirstSet::FirstSetCache first_set_cache_; @@ -2617,37 +3031,40 @@ public: } Result parse(const char *s, size_t n, const char *path = nullptr, - Log log = nullptr) const { + Log log = nullptr, + ErrorReporter error_reporter = nullptr) const { SemanticValues vs; std::any dt; - return parse_core(s, n, vs, dt, path, log); + return parse_core(s, n, vs, dt, path, log, error_reporter); } - Result parse(const char *s, const char *path = nullptr, - Log log = nullptr) const { + Result parse(const char *s, const char *path = nullptr, Log log = nullptr, + ErrorReporter error_reporter = nullptr) const { auto n = strlen(s); - return parse(s, n, path, log); + return parse(s, n, path, log, error_reporter); } Result parse(const char *s, size_t n, std::any &dt, - const char *path = nullptr, Log log = nullptr) const { + const char *path = nullptr, Log log = nullptr, + ErrorReporter error_reporter = nullptr) const { SemanticValues vs; - return parse_core(s, n, vs, dt, path, log); + return parse_core(s, n, vs, dt, path, log, error_reporter); } Result parse(const char *s, std::any &dt, const char *path = nullptr, - Log log = nullptr) const { + Log log = nullptr, + ErrorReporter error_reporter = nullptr) const { auto n = strlen(s); - return parse(s, n, dt, path, log); + return parse(s, n, dt, path, log, error_reporter); } template Result parse_and_get_value(const char *s, size_t n, T &val, - const char *path = nullptr, - Log log = nullptr) const { + const char *path = nullptr, Log log = nullptr, + ErrorReporter error_reporter = nullptr) const { SemanticValues vs; std::any dt; - auto r = parse_core(s, n, vs, dt, path, log); + auto r = parse_core(s, n, vs, dt, path, log, error_reporter); if (r.ret && !vs.empty() && vs.front().has_value()) { val = std::any_cast(vs[0]); } @@ -2656,17 +3073,18 @@ public: template Result parse_and_get_value(const char *s, T &val, const char *path = nullptr, - Log log = nullptr) const { + Log log = nullptr, + ErrorReporter error_reporter = nullptr) const { auto n = strlen(s); - return parse_and_get_value(s, n, val, path, log); + return parse_and_get_value(s, n, val, path, log, error_reporter); } template Result parse_and_get_value(const char *s, size_t n, std::any &dt, T &val, - const char *path = nullptr, - Log log = nullptr) const { + const char *path = nullptr, Log log = nullptr, + ErrorReporter error_reporter = nullptr) const { SemanticValues vs; - auto r = parse_core(s, n, vs, dt, path, log); + auto r = parse_core(s, n, vs, dt, path, log, error_reporter); if (r.ret && !vs.empty() && vs.front().has_value()) { val = std::any_cast(vs[0]); } @@ -2675,10 +3093,10 @@ public: template Result parse_and_get_value(const char *s, std::any &dt, T &val, - const char *path = nullptr, - Log log = nullptr) const { + const char *path = nullptr, Log log = nullptr, + ErrorReporter error_reporter = nullptr) const { auto n = strlen(s); - return parse_and_get_value(s, n, dt, val, path, log); + return parse_and_get_value(s, n, dt, val, path, log, error_reporter); } #if defined(__cpp_lib_char8_t) @@ -2789,6 +3207,10 @@ public: std::string error_message; bool no_ast_opt = false; + bool no_whitespace = false; // Disable %whitespace skipping inside this rule + // (like a token boundary, without capturing) + std::string ast_name; // When non-empty, AST nodes produced by this rule carry + // this name/tag instead of the rule's own name bool eoi_check = true; @@ -2816,7 +3238,8 @@ private: void initialize_packrat_filter() const; Result parse_core(const char *s, size_t n, SemanticValues &vs, std::any &dt, - const char *path, Log log) const { + const char *path, Log log, + ErrorReporter error_reporter = nullptr) const { initialize_definition_ids(); std::shared_ptr ope = holder_; @@ -2827,22 +3250,28 @@ private: if (tracer_end) { tracer_end(trace_data); } }); + const std::vector *packrat_index = nullptr; + size_t packrat_cached_count = 0; + if (enablePackratParsing) { + initialize_packrat_filter(); + if (!packrat_index_.empty()) { + packrat_index = &packrat_index_; + packrat_cached_count = packrat_cached_count_; + } else { + packrat_cached_count = definition_ids_.size(); + } + } + Context c(path, s, n, definition_ids_.size(), whitespaceOpe, wordOpe, enablePackratParsing, tracer_enter, tracer_leave, trace_data, - verbose_trace, log); + verbose_trace, log, error_reporter, packrat_index, + packrat_cached_count); if (collect_packrat_stats) { packrat_stats_.resize(definition_ids_.size()); c.packrat_stats = &packrat_stats_; } - if (enablePackratParsing) { - initialize_packrat_filter(); - if (!packrat_filter_.empty()) { - c.packrat_rule_filter = &packrat_filter_; - } - } - size_t i = 0; if (whitespaceOpe) { @@ -2881,7 +3310,8 @@ private: mutable std::once_flag definition_ids_init_; mutable std::unordered_map definition_ids_; mutable std::once_flag packrat_filter_init_; - mutable std::vector packrat_filter_; + mutable std::vector packrat_index_; // def_id -> cache slot or -1 + mutable size_t packrat_cached_count_ = 0; }; /* @@ -2895,9 +3325,11 @@ inline size_t parse_literal(const char *s, size_t n, SemanticValues &vs, size_t i = 0; for (; i < lit.size(); i++) { if (i >= n || - (ignore_case ? (static_cast(std::tolower( - static_cast(s[i]))) != lower_lit[i]) - : (s[i] != lit[i]))) { + (ignore_case + ? (static_cast( + c.tolower_table[static_cast(s[i])]) != + lower_lit[i]) + : (s[i] != lit[i]))) { c.set_error_pos(s, lit.data()); return static_cast(-1); } @@ -2950,14 +3382,15 @@ inline std::pair SemanticValues::line_info() const { return c_->line_info(sv_.data()); } -inline void ErrorInfo::output_log(const Log &log, const char *s, size_t n) { +inline void ErrorInfo::output_log(const Log &log, const ErrorReporter &reporter, + const char *s, size_t n) { if (message_pos) { if (message_pos > last_output_pos) { last_output_pos = message_pos; auto line = line_info(s, message_pos); std::string msg; - if (auto unexpected_token = heuristic_error_token(s, n, message_pos); - !unexpected_token.empty()) { + auto unexpected_token = heuristic_error_token(s, n, message_pos); + if (!unexpected_token.empty()) { msg = replace_all(message, "%t", unexpected_token); auto unexpected_char = unexpected_token.substr( @@ -2968,13 +3401,28 @@ inline void ErrorInfo::output_log(const Log &log, const char *s, size_t n) { } else { msg = message; } - log(line.first, line.second, msg, label); + if (reporter) { + ErrorReport report; + report.line = line.first; + report.col = line.second; + report.position = static_cast(message_pos - s); + report.unexpected_token = unexpected_token; + report.message = msg; + report.label = label; + reporter(report); + } + if (log) { log(line.first, line.second, msg, label); } } } else if (error_pos) { if (error_pos > last_output_pos) { last_output_pos = error_pos; auto line = line_info(s, error_pos); + ErrorReport report; + report.line = line.first; + report.col = line.second; + report.position = static_cast(error_pos - s); + std::string msg; if (expected_tokens.empty()) { msg = "syntax error."; @@ -2987,6 +3435,7 @@ inline void ErrorInfo::output_log(const Log &log, const char *s, size_t n) { msg += ", unexpected '"; msg += unexpected_token; msg += "'"; + report.unexpected_token = unexpected_token; } auto first_item = true; @@ -3001,9 +3450,11 @@ inline void ErrorInfo::output_log(const Log &log, const char *s, size_t n) { msg += "'"; msg += error_literal; msg += "'"; + report.expected_literals.emplace_back(error_literal); } else { msg += "<" + error_rule->name + ">"; if (label.empty()) { label = error_rule->name; } + report.expected_rules.emplace_back(error_rule->name); } first_item = false; } @@ -3012,7 +3463,11 @@ inline void ErrorInfo::output_log(const Log &log, const char *s, size_t n) { } msg += "."; } - log(line.first, line.second, msg, label); + if (reporter) { + report.label = label; + reporter(report); + } + if (log) { log(line.first, line.second, msg, label); } } } } @@ -3027,7 +3482,7 @@ inline size_t Context::skip_whitespace(const char *a_s, size_t n, } inline void Context::set_error_pos(const char *a_s, const char *literal) { - if (log) { + if (log || error_reporter) { if (error_info.error_pos <= a_s) { if (error_info.error_pos < a_s || !error_info.keep_previous_token) { error_info.error_pos = a_s; @@ -3074,7 +3529,7 @@ inline void Context::trace_leave(const Ope &ope, const char *a_s, size_t n, } inline bool Context::is_traceable(const Ope &ope) const { - if (tracer_enter && tracer_leave) { + if (has_tracer) { if (ignore_trace_state) { return false; } return !dynamic_cast(&ope); } @@ -3169,14 +3624,56 @@ inline size_t TokenBoundary::parse_core(const char *s, size_t n, return len; } +// Resolve `%{name}` placeholders in a custom error message against the +// named captures recorded so far ($name<...>). Unknown names resolve to an +// empty string. `%t` / `%c` are resolved later, at log-output time. +inline std::string resolve_capture_placeholders(const std::string &msg, + const Context &c) { + auto pos = msg.find("%{"); + if (pos == std::string::npos) { return msg; } + + std::string r; + size_t i = 0; + while (pos != std::string::npos) { + auto end = msg.find('}', pos + 2); + if (end == std::string::npos) { break; } + r.append(msg, i, pos - i); + auto name = std::string_view(msg).substr(pos + 2, end - (pos + 2)); + for (auto it = c.capture_entries.rbegin(); it != c.capture_entries.rend(); + ++it) { + if (it->first == name) { + // The captured span can include whitespace skipped after a token + // boundary; trim it for display. + auto v = std::string_view(it->second); + while (!v.empty() && + std::isspace(static_cast(v.back()))) { + v.remove_suffix(1); + } + while (!v.empty() && + std::isspace(static_cast(v.front()))) { + v.remove_prefix(1); + } + r += v; + break; + } + } + i = end + 1; + pos = msg.find("%{", i); + } + r.append(msg, i, msg.size() - i); + return r; +} + inline size_t Holder::parse_core(const char *s, size_t n, SemanticValues &vs, Context &c, std::any &dt) const { if (!ope_) { throw std::logic_error("Uninitialized definition ope was used..."); } - // Macro reference - if (outer_->is_macro) { + // Macro reference. A left-recursive macro cannot take this path: it needs + // the seed-growing below, which in turn needs its own semantic value scope + // to memoise. Such a macro forms a scope like a plain rule does. + if (outer_->is_macro && !outer_->is_left_recursive) { c.rule_stack.push_back(outer_); auto len = ope_->parse(s, n, vs, c, dt); c.rule_stack.pop_back(); @@ -3201,7 +3698,23 @@ inline size_t Holder::parse_core(const char *s, size_t n, SemanticValues &vs, }); c.rule_stack.push_back(outer_); - parse_len = ope_->parse(s, n, chvs, c, dt); + if (outer_->no_whitespace) { + { + c.in_token_boundary_count++; + auto se2 = scope_exit([&]() { c.in_token_boundary_count--; }); + parse_len = ope_->parse(s, n, chvs, c, dt); + } + if (success(parse_len)) { + auto wl = c.skip_whitespace(s + parse_len, n - parse_len, chvs, dt); + if (fail(wl)) { + parse_len = wl; + } else { + parse_len += wl; + } + } + } else { + parse_len = ope_->parse(s, n, chvs, c, dt); + } c.rule_stack.pop_back(); if (success(parse_len)) { @@ -3221,7 +3734,8 @@ inline size_t Holder::parse_core(const char *s, size_t n, SemanticValues &vs, std::any predicate_data; if (outer_->predicate) { if (!outer_->predicate(chvs, dt, msg, predicate_data)) { - if (c.log && !msg.empty() && c.error_info.message_pos < s) { + if ((c.log || c.error_reporter) && !msg.empty() && + c.error_info.message_pos < s) { c.error_info.message_pos = s; c.error_info.message = msg; c.error_info.label = outer_->name; @@ -3233,17 +3747,19 @@ inline size_t Holder::parse_core(const char *s, size_t n, SemanticValues &vs, if (success(parse_len)) { if (!c.recovered) { parse_val = reduce(chvs, dt, predicate_data); } } else { - if (c.log && !msg.empty() && c.error_info.message_pos < s) { + if ((c.log || c.error_reporter) && !msg.empty() && + c.error_info.message_pos < s) { c.error_info.message_pos = s; c.error_info.message = msg; c.error_info.label = outer_->name; } } } else { - if (c.log && !outer_->error_message.empty() && + if ((c.log || c.error_reporter) && !outer_->error_message.empty() && c.error_info.message_pos < s) { c.error_info.message_pos = s; - c.error_info.message = outer_->error_message; + c.error_info.message = + resolve_capture_placeholders(outer_->error_message, c); c.error_info.label = outer_->name; } } @@ -3252,7 +3768,10 @@ inline size_t Holder::parse_core(const char *s, size_t n, SemanticValues &vs, }; if (outer_->is_left_recursive) { - auto lr_key = std::make_pair(outer_, s); + // A macro grows one seed per instantiation: Sum(D) and Sum(L) are + // different rules as far as the memo is concerned. + auto lr_rule = Context::LRRule(outer_, c.top_macro_inst()); + auto lr_key = Context::LRKey(lr_rule, s); // Check LR memo first auto it = c.lr_memo.find(lr_key); @@ -3265,7 +3784,7 @@ inline size_t Holder::parse_core(const char *s, size_t n, SemanticValues &vs, } // Record that this rule's lr_memo was accessed. // Any LR rule currently seeding will know we're in its cycle. - c.lr_refs_hit.insert(outer_); + c.lr_refs_hit.insert(lr_rule); } else { // Seed with FAIL c.lr_memo[lr_key] = {static_cast(-1), {}}; @@ -3287,7 +3806,7 @@ inline size_t Holder::parse_core(const char *s, size_t n, SemanticValues &vs, // the cycle, so add self — this lets parent seeders see us as // a transitive cycle member. auto cycle_rules = c.lr_refs_hit; - if (!cycle_rules.empty()) { cycle_rules.insert(outer_); } + if (!cycle_rules.empty()) { cycle_rules.insert(lr_rule); } // Restore parent's refs and propagate cycle info upward c.lr_refs_hit = std::move(saved_refs); @@ -3303,15 +3822,17 @@ inline size_t Holder::parse_core(const char *s, size_t n, SemanticValues &vs, c.lr_memo[lr_key] = {len, val}; while (true) { - // Clear this rule's packrat cache - c.clear_packrat_cache(s, outer_->id); + // Clear this rule's packrat cache. A macro is never written there + // (that cache is keyed by rule id alone, which cannot tell two + // instantiations apart), so there is nothing to clear for one. + if (!outer_->is_macro) { c.clear_packrat_cache(s, outer_->id); } // Clear lr_memo for cycle-dependent rules at this position, // but NOT for rules currently in their own seeding phase // (lr_active_seeds) — those are outer growers we must not // interfere with. for (auto memo_it = c.lr_memo.begin(); memo_it != c.lr_memo.end();) { - if (memo_it->first.second == s && memo_it->first.first != outer_ && + if (memo_it->first.second == s && memo_it->first.first != lr_rule && cycle_rules.count(memo_it->first.first) && !c.lr_active_seeds.count(memo_it->first)) { memo_it = c.lr_memo.erase(memo_it); @@ -3334,7 +3855,9 @@ inline size_t Holder::parse_core(const char *s, size_t n, SemanticValues &vs, // Write final result to packrat cache (lr_memo entry is kept as // the primary lookup for LR rules at this position) - if (success(len)) { c.write_packrat_cache(s, outer_->id, len, val); } + if (success(len) && !outer_->is_macro) { + c.write_packrat_cache(s, outer_->id, len, val); + } } } else { if (c.enablePackratParsing) { @@ -3348,7 +3871,7 @@ inline size_t Holder::parse_core(const char *s, size_t n, SemanticValues &vs, } else { // Without packrat, use lr_memo as re-entry guard to prevent // stack overflow from undetected left recursion. - auto guard_key = std::make_pair(outer_, s); + auto guard_key = Context::LRKey({outer_, c.top_macro_inst()}, s); if (c.lr_memo.count(guard_key)) { len = static_cast(-1); } else { @@ -3390,6 +3913,23 @@ inline const std::string &Holder::trace_name() const { return trace_name_; } +// Key a macro instantiation by what each argument denotes rather than by the +// node that spells it: `M(N)` written at two call sites builds two Reference +// nodes for the same rule N, and those are the same instantiation. +inline std::vector +macro_inst_key(const Definition *def, + const std::vector> &args) { + std::vector key; + key.reserve(args.size() + 1); + key.push_back(def); + for (const auto &arg : args) { + auto ref = dynamic_cast(arg.get()); + key.push_back(ref && ref->rule_ ? static_cast(ref->rule_) + : static_cast(arg.get())); + } + return key; +} + inline size_t Reference::parse_core(const char *s, size_t n, SemanticValues &vs, Context &c, std::any &dt) const { auto save_ignore_trace_state = c.ignore_trace_state; @@ -3412,7 +3952,10 @@ inline size_t Reference::parse_core(const char *s, size_t n, SemanticValues &vs, args.emplace_back(std::move(vis.found_ope)); } - c.push_args(std::move(args)); + auto inst = rule_->is_left_recursive + ? c.intern_macro_inst(macro_inst_key(rule_, args)) + : 0; + c.push_args(std::move(args), inst); auto se = scope_exit([&]() { c.pop_args(); }); return rule_->holder_->parse(s, n, vs, c, dt); } else { @@ -3548,11 +4091,12 @@ inline size_t Recovery::parse_core(const char *s, size_t n, const auto &rule = dynamic_cast(*ope_); // Custom error message - if (c.log) { + if (c.log || c.error_reporter) { auto label = dynamic_cast(rule.args_[0].get()); if (label && !label->rule_->error_message.empty()) { c.error_info.message_pos = s; - c.error_info.message = label->rule_->error_message; + c.error_info.message = + resolve_capture_placeholders(label->rule_->error_message, c); c.error_info.label = label->rule_->name; } } @@ -3561,8 +4105,13 @@ inline size_t Recovery::parse_core(const char *s, size_t n, auto len = static_cast(-1); { auto save_log = c.log; + auto save_reporter = c.error_reporter; c.log = nullptr; - auto se = scope_exit([&]() { c.log = save_log; }); + c.error_reporter = nullptr; + auto se = scope_exit([&]() { + c.log = save_log; + c.error_reporter = save_reporter; + }); SemanticValues dummy_vs; std::any dummy_dt; @@ -3573,8 +4122,8 @@ inline size_t Recovery::parse_core(const char *s, size_t n, if (success(len)) { c.recovered = true; - if (c.log) { - c.error_info.output_log(c.log, c.s, c.l); + if (c.log || c.error_reporter) { + c.error_info.output_log(c.log, c.error_reporter, c.s, c.l); c.error_info.clear(); } } @@ -3662,32 +4211,37 @@ inline void ComputeCanBeEmpty::visit(Reference &ope) { } inline void DetectLeftRecursion::visit(Reference &ope) { + // Macro parameter reference: what it denotes lives in an enclosing + // instantiation (e.g. B(X) <- C(X) where X is itself a param ref). + auto param = !ope.rule_ && !macro_args_stack_.empty() + ? resolve_macro_arg(ope.iarg_) + : ResolvedArg{}; + if (ope.name_ == name_) { error_s = ope.s_; - } else if (!ope.rule_ && !macro_args_stack_.empty()) { - // Macro parameter reference: resolve through nested macro arg - // stacks (e.g. B(X) <- C(X) where X is itself a param ref). - auto resolved = resolve_macro_arg(ope.iarg_); - if (resolved) { - resolved->accept(*this); - if (done_ == false) { return; } - } - } else if (!refs_.count(ope.name_)) { - refs_.insert(ope.name_); - if (ope.rule_) { - if (ope.is_macro_) { macro_args_stack_.push_back(&ope.args_); } - ope.rule_->accept(*this); - if (ope.is_macro_) { macro_args_stack_.pop_back(); } - if (done_ == false) { return; } - } + } else if (param.ope) { + visit_in_defining_scope(param); + if (done_ == false) { return; } + } else if (ope.is_macro_ && + macro_args_stack_.size() >= max_macro_inst_depth) { + // Unbounded instantiation chain; stop descending. + } else if (ope.rule_ && + refs_ + .emplace(ope.rule_, ope.is_macro_ ? intern_macro_inst(ope) : 0) + .second) { + if (ope.is_macro_) { macro_args_stack_.push_back(&ope.args_); } + ope.rule_->accept(*this); + if (ope.is_macro_) { macro_args_stack_.pop_back(); } + if (done_ == false) { return; } } // If the referenced rule can match empty, don't mark as done — // the sequence may continue past this element to find LR. if (!ope.rule_ && !macro_args_stack_.empty()) { - auto resolved = resolve_macro_arg(ope.iarg_); - if (resolved) { + if (param.ope) { + // ComputeCanBeEmpty never consults the frame stack, so the scope it + // runs in cannot matter. ComputeCanBeEmpty cbe; - resolved->accept(cbe); + param.ope->accept(cbe); done_ = !cbe.result; } else { done_ = true; @@ -3697,20 +4251,51 @@ inline void DetectLeftRecursion::visit(Reference &ope) { } } -inline std::shared_ptr +inline size_t DetectLeftRecursion::intern_macro_inst(const Reference &ope) { + // Resolve bare parameter references to what the enclosing instantiation was + // given, so a macro passing its own parameter through interns to the same + // instantiation instead of a fresh one at every nesting level. + std::vector> args; + args.reserve(ope.args_.size()); + for (const auto &arg : ope.args_) { + auto ref = dynamic_cast(arg.get()); + auto resolved = ref && !ref->rule_ && !macro_args_stack_.empty() + ? resolve_macro_arg(ref->iarg_).ope + : nullptr; + args.push_back(resolved ? resolved : arg); + } + auto [it, inserted] = macro_inst_ids_.emplace(macro_inst_key(ope.rule_, args), + next_macro_inst_); + if (inserted) { next_macro_inst_++; } + return it->second; +} + +inline void +DetectLeftRecursion::visit_in_defining_scope(const ResolvedArg &arg) { + // The frames below the one holding it are the scope it was written in. + // `W(X) <- Y(X / 'x')` passes Y an argument whose own `X` means W's + // parameter, not Y's -- leaving Y's frame visible would resolve that `X` + // right back to `X / 'x'`, forever. + auto saved = macro_args_stack_; + auto se = scope_exit([&]() { macro_args_stack_ = std::move(saved); }); + macro_args_stack_.resize(arg.depth); + arg.ope->accept(*this); +} + +inline DetectLeftRecursion::ResolvedArg DetectLeftRecursion::resolve_macro_arg(size_t iarg) const { for (int i = static_cast(macro_args_stack_.size()) - 1; i >= 0; i--) { auto &args = *macro_args_stack_[i]; - if (iarg >= args.size()) { return nullptr; } + if (iarg >= args.size()) { return {}; } auto ref = dynamic_cast(args[iarg].get()); if (ref && !ref->rule_) { // Another param ref — resolve using parent level's args iarg = ref->iarg_; continue; } - return args[iarg]; + return {args[iarg], static_cast(i)}; } - return nullptr; + return {}; } inline void HasEmptyElement::visit(Sequence &ope) { @@ -3863,8 +4448,16 @@ inline void ComputeFirstSet::visit(Reference &ope) { inline void SetupFirstSets::visit(Reference &ope) { if (!ope.rule_) { return; } - if (!visited_rules_.insert(ope.rule_).second) { return; } - ope.rule_->accept(*this); + ope.rule_->accept(*this); // re-entry is guarded at the rule's Holder +} + +// Guard rule setup by Definition so a SetupFirstSets shared across all rules +// visits each rule's body at most once for the whole grammar. Without this the +// per-rule setup re-walks every reachable rule once per referencing rule, which +// is O(N^2) for grammars with dense cross-references. +inline void SetupFirstSets::visit(Holder &ope) { + if (!visited_rules_.insert(ope.outer_).second) { return; } + ope.ope_->accept(*this); } inline void SetupFirstSets::visit(Sequence &ope) { @@ -3982,17 +4575,47 @@ inline void Definition::initialize_packrat_filter() const { auto def_count = definition_ids_.size(); if (def_count == 0) { return; } - // Collect rule IDs reachable from an Ope subtree (bitvector indexed by - // def_id) - struct CollectReachableRules : public TraversalVisitor { + // Collect rule IDs that can be invoked at the *same start position* as + // the given Ope subtree (leftmost reachability). A packrat cache hit + // requires the same rule to be queried twice at the same position, and + // in a PEG that only happens when alternatives of a choice share a + // leftmost prefix — rules reachable only past a consuming element can + // never be re-queried by a sibling alternative. + struct CollectLeftmostRules : public TraversalVisitor { using TraversalVisitor::visit; std::vector reachable; // indexed by def_id + std::vector + visited_rules; // indexed by def_id; guards Holder cycles - CollectReachableRules(size_t n) : reachable(n, false) {} + CollectLeftmostRules(size_t n) + : reachable(n, false), visited_rules(n, false) {} + // Collect from the position element `from` starts at: element `from` + // itself, plus what follows for as long as elements can match empty — + // only up to (and including) the first one that must consume input. + void collect(const std::vector> &opes, size_t from) { + for (auto i = from; i < opes.size(); i++) { + opes[i]->accept(*this); + ComputeCanBeEmpty empty_vis; + opes[i]->accept(empty_vis); + if (!empty_vis.result) { break; } + } + } + + void visit(Sequence &ope) override { collect(ope.opes_, 0); } void visit(Holder &ope) override { auto id = ope.outer_->id; - if (id < reachable.size()) { reachable[id] = true; } + if (id < reachable.size()) { + reachable[id] = true; + + // Grammars built directly via the combinator API embed rules through + // WeakHolder rather than Reference, so a recursive rule forms a + // Holder cycle with no Reference to break it. Guard re-entry to avoid + // infinite recursion (reachability is monotone, so revisiting a rule + // we have already traversed adds nothing). + if (visited_rules[id]) { return; } + visited_rules[id] = true; + } ope.ope_->accept(*this); } void visit(Reference &ope) override { @@ -4004,7 +4627,8 @@ inline void Definition::initialize_packrat_filter() const { } }; - // Find rules that benefit: reachable from 2+ alternatives of same choice + // Find rules that benefit: queried by 2+ alternatives of the same choice + // at the same position std::vector benefits(def_count, false); struct FindBacktrackRules : public TraversalVisitor { @@ -4016,24 +4640,60 @@ inline void Definition::initialize_packrat_filter() const { FindBacktrackRules(std::vector &b, size_t n) : benefits(b), def_count(n), visited_rules(n, false) {} - void visit(PrioritizedChoice &ope) override { - // For each alternative, collect reachable rules as bitvectors - std::vector> alt_reachable; - for (auto &op : ope.opes_) { - CollectReachableRules crr(def_count); - op->accept(crr); - alt_reachable.push_back(std::move(crr.reachable)); - } + using Elements = std::vector>; - // Mark rules reachable from 2+ alternatives + // An alternative's top-level elements, so a shared prefix can be walked + // element by element. By value: this runs once per grammar. + static Elements elements_of(const std::shared_ptr &alt) { + if (auto *seq = dynamic_cast(alt.get())) { + return seq->opes_; + } + return {alt}; + } + + // `group` holds alternatives that agree on their first `k` elements, so + // every one of them reaches element k at the same input position — that + // is exactly when a packrat cache entry can hit. k == 0 is the plain + // "alternatives of one choice" case; deeper k is what a shared prefix + // like `'(' _ PATTERN _ ',' _` hides. + void mark_aligned(const std::vector &group, size_t k) { + if (group.size() < 2) { return; } + + std::vector> reachable; + reachable.reserve(group.size()); + for (const auto &seq : group) { + CollectLeftmostRules clr(def_count); + clr.collect(seq, k); + reachable.push_back(std::move(clr.reachable)); + } for (size_t id = 0; id < def_count; id++) { size_t count = 0; - for (auto &alt : alt_reachable) { + for (const auto &alt : reachable) { if (alt[id]) { count++; } } if (count >= 2) { benefits[id] = true; } } + // Only alternatives that also agree on element k stay aligned past it. + std::map> aligned; + for (const auto &seq : group) { + if (k < seq.size()) { + aligned[OpeSignature::get(*seq[k])].push_back(seq); + } + } + for (const auto &[sig, sub] : aligned) { + mark_aligned(sub, k + 1); + } + } + + void visit(PrioritizedChoice &ope) override { + std::vector group; + group.reserve(ope.opes_.size()); + for (const auto &op : ope.opes_) { + group.push_back(elements_of(op)); + } + mark_aligned(group, 0); + // Recurse into alternatives for (auto &op : ope.opes_) { op->accept(*this); @@ -4056,7 +4716,23 @@ inline void Definition::initialize_packrat_filter() const { if (whitespaceOpe) { whitespaceOpe->accept(finder); } if (wordOpe) { wordOpe->accept(finder); } - packrat_filter_ = std::move(benefits); + // Left-recursive rules read and write the packrat cache directly during + // seed-growing, so they must stay in the cached set. Macros are the + // exception: they use lr_memo only, keyed by instantiation. + for (const auto &[ptr, id] : definition_ids_) { + auto *def = static_cast(ptr); + if (def->is_left_recursive && !def->is_macro && id < def_count) { + benefits[id] = true; + } + } + + // Compact index: def_id -> slot in the cache tables (-1 = guard only) + packrat_index_.assign(def_count, -1); + int32_t k = 0; + for (size_t id = 0; id < def_count; id++) { + if (benefits[id]) { packrat_index_[id] = k++; } + } + packrat_cached_count_ = static_cast(k); }); } @@ -4094,6 +4770,419 @@ inline void FindReference::visit(Reference &ope) { found_ope = ope.shared_from_this(); } +/*----------------------------------------------------------------------------- + * Grammar serialization + * + * Serialize a compiled Grammar (the operator tree) to a byte blob and back, + * letting an application skip the meta-parse on startup by embedding a + * prebuilt blob. Structure only: semantic callbacks (actions / enter / leave / + * predicate, attached by enable_ast() etc.) are NOT serialized and must be + * re-applied after deserialize. References resolve by name (no pointer fixup); + * first-sets and keyword guards are recomputed on load (O(N)). The + * `precedence` instruction is supported (its operator table is structural). + * Grammars using the `User` operator or a Capture with a match action are + * rejected. The blob is specific to this peglib version's layout. + *---------------------------------------------------------------------------*/ + +struct GrammarBlob { + enum Tag : uint8_t { + T_Sequence, + T_Choice, + T_Repetition, + T_And, + T_Not, + T_Dictionary, + T_Literal, + T_CharClass, + T_Char, + T_AnyChar, + T_CaptureScope, + T_Capture, + T_TokenBoundary, + T_Ignore, + T_BackRef, + T_Reference, + T_Whitespace, + T_Recovery, + T_Cut, + T_PrecedenceClimbing, + T_Null + }; + + struct Writer { + std::vector b; + void u8(uint8_t v) { b.push_back(v); } + void u32(uint32_t v) { + for (int i = 0; i < 4; i++) + b.push_back((v >> (8 * i)) & 0xff); + } + void u64(uint64_t v) { + for (int i = 0; i < 8; i++) + b.push_back((v >> (8 * i)) & 0xff); + } + void str(const std::string &s) { + u32((uint32_t)s.size()); + b.insert(b.end(), s.begin(), s.end()); + } + }; + + static void write_ope(Writer &w, const std::shared_ptr &o) { + if (!o) { + w.u8(T_Null); + return; + } + Ope *p = o.get(); + if (auto x = dynamic_cast(p)) { + w.u8(T_Sequence); + w.u32((uint32_t)x->opes_.size()); + for (auto &c : x->opes_) + write_ope(w, c); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_Choice); + w.u8(x->for_label_ ? 1 : 0); + w.u32((uint32_t)x->opes_.size()); + for (auto &c : x->opes_) + write_ope(w, c); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_Repetition); + w.u64(x->min_); + w.u64(x->max_); + write_ope(w, x->ope_); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_And); + write_ope(w, x->ope_); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_Not); + write_ope(w, x->ope_); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_Dictionary); + w.u8(x->trie_.ignore_case_ ? 1 : 0); + // Recover words in their original choice-index order. The Trie stores + // each full word's id (its index in the constructor vector), which + // parse_core reports as vs.choice(). Iterating dic_ directly yields + // sorted key order and would renumber the choices, so place each word at + // its id. + std::vector words(x->trie_.items_count()); + for (auto &kv : x->trie_.dic_) + if (kv.second.match && kv.second.id < words.size()) + words[kv.second.id] = kv.first; + w.u32((uint32_t)words.size()); + for (auto &s : words) + w.str(s); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_Literal); + w.u8(x->ignore_case_ ? 1 : 0); + w.str(x->lit_); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_CharClass); + w.u8(x->negated_ ? 1 : 0); + w.u8(x->ignore_case_ ? 1 : 0); + w.u32((uint32_t)x->ranges_.size()); + for (auto &r : x->ranges_) { + w.u32((uint32_t)r.first); + w.u32((uint32_t)r.second); + } + } else if (auto x = dynamic_cast(p)) { + w.u8(T_Char); + w.u32((uint32_t)x->ch_); + } else if (dynamic_cast(p)) { + w.u8(T_AnyChar); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_CaptureScope); + write_ope(w, x->ope_); + } else if (auto x = dynamic_cast(p)) { + if (x->match_action_) { + throw std::runtime_error( + "GrammarBlob: Capture with a match action is not serializable"); + } + w.u8(T_Capture); + write_ope(w, x->ope_); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_TokenBoundary); + write_ope(w, x->ope_); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_Ignore); + write_ope(w, x->ope_); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_BackRef); + w.str(x->name_); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_Reference); + w.u8(x->is_macro_ ? 1 : 0); + w.str(x->name_); + w.u32((uint32_t)x->args_.size()); + for (auto &a : x->args_) + write_ope(w, a); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_Whitespace); + write_ope(w, x->ope_); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_Recovery); + write_ope(w, x->ope_); + } else if (dynamic_cast(p)) { + w.u8(T_Cut); + } else if (auto x = dynamic_cast(p)) { + w.u8(T_PrecedenceClimbing); + write_ope(w, x->atom_); + write_ope(w, x->binop_); + w.u32((uint32_t)x->info_.size()); + for (auto &[key, pri] : x->info_) { + w.str(std::string(key)); + w.u64((uint64_t)pri.first); + w.u8((uint8_t)pri.second); + } + } else { + throw std::runtime_error( + "GrammarBlob: operator not serializable (a custom User operator or " + "a Capture with a match action)"); + } + } + + struct Reader { + const uint8_t *p, *end; + uint8_t u8() { + if (p >= end) + throw std::runtime_error("GrammarBlob: unexpected end of blob"); + return *p++; + } + uint32_t u32() { + uint32_t v = 0; + for (int i = 0; i < 4; i++) + v |= (uint32_t)u8() << (8 * i); + return v; + } + uint64_t u64() { + uint64_t v = 0; + for (int i = 0; i < 8; i++) + v |= (uint64_t)u8() << (8 * i); + return v; + } + std::string str() { + uint32_t n = u32(); + std::string s((const char *)p, (const char *)p + n); + p += n; + return s; + } + }; + + static std::shared_ptr read_ope(Reader &r, Grammar &g, + Definition *owner) { + switch (r.u8()) { + case T_Null: return nullptr; + case T_Sequence: { + uint32_t n = r.u32(); + std::vector> v; + for (uint32_t i = 0; i < n; i++) + v.push_back(read_ope(r, g, owner)); + return std::make_shared(std::move(v)); + } + case T_Choice: { + bool fl = r.u8(); + uint32_t n = r.u32(); + std::vector> v; + for (uint32_t i = 0; i < n; i++) + v.push_back(read_ope(r, g, owner)); + auto c = std::make_shared(std::move(v)); + c->for_label_ = fl; + return c; + } + case T_Repetition: { + uint64_t mn = r.u64(), mx = r.u64(); + auto o = read_ope(r, g, owner); + return std::make_shared(o, mn, mx); + } + case T_And: return std::make_shared(read_ope(r, g, owner)); + case T_Not: return std::make_shared(read_ope(r, g, owner)); + case T_Dictionary: { + bool ic = r.u8(); + uint32_t n = r.u32(); + std::vector words; + for (uint32_t i = 0; i < n; i++) + words.push_back(r.str()); + return std::make_shared(words, ic); + } + case T_Literal: { + bool ic = r.u8(); + std::string s = r.str(); + return std::make_shared(std::move(s), ic); + } + case T_CharClass: { + bool neg = r.u8(), ic = r.u8(); + uint32_t n = r.u32(); + std::vector> ranges; + for (uint32_t i = 0; i < n; i++) { + auto lo = r.u32(), hi = r.u32(); + ranges.emplace_back((char32_t)lo, (char32_t)hi); + } + return std::make_shared(ranges, neg, ic); + } + case T_Char: return std::make_shared((char32_t)r.u32()); + case T_AnyChar: return std::make_shared(); + case T_CaptureScope: + return std::make_shared(read_ope(r, g, owner)); + case T_Capture: { + auto o = read_ope(r, g, owner); + return std::make_shared(o, nullptr); + } + case T_TokenBoundary: + return std::make_shared(read_ope(r, g, owner)); + case T_Ignore: return std::make_shared(read_ope(r, g, owner)); + case T_BackRef: return std::make_shared(r.str()); + case T_Reference: { + bool im = r.u8(); + std::string nm = r.str(); + uint32_t n = r.u32(); + std::vector> args; + for (uint32_t i = 0; i < n; i++) + args.push_back(read_ope(r, g, owner)); + return std::make_shared(g, nm, nullptr, im, args); + } + case T_Whitespace: + return std::make_shared(read_ope(r, g, owner)); + case T_Recovery: return std::make_shared(read_ope(r, g, owner)); + case T_Cut: return std::make_shared(); + case T_PrecedenceClimbing: { + if (!owner) { + throw std::runtime_error( + "GrammarBlob: 'precedence' operator outside a rule body"); + } + auto atom = read_ope(r, g, owner); + auto binop = read_ope(r, g, owner); + uint32_t n = r.u32(); + auto pc = std::make_shared( + atom, binop, PrecedenceClimbing::BinOpeInfo{}, *owner); + // info_ keys are string_views; back them with owned strings whose + // addresses stay stable (reserve avoids reallocation, and the node is + // never moved once held by shared_ptr). + pc->info_keys_.reserve(n); + for (uint32_t i = 0; i < n; i++) { + std::string key = r.str(); + auto level = (size_t)r.u64(); + auto assoc = (char)r.u8(); + pc->info_keys_.push_back(std::move(key)); + pc->info_[pc->info_keys_.back()] = std::pair(level, assoc); + } + return pc; + } + default: throw std::runtime_error("GrammarBlob: bad operator tag"); + } + } + + static const uint32_t MAGIC = 0x50454732; // "PEG2" + + static std::vector serialize(const Grammar &g, + const std::string &start) { + Writer w; + w.u32(MAGIC); + w.str(start); + w.u32((uint32_t)g.size()); + // Grammar is an unordered_map, whose iteration order is implementation + // defined: walking it directly yields different bytes for the same grammar + // on different standard libraries, so a blob generated on one platform + // cannot be byte-compared on another. Emit the definitions by name. + // deserialize() rebuilds the map from the names, so the order carries no + // meaning of its own. + std::vector defs; + defs.reserve(g.size()); + for (auto &kv : g) + defs.push_back(&kv); + std::sort(defs.begin(), defs.end(), + [](const auto *a, const auto *b) { return a->first < b->first; }); + for (auto *kv : defs) { + const auto &name = kv->first; + const auto &def = kv->second; + w.str(name); + uint8_t flags = + (def.ignoreSemanticValue ? 1 : 0) | (def.is_macro ? 2 : 0) | + (def.no_ast_opt ? 4 : 0) | (def.eoi_check ? 8 : 0) | + (def.enablePackratParsing ? 16 : 0) | + (def.is_left_recursive ? 32 : 0) | (def.can_be_empty ? 64 : 0) | + (def.disable_action ? 128 : 0); + w.u8(flags); + uint8_t flags2 = (def.no_whitespace ? 1 : 0); + w.u8(flags2); + w.u32((uint32_t)def.params.size()); + for (auto &s : def.params) + w.str(s); + w.str(def.ast_name); + w.str(def.error_message); + write_ope(w, const_cast(def).get_core_operator()); + } + return std::move(w.b); + } + + static std::shared_ptr deserialize(const std::vector &blob, + std::string &start_out) { + Reader r{blob.data(), blob.data() + blob.size()}; + if (r.u32() != MAGIC) + throw std::runtime_error("GrammarBlob: bad magic / not a grammar blob"); + start_out = r.str(); + uint32_t ndef = r.u32(); + auto g = std::make_shared(); + // Create each Definition before reading its body: a PrecedenceClimbing node + // needs a stable reference to its owning rule at construction. Grammar is a + // node-based map, so references stay valid as later rules are inserted. + for (uint32_t i = 0; i < ndef; i++) { + std::string name = r.str(); + uint8_t flags = r.u8(); + uint8_t flags2 = r.u8(); + uint32_t np = r.u32(); + std::vector params; + for (uint32_t k = 0; k < np; k++) + params.push_back(r.str()); + std::string ast_name = r.str(); + std::string err = r.str(); + + auto &def = (*g)[name]; + def.name = name; + def.ignoreSemanticValue = flags & 1; + def.is_macro = flags & 2; + def.no_ast_opt = flags & 4; + def.eoi_check = flags & 8; + def.enablePackratParsing = flags & 16; + def.is_left_recursive = flags & 32; + def.can_be_empty = flags & 64; + def.disable_action = flags & 128; + def.no_whitespace = flags2 & 1; + def.params = std::move(params); + def.ast_name = std::move(ast_name); + def.error_message = std::move(err); + + auto body = read_ope(r, *g, &def); + def <= body; + } + for (auto &x : *g) { + LinkReferences vis(*g, x.second.params); + x.second.accept(vis); + // TraversalVisitor descends only into a PrecedenceClimbing's atom_. In + // the from-source path binop_ is linked while the body is still a + // Sequence, before precedence lowering; a deserialized node is built + // already lowered so its binop_ reference must be linked explicitly here. + auto core = x.second.get_core_operator(); + if (auto pc = std::dynamic_pointer_cast(core)) { + pc->binop_->accept(vis); + } + } + { + SetupFirstSets vis; // shared across rules -> O(N) + for (auto &x : *g) + x.second.accept(vis); + } + // Re-derive automatic whitespace/word skipping on the start rule from the + // %whitespace / %word definitions, exactly as ParserGenerator does. Sharing + // the (already linked and first-set) definition operators avoids leaving + // references inside the skipping ope unlinked, and keeps the blob smaller. + if (g->count(WHITESPACE_DEFINITION_NAME)) { + (*g)[start_out].whitespaceOpe = + wsp((*g)[WHITESPACE_DEFINITION_NAME].get_core_operator()); + } + if (g->count(WORD_DEFINITION_NAME)) { + (*g)[start_out].wordOpe = (*g)[WORD_DEFINITION_NAME].get_core_operator(); + } + return g; + } +}; + /*----------------------------------------------------------------------------- * PEG parser generator *---------------------------------------------------------------------------*/ @@ -4140,6 +5229,17 @@ private: ParserGenerator() { make_grammar(); setup_actions(); + // Apply First-Set filtering to the bootstrap meta-grammar itself so that + // parsing a grammar (the bulk of load_grammar) skips alternatives whose + // next byte cannot match. This is safe -- First-Set filtering only skips + // alternatives that would have failed anyway, so no semantic action that + // would have committed is skipped (unlike packrat, which is unsound here). + { + SetupFirstSets vis; + for (auto &x : g) { + x.second.accept(vis); + } + } } struct Instruction { @@ -4181,11 +5281,13 @@ private: void make_grammar() { // Setup PEG syntax parser g["Grammar"] <= seq(g["Spacing"], oom(g["Definition"]), g["EndOfFile"]); - g["Definition"] <= - cho(seq(g["Ignore"], g["IdentCont"], g["Parameters"], g["LEFTARROW"], - g["Expression"], opt(g["Instruction"])), - seq(g["Ignore"], g["Identifier"], g["LEFTARROW"], g["Expression"], - opt(g["Instruction"]))); + // Left-factored: parse the rule name (IdentCont) once, then optionally the + // macro parameter list. `opt(Parameters)` pushes a value only for a macro + // (so the value layout matches the old two-alternative form), and Spacing + // (~, no value) consumes the gap before LEFTARROW that Identifier used to. + g["Definition"] <= seq(g["Ignore"], g["IdentCont"], opt(g["Parameters"]), + g["Spacing"], g["LEFTARROW"], g["Expression"], + opt(g["Instruction"])); g["Expression"] <= seq(g["Sequence"], zom(seq(g["SLASH"], g["Sequence"]))); g["Sequence"] <= zom(cho(g["CUT"], g["Prefix"])); g["Prefix"] <= seq(opt(cho(g["AND"], g["NOT"])), g["SuffixWithLabel"]); @@ -4193,17 +5295,19 @@ private: seq(g["Suffix"], opt(seq(g["LABEL"], g["Identifier"]))); g["Suffix"] <= seq(g["Primary"], opt(g["Loop"])); g["Loop"] <= cho(g["QUESTION"], g["STAR"], g["PLUS"], g["Repetition"]); - g["Primary"] <= cho(seq(g["Ignore"], g["IdentCont"], g["Arguments"], - npd(g["LEFTARROW"])), - seq(g["Ignore"], g["Identifier"], - npd(seq(opt(g["Parameters"]), g["LEFTARROW"]))), - seq(g["OPEN"], g["Expression"], g["CLOSE"]), - seq(g["BeginTok"], g["Expression"], g["EndTok"]), - g["CapScope"], - seq(g["BeginCap"], g["Expression"], g["EndCap"]), - g["BackRef"], g["DictionaryI"], g["LiteralI"], - g["Dictionary"], g["Literal"], g["NegatedClassI"], - g["NegatedClass"], g["ClassI"], g["Class"], g["DOT"]); + // Left-factored: a macro reference (`Name(args)`) and a plain reference + // (`Name`) share the leading `Ignore IdentCont`, so parse it once and let + // `opt(Arguments)` decide. opt() pushes the argument list only for a macro + // reference, so vs.size() distinguishes the two in the action. + g["Primary"] <= + cho(seq(g["Ignore"], g["IdentCont"], opt(g["Arguments"]), g["Spacing"], + npd(seq(opt(g["Parameters"]), g["LEFTARROW"]))), + seq(g["OPEN"], g["Expression"], g["CLOSE"]), + seq(g["BeginTok"], g["Expression"], g["EndTok"]), g["CapScope"], + seq(g["BeginCap"], g["Expression"], g["EndCap"]), g["BackRef"], + g["DictionaryI"], g["LiteralI"], g["Dictionary"], g["Literal"], + g["NegatedClassI"], g["NegatedClass"], g["ClassI"], g["Class"], + g["DOT"]); g["Identifier"] <= seq(g["IdentCont"], g["Spacing"]); g["IdentCont"] <= tok(seq(g["IdentStart"], zom(g["IdentRest"]))); @@ -4252,8 +5356,12 @@ private: // NOTE: This is different from The original Brian Ford's paper, and this // modification allows us to specify `[+-]` as a valid char class. - g["Range"] <= - cho(seq(g["Char"], chr('-'), npd(chr(']')), g["Char"]), g["Char"]); + g["Range"] <= cho(seq(g["Char"], chr('-'), npd(chr(']')), g["Char"]), + g["ClassEscape"], g["PosixClass"], g["Char"]); + + g["ClassEscape"] <= seq(chr('\\'), cls("dDwWsS")); + g["PosixClass"] <= + seq(lit("[:"), opt(chr('^')), oom(cls("a-z")), lit(":]")); g["Char"] <= cho(seq(chr('\\'), cls("fnrtv'\"[]\\^-")), @@ -4323,8 +5431,8 @@ private: opt(seq(g["InstructionItem"], zom(seq(g["InstructionItemSeparator"], g["InstructionItem"])))), g["EndBracket"]); - g["InstructionItem"] <= - cho(g["PrecedenceClimbing"], g["ErrorMessage"], g["NoAstOpt"]); + g["InstructionItem"] <= cho(g["PrecedenceClimbing"], g["ErrorMessage"], + g["NoAstOpt"], g["NoWhitespace"], g["AstName"]); ~g["InstructionItemSeparator"] <= seq(chr(';'), g["Spacing"]); ~g["SpacesZom"] <= zom(g["Space"]); @@ -4357,6 +5465,13 @@ private: // No Ast node optimization instruction g["NoAstOpt"] <= seq(lit("no_ast_opt"), g["SpacesZom"]); + // No whitespace skipping instruction + g["NoWhitespace"] <= seq(lit("no_whitespace"), g["SpacesZom"]); + + // AST node name override instruction: `{ ast_name: NodeTag }` + g["AstName"] <= seq(lit("ast_name"), g["SpacesZom"], lit(":"), + g["SpacesZom"], g["Identifier"], g["SpacesZom"]); + // Set definition names for (auto &x : g) { x.second.name = x.first; @@ -4367,7 +5482,10 @@ private: g["Definition"] = [&](const SemanticValues &vs, std::any &dt) { auto &data = *std::any_cast(dt); - auto is_macro = vs.choice() == 0; + // Macro iff the optional Parameters matched: its value (the parameter + // name list) then sits at vs[2]. A plain definition has LEFTARROW's value + // there instead. + auto is_macro = vs[2].type() == typeid(std::vector); auto ignore = std::any_cast(vs[0]); auto name = std::any_cast(vs[1]); @@ -4393,9 +5511,6 @@ private: if (types.find(type) == types.end()) { data.instructions[name].push_back(instruction); types.insert(instruction.type); - if (type == "declare_symbol" || type == "check_symbol") { - if (!TokenChecker::is_token(*ope)) { ope = tok(ope); } - } } else { data.duplicates_of_instruction.emplace_back(type, instruction.sv.data()); @@ -4414,7 +5529,9 @@ private: rule.is_macro = is_macro; rule.params = params; - if (data.start.empty()) { + // Reserved `%`-prefixed rules (%whitespace, %word, ...) are directives, + // not parseable entry points, so they must not become the start rule. + if (data.start.empty() && name[0] != '%') { data.start = rule.name; data.start_pos = rule.s_; } @@ -4531,9 +5648,9 @@ private: auto &data = *std::any_cast(dt); switch (vs.choice()) { - case 0: // Macro Reference - case 1: { // Reference - auto is_macro = vs.choice() == 0; + case 0: { // Reference / Macro reference (left-factored) + // Macro reference iff opt(Arguments) matched and pushed the arg list. + auto is_macro = vs.size() > 2; auto ignore = std::any_cast(vs[0]); const auto &ident = std::any_cast(vs[1]); @@ -4551,16 +5668,16 @@ private: return ope; } } - case 2: { // (Expression) + case 1: { // (Expression) return std::any_cast>(vs[0]); } - case 3: { // TokenBoundary + case 2: { // TokenBoundary return tok(std::any_cast>(vs[0])); } - case 4: { // CaptureScope + case 3: { // CaptureScope return csc(std::any_cast>(vs[0])); } - case 5: { // Capture + case 4: { // Capture const auto &name = std::any_cast(vs[0]); auto ope = std::any_cast>(vs[1]); @@ -4607,23 +5724,36 @@ private: return resolve_escape_sequence(tok.data(), tok.size()); }; - g["Class"] = [](const SemanticValues &vs) { - auto ranges = vs.transform>(); - return cls(ranges); + // A Range produces either a single range (std::pair) or a range list + // (std::vector) for `\d`-style escapes and POSIX classes. + auto collect_ranges = [](const SemanticValues &vs) { + std::vector> ranges; + for (const auto &v : vs) { + if (v.type() == typeid(std::pair)) { + ranges.push_back(std::any_cast>(v)); + } else { + const auto &vec = + std::any_cast> &>( + v); + ranges.insert(ranges.end(), vec.begin(), vec.end()); + } + } + return ranges; }; - g["ClassI"] = [](const SemanticValues &vs) { - auto ranges = vs.transform>(); - return cls(ranges, true); + + g["Class"] = [collect_ranges](const SemanticValues &vs) { + return cls(collect_ranges(vs)); }; - g["NegatedClass"] = [](const SemanticValues &vs) { - auto ranges = vs.transform>(); - return ncls(ranges); + g["ClassI"] = [collect_ranges](const SemanticValues &vs) { + return cls(collect_ranges(vs), true); }; - g["NegatedClassI"] = [](const SemanticValues &vs) { - auto ranges = vs.transform>(); - return ncls(ranges, true); + g["NegatedClass"] = [collect_ranges](const SemanticValues &vs) { + return ncls(collect_ranges(vs)); }; - g["Range"] = [](const SemanticValues &vs) { + g["NegatedClassI"] = [collect_ranges](const SemanticValues &vs) { + return ncls(collect_ranges(vs), true); + }; + g["Range"] = [](const SemanticValues &vs) -> std::any { switch (vs.choice()) { case 0: { auto s1 = std::any_cast(vs[0]); @@ -4636,7 +5766,10 @@ private: } return std::pair(cp1, cp2); } - case 1: { + case 1: // ClassEscape + case 2: // PosixClass + return vs[0]; + case 3: { auto s = std::any_cast(vs[0]); auto cp = decode_codepoint(s.data(), s.length()); return std::pair(cp, cp); @@ -4644,6 +5777,33 @@ private: } return std::pair(0, 0); }; + g["ClassEscape"] = [](const SemanticValues &vs) { + auto ch = vs.sv()[1]; + const char *name = nullptr; + switch (ch) { + case 'd': + case 'D': name = "digit"; break; + case 's': + case 'S': name = "space"; break; + default: name = "word"; break; + } + auto ranges = *predefined_character_class(name); + if (ch == 'D' || ch == 'S' || ch == 'W') { + ranges = complement_character_ranges(ranges); + } + return ranges; + }; + g["PosixClass"] = [](const SemanticValues &vs) { + auto sv = vs.sv(); // `[:name:]` or `[:^name:]` + auto negated = sv[2] == '^'; + auto name = sv.substr(negated ? 3 : 2, sv.size() - (negated ? 5 : 4)); + auto ranges = predefined_character_class(name); + if (!ranges) { + auto msg = "invalid POSIX character class '" + std::string(name) + "'"; + throw SyntaxErrorException(msg.c_str(), vs.line_info()); + } + return negated ? complement_character_ranges(*ranges) : *ranges; + }; g["Char"] = [](const SemanticValues &vs) { return resolve_escape_sequence(vs.sv().data(), vs.sv().length()); }; @@ -4773,6 +5933,21 @@ private: return instruction; }; + g["NoWhitespace"] = [](const SemanticValues &vs) { + Instruction instruction; + instruction.type = "no_whitespace"; + instruction.sv = vs.sv(); + return instruction; + }; + + g["AstName"] = [](const SemanticValues &vs) { + Instruction instruction; + instruction.type = "ast_name"; + instruction.data = std::any_cast(vs[0]); + instruction.sv = vs.sv(); + return instruction; + }; + g["Instruction"] = [](const SemanticValues &vs) { return vs.transform(); }; @@ -5076,14 +6251,23 @@ private: rule.error_message = std::any_cast(instruction.data); } else if (instruction.type == "no_ast_opt") { rule.no_ast_opt = true; + } else if (instruction.type == "no_whitespace") { + rule.no_whitespace = true; + } else if (instruction.type == "ast_name") { + rule.ast_name = std::any_cast(instruction.data); } } } - // Setup First-Set and ISpan optimizations - for (auto &x : grammar) { + // Setup First-Set and ISpan optimizations. A single visitor is shared + // across all rules so its first-set cache and visited-rule set persist: + // each rule's first-sets are computed once (O(N)) instead of re-walking + // every reachable rule once per referencing rule (O(N^2)). + { SetupFirstSets vis; - x.second.accept(vis); + for (auto &x : grammar) { + x.second.accept(vis); + } } return {data.grammar, start, data.enablePackratParsing}; @@ -5117,21 +6301,24 @@ template struct AstBase : public Annotation { AstBase(const char *path, size_t line, size_t column, const char *name, const std::vector> &nodes, size_t position = 0, size_t length = 0, size_t choice_count = 0, - size_t choice = 0) + size_t choice = 0, bool preserve_position = false) : path(path ? path : ""), line(line), column(column), name(name), position(position), length(length), choice_count(choice_count), choice(choice), original_name(name), original_choice_count(choice_count), original_choice(choice), - tag(str2tag(name)), original_tag(tag), is_token(false), nodes(nodes) {} + tag(str2tag(name)), original_tag(tag), is_token(false), + preserve_position(preserve_position), nodes(nodes) {} AstBase(const char *path, size_t line, size_t column, const char *name, const std::string_view &token, size_t position = 0, size_t length = 0, - size_t choice_count = 0, size_t choice = 0) + size_t choice_count = 0, size_t choice = 0, + bool preserve_position = false) : path(path ? path : ""), line(line), column(column), name(name), position(position), length(length), choice_count(choice_count), choice(choice), original_name(name), original_choice_count(choice_count), original_choice(choice), - tag(str2tag(name)), original_tag(tag), is_token(true), token(token) {} + tag(str2tag(name)), original_tag(tag), is_token(true), + preserve_position(preserve_position), token(token) {} AstBase(const AstBase &ast, const char *original_name, size_t position = 0, size_t length = 0, size_t original_choice_count = 0, @@ -5142,7 +6329,8 @@ template struct AstBase : public Annotation { original_choice_count(original_choice_count), original_choice(original_choice), tag(ast.tag), original_tag(str2tag(original_name)), is_token(ast.is_token), - token(ast.token), nodes(ast.nodes), parent(ast.parent) {} + preserve_position(ast.preserve_position), token(ast.token), + nodes(ast.nodes), parent(ast.parent) {} const std::string path; const size_t line = 1; @@ -5160,6 +6348,7 @@ template struct AstBase : public Annotation { const unsigned int original_tag; const bool is_token; + const bool preserve_position; const std::string_view token; std::vector>> nodes; @@ -5222,8 +6411,10 @@ struct AstOptimizer { if (opt && original->nodes.size() == 1) { auto child = optimize(original->nodes[0], parent); - auto ast = std::make_shared(*child, original->name.data(), - original->position, original->length, + auto pos = + child->preserve_position ? child->position : original->position; + auto len = child->preserve_position ? child->length : original->length; + auto ast = std::make_shared(*child, original->name.data(), pos, len, original->choice_count, original->choice); for (auto &node : ast->nodes) { node->parent = ast; @@ -5253,18 +6444,23 @@ template void add_ast_action(Definition &rule) { rule.action = [&](const SemanticValues &vs) { auto line = vs.line_info(); + // `{ ast_name: X }` overrides the node's name/tag (falls back to the + // rule's own name when unset). + const char *node_name = + rule.ast_name.empty() ? rule.name.data() : rule.ast_name.data(); + if (rule.is_token()) { return std::make_shared( - vs.path, line.first, line.second, rule.name.data(), vs.token(), + vs.path, line.first, line.second, node_name, vs.token(), std::distance(vs.ss, vs.sv().data()), vs.sv().length(), - vs.choice_count(), vs.choice()); + vs.choice_count(), vs.choice(), rule.no_ast_opt); } - auto ast = - std::make_shared(vs.path, line.first, line.second, rule.name.data(), - vs.transform>(), - std::distance(vs.ss, vs.sv().data()), - vs.sv().length(), vs.choice_count(), vs.choice()); + auto ast = std::make_shared(vs.path, line.first, line.second, node_name, + vs.transform>(), + std::distance(vs.ss, vs.sv().data()), + vs.sv().length(), vs.choice_count(), + vs.choice(), rule.no_ast_opt); for (auto &node : ast->nodes) { node->parent = ast; @@ -5461,10 +6657,32 @@ public: return load_grammar(sv.data(), sv.size(), Rules(), start); } + // Serialize the loaded grammar to a portable byte blob (see GrammarBlob). + // Semantic callbacks are not included; throws if the grammar is not + // serializable (uses the `User` operator or a Capture with a match action). + std::vector serialize_grammar() const { + return GrammarBlob::serialize(*grammar_, start_); + } + + // Load a grammar from a blob produced by serialize_grammar() / GrammarBlob, + // skipping the meta-parse. Re-apply enable_ast() etc. afterwards as needed. + bool load_blob(const std::vector &blob) { + try { + grammar_ = GrammarBlob::deserialize(blob, start_); + } catch (const std::exception &) { return false; } + if (grammar_ != nullptr) { + // Symmetry with load_grammar(): restore the parser-level packrat flag + // from the blob so a later enable_packrat_parsing() re-applies it + // instead of resetting the start rule to the false member default. + enablePackratParsing_ = (*grammar_)[start_].enablePackratParsing; + } + return grammar_ != nullptr; + } + bool parse_n(const char *s, size_t n, const char *path = nullptr) const { if (grammar_ != nullptr) { const auto &rule = (*grammar_)[start_]; - auto result = rule.parse(s, n, path, log_); + auto result = rule.parse(s, n, path, log_, error_reporter_); return post_process(s, n, result); } return false; @@ -5474,7 +6692,7 @@ public: const char *path = nullptr) const { if (grammar_ != nullptr) { const auto &rule = (*grammar_)[start_]; - auto result = rule.parse(s, n, dt, path, log_); + auto result = rule.parse(s, n, dt, path, log_, error_reporter_); return post_process(s, n, result); } return false; @@ -5485,7 +6703,8 @@ public: const char *path = nullptr) const { if (grammar_ != nullptr) { const auto &rule = (*grammar_)[start_]; - auto result = rule.parse_and_get_value(s, n, val, path, log_); + auto result = + rule.parse_and_get_value(s, n, val, path, log_, error_reporter_); return post_process(s, n, result); } return false; @@ -5496,7 +6715,8 @@ public: const char *path = nullptr) const { if (grammar_ != nullptr) { const auto &rule = (*grammar_)[start_]; - auto result = rule.parse_and_get_value(s, n, dt, val, path, log_); + auto result = + rule.parse_and_get_value(s, n, dt, val, path, log_, error_reporter_); return post_process(s, n, result); } return false; @@ -5613,6 +6833,12 @@ public: void set_logger(Log log) { log_ = log; } + // Receive structured error information instead of (or in addition to) the + // formatted string passed to the logger. + void set_error_reporter(ErrorReporter reporter) { + error_reporter_ = reporter; + } + void set_logger( std::function log) { @@ -5622,14 +6848,20 @@ public: private: bool post_process(const char *s, size_t n, Definition::Result &r) const { - if (log_ && !r.ret) { r.error_info.output_log(log_, s, n); } + if ((log_ || error_reporter_) && !r.ret) { + r.error_info.output_log(log_, error_reporter_, s, n); + } return r.ret && !r.recovered; } std::vector get_no_ast_opt_rules() const { std::vector rules; for (auto &[name, rule] : *grammar_) { - if (rule.no_ast_opt) { rules.push_back(name); } + // The optimizer keeps nodes by their emitted name, so honor the + // `ast_name` override when present (else the rule's own name). + if (rule.no_ast_opt) { + rules.push_back(rule.ast_name.empty() ? name : rule.ast_name); + } } return rules; } @@ -5639,6 +6871,7 @@ private: bool enableLeftRecursion_ = true; bool enablePackratParsing_ = false; Log log_; + ErrorReporter error_reporter_; }; /*----------------------------------------------------------------------------- diff --git a/libcockatrice_utility/libcockatrice/utility/playmat_params.h b/libcockatrice_utility/libcockatrice/utility/playmat_params.h new file mode 100644 index 000000000..c64a6f2ae --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/playmat_params.h @@ -0,0 +1,52 @@ +#ifndef COCKATRICE_PLAYMAT_PARAMS_H +#define COCKATRICE_PLAYMAT_PARAMS_H + +#include "card_ref.h" + +#include + +/** + * @struct PlaymatParams + * @ingroup Decks + * @brief Positioning parameters for a playmat card image. + * + * Controls how the cropped card art is positioned within the + * combined table+stack play area. The coordinate system is + * relative to the cropped art source image. + */ +struct PlaymatParams +{ + double marginPctL = 0.07; ///< Left margin as fraction of card width (0.0–0.95). + double marginPctR = 0.07; ///< Right margin as fraction of card width (0.0–0.95). + double verticalOffset = 0.33; ///< Vertical position within card (0.0=top, 1.0=bottom). + double zoom = 1.0; ///< Scale factor (0.1–4.0). + + bool operator==(const PlaymatParams &other) const + { + return qFuzzyCompare(marginPctL, other.marginPctL) && qFuzzyCompare(marginPctR, other.marginPctR) && + qFuzzyCompare(verticalOffset, other.verticalOffset) && qFuzzyCompare(zoom, other.zoom); + } + + bool operator!=(const PlaymatParams &other) const + { + return !(*this == other); + } +}; + +/** + * @struct PlaymatInfo + * @ingroup Decks + * @brief A resolved playmat (card + positioning parameters). + */ +struct PlaymatInfo +{ + CardRef card; ///< The card whose art is used as playmat. + PlaymatParams params; ///< Positioning parameters for the playmat card image. + + bool operator==(const PlaymatInfo &other) const + { + return card == other.card && params == other.params; + } +}; + +#endif // COCKATRICE_PLAYMAT_PARAMS_H diff --git a/libcockatrice_utility/libcockatrice/utility/report_categories.cpp b/libcockatrice_utility/libcockatrice/utility/report_categories.cpp new file mode 100644 index 000000000..f3576d7fd --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/report_categories.cpp @@ -0,0 +1,19 @@ +#include "report_categories.h" + +#include +#include + +namespace ReportCategories +{ +const QStringList &keys() +{ + static const QStringList categories = {"cheating", "bug_abuse", "harassment", "verbal_abuse", + "hate_speech", "spam", "other"}; + return categories; +} + +bool isValid(const QString &key) +{ + return keys().contains(key, Qt::CaseInsensitive); +} +} // namespace ReportCategories \ No newline at end of file diff --git a/libcockatrice_utility/libcockatrice/utility/report_categories.h b/libcockatrice_utility/libcockatrice/utility/report_categories.h new file mode 100644 index 000000000..5758c3dcf --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/report_categories.h @@ -0,0 +1,24 @@ +#ifndef REPORT_CATEGORIES_H +#define REPORT_CATEGORIES_H + +#include +#include + +/** + * Canonical report categories, shared by the client and the server. + * + * This is the single source of truth for the category keys exchanged over the + * wire (Command_Report.category) and stored in the reports table. The server + * only accepts keys from this list and the client report dialog only offers + * keys from this list, so the two sides cannot drift apart again. + */ +namespace ReportCategories +{ +/** @brief The complete list of valid category keys, in canonical order. */ +const QStringList &keys(); + +/** @brief Whether @a key is a valid report category (case-insensitive). */ +bool isValid(const QString &key); +} // namespace ReportCategories + +#endif // REPORT_CATEGORIES_H \ No newline at end of file diff --git a/libcockatrice_utility/libcockatrice/utility/server_rate_limiter.cpp b/libcockatrice_utility/libcockatrice/utility/server_rate_limiter.cpp index 2a1e3b283..50340c350 100644 --- a/libcockatrice_utility/libcockatrice/utility/server_rate_limiter.cpp +++ b/libcockatrice_utility/libcockatrice/utility/server_rate_limiter.cpp @@ -1,5 +1,9 @@ #include "server_rate_limiter.h" +#include +#include +#include + bool ServerRateLimiter::isRateLimited(const QString &host, const QDateTime &now) const { auto it = backoffUntil.constFind(host); diff --git a/libcockatrice_utility/libcockatrice/utility/server_rate_limiter.h b/libcockatrice_utility/libcockatrice/utility/server_rate_limiter.h index e071b9189..cae26983d 100644 --- a/libcockatrice_utility/libcockatrice/utility/server_rate_limiter.h +++ b/libcockatrice_utility/libcockatrice/utility/server_rate_limiter.h @@ -4,6 +4,7 @@ #include #include #include +#include /** * @class ServerRateLimiter diff --git a/libcockatrice_utility/libcockatrice/utility/string_limits.h b/libcockatrice_utility/libcockatrice/utility/string_limits.h index cca804bf0..5079ec46d 100644 --- a/libcockatrice_utility/libcockatrice/utility/string_limits.h +++ b/libcockatrice_utility/libcockatrice/utility/string_limits.h @@ -22,6 +22,19 @@ inline QString textFromStdString(const std::string &_string) { return QString::fromUtf8(_string.data(), std::min(int(_string.size()), MAX_TEXT_LENGTH)); } +/** @brief Returns a QString from a std::string, truncated to at most MAX_TEXT_LENGTH bytes, keeping the tail. */ +inline QString textTailFromStdString(const std::string &_string) +{ + if (int(_string.size()) <= MAX_TEXT_LENGTH) { + return QString::fromUtf8(_string.data(), int(_string.size())); + } + + int start = int(_string.size()) - MAX_TEXT_LENGTH; + while (start < int(_string.size()) && (static_cast(_string[start]) & 0xC0) == 0x80) { + ++start; + } + return QString::fromUtf8(_string.data() + start, int(_string.size()) - start); +} /** @brief Returns a QString from a std::string, truncated to at most MAX_FILE_LENGTH bytes. */ inline QString fileFromStdString(const std::string &_string) { diff --git a/libcockatrice_utility/libcockatrice/utility/translation_loader.cpp b/libcockatrice_utility/libcockatrice/utility/translation_loader.cpp new file mode 100644 index 000000000..41c6107b3 --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/translation_loader.cpp @@ -0,0 +1,176 @@ +#include "translation_loader.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Kept under the historical category name so existing QT_LOGGING_RULES filters keep working. +Q_LOGGING_CATEGORY(TranslationLoaderLog, "qt_translator") + +QList TranslationLoader::qtTranslationCandidates() +{ + QStringList candidates; + +#if QT_VERSION >= QT_VERSION_CHECK(6, 8, 0) + // Qt 6.8 exposes every path listed in qt.conf, path() only returns the first one. + candidates << QLibraryInfo::paths(QLibraryInfo::TranslationsPath); +#else + candidates << QLibraryInfo::path(QLibraryInfo::TranslationsPath); +#endif + + // A relocated Qt can report a prefix-relative path that does not exist, so probe the usual + // install layouts as well instead of trusting that single report. + const QString prefix = QLibraryInfo::path(QLibraryInfo::PrefixPath); + const QString dataPath = QLibraryInfo::path(QLibraryInfo::DataPath); + candidates << prefix + QStringLiteral("/share/qt6/translations") + << prefix + QStringLiteral("/share/qt/translations") << prefix + QStringLiteral("/translations") + << dataPath + QStringLiteral("/translations"); + + QList uniqueCandidates; + for (const QString &candidate : candidates) { + if (!candidate.isEmpty() && !uniqueCandidates.contains(candidate)) { + uniqueCandidates << candidate; + } + } + return uniqueCandidates; +} + +QList TranslationLoader::qtTranslationPaths() +{ + QList existingPaths; + for (const QString &candidate : qtTranslationCandidates()) { + if (QFileInfo::exists(candidate)) { + existingPaths << candidate; + } + } + return existingPaths; +} + +QList TranslationLoader::applicationTranslationPaths(const QString &_installedPath) +{ + // A build tree keeps the .qm files next to the executable, the installed layout puts them in + // _installedPath. Searching both keeps a developer build translated as well. + const QString executableDirectory = QCoreApplication::applicationDirPath(); + + QList paths; + for (const QString &path : {_installedPath, executableDirectory}) { + if (!path.isEmpty() && !paths.contains(path)) { + paths << path; + } + } + return paths; +} + +QList TranslationLoader::availableLanguages(const QString &_prefix, const QList &_directories) +{ + const QRegularExpression filePattern( + QRegularExpression::anchoredPattern(QRegularExpression::escape(_prefix) + QStringLiteral("_(.*)\\.qm"))); + + QSet languages; + for (const QString &directory : _directories) { + if (directory.isEmpty()) { + continue; + } + const QDir dir(directory); + for (const QString &fileName : dir.entryList(QDir::Files)) { + const auto match = filePattern.match(fileName); + if (match.hasMatch()) { + languages.insert(match.captured(1)); + } + } + } + + QList sortedLanguages(languages.cbegin(), languages.cend()); + std::sort(sortedLanguages.begin(), sortedLanguages.end()); + return sortedLanguages; +} + +TranslationLoadResult +TranslationLoader::loadFrom(QTranslator &_translator, const QString &_nameHint, const QList &_directories) +{ + bool anyDirectoryExists = false; + + for (const QString &directory : _directories) { + if (directory.isEmpty() || !QFileInfo::exists(directory)) { + continue; + } + anyDirectoryExists = true; + if (_translator.load(_nameHint, directory)) { + return TranslationLoadResult::Loaded; + } + } + + return anyDirectoryExists ? TranslationLoadResult::Missing : TranslationLoadResult::NoDirectory; +} + +TranslationLoadResult TranslationLoader::loadQt(QTranslator &_translator, const QString &_lang) +{ + // An unset language is the default for English, which has no translation file to begin with. + if (_lang.isEmpty()) { + return TranslationLoadResult::Missing; + } + + const QString nameHint = QStringLiteral("qt_") + _lang; + const auto result = loadFrom(_translator, nameHint, qtTranslationPaths()); + + switch (result) { + case TranslationLoadResult::Loaded: + qCInfo(TranslationLoaderLog) << "Loaded Qt translation" << nameHint << "at" << _translator.filePath(); + break; + case TranslationLoadResult::Missing: + qCDebug(TranslationLoaderLog) + << "No Qt translation" << nameHint << "in" << QStringList(qtTranslationPaths()); + break; + case TranslationLoadResult::NoDirectory: + qCWarning(TranslationLoaderLog) + << "No Qt translations directory exists, looked in" << QStringList(qtTranslationPaths()); + break; + } + + return result; +} + +TranslationLoadResult TranslationLoader::loadApplication(QTranslator &_translator, + const QString &_prefix, + const QString &_lang, + const QList &_directories) +{ + if (_lang.isEmpty()) { + return TranslationLoadResult::Missing; + } + + const QString nameHint = _prefix + QLatin1Char('_') + _lang; + const auto result = loadFrom(_translator, nameHint, _directories); + + switch (result) { + case TranslationLoadResult::Loaded: + qCInfo(TranslationLoaderLog) << "Loaded" << _prefix << "translation" << nameHint << "at" + << _translator.filePath(); + break; + case TranslationLoadResult::Missing: + qCDebug(TranslationLoaderLog) + << "No" << _prefix << "translation" << nameHint << "in" << QStringList(_directories); + break; + case TranslationLoadResult::NoDirectory: + qCWarning(TranslationLoaderLog) + << "No translation directory exists, looked in" << QStringList(_directories); + break; + } + + return result; +} diff --git a/libcockatrice_utility/libcockatrice/utility/translation_loader.h b/libcockatrice_utility/libcockatrice/utility/translation_loader.h new file mode 100644 index 000000000..645489696 --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/translation_loader.h @@ -0,0 +1,92 @@ +#ifndef TRANSLATION_LOADER_H +#define TRANSLATION_LOADER_H + +#include +#include +#include + +class QTranslator; + +Q_DECLARE_LOGGING_CATEGORY(TranslationLoaderLog) + +/** @brief The outcome of a translation load attempt. */ +enum class TranslationLoadResult +{ + Loaded, /**< A translation file was found and loaded into the translator. */ + Missing, /**< The search directories exist but hold no file for this language. */ + NoDirectory /**< Not a single search directory exists, so no lookup could be attempted. */ +}; + +namespace TranslationLoader +{ +/** + * @brief Every directory that may hold Qt's own translations, in order of preference. + * + * More than one entry is needed because a relocated Qt (Flatpak, AppImage, a bundled SDK) can report + * a prefix-relative translations path that does not exist: the KDE runtime reports /usr/translations + * while its qt_*.qm files live in /usr/share/qt6/translations. + */ +QList qtTranslationCandidates(); + +/** + * @brief The candidates that actually exist, most likely first, so no caller searches a path that is + * not there. + */ +QList qtTranslationPaths(); + +/** + * @brief The directories to search for an application's own translation files, in order of preference. + * + * @param _installedPath The location the platform installs the translation files in, derived from the + * executable's path. + * @return The installed location, followed by the executable's own directory: a build tree keeps the + * .qm files next to the binary instead of in the installed layout, so a developer build would + * otherwise find no translations at all. + */ +QList applicationTranslationPaths(const QString &_installedPath); + +/** + * @brief Loads the first _nameHint + language translation file found in _directories into _translator. + * + * QTranslator discards whatever it held before every load, so each translation file needs its own + * translator; loading a second file into the first one silently drops the first. + * + * @param _translator The translator to load into. + * @param _nameHint The translation file name without its language and suffix, e.g. "qt_de" or "cockatrice_de". + * @param _directories The directories to search, in order of preference. + * @return What the search found. + */ +TranslationLoadResult loadFrom(QTranslator &_translator, const QString &_nameHint, const QList &_directories); + +/** + * @brief Loads Qt's own translation for _lang, which covers the strings of Qt's own dialogs. + * + * @param _translator The translator to load into; must not also be used for an application translation. + * @param _lang The language code, e.g. "de". An empty language loads nothing. + * @return What the search found. + */ +TranslationLoadResult loadQt(QTranslator &_translator, const QString &_lang); + +/** + * @brief The language codes of the <_prefix>_.qm files in _directories, sorted by code. + * + * Lets a language selector offer exactly the languages that can be loaded. + */ +QList availableLanguages(const QString &_prefix, const QList &_directories); + +/** + * @brief Loads the <_prefix>_<_lang>.qm translation file from _directories into _translator. + * + * @param _translator The translator to load into; must not also be used for Qt's own translation. + * @param _prefix The translation file prefix, e.g. "cockatrice" or "oracle". + * @param _lang The language code, e.g. "de". An empty language loads nothing. + * @param _directories The directories to search, in order of preference. + * @return What the search found. + */ +TranslationLoadResult loadApplication(QTranslator &_translator, + const QString &_prefix, + const QString &_lang, + const QList &_directories); +} // namespace TranslationLoader + +#endif // TRANSLATION_LOADER_H diff --git a/libcockatrice_utility/libcockatrice/utility/warning_categories.cpp b/libcockatrice_utility/libcockatrice/utility/warning_categories.cpp new file mode 100644 index 000000000..ad0fc86f0 --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/warning_categories.cpp @@ -0,0 +1,28 @@ +#include "warning_categories.h" + +#include +#include +#include + +QList parseWarningCategories(const QString &value) +{ + QList categories; + const QStringList entries = value.split(',', Qt::SkipEmptyParts); + for (const QString &entry : entries) { + const QStringList parts = entry.split('|'); + WarningCategory category; + category.name = parts.first().trimmed(); + if (category.name.isEmpty()) { + continue; + } + if (parts.size() > 1) { + bool ok = false; + const int il = parts.at(1).trimmed().toInt(&ok); + if (ok && il > 0) { + category.startingIl = il; + } + } + categories.append(category); + } + return categories; +} diff --git a/libcockatrice_utility/libcockatrice/utility/warning_categories.h b/libcockatrice_utility/libcockatrice/utility/warning_categories.h new file mode 100644 index 000000000..ce5308cf4 --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/warning_categories.h @@ -0,0 +1,28 @@ +#ifndef WARNING_CATEGORIES_H +#define WARNING_CATEGORIES_H + +#include +#include + +/** + * A warning category the server offers to moderators, optionally carrying a + * recommended starting intervention level (see the moderator guide). + */ +struct WarningCategory +{ + QString name; + int startingIl = 1; +}; + +/** + * Parses the `server/officialwarnings` setting value into warning categories. + * + * Entries are separated by commas. Each entry is a category name, optionally + * followed by "|" and the recommended starting intervention level: + * "Abusive Language|1,Cheating|2,Spamming" + * Entries without an explicit level default to intervention level 1. + * Empty entries are skipped. + */ +QList parseWarningCategories(const QString &value); + +#endif // WARNING_CATEGORIES_H diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index 0736db7f5..0df345812 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -23,15 +23,14 @@ set(oracle_SOURCES src/pages.cpp src/pagetemplates.cpp src/parsehelpers.cpp - src/qt-json/json.cpp + src/raw_json_scanner.cpp ../cockatrice/src/client/settings/cache_settings.cpp ../cockatrice/src/client/settings/card_counter_settings.cpp ../cockatrice/src/client/settings/shortcuts_settings.cpp ../cockatrice/src/client/network/update/client/release_channel.cpp + ../cockatrice/src/interface/pixel_map_generator.cpp ../cockatrice/src/interface/theme_config.cpp ../cockatrice/src/interface/theme_manager.cpp - ../cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp - ../cockatrice/src/interface/widgets/quick_settings/settings_popup_widget.cpp ${VERSION_STRING_CPP} ) @@ -80,6 +79,9 @@ if(ZLIB_FOUND) include_directories(${ZLIB_INCLUDE_DIRS}) add_definitions("-DHAS_ZLIB") list(APPEND oracle_SOURCES src/zip/unzip.cpp src/zip/zipglobal.cpp) + # minizip and zlib are vendored. + cockatrice_disable_sanitizers_for(src/zip/unzip.cpp src/zip/zipglobal.cpp) + cockatrice_disable_coverage_for(src/zip/unzip.cpp src/zip/zipglobal.cpp) else() message(STATUS "Oracle: zlib not found; ZIP support disabled") endif() @@ -90,6 +92,9 @@ if(LIBLZMA_FOUND) include_directories(${LIBLZMA_INCLUDE_DIRS}) add_definitions("-DHAS_LZMA") list(APPEND oracle_SOURCES src/lzma/decompress.cpp) + # The LZMA SDK is vendored. + cockatrice_disable_sanitizers_for(src/lzma/decompress.cpp) + cockatrice_disable_coverage_for(src/lzma/decompress.cpp) else() message(STATUS "Oracle: LibLZMA not found; xz support disabled") endif() @@ -113,6 +118,8 @@ qt6_add_executable( MANUAL_FINALIZATION ) +target_precompile_headers(oracle PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtwidgets_pch.h") + # ------------------------ # Link libraries # ------------------------ @@ -120,10 +127,14 @@ target_link_libraries( oracle PUBLIC libcockatrice_card PUBLIC libcockatrice_settings - PUBLIC libcockatrice_network + PUBLIC libcockatrice_protocol PUBLIC ${ORACLE_QT_MODULES} ) +# Only user_level.h is needed from the network headers (pulled in transitively +# by pixel_map_generator.h); nothing from the network libraries is linked. +target_include_directories(oracle PRIVATE ${CMAKE_SOURCE_DIR}/libcockatrice_network) + if(ZLIB_FOUND) target_link_libraries(oracle PUBLIC ${ZLIB_LIBRARIES}) endif() @@ -210,10 +221,19 @@ if(WIN32) list(APPEND libSearchDirs ${QT_LIBRARY_DIR}) install( - DIRECTORY "${CMAKE_BINARY_DIR}/${PROJECT_NAME}/${CMAKE_BUILD_TYPE}/" + DIRECTORY "$/" DESTINATION ./ FILES_MATCHING PATTERN "*.dll" + PATTERN "*.pdb" EXCLUDE + PATTERN "*.dir*" EXCLUDE + PATTERN "*_autogen*" EXCLUDE + PATTERN "*.tlog*" EXCLUDE + PATTERN "CMakeFiles*" EXCLUDE + PATTERN "x64*" EXCLUDE + PATTERN ".qt*" EXCLUDE + PATTERN ".qsb*" EXCLUDE + PATTERN ".lupdate*" EXCLUDE ) # Qt plugins: iconengines, platforms, styles, tls (Qt6) @@ -285,6 +305,18 @@ if(Qt6LinguistTools_FOUND) elseif(WIN32) install(FILES ${oracle_QM} DESTINATION ${ORACLE_WIN32_QM_INSTALL_DIR}) endif() + + # Qt attaches the .qm files to the default build target only, so building the executable on its own + # (CLion, --target oracle) leaves it without any translations: nothing is translated and the + # language selector in the wizard stays empty. + # Depend on Qt's own lrelease target instead of attaching the .qm custom commands to a second + # target: two independent targets owning the same outputs race on MSBuild/Makefiles and are an + # error on Xcode. Older Qt versions already add this dependency themselves. + foreach(_lrelease_target Oracle_lrelease oracle_lrelease) + if(TARGET ${_lrelease_target}) + add_dependencies(oracle ${_lrelease_target}) + endif() + endforeach() endif() qt6_finalize_target(oracle) diff --git a/oracle/oracle_en@source.ts b/oracle/oracle_en@source.ts index b3263f861..09020f02c 100644 --- a/oracle/oracle_en@source.ts +++ b/oracle/oracle_en@source.ts @@ -4,22 +4,22 @@ IntroPage - + Introduction - + This wizard will import the list of sets, cards, and tokens that will be used by Cockatrice. - + Interface language: - + Version: @@ -27,192 +27,206 @@ LoadSetsPage - + Source selection - + Please specify a compatible source for the list of sets and cards. You can specify a URL address that will be downloaded or use an existing file from your computer. - + Download URL: - + Local file: - + Restore default URL - + Choose file... - + Load sets file - + Sets file (%1) Sets JSON file (%1) - - - - - - - + + + + + Error - + The provided URL is not valid. - - Downloading (0MB) - - - - + Please choose a file. - + Cannot open file '%1'. - - Downloading (%1MB) - - - - + Network error: %1. - + Parsing file - + Xz extraction failed. - + Sorry, this version of Oracle does not support xz compressed files. - + Failed to open Zip archive: %1. - + Zip extraction failed: the Zip archive doesn't contain exactly one file. - + Zip extraction failed: %1. - + Sorry, this version of Oracle does not support zipped files. - + Failed to interpret downloaded data. - + + Downloading (0 MB) + + + + + Downloading (%1 MB) + + + + + Parsing file (%1%) + + + + + Extracting file... + + + + Do you want to download the uncompressed file instead? - + + The file was retrieved successfully, but it does not contain any sets data. + + + Parsing file (100%) + + LoadSpoilersPage - + Save spoiler database - + XML; spoiler database (*.xml) - + spoiler - + Spoilers import - + Please specify a compatible source for spoiler data. - + Download URL: - + Local file: - + Restore default URL - + Choose file... - + The spoiler database will be saved at the following location: - + Save to a custom path (not recommended) @@ -220,57 +234,57 @@ LoadTokensPage - + Save token database - + XML; token database (*.xml) - + tokens - + Tokens import - + Please specify a compatible source for token data. - + Download URL: - + Local file: - + Restore default URL - + Choose file... - + The token database will be saved at the following location: - + Save to a custom path (not recommended) @@ -278,7 +292,7 @@ OracleImporter - + Dummy set containing tokens @@ -286,7 +300,7 @@ OracleWizard - + Oracle Importer @@ -294,22 +308,22 @@ OutroPage - + Finished - + The wizard has finished. - + You can now start using Cockatrice with the newly updated cards. - + If the card databases don't reload automatically, restart the Cockatrice client. @@ -317,73 +331,76 @@ SaveSetsPage - - + + Error - + + No set has been imported. - + Sets imported - - A cockatrice database file of %1 MB has been downloaded. + + A Cockatrice card database file of %1 MB has been downloaded. - + The following sets have been found: - + Press "Save" to store the imported cards in the Cockatrice database. - + The card database will be saved at the following location: - + Save to a custom path (not recommended) - + &Save - + + Import finished: %1 cards. - + + %1: %2 cards imported - + Save card database - + XML; card database (*.xml) - + The file could not be saved to %1 @@ -416,7 +433,8 @@ - Downloading (0MB) + Downloading (0 MB) + Downloading (0MB) @@ -589,7 +607,7 @@ i18n - + English @@ -597,12 +615,12 @@ main - + Only run in spoiler mode - + Run in no-confirm background mode diff --git a/oracle/src/main.cpp b/oracle/src/main.cpp index bb88153b1..cae2ee37d 100644 --- a/oracle/src/main.cpp +++ b/oracle/src/main.cpp @@ -5,12 +5,16 @@ #include <../../cockatrice/src/client/settings/cache_settings.h> #include +#include #include +#include +#include #include -#include +#include #include #include #include +#include QTranslator *translator, *qtTranslator; ThemeManager *themeManager; @@ -24,24 +28,13 @@ void installNewTranslator() { QString lang = SettingsCache::instance().personal().getLang(); - QString qtNameHint = "qt_" + lang; - QString qtTranslationPath = QLibraryInfo::path(QLibraryInfo::TranslationsPath); - - bool qtTranslationLoaded = qtTranslator->load(qtNameHint, qtTranslationPath); - if (!qtTranslationLoaded) { - qDebug() << "Unable to load qt translation" << qtNameHint << "at" << qtTranslationPath; - } else { - qDebug() << "Loaded qt translation" << qtNameHint << "at" << qtTranslationPath; - } + // Qt's own strings and ours need a translator each: QTranslator discards its previous contents + // on every load, so loading both files into one translator silently drops the first of them. + TranslationLoader::loadQt(*qtTranslator, lang); qApp->installTranslator(qtTranslator); - QString appNameHint = translationPrefix + "_" + lang; - bool appTranslationLoaded = qtTranslator->load(appNameHint, translationPath); - if (!appTranslationLoaded) { - qDebug() << "Unable to load" << translationPrefix << "translation" << appNameHint << "at" << translationPath; - } else { - qDebug() << "Loaded" << translationPrefix << "translation" << appNameHint << "at" << translationPath; - } + TranslationLoader::loadApplication(*translator, translationPrefix, lang, + TranslationLoader::applicationTranslationPaths(translationPath)); qApp->installTranslator(translator); } @@ -50,8 +43,8 @@ int main(int argc, char *argv[]) QApplication app(argc, argv); QCoreApplication::setOrganizationName("Cockatrice"); - QCoreApplication::setOrganizationDomain("cockatrice"); - // this can't be changed, as it influences the default save path for cards.xml + QCoreApplication::setOrganizationDomain("Cockatrice"); + // This can't be changed, as it influences the default save path for cards.xml QCoreApplication::setApplicationName("Cockatrice"); // If the program is opened with the -s flag, it will only do spoilers. Otherwise it will do MTGJSON/Tokens @@ -82,7 +75,8 @@ int main(int argc, char *argv[]) QIcon icon("theme:appicon.svg"); wizard.setWindowIcon(icon); - // set name of the app desktop file; used by wayland to load the window icon + // Base name of the installed oracle.desktop; wayland reads the window icon from it and + // xdg-desktop-portal registers it as the app ID, so the case has to match the file name. QGuiApplication::setDesktopFileName("oracle"); wizard.show(); diff --git a/oracle/src/oracleimporter.cpp b/oracle/src/oracleimporter.cpp index a9008c4da..3382e36d6 100644 --- a/oracle/src/oracleimporter.cpp +++ b/oracle/src/oracleimporter.cpp @@ -1,16 +1,35 @@ #include "oracleimporter.h" -#include "libcockatrice/interfaces/noop_card_preference_provider.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/format/format_legality_rules.h" +#include "libcockatrice/card/lazy_properties_hash.h" +#include "libcockatrice/card/relation/card_relation_type.h" #include "libcockatrice/interfaces/noop_card_set_priority_controller.h" #include "parsehelpers.h" -#include "qt-json/json.h" +#include #include +#include +#include +#include +#include +#include +#include +#include #include +#include +#include +#include +#include +#include #include #include +#include #include #include +#include + +class ICardSetPriorityController; static const QList kConstructedCounts = {{4, "legal"}, {0, "banned"}}; @@ -19,8 +38,9 @@ static const QList kSingletonCounts = {{1, "legal"}, {0, "banned"} SplitCardPart::SplitCardPart(const QString &_name, const QString &_text, const QHash &_properties, - const PrintingInfo &_printingInfo) - : name(_name), text(_text), properties(_properties), printingInfo(_printingInfo) + const PrintingInfo &_printingInfo, + const QString &_localizedText) + : name(_name), text(_text), localizedText(_localizedText), properties(_properties), printingInfo(_printingInfo) { } @@ -30,6 +50,42 @@ OracleImporter::OracleImporter(QObject *parent) : QObject(parent) { } +void OracleImporter::setCardLang(const QString &lang) +{ + cardLang = lang.trimmed().toLower(); + localizationEnabled = cardLang != "en" && CardLocalization::supportedLanguages().contains(cardLang); +} + +/** + * @brief Maps the MTGJSON foreignData language names to the short codes used by + * Scryfall and stored in cards.xml (e.g. "German" -> "de"). + * @param language The language name found in the MTGJSON foreignData entries. + * @return The short language code, or an empty string if unknown. + */ +static QString mtgjsonLanguageToCode(const QString &language) +{ + static const QHash map = { + {"Chinese Simplified", "zhs"}, + {"Chinese Traditional", "zht"}, + {"English", "en"}, + {"French", "fr"}, + {"German", "de"}, + {"Greek", "grc"}, + {"Ancient Greek", "grc"}, + {"Hebrew", "he"}, + {"Italian", "it"}, + {"Japanese", "ja"}, + {"Korean", "ko"}, + {"Latin", "la"}, + {"Phyrexian", "ph"}, + {"Portuguese (Brazil)", "pt"}, + {"Russian", "ru"}, + {"Sanskrit", "sa"}, + {"Spanish", "es"}, + }; + return map.value(language); +} + static CardSet::Priority getSetPriority(const QString &setType, const QString &shortName) { if (!setTypePriorities.contains(setType.toLower())) { @@ -42,31 +98,37 @@ static CardSet::Priority getSetPriority(const QString &setType, const QString &s return priority; } -bool OracleImporter::readSetsFromByteArray(const QByteArray &data) +bool OracleImporter::readSetsFromByteArray(QByteArray data) { - bool ok; - auto setsMap = QtJson::Json::parse(QString(data), ok).toMap().value("data").toMap(); - if (!ok) { - qDebug() << "error: QtJson::Json::parse()"; + const RawJson::ScanProgressCallback progress = + progressReporting + ? [this]( + qsizetype bytesRead, + qsizetype + totalBytes) { emit dataReadProgress(static_cast(bytesRead), static_cast(totalBytes)); } + : RawJson::ScanProgressCallback{}; + + RawJson::ScanError error; + const QList ranges = RawJson::scanSetRanges(data, &error, progress); + if (error.isError()) { + qDebug() << "error: RawJson::scanSetRanges():" << error.message; return false; } QList newSetList; + newSetList.reserve(ranges.size()); - QListIterator it(setsMap.values()); - - while (it.hasNext()) { - QVariantMap map = it.next().toMap(); - QString shortName = map.value("code").toString().toUpper(); - QString longName = map.value("name").toString(); - QList setCards = map.value("cards").toList(); - QString setType = map.value("type").toString(); - QDate releaseDate = map.value("releaseDate").toDate(); + for (const RawJson::SetRange &range : ranges) { + QString shortName = range.code.toUpper(); + QString longName = range.name; + QString setType = range.type; + QDate releaseDate = QDate::fromString(range.releaseDate, Qt::ISODate); CardSet::Priority priority = getSetPriority(setType, shortName); // capitalize set type if (setType.length() > 0) { // basic grammar for words that aren't capitalized, like in "From the Vault" - const QStringList noCapitalize = {"the", "a", "an", "on", "to", "for", "of", "in", "and", "with", "or"}; + static const QStringList noCapitalize = {"the", "a", "an", "on", "to", "for", + "of", "in", "and", "with", "or"}; QStringList words = setType.split("_"); setType.clear(); bool first = false; @@ -74,13 +136,15 @@ bool OracleImporter::readSetsFromByteArray(const QByteArray &data) if (first && noCapitalize.contains(item)) { setType += item + QString(" "); } else { - setType += item[0].toUpper() + item.mid(1, -1) + QString(" "); + setType += item[0].toUpper() + item.mid(1) + QString(" "); first = true; } } setType = setType.trimmed(); } - newSetList.append(SetToDownload(shortName, longName, setCards, priority, setType, releaseDate)); + SetToDownload set(shortName, longName, priority, setType, releaseDate); + set.setRawRange(range.dataRange); + newSetList.append(set); } std::sort(newSetList.begin(), newSetList.end()); @@ -89,19 +153,34 @@ bool OracleImporter::readSetsFromByteArray(const QByteArray &data) return false; } allSets = newSetList; + rawSetsData = std::move(data); return true; } +/** + * The priority order used to pick a card's main type when a card has multiple + * types (e.g. "Artifact Creature") or multiple faces (e.g. split/adventure cards). + * A lower index means a higher priority. + */ +static const QStringList MAIN_CARD_TYPE_PRIORITY = {"Planeswalker", "Creature", "Land", "Sorcery", + "Instant", "Artifact", "Enchantment"}; + +/** + * Returns the priority (index) of the given main card type. Known types map to their + * position in {@link mainCardTypePriority()}, unknown types map to -1 (lowest priority). + */ +static int mainCardTypePriority(const QString &mainCardType) +{ + return MAIN_CARD_TYPE_PRIORITY.indexOf(mainCardType); +} + static QString getMainCardType(const QStringList &typeList) { if (typeList.isEmpty()) { return {}; } - static const QStringList typePriority = {"Planeswalker", "Creature", "Land", "Sorcery", - "Instant", "Artifact", "Enchantment"}; - - for (const auto &type : typePriority) { + for (const auto &type : MAIN_CARD_TYPE_PRIORITY) { if (typeList.contains(type)) { return type; } @@ -122,14 +201,8 @@ static void sortAndReduceColors(QString &colors) std::sort(colors.begin(), colors.end(), [](const QChar a, const QChar b) { return colorOrder.value(a, INT_MAX) < colorOrder.value(b, INT_MAX); }); // reduce - QChar lastChar = '\0'; - for (int i = 0; i < colors.size(); ++i) { - if (colors.at(i) == lastChar) { - colors.remove(i, 1); - } else { - lastChar = colors.at(i); - } - } + auto last = std::unique(colors.begin(), colors.end()); + colors.erase(last, colors.end()); } CardInfoPtr OracleImporter::addCard(QString name, @@ -142,9 +215,12 @@ CardInfoPtr OracleImporter::addCard(QString name, // Workaround for card name weirdness name = name.replace("Æ", "AE"); name = name.replace("’", "'"); - if (cards.contains(name)) { - CardInfoPtr card = cards.value(name); + auto existingIt = cards.constFind(name); + if (existingIt != cards.constEnd()) { + CardInfoPtr card = existingIt.value(); card->addToSet(printingInfo.getSet(), printingInfo); + // Only merge legalities when the card has none yet, so multi-format + // printings don't overwrite each other's legality lists. if (card->getProperties().filter(formatRegex).empty()) { card->combineLegalities(properties); } @@ -182,8 +258,9 @@ CardInfoPtr OracleImporter::addCard(QString name, // DETECT CARD POSITIONING INFO - bool landscapeOrientation = properties.value("maintype") == "Battle" || properties.value("layout") == "split" || - properties.value("layout") == "planar"; + QString layoutVal = properties.value("layout"); + bool landscapeOrientation = + properties.value("maintype") == "Battle" || layoutVal == "split" || layoutVal == "planar"; // cards that enter the field tapped bool cipt = parseCipt(name, text) || landscapeOrientation; @@ -222,12 +299,110 @@ CardInfoPtr OracleImporter::addCard(QString name, return newCard; } -static QString getStringPropertyFromMap(const QVariantMap &card, const QString &propertyName) +static QString getJsonString(const QJsonObject &obj, const QString &key) { - return card.contains(propertyName) ? card.value(propertyName).toString() : QString(""); + // QVariant coerces numbers to text, while QJsonValue::toString() returns a + // null string for them — MTGJSON fields like manaValue and convertedManaCost + // carry those types. + return obj.value(key).toVariant().toString(); } -int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList &cardsList) +static QString normalizeCardName(QString name) +{ + // Mirror of the name cleanup applied in addCard(), so collected localization + // keys line up with the card map keys (Æ → AE, curly apostrophe → straight). + name = name.replace("Æ", "AE"); + name = name.replace("’", "'"); + return name; +} + +static QString matchingForeignEntryText(const QJsonObject &card, const QString &cardLang) +{ + // Multi-face cards (split/aftermath/adventure/prepare) expose each face as a + // separate card object, each with its own foreignData entry carrying that + // face's rules text; single-face cards carry the full text in one entry. + const QJsonArray foreignData = card.value("foreignData").toArray(); + for (const QJsonValue &entryValue : foreignData) { + const QJsonObject entry = entryValue.toObject(); + // MTGJSON reports languages by long-form name ("German"); match the + // short code ("de") that Scryfall and cards.xml use. + if (mtgjsonLanguageToCode(getJsonString(entry, "language")) == cardLang) { + return getJsonString(entry, "text"); + } + } + return QString(); +} + +void OracleImporter::collectForeignData(const QString &cardKey, + const CardSetPtr ¤tSet, + const QJsonObject &card, + bool collectText) +{ + if (!localizationEnabled) { + return; + } + + LocalizedCardEntry incoming; + bool found = false; + const QJsonArray foreignData = card.value("foreignData").toArray(); + for (const QJsonValue &entryValue : foreignData) { + const QJsonObject entry = entryValue.toObject(); + // MTGJSON reports languages by long-form name ("German"); match the + // short code ("de") that Scryfall and cards.xml use. + if (mtgjsonLanguageToCode(getJsonString(entry, "language")) != cardLang) { + continue; + } + incoming.name = getJsonString(entry, "name"); + incoming.text = collectText ? getJsonString(entry, "text") : QString(); + found = true; + break; + } + if (!found) { + return; + } + + // Prefer the entry from the highest-priority set (lower enum value = more + // authoritative); printings of equal priority keep the first one seen. + incoming.priority = currentSet->getPriority(); + const auto existing = localizedEntries.constFind(cardKey); + if (existing == localizedEntries.constEnd() || incoming.priority < existing->priority) { + localizedEntries.insert(cardKey, incoming); + } +} + +void OracleImporter::applyLocalizedData() +{ + if (!localizationEnabled) { + return; + } + for (auto it = localizedEntries.constBegin(); it != localizedEntries.constEnd(); ++it) { + CardInfoPtr card = cards.value(it.key()); + if (card.isNull()) { + continue; + } + const LocalizedCardEntry &entry = it.value(); + if (!entry.name.isEmpty()) { + card->setLocalizedName(cardLang, entry.name); + } + if (!entry.text.isEmpty()) { + card->setLocalizedText(cardLang, entry.text); + } + } + localizedEntries.clear(); + + for (auto it = splitLocalizedTexts.constBegin(); it != splitLocalizedTexts.constEnd(); ++it) { + CardInfoPtr card = cards.value(it.key()); + if (card.isNull()) { + continue; + } + if (!it.value().text.isEmpty()) { + card->setLocalizedText(cardLang, it.value().text); + } + } + splitLocalizedTexts.clear(); +} + +int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QJsonArray &cardsList) { // mtgjson name => xml name static const QMap cardProperties{ @@ -238,14 +413,14 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList // mtgjson name => xml name static const QMap setInfoProperties{ - {"number", "num"}, {"rarity", "rarity"}, {"isOnlineOnly", "isOnlineOnly"}, {"isRebalanced", "isRebalanced"}}; + {"number", "num"}, {"rarity", "rarity"}, {"artist", "artist"}}; // mtgjson name => xml name static const QMap identifierProperties{{"multiverseId", "muid"}, {"scryfallId", "uuid"}}; static const QString ptSeparator = "/"; static constexpr bool isToken = false; - static const QList setsWithCardsWithSameNameButDifferentText = {"UST"}; + static const QSet setsWithCardsWithSameNameButDifferentText = {"UST"}; int numCards = 0; @@ -253,16 +428,16 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList QMap, QString>> splitCards; // Keeps track of all names encountered so far - QList allNameProps; + QSet allNameProps; - for (const QVariant &cardVar : cardsList) { - QVariantMap card = cardVar.toMap(); + for (const QJsonValue &cardVal : cardsList) { + QJsonObject card = cardVal.toObject(); /* Currently used layouts are: * augment, double_faced_token, flip, host, leveler, meld, normal, planar, * saga, scheme, split, token, transform, vanguard */ - QString layout = getStringPropertyFromMap(card, "layout"); + QString layout = getJsonString(card, "layout"); // don't import tokens from the json file if (layout == "token") { @@ -270,9 +445,9 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList } // normal cards handling - QString name = getStringPropertyFromMap(card, "name"); - QString text = getStringPropertyFromMap(card, "text"); - QString faceName = getStringPropertyFromMap(card, "faceName"); + QString name = getJsonString(card, "name"); + QString text = getJsonString(card, "text"); + QString faceName = getJsonString(card, "faceName"); if (faceName.isEmpty()) { faceName = name; } @@ -280,43 +455,38 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList // card properties QHash properties; for (auto i = cardProperties.cbegin(), end = cardProperties.cend(); i != end; ++i) { - QString mtgjsonProperty = i.key(); - QString xmlPropertyName = i.value(); - QString propertyValue = getStringPropertyFromMap(card, mtgjsonProperty); + QString propertyValue = getJsonString(card, i.key()); if (!propertyValue.isEmpty()) { - properties.insert(xmlPropertyName, propertyValue); + properties.insert(i.value(), propertyValue); } } // per-set properties QHash printingProps; for (auto i = setInfoProperties.cbegin(), end = setInfoProperties.cend(); i != end; ++i) { - QString mtgjsonProperty = i.key(); - QString xmlPropertyName = i.value(); - QString propertyValue = getStringPropertyFromMap(card, mtgjsonProperty); + QString propertyValue = getJsonString(card, i.key()); if (!propertyValue.isEmpty()) { - printingProps.insert(xmlPropertyName, propertyValue); + printingProps.insert(i.value(), propertyValue); } } // handle flavorNames specially due to double-faced cards - QString faceFlavorName = getStringPropertyFromMap(card, "faceFlavorName"); - QString flavorName = !faceFlavorName.isEmpty() ? faceFlavorName : getStringPropertyFromMap(card, "flavorName"); + QString faceFlavorName = getJsonString(card, "faceFlavorName"); + QString flavorName = !faceFlavorName.isEmpty() ? faceFlavorName : getJsonString(card, "flavorName"); if (!flavorName.isEmpty()) { printingProps.insert("flavorName", flavorName); } // Identifiers + QJsonObject identifiers = card.value("identifiers").toObject(); for (auto i = identifierProperties.cbegin(), end = identifierProperties.cend(); i != end; ++i) { - QString mtgjsonProperty = i.key(); - QString xmlPropertyName = i.value(); - QString propertyValue = getStringPropertyFromMap(card.value("identifiers").toMap(), mtgjsonProperty); + QString propertyValue = getJsonString(identifiers, i.key()); if (!propertyValue.isEmpty()) { - printingProps.insert(xmlPropertyName, propertyValue); + printingProps.insert(i.value(), propertyValue); } } - PrintingInfo printingInfo(currentSet, printingProps); + PrintingInfo printingInfo(currentSet, LazyPropertiesHash(printingProps)); QString numComponent; const QString numProperty = printingInfo.getProperty("num"); @@ -328,21 +498,26 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList allNameProps.contains(faceName) && layout == "normal" && lastChar.isLetter()) { numComponent = " (" + QString(lastChar).toLower() + ")"; } - allNameProps.append(faceName); + allNameProps.insert(faceName); // special handling properties - QString colors = card.value("colors").toStringList().join(""); + QString colors; + for (const QJsonValue &color : card.value("colors").toArray()) { + colors += color.toString(); + } if (!colors.isEmpty()) { properties.insert("colors", colors); } - // special handling properties - QString colorIdentity = card.value("colorIdentity").toStringList().join(""); + QString colorIdentity; + for (const QJsonValue &color : card.value("colorIdentity").toArray()) { + colorIdentity += color.toString(); + } if (!colorIdentity.isEmpty()) { properties.insert("coloridentity", colorIdentity); } - const auto &mainCardType = getMainCardType(card.value("types").toStringList()); + const auto &mainCardType = getMainCardType(card.value("types").toVariant().toStringList()); if (mainCardType.isEmpty()) { qDebug() << "warning: no mainCardType for card:" << name; } else { @@ -351,39 +526,47 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList // Depending on whether power and/or toughness are present, the format // is either P/T (most common), P (no toughness), or /T (no power). - QString power = getStringPropertyFromMap(card, "power"); - QString toughness = getStringPropertyFromMap(card, "toughness"); + QString power = getJsonString(card, "power"); + QString toughness = getJsonString(card, "toughness"); if (toughness.isEmpty() && !power.isEmpty()) { properties.insert("pt", power); } else if (!toughness.isEmpty()) { properties.insert("pt", power + ptSeparator + toughness); } - auto legalities = card.value("legalities").toMap(); - for (auto i = legalities.cbegin(), end = legalities.cend(); i != end; ++i) { + auto legalities = card.value("legalities").toObject(); + for (auto i = legalities.constBegin(), end = legalities.constEnd(); i != end; ++i) { properties.insert(QString("format-%1").arg(i.key()), i.value().toString().toLower()); } // split cards are considered a single card, enqueue for later merging if (layout == "split" || layout == "aftermath" || layout == "adventure" || layout == "prepare") { - auto _faceName = getStringPropertyFromMap(card, "faceName"); - SplitCardPart split(_faceName, text, properties, printingInfo); + auto _faceName = getJsonString(card, "faceName"); + // MTGJSON exposes each face as a separate card object, each with its + // own foreignData entry holding that face's rules text; collect it so + // the per-face texts can be joined the same way as the English text. + const QString faceLocalizedText = + localizationEnabled ? matchingForeignEntryText(card, cardLang) : QString(); + SplitCardPart split(_faceName, text, properties, printingInfo, faceLocalizedText); auto found_iter = splitCards.find(name + numProperty); if (found_iter == splitCards.end()) { splitCards.insert(name + numProperty, {{split}, name}); } else { found_iter->first.append(split); } + // MTGJSON's foreignData name is the joined name present on every + // face, so collect the name once. + collectForeignData(normalizeCardName(name), currentSet, card, false); } else { // relations QList relatedCards; // add other face for split cards as card relation - if (!getStringPropertyFromMap(card, "side").isEmpty()) { - auto faceManaValue = getStringPropertyFromMap(card, "faceManaValue"); + if (!getJsonString(card, "side").isEmpty()) { + auto faceManaValue = getJsonString(card, "faceManaValue"); if (faceManaValue.isEmpty()) { // check the old name for the property, for backwards compatibility purposes - faceManaValue = getStringPropertyFromMap(card, "faceConvertedManaCost"); + faceManaValue = getJsonString(card, "faceConvertedManaCost"); } properties["cmc"] = faceManaValue; @@ -403,20 +586,23 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList name = faceName; } - // mtgjon related cards - if (card.contains("relatedCards")) { - QVariantMap givenRelated = card.value("relatedCards").toMap(); + // mtgjson related cards + QJsonObject givenRelated = card.value("relatedCards").toObject(); + if (!givenRelated.isEmpty()) { // conjured cards from a spellbook - if (givenRelated.contains("spellbook")) { - auto spbk = givenRelated.value("spellbook").toStringList(); - for (const QString &spbkName : spbk) { - relatedCards.append( - new CardRelation(spbkName, CardRelationType::DoesNotAttach, false, false, 1, true)); + QJsonArray spellbook = givenRelated.value("spellbook").toArray(); + if (!spellbook.isEmpty()) { + for (const QJsonValue &spbkVal : spellbook) { + relatedCards.append(new CardRelation(spbkVal.toString(), CardRelationType::DoesNotAttach, false, + false, 1, true)); } } } - CardInfoPtr newCard = addCard(name + numComponent, text, isToken, properties, relatedCards, printingInfo); + collectForeignData(normalizeCardName(name + numComponent), currentSet, card); + + CardInfoPtr newCard = + addCard(name + numComponent, text, isToken, std::move(properties), relatedCards, printingInfo); numCards++; } } @@ -424,11 +610,12 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList // split cards handling static const QString splitCardPropSeparator = QString(" // "); static const QString splitCardTextSeparator = QString("\n\n---\n\n"); - static const QList noRelatedCards = {}; QList, QString>> partsAndNames = splitCards.values(); for (auto [splitCardParts, name] : partsAndNames) { QString text; + QString localizedText; + bool localizedTextComplete = true; QHash properties; PrintingInfo printingInfo; @@ -438,6 +625,21 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList } text.append(tmp.getText()); + // Build the cardLang text by joining each face's translated text with + // the same separator as the English text. Any face missing a complete + // translation abandons the whole join, falling back to the English text. + if (localizedTextComplete) { + const QString partLocalizedText = tmp.getLocalizedText(); + if (partLocalizedText.isEmpty()) { + localizedTextComplete = false; + } else { + if (!localizedText.isEmpty()) { + localizedText.append(splitCardTextSeparator); + } + localizedText.append(partLocalizedText); + } + } + if (properties.isEmpty()) { properties = tmp.getProperties(); printingInfo = tmp.getPrintingInfo(); @@ -450,10 +652,17 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList if (!thisCardPropertyValue.isEmpty() && originalPropertyValue != thisCardPropertyValue) { if (originalPropertyValue.isEmpty()) { // don't create //es if one field is empty properties.insert(prop, thisCardPropertyValue); - } else if (prop == "colors") { // the card is both colors + } else if (prop == "colors" || prop == "coloridentity") { // the card is both colors properties.insert(prop, originalPropertyValue + thisCardPropertyValue); - } else if (prop == "maintype") { // don't create maintypes with //es in them - continue; + } else if (prop == "maintype") { + // Use the same priority as getMainCardType() to pick the + // "best" type across faces — e.g. Creature over Instant + // for adventure cards like Bonecrusher Giant. + int currentPriority = mainCardTypePriority(originalPropertyValue); + int newPriority = mainCardTypePriority(thisCardPropertyValue); + if (newPriority >= 0 && (currentPriority < 0 || newPriority < currentPriority)) { + properties.insert(prop, thisCardPropertyValue); + } } else { properties.insert(prop, originalPropertyValue + splitCardPropSeparator + thisCardPropertyValue); @@ -462,20 +671,32 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList } } } - CardInfoPtr newCard = addCard(name, text, isToken, properties, noRelatedCards, printingInfo); + CardInfoPtr newCard = addCard(name, text, isToken, std::move(properties), {}, printingInfo); + if (localizationEnabled && localizedTextComplete && !localizedText.isEmpty()) { + // Same priority policy as collectForeignData(): the joined text from the + // highest-priority set seen so far wins, applied once all printings are in. + LocalizedCardEntry entry; + entry.text = localizedText; + entry.priority = currentSet->getPriority(); + const QString entryKey = normalizeCardName(name); + const auto existing = splitLocalizedTexts.constFind(entryKey); + if (existing == splitLocalizedTexts.constEnd() || entry.priority < existing->priority) { + splitLocalizedTexts.insert(entryKey, entry); + } + } numCards++; } return numCards; } -FormatRulesNameMap OracleImporter::createDefaultMagicFormats() +static FormatRulesNameMap buildDefaultMagicFormats() { // Predefined common exceptions CardCondition superTypeIsBasic; superTypeIsBasic.field = "type"; superTypeIsBasic.matchType = "regex"; - superTypeIsBasic.value = "\bBasic\b[^—]+\bLand\b"; + superTypeIsBasic.value = R"(\bBasic\b[^—]+\bLand\b)"; ExceptionRule basicLands; basicLands.conditions.append(superTypeIsBasic); @@ -488,7 +709,6 @@ FormatRulesNameMap OracleImporter::createDefaultMagicFormats() ExceptionRule mayContainAnyNumber; mayContainAnyNumber.conditions.append(anyNumberAllowed); - // Map to store default rules FormatRulesNameMap defaultFormatRulesNameMap; // ----------------- Helper lambda to create format ----------------- @@ -534,10 +754,29 @@ FormatRulesNameMap OracleImporter::createDefaultMagicFormats() return defaultFormatRulesNameMap; } +const FormatRulesNameMap &OracleImporter::createDefaultMagicFormats() +{ + static const FormatRulesNameMap cached = buildDefaultMagicFormats(); + return cached; +} + int OracleImporter::startImport() { static ICardSetPriorityController *noOpController = new NoopCardSetPriorityController(); + importCancelled.storeRelease(0); + + // Pre-allocate the cards hash to avoid rehashing during import. Keys are + // distinct card names while raw ranges only count printings (AllPrintings + // ~100k printings vs ~35k names), so this over-reserves somewhat; an exact + // distinct-name count would require eagerly parsing, which the lazy reader + // deliberately avoids. It's a capacity hint, so the overshoot is harmless. + int estimatedCards = 0; + for (const SetToDownload &curSetToParse : allSets) { + estimatedCards += curSetToParse.getRawRange().cardCount; + } + cards.reserve(estimatedCards); + // add an empty set for tokens CardSetPtr tokenSet = CardSet::newInstance(noOpController, CardSet::TOKENS_SETNAME, tr("Dummy set containing tokens"), "Tokens"); @@ -546,20 +785,61 @@ int OracleImporter::startImport() int setIndex = 0; for (const SetToDownload &curSetToParse : allSets) { + if (importCancelled.loadAcquire()) { + // The wizard was closed mid-import: stop at the next set boundary so + // the caller can wait for this future without processing every set. + break; + } + CardSetPtr newSet = CardSet::newInstance(noOpController, curSetToParse.getShortName(), curSetToParse.getLongName(), curSetToParse.getSetType(), curSetToParse.getReleaseDate(), curSetToParse.getPriority()); + + // parse only this set's slice of the raw document so the whole JSON tree is + // never kept in memory at once + const RawJson::SetDataRange &rawRange = curSetToParse.getRawRange(); + const qsizetype rangeEnd = rawRange.start + rawRange.length; + if (rawRange.start < 0 || rawRange.length <= 0 || rangeEnd > rawSetsData.size()) { + // rawSetsData is cleared by releaseSetData() while SetToDownload copies + // taken from getSets() keep their ranges, and nothing else enforces the + // pairing — so never index past the buffer on stale/mismatched ranges. + qWarning() << "error: out-of-bounds raw range for set" << curSetToParse.getShortName() << "skipping"; + ++setIndex; + emit setIndexChanged(0, setIndex, curSetToParse.getLongName()); + continue; + } + // sliced() shares the buffer instead of deep-copying the slice; the largest + // sets in AllPrintings are tens of MB, so the copy is worth avoiding here. + const QByteArray setBytes = rawSetsData.sliced(rawRange.start, rawRange.length); + QJsonParseError parseError; + const QJsonDocument setDoc = QJsonDocument::fromJson(setBytes, &parseError); + if (parseError.error != QJsonParseError::NoError) { + qWarning() << "error: parsing card data for set" << curSetToParse.getShortName() << ":" + << parseError.errorString(); + ++setIndex; + // Keep the progress accounting honest: a set that failed to parse + // still advanced the index, so report it (with zero imported cards) + // rather than letting SaveSetsPage's bar stall per failed set. + emit setIndexChanged(0, setIndex, curSetToParse.getLongName()); + continue; + } + + // Only add the set to the database once its slice parsed cleanly; + // a set that fails here must not persist as an empty set in cards.xml. if (!sets.contains(newSet->getShortName())) { sets.insert(newSet->getShortName(), newSet); } - int numCardsInSet = importCardsFromSet(newSet, curSetToParse.getCards()); + const QJsonArray setCards = setDoc.object().value("cards").toArray(); + int numCardsInSet = importCardsFromSet(newSet, setCards); ++setIndex; emit setIndexChanged(numCardsInSet, setIndex, curSetToParse.getLongName()); } + applyLocalizedData(); + emit setIndexChanged(0, setIndex, QString()); // total number of sets @@ -568,14 +848,23 @@ int OracleImporter::startImport() bool OracleImporter::saveToFile(const QString &fileName, const QString &sourceUrl, const QString &sourceVersion) { - CockatriceXml4Parser parser(new NoopCardPreferenceProvider(), new NoopCardSetPriorityController()); + CockatriceXml4Parser parser(new NoopCardSetPriorityController()); return parser.saveToFile(createDefaultMagicFormats(), sets, cards, fileName, sourceUrl, sourceVersion); } +void OracleImporter::releaseSetData() +{ + allSets.clear(); + rawSetsData.clear(); +} + void OracleImporter::clear() { sets.clear(); cards.clear(); allSets.clear(); + rawSetsData.clear(); + localizedEntries.clear(); + splitLocalizedTexts.clear(); } diff --git a/oracle/src/oracleimporter.h b/oracle/src/oracleimporter.h index 99644f9ce..d872e184e 100644 --- a/oracle/src/oracleimporter.h +++ b/oracle/src/oracleimporter.h @@ -1,12 +1,29 @@ #ifndef ORACLEIMPORTER_H #define ORACLEIMPORTER_H +#include "libcockatrice/card/printing/printing_info.h" +#include "libcockatrice/card/set/card_set.h" +#include "raw_json_scanner.h" + +#include +#include +#include +#include +#include +#include +#include #include -#include -#include +#include +#include +#include #include +#include +#include #include +class CardRelation; +class QRegularExpression; + // many users prefer not to see these sets with non english arts // they will given priority PriorityLowest const QStringList nonEnglishSets = {"4BB", "FBB", "PS11", "PSAL", "REN", "RIN"}; @@ -44,10 +61,12 @@ class SetToDownload { private: QString shortName, longName; - QList cards; QDate releaseDate; QString setType; CardSet::Priority priority; + // Byte range of this set's object within the importer's raw JSON text. Parsing + // one set at a time keeps peak memory low instead of holding the whole document. + RawJson::SetDataRange rawRange; public: const QString &getShortName() const @@ -58,10 +77,6 @@ public: { return longName; } - const QList &getCards() const - { - return cards; - } const QString &getSetType() const { return setType; @@ -74,16 +89,23 @@ public: { return priority; } + const RawJson::SetDataRange &getRawRange() const + { + return rawRange; + } SetToDownload(QString _shortName, QString _longName, - QList _cards, CardSet::Priority _priority, QString _setType = QString(), const QDate &_releaseDate = QDate()) - : shortName(std::move(_shortName)), longName(std::move(_longName)), cards(std::move(_cards)), - releaseDate(_releaseDate), setType(std::move(_setType)), priority(_priority) + : shortName(std::move(_shortName)), longName(std::move(_longName)), releaseDate(_releaseDate), + setType(std::move(_setType)), priority(_priority) { } + void setRawRange(const RawJson::SetDataRange &_rawRange) + { + rawRange = _rawRange; + } bool operator<(const SetToDownload &set) const { return longName.compare(set.longName, Qt::CaseInsensitive) < 0; @@ -96,7 +118,8 @@ public: SplitCardPart(const QString &_name, const QString &_text, const QHash &_properties, - const PrintingInfo &_printingInfo); + const PrintingInfo &_printingInfo, + const QString &_localizedText = QString()); inline const QString &getName() const { return name; @@ -105,6 +128,13 @@ public: { return text; } + /** + * @brief The cardLang rules text of this face's foreignData entry, if any. + */ + inline const QString &getLocalizedText() const + { + return localizedText; + } inline const QHash &getProperties() const { return properties; @@ -117,10 +147,18 @@ public: private: QString name; QString text; + QString localizedText; QHash properties; PrintingInfo printingInfo; }; +struct LocalizedCardEntry +{ + QString name; + QString text; + CardSet::Priority priority = CardSet::PriorityLowest; +}; + class OracleImporter : public QObject { Q_OBJECT @@ -139,23 +177,131 @@ private: QList allSets; + /** + * The raw JSON text of the source document, retained for lazy per-set + * parsing during startImport(). Frees the card data as each set is imported. + */ + QByteArray rawSetsData; + + /** + * Whether readSetsFromByteArray() should report scan progress via + * dataReadProgress. The wizard streams that signal to stdout (for the + * hosting Cockatrice client to parse) in both interactive and background + * runs; the flag exists to skip the scanner instrumentation entirely when + * no consumer needs it. + */ + bool progressReporting = true; + + /** + * Atomic "please stop importing" flag. startImport() checks it between sets + * so a wizard being closed mid-import can be torn down without waiting for + * the whole import (or racing it). + */ + QAtomicInt importCancelled; + + /** + * The ISO-639 language code whose foreignData is imported; "en" by default. + */ + QString cardLang = "en"; + + /** + * Whether cardLang is a supported language other than English, so per-card + * foreignData scanning can be skipped entirely when disabled. + */ + bool localizationEnabled = false; + + /** + * Localized name/text collected per imported card key while parsing sets, + * applied to the CardInfo objects by applyLocalizedData() once all + * printings have been seen so the best-priority one wins. + */ + QMap localizedEntries; + + /** + * cardLang rules text collected for split-card names while parsing sets, + * applied by applyLocalizedData(). Kept apart from localizedEntries because + * MTGJSON emits each split face as its own card object with the joined name + * on every foreignData entry: names and the per-face text join have different + * completeness and must not overwrite each other under the same key. + */ + QMap splitLocalizedTexts; + CardInfoPtr addCard(QString name, const QString &text, bool isToken, QHash properties, const QList &relatedCards, const PrintingInfo &printingInfo); + + /** + * Records the first foreignData entry matching cardLang for the given card + * key, keeping the entry from the highest-priority set seen so far. + * + * Multi-face cards (split, adventure, aftermath, prepare) pass collectText = + * false: MTGJSON emits one foreignData entry per face with the same joined + * name but only that face's text, so the name is collected here while the + * per-face texts are joined during the split-card merge. + */ + void collectForeignData(const QString &cardKey, + const CardSetPtr ¤tSet, + const QJsonObject &card, + bool collectText = true); signals: void setIndexChanged(int cardsImported, int setIndex, const QString &setName); void dataReadProgress(int bytesRead, int totalBytes); public: explicit OracleImporter(QObject *parent = nullptr); - bool readSetsFromByteArray(const QByteArray &data); + /** + * @brief Controls whether readSetsFromByteArray() instruments the raw scan. + * + * When enabled (the default) the raw scanner reports progress via + * dataReadProgress(), which the wizard streams to stdout (so the hosting + * Cockatrice client can show it) and also displays on its own progress bar. + * Switch it off only when nothing will consume scan progress. + */ + void setProgressReporting(bool enabled) + { + progressReporting = enabled; + } + /** + * Selects the ISO-639 language code whose foreignData is imported. + * English (the default) and unsupported codes disable localization. + */ + void setCardLang(const QString &lang); + const QString &getCardLang() const + { + return cardLang; + } + /** + * Scans the given JSON document for set metadata. Takes the data by value so + * the wizard can hand over its decompressed buffer without copying it. + */ + bool readSetsFromByteArray(QByteArray data); int startImport(); + /** + * @brief Requests an in-flight startImport() to stop at the next set boundary. + * + * Works by setting an atomic flag that startImport() polls between sets, so + * cancelImport() followed by a short waitForFinished() on the running future is + * safe the moment the wizard is about to be destroyed. + */ + void cancelImport() + { + importCancelled.storeRelease(1); + } + /** + * Applies the collected localized names/texts to the imported cards. + * Called automatically at the end of startImport(); exposed separately so + * tests can drive it after importing sets directly. + */ + void applyLocalizedData(); bool saveToFile(const QString &fileName, const QString &sourceUrl, const QString &sourceVersion); - int importCardsFromSet(const CardSetPtr ¤tSet, const QList &cardsList); - FormatRulesNameMap createDefaultMagicFormats(); + int importCardsFromSet(const CardSetPtr ¤tSet, const QJsonArray &cardsList); + /** + * @brief Returns the default format rules. The result is memoized on first use and must be treated as immutable. + */ + const FormatRulesNameMap &createDefaultMagicFormats(); const CardNameMap &getCardList() const { return cards; @@ -164,6 +310,11 @@ public: { return allSets; } + const QByteArray &getRawSetsData() const + { + return rawSetsData; + } + void releaseSetData(); void clear(); }; diff --git a/oracle/src/oraclewizard.cpp b/oracle/src/oraclewizard.cpp index 11200dfc5..05f08ce64 100644 --- a/oracle/src/oraclewizard.cpp +++ b/oracle/src/oraclewizard.cpp @@ -6,16 +6,28 @@ #include "pages.h" #include "pagetemplates.h" -#include -#include -#include -#include -#include -#include -#include -#include -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include +#include +#include + +class QWidget; OracleWizard::OracleWizard(QWidget *parent) : QWizard(parent) { @@ -37,6 +49,9 @@ OracleWizard::OracleWizard(QWidget *parent) : QWizard(parent) connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &OracleWizard::updateLanguage); importer = new OracleImporter(this); + // Import card text in the language the client displays, if supported: + // foreignData for any other language is never imported. + importer->setCardLang(SettingsCache::instance().cardsDisplay().getCardLang()); nam = new QNetworkAccessManager(this); @@ -60,6 +75,7 @@ OracleWizard::OracleWizard(QWidget *parent) : QWizard(parent) } retranslateUi(); + startRaiseServer(); } /** @@ -110,6 +126,72 @@ void OracleWizard::accept() QDialog::accept(); } +void OracleWizard::reject() +{ + // The wizard is being closed while a page may still run a worker on the + // importer. Ask it to stop before the wizard (and the importer child) is + // destroyed, so the worker thread never touches freed memory. + if (auto *active = dynamic_cast(currentPage())) { + active->cancelWork(); + } + QWizard::reject(); +} + +void OracleWizard::runInBackground() +{ + backgroundMode = true; + hide(); + currentPage()->initializePage(); +} + +void OracleWizard::startRaiseServer() +{ + // Scope the socket name to the current user, mirroring the hosting client. + raiseServer = new QLocalServer(this); + connect(raiseServer, &QLocalServer::newConnection, this, &OracleWizard::handleRaiseRequest); + const QString serverName = scopedLocalServerName(QStringLiteral("CockatriceOracleRaise")); + if (raiseServer->listen(serverName)) { + return; + } + // A stale socket left behind by a crashed run holds the name: take it back. + QLocalServer::removeServer(serverName); + if (!raiseServer->listen(serverName)) { + qWarning() << "Oracle: could not listen for raise requests on" << serverName; + delete raiseServer; + raiseServer = nullptr; + } +} + +void OracleWizard::handleRaiseRequest() +{ + while (raiseServer && raiseServer->hasPendingConnections()) { + QLocalSocket *socket = raiseServer->nextPendingConnection(); + connect(socket, &QLocalSocket::readyRead, this, [this, socket]() { + socket->readAll(); + socket->deleteLater(); + raiseWizard(); + }); + connect(socket, &QLocalSocket::disconnected, socket, &QLocalSocket::deleteLater); + } + raiseWizard(); +} + +void OracleWizard::raiseWizard() +{ + // An ongoing background run is hidden; the user asked to see it, so promote it to a window. + if (backgroundMode && !isVisible()) { + show(); + } + if (isMinimized()) { + showNormal(); + } + raise(); + activateWindow(); + if (windowHandle()) { + windowHandle()->requestActivate(); + } +} + void OracleWizard::enableButtons() { button(QWizard::NextButton)->setDisabled(false); diff --git a/oracle/src/oraclewizard.h b/oracle/src/oraclewizard.h index 78427175c..ea652bdc5 100644 --- a/oracle/src/oraclewizard.h +++ b/oracle/src/oraclewizard.h @@ -1,21 +1,17 @@ #ifndef ORACLEWIZARD_H #define ORACLEWIZARD_H +#include +#include #include +#include #include -class QCheckBox; -class QGroupBox; -class QComboBox; -class QLabel; -class QLineEdit; -class QRadioButton; -class QProgressBar; class QNetworkAccessManager; -class QTextEdit; -class QVBoxLayout; class OracleImporter; +class QLocalServer; class QSettings; +class QWidget; class OracleWizard : public QWizard { @@ -23,6 +19,7 @@ class OracleWizard : public QWizard public: explicit OracleWizard(QWidget *parent = nullptr); void accept() override; + void reject() override; void enableButtons(); void disableButtons(); void retranslateUi(); @@ -52,12 +49,7 @@ public: } bool saveTokensToFile(const QString &fileName); - void runInBackground() - { - backgroundMode = true; - hide(); - currentPage()->initializePage(); - } + void runInBackground(); public: OracleImporter *importer; @@ -69,6 +61,7 @@ public: private slots: void updateLanguage(); + void handleRaiseRequest(); private: QByteArray tokensData; @@ -76,6 +69,13 @@ private: QString cardSourceVersion; void migrateOracleSettings(); + /** + * @brief Starts the local socket the hosting Cockatrice client uses to bring this wizard forward. + */ + void startRaiseServer(); + /** @brief Shows, raises and activates the wizard window. */ + void raiseWizard(); + QLocalServer *raiseServer = nullptr; ///< listens for raise requests from the hosting client protected: void changeEvent(QEvent *event) override; diff --git a/oracle/src/pages.cpp b/oracle/src/pages.cpp index df4d1a98c..8c286fb46 100644 --- a/oracle/src/pages.cpp +++ b/oracle/src/pages.cpp @@ -4,31 +4,59 @@ #include "main.h" #include "oracleimporter.h" #include "oraclewizard.h" -#include "pages.h" #include "pagetemplates.h" #include "version_string.h" -#include #include #include #include +#include +#include #include +#include #include +#include #include +#include +#include +#include #include #include +#include +#include #include #include #include +#include +#include +#include #include #include #include #include -#include +#include +#include #include -#include -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include +#include +#include +#include +#include +#include +#include +#include + +class QWidget; #ifdef HAS_LZMA #include "lzma/decompress.h" @@ -53,6 +81,114 @@ #define ALLSETS_URL "https://www.mtgjson.com/api/v5/AllPrintings.json" #endif +/** + * @brief Emits one machine-readable progress line to stdout. + * + * The hosting Cockatrice client parses these lines to drive a determinate + * progress bar. stderr stays reserved for human-readable log output. + */ +static void emitProgress(const char *stage, qint64 done, qint64 total) +{ + QTextStream out(stdout); + out << "PROGRESS " << stage << ' ' << done << ' ' << total << '\n'; + out.flush(); +} + +namespace +{ + +/** + * @brief Decompresses and dispatches a sets-file payload on a worker thread. + * + * Iteratively unwraps xz/zip compression, then either hands the JSON to the + * importer (which reports scan progress via dataReadProgress) or returns the raw + * XML for the plain-XML path. Must never touch the wizard or the page: the caller + * consumes the returned LoadSetsResult on the UI thread in importFinished(). + */ +LoadSetsResult loadSetsData(const QPointer &importer, QByteArray data) +{ + LoadSetsResult result; + + while (true) { + if (data.startsWith(XZ_SIGNATURE)) { +#ifdef HAS_LZMA + QBuffer inBuffer(&data); + QByteArray decompressed; + QBuffer outBuffer(&decompressed); + inBuffer.open(QBuffer::ReadOnly); + outBuffer.open(QBuffer::WriteOnly); + XzDecompressor xz; + if (!xz.decompress(&inBuffer, &outBuffer)) { + result.errorMessage = LoadSetsPage::tr("Xz extraction failed."); + result.offerUncompressedFallback = true; + return result; + } + data = decompressed; + continue; +#else + result.errorMessage = + LoadSetsPage::tr("Sorry, this version of Oracle does not support xz compressed files."); + result.offerUncompressedFallback = true; + return result; +#endif + } + + if (data.startsWith(ZIP_SIGNATURE)) { +#ifdef HAS_ZLIB + QBuffer inBuffer(&data); + UnZip uz; + const UnZip::ErrorCode openEc = uz.openArchive(&inBuffer); + if (openEc != UnZip::Ok) { + result.errorMessage = LoadSetsPage::tr("Failed to open Zip archive: %1.").arg(uz.formatError(openEc)); + result.offerUncompressedFallback = true; + return result; + } + if (uz.fileList().size() != 1) { + result.errorMessage = + LoadSetsPage::tr("Zip extraction failed: the Zip archive doesn't contain exactly one file."); + result.offerUncompressedFallback = true; + return result; + } + const QString fileName = uz.fileList().at(0); + QByteArray decompressed; + QBuffer outBuffer(&decompressed); + outBuffer.open(QBuffer::ReadWrite); + const UnZip::ErrorCode ec = uz.extractFile(fileName, &outBuffer); + uz.closeArchive(); + if (ec != UnZip::Ok) { + result.errorMessage = LoadSetsPage::tr("Zip extraction failed: %1.").arg(uz.formatError(ec)); + result.offerUncompressedFallback = true; + return result; + } + data = decompressed; + continue; +#else + result.errorMessage = LoadSetsPage::tr("Sorry, this version of Oracle does not support zipped files."); + result.offerUncompressedFallback = true; + return result; +#endif + } + break; + } + + if (data.startsWith("<")) { + result.ok = true; + result.plainXml = true; + result.xmlData = std::move(data); + return result; + } + + if (data.startsWith("{")) { + result.ok = importer && importer->readSetsFromByteArray(std::move(data)); + return result; + } + + result.errorMessage = LoadSetsPage::tr("Failed to interpret downloaded data."); + return result; +} + +} // namespace + #define TOKENS_URL "https://raw.githubusercontent.com/Cockatrice/Magic-Token/master/tokens.xml" #define SPOILERS_URL "https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/spoiler.xml" @@ -99,21 +235,18 @@ void IntroPage::initializePage() QStringList IntroPage::findQmFiles() { - QDir dir(translationPath); - QStringList fileNames = dir.entryList(QStringList(translationPrefix + "_*.qm"), QDir::Files, QDir::Name); - fileNames.replaceInStrings(QRegularExpression(translationPrefix + "_(.*)\\.qm"), "\\1"); - return fileNames; + return TranslationLoader::availableLanguages(translationPrefix, + TranslationLoader::applicationTranslationPaths(translationPath)); } QString IntroPage::languageName(const QString &lang) { QTranslator qTranslator; - QString appNameHint = translationPrefix + "_" + lang; - bool appTranslationLoaded = qTranslator.load(appNameHint, translationPath); - if (!appTranslationLoaded) { - qDebug() << "Unable to load" << translationPrefix << "translation" << appNameHint << "at" << translationPath; - } + // Only the language name is needed here, so the file is loaded without logging a line per + // language: findQmFiles() asked for languages whose files exist in the first place. + TranslationLoader::loadFrom(qTranslator, translationPrefix + "_" + lang, + TranslationLoader::applicationTranslationPaths(translationPath)); return qTranslator.translate("i18n", DEFAULT_LANG_NAME); } @@ -182,6 +315,11 @@ LoadSetsPage::LoadSetsPage(QWidget *parent) : OracleWizardPage(parent) setLayout(layout); } +bool LoadSetsPage::isComplete() const +{ + return !loadActive; +} + void LoadSetsPage::initializePage() { urlLineEdit->setText(wizard()->settings->value("allsetsurl", ALLSETS_URL).toString()); @@ -260,7 +398,7 @@ bool LoadSetsPage::validatePage() return false; } - progressLabel->setText(tr("Downloading (0MB)")); + progressLabel->setText(tr("Downloading (0 MB)")); // show an infinite progressbar progressBar->setMaximum(0); progressBar->setMinimum(0); @@ -279,18 +417,13 @@ bool LoadSetsPage::validatePage() return false; } - if (!setsFile.open(QIODevice::ReadOnly)) { - QMessageBox::critical(nullptr, tr("Error"), tr("Cannot open file '%1'.").arg(fileLineEdit->text())); - return false; - } - wizard()->disableButtons(); setEnabled(false); wizard()->setCardSourceUrl(setsFile.fileName()); wizard()->setCardSourceVersion("unknown"); - readSetsFromByteArray(setsFile.readAll()); + readSetsFromFile(setsFile.fileName()); } return false; @@ -339,11 +472,12 @@ void LoadSetsPage::downloadSetsFile(const QUrl &url) void LoadSetsPage::actDownloadProgressSetsFile(qint64 received, qint64 total) { + emitProgress("download", received, total); if (total > 0) { progressBar->setMaximum(static_cast(total)); progressBar->setValue(static_cast(received)); } - progressLabel->setText(tr("Downloading (%1MB)").arg((int)received / (1024 * 1024))); + progressLabel->setText(tr("Downloading (%1 MB)").arg((int)received / (1024 * 1024))); } void LoadSetsPage::actDownloadFinishedSetsFile() @@ -384,106 +518,97 @@ void LoadSetsPage::actDownloadFinishedSetsFile() reply->deleteLater(); } -void LoadSetsPage::readSetsFromByteArray(QByteArray _data) +void LoadSetsPage::updateParsingProgress(int bytesRead, int totalBytes) { - // show an infinite progressbar + if (totalBytes <= 0) { + return; + } + progressBar->setRange(0, totalBytes); + progressBar->setValue(bytesRead); + const int percent = static_cast((100.0 * bytesRead) / totalBytes); + progressLabel->setText(tr("Parsing file (%1%)").arg(percent)); +} + +void LoadSetsPage::scanProgressToStdout(int bytesRead, int totalBytes) +{ + emitProgress("scan", bytesRead, totalBytes); +} + +void LoadSetsPage::beginLoadSets(bool compressedFile) +{ + // Show an infinite progressbar while the worker decompresses; the scan + // steals the label via dataReadProgress as soon as it starts. progressBar->setMaximum(0); progressBar->setMinimum(0); progressBar->setValue(0); - progressLabel->setText(tr("Parsing file")); + progressLabel->setText(compressedFile ? tr("Extracting file...") : tr("Parsing file")); progressLabel->show(); progressBar->show(); + // Keep Next disabled (via completeChanged) until the worker reports in; + // updateButtonStates() re-evaluates button state whenever we re-enable. + loadActive = true; + emit completeChanged(); + wizard()->downloadedPlainXml = false; wizard()->xmlData.clear(); - readSetsFromByteArrayRef(_data); + + if (wizard()->backgroundMode) { + connect(wizard()->importer, &OracleImporter::dataReadProgress, this, &LoadSetsPage::scanProgressToStdout, + Qt::UniqueConnection); + } else { + connect(wizard()->importer, &OracleImporter::dataReadProgress, this, &LoadSetsPage::updateParsingProgress, + Qt::UniqueConnection); + } } -void LoadSetsPage::readSetsFromByteArrayRef(QByteArray &_data) +void LoadSetsPage::readSetsFromByteArray(QByteArray _data) { - // unzip the file if needed - if (_data.startsWith(XZ_SIGNATURE)) { -#ifdef HAS_LZMA - // zipped file - auto *inBuffer = new QBuffer(&_data); - auto newData = QByteArray(); - auto *outBuffer = new QBuffer(&newData); - inBuffer->open(QBuffer::ReadOnly); - outBuffer->open(QBuffer::WriteOnly); - XzDecompressor xz; - if (!xz.decompress(inBuffer, outBuffer)) { - zipDownloadFailed(tr("Xz extraction failed.")); - return; + const bool compressed = _data.startsWith(XZ_SIGNATURE) || _data.startsWith(ZIP_SIGNATURE); + beginLoadSets(compressed); + + // Decompress and scan off the UI thread so a large download can't freeze the window. + const QPointer importer = wizard()->importer; + future = QtConcurrent::run( + [importer, data = std::move(_data)]() mutable { return loadSetsData(importer, std::move(data)); }); + watcher.setFuture(future); +} + +void LoadSetsPage::readSetsFromFile(const QString &fileName) +{ + // Peek at the header on the UI thread so the status text can distinguish + // "Extracting file..." from a plain JSON parse; the full read happens in the worker. + QFile headerFile(fileName); + bool compressed = false; + if (headerFile.open(QIODevice::ReadOnly)) { + const QByteArray header = headerFile.read(6); + compressed = header.startsWith(XZ_SIGNATURE) || header.startsWith(ZIP_SIGNATURE); + } + beginLoadSets(compressed); + + // Read, decompress and scan off the UI thread (a plain JSON can be hundreds + // of MB, so even the read itself must not block the window). + const QPointer importer = wizard()->importer; + future = QtConcurrent::run([importer, fileName]() mutable -> LoadSetsResult { + QFile file(fileName); + if (!file.open(QIODevice::ReadOnly)) { + LoadSetsResult readError; + readError.errorMessage = LoadSetsPage::tr("Cannot open file '%1'.").arg(fileName); + return readError; } - _data.clear(); - readSetsFromByteArrayRef(newData); - return; -#else - zipDownloadFailed(tr("Sorry, this version of Oracle does not support xz compressed files.")); + return loadSetsData(importer, file.readAll()); + }); + watcher.setFuture(future); +} - wizard()->enableButtons(); - setEnabled(true); - progressLabel->hide(); - progressBar->hide(); - return; -#endif - } else if (_data.startsWith(ZIP_SIGNATURE)) { -#ifdef HAS_ZLIB - // zipped file - auto *inBuffer = new QBuffer(&_data); - auto newData = QByteArray(); - auto *outBuffer = new QBuffer(&newData); - QString fileName; - UnZip::ErrorCode ec; - UnZip uz; - - ec = uz.openArchive(inBuffer); - if (ec != UnZip::Ok) { - zipDownloadFailed(tr("Failed to open Zip archive: %1.").arg(uz.formatError(ec))); - return; - } - - if (uz.fileList().size() != 1) { - zipDownloadFailed(tr("Zip extraction failed: the Zip archive doesn't contain exactly one file.")); - return; - } - fileName = uz.fileList().at(0); - - outBuffer->open(QBuffer::ReadWrite); - ec = uz.extractFile(fileName, outBuffer); - if (ec != UnZip::Ok) { - zipDownloadFailed(tr("Zip extraction failed: %1.").arg(uz.formatError(ec))); - uz.closeArchive(); - return; - } - _data.clear(); - readSetsFromByteArrayRef(newData); - return; -#else - zipDownloadFailed(tr("Sorry, this version of Oracle does not support zipped files.")); - - wizard()->enableButtons(); - setEnabled(true); - progressLabel->hide(); - progressBar->hide(); - return; -#endif - } else if (_data.startsWith("{")) { - // Start the computation. - jsonData = std::move(_data); - future = QtConcurrent::run([this] { return wizard()->importer->readSetsFromByteArray(std::move(jsonData)); }); - watcher.setFuture(future); - } else if (_data.startsWith("<")) { - // save xml file and don't do any processing - wizard()->downloadedPlainXml = true; - wizard()->xmlData = std::move(_data); - importFinished(); - } else { - wizard()->enableButtons(); - setEnabled(true); - progressLabel->hide(); - progressBar->hide(); - QMessageBox::critical(this, tr("Error"), tr("Failed to interpret downloaded data.")); +void LoadSetsPage::cancelWork() +{ + // The scan is short-lived; just wait it out before the wizard (and its + // importer) can be torn down underneath the worker thread. + if (future.isRunning()) { + future.cancel(); + watcher.cancel(); + future.waitForFinished(); } } @@ -509,17 +634,65 @@ void LoadSetsPage::zipDownloadFailed(const QString &message) void LoadSetsPage::importFinished() { + loadActive = false; + emit completeChanged(); wizard()->enableButtons(); setEnabled(true); - progressLabel->hide(); - progressBar->hide(); - if (wizard()->downloadedPlainXml || watcher.future().result()) { - wizard()->next(); - } else { - QMessageBox::critical(this, tr("Error"), - tr("The file was retrieved successfully, but it does not contain any sets data.")); + const LoadSetsResult result = watcher.result(); + + if (result.plainXml) { + wizard()->downloadedPlainXml = true; + wizard()->xmlData = result.xmlData; } + + if (wizard()->backgroundMode) { + progressLabel->hide(); + progressBar->hide(); + if (!result.errorMessage.isEmpty()) { + qWarning() << result.errorMessage; + } else if (!result.ok && !result.plainXml) { + qWarning() << tr("The file was retrieved successfully, but it does not contain any sets data."); + } + emit readyToContinue(); + return; + } + + const auto fail = [this](const QString &message) { + progressLabel->hide(); + progressBar->hide(); + QMessageBox::critical(this, tr("Error"), message); + }; + + if (!result.errorMessage.isEmpty()) { + if (result.offerUncompressedFallback) { + zipDownloadFailed(result.errorMessage); + return; + } + fail(result.errorMessage); + return; + } + + if (!result.ok && !result.plainXml) { + fail(tr("The file was retrieved successfully, but it does not contain any sets data.")); + return; + } + + // Snap the bar to 100% and hold it there for a moment so the completed state + // is actually visible before the next page's own import progress takes over + // (a zero-length deferral can still fire before the repaint is delivered). + progressBar->setMaximum(1); + progressBar->setValue(1); + progressLabel->setText(tr("Parsing file (100%)")); + QTimer::singleShot(500, this, [this] { + if (wizard()->currentPage() == this) { + // Leave the page pristine: hide the completed load bar so a later + // Back from the save page doesn't show stale progress. + progressLabel->hide(); + progressBar->hide(); + wizard()->next(); + } + }); } SaveSetsPage::SaveSetsPage(QWidget *parent) : OracleWizardPage(parent) @@ -527,46 +700,113 @@ SaveSetsPage::SaveSetsPage(QWidget *parent) : OracleWizardPage(parent) pathLabel = new QLabel(this); saveLabel = new QLabel(this); + progressBar = new QProgressBar(this); + progressBar->hide(); + defaultPathCheckBox = new QCheckBox(this); messageLog = new QTextEdit(this); messageLog->setReadOnly(true); auto *layout = new QGridLayout(this); - layout->addWidget(messageLog, 0, 0); - layout->addWidget(saveLabel, 1, 0); - layout->addWidget(pathLabel, 2, 0); - layout->addWidget(defaultPathCheckBox, 3, 0); + layout->addWidget(progressBar, 0, 0); + layout->addWidget(messageLog, 1, 0); + layout->addWidget(saveLabel, 2, 0); + layout->addWidget(pathLabel, 3, 0); + layout->addWidget(defaultPathCheckBox, 4, 0); setLayout(layout); } +bool SaveSetsPage::isComplete() const +{ + return !importActive; +} + void SaveSetsPage::cleanupPage() { + cancelWork(); + disconnect(wizard()->importer, &OracleImporter::setIndexChanged, this, &SaveSetsPage::updateTotalProgress); + disconnect(&importWatcher, &QFutureWatcher::finished, this, &SaveSetsPage::importFinished); wizard()->importer->clear(); - disconnect(wizard()->importer, &OracleImporter::setIndexChanged, nullptr, nullptr); } void SaveSetsPage::initializePage() { - messageLog->clear(); - retranslateUi(); if (wizard()->downloadedPlainXml) { messageLog->hide(); - } else { - messageLog->show(); - connect(wizard()->importer, &OracleImporter::setIndexChanged, this, &SaveSetsPage::updateTotalProgress); - - int setsImported = wizard()->importer->startImport(); - - if (setsImported == 0) { - QMessageBox::critical(this, tr("Error"), tr("No set has been imported.")); + progressBar->hide(); + if (wizard()->backgroundMode) { + emit readyToContinue(); } + return; + } + + messageLog->clear(); + messageLog->show(); + progressBar->show(); + + totalSets = wizard()->importer->getSets().size(); + progressBar->setRange(0, totalSets); + progressBar->setValue(0); + + connect(wizard()->importer, &OracleImporter::setIndexChanged, this, &SaveSetsPage::updateTotalProgress, + Qt::UniqueConnection); + connect(&importWatcher, &QFutureWatcher::finished, this, &SaveSetsPage::importFinished, Qt::UniqueConnection); + + wizard()->disableButtons(); + importActive = true; + emit completeChanged(); + + const QPointer importer = wizard()->importer; + importFuture = QtConcurrent::run([importer] { return importer ? importer->startImport() : 0; }); + importWatcher.setFuture(importFuture); +} + +void SaveSetsPage::cancelWork() +{ + if (!importActive) { + return; + } + // Ask the worker to stop at the next set boundary, then wait it out so the + // wizard (and the importer it owns) is never torn down under a running thread. + importActive = false; + emit completeChanged(); + wizard()->importer->cancelImport(); + importFuture.cancel(); + importWatcher.cancel(); + importFuture.waitForFinished(); +} + +void SaveSetsPage::importFinished() +{ + if (!importActive) { + return; + } + importActive = false; + emit completeChanged(); + + wizard()->enableButtons(); + + const int setsImported = importWatcher.result(); + const QPointer importer = wizard()->importer; + if (importer) { + importer->releaseSetData(); } if (wizard()->backgroundMode) { + if (setsImported == 0) { + qWarning() << tr("No set has been imported."); + } emit readyToContinue(); + return; + } + + progressBar->setValue(progressBar->maximum()); + + if (setsImported == 0) { + QMessageBox::critical(this, tr("Error"), tr("No set has been imported.")); } } @@ -574,7 +814,7 @@ void SaveSetsPage::retranslateUi() { setTitle(tr("Sets imported")); if (wizard()->downloadedPlainXml) { - setSubTitle(tr("A cockatrice database file of %1 MB has been downloaded.") + setSubTitle(tr("A Cockatrice card database file of %1 MB has been downloaded.") .arg(qRound(wizard()->xmlData.size() / 1000000.0))); } else { setSubTitle(tr("The following sets have been found:")); @@ -588,13 +828,28 @@ void SaveSetsPage::retranslateUi() setButtonText(QWizard::NextButton, tr("&Save")); } -void SaveSetsPage::updateTotalProgress(int cardsImported, int /* setIndex */, const QString &setName) +void SaveSetsPage::updateTotalProgress(int cardsImported, int setIndex, const QString &setName) { + if (!importActive) { + return; + } if (setName.isEmpty()) { - messageLog->append("" + tr("Import finished: %1 cards.").arg(wizard()->importer->getCardList().size()) + - ""); + progressBar->setValue(progressBar->maximum()); + const int cardCount = wizard()->importer->getCardList().size(); + if (wizard()->backgroundMode) { + qInfo() << tr("Import finished: %1 cards.").arg(cardCount); + } else { + messageLog->append("" + tr("Import finished: %1 cards.").arg(cardCount) + ""); + } + emitProgress("import", totalSets, totalSets); } else { - messageLog->append(tr("%1: %2 cards imported").arg(setName).arg(cardsImported)); + progressBar->setValue(setIndex); + if (wizard()->backgroundMode) { + qInfo() << tr("%1: %2 cards imported").arg(setName).arg(cardsImported); + } else { + messageLog->append(tr("%1: %2 cards imported").arg(setName).arg(cardsImported)); + } + emitProgress("import", setIndex, totalSets); } messageLog->verticalScrollBar()->setValue(messageLog->verticalScrollBar()->maximum()); @@ -740,4 +995,4 @@ void LoadSpoilersPage::retranslateUi() pathLabel->setText(tr("The spoiler database will be saved at the following location:") + "
" + SettingsCache::instance().getSpoilerCardDatabasePath()); defaultPathCheckBox->setText(tr("Save to a custom path (not recommended)")); -} \ No newline at end of file +} diff --git a/oracle/src/pages.h b/oracle/src/pages.h index 066cc2e1b..221f032d3 100644 --- a/oracle/src/pages.h +++ b/oracle/src/pages.h @@ -3,24 +3,24 @@ #include "pagetemplates.h" +#include #include #include -#include -#include -#include +#include +#include +#include +#include class QCheckBox; -class QGroupBox; class QComboBox; class QLabel; class QLineEdit; class QRadioButton; class QProgressBar; -class QNetworkAccessManager; class QTextEdit; -class QVBoxLayout; -class OracleImporter; -class QSettings; +class QPushButton; +class QUrl; +class QWidget; class IntroPage : public OracleWizardPage { @@ -57,19 +57,31 @@ protected: void initializePage() override; }; +/** @brief Result of a worker-thread sets-file load (read + decompress + dispatch). */ +struct LoadSetsResult +{ + bool ok = false; ///< JSON scan produced set data (or plain XML was handled) + bool plainXml = false; ///< input was a plain Cockatrice XML database + QByteArray xmlData; ///< raw XML for the plain-XML path + QString errorMessage; ///< set when the input could not be processed + bool offerUncompressedFallback = false; ///< decompression-only failure: offer the uncompressed URL +}; + class LoadSetsPage : public OracleWizardPage { Q_OBJECT public: explicit LoadSetsPage(QWidget *parent = nullptr); void retranslateUi() override; + bool isComplete() const override; protected: void initializePage() override; bool validatePage() override; void readSetsFromByteArray(QByteArray _data); - void readSetsFromByteArrayRef(QByteArray &_data); + void readSetsFromFile(const QString &fileName); void downloadSetsFile(const QUrl &url); + void cancelWork() override; private: QRadioButton *urlRadioButton; @@ -81,15 +93,19 @@ private: QLabel *progressLabel; QProgressBar *progressBar; - QFutureWatcher watcher; - QFuture future; - QByteArray jsonData; + QFutureWatcher watcher; + QFuture future; + bool loadActive = false; + + void beginLoadSets(bool compressedFile = false); private slots: void actLoadSetsFile(); void actRestoreDefaultUrl(); void actDownloadProgressSetsFile(qint64 received, qint64 total); void actDownloadFinishedSetsFile(); + void updateParsingProgress(int bytesRead, int totalBytes); + void scanProgressToStdout(int bytesRead, int totalBytes); void importFinished(); void zipDownloadFailed(const QString &message); }; @@ -100,19 +116,28 @@ class SaveSetsPage : public OracleWizardPage public: explicit SaveSetsPage(QWidget *parent = nullptr); void retranslateUi() override; + bool isComplete() const override; private: QTextEdit *messageLog; + QProgressBar *progressBar; QCheckBox *defaultPathCheckBox; QLabel *pathLabel; QLabel *saveLabel; + QFutureWatcher importWatcher; + QFuture importFuture; + int totalSets = 0; + bool importActive = false; + protected: void initializePage() override; void cleanupPage() override; bool validatePage() override; + void cancelWork() override; private slots: + void importFinished(); void updateTotalProgress(int cardsImported, int setIndex, const QString &setName); }; diff --git a/oracle/src/pagetemplates.cpp b/oracle/src/pagetemplates.cpp index 4a27fef30..d37818dcb 100644 --- a/oracle/src/pagetemplates.cpp +++ b/oracle/src/pagetemplates.cpp @@ -3,17 +3,32 @@ #include "oraclewizard.h" #include +#include #include +#include #include +#include #include +#include #include #include +#include #include +#include #include +#include +#include #include #include #include -#include +#include +#include +#include +#include +#include +#include + +class QWidget; SimpleDownloadFilePage::SimpleDownloadFilePage(QWidget *parent) : OracleWizardPage(parent) { @@ -112,7 +127,7 @@ bool SimpleDownloadFilePage::validatePage() return false; } - progressLabel->setText(tr("Downloading (0MB)")); + progressLabel->setText(tr("Downloading (0 MB)")); // show an infinite progressbar progressBar->setMaximum(0); progressBar->setMinimum(0); diff --git a/oracle/src/pagetemplates.h b/oracle/src/pagetemplates.h index 6e79c867e..2a996801d 100644 --- a/oracle/src/pagetemplates.h +++ b/oracle/src/pagetemplates.h @@ -1,15 +1,21 @@ #ifndef PAGETEMPLATES_H #define PAGETEMPLATES_H +#include +#include #include +#include +#include -class QFile; class QRadioButton; class OracleWizard; class QCheckBox; class QLabel; class QLineEdit; class QProgressBar; +class QPushButton; +class QUrl; +class QWidget; class OracleWizardPage : public QWizardPage { @@ -20,6 +26,14 @@ public: } virtual void retranslateUi() = 0; + /** + * @brief Asks an active page to stop any background worker before the wizard + * (and its importer) can be torn down underneath it. Default is a no-op. + */ + virtual void cancelWork() + { + } + signals: void readyToContinue(); diff --git a/oracle/src/parsehelpers.cpp b/oracle/src/parsehelpers.cpp index a97bf67c9..2c897031f 100644 --- a/oracle/src/parsehelpers.cpp +++ b/oracle/src/parsehelpers.cpp @@ -1,7 +1,11 @@ #include "parsehelpers.h" #include +#include #include +#include +#include +#include /** * Parses the card text to determine if the card should have the cipt tag @@ -20,7 +24,7 @@ * Note that "...enters tapped unless..." returns false. * * @param name The name of the card - * @param text The oracle text of the card + * @param text The Oracle text of the card */ bool parseCipt(const QString &name, const QString &text) { diff --git a/oracle/src/qt-json/AUTHORS b/oracle/src/qt-json/AUTHORS deleted file mode 100644 index 29a85929f..000000000 --- a/oracle/src/qt-json/AUTHORS +++ /dev/null @@ -1,3 +0,0 @@ -Eeli Reilin -Luis Gustavo S. Barreto -Stephen Kockentiedt diff --git a/oracle/src/qt-json/LICENSE b/oracle/src/qt-json/LICENSE deleted file mode 100644 index 3c42b515a..000000000 --- a/oracle/src/qt-json/LICENSE +++ /dev/null @@ -1,27 +0,0 @@ -Copyright 2011 Eeli Reilin. All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, -are permitted provided that the following conditions are met: - - 1. Redistributions of source code must retain the above copyright notice, - this list of conditions and the following disclaimer. - - 2. Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - -THIS SOFTWARE IS PROVIDED BY ''AS IS'' AND ANY EXPRESS OR -IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO -EVENT SHALL EELI REILIN OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, -INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, -OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF -LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE -OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF -ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -The views and conclusions contained in the software and documentation -are those of the authors and should not be interpreted as representing -official policies, either expressed or implied, of Eeli Reilin. - diff --git a/oracle/src/qt-json/README b/oracle/src/qt-json/README deleted file mode 100644 index b60c1599b..000000000 --- a/oracle/src/qt-json/README +++ /dev/null @@ -1,96 +0,0 @@ -######################################################################## -1. INTRODUCTION - -The Json class is a simple class for parsing JSON data into a QVariant -hierarchies. Now, we can also reverse the process and serialize -QVariant hierarchies into valid JSON data. - - -######################################################################## -2. HOW TO USE - -The parser is really easy to use. Let's say we have the following -QString of JSON data: - ------------------------------------------------------------------------- -{ - "encoding" : "UTF-8", - "plug-ins" : [ - "python", - "c++", - "ruby" - ], - "indent" : { - "length" : 3, - "use_space" : true - } -} ------------------------------------------------------------------------- - -We would first call the parse-method: - ------------------------------------------------------------------------- -//Say that we're using the QtJson namespace -using namespace QtJson; -bool ok; -//json is a QString containing the JSON data -QVariantMap result = Json::parse(json, ok).toMap(); - -if(!ok) { - qFatal("An error occurred during parsing"); - exit(1); -} ------------------------------------------------------------------------- - -Assuming the parsing process completed without errors, we would then -go through the hierarchy: - ------------------------------------------------------------------------- -qDebug() << "encoding:" << result["encoding"].toString(); -qDebug() << "plugins:"; - -foreach(QVariant plugin, result["plug-ins"].toList()) { - qDebug() << "\t-" << plugin.toString(); -} - -QVariantMap nestedMap = result["indent"].toMap(); -qDebug() << "length:" << nestedMap["length"].toInt(); -qDebug() << "use_space:" << nestedMap["use_space"].toBool(); ------------------------------------------------------------------------- - -The previous code would print out the following: - ------------------------------------------------------------------------- -encoding: "UTF-8" -plugins: - - "python" - - "c++" - - "ruby" -length: 3 -use_space: true ------------------------------------------------------------------------- - -To write JSON data from Qt object is as simple as parsing: - ------------------------------------------------------------------------- -QVariantMap map; -map["name"] = "Name"; -map["age"] = 22; - -QByteArray data = Json::serialize(map); ------------------------------------------------------------------------- - -The byte array 'data' contains valid JSON data: - ------------------------------------------------------------------------- -{ - name: "Luis Gustavo", - age: 22, -} ------------------------------------------------------------------------- - - -######################################################################## -4. CONTRIBUTING - -The code is available to download at GitHub. Contribute if you dare! diff --git a/oracle/src/qt-json/json.cpp b/oracle/src/qt-json/json.cpp deleted file mode 100644 index ff739b49d..000000000 --- a/oracle/src/qt-json/json.cpp +++ /dev/null @@ -1,545 +0,0 @@ -/* Copyright 2011 Eeli Reilin. All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * - * THIS SOFTWARE IS PROVIDED BY ''AS IS'' AND ANY EXPRESS OR - * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF - * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO - * EVENT SHALL EELI REILIN OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, - * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, - * OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF - * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING - * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, - * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * - * The views and conclusions contained in the software and documentation - * are those of the authors and should not be interpreted as representing - * official policies, either expressed or implied, of Eeli Reilin. - */ - -/** - * \file json.cpp - */ - -#include "json.h" - -#include -#include - -namespace QtJson -{ - -static QString sanitizeString(QString str) -{ - str.replace(QLatin1String("\\"), QLatin1String("\\\\")); - str.replace(QLatin1String("\""), QLatin1String("\\\"")); - str.replace(QLatin1String("\b"), QLatin1String("\\b")); - str.replace(QLatin1String("\f"), QLatin1String("\\f")); - str.replace(QLatin1String("\n"), QLatin1String("\\n")); - str.replace(QLatin1String("\r"), QLatin1String("\\r")); - str.replace(QLatin1String("\t"), QLatin1String("\\t")); - return QString(QLatin1String("\"%1\"")).arg(str); -} - -static QByteArray join(const QList &list, const QByteArray &sep) -{ - QByteArray res; - for (const QByteArray &i : list) { - if (!res.isEmpty()) { - res += sep; - } - res += i; - } - return res; -} - -/** - * parse - */ -QVariant Json::parse(const QString &json) -{ - bool success = true; - return Json::parse(json, success); -} - -/** - * parse - */ -QVariant Json::parse(const QString &json, bool &success) -{ - success = true; - - // Return an empty QVariant if the JSON data is either null or empty - if (!json.isNull() || !json.isEmpty()) { - // We'll start from index 0 - int index = 0; - - // Parse the first value - QVariant value = Json::parseValue(json, index, success); - - // Return the parsed value - return value; - } else { - // Return the empty QVariant - return QVariant(); - } -} - -QByteArray Json::serialize(const QVariant &data) -{ - bool success = true; - return Json::serialize(data, success); -} - -QByteArray Json::serialize(const QVariant &data, bool &success) -{ - QByteArray str; - success = true; - - if (!data.isValid()) // invalid or null? - { - str = "null"; - } - else if ((data.typeId() == QMetaType::Type::QVariantList) || - (data.typeId() == QMetaType::Type::QStringList)) // variant is a list? - { - QList values; - const QVariantList list = data.toList(); - for (const QVariant &v : list) { - QByteArray serializedValue = serialize(v); - if (serializedValue.isNull()) { - success = false; - break; - } - values << serializedValue; - } - - str = "[ " + join(values, ", ") + " ]"; - } - else if ((data.typeId() == QMetaType::Type::QVariantHash)) // variant is a hash? - { - const QVariantHash vhash = data.toHash(); - QHashIterator it(vhash); - str = "{ "; - QList pairs; - - while (it.hasNext()) { - it.next(); - QByteArray serializedValue = serialize(it.value()); - - if (serializedValue.isNull()) { - success = false; - break; - } - - pairs << sanitizeString(it.key()).toUtf8() + " : " + serializedValue; - } - - str += join(pairs, ", "); - str += " }"; - } - else if ((data.typeId() == QMetaType::Type::QVariantMap)) // variant is a map? - { - const QVariantMap vmap = data.toMap(); - QMapIterator it(vmap); - str = "{ "; - QList pairs; - while (it.hasNext()) { - it.next(); - QByteArray serializedValue = serialize(it.value()); - if (serializedValue.isNull()) { - success = false; - break; - } - pairs << sanitizeString(it.key()).toUtf8() + " : " + serializedValue; - } - str += join(pairs, ", "); - str += " }"; - } - else if ((data.typeId() == QMetaType::Type::QString) || - (data.typeId() == QMetaType::Type::QByteArray)) // a string or a byte array? - { - str = sanitizeString(data.toString()).toUtf8(); - } - else if (data.typeId() == QMetaType::Type::Double) // double? - { - str = QByteArray::number(data.toDouble(), 'g', 20); - if (!str.contains(".") && !str.contains("e")) { - str += ".0"; - } - } - else if (data.typeId() == QMetaType::Type::Bool) // boolean value? - { - str = data.toBool() ? "true" : "false"; - } - else if (data.typeId() == QMetaType::Type::ULongLong) // large unsigned number? - { - str = QByteArray::number(data.value()); - } else if (data.canConvert()) // any signed number? - { - str = QByteArray::number(data.value()); - } else if (data.canConvert()) { - str = QString::number(data.value()).toUtf8(); - } else if (data.canConvert()) // can value be converted to string? - { - // this will catch QDate, QDateTime, QUrl, ... - str = sanitizeString(data.toString()).toUtf8(); - } else { - success = false; - } - if (success) { - return str; - } else { - return QByteArray(); - } -} - -/** - * parseValue - */ -QVariant Json::parseValue(const QString &json, int &index, bool &success) -{ - // Determine what kind of data we should parse by - // checking out the upcoming token - switch (Json::lookAhead(json, index)) { - case JsonTokenString: - return Json::parseString(json, index, success); - case JsonTokenNumber: - return Json::parseNumber(json, index); - case JsonTokenCurlyOpen: - return Json::parseObject(json, index, success); - case JsonTokenSquaredOpen: - return Json::parseArray(json, index, success); - case JsonTokenTrue: - Json::nextToken(json, index); - return QVariant(true); - case JsonTokenFalse: - Json::nextToken(json, index); - return QVariant(false); - case JsonTokenNull: - Json::nextToken(json, index); - return QVariant(); - case JsonTokenNone: - break; - } - - // If there were no tokens, flag the failure and return an empty QVariant - success = false; - return QVariant(); -} - -/** - * parseObject - */ -QVariant Json::parseObject(const QString &json, int &index, bool &success) -{ - QVariantMap map; - int token; - - // Get rid of the whitespace and increment index - Json::nextToken(json, index); - - // Loop through all of the key/value pairs of the object - bool done = false; - while (!done) { - // Get the upcoming token - token = Json::lookAhead(json, index); - - if (token == JsonTokenNone) { - success = false; - return QVariantMap(); - } else if (token == JsonTokenComma) { - Json::nextToken(json, index); - } else if (token == JsonTokenCurlyClose) { - Json::nextToken(json, index); - return map; - } else { - // Parse the key/value pair's name - QString name = Json::parseString(json, index, success).toString(); - - if (!success) { - return QVariantMap(); - } - - // Get the next token - token = Json::nextToken(json, index); - - // If the next token is not a colon, flag the failure - // return an empty QVariant - if (token != JsonTokenColon) { - success = false; - return QVariant(QVariantMap()); - } - - // Parse the key/value pair's value - QVariant value = Json::parseValue(json, index, success); - - if (!success) { - return QVariantMap(); - } - - // Assign the value to the key in the map - map[name] = value; - } - } - - // Return the map successfully - return QVariant(map); -} - -/** - * parseArray - */ -QVariant Json::parseArray(const QString &json, int &index, bool &success) -{ - QVariantList list; - - Json::nextToken(json, index); - - bool done = false; - while (!done) { - int token = Json::lookAhead(json, index); - - if (token == JsonTokenNone) { - success = false; - return QVariantList(); - } else if (token == JsonTokenComma) { - Json::nextToken(json, index); - } else if (token == JsonTokenSquaredClose) { - Json::nextToken(json, index); - break; - } else { - QVariant value = Json::parseValue(json, index, success); - - if (!success) { - return QVariantList(); - } - - list.push_back(value); - } - } - - return QVariant(list); -} - -/** - * parseString - */ -QVariant Json::parseString(const QString &json, int &index, bool &success) -{ - QString s; - QChar c; - - Json::eatWhitespace(json, index); - - c = json[index++]; - - bool complete = false; - while (!complete) { - if (index == json.size()) { - break; - } - - c = json[index++]; - - if (c == '\"') { - complete = true; - break; - } else if (c == '\\') { - if (index == json.size()) { - break; - } - - c = json[index++]; - - if (c == '\"') { - s.append('\"'); - } else if (c == '\\') { - s.append('\\'); - } else if (c == '/') { - s.append('/'); - } else if (c == 'b') { - s.append('\b'); - } else if (c == 'f') { - s.append('\f'); - } else if (c == 'n') { - s.append('\n'); - } else if (c == 'r') { - s.append('\r'); - } else if (c == 't') { - s.append('\t'); - } else if (c == 'u') { - int remainingLength = json.size() - index; - - if (remainingLength >= 4) { - QString unicodeStr = json.mid(index, 4); - - int symbol = unicodeStr.toInt(0, 16); - - s.append(QChar(symbol)); - - index += 4; - } else { - break; - } - } - } else { - s.append(c); - } - } - - if (!complete) { - success = false; - return QVariant(); - } - - return QVariant(s); -} - -/** - * parseNumber - */ -QVariant Json::parseNumber(const QString &json, int &index) -{ - Json::eatWhitespace(json, index); - - int lastIndex = Json::lastIndexOfNumber(json, index); - int charLength = (lastIndex - index) + 1; - QString numberStr; - - numberStr = json.mid(index, charLength); - - index = lastIndex + 1; - - if (numberStr.contains('.')) { - return QVariant(numberStr.toDouble(NULL)); - } else if (numberStr.startsWith('-')) { - return QVariant(numberStr.toLongLong(NULL)); - } else { - return QVariant(numberStr.toULongLong(NULL)); - } -} - -/** - * lastIndexOfNumber - */ -int Json::lastIndexOfNumber(const QString &json, int index) -{ - static const QString numericCharacters("0123456789+-.eE"); - int lastIndex; - - for (lastIndex = index; lastIndex < json.size(); lastIndex++) { - if (numericCharacters.indexOf(json[lastIndex]) == -1) { - break; - } - } - - return lastIndex - 1; -} - -/** - * eatWhitespace - */ -void Json::eatWhitespace(const QString &json, int &index) -{ - static const QString whitespaceChars(" \t\n\r"); - for (; index < json.size(); index++) { - if (whitespaceChars.indexOf(json[index]) == -1) { - break; - } - } -} - -/** - * lookAhead - */ -int Json::lookAhead(const QString &json, int index) -{ - int saveIndex = index; - return Json::nextToken(json, saveIndex); -} - -/** - * nextToken - */ -int Json::nextToken(const QString &json, int &index) -{ - Json::eatWhitespace(json, index); - - if (index == json.size()) { - return JsonTokenNone; - } - - QChar c = json[index]; - index++; - switch (c.toLatin1()) { - case '{': - return JsonTokenCurlyOpen; - case '}': - return JsonTokenCurlyClose; - case '[': - return JsonTokenSquaredOpen; - case ']': - return JsonTokenSquaredClose; - case ',': - return JsonTokenComma; - case '"': - return JsonTokenString; - case '0': - case '1': - case '2': - case '3': - case '4': - case '5': - case '6': - case '7': - case '8': - case '9': - case '-': - return JsonTokenNumber; - case ':': - return JsonTokenColon; - } - - index--; - - int remainingLength = json.size() - index; - - // True - if (remainingLength >= 4) { - if (json[index] == 't' && json[index + 1] == 'r' && json[index + 2] == 'u' && json[index + 3] == 'e') { - index += 4; - return JsonTokenTrue; - } - } - - // False - if (remainingLength >= 5) { - if (json[index] == 'f' && json[index + 1] == 'a' && json[index + 2] == 'l' && json[index + 3] == 's' && - json[index + 4] == 'e') { - index += 5; - return JsonTokenFalse; - } - } - - // Null - if (remainingLength >= 4) { - if (json[index] == 'n' && json[index + 1] == 'u' && json[index + 2] == 'l' && json[index + 3] == 'l') { - index += 4; - return JsonTokenNull; - } - } - - return JsonTokenNone; -} - -} // namespace QtJson diff --git a/oracle/src/qt-json/json.h b/oracle/src/qt-json/json.h deleted file mode 100644 index cf0499d4e..000000000 --- a/oracle/src/qt-json/json.h +++ /dev/null @@ -1,204 +0,0 @@ -/* Copyright 2011 Eeli Reilin. All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, - * this list of conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * - * THIS SOFTWARE IS PROVIDED BY ''AS IS'' AND ANY EXPRESS OR - * IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF - * MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO - * EVENT SHALL EELI REILIN OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, - * INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT - * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, - * OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF - * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING - * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, - * EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - * - * The views and conclusions contained in the software and documentation - * are those of the authors and should not be interpreted as representing - * official policies, either expressed or implied, of Eeli Reilin. - */ - -/** - * \file json.h - */ - -#ifndef JSON_H -#define JSON_H - -#include -#include - -namespace QtJson -{ - -/** - * \enum JsonToken - */ -enum JsonToken -{ - JsonTokenNone = 0, - JsonTokenCurlyOpen = 1, - JsonTokenCurlyClose = 2, - JsonTokenSquaredOpen = 3, - JsonTokenSquaredClose = 4, - JsonTokenColon = 5, - JsonTokenComma = 6, - JsonTokenString = 7, - JsonTokenNumber = 8, - JsonTokenTrue = 9, - JsonTokenFalse = 10, - JsonTokenNull = 11 -}; - -/** - * \class Json - * \brief A JSON data parser - * - * Json parses a JSON data into a QVariant hierarchy. - */ -class Json -{ - public: - /** - * Parse a JSON string - * - * \param json The JSON data - */ - static QVariant parse(const QString &json); - - /** - * Parse a JSON string - * - * \param json The JSON data - * \param success The success of the parsing - */ - static QVariant parse(const QString &json, bool &success); - - /** - * This method generates a textual JSON representation - * - * \param data The JSON data generated by the parser. - * \param success The success of the serialization - */ - static QByteArray serialize(const QVariant &data); - - /** - * This method generates a textual JSON representation - * - * \param data The JSON data generated by the parser. - * \param success The success of the serialization - * - * \return QByteArray Textual JSON representation - */ - static QByteArray serialize(const QVariant &data, bool &success); - - private: - /** - * Parses a value starting from index - * - * \param json The JSON data - * \param index The start index - * \param success The success of the parse process - * - * \return QVariant The parsed value - */ - static QVariant parseValue(const QString &json, int &index, - bool &success); - - /** - * Parses an object starting from index - * - * \param json The JSON data - * \param index The start index - * \param success The success of the object parse - * - * \return QVariant The parsed object map - */ - static QVariant parseObject(const QString &json, int &index, - bool &success); - - /** - * Parses an array starting from index - * - * \param json The JSON data - * \param index The starting index - * \param success The success of the array parse - * - * \return QVariant The parsed variant array - */ - static QVariant parseArray(const QString &json, int &index, - bool &success); - - /** - * Parses a string starting from index - * - * \param json The JSON data - * \param index The starting index - * \param success The success of the string parse - * - * \return QVariant The parsed string - */ - static QVariant parseString(const QString &json, int &index, - bool &success); - - /** - * Parses a number starting from index - * - * \param json The JSON data - * \param index The starting index - * - * \return QVariant The parsed number - */ - static QVariant parseNumber(const QString &json, int &index); - - /** - * Get the last index of a number starting from index - * - * \param json The JSON data - * \param index The starting index - * - * \return The last index of the number - */ - static int lastIndexOfNumber(const QString &json, int index); - - /** - * Skip unwanted whitespace symbols starting from index - * - * \param json The JSON data - * \param index The start index - */ - static void eatWhitespace(const QString &json, int &index); - - /** - * Check what token lies ahead - * - * \param json The JSON data - * \param index The starting index - * - * \return int The upcoming token - */ - static int lookAhead(const QString &json, int index); - - /** - * Get the next JSON token - * - * \param json The JSON data - * \param index The starting index - * - * \return int The next JSON token - */ - static int nextToken(const QString &json, int &index); -}; - - -} //end namespace - -#endif //JSON_H diff --git a/oracle/src/raw_json_scanner.cpp b/oracle/src/raw_json_scanner.cpp new file mode 100644 index 000000000..2396b59ae --- /dev/null +++ b/oracle/src/raw_json_scanner.cpp @@ -0,0 +1,681 @@ +#include "raw_json_scanner.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + +// Nesting cap matching QJsonDocument's limit, so a pathologically deep document +// fails shallowly instead of overflowing the stack through the recursive +// skipValue/skipArray/skipObject walk (Qt's parser caps at 1024 for the same +// reason and reports DeepNesting). +constexpr int kMaxNestingDepth = 1024; + +/** + * @brief Throttled byte-position reporting for scanSetRanges(). + * + * Threaded through the skip walk so progress can be reported without materializing + * the whole document. Reports are rate-limited so a GUI showing progress isn't + * flooded with interrupts: a callback is invoked at most ~100 times per scan + * regardless of element count. The closing stretch (the last ~1%) is reported + * more finely so a large document doesn't stall the progress bar on the final + * percent before the scan wraps up. + */ +struct ScanProgress +{ + const char *begin = nullptr; + qsizetype size = 0; + RawJson::ScanProgressCallback callback; + qsizetype step = 1; + qsizetype lastReported = 0; + + /** + * @brief Reports the scanner's absolute offset, unless within @p step bytes + * of the previous report and not yet at the end of the document. + */ + void report(const char *p) + { + if (!callback) { + return; + } + const qsizetype offset = p - begin; + if (offset == lastReported) { + return; // the final element often already sits exactly at the end + } + const qsizetype reportingStep = offset >= size - step ? std::max(1, step / 16) : step; + if (offset - lastReported < reportingStep && offset < size) { + return; + } + lastReported = offset; + callback(offset, size); + } +}; + +inline bool isWhitespace(char c) +{ + return c == ' ' || c == '\t' || c == '\r' || c == '\n'; +} + +const char *skipWhitespace(const char *p, const char *end) +{ + while (p < end && isWhitespace(*p)) { + ++p; + } + return p; +} + +inline bool isHexDigit(char c) +{ + return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); +} + +inline quint8 hexValue(char c) +{ + if (c >= '0' && c <= '9') { + return c - '0'; + } + if (c >= 'a' && c <= 'f') { + return c - 'a' + 10; + } + return c - 'A' + 10; +} + +/** + * @brief Skips past a JSON string without decoding it, validating escapes. + * @param p In: pointing at the opening quote. Out: pointing past the closing quote. + */ +bool skipString(const char *&p, const char *end) +{ + ++p; // opening quote + for (;;) { + const void *quote = memchr(p, '"', static_cast(end - p)); + if (!quote) { + return false; // unterminated string + } + // Backslash escapes can only appear before the closing quote, so bound + // the scan to the string extent instead of the rest of the document. + const void *backslash = memchr(p, '\\', static_cast(static_cast(quote) - p)); + if (!backslash) { + p = static_cast(quote) + 1; + return true; + } + const char *b = static_cast(backslash); + if (end - b < 2) { + return false; + } + const char escaped = b[1]; + if (escaped == 'u') { + if (end - b < 6) { + return false; + } + quint32 codepoint = 0; + for (int i = 0; i < 4; ++i) { + if (!isHexDigit(b[2 + i])) { + return false; + } + codepoint = codepoint * 16 + hexValue(b[2 + i]); + } + p = b + 6; + if (codepoint >= 0xD800 && codepoint <= 0xDBFF) { + // expect the low-surrogate escape for the second half + if (end - p < 6 || p[0] != '\\' || p[1] != 'u') { + return false; // unpaired high surrogate + } + quint32 low = 0; + for (int i = 0; i < 4; ++i) { + if (!isHexDigit(p[2 + i])) { + return false; + } + low = low * 16 + hexValue(p[2 + i]); + } + if (low < 0xDC00 || low > 0xDFFF) { + return false; + } + p += 6; + } else if (codepoint >= 0xDC00 && codepoint <= 0xDFFF) { + return false; // unpaired low surrogate + } + continue; + } + switch (escaped) { + case '"': + case '\\': + case '/': + case 'b': + case 'f': + case 'n': + case 'r': + case 't': + p = b + 2; + continue; + default: + return false; // invalid escape + } + } +} + +/** + * @brief Decodes a JSON string into @p out, validating it as it goes. + * @param p In: pointing at the opening quote. Out: pointing past the closing quote. + */ +bool decodeString(const char *&p, const char *end, QString &out) +{ + out.clear(); + QByteArray utf8; + auto flush = [&out, &utf8]() { + if (!utf8.isEmpty()) { + out += QString::fromUtf8(utf8); + utf8.clear(); + } + }; + + ++p; // opening quote + while (p < end) { + const char c = *p; + if (c == '\\') { + flush(); + ++p; // escaped character + if (p >= end) { + return false; + } + const char escaped = *p; + if (escaped == 'u') { + ++p; // first hex digit + if (p + 4 > end) { + return false; + } + quint32 codepoint = 0; + for (int i = 0; i < 4; ++i) { + if (!isHexDigit(p[i])) { + return false; + } + codepoint = codepoint * 16 + hexValue(p[i]); + } + p += 4; + if (codepoint >= 0xD800 && codepoint <= 0xDBFF) { + // expect a low-surrogate escape for the second half + if (p + 6 > end || p[0] != '\\' || p[1] != 'u') { + return false; // unpaired high surrogate + } + quint32 low = 0; + for (int i = 0; i < 4; ++i) { + if (!isHexDigit(p[2 + i])) { + return false; + } + low = low * 16 + hexValue(p[2 + i]); + } + if (low < 0xDC00 || low > 0xDFFF) { + return false; + } + out += QChar(codepoint); + out += QChar(low); + p += 6; + } else if (codepoint >= 0xDC00 && codepoint <= 0xDFFF) { + return false; // unpaired low surrogate + } else { + out += QChar(codepoint); + } + continue; + } + switch (escaped) { + case '"': + out += '"'; + break; + case '\\': + out += '\\'; + break; + case '/': + out += '/'; + break; + case 'b': + out += '\b'; + break; + case 'f': + out += '\f'; + break; + case 'n': + out += '\n'; + break; + case 'r': + out += '\r'; + break; + case 't': + out += '\t'; + break; + default: + return false; + } + ++p; + continue; + } + if (c == '"') { + ++p; + flush(); + return true; + } + // Deliberately accept unescaped control characters (e.g. a tab inside + // a set name): QJsonDocument and skipString accept them too, so + // rejecting them here would fail the whole document on a byte that + // Qt is fine with — the very total-failure mode this scanner avoids. + utf8 += c; + ++p; + } + return false; +} + +/** + * @brief Reads a set-metadata field, tolerating null and non-string values. + * + * A set's metadata may carry null or non-string values in otherwise-valid + * payloads ("releaseDate": null, "type": 7). The token itself was already + * structurally validated by skipValue, so a non-string value is accepted and + * leaves @p out at its default (empty) — one bad set must not abort the + * import of every other set in the document. + */ +bool decodeStringMember(const char *&fs, const char *&fe, QString &out) +{ + if (fs >= fe) { + return false; + } + if (*fs != '"') { + return true; + } + return decodeString(fs, fe, out); +} + +bool matchLiteral(const char *&p, const char *end, const char *literal, int length) +{ + if (end - p < length || memcmp(p, literal, static_cast(length)) != 0) { + return false; + } + const char *after = p + length; + if (after < end && (QChar::isLetter(*after) || QChar::isDigit(*after) || *after == '_')) { + return false; + } + p = after; + return true; +} + +bool skipNumber(const char *&p, const char *end) +{ + // JSON number: -?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)? + if (p < end && *p == '-') { + ++p; + } + if (p < end && *p == '0') { + ++p; + } else if (p < end && *p >= '1' && *p <= '9') { + ++p; + while (p < end && QChar::isDigit(*p)) { + ++p; + } + } else { + return false; + } + if (p < end && *p == '.') { + ++p; + if (p >= end || !QChar::isDigit(*p)) { + return false; + } + while (p < end && QChar::isDigit(*p)) { + ++p; + } + } + if (p < end && (*p == 'e' || *p == 'E')) { + ++p; + if (p < end && (*p == '+' || *p == '-')) { + ++p; + } + if (p >= end || !QChar::isDigit(*p)) { + return false; + } + while (p < end && QChar::isDigit(*p)) { + ++p; + } + } + return true; +} + +bool skipValue(const char *&p, const char *end, int depth, ScanProgress &scan); +bool skipObject(const char *&p, const char *end, int depth, ScanProgress &scan); +bool skipArray(const char *&p, const char *end, int depth, ScanProgress &scan); + +bool skipPrimitive(const char *&p, const char *end) +{ + if (p >= end) { + return false; + } + const char c = *p; + if (c == '"') { + return skipString(p, end); + } + if (c == 't') { + return matchLiteral(p, end, "true", 4); + } + if (c == 'f') { + return matchLiteral(p, end, "false", 5); + } + if (c == 'n') { + return matchLiteral(p, end, "null", 4); + } + if (c == '-' || (c >= '0' && c <= '9')) { + return skipNumber(p, end); + } + return false; +} + +bool skipObject(const char *&p, const char *end, int depth, ScanProgress &scan) +{ + if (depth <= 0) { + return false; // nest deeper than the cap + } + ++p; // '{' + p = skipWhitespace(p, end); + if (p < end && *p == '}') { + ++p; + return true; + } + for (;;) { + p = skipWhitespace(p, end); + if (p >= end || *p != '"') { + return false; + } + if (!skipString(p, end)) { + return false; + } + p = skipWhitespace(p, end); + if (p >= end || *p != ':') { + return false; + } + ++p; + if (!skipValue(p, end, depth - 1, scan)) { + return false; + } + scan.report(p); + p = skipWhitespace(p, end); + if (p >= end) { + return false; + } + if (*p == ',') { + ++p; + continue; + } + if (*p == '}') { + ++p; + return true; + } + return false; + } +} + +bool skipArray(const char *&p, const char *end, int depth, ScanProgress &scan) +{ + if (depth <= 0) { + return false; // nest deeper than the cap + } + ++p; // '[' + p = skipWhitespace(p, end); + if (p < end && *p == ']') { + ++p; + return true; + } + for (;;) { + if (!skipValue(p, end, depth - 1, scan)) { + return false; + } + scan.report(p); + p = skipWhitespace(p, end); + if (p >= end) { + return false; + } + if (*p == ',') { + ++p; + continue; + } + if (*p == ']') { + ++p; + return true; + } + return false; + } +} + +bool skipValue(const char *&p, const char *end, int depth, ScanProgress &scan) +{ + p = skipWhitespace(p, end); + if (p >= end) { + return false; + } + const char c = *p; + if (c == '{') { + // pass depth through: skipObject consumes the single decrement for this level + return skipObject(p, end, depth, scan); + } + if (c == '[') { + return skipArray(p, end, depth, scan); + } + // a primitive is a leaf, so it never wastes a nesting level + return skipPrimitive(p, end); +} + +/** + * @brief Iterates the members of the object starting at @p p. + * + * For each member invokes @p memberCallback with the key and the byte range of + * its value. Advancing @p p is unaffected by the callback. + */ +template +bool forEachObjectMember(const char *&p, const char *end, int depth, F &&memberCallback, ScanProgress &scan) +{ + if (depth <= 0) { + return false; // nest deeper than the cap + } + ++p; // '{' + p = skipWhitespace(p, end); + if (p < end && *p == '}') { + ++p; + return true; + } + for (;;) { + p = skipWhitespace(p, end); + if (p >= end || *p != '"') { + return false; + } + QString key; + if (!decodeString(p, end, key)) { + return false; + } + p = skipWhitespace(p, end); + if (p >= end || *p != ':') { + return false; + } + ++p; + const char *valueStart = skipWhitespace(p, end); + const char *valueEnd = valueStart; + if (!skipValue(valueEnd, end, depth - 1, scan)) { + return false; + } + if (!memberCallback(key, valueStart, valueEnd)) { + return false; + } + p = valueEnd; + p = skipWhitespace(p, end); + if (p >= end) { + return false; + } + if (*p == ',') { + ++p; + continue; + } + if (*p == '}') { + ++p; + return true; + } + return false; + } +} + +// Counts the direct elements of an array value; returns -1 if the array is malformed. +int countArrayElements(const char *p, const char *end, int depth, ScanProgress &scan) +{ + if (depth <= 0) { + return -1; // nest deeper than the cap + } + ++p; // '[' + p = skipWhitespace(p, end); + int count = 0; + if (p < end && *p == ']') { + return 0; + } + for (;;) { + if (!skipValue(p, end, depth - 1, scan)) { + return -1; + } + scan.report(p); + ++count; + p = skipWhitespace(p, end); + if (p >= end) { + return -1; + } + if (*p == ',') { + ++p; + continue; + } + if (*p == ']') { + return count; + } + return -1; + } +} + +} // namespace + +namespace RawJson +{ + +QList scanSetRanges(const QByteArray &json, ScanError *error, const ScanProgressCallback &progress) +{ + QList ranges; + if (error) { + *error = ScanError{}; + } + + const auto fail = [&](const QString &message) -> QList { + if (error) { + error->message = message; + } + return {}; + }; + + const char *begin = json.constData(); + const char *end = begin + json.size(); + if (begin >= end) { + return fail(QStringLiteral("empty JSON document")); + } + + // Throttle reports to ~100 per scan so a GUI thread unthrottling them never + // drowns under per-card interrupts, whatever the document size. + ScanProgress scan; + scan.begin = begin; + scan.size = end - begin; + scan.step = std::max(1, scan.size / 100); + scan.callback = progress; + + const char *p = skipWhitespace(begin, end); + if (p >= end || *p != '{') { + return fail(QStringLiteral("top-level JSON must be an object")); + } + + bool foundData = false; + bool malformedSetData = false; + + const auto topLevelCallback = [&](const QString &key, const char *valueStart, const char *valueEnd) { + if (key == QStringLiteral("data")) { + foundData = true; + if (valueStart >= valueEnd || *valueStart != '{') { + malformedSetData = true; + return false; + } + const char *setP = valueStart; + const bool ok = forEachObjectMember( + setP, valueEnd, kMaxNestingDepth - 1, + [&](const QString &setCode, const char *setStart, const char *setEnd) { + if (setStart >= setEnd || *setStart != '{') { + malformedSetData = true; + return false; + } + SetRange range; + range.dataRange.start = setStart - begin; + range.dataRange.length = setEnd - setStart; + range.code = setCode; + + const char *memberP = setStart; + const bool metaOk = forEachObjectMember( + memberP, setEnd, kMaxNestingDepth - 2, + [&](const QString &field, const char *fs, const char *fe) { + if (field == QStringLiteral("code")) { + return decodeStringMember(fs, fe, range.code); + } + if (field == QStringLiteral("name")) { + return decodeStringMember(fs, fe, range.name); + } + if (field == QStringLiteral("type")) { + return decodeStringMember(fs, fe, range.type); + } + if (field == QStringLiteral("releaseDate")) { + return decodeStringMember(fs, fe, range.releaseDate); + } + if (field == QStringLiteral("cards")) { + if (fs >= fe) { + return false; + } + if (*fs != '[') { + // e.g. "cards": null — treat as an empty array, + // matching Qt's tolerance. + return true; + } + range.dataRange.cardCount = countArrayElements(fs, fe, kMaxNestingDepth - 2, scan); + return range.dataRange.cardCount >= 0; + } + return true; + }, + scan); + if (!metaOk) { + malformedSetData = true; + return false; + } + ranges.append(range); + return true; + }, + scan); + if (!ok) { + malformedSetData = true; + return false; + } + } + return true; + }; + + if (!forEachObjectMember(p, end, kMaxNestingDepth, topLevelCallback, scan)) { + return fail(malformedSetData ? QStringLiteral("malformed set data") : QStringLiteral("malformed JSON")); + } + p = skipWhitespace(p, end); + if (p != end) { + return fail(QStringLiteral("trailing content after top-level JSON object")); + } + if (!foundData) { + return fail(QStringLiteral("missing \"data\" object")); + } + if (ranges.isEmpty()) { + return fail(QStringLiteral("no sets found in \"data\"")); + } + scan.report(end); + return ranges; +} + +} // namespace RawJson \ No newline at end of file diff --git a/oracle/src/raw_json_scanner.h b/oracle/src/raw_json_scanner.h new file mode 100644 index 000000000..18030d834 --- /dev/null +++ b/oracle/src/raw_json_scanner.h @@ -0,0 +1,91 @@ +#ifndef RAW_JSON_SCANNER_H +#define RAW_JSON_SCANNER_H + +#include +#include +#include +#include + +class QByteArray; + +namespace RawJson +{ + +/** + * @brief The byte extent of a set's object inside the scanned document, plus + * the size of its cards array. This is the slice SetToDownload needs for lazy + * per-set parsing; the metadata strings live in SetRange alongside it. + */ +struct SetDataRange +{ + /** @brief Byte offset of the set's object within the scanned buffer. */ + qsizetype start = -1; + /** @brief Byte length of the set's object, including the surrounding braces. */ + qsizetype length = 0; + /** @brief Number of entries in the set's "cards" array. */ + int cardCount = 0; +}; + +struct SetRange +{ + /** @brief The byte slice of this set within the document. */ + SetDataRange dataRange; + QString code; + QString name; + QString type; + QString releaseDate; +}; + +struct ScanError +{ + bool isError() const + { + return !message.isEmpty(); + } + QString message; +}; + +/** + * @brief Optional progress callback receiving @c (bytesRead, totalBytes) while + * the document is walked. Invoked from the scanning thread; the caller decides + * how the throttled offsets are relayed to a GUI event loop. + */ +using ScanProgressCallback = std::function; + +/** + * @brief Scans a full MTGJSON document without materializing the JSON tree. + * + * Splits the top-level "data" object into per-set byte ranges and reads each + * set's metadata directly from the raw bytes. The Oracle importer can then + * parse one set at a time during import, keeping peak memory far below a single + * QJsonDocument::fromJson() over the whole file. + * + * The whole document is structurally validated while scanning (strings, + * escapes, braces, and a trailing-content check) and nesting depth is capped at + * 1024 to match QJsonDocument, so pathologically deep documents fail shallowly + * instead of exhausting the stack. Verdicts agree with QJsonDocument::fromJson + * on structurally malformed input; unlike Qt, string metadata fields + * ("name", "type", "releaseDate", "code") tolerate null / non-string values by + * defaulting to empty rather than rejecting the whole document, so one broken + * set cannot abort the import of the rest. + * + * Following QJsonDocument::fromJson's convention, the parsed ranges are + * returned by value and any failure is reported through the @p error out + * parameter. + * + * @param json The raw MTGJSON document bytes. + * @param error Out parameter. Set to an error ScanError when the document + * cannot be parsed, otherwise left empty. Passing a null + * pointer disables error reporting. + * @param progress Optional progress callback. When non-empty it is invoked as + * the scanner advances through the document, throttled to a + * tiny fraction of the total size. + * @return The detected per-set ranges, or an empty list on failure. + */ +QList scanSetRanges(const QByteArray &json, + ScanError *error = nullptr, + const ScanProgressCallback &progress = ScanProgressCallback()); + +} // namespace RawJson + +#endif // RAW_JSON_SCANNER_H \ No newline at end of file diff --git a/qt6.imp b/qt6.imp new file mode 100644 index 000000000..4f09d6406 --- /dev/null +++ b/qt6.imp @@ -0,0 +1,2471 @@ +# Do not edit! Generated by generate_qt_mappings.py from +# /usr/include/qt6 +[ + { "symbol": [ "QAbstractAnimation", "private", "", "public"] }, + { "symbol": [ "QAbstractButton", "private", "", "public"] }, + { "symbol": [ "QAbstractEventDispatcher", "private", "", "public"] }, + { "symbol": [ "QAbstractEventDispatcherV2", "private", "", "public"] }, + { "symbol": [ "QAbstractFileIconProvider", "private", "", "public"] }, + { "symbol": [ "QAbstractGraphicsShapeItem", "private", "", "public"] }, + { "symbol": [ "QAbstractItemDelegate", "private", "", "public"] }, + { "symbol": [ "QAbstractItemModel", "private", "", "public"] }, + { "symbol": [ "QAbstractItemModelTester", "private", "", "public"] }, + { "symbol": [ "QAbstractItemView", "private", "", "public"] }, + { "symbol": [ "QAbstractListModel", "private", "", "public"] }, + { "symbol": [ "QAbstractNativeEventFilter", "private", "", "public"] }, + { "symbol": [ "QAbstractNetworkCache", "private", "", "public"] }, + { "symbol": [ "QAbstractOAuth", "private", "", "public"] }, + { "symbol": [ "QAbstractOAuth2", "private", "", "public"] }, + { "symbol": [ "QAbstractOAuthReplyHandler", "private", "", "public"] }, + { "symbol": [ "QAbstractPrintDialog", "private", "", "public"] }, + { "symbol": [ "QAbstractProxyModel", "private", "", "public"] }, + { "symbol": [ "QAbstractScrollArea", "private", "", "public"] }, + { "symbol": [ "QAbstractSlider", "private", "", "public"] }, + { "symbol": [ "QAbstractSocket", "private", "", "public"] }, + { "symbol": [ "QAbstractSpinBox", "private", "", "public"] }, + { "symbol": [ "QAbstractState", "private", "", "public"] }, + { "symbol": [ "QAbstractTableModel", "private", "", "public"] }, + { "symbol": [ "QAbstractTextDocumentLayout", "private", "", "public"] }, + { "symbol": [ "QAbstractTransition", "private", "", "public"] }, + { "symbol": [ "QAbstractUndoItem", "private", "", "public"] }, + { "symbol": [ "QAbstractVideoBuffer", "private", "", "public"] }, + { "symbol": [ "QAccessibilityHints", "private", "", "public"] }, + { "symbol": [ "QAccessible", "private", "", "public"] }, + { "symbol": [ "QAccessibleActionInterface", "private", "", "public"] }, + { "symbol": [ "QAccessibleAnnouncementEvent", "private", "", "public"] }, + { "symbol": [ "QAccessibleApplication", "private", "", "public"] }, + { "symbol": [ "QAccessibleAttributesInterface", "private", "", "public"] }, + { "symbol": [ "QAccessibleBridge", "private", "", "public"] }, + { "symbol": [ "QAccessibleBridgePlugin", "private", "", "public"] }, + { "symbol": [ "QAccessibleEditableTextInterface", "private", "", "public"] }, + { "symbol": [ "QAccessibleEvent", "private", "", "public"] }, + { "symbol": [ "QAccessibleHyperlinkInterface", "private", "", "public"] }, + { "symbol": [ "QAccessibleImageInterface", "private", "", "public"] }, + { "symbol": [ "QAccessibleInterface", "private", "", "public"] }, + { "symbol": [ "QAccessibleObject", "private", "", "public"] }, + { "symbol": [ "QAccessiblePlugin", "private", "", "public"] }, + { "symbol": [ "QAccessibleSelectionInterface", "private", "", "public"] }, + { "symbol": [ "QAccessibleStateChangeEvent", "private", "", "public"] }, + { "symbol": [ "QAccessibleTableCellInterface", "private", "", "public"] }, + { "symbol": [ "QAccessibleTableInterface", "private", "", "public"] }, + { "symbol": [ "QAccessibleTableModelChangeEvent", "private", "", "public"] }, + { "symbol": [ "QAccessibleTextCursorEvent", "private", "", "public"] }, + { "symbol": [ "QAccessibleTextInsertEvent", "private", "", "public"] }, + { "symbol": [ "QAccessibleTextInterface", "private", "", "public"] }, + { "symbol": [ "QAccessibleTextRemoveEvent", "private", "", "public"] }, + { "symbol": [ "QAccessibleTextSelectionEvent", "private", "", "public"] }, + { "symbol": [ "QAccessibleTextUpdateEvent", "private", "", "public"] }, + { "symbol": [ "QAccessibleValueChangeEvent", "private", "", "public"] }, + { "symbol": [ "QAccessibleValueInterface", "private", "", "public"] }, + { "symbol": [ "QAccessibleWidget", "private", "", "public"] }, + { "symbol": [ "QAccessibleWidgetV2", "private", "", "public"] }, + { "symbol": [ "QAction", "private", "", "public"] }, + { "symbol": [ "QActionEvent", "private", "", "public"] }, + { "symbol": [ "QActionGroup", "private", "", "public"] }, + { "symbol": [ "QAmbientSound", "private", "", "public"] }, + { "symbol": [ "QAnimationDriver", "private", "", "public"] }, + { "symbol": [ "QAnimationGroup", "private", "", "public"] }, + { "symbol": [ "QAnyStringView", "private", "", "public"] }, + { "symbol": [ "QApplication", "private", "", "public"] }, + { "symbol": [ "QApplicationStateChangeEvent", "private", "", "public"] }, + { "symbol": [ "QApplicationStatic", "private", "", "public"] }, + { "symbol": [ "QArgument", "private", "", "public"] }, + { "symbol": [ "QArrayData", "private", "", "public"] }, + { "symbol": [ "QArrayDataOps", "private", "", "public"] }, + { "symbol": [ "QArrayDataPointer", "private", "", "public"] }, + { "symbol": [ "QAssociativeConstIterator", "private", "", "public"] }, + { "symbol": [ "QAssociativeIterable", "private", "", "public"] }, + { "symbol": [ "QAssociativeIterator", "private", "", "public"] }, + { "symbol": [ "QAtomicInt", "private", "", "public"] }, + { "symbol": [ "QAtomicInteger", "private", "", "public"] }, + { "symbol": [ "QAtomicPointer", "private", "", "public"] }, + { "symbol": [ "QAtomicScopedValueRollback", "private", "", "public"] }, + { "symbol": [ "QAudio", "private", "", "public"] }, + { "symbol": [ "QAudioBuffer", "private", "", "public"] }, + { "symbol": [ "QAudioBufferInput", "private", "", "public"] }, + { "symbol": [ "QAudioBufferOutput", "private", "", "public"] }, + { "symbol": [ "QAudioDecoder", "private", "", "public"] }, + { "symbol": [ "QAudioDevice", "private", "", "public"] }, + { "symbol": [ "QAudioEngine", "private", "", "public"] }, + { "symbol": [ "QAudioFormat", "private", "", "public"] }, + { "symbol": [ "QAudioFrame", "private", "", "public"] }, + { "symbol": [ "QAudioInput", "private", "", "public"] }, + { "symbol": [ "QAudioListener", "private", "", "public"] }, + { "symbol": [ "QAudioOutput", "private", "", "public"] }, + { "symbol": [ "QAudioRoom", "private", "", "public"] }, + { "symbol": [ "QAudioSink", "private", "", "public"] }, + { "symbol": [ "QAudioSource", "private", "", "public"] }, + { "symbol": [ "QAuthenticator", "private", "", "public"] }, + { "symbol": [ "QBEInteger", "private", "", "public"] }, + { "symbol": [ "QBackingStore", "private", "", "public"] }, + { "symbol": [ "QBaseIterator", "private", "", "public"] }, + { "symbol": [ "QBasicMutex", "private", "", "public"] }, + { "symbol": [ "QBasicReadWriteLock", "private", "", "public"] }, + { "symbol": [ "QBasicTimer", "private", "", "public"] }, + { "symbol": [ "QBasicUtf8StringView", "private", "", "public"] }, + { "symbol": [ "QBigEndianStorageType", "private", "", "public"] }, + { "symbol": [ "QBinaryJson", "private", "", "public"] }, + { "symbol": [ "QBindable", "private", "", "public"] }, + { "symbol": [ "QBindingStatus", "private", "", "public"] }, + { "symbol": [ "QBindingStorage", "private", "", "public"] }, + { "symbol": [ "QBitArray", "private", "", "public"] }, + { "symbol": [ "QBitRef", "private", "", "public"] }, + { "symbol": [ "QBitmap", "private", "", "public"] }, + { "symbol": [ "QBluetoothAddress", "private", "", "public"] }, + { "symbol": [ "QBluetoothDeviceDiscoveryAgent", "private", "", "public"] }, + { "symbol": [ "QBluetoothDeviceInfo", "private", "", "public"] }, + { "symbol": [ "QBluetoothHostInfo", "private", "", "public"] }, + { "symbol": [ "QBluetoothLocalDevice", "private", "", "public"] }, + { "symbol": [ "QBluetoothPermission", "private", "", "public"] }, + { "symbol": [ "QBluetoothServer", "private", "", "public"] }, + { "symbol": [ "QBluetoothServiceDiscoveryAgent", "private", "", "public"] }, + { "symbol": [ "QBluetoothServiceInfo", "private", "", "public"] }, + { "symbol": [ "QBluetoothSocket", "private", "", "public"] }, + { "symbol": [ "QBluetoothUuid", "private", "", "public"] }, + { "symbol": [ "QBoxLayout", "private", "", "public"] }, + { "symbol": [ "QBrush", "private", "", "public"] }, + { "symbol": [ "QBrushData", "private", "", "public"] }, + { "symbol": [ "QBrushDataPointerDeleter", "private", "", "public"] }, + { "symbol": [ "QBuffer", "private", "", "public"] }, + { "symbol": [ "QButtonGroup", "private", "", "public"] }, + { "symbol": [ "QByteArray", "private", "", "public"] }, + { "symbol": [ "QByteArrayAlgorithms", "private", "", "public"] }, + { "symbol": [ "QByteArrayList", "private", "", "public"] }, + { "symbol": [ "QByteArrayListIterator", "private", "", "public"] }, + { "symbol": [ "QByteArrayMatcher", "private", "", "public"] }, + { "symbol": [ "QByteArrayView", "private", "", "public"] }, + { "symbol": [ "QCache", "private", "", "public"] }, + { "symbol": [ "QCalendar", "private", "", "public"] }, + { "symbol": [ "QCalendarPermission", "private", "", "public"] }, + { "symbol": [ "QCalendarWidget", "private", "", "public"] }, + { "symbol": [ "QCamera", "private", "", "public"] }, + { "symbol": [ "QCameraDevice", "private", "", "public"] }, + { "symbol": [ "QCameraFormat", "private", "", "public"] }, + { "symbol": [ "QCapturableWindow", "private", "", "public"] }, + { "symbol": [ "QCborArray", "private", "", "public"] }, + { "symbol": [ "QCborError", "private", "", "public"] }, + { "symbol": [ "QCborMap", "private", "", "public"] }, + { "symbol": [ "QCborParserError", "private", "", "public"] }, + { "symbol": [ "QCborStreamReader", "private", "", "public"] }, + { "symbol": [ "QCborStreamWriter", "private", "", "public"] }, + { "symbol": [ "QCborValue", "private", "", "public"] }, + { "symbol": [ "QCborValueConstRef", "private", "", "public"] }, + { "symbol": [ "QCborValueRef", "private", "", "public"] }, + { "symbol": [ "QChar", "private", "", "public"] }, + { "symbol": [ "QCheckBox", "private", "", "public"] }, + { "symbol": [ "QChildEvent", "private", "", "public"] }, + { "symbol": [ "QChildWindowEvent", "private", "", "public"] }, + { "symbol": [ "QChronoTimer", "private", "", "public"] }, + { "symbol": [ "QClipboard", "private", "", "public"] }, + { "symbol": [ "QCloseEvent", "private", "", "public"] }, + { "symbol": [ "QCollator", "private", "", "public"] }, + { "symbol": [ "QCollatorSortKey", "private", "", "public"] }, + { "symbol": [ "QColor", "private", "", "public"] }, + { "symbol": [ "QColorDialog", "private", "", "public"] }, + { "symbol": [ "QColorSpace", "private", "", "public"] }, + { "symbol": [ "QColorTransform", "private", "", "public"] }, + { "symbol": [ "QColormap", "private", "", "public"] }, + { "symbol": [ "QColumnView", "private", "", "public"] }, + { "symbol": [ "QComboBox", "private", "", "public"] }, + { "symbol": [ "QCommandLineOption", "private", "", "public"] }, + { "symbol": [ "QCommandLineParser", "private", "", "public"] }, + { "symbol": [ "QCommandLinkButton", "private", "", "public"] }, + { "symbol": [ "QCommonStyle", "private", "", "public"] }, + { "symbol": [ "QCompleter", "private", "", "public"] }, + { "symbol": [ "QCompressedHelpInfo", "private", "", "public"] }, + { "symbol": [ "QConcatenateTablesProxyModel", "private", "", "public"] }, + { "symbol": [ "QConicalGradient", "private", "", "public"] }, + { "symbol": [ "QConstIterator", "private", "", "public"] }, + { "symbol": [ "QContactsPermission", "private", "", "public"] }, + { "symbol": [ "QContextMenuEvent", "private", "", "public"] }, + { "symbol": [ "QContiguousCache", "private", "", "public"] }, + { "symbol": [ "QContiguousCacheData", "private", "", "public"] }, + { "symbol": [ "QContiguousCacheTypedData", "private", "", "public"] }, + { "symbol": [ "QCoreApplication", "private", "", "public"] }, + { "symbol": [ "QCryptographicHash", "private", "", "public"] }, + { "symbol": [ "QCursor", "private", "", "public"] }, + { "symbol": [ "QDBusAbstractAdaptor", "private", "", "public"] }, + { "symbol": [ "QDBusAbstractInterface", "private", "", "public"] }, + { "symbol": [ "QDBusAbstractInterfaceBase", "private", "", "public"] }, + { "symbol": [ "QDBusArgument", "private", "", "public"] }, + { "symbol": [ "QDBusConnection", "private", "", "public"] }, + { "symbol": [ "QDBusConnectionInterface", "private", "", "public"] }, + { "symbol": [ "QDBusContext", "private", "", "public"] }, + { "symbol": [ "QDBusError", "private", "", "public"] }, + { "symbol": [ "QDBusInterface", "private", "", "public"] }, + { "symbol": [ "QDBusMessage", "private", "", "public"] }, + { "symbol": [ "QDBusMetaType", "private", "", "public"] }, + { "symbol": [ "QDBusObjectPath", "private", "", "public"] }, + { "symbol": [ "QDBusPendingCall", "private", "", "public"] }, + { "symbol": [ "QDBusPendingCallWatcher", "private", "", "public"] }, + { "symbol": [ "QDBusPendingReply", "private", "", "public"] }, + { "symbol": [ "QDBusPendingReplyBase", "private", "", "public"] }, + { "symbol": [ "QDBusReply", "private", "", "public"] }, + { "symbol": [ "QDBusServer", "private", "", "public"] }, + { "symbol": [ "QDBusServiceWatcher", "private", "", "public"] }, + { "symbol": [ "QDBusSignature", "private", "", "public"] }, + { "symbol": [ "QDBusUnixFileDescriptor", "private", "", "public"] }, + { "symbol": [ "QDBusVariant", "private", "", "public"] }, + { "symbol": [ "QDBusVirtualObject", "private", "", "public"] }, + { "symbol": [ "QDataStream", "private", "", "public"] }, + { "symbol": [ "QDataWidgetMapper", "private", "", "public"] }, + { "symbol": [ "QDate", "private", "", "public"] }, + { "symbol": [ "QDateEdit", "private", "", "public"] }, + { "symbol": [ "QDateTime", "private", "", "public"] }, + { "symbol": [ "QDateTimeEdit", "private", "", "public"] }, + { "symbol": [ "QDeadlineTimer", "private", "", "public"] }, + { "symbol": [ "QDebug", "private", "", "public"] }, + { "symbol": [ "QDebugStateSaver", "private", "", "public"] }, + { "symbol": [ "QDesignerExportWidget", "private", "", "public"] }, + { "symbol": [ "QDesktopServices", "private", "", "public"] }, + { "symbol": [ "QDial", "private", "", "public"] }, + { "symbol": [ "QDialog", "private", "", "public"] }, + { "symbol": [ "QDialogButtonBox", "private", "", "public"] }, + { "symbol": [ "QDir", "private", "", "public"] }, + { "symbol": [ "QDirIterator", "private", "", "public"] }, + { "symbol": [ "QDirListing", "private", "", "public"] }, + { "symbol": [ "QDnsDomainNameRecord", "private", "", "public"] }, + { "symbol": [ "QDnsHostAddressRecord", "private", "", "public"] }, + { "symbol": [ "QDnsLookup", "private", "", "public"] }, + { "symbol": [ "QDnsMailExchangeRecord", "private", "", "public"] }, + { "symbol": [ "QDnsServiceRecord", "private", "", "public"] }, + { "symbol": [ "QDnsTextRecord", "private", "", "public"] }, + { "symbol": [ "QDnsTlsAssociationRecord", "private", "", "public"] }, + { "symbol": [ "QDockWidget", "private", "", "public"] }, + { "symbol": [ "QDomAttr", "private", "", "public"] }, + { "symbol": [ "QDomCDATASection", "private", "", "public"] }, + { "symbol": [ "QDomCharacterData", "private", "", "public"] }, + { "symbol": [ "QDomComment", "private", "", "public"] }, + { "symbol": [ "QDomDocument", "private", "", "public"] }, + { "symbol": [ "QDomDocumentFragment", "private", "", "public"] }, + { "symbol": [ "QDomDocumentType", "private", "", "public"] }, + { "symbol": [ "QDomElement", "private", "", "public"] }, + { "symbol": [ "QDomEntity", "private", "", "public"] }, + { "symbol": [ "QDomEntityReference", "private", "", "public"] }, + { "symbol": [ "QDomImplementation", "private", "", "public"] }, + { "symbol": [ "QDomNamedNodeMap", "private", "", "public"] }, + { "symbol": [ "QDomNode", "private", "", "public"] }, + { "symbol": [ "QDomNodeList", "private", "", "public"] }, + { "symbol": [ "QDomNotation", "private", "", "public"] }, + { "symbol": [ "QDomProcessingInstruction", "private", "", "public"] }, + { "symbol": [ "QDomText", "private", "", "public"] }, + { "symbol": [ "QDoubleSpinBox", "private", "", "public"] }, + { "symbol": [ "QDoubleValidator", "private", "", "public"] }, + { "symbol": [ "QDrag", "private", "", "public"] }, + { "symbol": [ "QDragEnterEvent", "private", "", "public"] }, + { "symbol": [ "QDragLeaveEvent", "private", "", "public"] }, + { "symbol": [ "QDragMoveEvent", "private", "", "public"] }, + { "symbol": [ "QDropEvent", "private", "", "public"] }, + { "symbol": [ "QDtls", "private", "", "public"] }, + { "symbol": [ "QDtlsClientVerifier", "private", "", "public"] }, + { "symbol": [ "QDynamicPropertyChangeEvent", "private", "", "public"] }, + { "symbol": [ "QEasingCurve", "private", "", "public"] }, + { "symbol": [ "QElapsedTimer", "private", "", "public"] }, + { "symbol": [ "QEnableSharedFromThis", "private", "", "public"] }, + { "symbol": [ "QEnterEvent", "private", "", "public"] }, + { "symbol": [ "QErrorMessage", "private", "", "public"] }, + { "symbol": [ "QEvent", "private", "", "public"] }, + { "symbol": [ "QEventLoop", "private", "", "public"] }, + { "symbol": [ "QEventLoopLocker", "private", "", "public"] }, + { "symbol": [ "QEventPoint", "private", "", "public"] }, + { "symbol": [ "QEventTransition", "private", "", "public"] }, + { "symbol": [ "QException", "private", "", "public"] }, + { "symbol": [ "QExplicitlySharedDataPointer", "private", "", "public"] }, + { "symbol": [ "QExposeEvent", "private", "", "public"] }, + { "symbol": [ "QFactoryInterface", "private", "", "public"] }, + { "symbol": [ "QFile", "private", "", "public"] }, + { "symbol": [ "QFileDevice", "private", "", "public"] }, + { "symbol": [ "QFileDialog", "private", "", "public"] }, + { "symbol": [ "QFileIconProvider", "private", "", "public"] }, + { "symbol": [ "QFileInfo", "private", "", "public"] }, + { "symbol": [ "QFileInfoList", "private", "", "public"] }, + { "symbol": [ "QFileOpenEvent", "private", "", "public"] }, + { "symbol": [ "QFileSelector", "private", "", "public"] }, + { "symbol": [ "QFileSystemModel", "private", "", "public"] }, + { "symbol": [ "QFileSystemWatcher", "private", "", "public"] }, + { "symbol": [ "QFinalState", "private", "", "public"] }, + { "symbol": [ "QFlag", "private", "", "public"] }, + { "symbol": [ "QFlags", "private", "", "public"] }, + { "symbol": [ "QFloat16", "private", "", "public"] }, + { "symbol": [ "QFocusEvent", "private", "", "public"] }, + { "symbol": [ "QFocusFrame", "private", "", "public"] }, + { "symbol": [ "QFont", "private", "", "public"] }, + { "symbol": [ "QFontComboBox", "private", "", "public"] }, + { "symbol": [ "QFontDatabase", "private", "", "public"] }, + { "symbol": [ "QFontDialog", "private", "", "public"] }, + { "symbol": [ "QFontInfo", "private", "", "public"] }, + { "symbol": [ "QFontMetrics", "private", "", "public"] }, + { "symbol": [ "QFontMetricsF", "private", "", "public"] }, + { "symbol": [ "QFontVariableAxis", "private", "", "public"] }, + { "symbol": [ "QForeach", "private", "", "public"] }, + { "symbol": [ "QFormDataBuilder", "private", "", "public"] }, + { "symbol": [ "QFormDataPartBuilder", "private", "", "public"] }, + { "symbol": [ "QFormLayout", "private", "", "public"] }, + { "symbol": [ "QFrame", "private", "", "public"] }, + { "symbol": [ "QFunctionPointer", "private", "", "public"] }, + { "symbol": [ "QFuture", "private", "", "public"] }, + { "symbol": [ "QFutureInterface", "private", "", "public"] }, + { "symbol": [ "QFutureInterfaceBase", "private", "", "public"] }, + { "symbol": [ "QFutureIterator", "private", "", "public"] }, + { "symbol": [ "QFutureSynchronizer", "private", "", "public"] }, + { "symbol": [ "QFutureWatcher", "private", "", "public"] }, + { "symbol": [ "QFutureWatcherBase", "private", "", "public"] }, + { "symbol": [ "QGenericArgument", "private", "", "public"] }, + { "symbol": [ "QGenericMatrix", "private", "", "public"] }, + { "symbol": [ "QGenericPlugin", "private", "", "public"] }, + { "symbol": [ "QGenericPluginFactory", "private", "", "public"] }, + { "symbol": [ "QGenericReturnArgument", "private", "", "public"] }, + { "symbol": [ "QGesture", "private", "", "public"] }, + { "symbol": [ "QGestureEvent", "private", "", "public"] }, + { "symbol": [ "QGestureRecognizer", "private", "", "public"] }, + { "symbol": [ "QGlobalStatic", "private", "", "public"] }, + { "symbol": [ "QGlyphRun", "private", "", "public"] }, + { "symbol": [ "QGradient", "private", "", "public"] }, + { "symbol": [ "QGradientStop", "private", "", "public"] }, + { "symbol": [ "QGradientStops", "private", "", "public"] }, + { "symbol": [ "QGraphicsAnchor", "private", "", "public"] }, + { "symbol": [ "QGraphicsAnchorLayout", "private", "", "public"] }, + { "symbol": [ "QGraphicsBlurEffect", "private", "", "public"] }, + { "symbol": [ "QGraphicsColorizeEffect", "private", "", "public"] }, + { "symbol": [ "QGraphicsDropShadowEffect", "private", "", "public"] }, + { "symbol": [ "QGraphicsEffect", "private", "", "public"] }, + { "symbol": [ "QGraphicsEllipseItem", "private", "", "public"] }, + { "symbol": [ "QGraphicsGridLayout", "private", "", "public"] }, + { "symbol": [ "QGraphicsItem", "private", "", "public"] }, + { "symbol": [ "QGraphicsItemAnimation", "private", "", "public"] }, + { "symbol": [ "QGraphicsItemGroup", "private", "", "public"] }, + { "symbol": [ "QGraphicsLayout", "private", "", "public"] }, + { "symbol": [ "QGraphicsLayoutItem", "private", "", "public"] }, + { "symbol": [ "QGraphicsLineItem", "private", "", "public"] }, + { "symbol": [ "QGraphicsLinearLayout", "private", "", "public"] }, + { "symbol": [ "QGraphicsObject", "private", "", "public"] }, + { "symbol": [ "QGraphicsOpacityEffect", "private", "", "public"] }, + { "symbol": [ "QGraphicsPathItem", "private", "", "public"] }, + { "symbol": [ "QGraphicsPixmapItem", "private", "", "public"] }, + { "symbol": [ "QGraphicsPolygonItem", "private", "", "public"] }, + { "symbol": [ "QGraphicsProxyWidget", "private", "", "public"] }, + { "symbol": [ "QGraphicsRectItem", "private", "", "public"] }, + { "symbol": [ "QGraphicsRotation", "private", "", "public"] }, + { "symbol": [ "QGraphicsScale", "private", "", "public"] }, + { "symbol": [ "QGraphicsScene", "private", "", "public"] }, + { "symbol": [ "QGraphicsSceneContextMenuEvent", "private", "", "public"] }, + { "symbol": [ "QGraphicsSceneDragDropEvent", "private", "", "public"] }, + { "symbol": [ "QGraphicsSceneEvent", "private", "", "public"] }, + { "symbol": [ "QGraphicsSceneHelpEvent", "private", "", "public"] }, + { "symbol": [ "QGraphicsSceneHoverEvent", "private", "", "public"] }, + { "symbol": [ "QGraphicsSceneMouseEvent", "private", "", "public"] }, + { "symbol": [ "QGraphicsSceneMoveEvent", "private", "", "public"] }, + { "symbol": [ "QGraphicsSceneResizeEvent", "private", "", "public"] }, + { "symbol": [ "QGraphicsSceneWheelEvent", "private", "", "public"] }, + { "symbol": [ "QGraphicsSimpleTextItem", "private", "", "public"] }, + { "symbol": [ "QGraphicsSvgItem", "private", "", "public"] }, + { "symbol": [ "QGraphicsTextItem", "private", "", "public"] }, + { "symbol": [ "QGraphicsTransform", "private", "", "public"] }, + { "symbol": [ "QGraphicsVideoItem", "private", "", "public"] }, + { "symbol": [ "QGraphicsView", "private", "", "public"] }, + { "symbol": [ "QGraphicsWidget", "private", "", "public"] }, + { "symbol": [ "QGridLayout", "private", "", "public"] }, + { "symbol": [ "QGroupBox", "private", "", "public"] }, + { "symbol": [ "QGuiApplication", "private", "", "public"] }, + { "symbol": [ "QHBoxLayout", "private", "", "public"] }, + { "symbol": [ "QHash", "private", "", "public"] }, + { "symbol": [ "QHashDummyValue", "private", "", "public"] }, + { "symbol": [ "QHashFunctions", "private", "", "public"] }, + { "symbol": [ "QHashHeterogeneousSearch", "private", "", "public"] }, + { "symbol": [ "QHashIterator", "private", "", "public"] }, + { "symbol": [ "QHashSeed", "private", "", "public"] }, + { "symbol": [ "QHeaderView", "private", "", "public"] }, + { "symbol": [ "QHelpContentItem", "private", "", "public"] }, + { "symbol": [ "QHelpContentModel", "private", "", "public"] }, + { "symbol": [ "QHelpContentWidget", "private", "", "public"] }, + { "symbol": [ "QHelpEngine", "private", "", "public"] }, + { "symbol": [ "QHelpEngineCore", "private", "", "public"] }, + { "symbol": [ "QHelpEvent", "private", "", "public"] }, + { "symbol": [ "QHelpFilterData", "private", "", "public"] }, + { "symbol": [ "QHelpFilterEngine", "private", "", "public"] }, + { "symbol": [ "QHelpFilterSettingsWidget", "private", "", "public"] }, + { "symbol": [ "QHelpGlobal", "private", "", "public"] }, + { "symbol": [ "QHelpIndexModel", "private", "", "public"] }, + { "symbol": [ "QHelpIndexWidget", "private", "", "public"] }, + { "symbol": [ "QHelpLink", "private", "", "public"] }, + { "symbol": [ "QHelpSearchEngine", "private", "", "public"] }, + { "symbol": [ "QHelpSearchEngineCore", "private", "", "public"] }, + { "symbol": [ "QHelpSearchQuery", "private", "", "public"] }, + { "symbol": [ "QHelpSearchQueryWidget", "private", "", "public"] }, + { "symbol": [ "QHelpSearchResult", "private", "", "public"] }, + { "symbol": [ "QHelpSearchResultWidget", "private", "", "public"] }, + { "symbol": [ "QHideEvent", "private", "", "public"] }, + { "symbol": [ "QHistoryState", "private", "", "public"] }, + { "symbol": [ "QHostAddress", "private", "", "public"] }, + { "symbol": [ "QHostInfo", "private", "", "public"] }, + { "symbol": [ "QHoverEvent", "private", "", "public"] }, + { "symbol": [ "QHstsPolicy", "private", "", "public"] }, + { "symbol": [ "QHttp1Configuration", "private", "", "public"] }, + { "symbol": [ "QHttp2Configuration", "private", "", "public"] }, + { "symbol": [ "QHttpHeaders", "private", "", "public"] }, + { "symbol": [ "QHttpMultiPart", "private", "", "public"] }, + { "symbol": [ "QHttpPart", "private", "", "public"] }, + { "symbol": [ "QIODevice", "private", "", "public"] }, + { "symbol": [ "QIODeviceBase", "private", "", "public"] }, + { "symbol": [ "QIPv6Address", "private", "", "public"] }, + { "symbol": [ "QIcon", "private", "", "public"] }, + { "symbol": [ "QIconDragEvent", "private", "", "public"] }, + { "symbol": [ "QIconEngine", "private", "", "public"] }, + { "symbol": [ "QIconEnginePlugin", "private", "", "public"] }, + { "symbol": [ "QIdentityProxyModel", "private", "", "public"] }, + { "symbol": [ "QImage", "private", "", "public"] }, + { "symbol": [ "QImageCapture", "private", "", "public"] }, + { "symbol": [ "QImageCleanupFunction", "private", "", "public"] }, + { "symbol": [ "QImageIOHandler", "private", "", "public"] }, + { "symbol": [ "QImageIOPlugin", "private", "", "public"] }, + { "symbol": [ "QImageReader", "private", "", "public"] }, + { "symbol": [ "QImageWriter", "private", "", "public"] }, + { "symbol": [ "QIncompatibleFlag", "private", "", "public"] }, + { "symbol": [ "QInputDevice", "private", "", "public"] }, + { "symbol": [ "QInputDialog", "private", "", "public"] }, + { "symbol": [ "QInputEvent", "private", "", "public"] }, + { "symbol": [ "QInputMethod", "private", "", "public"] }, + { "symbol": [ "QInputMethodEvent", "private", "", "public"] }, + { "symbol": [ "QInputMethodQueryEvent", "private", "", "public"] }, + { "symbol": [ "QIntValidator", "private", "", "public"] }, + { "symbol": [ "QInternal", "private", "", "public"] }, + { "symbol": [ "QItemDelegate", "private", "", "public"] }, + { "symbol": [ "QItemEditorCreator", "private", "", "public"] }, + { "symbol": [ "QItemEditorCreatorBase", "private", "", "public"] }, + { "symbol": [ "QItemEditorFactory", "private", "", "public"] }, + { "symbol": [ "QItemSelection", "private", "", "public"] }, + { "symbol": [ "QItemSelectionModel", "private", "", "public"] }, + { "symbol": [ "QItemSelectionRange", "private", "", "public"] }, + { "symbol": [ "QIterable", "private", "", "public"] }, + { "symbol": [ "QIterator", "private", "", "public"] }, + { "symbol": [ "QJSEngine", "private", "", "public"] }, + { "symbol": [ "QJSList", "private", "", "public"] }, + { "symbol": [ "QJSListForInIterator", "private", "", "public"] }, + { "symbol": [ "QJSListForOfIterator", "private", "", "public"] }, + { "symbol": [ "QJSListIndexClamp", "private", "", "public"] }, + { "symbol": [ "QJSManagedValue", "private", "", "public"] }, + { "symbol": [ "QJSNumberCoercion", "private", "", "public"] }, + { "symbol": [ "QJSPrimitiveValue", "private", "", "public"] }, + { "symbol": [ "QJSValue", "private", "", "public"] }, + { "symbol": [ "QJSValueIterator", "private", "", "public"] }, + { "symbol": [ "QJSValueList", "private", "", "public"] }, + { "symbol": [ "QJsonArray", "private", "", "public"] }, + { "symbol": [ "QJsonDocument", "private", "", "public"] }, + { "symbol": [ "QJsonObject", "private", "", "public"] }, + { "symbol": [ "QJsonParseError", "private", "", "public"] }, + { "symbol": [ "QJsonValue", "private", "", "public"] }, + { "symbol": [ "QJsonValueConstRef", "private", "", "public"] }, + { "symbol": [ "QJsonValueRef", "private", "", "public"] }, + { "symbol": [ "QKeyCombination", "private", "", "public"] }, + { "symbol": [ "QKeyEvent", "private", "", "public"] }, + { "symbol": [ "QKeyEventTransition", "private", "", "public"] }, + { "symbol": [ "QKeySequence", "private", "", "public"] }, + { "symbol": [ "QKeySequenceEdit", "private", "", "public"] }, + { "symbol": [ "QKeyValueIterator", "private", "", "public"] }, + { "symbol": [ "QLCDNumber", "private", "", "public"] }, + { "symbol": [ "QLEInteger", "private", "", "public"] }, + { "symbol": [ "QLabel", "private", "", "public"] }, + { "symbol": [ "QLatin1Char", "private", "", "public"] }, + { "symbol": [ "QLatin1String", "private", "", "public"] }, + { "symbol": [ "QLatin1StringMatcher", "private", "", "public"] }, + { "symbol": [ "QLatin1StringView", "private", "", "public"] }, + { "symbol": [ "QLayout", "private", "", "public"] }, + { "symbol": [ "QLayoutItem", "private", "", "public"] }, + { "symbol": [ "QLibrary", "private", "", "public"] }, + { "symbol": [ "QLibraryInfo", "private", "", "public"] }, + { "symbol": [ "QLine", "private", "", "public"] }, + { "symbol": [ "QLineEdit", "private", "", "public"] }, + { "symbol": [ "QLineF", "private", "", "public"] }, + { "symbol": [ "QLinearGradient", "private", "", "public"] }, + { "symbol": [ "QLinkedList", "private", "", "public"] }, + { "symbol": [ "QLinkedListData", "private", "", "public"] }, + { "symbol": [ "QLinkedListIterator", "private", "", "public"] }, + { "symbol": [ "QLinkedListNode", "private", "", "public"] }, + { "symbol": [ "QList", "private", "", "public"] }, + { "symbol": [ "QListIterator", "private", "", "public"] }, + { "symbol": [ "QListSpecialMethods", "private", "", "public"] }, + { "symbol": [ "QListSpecialMethodsBase", "private", "", "public"] }, + { "symbol": [ "QListView", "private", "", "public"] }, + { "symbol": [ "QListWidget", "private", "", "public"] }, + { "symbol": [ "QListWidgetItem", "private", "", "public"] }, + { "symbol": [ "QLittleEndianStorageType", "private", "", "public"] }, + { "symbol": [ "QLocalServer", "private", "", "public"] }, + { "symbol": [ "QLocalSocket", "private", "", "public"] }, + { "symbol": [ "QLocale", "private", "", "public"] }, + { "symbol": [ "QLocationPermission", "private", "", "public"] }, + { "symbol": [ "QLockFile", "private", "", "public"] }, + { "symbol": [ "QLoggingCategory", "private", "", "public"] }, + { "symbol": [ "QLowEnergyAdvertisingData", "private", "", "public"] }, + { "symbol": [ "QLowEnergyAdvertisingParameters", "private", "", "public"] }, + { "symbol": [ "QLowEnergyCharacteristic", "private", "", "public"] }, + { "symbol": [ "QLowEnergyCharacteristicData", "private", "", "public"] }, + { "symbol": [ "QLowEnergyConnectionParameters", "private", "", "public"] }, + { "symbol": [ "QLowEnergyController", "private", "", "public"] }, + { "symbol": [ "QLowEnergyDescriptor", "private", "", "public"] }, + { "symbol": [ "QLowEnergyDescriptorData", "private", "", "public"] }, + { "symbol": [ "QLowEnergyHandle", "private", "", "public"] }, + { "symbol": [ "QLowEnergyService", "private", "", "public"] }, + { "symbol": [ "QLowEnergyServiceData", "private", "", "public"] }, + { "symbol": [ "QMainWindow", "private", "", "public"] }, + { "symbol": [ "QMap", "private", "", "public"] }, + { "symbol": [ "QMapData", "private", "", "public"] }, + { "symbol": [ "QMapIterator", "private", "", "public"] }, + { "symbol": [ "QMargins", "private", "", "public"] }, + { "symbol": [ "QMarginsF", "private", "", "public"] }, + { "symbol": [ "QMaskGenerator", "private", "", "public"] }, + { "symbol": [ "QMatrix2x2", "private", "", "public"] }, + { "symbol": [ "QMatrix2x3", "private", "", "public"] }, + { "symbol": [ "QMatrix2x4", "private", "", "public"] }, + { "symbol": [ "QMatrix3x2", "private", "", "public"] }, + { "symbol": [ "QMatrix3x3", "private", "", "public"] }, + { "symbol": [ "QMatrix3x4", "private", "", "public"] }, + { "symbol": [ "QMatrix4x2", "private", "", "public"] }, + { "symbol": [ "QMatrix4x3", "private", "", "public"] }, + { "symbol": [ "QMatrix4x4", "private", "", "public"] }, + { "symbol": [ "QMdiArea", "private", "", "public"] }, + { "symbol": [ "QMdiSubWindow", "private", "", "public"] }, + { "symbol": [ "QMediaCaptureSession", "private", "", "public"] }, + { "symbol": [ "QMediaDevices", "private", "", "public"] }, + { "symbol": [ "QMediaFormat", "private", "", "public"] }, + { "symbol": [ "QMediaMetaData", "private", "", "public"] }, + { "symbol": [ "QMediaPlayer", "private", "", "public"] }, + { "symbol": [ "QMediaRecorder", "private", "", "public"] }, + { "symbol": [ "QMediaTimeRange", "private", "", "public"] }, + { "symbol": [ "QMenu", "private", "", "public"] }, + { "symbol": [ "QMenuBar", "private", "", "public"] }, + { "symbol": [ "QMessageAuthenticationCode", "private", "", "public"] }, + { "symbol": [ "QMessageBox", "private", "", "public"] }, + { "symbol": [ "QMessageLogContext", "private", "", "public"] }, + { "symbol": [ "QMessageLogger", "private", "", "public"] }, + { "symbol": [ "QMetaAssociation", "private", "", "public"] }, + { "symbol": [ "QMetaClassInfo", "private", "", "public"] }, + { "symbol": [ "QMetaContainer", "private", "", "public"] }, + { "symbol": [ "QMetaEnum", "private", "", "public"] }, + { "symbol": [ "QMetaMethod", "private", "", "public"] }, + { "symbol": [ "QMetaMethodArgument", "private", "", "public"] }, + { "symbol": [ "QMetaMethodReturnArgument", "private", "", "public"] }, + { "symbol": [ "QMetaObject", "private", "", "public"] }, + { "symbol": [ "QMetaProperty", "private", "", "public"] }, + { "symbol": [ "QMetaSequence", "private", "", "public"] }, + { "symbol": [ "QMetaType", "private", "", "public"] }, + { "symbol": [ "QMetaTypeId", "private", "", "public"] }, + { "symbol": [ "QMetaTypeId2", "private", "", "public"] }, + { "symbol": [ "QMetaTypeIdQObject", "private", "", "public"] }, + { "symbol": [ "QMethodRawArguments", "private", "", "public"] }, + { "symbol": [ "QMimeData", "private", "", "public"] }, + { "symbol": [ "QMimeDatabase", "private", "", "public"] }, + { "symbol": [ "QMimeType", "private", "", "public"] }, + { "symbol": [ "QModelIndex", "private", "", "public"] }, + { "symbol": [ "QModelIndexList", "private", "", "public"] }, + { "symbol": [ "QModelRoleData", "private", "", "public"] }, + { "symbol": [ "QModelRoleDataSpan", "private", "", "public"] }, + { "symbol": [ "QMouseEvent", "private", "", "public"] }, + { "symbol": [ "QMouseEventTransition", "private", "", "public"] }, + { "symbol": [ "QMoveEvent", "private", "", "public"] }, + { "symbol": [ "QMovie", "private", "", "public"] }, + { "symbol": [ "QMultiHash", "private", "", "public"] }, + { "symbol": [ "QMultiHashIterator", "private", "", "public"] }, + { "symbol": [ "QMultiMap", "private", "", "public"] }, + { "symbol": [ "QMultiMapIterator", "private", "", "public"] }, + { "symbol": [ "QMutableByteArrayListIterator", "private", "", "public"] }, + { "symbol": [ "QMutableFutureIterator", "private", "", "public"] }, + { "symbol": [ "QMutableHashIterator", "private", "", "public"] }, + { "symbol": [ "QMutableLinkedListIterator", "private", "", "public"] }, + { "symbol": [ "QMutableListIterator", "private", "", "public"] }, + { "symbol": [ "QMutableMapIterator", "private", "", "public"] }, + { "symbol": [ "QMutableMultiHashIterator", "private", "", "public"] }, + { "symbol": [ "QMutableMultiMapIterator", "private", "", "public"] }, + { "symbol": [ "QMutableSetIterator", "private", "", "public"] }, + { "symbol": [ "QMutableVectorIterator", "private", "", "public"] }, + { "symbol": [ "QMutex", "private", "", "public"] }, + { "symbol": [ "QMutexLocker", "private", "", "public"] }, + { "symbol": [ "QNativeGestureEvent", "private", "", "public"] }, + { "symbol": [ "QNativeIpcKey", "private", "", "public"] }, + { "symbol": [ "QNetworkAccessManager", "private", "", "public"] }, + { "symbol": [ "QNetworkAddressEntry", "private", "", "public"] }, + { "symbol": [ "QNetworkCacheMetaData", "private", "", "public"] }, + { "symbol": [ "QNetworkCookie", "private", "", "public"] }, + { "symbol": [ "QNetworkCookieJar", "private", "", "public"] }, + { "symbol": [ "QNetworkDatagram", "private", "", "public"] }, + { "symbol": [ "QNetworkDiskCache", "private", "", "public"] }, + { "symbol": [ "QNetworkInformation", "private", "", "public"] }, + { "symbol": [ "QNetworkInterface", "private", "", "public"] }, + { "symbol": [ "QNetworkProxy", "private", "", "public"] }, + { "symbol": [ "QNetworkProxyFactory", "private", "", "public"] }, + { "symbol": [ "QNetworkProxyQuery", "private", "", "public"] }, + { "symbol": [ "QNetworkReply", "private", "", "public"] }, + { "symbol": [ "QNetworkRequest", "private", "", "public"] }, + { "symbol": [ "QNetworkRequestFactory", "private", "", "public"] }, + { "symbol": [ "QNoDebug", "private", "", "public"] }, + { "symbol": [ "QNtfsPermissionCheckGuard", "private", "", "public"] }, + { "symbol": [ "QOAuth1", "private", "", "public"] }, + { "symbol": [ "QOAuth1Signature", "private", "", "public"] }, + { "symbol": [ "QOAuth2AuthorizationCodeFlow", "private", "", "public"] }, + { "symbol": [ "QOAuth2DeviceAuthorizationFlow", "private", "", "public"] }, + { "symbol": [ "QOAuthHttpServerReplyHandler", "private", "", "public"] }, + { "symbol": [ "QOAuthOobReplyHandler", "private", "", "public"] }, + { "symbol": [ "QOAuthUriSchemeReplyHandler", "private", "", "public"] }, + { "symbol": [ "QObject", "private", "", "public"] }, + { "symbol": [ "QObjectBindableProperty", "private", "", "public"] }, + { "symbol": [ "QObjectCleanupHandler", "private", "", "public"] }, + { "symbol": [ "QObjectComputedProperty", "private", "", "public"] }, + { "symbol": [ "QObjectData", "private", "", "public"] }, + { "symbol": [ "QObjectList", "private", "", "public"] }, + { "symbol": [ "QOcspResponse", "private", "", "public"] }, + { "symbol": [ "QOffscreenSurface", "private", "", "public"] }, + { "symbol": [ "QOpenGLBuffer", "private", "", "public"] }, + { "symbol": [ "QOpenGLContext", "private", "", "public"] }, + { "symbol": [ "QOpenGLContextGroup", "private", "", "public"] }, + { "symbol": [ "QOpenGLDebugLogger", "private", "", "public"] }, + { "symbol": [ "QOpenGLDebugMessage", "private", "", "public"] }, + { "symbol": [ "QOpenGLExtraFunctions", "private", "", "public"] }, + { "symbol": [ "QOpenGLExtraFunctionsPrivate", "private", "", "public"] }, + { "symbol": [ "QOpenGLFramebufferObject", "private", "", "public"] }, + { "symbol": [ "QOpenGLFramebufferObjectFormat", "private", "", "public"] }, + { "symbol": [ "QOpenGLFunctions", "private", "", "public"] }, + { "symbol": [ "QOpenGLFunctionsPrivate", "private", "", "public"] }, + { "symbol": [ "QOpenGLFunctions_1_0", "private", "", "public"] }, + { "symbol": [ "QOpenGLFunctions_1_1", "private", "", "public"] }, + { "symbol": [ "QOpenGLFunctions_1_2", "private", "", "public"] }, + { "symbol": [ "QOpenGLFunctions_1_3", "private", "", "public"] }, + { "symbol": [ "QOpenGLFunctions_1_4", "private", "", "public"] }, + { "symbol": [ "QOpenGLFunctions_1_5", "private", "", "public"] }, + { "symbol": [ "QOpenGLFunctions_2_0", "private", "", "public"] }, + { "symbol": [ "QOpenGLFunctions_2_1", "private", "", "public"] }, + { "symbol": [ "QOpenGLFunctions_3_0", "private", "", "public"] }, + { "symbol": [ "QOpenGLFunctions_3_1", "private", "", "public"] }, + { "symbol": [ "QOpenGLFunctions_3_2_Compatibility", "private", "", "public"] }, + { "symbol": [ "QOpenGLFunctions_3_2_Core", "private", "", "public"] }, + { "symbol": [ "QOpenGLFunctions_3_3_Compatibility", "private", "", "public"] }, + { "symbol": [ "QOpenGLFunctions_3_3_Core", "private", "", "public"] }, + { "symbol": [ "QOpenGLFunctions_4_0_Compatibility", "private", "", "public"] }, + { "symbol": [ "QOpenGLFunctions_4_0_Core", "private", "", "public"] }, + { "symbol": [ "QOpenGLFunctions_4_1_Compatibility", "private", "", "public"] }, + { "symbol": [ "QOpenGLFunctions_4_1_Core", "private", "", "public"] }, + { "symbol": [ "QOpenGLFunctions_4_2_Compatibility", "private", "", "public"] }, + { "symbol": [ "QOpenGLFunctions_4_2_Core", "private", "", "public"] }, + { "symbol": [ "QOpenGLFunctions_4_3_Compatibility", "private", "", "public"] }, + { "symbol": [ "QOpenGLFunctions_4_3_Core", "private", "", "public"] }, + { "symbol": [ "QOpenGLFunctions_4_4_Compatibility", "private", "", "public"] }, + { "symbol": [ "QOpenGLFunctions_4_4_Core", "private", "", "public"] }, + { "symbol": [ "QOpenGLFunctions_4_5_Compatibility", "private", "", "public"] }, + { "symbol": [ "QOpenGLFunctions_4_5_Core", "private", "", "public"] }, + { "symbol": [ "QOpenGLPaintDevice", "private", "", "public"] }, + { "symbol": [ "QOpenGLPixelTransferOptions", "private", "", "public"] }, + { "symbol": [ "QOpenGLShader", "private", "", "public"] }, + { "symbol": [ "QOpenGLShaderProgram", "private", "", "public"] }, + { "symbol": [ "QOpenGLTexture", "private", "", "public"] }, + { "symbol": [ "QOpenGLTextureBlitter", "private", "", "public"] }, + { "symbol": [ "QOpenGLTimeMonitor", "private", "", "public"] }, + { "symbol": [ "QOpenGLTimerQuery", "private", "", "public"] }, + { "symbol": [ "QOpenGLVersionFunctions", "private", "", "public"] }, + { "symbol": [ "QOpenGLVersionFunctionsFactory", "private", "", "public"] }, + { "symbol": [ "QOpenGLVersionProfile", "private", "", "public"] }, + { "symbol": [ "QOpenGLVertexArrayObject", "private", "", "public"] }, + { "symbol": [ "QOpenGLWidget", "private", "", "public"] }, + { "symbol": [ "QOpenGLWindow", "private", "", "public"] }, + { "symbol": [ "QOperatingSystemVersion", "private", "", "public"] }, + { "symbol": [ "QOperatingSystemVersionBase", "private", "", "public"] }, + { "symbol": [ "QOperatingSystemVersionUnexported", "private", "", "public"] }, + { "symbol": [ "QPageLayout", "private", "", "public"] }, + { "symbol": [ "QPageRanges", "private", "", "public"] }, + { "symbol": [ "QPageSetupDialog", "private", "", "public"] }, + { "symbol": [ "QPageSize", "private", "", "public"] }, + { "symbol": [ "QPagedPaintDevice", "private", "", "public"] }, + { "symbol": [ "QPaintDevice", "private", "", "public"] }, + { "symbol": [ "QPaintDeviceWindow", "private", "", "public"] }, + { "symbol": [ "QPaintEngine", "private", "", "public"] }, + { "symbol": [ "QPaintEngineState", "private", "", "public"] }, + { "symbol": [ "QPaintEvent", "private", "", "public"] }, + { "symbol": [ "QPainter", "private", "", "public"] }, + { "symbol": [ "QPainterPath", "private", "", "public"] }, + { "symbol": [ "QPainterPathStroker", "private", "", "public"] }, + { "symbol": [ "QPainterStateGuard", "private", "", "public"] }, + { "symbol": [ "QPair", "private", "", "public"] }, + { "symbol": [ "QPalette", "private", "", "public"] }, + { "symbol": [ "QPanGesture", "private", "", "public"] }, + { "symbol": [ "QParallelAnimationGroup", "private", "", "public"] }, + { "symbol": [ "QPartialOrdering", "private", "", "public"] }, + { "symbol": [ "QPasswordDigestor", "private", "", "public"] }, + { "symbol": [ "QPauseAnimation", "private", "", "public"] }, + { "symbol": [ "QPdfBookmarkModel", "private", "", "public"] }, + { "symbol": [ "QPdfDocument", "private", "", "public"] }, + { "symbol": [ "QPdfDocumentRenderOptions", "private", "", "public"] }, + { "symbol": [ "QPdfLink", "private", "", "public"] }, + { "symbol": [ "QPdfLinkModel", "private", "", "public"] }, + { "symbol": [ "QPdfOutputIntent", "private", "", "public"] }, + { "symbol": [ "QPdfPageNavigator", "private", "", "public"] }, + { "symbol": [ "QPdfPageRenderer", "private", "", "public"] }, + { "symbol": [ "QPdfPageSelector", "private", "", "public"] }, + { "symbol": [ "QPdfSearchModel", "private", "", "public"] }, + { "symbol": [ "QPdfSelection", "private", "", "public"] }, + { "symbol": [ "QPdfView", "private", "", "public"] }, + { "symbol": [ "QPdfWriter", "private", "", "public"] }, + { "symbol": [ "QPen", "private", "", "public"] }, + { "symbol": [ "QPermission", "private", "", "public"] }, + { "symbol": [ "QPermissions", "private", "", "public"] }, + { "symbol": [ "QPersistentModelIndex", "private", "", "public"] }, + { "symbol": [ "QPicture", "private", "", "public"] }, + { "symbol": [ "QPinchGesture", "private", "", "public"] }, + { "symbol": [ "QPixelFormat", "private", "", "public"] }, + { "symbol": [ "QPixmap", "private", "", "public"] }, + { "symbol": [ "QPixmapCache", "private", "", "public"] }, + { "symbol": [ "QPlainTextDocumentLayout", "private", "", "public"] }, + { "symbol": [ "QPlainTextEdit", "private", "", "public"] }, + { "symbol": [ "QPlatformSurfaceEvent", "private", "", "public"] }, + { "symbol": [ "QPlaybackOptions", "private", "", "public"] }, + { "symbol": [ "QPluginLoader", "private", "", "public"] }, + { "symbol": [ "QPluginMetaData", "private", "", "public"] }, + { "symbol": [ "QPluginMetaDataV2", "private", "", "public"] }, + { "symbol": [ "QPoint", "private", "", "public"] }, + { "symbol": [ "QPointF", "private", "", "public"] }, + { "symbol": [ "QPointer", "private", "", "public"] }, + { "symbol": [ "QPointerEvent", "private", "", "public"] }, + { "symbol": [ "QPointingDevice", "private", "", "public"] }, + { "symbol": [ "QPointingDeviceUniqueId", "private", "", "public"] }, + { "symbol": [ "QPolygon", "private", "", "public"] }, + { "symbol": [ "QPolygonF", "private", "", "public"] }, + { "symbol": [ "QPrintDialog", "private", "", "public"] }, + { "symbol": [ "QPrintEngine", "private", "", "public"] }, + { "symbol": [ "QPrintPreviewDialog", "private", "", "public"] }, + { "symbol": [ "QPrintPreviewWidget", "private", "", "public"] }, + { "symbol": [ "QPrinter", "private", "", "public"] }, + { "symbol": [ "QPrinterInfo", "private", "", "public"] }, + { "symbol": [ "QProcess", "private", "", "public"] }, + { "symbol": [ "QProcessEnvironment", "private", "", "public"] }, + { "symbol": [ "QProgressBar", "private", "", "public"] }, + { "symbol": [ "QProgressDialog", "private", "", "public"] }, + { "symbol": [ "QPromise", "private", "", "public"] }, + { "symbol": [ "QProperty", "private", "", "public"] }, + { "symbol": [ "QPropertyAnimation", "private", "", "public"] }, + { "symbol": [ "QPropertyBinding", "private", "", "public"] }, + { "symbol": [ "QPropertyBindingError", "private", "", "public"] }, + { "symbol": [ "QPropertyBindingPrivatePtr", "private", "", "public"] }, + { "symbol": [ "QPropertyBindingSourceLocation", "private", "", "public"] }, + { "symbol": [ "QPropertyChangeHandler", "private", "", "public"] }, + { "symbol": [ "QPropertyData", "private", "", "public"] }, + { "symbol": [ "QPropertyNotifier", "private", "", "public"] }, + { "symbol": [ "QPropertyObserver", "private", "", "public"] }, + { "symbol": [ "QPropertyObserverBase", "private", "", "public"] }, + { "symbol": [ "QPropertyProxyBindingData", "private", "", "public"] }, + { "symbol": [ "QProxyStyle", "private", "", "public"] }, + { "symbol": [ "QPushButton", "private", "", "public"] }, + { "symbol": [ "QQmlAbstractUrlInterceptor", "private", "", "public"] }, + { "symbol": [ "QQmlApplicationEngine", "private", "", "public"] }, + { "symbol": [ "QQmlComponent", "private", "", "public"] }, + { "symbol": [ "QQmlContext", "private", "", "public"] }, + { "symbol": [ "QQmlDebuggingEnabler", "private", "", "public"] }, + { "symbol": [ "QQmlEngine", "private", "", "public"] }, + { "symbol": [ "QQmlEngineExtensionInterface", "private", "", "public"] }, + { "symbol": [ "QQmlEngineExtensionPlugin", "private", "", "public"] }, + { "symbol": [ "QQmlError", "private", "", "public"] }, + { "symbol": [ "QQmlExpression", "private", "", "public"] }, + { "symbol": [ "QQmlExtensionInterface", "private", "", "public"] }, + { "symbol": [ "QQmlExtensionPlugin", "private", "", "public"] }, + { "symbol": [ "QQmlFile", "private", "", "public"] }, + { "symbol": [ "QQmlFileSelector", "private", "", "public"] }, + { "symbol": [ "QQmlImageProviderBase", "private", "", "public"] }, + { "symbol": [ "QQmlIncubationController", "private", "", "public"] }, + { "symbol": [ "QQmlIncubator", "private", "", "public"] }, + { "symbol": [ "QQmlInfo", "private", "", "public"] }, + { "symbol": [ "QQmlListProperty", "private", "", "public"] }, + { "symbol": [ "QQmlListReference", "private", "", "public"] }, + { "symbol": [ "QQmlModuleRegistration", "private", "", "public"] }, + { "symbol": [ "QQmlNetworkAccessManagerFactory", "private", "", "public"] }, + { "symbol": [ "QQmlParserStatus", "private", "", "public"] }, + { "symbol": [ "QQmlProperties", "private", "", "public"] }, + { "symbol": [ "QQmlProperty", "private", "", "public"] }, + { "symbol": [ "QQmlPropertyMap", "private", "", "public"] }, + { "symbol": [ "QQmlPropertyValueSource", "private", "", "public"] }, + { "symbol": [ "QQmlScriptString", "private", "", "public"] }, + { "symbol": [ "QQmlTypeInfo", "private", "", "public"] }, + { "symbol": [ "QQmlTypeNotAvailable", "private", "", "public"] }, + { "symbol": [ "QQmlTypesExtensionInterface", "private", "", "public"] }, + { "symbol": [ "QQmlWebChannel", "private", "", "public"] }, + { "symbol": [ "QQuaternion", "private", "", "public"] }, + { "symbol": [ "QQueue", "private", "", "public"] }, + { "symbol": [ "QQuick3D", "private", "", "public"] }, + { "symbol": [ "QQuick3DGeometry", "private", "", "public"] }, + { "symbol": [ "QQuick3DInstancing", "private", "", "public"] }, + { "symbol": [ "QQuick3DObject", "private", "", "public"] }, + { "symbol": [ "QQuick3DRenderExtension", "private", "", "public"] }, + { "symbol": [ "QQuick3DTextureData", "private", "", "public"] }, + { "symbol": [ "QQuick3DTextureProviderExtension", "private", "", "public"] }, + { "symbol": [ "QQuickAsyncImageProvider", "private", "", "public"] }, + { "symbol": [ "QQuickAttachedPropertyPropagator", "private", "", "public"] }, + { "symbol": [ "QQuickFramebufferObject", "private", "", "public"] }, + { "symbol": [ "QQuickGraphicsConfiguration", "private", "", "public"] }, + { "symbol": [ "QQuickGraphicsDevice", "private", "", "public"] }, + { "symbol": [ "QQuickImageProvider", "private", "", "public"] }, + { "symbol": [ "QQuickImageResponse", "private", "", "public"] }, + { "symbol": [ "QQuickItem", "private", "", "public"] }, + { "symbol": [ "QQuickItemGrabResult", "private", "", "public"] }, + { "symbol": [ "QQuickOpenGLUtils", "private", "", "public"] }, + { "symbol": [ "QQuickPaintedItem", "private", "", "public"] }, + { "symbol": [ "QQuickRenderControl", "private", "", "public"] }, + { "symbol": [ "QQuickRenderTarget", "private", "", "public"] }, + { "symbol": [ "QQuickRhiItem", "private", "", "public"] }, + { "symbol": [ "QQuickRhiItemRenderer", "private", "", "public"] }, + { "symbol": [ "QQuickStyle", "private", "", "public"] }, + { "symbol": [ "QQuickTextDocument", "private", "", "public"] }, + { "symbol": [ "QQuickTextureFactory", "private", "", "public"] }, + { "symbol": [ "QQuickTransform", "private", "", "public"] }, + { "symbol": [ "QQuickView", "private", "", "public"] }, + { "symbol": [ "QQuickWebEngineDownloadRequest", "private", "", "public"] }, + { "symbol": [ "QQuickWebEngineProfile", "private", "", "public"] }, + { "symbol": [ "QQuickWidget", "private", "", "public"] }, + { "symbol": [ "QQuickWindow", "private", "", "public"] }, + { "symbol": [ "QRadialGradient", "private", "", "public"] }, + { "symbol": [ "QRadioButton", "private", "", "public"] }, + { "symbol": [ "QRandomGenerator", "private", "", "public"] }, + { "symbol": [ "QRandomGenerator64", "private", "", "public"] }, + { "symbol": [ "QRangeModel", "private", "", "public"] }, + { "symbol": [ "QRangeModelAdapter", "private", "", "public"] }, + { "symbol": [ "QRasterWindow", "private", "", "public"] }, + { "symbol": [ "QRawFont", "private", "", "public"] }, + { "symbol": [ "QReadLocker", "private", "", "public"] }, + { "symbol": [ "QReadWriteLock", "private", "", "public"] }, + { "symbol": [ "QRect", "private", "", "public"] }, + { "symbol": [ "QRectF", "private", "", "public"] }, + { "symbol": [ "QRecursiveMutex", "private", "", "public"] }, + { "symbol": [ "QRegExp", "private", "", "public"] }, + { "symbol": [ "QRegion", "private", "", "public"] }, + { "symbol": [ "QRegularExpression", "private", "", "public"] }, + { "symbol": [ "QRegularExpressionMatch", "private", "", "public"] }, + { "symbol": [ "QRegularExpressionMatchIterator", "private", "", "public"] }, + { "symbol": [ "QRegularExpressionValidator", "private", "", "public"] }, + { "symbol": [ "QResizeEvent", "private", "", "public"] }, + { "symbol": [ "QResource", "private", "", "public"] }, + { "symbol": [ "QRestAccessManager", "private", "", "public"] }, + { "symbol": [ "QRestReply", "private", "", "public"] }, + { "symbol": [ "QReturnArgument", "private", "", "public"] }, + { "symbol": [ "QRgb", "private", "", "public"] }, + { "symbol": [ "QRgba64", "private", "", "public"] }, + { "symbol": [ "QRgbaFloat", "private", "", "public"] }, + { "symbol": [ "QRgbaFloat16", "private", "", "public"] }, + { "symbol": [ "QRgbaFloat32", "private", "", "public"] }, + { "symbol": [ "QRhiWidget", "private", "", "public"] }, + { "symbol": [ "QRubberBand", "private", "", "public"] }, + { "symbol": [ "QRunnable", "private", "", "public"] }, + { "symbol": [ "QSGBasicGeometryNode", "private", "", "public"] }, + { "symbol": [ "QSGClipNode", "private", "", "public"] }, + { "symbol": [ "QSGDynamicTexture", "private", "", "public"] }, + { "symbol": [ "QSGFlatColorMaterial", "private", "", "public"] }, + { "symbol": [ "QSGGeometry", "private", "", "public"] }, + { "symbol": [ "QSGGeometryNode", "private", "", "public"] }, + { "symbol": [ "QSGImageNode", "private", "", "public"] }, + { "symbol": [ "QSGMaterial", "private", "", "public"] }, + { "symbol": [ "QSGMaterialShader", "private", "", "public"] }, + { "symbol": [ "QSGNinePatchNode", "private", "", "public"] }, + { "symbol": [ "QSGNode", "private", "", "public"] }, + { "symbol": [ "QSGNodeVisitor", "private", "", "public"] }, + { "symbol": [ "QSGOpacityNode", "private", "", "public"] }, + { "symbol": [ "QSGOpaqueTextureMaterial", "private", "", "public"] }, + { "symbol": [ "QSGRectangleNode", "private", "", "public"] }, + { "symbol": [ "QSGRenderNode", "private", "", "public"] }, + { "symbol": [ "QSGRendererInterface", "private", "", "public"] }, + { "symbol": [ "QSGRootNode", "private", "", "public"] }, + { "symbol": [ "QSGSimpleRectNode", "private", "", "public"] }, + { "symbol": [ "QSGSimpleTextureNode", "private", "", "public"] }, + { "symbol": [ "QSGTextNode", "private", "", "public"] }, + { "symbol": [ "QSGTexture", "private", "", "public"] }, + { "symbol": [ "QSGTextureMaterial", "private", "", "public"] }, + { "symbol": [ "QSGTextureProvider", "private", "", "public"] }, + { "symbol": [ "QSGTransformNode", "private", "", "public"] }, + { "symbol": [ "QSGVertexColorMaterial", "private", "", "public"] }, + { "symbol": [ "QSaveFile", "private", "", "public"] }, + { "symbol": [ "QScopeGuard", "private", "", "public"] }, + { "symbol": [ "QScopedArrayPointer", "private", "", "public"] }, + { "symbol": [ "QScopedPointer", "private", "", "public"] }, + { "symbol": [ "QScopedPointerArrayDeleter", "private", "", "public"] }, + { "symbol": [ "QScopedPointerDeleteLater", "private", "", "public"] }, + { "symbol": [ "QScopedPointerDeleter", "private", "", "public"] }, + { "symbol": [ "QScopedPointerObjectDeleteLater", "private", "", "public"] }, + { "symbol": [ "QScopedPointerPodDeleter", "private", "", "public"] }, + { "symbol": [ "QScopedPropertyUpdateGroup", "private", "", "public"] }, + { "symbol": [ "QScopedValueRollback", "private", "", "public"] }, + { "symbol": [ "QScreen", "private", "", "public"] }, + { "symbol": [ "QScreenCapture", "private", "", "public"] }, + { "symbol": [ "QScreenOrientationChangeEvent", "private", "", "public"] }, + { "symbol": [ "QScrollArea", "private", "", "public"] }, + { "symbol": [ "QScrollBar", "private", "", "public"] }, + { "symbol": [ "QScrollEvent", "private", "", "public"] }, + { "symbol": [ "QScrollPrepareEvent", "private", "", "public"] }, + { "symbol": [ "QScroller", "private", "", "public"] }, + { "symbol": [ "QScrollerProperties", "private", "", "public"] }, + { "symbol": [ "QScxmlCompiler", "private", "", "public"] }, + { "symbol": [ "QScxmlCppDataModel", "private", "", "public"] }, + { "symbol": [ "QScxmlDataModel", "private", "", "public"] }, + { "symbol": [ "QScxmlDynamicScxmlServiceFactory", "private", "", "public"] }, + { "symbol": [ "QScxmlError", "private", "", "public"] }, + { "symbol": [ "QScxmlEvent", "private", "", "public"] }, + { "symbol": [ "QScxmlInvokableService", "private", "", "public"] }, + { "symbol": [ "QScxmlInvokableServiceFactory", "private", "", "public"] }, + { "symbol": [ "QScxmlNullDataModel", "private", "", "public"] }, + { "symbol": [ "QScxmlStateMachine", "private", "", "public"] }, + { "symbol": [ "QScxmlStaticScxmlServiceFactory", "private", "", "public"] }, + { "symbol": [ "QScxmlTableData", "private", "", "public"] }, + { "symbol": [ "QSemaphore", "private", "", "public"] }, + { "symbol": [ "QSemaphoreReleaser", "private", "", "public"] }, + { "symbol": [ "QSequentialAnimationGroup", "private", "", "public"] }, + { "symbol": [ "QSequentialConstIterator", "private", "", "public"] }, + { "symbol": [ "QSequentialIterable", "private", "", "public"] }, + { "symbol": [ "QSequentialIterator", "private", "", "public"] }, + { "symbol": [ "QSessionManager", "private", "", "public"] }, + { "symbol": [ "QSet", "private", "", "public"] }, + { "symbol": [ "QSetIterator", "private", "", "public"] }, + { "symbol": [ "QSettings", "private", "", "public"] }, + { "symbol": [ "QSharedData", "private", "", "public"] }, + { "symbol": [ "QSharedDataPointer", "private", "", "public"] }, + { "symbol": [ "QSharedDataPointerBase", "private", "", "public"] }, + { "symbol": [ "QSharedMemory", "private", "", "public"] }, + { "symbol": [ "QSharedPointer", "private", "", "public"] }, + { "symbol": [ "QShortcut", "private", "", "public"] }, + { "symbol": [ "QShortcutEvent", "private", "", "public"] }, + { "symbol": [ "QShowEvent", "private", "", "public"] }, + { "symbol": [ "QSignalBlocker", "private", "", "public"] }, + { "symbol": [ "QSignalMapper", "private", "", "public"] }, + { "symbol": [ "QSignalSpy", "private", "", "public"] }, + { "symbol": [ "QSignalTransition", "private", "", "public"] }, + { "symbol": [ "QSinglePointEvent", "private", "", "public"] }, + { "symbol": [ "QSize", "private", "", "public"] }, + { "symbol": [ "QSizeF", "private", "", "public"] }, + { "symbol": [ "QSizeGrip", "private", "", "public"] }, + { "symbol": [ "QSizePolicy", "private", "", "public"] }, + { "symbol": [ "QSlider", "private", "", "public"] }, + { "symbol": [ "QSocketDescriptor", "private", "", "public"] }, + { "symbol": [ "QSocketNotifier", "private", "", "public"] }, + { "symbol": [ "QSortFilterProxyModel", "private", "", "public"] }, + { "symbol": [ "QSoundEffect", "private", "", "public"] }, + { "symbol": [ "QSpacerItem", "private", "", "public"] }, + { "symbol": [ "QSpan", "private", "", "public"] }, + { "symbol": [ "QSpatialSound", "private", "", "public"] }, + { "symbol": [ "QSpecialInteger", "private", "", "public"] }, + { "symbol": [ "QSpinBox", "private", "", "public"] }, + { "symbol": [ "QSplashScreen", "private", "", "public"] }, + { "symbol": [ "QSplitter", "private", "", "public"] }, + { "symbol": [ "QSplitterHandle", "private", "", "public"] }, + { "symbol": [ "QSpontaneKeyEvent", "private", "", "public"] }, + { "symbol": [ "QSql", "private", "", "public"] }, + { "symbol": [ "QSqlDatabase", "private", "", "public"] }, + { "symbol": [ "QSqlDatabaseDefaultConnectionName", "private", "", "public"] }, + { "symbol": [ "QSqlDriver", "private", "", "public"] }, + { "symbol": [ "QSqlDriverCreator", "private", "", "public"] }, + { "symbol": [ "QSqlDriverCreatorBase", "private", "", "public"] }, + { "symbol": [ "QSqlDriverPlugin", "private", "", "public"] }, + { "symbol": [ "QSqlError", "private", "", "public"] }, + { "symbol": [ "QSqlField", "private", "", "public"] }, + { "symbol": [ "QSqlIndex", "private", "", "public"] }, + { "symbol": [ "QSqlQuery", "private", "", "public"] }, + { "symbol": [ "QSqlQueryModel", "private", "", "public"] }, + { "symbol": [ "QSqlRecord", "private", "", "public"] }, + { "symbol": [ "QSqlRelation", "private", "", "public"] }, + { "symbol": [ "QSqlRelationalDelegate", "private", "", "public"] }, + { "symbol": [ "QSqlRelationalTableModel", "private", "", "public"] }, + { "symbol": [ "QSqlResult", "private", "", "public"] }, + { "symbol": [ "QSqlTableModel", "private", "", "public"] }, + { "symbol": [ "QSsl", "private", "", "public"] }, + { "symbol": [ "QSslCertificate", "private", "", "public"] }, + { "symbol": [ "QSslCertificateExtension", "private", "", "public"] }, + { "symbol": [ "QSslCipher", "private", "", "public"] }, + { "symbol": [ "QSslConfiguration", "private", "", "public"] }, + { "symbol": [ "QSslDiffieHellmanParameters", "private", "", "public"] }, + { "symbol": [ "QSslEllipticCurve", "private", "", "public"] }, + { "symbol": [ "QSslError", "private", "", "public"] }, + { "symbol": [ "QSslKey", "private", "", "public"] }, + { "symbol": [ "QSslPreSharedKeyAuthenticator", "private", "", "public"] }, + { "symbol": [ "QSslServer", "private", "", "public"] }, + { "symbol": [ "QSslSocket", "private", "", "public"] }, + { "symbol": [ "QStack", "private", "", "public"] }, + { "symbol": [ "QStackedLayout", "private", "", "public"] }, + { "symbol": [ "QStackedWidget", "private", "", "public"] }, + { "symbol": [ "QStandardItem", "private", "", "public"] }, + { "symbol": [ "QStandardItemEditorCreator", "private", "", "public"] }, + { "symbol": [ "QStandardItemModel", "private", "", "public"] }, + { "symbol": [ "QStandardPaths", "private", "", "public"] }, + { "symbol": [ "QState", "private", "", "public"] }, + { "symbol": [ "QStateMachine", "private", "", "public"] }, + { "symbol": [ "QStaticByteArrayMatcher", "private", "", "public"] }, + { "symbol": [ "QStaticByteArrayMatcherBase", "private", "", "public"] }, + { "symbol": [ "QStaticLatin1StringMatcher", "private", "", "public"] }, + { "symbol": [ "QStaticPlugin", "private", "", "public"] }, + { "symbol": [ "QStaticText", "private", "", "public"] }, + { "symbol": [ "QStatusBar", "private", "", "public"] }, + { "symbol": [ "QStatusTipEvent", "private", "", "public"] }, + { "symbol": [ "QStorageInfo", "private", "", "public"] }, + { "symbol": [ "QString", "private", "", "public"] }, + { "symbol": [ "QStringAlgorithms", "private", "", "public"] }, + { "symbol": [ "QStringBuilder", "private", "", "public"] }, + { "symbol": [ "QStringConverter", "private", "", "public"] }, + { "symbol": [ "QStringConverterBase", "private", "", "public"] }, + { "symbol": [ "QStringDecoder", "private", "", "public"] }, + { "symbol": [ "QStringEncoder", "private", "", "public"] }, + { "symbol": [ "QStringList", "private", "", "public"] }, + { "symbol": [ "QStringListModel", "private", "", "public"] }, + { "symbol": [ "QStringLiteral", "private", "", "public"] }, + { "symbol": [ "QStringMatcher", "private", "", "public"] }, + { "symbol": [ "QStringRef", "private", "", "public"] }, + { "symbol": [ "QStringTokenizer", "private", "", "public"] }, + { "symbol": [ "QStringTokenizerBase", "private", "", "public"] }, + { "symbol": [ "QStringTokenizerBaseBase", "private", "", "public"] }, + { "symbol": [ "QStringView", "private", "", "public"] }, + { "symbol": [ "QStyle", "private", "", "public"] }, + { "symbol": [ "QStyleFactory", "private", "", "public"] }, + { "symbol": [ "QStyleHintReturn", "private", "", "public"] }, + { "symbol": [ "QStyleHintReturnMask", "private", "", "public"] }, + { "symbol": [ "QStyleHintReturnVariant", "private", "", "public"] }, + { "symbol": [ "QStyleHints", "private", "", "public"] }, + { "symbol": [ "QStyleOption", "private", "", "public"] }, + { "symbol": [ "QStyleOptionButton", "private", "", "public"] }, + { "symbol": [ "QStyleOptionComboBox", "private", "", "public"] }, + { "symbol": [ "QStyleOptionComplex", "private", "", "public"] }, + { "symbol": [ "QStyleOptionDockWidget", "private", "", "public"] }, + { "symbol": [ "QStyleOptionFocusRect", "private", "", "public"] }, + { "symbol": [ "QStyleOptionFrame", "private", "", "public"] }, + { "symbol": [ "QStyleOptionGraphicsItem", "private", "", "public"] }, + { "symbol": [ "QStyleOptionGroupBox", "private", "", "public"] }, + { "symbol": [ "QStyleOptionHeader", "private", "", "public"] }, + { "symbol": [ "QStyleOptionHeaderV2", "private", "", "public"] }, + { "symbol": [ "QStyleOptionMenuItem", "private", "", "public"] }, + { "symbol": [ "QStyleOptionMenuItemV2", "private", "", "public"] }, + { "symbol": [ "QStyleOptionProgressBar", "private", "", "public"] }, + { "symbol": [ "QStyleOptionRubberBand", "private", "", "public"] }, + { "symbol": [ "QStyleOptionSizeGrip", "private", "", "public"] }, + { "symbol": [ "QStyleOptionSlider", "private", "", "public"] }, + { "symbol": [ "QStyleOptionSpinBox", "private", "", "public"] }, + { "symbol": [ "QStyleOptionTab", "private", "", "public"] }, + { "symbol": [ "QStyleOptionTabBarBase", "private", "", "public"] }, + { "symbol": [ "QStyleOptionTabWidgetFrame", "private", "", "public"] }, + { "symbol": [ "QStyleOptionTitleBar", "private", "", "public"] }, + { "symbol": [ "QStyleOptionToolBar", "private", "", "public"] }, + { "symbol": [ "QStyleOptionToolBox", "private", "", "public"] }, + { "symbol": [ "QStyleOptionToolButton", "private", "", "public"] }, + { "symbol": [ "QStyleOptionViewItem", "private", "", "public"] }, + { "symbol": [ "QStylePainter", "private", "", "public"] }, + { "symbol": [ "QStylePlugin", "private", "", "public"] }, + { "symbol": [ "QStyledItemDelegate", "private", "", "public"] }, + { "symbol": [ "QSurface", "private", "", "public"] }, + { "symbol": [ "QSurfaceFormat", "private", "", "public"] }, + { "symbol": [ "QSvgGenerator", "private", "", "public"] }, + { "symbol": [ "QSvgRenderer", "private", "", "public"] }, + { "symbol": [ "QSvgWidget", "private", "", "public"] }, + { "symbol": [ "QSwipeGesture", "private", "", "public"] }, + { "symbol": [ "QSyntaxHighlighter", "private", "", "public"] }, + { "symbol": [ "QSysInfo", "private", "", "public"] }, + { "symbol": [ "QSystemSemaphore", "private", "", "public"] }, + { "symbol": [ "QSystemTrayIcon", "private", "", "public"] }, + { "symbol": [ "QTabBar", "private", "", "public"] }, + { "symbol": [ "QTabWidget", "private", "", "public"] }, + { "symbol": [ "QTableView", "private", "", "public"] }, + { "symbol": [ "QTableWidget", "private", "", "public"] }, + { "symbol": [ "QTableWidgetItem", "private", "", "public"] }, + { "symbol": [ "QTableWidgetSelectionRange", "private", "", "public"] }, + { "symbol": [ "QTabletEvent", "private", "", "public"] }, + { "symbol": [ "QTaggedIterator", "private", "", "public"] }, + { "symbol": [ "QTaggedPointer", "private", "", "public"] }, + { "symbol": [ "QTapAndHoldGesture", "private", "", "public"] }, + { "symbol": [ "QTapGesture", "private", "", "public"] }, + { "symbol": [ "QTcpServer", "private", "", "public"] }, + { "symbol": [ "QTcpSocket", "private", "", "public"] }, + { "symbol": [ "QTemplatedMetaMethodReturnArgument", "private", "", "public"] }, + { "symbol": [ "QTemporaryDir", "private", "", "public"] }, + { "symbol": [ "QTemporaryFile", "private", "", "public"] }, + { "symbol": [ "QTest", "private", "", "public"] }, + { "symbol": [ "QTestAccessibility", "private", "", "public"] }, + { "symbol": [ "QTestData", "private", "", "public"] }, + { "symbol": [ "QTestDelayEvent", "private", "", "public"] }, + { "symbol": [ "QTestEvent", "private", "", "public"] }, + { "symbol": [ "QTestEventList", "private", "", "public"] }, + { "symbol": [ "QTestEventLoop", "private", "", "public"] }, + { "symbol": [ "QTestKeyClicksEvent", "private", "", "public"] }, + { "symbol": [ "QTestKeyEvent", "private", "", "public"] }, + { "symbol": [ "QTestMouseEvent", "private", "", "public"] }, + { "symbol": [ "QTextBlock", "private", "", "public"] }, + { "symbol": [ "QTextBlockFormat", "private", "", "public"] }, + { "symbol": [ "QTextBlockGroup", "private", "", "public"] }, + { "symbol": [ "QTextBlockUserData", "private", "", "public"] }, + { "symbol": [ "QTextBoundaryFinder", "private", "", "public"] }, + { "symbol": [ "QTextBrowser", "private", "", "public"] }, + { "symbol": [ "QTextCharFormat", "private", "", "public"] }, + { "symbol": [ "QTextCodec", "private", "", "public"] }, + { "symbol": [ "QTextCursor", "private", "", "public"] }, + { "symbol": [ "QTextDecoder", "private", "", "public"] }, + { "symbol": [ "QTextDocument", "private", "", "public"] }, + { "symbol": [ "QTextDocumentFragment", "private", "", "public"] }, + { "symbol": [ "QTextDocumentWriter", "private", "", "public"] }, + { "symbol": [ "QTextEdit", "private", "", "public"] }, + { "symbol": [ "QTextEncoder", "private", "", "public"] }, + { "symbol": [ "QTextFormat", "private", "", "public"] }, + { "symbol": [ "QTextFragment", "private", "", "public"] }, + { "symbol": [ "QTextFrame", "private", "", "public"] }, + { "symbol": [ "QTextFrameFormat", "private", "", "public"] }, + { "symbol": [ "QTextFrameLayoutData", "private", "", "public"] }, + { "symbol": [ "QTextImageFormat", "private", "", "public"] }, + { "symbol": [ "QTextInlineObject", "private", "", "public"] }, + { "symbol": [ "QTextItem", "private", "", "public"] }, + { "symbol": [ "QTextLayout", "private", "", "public"] }, + { "symbol": [ "QTextLength", "private", "", "public"] }, + { "symbol": [ "QTextLine", "private", "", "public"] }, + { "symbol": [ "QTextList", "private", "", "public"] }, + { "symbol": [ "QTextListFormat", "private", "", "public"] }, + { "symbol": [ "QTextObject", "private", "", "public"] }, + { "symbol": [ "QTextObjectInterface", "private", "", "public"] }, + { "symbol": [ "QTextOption", "private", "", "public"] }, + { "symbol": [ "QTextStream", "private", "", "public"] }, + { "symbol": [ "QTextStreamFunction", "private", "", "public"] }, + { "symbol": [ "QTextStreamManipulator", "private", "", "public"] }, + { "symbol": [ "QTextTable", "private", "", "public"] }, + { "symbol": [ "QTextTableCell", "private", "", "public"] }, + { "symbol": [ "QTextTableCellFormat", "private", "", "public"] }, + { "symbol": [ "QTextTableFormat", "private", "", "public"] }, + { "symbol": [ "QThread", "private", "", "public"] }, + { "symbol": [ "QThreadPool", "private", "", "public"] }, + { "symbol": [ "QThreadStorage", "private", "", "public"] }, + { "symbol": [ "QThreadStorageData", "private", "", "public"] }, + { "symbol": [ "QThreadStorageTraits", "private", "", "public"] }, + { "symbol": [ "QTileRules", "private", "", "public"] }, + { "symbol": [ "QTime", "private", "", "public"] }, + { "symbol": [ "QTimeEdit", "private", "", "public"] }, + { "symbol": [ "QTimeLine", "private", "", "public"] }, + { "symbol": [ "QTimeZone", "private", "", "public"] }, + { "symbol": [ "QTimer", "private", "", "public"] }, + { "symbol": [ "QTimerEvent", "private", "", "public"] }, + { "symbol": [ "QToolBar", "private", "", "public"] }, + { "symbol": [ "QToolBarChangeEvent", "private", "", "public"] }, + { "symbol": [ "QToolBox", "private", "", "public"] }, + { "symbol": [ "QToolButton", "private", "", "public"] }, + { "symbol": [ "QToolTip", "private", "", "public"] }, + { "symbol": [ "QTouchEvent", "private", "", "public"] }, + { "symbol": [ "QTransform", "private", "", "public"] }, + { "symbol": [ "QTranslator", "private", "", "public"] }, + { "symbol": [ "QTransposeProxyModel", "private", "", "public"] }, + { "symbol": [ "QTreeView", "private", "", "public"] }, + { "symbol": [ "QTreeWidget", "private", "", "public"] }, + { "symbol": [ "QTreeWidgetItem", "private", "", "public"] }, + { "symbol": [ "QTreeWidgetItemIterator", "private", "", "public"] }, + { "symbol": [ "QTypeInfo", "private", "", "public"] }, + { "symbol": [ "QTypeInfoMerger", "private", "", "public"] }, + { "symbol": [ "QTypeRevision", "private", "", "public"] }, + { "symbol": [ "QTypedArrayAllocationResult", "private", "", "public"] }, + { "symbol": [ "QTypedArrayData", "private", "", "public"] }, + { "symbol": [ "QUdpSocket", "private", "", "public"] }, + { "symbol": [ "QUiLoader", "private", "", "public"] }, + { "symbol": [ "QUndoCommand", "private", "", "public"] }, + { "symbol": [ "QUndoGroup", "private", "", "public"] }, + { "symbol": [ "QUndoStack", "private", "", "public"] }, + { "symbol": [ "QUndoView", "private", "", "public"] }, + { "symbol": [ "QUnhandledException", "private", "", "public"] }, + { "symbol": [ "QUntypedBindable", "private", "", "public"] }, + { "symbol": [ "QUntypedPropertyBinding", "private", "", "public"] }, + { "symbol": [ "QUntypedPropertyData", "private", "", "public"] }, + { "symbol": [ "QUrl", "private", "", "public"] }, + { "symbol": [ "QUrlQuery", "private", "", "public"] }, + { "symbol": [ "QUrlTwoFlags", "private", "", "public"] }, + { "symbol": [ "QUtf8StringView", "private", "", "public"] }, + { "symbol": [ "QUuid", "private", "", "public"] }, + { "symbol": [ "QVBoxLayout", "private", "", "public"] }, + { "symbol": [ "QValidator", "private", "", "public"] }, + { "symbol": [ "QVarLengthArray", "private", "", "public"] }, + { "symbol": [ "QVariant", "private", "", "public"] }, + { "symbol": [ "QVariantAnimation", "private", "", "public"] }, + { "symbol": [ "QVariantHash", "private", "", "public"] }, + { "symbol": [ "QVariantList", "private", "", "public"] }, + { "symbol": [ "QVariantMap", "private", "", "public"] }, + { "symbol": [ "QVector", "private", "", "public"] }, + { "symbol": [ "QVector2D", "private", "", "public"] }, + { "symbol": [ "QVector3D", "private", "", "public"] }, + { "symbol": [ "QVector4D", "private", "", "public"] }, + { "symbol": [ "QVectorIterator", "private", "", "public"] }, + { "symbol": [ "QVersionNumber", "private", "", "public"] }, + { "symbol": [ "QVideoFrame", "private", "", "public"] }, + { "symbol": [ "QVideoFrameFormat", "private", "", "public"] }, + { "symbol": [ "QVideoFrameInput", "private", "", "public"] }, + { "symbol": [ "QVideoSink", "private", "", "public"] }, + { "symbol": [ "QVideoWidget", "private", "", "public"] }, + { "symbol": [ "QVulkanDeviceFunctions", "private", "", "public"] }, + { "symbol": [ "QVulkanExtension", "private", "", "public"] }, + { "symbol": [ "QVulkanFunctions", "private", "", "public"] }, + { "symbol": [ "QVulkanInfoVector", "private", "", "public"] }, + { "symbol": [ "QVulkanInstance", "private", "", "public"] }, + { "symbol": [ "QVulkanLayer", "private", "", "public"] }, + { "symbol": [ "QVulkanWindow", "private", "", "public"] }, + { "symbol": [ "QVulkanWindowRenderer", "private", "", "public"] }, + { "symbol": [ "QWaitCondition", "private", "", "public"] }, + { "symbol": [ "QWaveDecoder", "private", "", "public"] }, + { "symbol": [ "QWaylandBufferRef", "private", "", "public"] }, + { "symbol": [ "QWaylandClient", "private", "", "public"] }, + { "symbol": [ "QWaylandClientExtension", "private", "", "public"] }, + { "symbol": [ "QWaylandClientExtensionTemplate", "private", "", "public"] }, + { "symbol": [ "QWaylandCompositor", "private", "", "public"] }, + { "symbol": [ "QWaylandCompositorExtension", "private", "", "public"] }, + { "symbol": [ "QWaylandCompositorExtensionTemplate", "private", "", "public"] }, + { "symbol": [ "QWaylandDestroyListener", "private", "", "public"] }, + { "symbol": [ "QWaylandDrag", "private", "", "public"] }, + { "symbol": [ "QWaylandIdleInhibitManagerV1", "private", "", "public"] }, + { "symbol": [ "QWaylandInputMethodControl", "private", "", "public"] }, + { "symbol": [ "QWaylandIviApplication", "private", "", "public"] }, + { "symbol": [ "QWaylandIviSurface", "private", "", "public"] }, + { "symbol": [ "QWaylandKeyboard", "private", "", "public"] }, + { "symbol": [ "QWaylandKeymap", "private", "", "public"] }, + { "symbol": [ "QWaylandObject", "private", "", "public"] }, + { "symbol": [ "QWaylandOutput", "private", "", "public"] }, + { "symbol": [ "QWaylandOutputMode", "private", "", "public"] }, + { "symbol": [ "QWaylandPointer", "private", "", "public"] }, + { "symbol": [ "QWaylandQtTextInputMethod", "private", "", "public"] }, + { "symbol": [ "QWaylandQtTextInputMethodManager", "private", "", "public"] }, + { "symbol": [ "QWaylandQtWindowManager", "private", "", "public"] }, + { "symbol": [ "QWaylandQuickCompositor", "private", "", "public"] }, + { "symbol": [ "QWaylandQuickExtension", "private", "", "public"] }, + { "symbol": [ "QWaylandQuickItem", "private", "", "public"] }, + { "symbol": [ "QWaylandQuickOutput", "private", "", "public"] }, + { "symbol": [ "QWaylandQuickShellIntegration", "private", "", "public"] }, + { "symbol": [ "QWaylandQuickShellSurfaceItem", "private", "", "public"] }, + { "symbol": [ "QWaylandQuickSurface", "private", "", "public"] }, + { "symbol": [ "QWaylandQuickXdgOutputV1", "private", "", "public"] }, + { "symbol": [ "QWaylandResource", "private", "", "public"] }, + { "symbol": [ "QWaylandSeat", "private", "", "public"] }, + { "symbol": [ "QWaylandShell", "private", "", "public"] }, + { "symbol": [ "QWaylandShellSurface", "private", "", "public"] }, + { "symbol": [ "QWaylandShellSurfaceTemplate", "private", "", "public"] }, + { "symbol": [ "QWaylandShellTemplate", "private", "", "public"] }, + { "symbol": [ "QWaylandSurface", "private", "", "public"] }, + { "symbol": [ "QWaylandSurfaceGrabber", "private", "", "public"] }, + { "symbol": [ "QWaylandSurfaceRole", "private", "", "public"] }, + { "symbol": [ "QWaylandTextInput", "private", "", "public"] }, + { "symbol": [ "QWaylandTextInputManager", "private", "", "public"] }, + { "symbol": [ "QWaylandTextInputManagerV3", "private", "", "public"] }, + { "symbol": [ "QWaylandTextInputV3", "private", "", "public"] }, + { "symbol": [ "QWaylandTouch", "private", "", "public"] }, + { "symbol": [ "QWaylandView", "private", "", "public"] }, + { "symbol": [ "QWaylandViewporter", "private", "", "public"] }, + { "symbol": [ "QWaylandWlShell", "private", "", "public"] }, + { "symbol": [ "QWaylandWlShellSurface", "private", "", "public"] }, + { "symbol": [ "QWaylandXdgDecorationManagerV1", "private", "", "public"] }, + { "symbol": [ "QWaylandXdgOutputManagerV1", "private", "", "public"] }, + { "symbol": [ "QWaylandXdgOutputV1", "private", "", "public"] }, + { "symbol": [ "QWaylandXdgPopup", "private", "", "public"] }, + { "symbol": [ "QWaylandXdgShell", "private", "", "public"] }, + { "symbol": [ "QWaylandXdgSurface", "private", "", "public"] }, + { "symbol": [ "QWaylandXdgToplevel", "private", "", "public"] }, + { "symbol": [ "QWeakPointer", "private", "", "public"] }, + { "symbol": [ "QWebChannel", "private", "", "public"] }, + { "symbol": [ "QWebChannelAbstractTransport", "private", "", "public"] }, + { "symbol": [ "QWebEngineCertificateError", "private", "", "public"] }, + { "symbol": [ "QWebEngineClientCertificateSelection", "private", "", "public"] }, + { "symbol": [ "QWebEngineClientCertificateStore", "private", "", "public"] }, + { "symbol": [ "QWebEngineClientHints", "private", "", "public"] }, + { "symbol": [ "QWebEngineContextMenuRequest", "private", "", "public"] }, + { "symbol": [ "QWebEngineCookieStore", "private", "", "public"] }, + { "symbol": [ "QWebEngineDesktopMediaRequest", "private", "", "public"] }, + { "symbol": [ "QWebEngineDownloadRequest", "private", "", "public"] }, + { "symbol": [ "QWebEngineExtensionInfo", "private", "", "public"] }, + { "symbol": [ "QWebEngineExtensionManager", "private", "", "public"] }, + { "symbol": [ "QWebEngineFileSystemAccessRequest", "private", "", "public"] }, + { "symbol": [ "QWebEngineFindTextResult", "private", "", "public"] }, + { "symbol": [ "QWebEngineFrame", "private", "", "public"] }, + { "symbol": [ "QWebEngineFullScreenRequest", "private", "", "public"] }, + { "symbol": [ "QWebEngineGlobalSettings", "private", "", "public"] }, + { "symbol": [ "QWebEngineHistory", "private", "", "public"] }, + { "symbol": [ "QWebEngineHistoryItem", "private", "", "public"] }, + { "symbol": [ "QWebEngineHistoryModel", "private", "", "public"] }, + { "symbol": [ "QWebEngineHttpRequest", "private", "", "public"] }, + { "symbol": [ "QWebEngineLoadingInfo", "private", "", "public"] }, + { "symbol": [ "QWebEngineNavigationRequest", "private", "", "public"] }, + { "symbol": [ "QWebEngineNewWindowRequest", "private", "", "public"] }, + { "symbol": [ "QWebEngineNotification", "private", "", "public"] }, + { "symbol": [ "QWebEnginePage", "private", "", "public"] }, + { "symbol": [ "QWebEnginePermission", "private", "", "public"] }, + { "symbol": [ "QWebEngineProfile", "private", "", "public"] }, + { "symbol": [ "QWebEngineProfileBuilder", "private", "", "public"] }, + { "symbol": [ "QWebEngineQuotaRequest", "private", "", "public"] }, + { "symbol": [ "QWebEngineRegisterProtocolHandlerRequest", "private", "", "public"] }, + { "symbol": [ "QWebEngineScript", "private", "", "public"] }, + { "symbol": [ "QWebEngineScriptCollection", "private", "", "public"] }, + { "symbol": [ "QWebEngineSettings", "private", "", "public"] }, + { "symbol": [ "QWebEngineUrlRequestInfo", "private", "", "public"] }, + { "symbol": [ "QWebEngineUrlRequestInterceptor", "private", "", "public"] }, + { "symbol": [ "QWebEngineUrlRequestJob", "private", "", "public"] }, + { "symbol": [ "QWebEngineUrlScheme", "private", "", "public"] }, + { "symbol": [ "QWebEngineUrlSchemeHandler", "private", "", "public"] }, + { "symbol": [ "QWebEngineView", "private", "", "public"] }, + { "symbol": [ "QWebEngineWebAuthPinRequest", "private", "", "public"] }, + { "symbol": [ "QWebEngineWebAuthUxRequest", "private", "", "public"] }, + { "symbol": [ "QWebSocket", "private", "", "public"] }, + { "symbol": [ "QWebSocketCorsAuthenticator", "private", "", "public"] }, + { "symbol": [ "QWebSocketHandshakeOptions", "private", "", "public"] }, + { "symbol": [ "QWebSocketProtocol", "private", "", "public"] }, + { "symbol": [ "QWebSocketServer", "private", "", "public"] }, + { "symbol": [ "QWhatsThis", "private", "", "public"] }, + { "symbol": [ "QWhatsThisClickedEvent", "private", "", "public"] }, + { "symbol": [ "QWheelEvent", "private", "", "public"] }, + { "symbol": [ "QWidget", "private", "", "public"] }, + { "symbol": [ "QWidgetAction", "private", "", "public"] }, + { "symbol": [ "QWidgetData", "private", "", "public"] }, + { "symbol": [ "QWidgetItem", "private", "", "public"] }, + { "symbol": [ "QWidgetItemV2", "private", "", "public"] }, + { "symbol": [ "QWidgetList", "private", "", "public"] }, + { "symbol": [ "QWidgetMapper", "private", "", "public"] }, + { "symbol": [ "QWidgetSet", "private", "", "public"] }, + { "symbol": [ "QWinEventNotifier", "private", "", "public"] }, + { "symbol": [ "QWindow", "private", "", "public"] }, + { "symbol": [ "QWindowCapture", "private", "", "public"] }, + { "symbol": [ "QWindowList", "private", "", "public"] }, + { "symbol": [ "QWindowStateChangeEvent", "private", "", "public"] }, + { "symbol": [ "QWizard", "private", "", "public"] }, + { "symbol": [ "QWizardPage", "private", "", "public"] }, + { "symbol": [ "QWriteLocker", "private", "", "public"] }, + { "symbol": [ "QXmlAttributes", "private", "", "public"] }, + { "symbol": [ "QXmlContentHandler", "private", "", "public"] }, + { "symbol": [ "QXmlDTDHandler", "private", "", "public"] }, + { "symbol": [ "QXmlDeclHandler", "private", "", "public"] }, + { "symbol": [ "QXmlDefaultHandler", "private", "", "public"] }, + { "symbol": [ "QXmlEntityResolver", "private", "", "public"] }, + { "symbol": [ "QXmlErrorHandler", "private", "", "public"] }, + { "symbol": [ "QXmlInputSource", "private", "", "public"] }, + { "symbol": [ "QXmlLexicalHandler", "private", "", "public"] }, + { "symbol": [ "QXmlLocator", "private", "", "public"] }, + { "symbol": [ "QXmlNamespaceSupport", "private", "", "public"] }, + { "symbol": [ "QXmlParseException", "private", "", "public"] }, + { "symbol": [ "QXmlReader", "private", "", "public"] }, + { "symbol": [ "QXmlSimpleReader", "private", "", "public"] }, + { "symbol": [ "QXmlStreamAttribute", "private", "", "public"] }, + { "symbol": [ "QXmlStreamAttributes", "private", "", "public"] }, + { "symbol": [ "QXmlStreamEntityDeclaration", "private", "", "public"] }, + { "symbol": [ "QXmlStreamEntityDeclarations", "private", "", "public"] }, + { "symbol": [ "QXmlStreamEntityResolver", "private", "", "public"] }, + { "symbol": [ "QXmlStreamNamespaceDeclaration", "private", "", "public"] }, + { "symbol": [ "QXmlStreamNamespaceDeclarations", "private", "", "public"] }, + { "symbol": [ "QXmlStreamNotationDeclaration", "private", "", "public"] }, + { "symbol": [ "QXmlStreamNotationDeclarations", "private", "", "public"] }, + { "symbol": [ "QXmlStreamReader", "private", "", "public"] }, + { "symbol": [ "QXmlStreamWriter", "private", "", "public"] }, + { "symbol": [ "Q_IPV6ADDR", "private", "", "public"] }, + { "symbol": [ "Qt", "private", "", "public"] }, + { "symbol": [ "QtAlgorithms", "private", "", "public"] }, + { "symbol": [ "QtAudio", "private", "", "public"] }, + { "symbol": [ "QtBluetoothVersion", "private", "", "public"] }, + { "symbol": [ "QtCborCommon", "private", "", "public"] }, + { "symbol": [ "QtCleanUpFunction", "private", "", "public"] }, + { "symbol": [ "QtCompare", "private", "", "public"] }, + { "symbol": [ "QtCompilerDetection", "private", "", "public"] }, + { "symbol": [ "QtConcurrentFilter", "private", "", "public"] }, + { "symbol": [ "QtConcurrentMap", "private", "", "public"] }, + { "symbol": [ "QtConcurrentRun", "private", "", "public"] }, + { "symbol": [ "QtConcurrentTask", "private", "", "public"] }, + { "symbol": [ "QtConcurrentVersion", "private", "", "public"] }, + { "symbol": [ "QtConfig", "private", "", "public"] }, + { "symbol": [ "QtConnectivityVersion", "private", "", "public"] }, + { "symbol": [ "QtConstructorMacros", "private", "", "public"] }, + { "symbol": [ "QtContainerFwd", "private", "", "public"] }, + { "symbol": [ "QtCore5CompatVersion", "private", "", "public"] }, + { "symbol": [ "QtCoreGlobal", "private", "", "public"] }, + { "symbol": [ "QtCoreVersion", "private", "", "public"] }, + { "symbol": [ "QtCoverageScanner", "private", "", "public"] }, + { "symbol": [ "QtDBusVersion", "private", "", "public"] }, + { "symbol": [ "QtDarwinHelpers", "private", "", "public"] }, + { "symbol": [ "QtDebug", "private", "", "public"] }, + { "symbol": [ "QtDeprecationMarkers", "private", "", "public"] }, + { "symbol": [ "QtDeviceDiscoverySupportVersion", "private", "", "public"] }, + { "symbol": [ "QtEndian", "private", "", "public"] }, + { "symbol": [ "QtEvents", "private", "", "public"] }, + { "symbol": [ "QtExceptionHandling", "private", "", "public"] }, + { "symbol": [ "QtFFmpegMediaPluginImplVersion", "private", "", "public"] }, + { "symbol": [ "QtFbSupportVersion", "private", "", "public"] }, + { "symbol": [ "QtGlobal", "private", "", "public"] }, + { "symbol": [ "QtGuiVersion", "private", "", "public"] }, + { "symbol": [ "QtHelpVersion", "private", "", "public"] }, + { "symbol": [ "QtInputSupportVersion", "private", "", "public"] }, + { "symbol": [ "QtKmsSupportVersion", "private", "", "public"] }, + { "symbol": [ "QtLabsAnimationVersion", "private", "", "public"] }, + { "symbol": [ "QtLabsFolderListModelVersion", "private", "", "public"] }, + { "symbol": [ "QtLabsPlatformVersion", "private", "", "public"] }, + { "symbol": [ "QtLabsQmlModelsVersion", "private", "", "public"] }, + { "symbol": [ "QtLabsSettingsVersion", "private", "", "public"] }, + { "symbol": [ "QtLabsSharedImageVersion", "private", "", "public"] }, + { "symbol": [ "QtLabsStyleKitImplVersion", "private", "", "public"] }, + { "symbol": [ "QtLabsStyleKitVersion", "private", "", "public"] }, + { "symbol": [ "QtLabsSynchronizerVersion", "private", "", "public"] }, + { "symbol": [ "QtLabsWavefrontMeshVersion", "private", "", "public"] }, + { "symbol": [ "QtLogging", "private", "", "public"] }, + { "symbol": [ "QtMalloc", "private", "", "public"] }, + { "symbol": [ "QtMath", "private", "", "public"] }, + { "symbol": [ "QtMessageHandler", "private", "", "public"] }, + { "symbol": [ "QtMultimediaQuickVersion", "private", "", "public"] }, + { "symbol": [ "QtMultimediaTestLibVersion", "private", "", "public"] }, + { "symbol": [ "QtMultimediaVersion", "private", "", "public"] }, + { "symbol": [ "QtMultimediaWidgetsVersion", "private", "", "public"] }, + { "symbol": [ "QtNetworkAuthVersion", "private", "", "public"] }, + { "symbol": [ "QtNetworkVersion", "private", "", "public"] }, + { "symbol": [ "QtNumeric", "private", "", "public"] }, + { "symbol": [ "QtOpenGLVersion", "private", "", "public"] }, + { "symbol": [ "QtOpenGLWidgetsVersion", "private", "", "public"] }, + { "symbol": [ "QtPacketProtocolVersion", "private", "", "public"] }, + { "symbol": [ "QtPdfQuickVersion", "private", "", "public"] }, + { "symbol": [ "QtPdfVersion", "private", "", "public"] }, + { "symbol": [ "QtPdfWidgetsVersion", "private", "", "public"] }, + { "symbol": [ "QtPlugin", "private", "", "public"] }, + { "symbol": [ "QtPluginInstanceFunction", "private", "", "public"] }, + { "symbol": [ "QtPluginMetaDataFunction", "private", "", "public"] }, + { "symbol": [ "QtPrintSupportVersion", "private", "", "public"] }, + { "symbol": [ "QtPrivate", "private", "", "public"] }, + { "symbol": [ "QtProcessorDetection", "private", "", "public"] }, + { "symbol": [ "QtQmlCompilerVersion", "private", "", "public"] }, + { "symbol": [ "QtQmlCoreVersion", "private", "", "public"] }, + { "symbol": [ "QtQmlDebugVersion", "private", "", "public"] }, + { "symbol": [ "QtQmlDomVersion", "private", "", "public"] }, + { "symbol": [ "QtQmlFormatVersion", "private", "", "public"] }, + { "symbol": [ "QtQmlIntegrationVersion", "private", "", "public"] }, + { "symbol": [ "QtQmlLocalStorageVersion", "private", "", "public"] }, + { "symbol": [ "QtQmlMetaVersion", "private", "", "public"] }, + { "symbol": [ "QtQmlModelsVersion", "private", "", "public"] }, + { "symbol": [ "QtQmlNetworkVersion", "private", "", "public"] }, + { "symbol": [ "QtQmlToolingSettingsVersion", "private", "", "public"] }, + { "symbol": [ "QtQmlTypeRegistrarVersion", "private", "", "public"] }, + { "symbol": [ "QtQmlVersion", "private", "", "public"] }, + { "symbol": [ "QtQmlWorkerScriptVersion", "private", "", "public"] }, + { "symbol": [ "QtQmlXmlListModelVersion", "private", "", "public"] }, + { "symbol": [ "QtQmltcVersion", "private", "", "public"] }, + { "symbol": [ "QtQuick3DAssetImportVersion", "private", "", "public"] }, + { "symbol": [ "QtQuick3DAssetUtilsVersion", "private", "", "public"] }, + { "symbol": [ "QtQuick3DGlslParserVersion", "private", "", "public"] }, + { "symbol": [ "QtQuick3DHelpersImplVersion", "private", "", "public"] }, + { "symbol": [ "QtQuick3DHelpersVersion", "private", "", "public"] }, + { "symbol": [ "QtQuick3DIblBakerVersion", "private", "", "public"] }, + { "symbol": [ "QtQuick3DParticlesVersion", "private", "", "public"] }, + { "symbol": [ "QtQuick3DRuntimeRenderVersion", "private", "", "public"] }, + { "symbol": [ "QtQuick3DSpatialAudioVersion", "private", "", "public"] }, + { "symbol": [ "QtQuick3DUtilsVersion", "private", "", "public"] }, + { "symbol": [ "QtQuick3DVersion", "private", "", "public"] }, + { "symbol": [ "QtQuickControls2BasicStyleImplVersion", "private", "", "public"] }, + { "symbol": [ "QtQuickControls2BasicVersion", "private", "", "public"] }, + { "symbol": [ "QtQuickControls2FluentWinUI3StyleImplVersion", "private", "", "public"] }, + { "symbol": [ "QtQuickControls2FusionStyleImplVersion", "private", "", "public"] }, + { "symbol": [ "QtQuickControls2FusionVersion", "private", "", "public"] }, + { "symbol": [ "QtQuickControls2ImagineStyleImplVersion", "private", "", "public"] }, + { "symbol": [ "QtQuickControls2ImagineVersion", "private", "", "public"] }, + { "symbol": [ "QtQuickControls2ImplVersion", "private", "", "public"] }, + { "symbol": [ "QtQuickControls2MaterialStyleImplVersion", "private", "", "public"] }, + { "symbol": [ "QtQuickControls2MaterialVersion", "private", "", "public"] }, + { "symbol": [ "QtQuickControls2UniversalStyleImplVersion", "private", "", "public"] }, + { "symbol": [ "QtQuickControls2UniversalVersion", "private", "", "public"] }, + { "symbol": [ "QtQuickControls2Version", "private", "", "public"] }, + { "symbol": [ "QtQuickControlsTestUtilsVersion", "private", "", "public"] }, + { "symbol": [ "QtQuickDialogs2QuickImplVersion", "private", "", "public"] }, + { "symbol": [ "QtQuickDialogs2UtilsVersion", "private", "", "public"] }, + { "symbol": [ "QtQuickDialogs2Version", "private", "", "public"] }, + { "symbol": [ "QtQuickEffectsVersion", "private", "", "public"] }, + { "symbol": [ "QtQuickLayoutsVersion", "private", "", "public"] }, + { "symbol": [ "QtQuickParticlesVersion", "private", "", "public"] }, + { "symbol": [ "QtQuickShapesDesignHelpersVersion", "private", "", "public"] }, + { "symbol": [ "QtQuickShapesVersion", "private", "", "public"] }, + { "symbol": [ "QtQuickTemplates2Version", "private", "", "public"] }, + { "symbol": [ "QtQuickTestUtilsVersion", "private", "", "public"] }, + { "symbol": [ "QtQuickTestVersion", "private", "", "public"] }, + { "symbol": [ "QtQuickTimelineBlendTreesVersion", "private", "", "public"] }, + { "symbol": [ "QtQuickTimelineVersion", "private", "", "public"] }, + { "symbol": [ "QtQuickVectorImageGeneratorVersion", "private", "", "public"] }, + { "symbol": [ "QtQuickVectorImageHelpersVersion", "private", "", "public"] }, + { "symbol": [ "QtQuickVectorImageVersion", "private", "", "public"] }, + { "symbol": [ "QtQuickVersion", "private", "", "public"] }, + { "symbol": [ "QtQuickWidgetsVersion", "private", "", "public"] }, + { "symbol": [ "QtResource", "private", "", "public"] }, + { "symbol": [ "QtScxmlGlobalVersion", "private", "", "public"] }, + { "symbol": [ "QtScxmlQmlVersion", "private", "", "public"] }, + { "symbol": [ "QtScxmlVersion", "private", "", "public"] }, + { "symbol": [ "QtShaderToolsVersion", "private", "", "public"] }, + { "symbol": [ "QtSpatialAudioVersion", "private", "", "public"] }, + { "symbol": [ "QtSqlVersion", "private", "", "public"] }, + { "symbol": [ "QtStartUpFunction", "private", "", "public"] }, + { "symbol": [ "QtStateMachineQmlVersion", "private", "", "public"] }, + { "symbol": [ "QtStateMachineVersion", "private", "", "public"] }, + { "symbol": [ "QtStdLibDetection", "private", "", "public"] }, + { "symbol": [ "QtSvgVersion", "private", "", "public"] }, + { "symbol": [ "QtSvgWidgetsVersion", "private", "", "public"] }, + { "symbol": [ "QtSwap", "private", "", "public"] }, + { "symbol": [ "QtSystemDetection", "private", "", "public"] }, + { "symbol": [ "QtTestGui", "private", "", "public"] }, + { "symbol": [ "QtTestNetwork", "private", "", "public"] }, + { "symbol": [ "QtTestVersion", "private", "", "public"] }, + { "symbol": [ "QtTestWidgets", "private", "", "public"] }, + { "symbol": [ "QtToolsVersion", "private", "", "public"] }, + { "symbol": [ "QtTypeTraits", "private", "", "public"] }, + { "symbol": [ "QtUiPluginVersion", "private", "", "public"] }, + { "symbol": [ "QtUiToolsVersion", "private", "", "public"] }, + { "symbol": [ "QtVersion", "private", "", "public"] }, + { "symbol": [ "QtWaylandClientVersion", "private", "", "public"] }, + { "symbol": [ "QtWaylandCompositorIviapplicationVersion", "private", "", "public"] }, + { "symbol": [ "QtWaylandCompositorPresentationTimeVersion", "private", "", "public"] }, + { "symbol": [ "QtWaylandCompositorVersion", "private", "", "public"] }, + { "symbol": [ "QtWaylandCompositorWLShellVersion", "private", "", "public"] }, + { "symbol": [ "QtWaylandCompositorXdgShellVersion", "private", "", "public"] }, + { "symbol": [ "QtWaylandEglCompositorHwIntegrationVersion", "private", "", "public"] }, + { "symbol": [ "QtWaylandGlobalVersion", "private", "", "public"] }, + { "symbol": [ "QtWebChannelQuickVersion", "private", "", "public"] }, + { "symbol": [ "QtWebChannelVersion", "private", "", "public"] }, + { "symbol": [ "QtWebEngineCoreVersion", "private", "", "public"] }, + { "symbol": [ "QtWebEngineQuickVersion", "private", "", "public"] }, + { "symbol": [ "QtWebEngineWidgetsVersion", "private", "", "public"] }, + { "symbol": [ "QtWebSocketsVersion", "private", "", "public"] }, + { "symbol": [ "QtWidgetsVersion", "private", "", "public"] }, + { "symbol": [ "QtWlShellIntegrationVersion", "private", "", "public"] }, + { "symbol": [ "QtXmlVersion", "private", "", "public"] }, + { "symbol": [ "operator==", "private", "", "public"] }, + { "symbol": [ "operator!=", "private", "", "public"] }, + { "symbol": [ "operator<", "private", "", "public"] }, + { "symbol": [ "operator<=", "private", "", "public"] }, + { "symbol": [ "operator>", "private", "", "public"] }, + { "symbol": [ "operator>=", "private", "", "public"] }, + { "symbol": [ "comparesEqual", "private", "", "public"] }, + { "include": [ "@[\"<](QtBluetooth/)?qbluetoothaddress\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtBluetooth/)?qbluetoothdevicediscoveryagent\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtBluetooth/)?qbluetoothdeviceinfo\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtBluetooth/)?qbluetoothhostinfo\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtBluetooth/)?qbluetoothlocaldevice\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtBluetooth/)?qbluetoothserver\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtBluetooth/)?qbluetoothservicediscoveryagent\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtBluetooth/)?qbluetoothserviceinfo\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtBluetooth/)?qbluetoothsocket\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtBluetooth/)?qbluetoothuuid\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtBluetooth/)?qlowenergyadvertisingdata\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtBluetooth/)?qlowenergyadvertisingparameters\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtBluetooth/)?qlowenergycharacteristic\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtBluetooth/)?qlowenergycharacteristicdata\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtBluetooth/)?qlowenergyconnectionparameters\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtBluetooth/)?qlowenergycontroller\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtBluetooth/)?qlowenergydescriptor\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtBluetooth/)?qlowenergydescriptordata\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtBluetooth/)?qlowenergyservice\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtBluetooth/)?qlowenergyservicedata\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtBluetooth/)?qtbluetoothversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtConcurrent/)?qtconcurrentfilter\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtConcurrent/)?qtconcurrentmap\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtConcurrent/)?qtconcurrentrun\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtConcurrent/)?qtconcurrenttask\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtConcurrent/)?qtconcurrentversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtConnectivity/)?qtconnectivityversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qabstractanimation\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qabstracteventdispatcher\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qabstractitemmodel\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qabstractnativeeventfilter\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qabstractproxymodel\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qanimationgroup\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qanystringview\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qapplicationstatic\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qarraydata\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qarraydataops\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qarraydatapointer\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qassert\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qassociativeiterable\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qatomicscopedvaluerollback\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qbasictimer\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qbindingstorage\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qbitarray\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qbuffer\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qbytearray\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qbytearrayalgorithms\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qbytearraylist\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qbytearraymatcher\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qbytearrayview\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qcache\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qcalendar\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qcborarray\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qcbormap\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qcborstreamreader\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qcborstreamwriter\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qcborvalue\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qchar\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qchronotimer\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qcollator\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qcommandlineoption\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qcommandlineparser\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qconcatenatetablesproxymodel\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qcontiguouscache\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qcoreapplication\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qcryptographichash\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qdatastream\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qdatetime\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qdeadlinetimer\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qdebug\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qdir\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qdiriterator\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qdirlisting\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qeasingcurve\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qelapsedtimer\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qeventloop\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qexception\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qfactoryinterface\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qfile\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qfiledevice\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qfileinfo\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qfileselector\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qfilesystemwatcher\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qflags\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qfloat16\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qforeach\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qfunctionpointer\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qfuture\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qfutureinterface\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qfuturesynchronizer\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qfuturewatcher\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qglobalstatic\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qhash\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qhashfunctions\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qidentityproxymodel\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qiodevice\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qiodevicebase\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qitemselectionmodel\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qiterable\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qjsonarray\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qjsondocument\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qjsonobject\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qjsonparseerror\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qjsonvalue\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qlatin1stringmatcher\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qlatin1stringview\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qlibrary\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qlibraryinfo\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qline\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qlist\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qlocale\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qlockfile\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qloggingcategory\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qmap\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qmargins\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qmessageauthenticationcode\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qmetaassociation\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qmetacontainer\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qmetasequence\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qmetatype\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qmimedata\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qmimedatabase\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qmimetype\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qminmax\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qmutex\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qobject\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qobjectcleanuphandler\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qoperatingsystemversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qoverload\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qpair\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qparallelanimationgroup\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qpauseanimation\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qpermissions\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qpluginloader\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qpoint\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qpointer\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qprocess\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qpromise\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qproperty\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qpropertyanimation\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qqueue\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qrangemodel\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qrangemodeladapter\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qreadwritelock\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qrect\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qregularexpression\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qresource\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qrunnable\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qsavefile\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qscopedpointer\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qscopedvaluerollback\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qscopeguard\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qsemaphore\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qsequentialanimationgroup\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qsequentialiterable\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qset\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qsettings\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qshareddata\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qsharedmemory\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qsharedpointer\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qsignalmapper\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qsize\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qsocketnotifier\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qsortfilterproxymodel\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qspan\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qstack\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qstandardpaths\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qstaticlatin1stringmatcher\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qstorageinfo\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qstring\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qstringalgorithms\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qstringbuilder\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qstringconverter\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qstringlist\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qstringlistmodel\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qstringliteral\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qstringmatcher\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qstringtokenizer\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qstringview\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qsysinfo\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qsystemsemaphore\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qtaggedpointer\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qtclasshelpermacros\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qtcoreglobal\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qtcoreversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qtdeprecationmarkers\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qtemporarydir\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qtemporaryfile\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qtenvironmentvariables\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qtextboundaryfinder\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qtextstream\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qthread\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qthreadpool\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qthreadstorage\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qtimeline\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qtimer\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qtimezone\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qtpreprocessorsupport\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qtranslator\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qtransposeproxymodel\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qtresource\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qttranslation\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qttypetraits\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qtversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qtversionchecks\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qtypeinfo\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qtyperevision\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qtypes\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qurl\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qurlquery\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qutf8stringview\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?quuid\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qvariant\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qvariantanimation\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qvarianthash\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qvariantlist\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qvariantmap\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qvarlengtharray\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qvector\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qversionnumber\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qwaitcondition\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore/)?qwineventnotifier\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore5Compat/)?qbinaryjson\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore5Compat/)?qlinkedlist\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore5Compat/)?qregexp\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore5Compat/)?qstringref\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore5Compat/)?qtcore5compatversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtCore5Compat/)?qtextcodec\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtDBus/)?qdbusabstractadaptor\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtDBus/)?qdbusabstractinterface\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtDBus/)?qdbusargument\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtDBus/)?qdbusconnection\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtDBus/)?qdbusconnectioninterface\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtDBus/)?qdbuscontext\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtDBus/)?qdbuserror\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtDBus/)?qdbusinterface\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtDBus/)?qdbusmessage\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtDBus/)?qdbusmetatype\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtDBus/)?qdbuspendingcall\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtDBus/)?qdbuspendingreply\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtDBus/)?qdbusreply\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtDBus/)?qdbusserver\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtDBus/)?qdbusservicewatcher\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtDBus/)?qdbusunixfiledescriptor\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtDBus/)?qdbusvirtualobject\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtDBus/)?qtdbusversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtDeviceDiscoverySupport/)?qtdevicediscoverysupportversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtFFmpegMediaPluginImpl/)?qtffmpegmediapluginimplversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtFbSupport/)?qtfbsupportversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qabstractfileiconprovider\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qabstracttextdocumentlayout\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qaccessibilityhints\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qaccessible\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qaccessiblebridge\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qaccessibleobject\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qaccessibleplugin\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qaction\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qactiongroup\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qbackingstore\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qbitmap\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qbrush\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qclipboard\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qcolor\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qcolorspace\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qcolortransform\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qcursor\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qdesktopservices\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qdrag\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qeventpoint\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qfilesystemmodel\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qfont\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qfontdatabase\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qfontinfo\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qfontmetrics\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qfontvariableaxis\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qgenericmatrix\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qgenericplugin\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qgenericpluginfactory\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qglyphrun\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qguiapplication\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qicon\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qiconengine\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qiconengineplugin\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qimage\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qimageiohandler\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qimagereader\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qimagewriter\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qinputdevice\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qinputmethod\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qkeysequence\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qmatrix4x4\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qmovie\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qoffscreensurface\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qopenglcontext\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qopenglextrafunctions\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qopenglfunctions\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qpagedpaintdevice\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qpagelayout\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qpageranges\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qpagesize\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qpaintdevice\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qpaintdevicewindow\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qpaintengine\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qpainter\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qpainterpath\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qpainterstateguard\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qpalette\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qpdfoutputintent\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qpdfwriter\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qpen\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qpicture\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qpixelformat\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qpixmap\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qpixmapcache\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qpointingdevice\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qpolygon\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qquaternion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qrasterwindow\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qrawfont\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qregion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qrgb\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qrgba64\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qrgbafloat\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qscreen\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qsessionmanager\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qshortcut\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qstandarditemmodel\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qstatictext\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qstylehints\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qsurface\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qsurfaceformat\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qsyntaxhighlighter\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qtextcursor\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qtextdocument\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qtextdocumentfragment\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qtextdocumentwriter\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qtextformat\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qtextlayout\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qtextlist\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qtextobject\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qtextoption\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qtexttable\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qtguiversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qtransform\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qundogroup\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qundostack\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qvalidator\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qvulkanfunctions\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qvulkaninstance\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qvulkanwindow\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtGui/)?qwindow\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtHelp/)?qcompressedhelpinfo\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtHelp/)?qhelpcontentitem\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtHelp/)?qhelpcontentwidget\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtHelp/)?qhelpengine\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtHelp/)?qhelpenginecore\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtHelp/)?qhelpfilterdata\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtHelp/)?qhelpfilterengine\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtHelp/)?qhelpfiltersettingswidget\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtHelp/)?qhelpindexwidget\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtHelp/)?qhelplink\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtHelp/)?qhelpsearchengine\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtHelp/)?qhelpsearchenginecore\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtHelp/)?qhelpsearchquerywidget\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtHelp/)?qhelpsearchresult\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtHelp/)?qhelpsearchresultwidget\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtHelp/)?qthelpversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtInputSupport/)?qtinputsupportversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtKmsSupport/)?qtkmssupportversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtLabsAnimation/)?qtlabsanimationversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtLabsFolderListModel/)?qtlabsfolderlistmodelversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtLabsPlatform/)?qtlabsplatformversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtLabsQmlModels/)?qtlabsqmlmodelsversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtLabsSettings/)?qtlabssettingsversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtLabsSharedImage/)?qtlabssharedimageversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtLabsStyleKit/)?qtlabsstylekitversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtLabsStyleKitImpl/)?qtlabsstylekitimplversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtLabsSynchronizer/)?qtlabssynchronizerversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtLabsWavefrontMesh/)?qtlabswavefrontmeshversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qabstractvideobuffer\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qaudio\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qaudiobuffer\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qaudiobufferinput\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qaudiobufferoutput\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qaudiodecoder\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qaudiodevice\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qaudioformat\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qaudioinput\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qaudiooutput\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qaudiosink\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qaudiosource\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qcamera\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qcameradevice\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qcapturablewindow\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qimagecapture\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qmediacapturesession\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qmediadevices\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qmediaformat\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qmediametadata\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qmediaplayer\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qmediarecorder\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qmediatimerange\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qplaybackoptions\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qscreencapture\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qsoundeffect\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qtaudio\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qtmultimediaversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qvideoframe\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qvideoframeformat\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qvideoframeinput\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qvideosink\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qwavedecoder\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimedia/)?qwindowcapture\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimediaQuick/)?qtmultimediaquickversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimediaTestLib/)?qtmultimediatestlibversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimediaWidgets/)?qgraphicsvideoitem\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimediaWidgets/)?qtmultimediawidgetsversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtMultimediaWidgets/)?qvideowidget\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qabstractnetworkcache\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qabstractsocket\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qauthenticator\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qdnslookup\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qdtls\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qformdatabuilder\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qhostaddress\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qhostinfo\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qhstspolicy\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qhttp1configuration\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qhttp2configuration\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qhttpheaders\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qhttpmultipart\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qlocalserver\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qlocalsocket\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qnetworkaccessmanager\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qnetworkcookie\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qnetworkcookiejar\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qnetworkdatagram\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qnetworkdiskcache\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qnetworkinformation\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qnetworkinterface\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qnetworkproxy\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qnetworkreply\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qnetworkrequest\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qnetworkrequestfactory\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qocspresponse\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qpassworddigestor\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qrestaccessmanager\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qrestreply\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qssl\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qsslcertificate\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qsslcertificateextension\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qsslcipher\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qsslconfiguration\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qssldiffiehellmanparameters\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qsslellipticcurve\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qsslerror\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qsslkey\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qsslpresharedkeyauthenticator\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qsslserver\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qsslsocket\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qtcpserver\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qtcpsocket\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qtnetworkversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetwork/)?qudpsocket\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetworkAuth/)?qabstractoauth\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetworkAuth/)?qabstractoauth2\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetworkAuth/)?qabstractoauthreplyhandler\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetworkAuth/)?qoauth1\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetworkAuth/)?qoauth1signature\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetworkAuth/)?qoauth2authorizationcodeflow\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetworkAuth/)?qoauth2deviceauthorizationflow\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetworkAuth/)?qoauthhttpserverreplyhandler\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetworkAuth/)?qoauthoobreplyhandler\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetworkAuth/)?qoauthurischemereplyhandler\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtNetworkAuth/)?qtnetworkauthversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglbuffer\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglframebufferobject\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglfunctions_1_0\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglfunctions_1_1\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglfunctions_1_2\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglfunctions_1_3\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglfunctions_1_4\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglfunctions_1_5\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglfunctions_2_0\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglfunctions_2_1\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglfunctions_3_0\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglfunctions_3_1\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglfunctions_3_2_compatibility\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglfunctions_3_2_core\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglfunctions_3_3_compatibility\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglfunctions_3_3_core\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglfunctions_4_0_compatibility\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglfunctions_4_0_core\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglfunctions_4_1_compatibility\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglfunctions_4_1_core\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglfunctions_4_2_compatibility\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglfunctions_4_2_core\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglfunctions_4_3_compatibility\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglfunctions_4_3_core\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglfunctions_4_4_compatibility\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglfunctions_4_4_core\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglfunctions_4_5_compatibility\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglfunctions_4_5_core\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglpaintdevice\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglpixeltransferoptions\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglshaderprogram\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopengltexture\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopengltextureblitter\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopengltimerquery\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglversionfunctions\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglversionfunctionsfactory\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglversionprofile\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglvertexarrayobject\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qopenglwindow\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGL/)?qtopenglversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGLWidgets/)?qopenglwidget\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtOpenGLWidgets/)?qtopenglwidgetsversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtPacketProtocol/)?qtpacketprotocolversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtPdf/)?qpdfbookmarkmodel\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtPdf/)?qpdfdocument\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtPdf/)?qpdfdocumentrenderoptions\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtPdf/)?qpdflink\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtPdf/)?qpdflinkmodel\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtPdf/)?qpdfpagenavigator\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtPdf/)?qpdfpagerenderer\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtPdf/)?qpdfsearchmodel\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtPdf/)?qpdfselection\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtPdf/)?qtpdfversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtPdfQuick/)?qtpdfquickversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtPdfWidgets/)?qpdfpageselector\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtPdfWidgets/)?qpdfview\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtPdfWidgets/)?qtpdfwidgetsversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtPrintSupport/)?qabstractprintdialog\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtPrintSupport/)?qpagesetupdialog\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtPrintSupport/)?qprintdialog\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtPrintSupport/)?qprintengine\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtPrintSupport/)?qprinter\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtPrintSupport/)?qprinterinfo\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtPrintSupport/)?qprintpreviewdialog\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtPrintSupport/)?qprintpreviewwidget\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtPrintSupport/)?qtprintsupportversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQml/)?qjsengine\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQml/)?qjslist\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQml/)?qjsmanagedvalue\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQml/)?qjsnumbercoercion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQml/)?qjsprimitivevalue\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQml/)?qjsvalue\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQml/)?qjsvalueiterator\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQml/)?qqmlabstracturlinterceptor\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQml/)?qqmlapplicationengine\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQml/)?qqmlcomponent\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQml/)?qqmlcontext\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQml/)?qqmlengine\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQml/)?qqmlerror\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQml/)?qqmlexpression\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQml/)?qqmlextensioninterface\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQml/)?qqmlextensionplugin\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQml/)?qqmlfile\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQml/)?qqmlfileselector\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQml/)?qqmlincubator\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQml/)?qqmlinfo\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQml/)?qqmlmoduleregistration\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQml/)?qqmlnetworkaccessmanagerfactory\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQml/)?qqmlparserstatus\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQml/)?qqmlproperty\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQml/)?qqmlpropertymap\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQml/)?qqmlpropertyvaluesource\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQml/)?qqmlscriptstring\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQml/)?qtqmlversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQmlCompiler/)?qtqmlcompilerversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQmlCore/)?qtqmlcoreversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQmlDebug/)?qtqmldebugversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQmlDom/)?qtqmldomversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQmlFormat/)?qtqmlformatversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQmlIntegration/)?qtqmlintegrationversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQmlLocalStorage/)?qtqmllocalstorageversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQmlMeta/)?qtqmlmetaversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQmlModels/)?qtqmlmodelsversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQmlNetwork/)?qtqmlnetworkversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQmlToolingSettings/)?qtqmltoolingsettingsversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQmlTypeRegistrar/)?qtqmltyperegistrarversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQmlWorkerScript/)?qtqmlworkerscriptversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQmlXmlListModel/)?qtqmlxmllistmodelversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQmltc/)?qtqmltcversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick/)?qquickframebufferobject\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick/)?qquickgraphicsconfiguration\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick/)?qquickgraphicsdevice\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick/)?qquickimageprovider\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick/)?qquickitem\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick/)?qquickitemgrabresult\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick/)?qquickopenglutils\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick/)?qquickpainteditem\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick/)?qquickrendercontrol\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick/)?qquickrendertarget\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick/)?qquickrhiitem\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick/)?qquicktextdocument\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick/)?qquickview\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick/)?qquickwindow\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick/)?qsgflatcolormaterial\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick/)?qsggeometry\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick/)?qsgimagenode\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick/)?qsgmaterial\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick/)?qsgmaterialshader\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick/)?qsgninepatchnode\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick/)?qsgnode\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick/)?qsgrectanglenode\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick/)?qsgrendererinterface\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick/)?qsgrendernode\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick/)?qsgsimplerectnode\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick/)?qsgsimpletexturenode\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick/)?qsgtextnode\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick/)?qsgtexture\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick/)?qsgtexturematerial\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick/)?qsgtextureprovider\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick/)?qsgvertexcolormaterial\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick/)?qtquickversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick3D/)?qquick3d\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick3D/)?qquick3dgeometry\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick3D/)?qquick3dinstancing\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick3D/)?qquick3dobject\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick3D/)?qquick3dtexturedata\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick3D/)?qquick3dtextureproviderextension\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick3D/)?qtquick3dversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick3DAssetImport/)?qtquick3dassetimportversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick3DAssetUtils/)?qtquick3dassetutilsversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick3DGlslParser/)?qtquick3dglslparserversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick3DHelpers/)?qtquick3dhelpersversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick3DHelpersImpl/)?qtquick3dhelpersimplversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick3DIblBaker/)?qtquick3diblbakerversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick3DParticles/)?qtquick3dparticlesversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick3DRuntimeRender/)?qtquick3druntimerenderversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick3DSpatialAudio/)?qtquick3dspatialaudioversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuick3DUtils/)?qtquick3dutilsversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickControls2/)?qquickattachedpropertypropagator\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickControls2/)?qquickstyle\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickControls2/)?qtquickcontrols2version\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickControls2Basic/)?qtquickcontrols2basicversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickControls2BasicStyleImpl/)?qtquickcontrols2basicstyleimplversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickControls2FluentWinUI3StyleImpl/)?qtquickcontrols2fluentwinui3styleimplversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickControls2Fusion/)?qtquickcontrols2fusionversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickControls2FusionStyleImpl/)?qtquickcontrols2fusionstyleimplversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickControls2Imagine/)?qtquickcontrols2imagineversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickControls2ImagineStyleImpl/)?qtquickcontrols2imaginestyleimplversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickControls2Impl/)?qtquickcontrols2implversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickControls2Material/)?qtquickcontrols2materialversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickControls2MaterialStyleImpl/)?qtquickcontrols2materialstyleimplversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickControls2Universal/)?qtquickcontrols2universalversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickControls2UniversalStyleImpl/)?qtquickcontrols2universalstyleimplversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickControlsTestUtils/)?qtquickcontrolstestutilsversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickDialogs2/)?qtquickdialogs2version\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickDialogs2QuickImpl/)?qtquickdialogs2quickimplversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickDialogs2Utils/)?qtquickdialogs2utilsversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickEffects/)?qtquickeffectsversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickLayouts/)?qtquicklayoutsversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickParticles/)?qtquickparticlesversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickShapes/)?qtquickshapesversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickShapesDesignHelpers/)?qtquickshapesdesignhelpersversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickTemplates2/)?qtquicktemplates2version\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickTest/)?qtquicktestversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickTestUtils/)?qtquicktestutilsversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickTimeline/)?qtquicktimelineversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickTimelineBlendTrees/)?qtquicktimelineblendtreesversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickVectorImage/)?qtquickvectorimageversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickVectorImageGenerator/)?qtquickvectorimagegeneratorversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickVectorImageHelpers/)?qtquickvectorimagehelpersversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickWidgets/)?qquickwidget\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtQuickWidgets/)?qtquickwidgetsversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtScxml/)?qscxmlcompiler\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtScxml/)?qscxmlcppdatamodel\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtScxml/)?qscxmldatamodel\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtScxml/)?qscxmlerror\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtScxml/)?qscxmlevent\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtScxml/)?qscxmlinvokableservice\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtScxml/)?qscxmlnulldatamodel\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtScxml/)?qscxmlstatemachine\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtScxml/)?qscxmltabledata\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtScxml/)?qtscxmlversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtScxmlGlobal/)?qtscxmlglobalversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtScxmlQml/)?qtscxmlqmlversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtShaderTools/)?qtshadertoolsversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtSpatialAudio/)?qambientsound\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtSpatialAudio/)?qaudioengine\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtSpatialAudio/)?qaudiolistener\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtSpatialAudio/)?qaudioroom\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtSpatialAudio/)?qspatialsound\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtSpatialAudio/)?qtspatialaudioversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtSql/)?qsqldatabase\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtSql/)?qsqldriver\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtSql/)?qsqldriverplugin\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtSql/)?qsqlerror\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtSql/)?qsqlfield\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtSql/)?qsqlindex\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtSql/)?qsqlquery\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtSql/)?qsqlquerymodel\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtSql/)?qsqlrecord\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtSql/)?qsqlrelationaldelegate\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtSql/)?qsqlrelationaltablemodel\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtSql/)?qsqlresult\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtSql/)?qsqltablemodel\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtSql/)?qtsqlversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtStateMachine/)?qabstractstate\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtStateMachine/)?qabstracttransition\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtStateMachine/)?qeventtransition\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtStateMachine/)?qfinalstate\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtStateMachine/)?qhistorystate\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtStateMachine/)?qkeyeventtransition\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtStateMachine/)?qmouseeventtransition\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtStateMachine/)?qsignaltransition\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtStateMachine/)?qstate\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtStateMachine/)?qstatemachine\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtStateMachine/)?qtstatemachineversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtStateMachineQml/)?qtstatemachineqmlversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtSvg/)?qsvggenerator\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtSvg/)?qsvgrenderer\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtSvg/)?qtsvgversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtSvgWidgets/)?qgraphicssvgitem\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtSvgWidgets/)?qsvgwidget\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtSvgWidgets/)?qtsvgwidgetsversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtTest/)?qabstractitemmodeltester\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtTest/)?qsignalspy\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtTest/)?qtest\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtTest/)?qtestdata\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtTest/)?qtestevent\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtTest/)?qtesteventloop\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtTest/)?qttestversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtTools/)?qttoolsversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtUiPlugin/)?qdesignerexportwidget\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtUiPlugin/)?qtuipluginversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtUiTools/)?qtuitoolsversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtUiTools/)?quiloader\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandClient/)?qtwaylandclientversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandClient/)?qwaylandclientextension\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qtwaylandcompositorversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandbufferref\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandclient\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandcompositor\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandcompositorextension\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylanddestroylistener\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylanddrag\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandinputmethodcontrol\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandiviapplication\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandivisurface\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandkeyboard\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandkeymap\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandoutput\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandoutputmode\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandpointer\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandqttextinputmethod\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandqttextinputmethodmanager\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandqtwindowmanager\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandquickcompositor\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandquickextension\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandquickitem\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandquickoutput\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandquickshellintegration\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandquickshellsurfaceitem\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandquicksurface\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandquickxdgoutputv1\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandresource\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandseat\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandshell\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandshellsurface\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandsurface\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandsurfacegrabber\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandtextinput\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandtextinputmanager\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandtextinputmanagerv3\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandtextinputv3\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandtouch\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandview\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandviewporter\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandwlshell\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandxdgoutputv1\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositor/)?qwaylandxdgshell\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositorIviapplication/)?qtwaylandcompositoriviapplicationversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositorPresentationTime/)?qtwaylandcompositorpresentationtimeversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositorWLShell/)?qtwaylandcompositorwlshellversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandCompositorXdgShell/)?qtwaylandcompositorxdgshellversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandEglCompositorHwIntegration/)?qtwaylandeglcompositorhwintegrationversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWaylandGlobal/)?qtwaylandglobalversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebChannel/)?qtwebchannelversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebChannel/)?qwebchannel\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebChannel/)?qwebchannelabstracttransport\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebChannelQuick/)?qqmlwebchannel\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebChannelQuick/)?qtwebchannelquickversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qtwebenginecoreversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebenginecertificateerror\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebengineclientcertificateselection\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebengineclientcertificatestore\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebengineclienthints\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebenginecontextmenurequest\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebenginecookiestore\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebenginedesktopmediarequest\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebenginedownloadrequest\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebengineextensioninfo\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebengineextensionmanager\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebenginefilesystemaccessrequest\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebenginefindtextresult\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebengineframe\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebenginefullscreenrequest\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebengineglobalsettings\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebenginehistory\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebenginehttprequest\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebengineloadinginfo\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebenginenavigationrequest\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebenginenewwindowrequest\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebenginenotification\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebenginepage\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebenginepermission\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebengineprofile\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebengineprofilebuilder\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebenginequotarequest\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebengineregisterprotocolhandlerrequest\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebenginescript\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebenginescriptcollection\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebenginesettings\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebengineurlrequestinfo\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebengineurlrequestinterceptor\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebengineurlrequestjob\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebengineurlscheme\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebengineurlschemehandler\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineCore/)?qwebenginewebauthuxrequest\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineQuick/)?qquickwebenginedownloadrequest\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineQuick/)?qquickwebengineprofile\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineQuick/)?qtwebenginequickversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineWidgets/)?qtwebenginewidgetsversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebEngineWidgets/)?qwebengineview\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebSockets/)?qmaskgenerator\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebSockets/)?qtwebsocketsversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebSockets/)?qwebsocket\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebSockets/)?qwebsocketcorsauthenticator\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebSockets/)?qwebsockethandshakeoptions\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebSockets/)?qwebsocketprotocol\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWebSockets/)?qwebsocketserver\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qabstractbutton\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qabstractitemdelegate\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qabstractitemview\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qabstractscrollarea\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qabstractslider\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qabstractspinbox\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qaccessiblewidget\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qapplication\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qboxlayout\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qbuttongroup\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qcalendarwidget\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qcheckbox\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qcolordialog\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qcolormap\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qcolumnview\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qcombobox\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qcommandlinkbutton\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qcommonstyle\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qcompleter\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qdatawidgetmapper\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qdatetimeedit\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qdial\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qdialog\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qdialogbuttonbox\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qdockwidget\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qerrormessage\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qfiledialog\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qfileiconprovider\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qfocusframe\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qfontcombobox\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qfontdialog\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qformlayout\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qframe\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qgesture\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qgesturerecognizer\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qgraphicsanchorlayout\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qgraphicseffect\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qgraphicsgridlayout\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qgraphicsitem\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qgraphicsitemanimation\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qgraphicslayout\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qgraphicslayoutitem\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qgraphicslinearlayout\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qgraphicsproxywidget\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qgraphicsscene\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qgraphicssceneevent\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qgraphicstransform\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qgraphicsview\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qgraphicswidget\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qgridlayout\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qgroupbox\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qheaderview\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qinputdialog\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qitemdelegate\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qitemeditorfactory\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qkeysequenceedit\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qlabel\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qlayout\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qlayoutitem\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qlcdnumber\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qlineedit\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qlistview\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qlistwidget\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qmainwindow\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qmdiarea\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qmdisubwindow\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qmenu\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qmenubar\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qmessagebox\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qplaintextedit\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qprogressbar\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qprogressdialog\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qproxystyle\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qpushbutton\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qradiobutton\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qrhiwidget\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qrubberband\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qscrollarea\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qscrollbar\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qscroller\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qscrollerproperties\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qsizegrip\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qsizepolicy\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qslider\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qspinbox\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qsplashscreen\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qsplitter\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qstackedlayout\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qstackedwidget\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qstatusbar\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qstyle\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qstyleditemdelegate\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qstylefactory\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qstyleoption\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qstylepainter\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qstyleplugin\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qsystemtrayicon\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qtabbar\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qtableview\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qtablewidget\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qtabwidget\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qtextbrowser\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qtextedit\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qtoolbar\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qtoolbox\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qtoolbutton\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qtooltip\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qtreeview\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qtreewidget\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qtreewidgetitemiterator\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qtwidgetsversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qundoview\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qwhatsthis\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qwidget\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qwidgetaction\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWidgets/)?qwizard\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtWlShellIntegration/)?qtwlshellintegrationversion\\.h[\">]", "private", "", "public"] }, + { "include": [ "@[\"<](QtXml/)?qtxmlversion\\.h[\">]", "private", "", "public"] } +] diff --git a/servatrice/CMakeLists.txt b/servatrice/CMakeLists.txt index aba63800c..b45608d86 100644 --- a/servatrice/CMakeLists.txt +++ b/servatrice/CMakeLists.txt @@ -6,7 +6,9 @@ project(Servatrice VERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${ set(servatrice_SOURCES src/email_parser.cpp + src/event_loop_watchdog.cpp src/main.cpp + src/metrics_registry.cpp src/servatrice.cpp src/servatrice_connection_pool.cpp src/servatrice_database_interface.cpp @@ -25,6 +27,14 @@ set(servatrice_SOURCES set(servatrice_RESOURCES servatrice.qrc) +# Qxt SMTP is vendored. +cockatrice_disable_sanitizers_for( + src/smtp/qxthmac.cpp src/smtp/qxtmailattachment.cpp src/smtp/qxtmailmessage.cpp src/smtp/qxtsmtp.cpp +) +cockatrice_disable_coverage_for( + src/smtp/qxthmac.cpp src/smtp/qxtmailattachment.cpp src/smtp/qxtmailmessage.cpp src/smtp/qxtsmtp.cpp +) + if(WIN32) set(servatrice_SOURCES ${servatrice_SOURCES} servatrice.rc) endif(WIN32) @@ -95,6 +105,8 @@ set(DESKTOPDIR # Build servatrice binary and link it add_executable(servatrice MACOSX_BUNDLE ${servatrice_MOC_SRCS} ${servatrice_RESOURCES_RCC} ${servatrice_SOURCES}) +target_precompile_headers(servatrice PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtcore_pch.h") + if(CMAKE_HOST_SYSTEM MATCHES "FreeBSD") target_link_libraries( servatrice libcockatrice_deck_list libcockatrice_network_server_remote Threads::Threads ${SERVATRICE_QT_MODULES} @@ -180,10 +192,19 @@ if(WIN32) set(qtconf_dest_dir .) install( - DIRECTORY "${CMAKE_BINARY_DIR}/${PROJECT_NAME}/${CMAKE_BUILD_TYPE}/" + DIRECTORY "$/" DESTINATION ./ FILES_MATCHING PATTERN "*.dll" + PATTERN "*.pdb" EXCLUDE + PATTERN "*.dir*" EXCLUDE + PATTERN "*_autogen*" EXCLUDE + PATTERN "*.tlog*" EXCLUDE + PATTERN "CMakeFiles*" EXCLUDE + PATTERN "x64*" EXCLUDE + PATTERN ".qt*" EXCLUDE + PATTERN ".qsb*" EXCLUDE + PATTERN ".lupdate*" EXCLUDE ) # Qt plugins: platforms, sqldrivers, tls (Qt6) diff --git a/servatrice/migrations/servatrice_0035_to_0036.sql b/servatrice/migrations/servatrice_0035_to_0036.sql new file mode 100644 index 000000000..9727eeba5 --- /dev/null +++ b/servatrice/migrations/servatrice_0035_to_0036.sql @@ -0,0 +1,53 @@ +-- Servatrice db migration from version 35 to version 36 + +CREATE TABLE IF NOT EXISTS `cockatrice_reports` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `reporter_id` int(7) unsigned NULL, + `reporter_name` varchar(35) NOT NULL, + `reported_user_id` int(7) unsigned NULL, + `reported_user_name` varchar(35) NOT NULL, + `game_id` int(7) unsigned NULL, + `room_id` int(7) unsigned NULL, + `category` varchar(255) NOT NULL, + `description` text NOT NULL, + `chat_log` mediumtext NULL, + `created_at` datetime NOT NULL, + `resolution_time` datetime NULL, + `status` enum('open','assigned','resolved','dismissed') NOT NULL DEFAULT 'open', + `assigned_to` int(7) unsigned NULL, + `resolved_by` int(7) unsigned NULL, + `resolution_note` text, + `notified` tinyint(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + INDEX `idx_status` (`status`), + INDEX `idx_created` (`created_at`), + INDEX `idx_reporter_id_created` (`reporter_id`, `created_at`), + INDEX `idx_reported_user_name` (`reported_user_name`), + INDEX `idx_status_created` (`status`, `created_at`), + FOREIGN KEY (`reporter_id`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE, + FOREIGN KEY (`reported_user_id`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE, + FOREIGN KEY (`assigned_to`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE, + FOREIGN KEY (`resolved_by`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `cockatrice_report_comments` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `report_id` int(11) unsigned NOT NULL, + `author_name` varchar(35) NOT NULL, + `author_id` int(7) unsigned NULL, + `comment_text` text NOT NULL, + `created_at` datetime NOT NULL, + `is_moderator` tinyint(1) NOT NULL DEFAULT 0, + `notified` tinyint(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + INDEX `idx_report_id` (`report_id`), + INDEX `idx_notified` (`notified`), + FOREIGN KEY (`report_id`) REFERENCES `cockatrice_reports`(`id`) ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY (`author_id`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + +ALTER TABLE `cockatrice_users` + ADD COLUMN `force_password_change` tinyint(1) NOT NULL DEFAULT 0 + AFTER `passwordLastChangedDate`; + +UPDATE cockatrice_schema_version SET version=36 WHERE version=35; diff --git a/servatrice/migrations/servatrice_0036_to_0037.sql b/servatrice/migrations/servatrice_0036_to_0037.sql new file mode 100644 index 000000000..576ab53b4 --- /dev/null +++ b/servatrice/migrations/servatrice_0036_to_0037.sql @@ -0,0 +1,71 @@ +-- Servatrice db migration from version 36 to version 37 + +-- Deck sharing (temporary share links + permanent public decks). +-- +-- This feature was developed behind several intermediate migrations that have +-- never shipped, so they are folded into this single 36 -> 37 migration: +-- temporary share links, permanent public-deck visibility, preview metadata, +-- and per-deck tags. + +-- 1. Temporary deck shares: a named bundle of decks that can be fetched by +-- anyone who knows the (unguessable) token, until the share expires. +CREATE TABLE IF NOT EXISTS `cockatrice_deck_share` ( + `id` int(7) unsigned zerofill NOT NULL auto_increment, + `token` varchar(64) COLLATE utf8mb4_bin NOT NULL, + `name` varchar(64) NOT NULL, + `created_by` int(7) unsigned NULL, + `created_at` datetime NOT NULL, + `expires_at` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `token` (`token`), + KEY `expires_at` (`expires_at`), + FOREIGN KEY(`created_by`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + +-- Individual decks inside a share bundle. Content is materialized at share +-- time so expiring/deleting a share can cascade cleanly. The metadata columns +-- are nullable because Qt's MySQL driver binds empty QStrings as NULL when +-- using prepared statements. +CREATE TABLE IF NOT EXISTS `cockatrice_deck_share_item` ( + `id` int(7) unsigned zerofill NOT NULL auto_increment, + `share_id` int(7) unsigned zerofill NOT NULL, + `name` varchar(50) NOT NULL, + `tags` text NULL, + `banner_card` varchar(255) NULL, + `game_format` varchar(50) NULL, + `color_identity` varchar(5) NULL, + `content` text NOT NULL, + `position` int(7) NOT NULL, + PRIMARY KEY (`id`), + KEY `share_id` (`share_id`), + FOREIGN KEY(`share_id`) REFERENCES `cockatrice_deck_share`(`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + +-- 2. Permanent deck sharing: add public visibility flags to the deck storage +-- tables. A deck is visible to other users if it is marked public, or if any +-- ancestor folder is marked public (inherited). Existing decks default to +-- private, so the upgrade does not expose any data. +ALTER TABLE `cockatrice_decklist_files` + ADD COLUMN `is_public` tinyint(1) NOT NULL DEFAULT 0 AFTER `content`; + +ALTER TABLE `cockatrice_decklist_folders` + ADD COLUMN `is_public` tinyint(1) NOT NULL DEFAULT 0 AFTER `name`; + +-- 3. Per-deck preview metadata so clients can render another user's public +-- decks (e.g. in a visual deck storage grid) without downloading each deck +-- list. The metadata is derived by the server from the deck content (the color +-- identity is supplied by the uploading client); decks uploaded before this +-- migration have empty values until they are re-uploaded. +ALTER TABLE `cockatrice_decklist_files` + ADD COLUMN `banner_card_name` varchar(255) NULL AFTER `is_public`, + ADD COLUMN `banner_card_provider` varchar(32) NULL AFTER `banner_card_name`, + ADD COLUMN `color_identity` varchar(5) NULL AFTER `banner_card_provider`; + +-- 4. Per-deck tags for public decks. The server renders the deck's own tags +-- into a JSON array, so another user's public decks can filter by tag without +-- downloading each deck list. Decks uploaded before this migration have NULL +-- tags until they are re-uploaded. +ALTER TABLE `cockatrice_decklist_files` + ADD COLUMN `tags` text NULL AFTER `color_identity`; + +UPDATE cockatrice_schema_version SET version=37 WHERE version=36; diff --git a/servatrice/servatrice.ini.example b/servatrice/servatrice.ini.example index fac743c39..c625705da 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -41,6 +41,12 @@ websocket_host=any ; The TCP port number servatrice will listen on for websockets clients; default is 4748 websocket_port=4748 +; If websockets are served through a reverse proxy (e.g. nginx), the real client address can be +; read from an HTTP header such as X-Forwarded-For. The header is ONLY trusted when the websocket +; connection itself originates from an address listed in security/trusted_sources (default 127.0.0.1,::1), +; so that clients cannot spoof their address to bypass bans or rate limits. +web_socket_ip_header= + ; When database is enabled, servatrice writes the server status in the "update" database table; this ; setting defines every how many milliseconds servatrice will update its status; default is 15000 (15 secs) statusupdate=15000 @@ -79,8 +85,10 @@ requiredfeatures="" ; You can define custom warnings that users are sent when the moderation staff uses the right client warn user ; menu option. This list is comma seperated that each item will appear in the drop down list for staff members -; to choose from. Example: "Flaming,Foul Language" -officialwarnings="Flaming,Spamming,Causing Drama,Abusive Language" +; to choose from. Each entry may optionally carry a recommended starting intervention level (see the moderator +; guide) by appending "|" and the level number. Entries without an explicit level default to intervention +; level 1. Example: "Flaming,Foul Language" +officialwarnings="Abusive Language|1,Calling Out User|1,Causing Drama|1,Cheating|2,Disrespecting Staff|1,Disrupting a Draft|1,Inappropriate Avatar|3,Inappropriate Game Name|1,Kicking Without Valid Reason|1,Spamming|1,Targeted Harassment|2" ; Maximum time in seconds a player can stay connected but idle. Default is 3600 (0 = disabled) ; Clients will be notified at the 90% time period of pending disconnection if they do not take action. @@ -373,6 +381,26 @@ command_counting_interval=10 ; Maximum number of game commands in an interval before new commands gets dropped; default is 20 max_command_count_per_interval=20 +[reporting] +; Maximum number of user reports a single user can file per day; default is 10; set to 0 to disable the limit +max_reports_per_day=10 + +; Maximum number of report comments a single user can post per hour; default is 30; set to 0 to disable the limit +max_comments_per_hour=30 + +[metrics] +; Command containers that take longer than this many milliseconds are logged +; as slow commands. Set to 0 to disable the log line. A latency spike produces +; one warning per slow container with no rate limiting of its own -- a bad +; patch can briefly flood the log, which is how you notice it. +slow_command_ms=500 + +; Each socket pool thread runs a watchdog heartbeat. If a heartbeat arrives +; this many milliseconds late, the stall is logged and exposed as +; servatrice_eventloop_* metrics in the Developer tab. Set to 0 to disable +; the watchdogs. +stall_warn_ms=2000 + [logging] ; Admin/Moderators can query the stored logs for information when looking up reports by various players. This ; option can allow or disallow them from doing so. @@ -430,3 +458,24 @@ ssl_cert=ssl_cert.pem ; Filename of the private key for the server-to-server certificate ssl_key=ssl_key.pem + + +[deck_share] + +; How many days a created deck share link remains valid before it expires. +; Default: 7 +expiry_days=7 + +; How often (in minutes) the server checks for and removes expired deck shares. +; A value of 0 disables the automatic cleanup. +; Default: 60 +cleanup_interval=60 + +; Maximum number of decks a single share link can contain. +; Default: 50 +max_decks_per_share=50 + +; Maximum number of share links a single user may create per day. +; A value of 0 disables the limit. +; Default: 50 +max_shares_per_day=50 diff --git a/servatrice/servatrice.sql b/servatrice/servatrice.sql index 7f530063c..4fbc1d8bd 100644 --- a/servatrice/servatrice.sql +++ b/servatrice/servatrice.sql @@ -20,11 +20,14 @@ CREATE TABLE IF NOT EXISTS `cockatrice_schema_version` ( PRIMARY KEY (`version`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; -INSERT INTO cockatrice_schema_version VALUES(35); +INSERT INTO cockatrice_schema_version VALUES(37); -- users and user data tables CREATE TABLE IF NOT EXISTS `cockatrice_users` ( `id` int(7) unsigned zerofill NOT NULL auto_increment, + -- Bitfield of staff levels: 1 = admin (implies moderator), 2 = moderator, + -- 4 = judge, 8 = developer. Operators set these by hand with + -- "UPDATE cockatrice_users SET admin = ...". `admin` tinyint(1) NOT NULL, `name` varchar(35) NOT NULL, `realname` varchar(255) NOT NULL, @@ -41,6 +44,7 @@ CREATE TABLE IF NOT EXISTS `cockatrice_users` ( `privlevelStartDate` datetime NOT NULL, `privlevelEndDate` datetime NOT NULL, `passwordLastChangedDate` datetime NOT NULL DEFAULT '0000-00-00 00:00:00', + `force_password_change` tinyint(1) NOT NULL DEFAULT 0, `leftPawnColorOverride` varchar(255), `rightPawnColorOverride` varchar(255), `card_art_params` TEXT DEFAULT NULL, @@ -62,16 +66,56 @@ CREATE TABLE IF NOT EXISTS `cockatrice_decklist_files` ( `name` varchar(50) NOT NULL, `upload_time` datetime NOT NULL, `content` text NOT NULL, + `is_public` tinyint(1) NOT NULL DEFAULT 0, + `banner_card_name` varchar(255) NULL, + `banner_card_provider` varchar(32) NULL, + `color_identity` varchar(5) NULL, + `tags` text NULL, PRIMARY KEY (`id`), KEY `FolderPlusUser` (`id_folder`,`id_user`), FOREIGN KEY(`id_user`) REFERENCES `cockatrice_users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; +-- Temporary deck shares: a named bundle of decks that can be fetched by +-- anyone who knows the (unguessable) token, until the share expires. +CREATE TABLE IF NOT EXISTS `cockatrice_deck_share` ( + `id` int(7) unsigned zerofill NOT NULL auto_increment, + `token` varchar(64) COLLATE utf8mb4_bin NOT NULL, + `name` varchar(64) NOT NULL, + `created_by` int(7) unsigned NULL, + `created_at` datetime NOT NULL, + `expires_at` datetime NOT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `token` (`token`), + KEY `expires_at` (`expires_at`), + FOREIGN KEY(`created_by`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + +-- Individual decks inside a share bundle. Content is materialized at share +-- time so expiring/deleting a share can cascade cleanly. The metadata columns +-- are nullable because Qt's MySQL driver binds empty QStrings as NULL when +-- using prepared statements. +CREATE TABLE IF NOT EXISTS `cockatrice_deck_share_item` ( + `id` int(7) unsigned zerofill NOT NULL auto_increment, + `share_id` int(7) unsigned zerofill NOT NULL, + `name` varchar(50) NOT NULL, + `tags` text NULL, + `banner_card` varchar(255) NULL, + `game_format` varchar(50) NULL, + `color_identity` varchar(5) NULL, + `content` text NOT NULL, + `position` int(7) NOT NULL, + PRIMARY KEY (`id`), + KEY `share_id` (`share_id`), + FOREIGN KEY(`share_id`) REFERENCES `cockatrice_deck_share`(`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `cockatrice_decklist_folders` ( `id` int(7) unsigned zerofill NOT NULL auto_increment, `id_parent` int(7) unsigned zerofill NOT NULL, `id_user` int(7) unsigned NULL, `name` varchar(30) NOT NULL, + `is_public` tinyint(1) NOT NULL DEFAULT 0, PRIMARY KEY (`id`), KEY `ParentPlusUser` (`id_parent`,`id_user`), FOREIGN KEY(`id_user`) REFERENCES `cockatrice_users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE @@ -233,6 +277,52 @@ CREATE TABLE IF NOT EXISTS `cockatrice_warnings` ( INDEX `idx_user_name` (`user_name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `cockatrice_reports` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `reporter_id` int(7) unsigned NULL, + `reporter_name` varchar(35) NOT NULL, + `reported_user_id` int(7) unsigned NULL, + `reported_user_name` varchar(35) NOT NULL, + `game_id` int(7) unsigned NULL, + `room_id` int(7) unsigned NULL, + `category` varchar(255) NOT NULL, + `description` text NOT NULL, + `chat_log` mediumtext NULL, + `created_at` datetime NOT NULL, + `resolution_time` datetime NULL, + `status` enum('open','assigned','resolved','dismissed') NOT NULL DEFAULT 'open', + `assigned_to` int(7) unsigned NULL, + `resolved_by` int(7) unsigned NULL, + `resolution_note` text, + `notified` tinyint(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + INDEX `idx_status` (`status`), + INDEX `idx_created` (`created_at`), + INDEX `idx_reporter_id_created` (`reporter_id`, `created_at`), + INDEX `idx_reported_user_name` (`reported_user_name`), + INDEX `idx_status_created` (`status`, `created_at`), + FOREIGN KEY (`reporter_id`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE, + FOREIGN KEY (`reported_user_id`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE, + FOREIGN KEY (`assigned_to`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE, + FOREIGN KEY (`resolved_by`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `cockatrice_report_comments` ( + `id` int(11) unsigned NOT NULL AUTO_INCREMENT, + `report_id` int(11) unsigned NOT NULL, + `author_name` varchar(35) NOT NULL, + `author_id` int(7) unsigned NULL, + `comment_text` text NOT NULL, + `created_at` datetime NOT NULL, + `is_moderator` tinyint(1) NOT NULL DEFAULT 0, + `notified` tinyint(1) NOT NULL DEFAULT 0, + PRIMARY KEY (`id`), + INDEX `idx_report_id` (`report_id`), + INDEX `idx_notified` (`notified`), + FOREIGN KEY (`report_id`) REFERENCES `cockatrice_reports`(`id`) ON DELETE CASCADE ON UPDATE CASCADE, + FOREIGN KEY (`author_id`) REFERENCES `cockatrice_users`(`id`) ON DELETE SET NULL ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `cockatrice_log` ( `log_time` datetime NOT NULL, `sender_id` int(7) unsigned NULL, diff --git a/servatrice/src/deck_tag_serialization.h b/servatrice/src/deck_tag_serialization.h new file mode 100644 index 000000000..162b0604d --- /dev/null +++ b/servatrice/src/deck_tag_serialization.h @@ -0,0 +1,39 @@ +#ifndef DECK_TAG_SERIALIZATION_H +#define DECK_TAG_SERIALIZATION_H + +#include +#include +#include +#include +#include + +/** + * @brief Encodes deck tags as a compact JSON array for storage in a text column. + * + * Deck tags are stored as JSON (rather than a delimited string) so tag names may + * contain any character, and decoded uniformly everywhere they are read. + */ +inline QString serializeDeckTags(const QStringList &tags) +{ + QJsonArray array; + for (const QString &tag : tags) { + array.append(tag); + } + return QString::fromUtf8(QJsonDocument(array).toJson(QJsonDocument::Compact)); +} + +/** @brief Decodes deck tags previously written by serializeDeckTags. */ +inline QStringList deserializeDeckTags(const QString &serialized) +{ + QStringList tags; + if (serialized.isEmpty()) { + return tags; + } + const QJsonArray array = QJsonDocument::fromJson(serialized.toUtf8()).array(); + for (const QJsonValue &tag : array) { + tags.append(tag.toString()); + } + return tags; +} + +#endif // DECK_TAG_SERIALIZATION_H diff --git a/servatrice/src/email_parser.cpp b/servatrice/src/email_parser.cpp index d8c30d852..44c788e67 100644 --- a/servatrice/src/email_parser.cpp +++ b/servatrice/src/email_parser.cpp @@ -1,6 +1,8 @@ #include "email_parser.h" #include +#include +#include #include QPair EmailParser::parseEmailAddress(const QString &dirtyEmailAddress) diff --git a/servatrice/src/event_loop_watchdog.cpp b/servatrice/src/event_loop_watchdog.cpp new file mode 100644 index 000000000..a2f20ff1c --- /dev/null +++ b/servatrice/src/event_loop_watchdog.cpp @@ -0,0 +1,36 @@ +/** + * @file event_loop_watchdog.cpp + * @ingroup Servatrice + */ + +#include "event_loop_watchdog.h" + +#include "servatrice.h" + +#include +#include +#include + +EventLoopWatchdog::EventLoopWatchdog(Servatrice *_servatrice, QString _threadName) + : QObject(nullptr), servatrice(_servatrice), threadName(std::move(_threadName)) +{ +} + +void EventLoopWatchdog::start() +{ + heartbeatTimer = new QTimer(this); + sinceLastTick.start(); + connect(heartbeatTimer, &QTimer::timeout, this, &EventLoopWatchdog::checkHeartbeat); + heartbeatTimer->start(HeartbeatIntervalMs); +} + +void EventLoopWatchdog::checkHeartbeat() +{ + const qint64 elapsedMs = sinceLastTick.restart(); + const qint64 overshootMs = qMax(0, elapsedMs - HeartbeatIntervalMs); + if (overshootMs < servatrice->getMetricsStallWarnMs()) { + return; + } + + servatrice->observeEventLoopStall(threadName, overshootMs); +} diff --git a/servatrice/src/event_loop_watchdog.h b/servatrice/src/event_loop_watchdog.h new file mode 100644 index 000000000..d05430eaa --- /dev/null +++ b/servatrice/src/event_loop_watchdog.h @@ -0,0 +1,51 @@ +/** + * @file event_loop_watchdog.h + * @ingroup Servatrice + */ + +#ifndef EVENT_LOOP_WATCHDOG_H +#define EVENT_LOOP_WATCHDOG_H + +#include +#include +#include +#include + +class Servatrice; +class QTimer; + +/** + * @brief Detects blocked or overloaded worker event loops. + * + * One instance lives in each socket pool thread. A heartbeat timer tick that + * arrives late means the loop spent that time elsewhere: busy work, a queued + * slot, or a hard wedge. Overshoots past the configured threshold bump + * lock-free counters on the metrics registry and log one warning per stall, + * so a stuck pool thread becomes visible instead of silent lag. + */ +class EventLoopWatchdog : public QObject +{ + Q_OBJECT +public: + /// How often the heartbeat expects to fire. Small enough to catch short stalls. + static constexpr int HeartbeatIntervalMs = 500; + + EventLoopWatchdog(Servatrice *_servatrice, QString _threadName); + + /** + * Starts the heartbeat timer. Must be invoked queued after the instance + * was moved to its target thread so the timer lives there too. + */ + void start(); + +private slots: + void checkHeartbeat(); + +private: + Servatrice *servatrice; + QString threadName; + QElapsedTimer sinceLastTick; + QTimer *heartbeatTimer = nullptr; +}; + +#endif diff --git a/servatrice/src/isl_interface.cpp b/servatrice/src/isl_interface.cpp index b0ee201bf..a78ee9e72 100644 --- a/servatrice/src/isl_interface.cpp +++ b/servatrice/src/isl_interface.cpp @@ -1,11 +1,34 @@ #include "isl_interface.h" +#include "libcockatrice/protocol/pb/commands.pb.h" +#include "libcockatrice/protocol/pb/room_commands.pb.h" +#include "libcockatrice/protocol/pb/room_event.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_game.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_room.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_user.pb.h" +#include "libcockatrice/protocol/pb/session_event.pb.h" #include "main.h" +#include "servatrice.h" +#include "server_abstractuserinterface.h" #include "server_logger.h" +#include "serverinfo_user_container.h" +#include +#include +#include #include +#include +#include +#include +#include +#include +#include +#include +#include #include #include +#include +#include #include #include #include @@ -17,11 +40,15 @@ #include #include #include -#include #include +#include +#include #include #include +// Maximum size of a single ISL message. +static const int MAX_ISL_MESSAGE_SIZE = 4 * 1024 * 1024; + inline Q_LOGGING_CATEGORY(IslInterfaceLog, "isl_interface"); void IslInterface::sharedCtor(const QSslCertificate &cert, const QSslKey &privateKey) @@ -243,6 +270,13 @@ void IslInterface::readClient() (((quint32)(unsigned char)inputBuffer[1]) << 16) + (((quint32)(unsigned char)inputBuffer[2]) << 8) + ((quint32)(unsigned char)inputBuffer[3]); + // Reject implausible lengths instead of buffering until they arrive. + // This also handles lengths that overflow into negative int values. + if (messageLength < 0 || messageLength > MAX_ISL_MESSAGE_SIZE) { + qCWarning(IslInterfaceLog) << "Invalid message length" << messageLength << "from" << peerAddress; + socket->disconnectFromHost(); + return; + } inputBuffer.remove(0, 4); messageInProgress = true; } else { diff --git a/servatrice/src/isl_interface.h b/servatrice/src/isl_interface.h index 27e5b8293..f7680c0a2 100644 --- a/servatrice/src/isl_interface.h +++ b/servatrice/src/isl_interface.h @@ -1,21 +1,26 @@ #ifndef ISL_INTERFACE_H #define ISL_INTERFACE_H -#include "servatrice.h" - +#include +#include +#include +#include #include -#include +#include + +// IWYU pragma: keep +// ServerInfo_User and ServerInfo_Game are passed by value to signals, so the +// moc-generated code needs the complete types. +#include #include -#include #include +#include class Servatrice; class QSslSocket; class QSslKey; class IslMessage; - class Event_ServerCompleteList; -class Event_UserMessage; class Event_UserJoined; class Event_UserLeft; class Event_JoinRoom; @@ -24,6 +29,11 @@ class Event_RoomSay; class Event_ListGames; class Event_RemoveMessages; class Command_JoinGame; +class CommandContainer; +class GameEventContainer; +class Response; +class RoomEvent; +class SessionEvent; class IslInterface : public QObject { diff --git a/servatrice/src/main.cpp b/servatrice/src/main.cpp index 9e7fe38d9..eb019a2b7 100644 --- a/servatrice/src/main.cpp +++ b/servatrice/src/main.cpp @@ -18,6 +18,7 @@ * 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. * ***************************************************************************/ +#include "libcockatrice/rng/rng_abstract.h" #include "servatrice.h" #include "server_logger.h" #include "settingscache.h" @@ -25,15 +26,29 @@ #include "smtpclient.h" #include "version_string.h" +#include #include #include #include +#include #include +#include +#include #include -#include +#include +#include +#include +#include +#include +#include +#include #include #include +#include #include +#include +#include +#include RNG_Abstract *rng; ServerLogger *logger; @@ -169,7 +184,7 @@ int main(int argc, char *argv[]) signalhandler = new SignalHandler(); - rng = new RNG_SFMT; + rng = new RNG_SFMT(CryptoUtil::randomUInt64()); std::cerr << "Servatrice " << VERSION_STRING << " starting." << std::endl; std::cerr << "-------------------------" << std::endl; diff --git a/servatrice/src/metrics_registry.cpp b/servatrice/src/metrics_registry.cpp new file mode 100644 index 000000000..51abb7e3e --- /dev/null +++ b/servatrice/src/metrics_registry.cpp @@ -0,0 +1,71 @@ +#include "metrics_registry.h" + +#include +#include + +void MetricsRegistry::observeCommand(int typeId, qint64 elapsedMs) +{ + if (typeId < 0 || typeId >= MaxTypes) { + typeId = MaxTypes - 1; // overflow slot keeps misrouted ids visible + } + if (elapsedMs < 0) { + elapsedMs = 0; + } + + TypeStats &stats = slotFor(typeId); + stats.count.fetch_add(1, std::memory_order_relaxed); + stats.totalMs.fetch_add(elapsedMs, std::memory_order_relaxed); + totalCommandsCounter.fetch_add(1, std::memory_order_relaxed); + totalTimeCounter.fetch_add(elapsedMs, std::memory_order_relaxed); +} + +void MetricsRegistry::observeGameStartDurationMs(qint64 elapsedMs) +{ + if (elapsedMs < 0) { + elapsedMs = 0; + } + + gameStartStats.count.fetch_add(1, std::memory_order_relaxed); + gameStartStats.totalMs.fetch_add(elapsedMs, std::memory_order_relaxed); +} + +MetricsRegistry::TypeStats &MetricsRegistry::slotFor(int typeId) +{ + return typeSlots[static_cast(typeId)]; +} + +const MetricsRegistry::TypeStats &MetricsRegistry::slotFor(int typeId) const +{ + return typeSlots[static_cast(typeId)]; +} + +int MetricsRegistry::activeTypeCount() const +{ + int active = 0; + for (int type = 0; type < MaxTypes; ++type) { + if (slotFor(type).count.load(std::memory_order_relaxed) > 0) { + ++active; + } + } + return active; +} + +QList MetricsRegistry::collectActiveStats() const +{ + QList result; + for (int type = 0; type < MaxTypes; ++type) { + const TypeStats &stats = slotFor(type); + const qint64 count = stats.count.load(std::memory_order_relaxed); + if (count == 0) { + continue; + } + result.append({type, count, stats.totalMs.load(std::memory_order_relaxed)}); + } + return result; +} + +MetricsRegistry::GameStartSnapshot MetricsRegistry::getGameStartSnapshot() const +{ + return {gameStartStats.count.load(std::memory_order_relaxed), + gameStartStats.totalMs.load(std::memory_order_relaxed)}; +} \ No newline at end of file diff --git a/servatrice/src/metrics_registry.h b/servatrice/src/metrics_registry.h new file mode 100644 index 000000000..1175d03c4 --- /dev/null +++ b/servatrice/src/metrics_registry.h @@ -0,0 +1,120 @@ +/** + * @file metrics_registry.h + * @ingroup Servatrice + */ + +#ifndef METRICS_REGISTRY_H +#define METRICS_REGISTRY_H + +#include +#include +#include +#include + +/** + * @brief Lock-free accumulation of command processing statistics. + * + * observeCommand() is called once per processed command from whichever socket + * thread handled it. It uses relaxed atomic adds on preallocated storage only, + * so it introduces no locks, allocations, or shared cache-line ping-pong + * beyond the unavoidable counter updates. + * + * Reading happens rarely (metrics scraping), accepts momentary tears between + * related counters, and therefore also needs no synchronization. + * + * Only counts and totals are retained. An earlier Prometheus-style cumulative + * histogram (per-type, time-bucketed) was cut because nothing in the server + * ever wrote it out; it belongs to the future /metrics exporter that needs it. + */ +class MetricsRegistry +{ +public: + /** + * Extension numbers are only unique per command kind, so recorded ids + * combine the kind index with the protobuf extension number. + * + * The stride is only as wide as it needs to be: 1280 is the first round + * number above the largest extension actually in use (ModeratorCommand = + * 1206) and keeps the preallocated TypeStats array small. Bump it if a new + * command exceeds it. + */ + static constexpr int KindStride = 1280; + + static constexpr int NumKinds = 6; + + static constexpr const char *KindNames[NumKinds] = {"session", "room", "game", "moderator", "admin", "developer"}; + + /// Upper bound on distinct command type ids (see typeIdFor). + static constexpr int MaxTypes = NumKinds * KindStride; + + /// Guard against typeIdFor() overflowing into the neighbouring kind's slots. + static_assert(KindStride > 1206, "KindStride must exceed the highest command extension number in use"); + + static int typeIdFor(int kindIndex, int extensionNumber) + { + return kindIndex * KindStride + extensionNumber; + } + + void observeCommand(int typeId, qint64 elapsedMs); + + /** + * Records how long one game start took to bring every player's zones + * online. Kept separate from command timings because it is triggered by + * the server itself and can dwarf any single command when decks are huge. + */ + void observeGameStartDurationMs(qint64 elapsedMs); + + /// Total number of observed commands across all types. + qint64 totalCommands() const + { + return totalCommandsCounter.load(std::memory_order_relaxed); + } + + /// Cumulative processing milliseconds across all types. + qint64 totalTimeMs() const + { + return totalTimeCounter.load(std::memory_order_relaxed); + } + + /// Number of distinct type slots that have seen at least one sample. + int activeTypeCount() const; + + struct ActiveTypeStats + { + int typeId; + qint64 count; + qint64 totalMs; + }; + + /** + * Returns stats for every type slot that has seen at least one sample. + * Callers resolve the numeric type id to a human-readable label via + * typeIdFor()/KindNames as needed. + */ + QList collectActiveStats() const; + + struct GameStartSnapshot + { + qint64 count; + qint64 totalMs; + }; + + GameStartSnapshot getGameStartSnapshot() const; + +private: + struct TypeStats + { + std::atomic count{0}; + std::atomic totalMs{0}; + }; + + TypeStats &slotFor(int typeId); + const TypeStats &slotFor(int typeId) const; + + std::array typeSlots{}; + TypeStats gameStartStats{}; + std::atomic totalCommandsCounter{0}; + std::atomic totalTimeCounter{0}; +}; + +#endif \ No newline at end of file diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp index aa50e068a..0329a31fd 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -20,11 +20,16 @@ #include "servatrice.h" #include "email_parser.h" +#include "event_loop_watchdog.h" #include "isl_interface.h" +#include "libcockatrice/protocol/pb/response_report_stats.pb.h" +#include "libcockatrice/protocol/pb/session_event.pb.h" #include "main.h" #include "servatrice_connection_pool.h" #include "servatrice_database_interface.h" +#include "server.h" #include "server_logger.h" +#include "server_protocolhandler.h" #include "serversocketinterface.h" #include "settingscache.h" #include "smtpclient.h" @@ -32,19 +37,37 @@ #include #include #include +#include +#include +#include +#include +#include #include +#include +#include +#include #include +#include #include #include +#include #include +#include #include -#include -#include +#include +#include +#include +#include +#include #include #include #include #include +#include +#include +#include #include +#include Servatrice_GameServer::Servatrice_GameServer(Servatrice *_server, int _numberPools, @@ -63,6 +86,7 @@ Servatrice_GameServer::Servatrice_GameServer(Servatrice *_server, server->addDatabaseInterface(newThread, newDatabaseInterface); newThread->start(); + server->watchWorkerThread(newThread); QMetaObject::invokeMethod(newDatabaseInterface, "initDatabase", Qt::BlockingQueuedConnection, Q_ARG(QSqlDatabase, _sqlDatabase)); @@ -86,7 +110,6 @@ void Servatrice_GameServer::incomingConnection(qintptr socketDescriptor) Servatrice_ConnectionPool *pool = findLeastUsedConnectionPool(); auto ssi = new TcpServerSocketInterface(server, pool->getDatabaseInterface()); - connect(ssi, SIGNAL(incTxBytes(qint64)), this, SLOT(incTxBytes(qint64))); ssi->moveToThread(pool->thread()); pool->addClient(); connect(ssi, SIGNAL(destroyed()), pool, SLOT(removeClient())); @@ -131,6 +154,7 @@ Servatrice_WebsocketGameServer::Servatrice_WebsocketGameServer(Servatrice *_serv server->addDatabaseInterface(newThread, newDatabaseInterface); newThread->start(); + server->watchWorkerThread(newThread); QMetaObject::invokeMethod(newDatabaseInterface, "initDatabase", Qt::BlockingQueuedConnection, Q_ARG(QSqlDatabase, _sqlDatabase)); @@ -156,7 +180,6 @@ void Servatrice_WebsocketGameServer::onNewConnection() Servatrice_ConnectionPool *pool = findLeastUsedConnectionPool(); auto ssi = new WebsocketServerSocketInterface(server, pool->getDatabaseInterface()); - connect(ssi, SIGNAL(incTxBytes(quint64)), this, SLOT(incTxBytes(quint64))); /* * Due to a Qt limitation, websockets can't be moved to another thread. * This will hopefully change in Qt6 if QtWebSocket will be integrated in QtNetwork @@ -226,6 +249,13 @@ bool Servatrice::initServer() { serverId = getServerID(); + + // METRICS (always active. Slow-command logging and stall watchdogs are + // controlled by their respective thresholds below). Read up front so the + // values are available before any pool thread is started and watchdogged. + metricsSlowCommandMs = settingsCache->value("metrics/slow_command_ms", 500).toInt(); + metricsStallWarnMs = qMax(0, settingsCache->value("metrics/stall_warn_ms", 2000).toInt()); + if (getAuthenticationMethodString() == "sql") { qDebug() << "Authenticating method: sql"; authenticationMethod = AuthenticationSql; @@ -428,6 +458,14 @@ bool Servatrice::initServer() statusUpdateClock->start(getServerStatusUpdateTime()); } + deckShareCleanupClock = new QTimer(this); + connect(deckShareCleanupClock, SIGNAL(timeout()), this, SLOT(cleanupExpiredDeckShares())); + const int deckShareCleanupInterval = getDeckShareCleanupInterval(); + if (deckShareCleanupInterval > 0) { + qDebug() << "Starting deck share cleanup clock, interval" << deckShareCleanupInterval << "ms"; + deckShareCleanupClock->start(deckShareCleanupInterval); + } + // SOCKET SERVER if (getNumberOfTCPPools() > 0) { gameServer = @@ -470,9 +508,55 @@ bool Servatrice::initServer() } setRequiredFeatures(getRequiredFeatures()); + return true; } +void Servatrice::observeGameStartDurationMs(qint64 elapsedMs) +{ + metricsRegistry.observeGameStartDurationMs(elapsedMs); +} + +void Servatrice::observeEventLoopStall(const QString &threadName, qint64 overshootMs) +{ + eventLoopStallsTotal.fetch_add(1, std::memory_order_relaxed); + eventLoopLastStallMs.store(overshootMs, std::memory_order_relaxed); + qint64 prevMax = eventLoopMaxStallMs.load(std::memory_order_relaxed); + while (overshootMs > prevMax && + !eventLoopMaxStallMs.compare_exchange_weak(prevMax, overshootMs, std::memory_order_relaxed)) { + // retry until the max is at least as high as the new sample + } + + qWarning() << "Event loop stall in" << threadName << "- heartbeat overshot by" << overshootMs << "ms"; +} + +void Servatrice::watchWorkerThread(QThread *thread) +{ + if (metricsStallWarnMs <= 0) { + return; // watchdogs disabled via metrics/stall_warn_ms = 0 + } + + auto *watchdog = new EventLoopWatchdog(this, thread->objectName()); + connect(thread, &QThread::finished, watchdog, &QObject::deleteLater); + watchdog->moveToThread(thread); + QMetaObject::invokeMethod(watchdog, &EventLoopWatchdog::start, Qt::QueuedConnection); +} + +qint64 Servatrice::getCardsInGamesTotal() const +{ + qint64 total = 0; + QReadLocker roomsLocker(&roomsLock); // locking order: roomsLock before gamesLock/gameMutex + QMapIterator roomIterator(rooms); + while (roomIterator.hasNext()) { + Server_Room *room = roomIterator.next().value(); + QReadLocker gamesLocker(&room->gamesLock); + for (auto *game : room->getGames()) { + total += game->getCardsInGame(); + } + } + return total; +} + void Servatrice::addDatabaseInterface(QThread *thread, Servatrice_DatabaseInterface *databaseInterface) { databaseInterfaces.insert(thread, databaseInterface); @@ -514,6 +598,23 @@ QList Servatrice::getServerList() const return result; } +std::shared_ptr Servatrice::getCachedReportStats() const +{ + QMutexLocker locker(&reportStatsMutex); + if (!reportStatsTimestamp.isValid() || + reportStatsTimestamp.secsTo(QDateTime::currentDateTime()) >= reportStatsCacheTtlSeconds) { + return nullptr; + } + return reportStatsCache; +} + +void Servatrice::cacheReportStats(const Response_ReportStats &stats) +{ + QMutexLocker locker(&reportStatsMutex); + reportStatsCache = std::make_shared(stats); + reportStatsTimestamp = QDateTime::currentDateTime(); +} + int Servatrice::getUsersWithAddress(const QHostAddress &address) const { int result = 0; @@ -583,6 +684,11 @@ void Servatrice::setRequiredFeatures(const QString &featureList) qDebug() << "Set required client features to:" << serverRequiredFeatureList; } +void Servatrice::cleanupExpiredDeckShares() +{ + servatriceDatabaseInterface->cleanupExpiredDeckShares(); +} + void Servatrice::statusUpdate() { if (!servatriceDatabaseInterface->checkSql()) { @@ -673,8 +779,27 @@ void Servatrice::statusUpdate() } } +SessionEvent *Servatrice::makeShutdownEvent() const +{ + Event_ServerShutdown event; + event.set_reason(shutdownReason.toStdString()); + event.set_minutes(static_cast(shutdownMinutes)); + return Server_ProtocolHandler::prepareSessionEvent(event); +} + +SessionEvent *Servatrice::getLoginSessionEvent() const +{ + // Notify newly logged-in users of a pending server shutdown + QMutexLocker locker(&shutdownStateMutex); + if (shutdownTimer && shutdownMinutes > 0) { + return makeShutdownEvent(); + } + return nullptr; +} + void Servatrice::scheduleShutdown(const QString &reason, int minutes) { + shutdownStateMutex.lock(); shutdownReason = reason; shutdownMinutes = minutes; nextShutdownMessageMinutes = shutdownMinutes; @@ -683,6 +808,7 @@ void Servatrice::scheduleShutdown(const QString &reason, int minutes) connect(shutdownTimer, SIGNAL(timeout()), this, SLOT(shutdownTimeout())); shutdownTimer->start(60000); } + shutdownStateMutex.unlock(); shutdownTimeout(); } @@ -702,6 +828,7 @@ void Servatrice::incRxBytes(quint64 num) void Servatrice::shutdownTimeout() { + QMutexLocker locker(&shutdownStateMutex); // Show every time counter cut in half & every minute for last 5 minutes if (shutdownMinutes <= 5 || shutdownMinutes == nextShutdownMessageMinutes) { if (shutdownMinutes == nextShutdownMessageMinutes) { @@ -710,10 +837,7 @@ void Servatrice::shutdownTimeout() SessionEvent *se; if (shutdownMinutes) { - Event_ServerShutdown event; - event.set_reason(shutdownReason.toStdString()); - event.set_minutes(static_cast(shutdownMinutes)); - se = Server_ProtocolHandler::prepareSessionEvent(event); + se = makeShutdownEvent(); } else { Event_ConnectionClosed event; event.set_reason(Event_ConnectionClosed::SERVER_SHUTDOWN); @@ -977,6 +1101,27 @@ int Servatrice::getServerStatusUpdateTime() const return settingsCache->value("server/statusupdate", 15000).toInt(); } +int Servatrice::getDeckShareExpiryDays() const +{ + return qMax(1, settingsCache->value("deck_share/expiry_days", 7).toInt()); +} + +int Servatrice::getDeckShareCleanupInterval() const +{ + // default: every 60 minutes + return settingsCache->value("deck_share/cleanup_interval", 60).toInt() * 60000; +} + +int Servatrice::getDeckShareMaxDecksPerShare() const +{ + return settingsCache->value("deck_share/max_decks_per_share", 50).toInt(); +} + +int Servatrice::getDeckShareMaxSharesPerDay() const +{ + return settingsCache->value("deck_share/max_shares_per_day", 50).toInt(); +} + int Servatrice::getNumberOfTCPPools() const { return settingsCache->value("server/number_pools", 1).toInt(); diff --git a/servatrice/src/servatrice.h b/servatrice/src/servatrice.h index 62fb382cb..242d445ad 100644 --- a/servatrice/src/servatrice.h +++ b/servatrice/src/servatrice.h @@ -20,30 +20,46 @@ #ifndef SERVATRICE_H #define SERVATRICE_H +#include "metrics_registry.h" + +#include +#include #include +#include +#include #include #include +#include #include -#include #include #include +#include + +// IWYU pragma: keep +// Q_DECLARE_METATYPE below needs the complete type. +#include #include #include +#include +#include +#include +#include +#include #include #include +class QObject; +class QThread; +class SessionEvent; + Q_DECLARE_METATYPE(QSqlDatabase) -class QSqlQuery; class QTimer; - -class GameReplay; class Servatrice; class Servatrice_ConnectionPool; class Servatrice_DatabaseInterface; class AbstractServerSocketInterface; class IslInterface; -class FeatureSet; class Servatrice_GameServer : public QTcpServer { @@ -140,6 +156,7 @@ public: private slots: void statusUpdate(); void shutdownTimeout(); + void cleanupExpiredDeckShares(); protected: void doSendIslMessage(const IslMessage &msg, int _serverId) override; @@ -153,11 +170,13 @@ private: AuthenticationMethod authenticationMethod; DatabaseType databaseType; QTimer *pingClock, *statusUpdateClock; + QTimer *deckShareCleanupClock; Servatrice_GameServer *gameServer; Servatrice_WebsocketGameServer *websocketGameServer; Servatrice_IslServer *islServer; mutable QMutex loginMessageMutex; QString loginMessage; + mutable QMutex shutdownStateMutex; QString dbPrefix; QMap serverRequiredFeatureList; QString officialWarnings; @@ -166,12 +185,23 @@ private: int uptime; QMutex txBytesMutex, rxBytesMutex; quint64 txBytes, rxBytes; + MetricsRegistry metricsRegistry; + int metricsSlowCommandMs = 500; + int metricsStallWarnMs = 2000; + std::atomic eventLoopStallsTotal{0}; ///< heartbeat overshoots past the warn threshold + std::atomic eventLoopLastStallMs{0}; ///< overshoot of the most recent stall + std::atomic eventLoopMaxStallMs{0}; ///< worst overshoot seen since process start QString shutdownReason; int shutdownMinutes; int nextShutdownMessageMinutes; QTimer *shutdownTimer; + mutable QMutex reportStatsMutex; + QDateTime reportStatsTimestamp; + std::shared_ptr reportStatsCache; + static constexpr int reportStatsCacheTtlSeconds = 60; + mutable QMutex serverListMutex; QList serverList; void updateServerList(); @@ -216,6 +246,8 @@ public: QMutexLocker locker(&loginMessageMutex); return loginMessage; } + SessionEvent *getLoginSessionEvent() const override; + SessionEvent *makeShutdownEvent() const; QString getRequiredFeatures() const override; QString getAuthenticationMethodString() const; QString getDBTypeString() const; @@ -256,6 +288,10 @@ public: int getMaxGameInactivityTime() const override; int getMaxPlayerInactivityTime() const override; int getClientKeepAlive() const override; + int getDeckShareExpiryDays() const; + int getDeckShareCleanupInterval() const; + int getDeckShareMaxDecksPerShare() const; + int getDeckShareMaxSharesPerDay() const; int getMaxUsersPerAddress() const; int getMessageCountingInterval() const override; int getMaxMessageCountPerInterval() const override; @@ -275,11 +311,59 @@ public: void incRxBytes(quint64 num); void addDatabaseInterface(QThread *thread, Servatrice_DatabaseInterface *databaseInterface); + // Metrics (see [metrics] section in servatrice.ini.example) + MetricsRegistry &getMetricsRegistry() + { + return metricsRegistry; + } + /** + * Sums cards across all zones of all running games. Each game takes its + * own gameMutex -- the hot per-game lock every game action contends on -- + * and then iterates every player's zones, so the scrape cost is really + * O(total cards in play) plus one mutex acquisition per live game. Keep + * scrapes infrequent in big multiplayer rooms. + */ + qint64 getCardsInGamesTotal() const; + int getMetricsSlowCommandMs() const + { + return metricsSlowCommandMs; + } + /// Heartbeat overshoot that counts as a stall. A value of 0 disables the watchdogs. + int getMetricsStallWarnMs() const + { + return metricsStallWarnMs; + } + void observeGameStartDurationMs(qint64 elapsedMs) override; + qint64 getEventLoopStallsTotal() const + { + return eventLoopStallsTotal.load(std::memory_order_relaxed); + } + qint64 getEventLoopLastStallMs() const + { + return eventLoopLastStallMs.load(std::memory_order_relaxed); + } + qint64 getEventLoopMaxStallMs() const + { + return eventLoopMaxStallMs.load(std::memory_order_relaxed); + } + /// Records one heartbeat overshoot and logs a single warning for it. + void observeEventLoopStall(const QString &threadName, qint64 overshootMs); + /** + * Installs an EventLoopWatchdog in @p thread. Called once per socket pool + * thread right after it starts. + */ + void watchWorkerThread(QThread *thread); + bool islConnectionExists(int _serverId) const; void addIslInterface(int _serverId, IslInterface *interface); void removeIslInterface(int _serverId); QReadWriteLock islLock; + // The moderation queue statistics are shared between all connected moderators and + // cached briefly to avoid re-running several full-table queries on every refresh. + std::shared_ptr getCachedReportStats() const; + void cacheReportStats(const Response_ReportStats &stats); + QList getServerList() const; }; diff --git a/servatrice/src/servatrice_connection_pool.h b/servatrice/src/servatrice_connection_pool.h index 7479122cf..ddd12fc95 100644 --- a/servatrice/src/servatrice_connection_pool.h +++ b/servatrice/src/servatrice_connection_pool.h @@ -4,6 +4,7 @@ #include #include #include +#include class Servatrice_DatabaseInterface; diff --git a/servatrice/src/servatrice_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index d5e1f13ef..ac3a19c8e 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -1,20 +1,46 @@ #include "servatrice_database_interface.h" +#include "deck_tag_serialization.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_chat_message.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_game.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_moderator_login.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_user_alt.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_user_session.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_warning.pb.h" #include "servatrice.h" -#include "serversocketinterface.h" +#include "server.h" +#include "server_protocolhandler.h" #include "settingscache.h" +#include #include #include #include #include #include +#include #include +#include +#include +#include +#include +#include +#include +#include #include #include +#include +#include +#include +#include #include #include +#include #include +#include +#include +#include inline Q_LOGGING_CATEGORY(DatabaseInterfaceLog, "database_interface"); @@ -85,9 +111,10 @@ bool Servatrice_DatabaseInterface::openDatabase() << dbversion << "to version" << expectedversion; return false; } else if (dbversion > expectedversion) { - qCCritical(DatabaseInterfaceLog) << poolStr << "Error opening database: the database schema version" - << dbversion << "is too new, you need to update servatrice" - << "(this servatrice actually uses version" << expectedversion << ")"; + qCCritical(DatabaseInterfaceLog) + << poolStr << "Error opening database: the database schema version" << dbversion + << "is too new, you need to update Servatrice" << "(Currently running Servatrice actually uses version" + << expectedversion << ")"; return false; } } else { @@ -97,12 +124,59 @@ bool Servatrice_DatabaseInterface::openDatabase() return false; } + if (sqlDatabase.driverName() != "QMYSQL") { + qCCritical(DatabaseInterfaceLog) + << poolStr + << "Error opening database: connection is not a MySQL/MariaDB database, Servatrice only " + "supports the QMYSQL driver (actual driver:" + << sqlDatabase.driverName() << ")."; + return false; + } + + bool strictModeCheckOk = false; + const bool strictModeEnabled = isStrictModeEnabled(strictModeCheckOk); + if (!strictModeCheckOk) { + qCCritical(DatabaseInterfaceLog) << poolStr + << "Error opening database: unable to determine whether MySQL/MariaDB strict " + "mode is enabled"; + return false; + } + if (strictModeEnabled) { + qCCritical(DatabaseInterfaceLog) << poolStr + << "Error opening database: MySQL/MariaDB strict mode is enabled, which " + "breaks most Servatrice database operations. Please disable strict mode " + "by removing STRICT_TRANS_TABLES and STRICT_ALL_TABLES from sql_mode, " + "for example by adding 'sql_mode=NO_ENGINE_SUBSTITUTION' under [mysqld] " + "in your my.cnf (or my.ini on Windows) and restarting the database " + "server."; + return false; + } + // reset all prepared statements qDeleteAll(preparedStatements); preparedStatements.clear(); return true; } +bool Servatrice_DatabaseInterface::isStrictModeEnabled(bool &ok) const +{ + ok = true; + + QSqlQuery query(sqlDatabase); + if (!query.exec("SELECT @@GLOBAL.sql_mode")) { + ok = false; + return false; + } + + const QStringList modes = query.next() ? query.value(0).toString().split(',') : QStringList(); + for (const QString &mode : modes) { + if (mode.trimmed() == "STRICT_TRANS_TABLES" || mode.trimmed() == "STRICT_ALL_TABLES") { + return true; + } + } + return false; +} + bool Servatrice_DatabaseInterface::checkSql() { if (!sqlDatabase.isValid()) { @@ -339,8 +413,8 @@ AuthenticationResult Servatrice_DatabaseInterface::checkUserPassword(Server_Prot return UserIsBanned; } - QSqlQuery *passwordQuery = - prepareQuery("select password_sha512, active from {prefix}_users where name = :name"); + QSqlQuery *passwordQuery = prepareQuery( + "select password_sha512, active, force_password_change from {prefix}_users where name = :name"); passwordQuery->bindValue(":name", user); if (!execSqlQuery(passwordQuery)) { qCWarning(DatabaseInterfaceLog) << "Login denied: SQL error"; @@ -350,6 +424,7 @@ AuthenticationResult Servatrice_DatabaseInterface::checkUserPassword(Server_Prot if (passwordQuery->next()) { const QString correctPasswordSha512 = passwordQuery->value(0).toString(); const bool userIsActive = passwordQuery->value(1).toBool(); + const bool forceChange = passwordQuery->value(2).toBool(); if (!userIsActive) { qCWarning(DatabaseInterfaceLog) << "Login denied: user not active"; return UserIsInactive; @@ -361,6 +436,10 @@ AuthenticationResult Servatrice_DatabaseInterface::checkUserPassword(Server_Prot hashedPassword = password; } if (correctPasswordSha512 == hashedPassword) { + if (forceChange) { + qCDebug(DatabaseInterfaceLog) << "Login accepted but password change required"; + return PasswordChangeRequired; + } qCDebug(DatabaseInterfaceLog) << "Login accepted: password right"; return PasswordRight; } else { @@ -635,6 +714,10 @@ ServerInfo_User Servatrice_DatabaseInterface::evalUserQueryResult(const QSqlQuer userLevel |= ServerInfo_User::IsJudge; } + if (is_admin & 8) { + userLevel |= ServerInfo_User::IsDeveloper; + } + result.set_user_level(userLevel); const QString country = query->value(3).toString(); @@ -1020,6 +1103,183 @@ DeckList *Servatrice_DatabaseInterface::getDeckFromDatabase(int deckId, int user return deck; } +bool Servatrice_DatabaseInterface::createDeckShare(const QString &token, + const QString &name, + int userId, + const QList &items, + int expiryDays, + qint64 &expiresAt) +{ + checkSql(); + + if (items.isEmpty()) { + return false; + } + + if (!sqlDatabase.transaction()) { + return false; + } + + QSqlQuery *query = prepareQuery("insert into {prefix}_deck_share (token, name, created_by, created_at, expires_at) " + "values (:token, :name, :created_by, NOW(), DATE_ADD(NOW(), INTERVAL :days DAY))"); + query->bindValue(":token", token); + query->bindValue(":name", name); + query->bindValue(":created_by", userId < 1 ? QVariant() : userId); + query->bindValue(":days", expiryDays); + if (!execSqlQuery(query)) { + // A failed execSqlQuery has already closed and reopened the connection, + // which implicitly discards the transaction; rollback below is a no-op. + sqlDatabase.rollback(); + return false; + } + + const int shareId = query->lastInsertId().toInt(); + + // Read the expiry back from the database so the value returned to the client + // matches the server clock rather than being approximated client-side. + QSqlQuery *expiryQuery = prepareQuery("select UNIX_TIMESTAMP(expires_at) from {prefix}_deck_share where id = :id"); + expiryQuery->bindValue(":id", shareId); + if (!execSqlQuery(expiryQuery) || !expiryQuery->next()) { + // See the note above: after a failed execSqlQuery the transaction is + // already gone because the connection was torn down. + sqlDatabase.rollback(); + return false; + } + expiresAt = expiryQuery->value(0).toLongLong(); + + for (int i = 0; i < items.size(); ++i) { + const DeckShareItemRecord &item = items.at(i); + QSqlQuery *itemQuery = prepareQuery("insert into {prefix}_deck_share_item (share_id, name, tags, banner_card, " + "game_format, color_identity, content, position) values (:share_id, :name, " + ":tags, :banner_card, :game_format, :color_identity, :content, :position)"); + itemQuery->bindValue(":share_id", shareId); + itemQuery->bindValue(":name", item.name); + itemQuery->bindValue(":tags", serializeDeckTags(item.tags)); + itemQuery->bindValue(":banner_card", item.bannerCard); + itemQuery->bindValue(":game_format", item.gameFormat); + itemQuery->bindValue(":color_identity", item.colorIdentity); + itemQuery->bindValue(":content", item.content); + itemQuery->bindValue(":position", i); + if (!execSqlQuery(itemQuery)) { + // See the note above: the transaction is already gone after the + // reconnect performed by a failed execSqlQuery. + sqlDatabase.rollback(); + return false; + } + } + + if (!sqlDatabase.commit()) { + sqlDatabase.rollback(); + return false; + } + return true; +} + +bool Servatrice_DatabaseInterface::getDeckShareList(const QString &token, + QString &name, + qint64 &expiresAt, + QList &items) +{ + checkSql(); + + QSqlQuery *query = + prepareQuery("select id, name, UNIX_TIMESTAMP(expires_at) from {prefix}_deck_share where token = " + ":token and expires_at > now()"); + query->bindValue(":token", token); + execSqlQuery(query); + if (!query->next()) { + return false; + } + + const int shareId = query->value(0).toInt(); + name = query->value(1).toString(); + expiresAt = query->value(2).toLongLong(); + items.clear(); + + QSqlQuery *itemQuery = + prepareQuery("select id, name, tags, banner_card, game_format, color_identity from {prefix}_deck_share_item " + "where share_id = :share_id order by position"); + itemQuery->bindValue(":share_id", shareId); + execSqlQuery(itemQuery); + while (itemQuery->next()) { + DeckShareItemRecord item; + item.id = itemQuery->value(0).toInt(); + item.name = itemQuery->value(1).toString(); + item.tags = deserializeDeckTags(itemQuery->value(2).toString()); + item.bannerCard = itemQuery->value(3).toString(); + item.gameFormat = itemQuery->value(4).toString(); + item.colorIdentity = itemQuery->value(5).toString(); + items.append(item); + } + + return true; +} + +bool Servatrice_DatabaseInterface::getDeckShareItem(const QString &token, int itemId, QString &content) +{ + checkSql(); + + QSqlQuery *query = prepareQuery("select i.content from {prefix}_deck_share_item i join {prefix}_deck_share s on " + "s.id = i.share_id where s.token = :token and s.expires_at > now() and i.id = :id"); + query->bindValue(":token", token); + query->bindValue(":id", itemId); + execSqlQuery(query); + if (!query->next()) { + return false; + } + + content = query->value(0).toString(); + return true; +} + +void Servatrice_DatabaseInterface::cleanupExpiredDeckShares() +{ + checkSql(); + + QSqlQuery *query = prepareQuery("delete from {prefix}_deck_share where expires_at < now()"); + execSqlQuery(query); +} + +bool Servatrice_DatabaseInterface::getDeckSharesForUser(int userId, QList &shares) +{ + checkSql(); + + QSqlQuery *query = prepareQuery("select s.id, s.name, UNIX_TIMESTAMP(s.created_at), " + "UNIX_TIMESTAMP(s.expires_at), count(i.id) from {prefix}_deck_share s left join " + "{prefix}_deck_share_item i on i.share_id = s.id " + "where s.created_by = :created_by and s.expires_at > now() " + "group by s.id, s.name, s.created_at, s.expires_at order by s.created_at desc"); + query->bindValue(":created_by", userId); + if (!execSqlQuery(query)) { + return false; + } + + shares.clear(); + while (query->next()) { + DeckShareSummaryRecord summary; + summary.id = query->value(0).toInt(); + summary.name = query->value(1).toString(); + summary.creationTime = query->value(2).toLongLong(); + summary.expiresAt = query->value(3).toLongLong(); + summary.itemCount = query->value(4).toInt(); + shares.append(summary); + } + return true; +} + +bool Servatrice_DatabaseInterface::deleteDeckShare(int shareId, int userId) +{ + checkSql(); + + QSqlQuery *query = prepareQuery("delete from {prefix}_deck_share where id = :id and created_by = :created_by"); + query->bindValue(":id", shareId); + query->bindValue(":created_by", userId); + if (!execSqlQuery(query)) { + return false; + } + return query->numRowsAffected() > 0; +} + void Servatrice_DatabaseInterface::logMessage(const int senderId, const QString &senderName, const QString &senderIp, @@ -1084,11 +1344,22 @@ bool Servatrice_DatabaseInterface::changeUserPassword(const QString &user, "passwordLastChangedDate = NOW() where name = :name"); passwordQuery->bindValue(":password", passwordSha512); passwordQuery->bindValue(":name", user); - if (execSqlQuery(passwordQuery)) { - return true; + if (!execSqlQuery(passwordQuery)) { + return false; + } + return passwordQuery->numRowsAffected() > 0; +} + +void Servatrice_DatabaseInterface::setForcePasswordChange(const QString &user, bool force) +{ + if (!checkSql()) { + return; } - return false; + QSqlQuery *query = prepareQuery("UPDATE {prefix}_users SET force_password_change = :force WHERE name = :name"); + query->bindValue(":force", force ? 1 : 0); + query->bindValue(":name", user); + execSqlQuery(query); } bool Servatrice_DatabaseInterface::changeUserPassword(const QString &user, @@ -1314,6 +1585,204 @@ QList Servatrice_DatabaseInterface::getUserWarnHistory(const return results; } +QList Servatrice_DatabaseInterface::getUserSessions(const QString &userName, int limit) +{ + QList results; + + if (!checkSql()) { + return results; + } + + QSqlQuery *query = prepareQuery("SELECT user_name, ip_address, clientid, " + "UNIX_TIMESTAMP(start_time), UNIX_TIMESTAMP(end_time), connection_type " + "FROM {prefix}_sessions WHERE user_name = :user_name " + "ORDER BY start_time DESC LIMIT :limit"); + query->bindValue(":user_name", userName); + query->bindValue(":limit", limit); + + if (!execSqlQuery(query)) { + qCWarning(DatabaseInterfaceLog) << "Failed to collect session history information: SQL Error"; + return results; + } + + while (query->next()) { + ServerInfo_UserSession sessionDetails; + sessionDetails.set_user_name(query->value(0).toString().toStdString()); + sessionDetails.set_ip_address(query->value(1).toString().toStdString()); + sessionDetails.set_clientid(query->value(2).toString().toStdString()); + sessionDetails.set_start_time(query->value(3).toLongLong()); + if (!query->value(4).isNull()) { + sessionDetails.set_end_time(query->value(4).toLongLong()); + } + sessionDetails.set_connection_type(query->value(5).toString().toStdString()); + results << sessionDetails; + } + + return results; +} + +QList Servatrice_DatabaseInterface::getUserAlts(const QString &userName) +{ + QList results; + + if (!checkSql()) { + return results; + } + + // Seed account identifiers used to find related accounts + QSqlQuery *seedQuery = prepareQuery("SELECT email, clientid FROM {prefix}_users WHERE name = :user_name"); + seedQuery->bindValue(":user_name", userName); + if (!execSqlQuery(seedQuery) || !seedQuery->next()) { + return results; + } + const QString seedEmail = seedQuery->value(0).toString(); + const QString seedClientId = seedQuery->value(1).toString(); + + QString queryString = "SELECT u.name, u.email, u.clientid, UNIX_TIMESTAMP(u.registrationDate), " + "UNIX_TIMESTAMP(a.last_login), " + "(SELECT COUNT(*) FROM {prefix}_warnings w WHERE w.user_id = u.id), " + "(SELECT COUNT(*) FROM {prefix}_bans b WHERE b.user_name = u.name), " + "u.active " + "FROM {prefix}_users u " + "LEFT JOIN {prefix}_user_analytics a ON a.id = u.id " + "WHERE u.name = :user_name"; + if (!seedEmail.isEmpty()) { + queryString.append(" OR u.email = :seed_email"); + } + if (!seedClientId.isEmpty()) { + queryString.append(" OR u.clientid = :seed_clientid"); + } + queryString.append(" OR u.name IN (SELECT DISTINCT s.user_name FROM {prefix}_sessions s " + "WHERE s.ip_address IN (SELECT DISTINCT s2.ip_address FROM {prefix}_sessions s2 " + "WHERE s2.user_name = :user_name" + " AND s2.start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH))" + " AND s.start_time >= DATE_SUB(NOW(), INTERVAL 6 MONTH)) " + "ORDER BY u.name LIMIT 200"); + + QSqlQuery *query = prepareQuery(queryString); + query->bindValue(":user_name", userName); + if (!seedEmail.isEmpty()) { + query->bindValue(":seed_email", seedEmail); + } + if (!seedClientId.isEmpty()) { + query->bindValue(":seed_clientid", seedClientId); + } + + if (!execSqlQuery(query)) { + qCWarning(DatabaseInterfaceLog) << "Failed to collect user alt information: SQL Error"; + return results; + } + + while (query->next()) { + ServerInfo_UserAlt altDetails; + altDetails.set_user_name(query->value(0).toString().toStdString()); + altDetails.set_email(query->value(1).toString().toStdString()); + altDetails.set_clientid(query->value(2).toString().toStdString()); + altDetails.set_registration_time(query->value(3).toLongLong()); + if (!query->value(4).isNull()) { + altDetails.set_last_login(query->value(4).toLongLong()); + } + altDetails.set_warn_count(query->value(5).toInt()); + altDetails.set_ban_count(query->value(6).toInt()); + altDetails.set_is_active(query->value(7).toBool()); + results << altDetails; + } + + return results; +} + +QList Servatrice_DatabaseInterface::getModeratorLastLogins() +{ + QList results; + + if (!checkSql()) { + return results; + } + + QSqlQuery *query = prepareQuery("SELECT u.name, u.admin, UNIX_TIMESTAMP(a.last_login) " + "FROM {prefix}_users u " + "LEFT JOIN {prefix}_user_analytics a ON a.id = u.id " + "WHERE (u.admin & 15) <> 0 ORDER BY u.name"); + + if (!execSqlQuery(query)) { + qCWarning(DatabaseInterfaceLog) << "Failed to collect moderator login information: SQL Error"; + return results; + } + + while (query->next()) { + ServerInfo_ModeratorLogin loginDetails; + loginDetails.set_user_name(query->value(0).toString().toStdString()); + + const int isAdmin = query->value(1).toInt(); + int userLevel = ServerInfo_User::IsUser | ServerInfo_User::IsRegistered; + if (isAdmin & 1) { + userLevel |= ServerInfo_User::IsAdmin | ServerInfo_User::IsModerator; + } else if (isAdmin & 2) { + userLevel |= ServerInfo_User::IsModerator; + } + if (isAdmin & 4) { + userLevel |= ServerInfo_User::IsJudge; + } + if (isAdmin & 8) { + userLevel |= ServerInfo_User::IsDeveloper; + } + loginDetails.set_user_level(userLevel); + + if (!query->value(2).isNull()) { + loginDetails.set_last_login(query->value(2).toLongLong()); + } + results << loginDetails; + } + + return results; +} + +Servatrice_DatabaseInterface::UptimeSnapshot Servatrice_DatabaseInterface::getLatestUptimeSnapshot(int serverId) +{ + UptimeSnapshot snapshot; + + if (!checkSql()) { + return snapshot; + } + + QSqlQuery *query = prepareQuery("SELECT users_count, mods_count, games_count, tx_bytes, rx_bytes, uptime, " + "UNIX_TIMESTAMP(timest) FROM {prefix}_uptime " + "WHERE id_server = :id_server ORDER BY timest DESC LIMIT 1"); + query->bindValue(":id_server", serverId); + + if (!execSqlQuery(query)) { + qCWarning(DatabaseInterfaceLog) << "Failed to collect server stats snapshot: SQL Error"; + return snapshot; + } + + if (query->next()) { + snapshot.valid = true; + snapshot.usersCount = query->value(0).toULongLong(); + snapshot.modsCount = query->value(1).toULongLong(); + snapshot.gamesCount = query->value(2).toULongLong(); + snapshot.txBytes = query->value(3).toULongLong(); + snapshot.rxBytes = query->value(4).toULongLong(); + snapshot.uptimeSecs = query->value(5).toULongLong(); + snapshot.timest = query->value(6).toULongLong(); + } + + return snapshot; +} + +bool Servatrice_DatabaseInterface::removeUserAvatar(const QString &userName) +{ + if (!checkSql()) { + return false; + } + + QSqlQuery *query = prepareQuery("UPDATE {prefix}_users SET avatar_bmp = '' WHERE name = :user_name"); + query->bindValue(":user_name", userName); + if (!execSqlQuery(query)) { + return false; + } + return query->numRowsAffected() > 0; +} + QList Servatrice_DatabaseInterface::getMessageLogHistory(const QString &user, const QString &ipaddress, const QString &gamename, diff --git a/servatrice/src/servatrice_database_interface.h b/servatrice/src/servatrice_database_interface.h index 1e3501ec7..0509d1a03 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -1,19 +1,52 @@ #ifndef SERVATRICE_DATABASE_INTERFACE_H #define SERVATRICE_DATABASE_INTERFACE_H -#include +#include "libcockatrice/protocol/pb/serverinfo_user.pb.h" + #include -#include +#include #include +#include +#include +#include +#include #include +#include +#include +#include #include +#include #include #include -#define DATABASE_SCHEMA_VERSION 35 +class QSqlQuery; + +#define DATABASE_SCHEMA_VERSION 37 class Servatrice; +/** @brief Metadata of a single deck inside a temporary deck share bundle. */ +struct DeckShareItemRecord +{ + int id = -1; ///< Database id, used for downloads. + QString name; ///< Deck name. + QStringList tags; ///< Deck tags. + QString bannerCard; ///< Banner card name (deck image). + QString gameFormat; ///< Game format the deck was built for. + QString colorIdentity; ///< Color identity, e.g. "WUBRG". + QString content; ///< Deck content (native format); empty in list queries. +}; + +/** @brief Summary of a share bundle owned by a user. */ +struct DeckShareSummaryRecord +{ + int id = -1; ///< Database id, used for revocation. + QString name; ///< Share name. + qint64 creationTime = 0; ///< Unix timestamp at which the share was created. + qint64 expiresAt = 0; ///< Unix timestamp at which the share expires. + int itemCount = 0; ///< Number of decks in the bundle. +}; + class Servatrice_DatabaseInterface : public Server_DatabaseInterface { Q_OBJECT @@ -29,6 +62,7 @@ private: bool checkUserIsIpBanned(const QString &ipAddress, QString &banReason, int &banSecondsRemaining); /** Must be called after checkSql and server is known to be in auth mode. */ bool checkUserIsNameBanned(QString const &userName, QString &banReason, int &banSecondsRemaining); + bool isStrictModeEnabled(bool &ok) const; protected: AuthenticationResult checkUserPassword(Server_ProtocolHandler *handler, @@ -76,6 +110,37 @@ public: const QList &replayList) override; DeckList *getDeckFromDatabase(int deckId, int userId) override; + /** + * @brief Creates a new temporary deck share bundle. + * @param expiresAt Receives the actual expiry read back from the database. + * @return false on failure. + */ + bool createDeckShare(const QString &token, + const QString &name, + int userId, + const QList &items, + int expiryDays, + qint64 &expiresAt); + /** @brief Lists the share bundles created by a user, newest first. */ + bool getDeckSharesForUser(int userId, QList &shares); + /** + * @brief Deletes one of a user's own share bundles (cascades to its items). + * @return false if no such bundle belongs to the user. + */ + bool deleteDeckShare(int shareId, int userId); + /** + * @brief Looks up a valid (non-expired) share bundle by token. + * @return false if the token is unknown or expired. + */ + bool getDeckShareList(const QString &token, QString &name, qint64 &expiresAt, QList &items); + /** + * @brief Fetches the content of one item of a valid share bundle. + * @return false if the token is unknown/expired or the item does not belong to the bundle. + */ + bool getDeckShareItem(const QString &token, int itemId, QString &content); + /** @brief Deletes all expired share bundles (cascades to their items). */ + void cleanupExpiredDeckShares(); + int getNextGameId() override; int getNextReplayId() override; int getActiveUserCount(QString connectionType = QString()) override; @@ -119,6 +184,7 @@ public: bool oldPasswordNeedsHash, const QString &newPassword, bool newPasswordNeedsHash) override; + void setForcePasswordChange(const QString &user, bool force) override; QList getUserBanHistory(const QString userName); bool addWarning(const QString userName, const QString adminName, const QString warningReason, const QString clientID); @@ -133,6 +199,25 @@ public: bool &room, int &range, int &maxresults); + QList getUserSessions(const QString &userName, int limit); + QList getUserAlts(const QString &userName); + QList getModeratorLastLogins(); + + // Uptime snapshot as recorded by Servatrice::statusUpdate() into the + // {prefix}_uptime table. valid is false when no snapshot exists yet. + struct UptimeSnapshot + { + bool valid = false; + quint64 usersCount = 0; + quint64 modsCount = 0; + quint64 gamesCount = 0; + quint64 txBytes = 0; + quint64 rxBytes = 0; + quint64 uptimeSecs = 0; + quint64 timest = 0; + }; + UptimeSnapshot getLatestUptimeSnapshot(int serverId); + bool removeUserAvatar(const QString &userName); bool addForgotPassword(const QString &user); bool removeForgotPassword(const QString &user) override; bool doesForgotPasswordExist(const QString &user); diff --git a/servatrice/src/server_logger.cpp b/servatrice/src/server_logger.cpp index 620780052..1972444f1 100644 --- a/servatrice/src/server_logger.cpp +++ b/servatrice/src/server_logger.cpp @@ -6,8 +6,16 @@ #include #include #include +#include +#include #include +#include +#include +#include #include +#include +#include +#include ServerLogger::ServerLogger(bool _logToConsole, QObject *parent) : QObject(parent), logToConsole(_logToConsole), flushRunning(false) diff --git a/servatrice/src/server_logger.h b/servatrice/src/server_logger.h index 0ad092b66..f2875a6a3 100644 --- a/servatrice/src/server_logger.h +++ b/servatrice/src/server_logger.h @@ -1,14 +1,14 @@ #ifndef SERVER_LOGGER_H #define SERVER_LOGGER_H +#include #include #include +#include #include -#include -#include +#include class QFile; -class Server_ProtocolHandler; class ServerLogger : public QObject { diff --git a/servatrice/src/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 842ddb4c8..d46a9b0ee 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -20,39 +20,99 @@ #include "serversocketinterface.h" +#include "deck_tag_serialization.h" #include "email_parser.h" +#include "libcockatrice/protocol/pb/admin_commands.pb.h" +#include "libcockatrice/protocol/pb/developer_commands.pb.h" +#include "libcockatrice/protocol/pb/moderator_commands.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_deck_share_item.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_deck_share_summary.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_replay_match.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_report.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_warning.pb.h" +#include "libcockatrice/protocol/pb/session_commands.pb.h" +#include "libcockatrice/protocol/pb/session_event.pb.h" +#include "libcockatrice/utility/card_ref.h" #include "main.h" +#include "metrics_registry.h" #include "servatrice.h" #include "servatrice_database_interface.h" +#include "server.h" +#include "server_database_interface.h" #include "server_logger.h" +#include "server_protocolhandler.h" #include "settingscache.h" #include "version_string.h" +#include +#include #include #include +#include #include #include #include +#include +#include #include -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include -#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include +#include #include #include #include +#include #include +#include #include +#include +#include +#include +#include +#include +#include #include +#include #include #include +#include #include #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -60,33 +120,60 @@ #include #include #include -#include -#include #include #include #include #include #include +#include +#include +#include +#include #include #include #include +#include +#include #include #include +#include #include +#include #include #include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include #include #include #include +#include #include #include +#include +#include +#include +#include #include +#include +#include +#include +#include #include #include +#include #include +#include + +class QObject; inline Q_LOGGING_CATEGORY(AbstractServerSocketInterfaceLog, "abstract_server_socket_interface"); inline Q_LOGGING_CATEGORY(TcpServerSocketInterfaceLog, "tcp_server_socket_interface"); @@ -94,6 +181,9 @@ inline Q_LOGGING_CATEGORY(WebsocketServerSocketInterfaceLog, "websocket_server_s static const int protocolVersion = 14; +// Maximum size of a single TCP protocol message. Matches the websocket limit. +static const int MAX_TCP_MESSAGE_SIZE = 1500000; + AbstractServerSocketInterface::AbstractServerSocketInterface(Servatrice *_server, Servatrice_DatabaseInterface *_databaseInterface, QObject *parent) @@ -164,6 +254,77 @@ void AbstractServerSocketInterface::logDebugMessage(const QString &message) logger->logMessage(message, this); } +void AbstractServerSocketInterface::processCommandContainer(const CommandContainer &cont) +{ + QElapsedTimer timer; + timer.start(); + Server_ProtocolHandler::processCommandContainer(cont); + const qint64 elapsedMs = timer.nsecsElapsed() / 1000000; + + // The base dispatch is an if/else-if chain — at most one family is + // actually processed. Recording every family in the container would + // let an unauthenticated client stampforge developer/moderator/admin + // samples by batching them alongside a session command the server + // actually runs. Mirror the base's selection and skip entirely when + // deleted or when no family matched. + if (deleted) { + return; + } + + // When getPbExtension returns -1 (no extension set) and the kind is + // non-zero, typeIdFor wraps into the previous kind's range instead of + // hitting the typeId < 0 guard in observeCommand. Skip such entries. + int kind = -1; + if (cont.game_command_size()) { + kind = 2; + } else if (cont.room_command_size()) { + kind = 1; + } else if (cont.session_command_size()) { + kind = 0; + } else if (cont.moderator_command_size()) { + kind = 3; + } else if (cont.admin_command_size()) { + kind = 4; + } else if (cont.developer_command_size()) { + kind = 5; + } + + if (kind >= 0) { + auto recordDispatched = [&](int familyKind, const auto &cmds) { + for (const auto &cmd : cmds) { + const int ext = getPbExtension(cmd); + if (ext >= 0) { + servatrice->getMetricsRegistry().observeCommand(MetricsRegistry::typeIdFor(familyKind, ext), + elapsedMs); + } + } + }; + + if (kind == 0) { + recordDispatched(kind, cont.session_command()); + } else if (kind == 1) { + recordDispatched(kind, cont.room_command()); + } else if (kind == 2) { + recordDispatched(kind, cont.game_command()); + } else if (kind == 3) { + recordDispatched(kind, cont.moderator_command()); + } else if (kind == 4) { + recordDispatched(kind, cont.admin_command()); + } else { + recordDispatched(kind, cont.developer_command()); + } + } + + const int slowCommandMs = servatrice->getMetricsSlowCommandMs(); + if (slowCommandMs > 0 && elapsedMs >= slowCommandMs) { + const ServerInfo_User *info = getUserInfo(); + const QString user = authState == PasswordRight && info ? QString::fromStdString(info->name()) + : QStringLiteral("unauthenticated"); + qCWarning(AbstractServerSocketInterfaceLog) << "slow command container from" << user << "processed in" + << elapsedMs << "ms (" << cont.ByteSizeLong() << "bytes)"; + } +} + Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionCommand(int cmdType, const SessionCommand &cmd, ResponseContainer &rc) @@ -175,6 +336,12 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm return cmdRemoveFromList(cmd.GetExtension(Command_RemoveFromList::ext), rc); case SessionCommand::DECK_LIST: return cmdDeckList(cmd.GetExtension(Command_DeckList::ext), rc); + case SessionCommand::DECK_LIST_OTHER_USER: + return cmdDeckListOtherUser(cmd.GetExtension(Command_DeckListOtherUser::ext), rc); + case SessionCommand::DECK_SET_VISIBILITY: + return cmdDeckSetVisibility(cmd.GetExtension(Command_DeckSetVisibility::ext), rc); + case SessionCommand::DECK_DOWNLOAD_PUBLIC: + return cmdDeckDownloadPublic(cmd.GetExtension(Command_DeckDownloadPublic::ext), rc); case SessionCommand::DECK_NEW_DIR: return cmdDeckNewDir(cmd.GetExtension(Command_DeckNewDir::ext), rc); case SessionCommand::DECK_DEL_DIR: @@ -218,11 +385,29 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm return cmdAccountImage(cmd.GetExtension(Command_AccountImage::ext), rc); case SessionCommand::SET_CARD_ART_PARAMS: return cmdSetCardArtParams(cmd.GetExtension(Command_SetCardArtParams::ext), rc); + case SessionCommand::DECK_SHARE_CREATE: + return cmdDeckShareCreate(cmd.GetExtension(Command_DeckShareCreate::ext), rc); + case SessionCommand::DECK_SHARE_LIST: + return cmdDeckShareList(cmd.GetExtension(Command_DeckShareList::ext), rc); + case SessionCommand::DECK_SHARE_LIST_MINE: + return cmdDeckShareListMine(cmd.GetExtension(Command_DeckShareListMine::ext), rc); + case SessionCommand::DECK_SHARE_REMOVE: + return cmdDeckShareRemove(cmd.GetExtension(Command_DeckShareRemove::ext), rc); + case SessionCommand::DECK_SHARE_DOWNLOAD: + return cmdDeckShareDownload(cmd.GetExtension(Command_DeckShareDownload::ext), rc); case SessionCommand::ACCOUNT_PASSWORD: return cmdAccountPassword(cmd.GetExtension(Command_AccountPassword::ext), rc); case SessionCommand::REQUEST_PASSWORD_SALT: return cmdRequestPasswordSalt(cmd.GetExtension(Command_RequestPasswordSalt::ext), rc); break; + case SessionCommand::REPORT: + return cmdReport(cmd.GetExtension(Command_Report::ext), rc); + case SessionCommand::REPORT_MY_LIST: + return cmdReportMyList(cmd.GetExtension(Command_ReportMyList::ext), rc); + case SessionCommand::REPORT_ADD_COMMENT: + return cmdReportAddComment(cmd.GetExtension(Command_ReportAddComment::ext), rc); + case SessionCommand::REPORT_DETAILS: + return cmdReportDetails(cmd.GetExtension(Command_ReportDetails::ext), rc); default: return Response::RespFunctionNotAllowed; } @@ -243,10 +428,18 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedModeratorCo return cmdGetWarnHistory(cmd.GetExtension(Command_GetWarnHistory::ext), rc); case ModeratorCommand::WARN_LIST: return cmdGetWarnList(cmd.GetExtension(Command_GetWarnList::ext), rc); + case ModeratorCommand::REPORT_LIST: + return cmdReportList(cmd.GetExtension(Command_ReportList::ext), rc); + case ModeratorCommand::REPORT_ASSIGN: + return cmdReportAssign(cmd.GetExtension(Command_ReportAssign::ext), rc); + case ModeratorCommand::REPORT_RESOLVE: + return cmdReportResolve(cmd.GetExtension(Command_ReportResolve::ext), rc); case ModeratorCommand::VIEWLOG_HISTORY: - return cmdGetLogHistory(cmd.GetExtension(Command_ViewLogHistory::ext), rc); + return cmdGetLogHistory(cmd.GetExtension(Command_ViewLogHistory::ext), rc, true); case ModeratorCommand::GRANT_REPLAY_ACCESS: return cmdGrantReplayAccess(cmd.GetExtension(Command_GrantReplayAccess::ext), rc); + case ModeratorCommand::REPLAY_DOWNLOAD_BY_GAME_ID: + return cmdReplayDownloadByGameId(cmd.GetExtension(Command_ReplayDownloadByGameId::ext), rc); case ModeratorCommand::FORCE_ACTIVATE_USER: return cmdForceActivateUser(cmd.GetExtension(Command_ForceActivateUser::ext), rc); case ModeratorCommand::GET_ADMIN_NOTES: @@ -259,6 +452,18 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedModeratorCo return cmdRemoveCardArtRule(cmd.GetExtension((Command_RemoveCardArtRule::ext)), rc); case ModeratorCommand::LIST_CARD_ART_RULES: return cmdListCardArtRules(cmd.GetExtension((Command_ListCardArtRules::ext)), rc); + case ModeratorCommand::REPORT_USER_INFO: + return cmdReportUserInfo(cmd.GetExtension(Command_ReportUserInfo::ext), rc); + case ModeratorCommand::REPORT_STATS: + return cmdReportStats(cmd.GetExtension(Command_ReportStats::ext), rc); + case ModeratorCommand::GET_USER_SESSIONS: + return cmdGetUserSessions(cmd.GetExtension(Command_GetUserSessions::ext), rc); + case ModeratorCommand::GET_USER_ALTS: + return cmdGetUserAlts(cmd.GetExtension(Command_GetUserAlts::ext), rc); + case ModeratorCommand::GET_MODERATOR_LAST_LOGINS: + return cmdGetModeratorLastLogins(cmd.GetExtension(Command_GetModeratorLastLogins::ext), rc); + case ModeratorCommand::REMOVE_USER_AVATAR: + return cmdRemoveUserAvatar(cmd.GetExtension(Command_RemoveUserAvatar::ext), rc); default: return Response::RespFunctionNotAllowed; } @@ -276,6 +481,28 @@ AbstractServerSocketInterface::processExtendedAdminCommand(int cmdType, const Ad return cmdReloadConfig(cmd.GetExtension(Command_ReloadConfig::ext), rc); case AdminCommand::ADJUST_MOD: return cmdAdjustMod(cmd.GetExtension(Command_AdjustMod::ext), rc); + case AdminCommand::RESET_USER_PASSWORD: + return cmdResetUserPassword(cmd.GetExtension(Command_ResetUserPassword::ext), rc); + default: + return Response::RespFunctionNotAllowed; + } +} + +// DEVELOPER FUNCTIONS. +// Permission is checked by processDeveloperCommandContainer. Only stats-style +// queries live here, never community moderation or server administration. +Response::ResponseCode AbstractServerSocketInterface::processExtendedDeveloperCommand(int cmdType, + const DeveloperCommand &cmd, + ResponseContainer &rc) +{ + switch ((DeveloperCommand::DeveloperCommandType)cmdType) { + case DeveloperCommand::GET_SERVER_STATS: + return cmdGetServerStats(cmd.GetExtension(Command_GetServerStats::ext), rc); + case DeveloperCommand::VIEWLOG_HISTORY: { + // Same query as the moderator log view, carried by the developer + // command family, but narrows out private chats and sender IPs. + return cmdGetLogHistory(cmd.GetExtension(Command_ViewLogHistory::dev_ext), rc, false); + } default: return Response::RespFunctionNotAllowed; } @@ -414,46 +641,73 @@ int AbstractServerSocketInterface::getDeckPathId(const QString &path) return getDeckPathId(0, path.split("/")); } -bool AbstractServerSocketInterface::deckListHelper(int folderId, ServerInfo_DeckStorage_Folder *folder) +bool AbstractServerSocketInterface::deckListHelper(int folderId, + ServerInfo_DeckStorage_Folder *folder, + int userId, + bool inheritedPublic, + bool publicOnly) { - QSqlQuery *query = sqlInterface->prepareQuery( - "select id, name from {prefix}_decklist_folders where id_parent = :id_parent and id_user = :id_user"); + QSqlQuery *query = sqlInterface->prepareQuery("select id, name, is_public from {prefix}_decklist_folders where " + "id_parent = :id_parent and id_user = :id_user"); query->bindValue(":id_parent", folderId); - query->bindValue(":id_user", userInfo->id()); + query->bindValue(":id_user", userId); if (!sqlInterface->execSqlQuery(query)) { return false; } - QMap results; + QList>> folderRows; while (query->next()) { - results[query->value(0).toInt()] = query->value(1).toString(); + folderRows.append({query->value(0).toInt(), {query->value(1).toString(), query->value(2).toBool()}}); } + std::sort(folderRows.begin(), folderRows.end(), [](const auto &a, const auto &b) { return a.first < b.first; }); + + for (const auto &[folderIdValue, folderInfo] : folderRows) { + const QString name = folderInfo.first; + const bool ownPublic = folderInfo.second; + const bool effectivePublic = inheritedPublic || ownPublic; - for (int key : results.keys()) { ServerInfo_DeckStorage_TreeItem *newItem = folder->add_items(); - newItem->set_id(key); - newItem->set_name(results.value(key).toStdString()); + newItem->set_id(folderIdValue); + newItem->set_name(name.toStdString()); + newItem->mutable_folder()->set_is_public(ownPublic); - if (!deckListHelper(newItem->id(), newItem->mutable_folder())) { + if (!deckListHelper(newItem->id(), newItem->mutable_folder(), userId, effectivePublic, publicOnly)) { return false; } + + if (publicOnly && !effectivePublic && newItem->mutable_folder()->items_size() == 0) { + folder->mutable_items()->RemoveLast(); + } } - query = sqlInterface->prepareQuery("select id, name, upload_time from {prefix}_decklist_files where id_folder = " - ":id_folder and id_user = :id_user"); + query = sqlInterface->prepareQuery("select id, name, upload_time, is_public, banner_card_name, " + "banner_card_provider, color_identity, tags from {prefix}_decklist_files where " + "id_folder = :id_folder and id_user = :id_user"); query->bindValue(":id_folder", folderId); - query->bindValue(":id_user", userInfo->id()); + query->bindValue(":id_user", userId); if (!sqlInterface->execSqlQuery(query)) { return false; } while (query->next()) { + const bool ownPublic = query->value(3).toBool(); + if (publicOnly && !(inheritedPublic || ownPublic)) { + continue; + } + ServerInfo_DeckStorage_TreeItem *newItem = folder->add_items(); newItem->set_id(query->value(0).toInt()); newItem->set_name(query->value(1).toString().toStdString()); ServerInfo_DeckStorage_File *newFile = newItem->mutable_file(); newFile->set_creation_time(query->value(2).toDateTime().toSecsSinceEpoch()); + newFile->set_is_public(ownPublic); + newFile->set_banner_card_name(query->value(4).toString().toStdString()); + newFile->set_banner_card_provider(query->value(5).toString().toStdString()); + newFile->set_color_identity(query->value(6).toString().toStdString()); + for (const QString &tag : deserializeDeckTags(query->value(7).toString())) { + newFile->add_tags(tag.toStdString()); + } } return true; @@ -474,7 +728,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckList(const Command_ Response_DeckList *re = new Response_DeckList; ServerInfo_DeckStorage_Folder *root = re->mutable_root(); - if (!deckListHelper(0, root)) { + if (!deckListHelper(0, root, userInfo->id(), false, false)) { return Response::RespContextError; } @@ -482,6 +736,160 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckList(const Command_ return Response::RespOk; } +Response::ResponseCode AbstractServerSocketInterface::cmdDeckListOtherUser(const Command_DeckListOtherUser &cmd, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + sqlInterface->checkSql(); + + const QString userName = nameFromStdString(cmd.user_name()); + const int userId = sqlInterface->getUserIdInDB(userName); + if (userId == -1) { + return Response::RespNameNotFound; + } + + Response_DeckList *re = new Response_DeckList; + ServerInfo_DeckStorage_Folder *root = re->mutable_root(); + + if (!deckListHelper(0, root, userId, false, true)) { + return Response::RespContextError; + } + + rc.setResponseExtension(re); + return Response::RespOk; +} + +int AbstractServerSocketInterface::getDeckOwnerId(int deckId) +{ + QSqlQuery *query = sqlInterface->prepareQuery("select id_user from {prefix}_decklist_files where id = :id"); + query->bindValue(":id", deckId); + if (!sqlInterface->execSqlQuery(query)) { + return -1; + } + if (!query->next()) { + return -1; + } + return query->value(0).toInt(); +} + +bool AbstractServerSocketInterface::isDeckEffectivelyPublic(int deckId) +{ + QSqlQuery *query = + sqlInterface->prepareQuery("select is_public, id_folder from {prefix}_decklist_files where id = :id"); + query->bindValue(":id", deckId); + if (!sqlInterface->execSqlQuery(query)) { + return false; + } + if (!query->next()) { + return false; + } + if (query->value(0).toBool()) { + return true; + } + + int folderId = query->value(1).toInt(); + int guard = 0; + while (folderId != 0 && guard < 100) { + QSqlQuery *folderQuery = + sqlInterface->prepareQuery("select is_public, id_parent from {prefix}_decklist_folders where id = :id"); + folderQuery->bindValue(":id", folderId); + if (!sqlInterface->execSqlQuery(folderQuery)) { + return false; + } + if (!folderQuery->next()) { + return false; + } + if (folderQuery->value(0).toBool()) { + return true; + } + folderId = folderQuery->value(1).toInt(); + ++guard; + } + return false; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdDeckSetVisibility(const Command_DeckSetVisibility &cmd, + ResponseContainer & /*rc*/) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + sqlInterface->checkSql(); + + if (cmd.has_deck_id()) { + QSqlQuery *query = + sqlInterface->prepareQuery("select 1 from {prefix}_decklist_files where id = :id and id_user = :id_user"); + query->bindValue(":id", cmd.deck_id()); + query->bindValue(":id_user", userInfo->id()); + sqlInterface->execSqlQuery(query); + if (!query->next()) { + return Response::RespNameNotFound; + } + + query = sqlInterface->prepareQuery("update {prefix}_decklist_files set is_public = :is_public where id = :id " + "and id_user = :id_user"); + query->bindValue(":is_public", cmd.is_public() ? 1 : 0); + query->bindValue(":id", cmd.deck_id()); + query->bindValue(":id_user", userInfo->id()); + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespContextError; + } + } else if (cmd.has_folder_path()) { + const int folderId = getDeckPathId(nameFromStdString(cmd.folder_path())); + if (folderId == -1 || folderId == 0) { + return Response::RespNameNotFound; + } + + QSqlQuery *query = + sqlInterface->prepareQuery("update {prefix}_decklist_folders set is_public = :is_public where id = :id " + "and id_user = :id_user"); + query->bindValue(":is_public", cmd.is_public() ? 1 : 0); + query->bindValue(":id", folderId); + query->bindValue(":id_user", userInfo->id()); + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespContextError; + } + } else { + return Response::RespInvalidData; + } + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdDeckDownloadPublic(const Command_DeckDownloadPublic &cmd, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + sqlInterface->checkSql(); + + const int deckId = cmd.deck_id(); + const int ownerId = getDeckOwnerId(deckId); + if (ownerId == -1 || !isDeckEffectivelyPublic(deckId)) { + return Response::RespNameNotFound; + } + + DeckList *deck; + try { + deck = sqlInterface->getDeckFromDatabase(deckId, ownerId); + } catch (Response::ResponseCode &r) { + return r; + } + + Response_DeckDownload *re = new Response_DeckDownload; + re->set_deck(deck->writeToString_Native().toStdString()); + rc.setResponseExtension(re); + delete deck; + + return Response::RespOk; +} + Response::ResponseCode AbstractServerSocketInterface::cmdDeckNewDir(const Command_DeckNewDir &cmd, ResponseContainer & /*rc*/) { @@ -590,6 +998,22 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckDel(const Command_D return Response::RespOk; } +namespace +{ +/** @brief Keeps only the WUBRG colors from a color identity string, deduplicated. */ +QString sanitizeColorIdentity(const QString &colorIdentity) +{ + QString sanitized; + for (const QChar &color : colorIdentity) { + const QChar upper = color.toUpper(); + if (QStringLiteral("WUBRG").contains(upper) && !sanitized.contains(upper)) { + sanitized.append(upper); + } + } + return sanitized; +} +} // namespace + Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Command_DeckUpload &cmd, ResponseContainer &rc) { @@ -601,6 +1025,10 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman return Response::RespInvalidData; } + if (cmd.deck_list().size() > static_cast(MAX_FILE_LENGTH)) { + return Response::RespInvalidData; + } + sqlInterface->checkSql(); QString deckStr = fileFromStdString(cmd.deck_list()); @@ -614,6 +1042,14 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman deckName = "Unnamed deck"; } + // The server derives the banner card and tags from the deck itself. Only the + // color identity must come from the client, since the server has no card + // database to compute it. All values are bounded to the column sizes. + const QString bannerCardName = deck.getBannerCard().name.left(255); + const QString bannerCardProvider = deck.getBannerCard().providerId.left(32); + const QString tagsJson = serializeDeckTags(deck.getTags()); + const QString colorIdentity = sanitizeColorIdentity(nameFromStdString(cmd.color_identity())); + if (cmd.has_path()) { int folderId = getDeckPathId(nameFromStdString(cmd.path())); if (folderId == -1) { @@ -622,38 +1058,74 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckUpload(const Comman QSqlQuery *query = sqlInterface->prepareQuery("insert into {prefix}_decklist_files (id_folder, id_user, name, upload_time, " - "content) values(:id_folder, :id_user, :name, NOW(), :content)"); + "content, is_public, banner_card_name, banner_card_provider, color_identity, " + "tags) values(:id_folder, :id_user, :name, NOW(), :content, :is_public, " + ":banner_card_name, :banner_card_provider, :color_identity, :tags)"); query->bindValue(":id_folder", folderId); query->bindValue(":id_user", userInfo->id()); query->bindValue(":name", deckName); query->bindValue(":content", deckStr); - sqlInterface->execSqlQuery(query); + query->bindValue(":is_public", cmd.has_is_public() && cmd.is_public() ? 1 : 0); + query->bindValue(":banner_card_name", bannerCardName); + query->bindValue(":banner_card_provider", bannerCardProvider); + query->bindValue(":color_identity", colorIdentity); + query->bindValue(":tags", tagsJson); + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespContextError; + } Response_DeckUpload *re = new Response_DeckUpload; ServerInfo_DeckStorage_TreeItem *fileInfo = re->mutable_new_file(); fileInfo->set_id(query->lastInsertId().toInt()); fileInfo->set_name(deckName.toStdString()); fileInfo->mutable_file()->set_creation_time(QDateTime::currentDateTime().toSecsSinceEpoch()); + fileInfo->mutable_file()->set_is_public(cmd.has_is_public() && cmd.is_public()); rc.setResponseExtension(re); } else if (cmd.has_deck_id()) { - QSqlQuery *query = - sqlInterface->prepareQuery("update {prefix}_decklist_files set name=:name, upload_time=NOW(), " - "content=:content where id = :id_deck and id_user = :id_user"); + QString updateQuery = "update {prefix}_decklist_files set name=:name, upload_time=NOW(), content=:content, " + "banner_card_name=:banner_card_name, banner_card_provider=:banner_card_provider, " + "color_identity=:color_identity, tags=:tags"; + if (cmd.has_is_public()) { + updateQuery += ", is_public=:is_public"; + } + updateQuery += " where id = :id_deck and id_user = :id_user"; + + QSqlQuery *query = sqlInterface->prepareQuery(updateQuery); query->bindValue(":id_deck", cmd.deck_id()); query->bindValue(":id_user", userInfo->id()); query->bindValue(":name", deckName); query->bindValue(":content", deckStr); - sqlInterface->execSqlQuery(query); + query->bindValue(":banner_card_name", bannerCardName); + query->bindValue(":banner_card_provider", bannerCardProvider); + query->bindValue(":color_identity", colorIdentity); + query->bindValue(":tags", tagsJson); + if (cmd.has_is_public()) { + query->bindValue(":is_public", cmd.is_public() ? 1 : 0); + } + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespContextError; + } if (query->numRowsAffected() == 0) { return Response::RespNameNotFound; } + QSqlQuery *visibilityQuery = + sqlInterface->prepareQuery("select is_public from {prefix}_decklist_files where id = :id and " + "id_user = :id_user"); + visibilityQuery->bindValue(":id", cmd.deck_id()); + visibilityQuery->bindValue(":id_user", userInfo->id()); + if (!sqlInterface->execSqlQuery(visibilityQuery)) { + return Response::RespContextError; + } + const bool isPublic = visibilityQuery->next() && visibilityQuery->value(0).toBool(); + Response_DeckUpload *re = new Response_DeckUpload; ServerInfo_DeckStorage_TreeItem *fileInfo = re->mutable_new_file(); fileInfo->set_id(cmd.deck_id()); fileInfo->set_name(deckName.toStdString()); fileInfo->mutable_file()->set_creation_time(QDateTime::currentDateTime().toSecsSinceEpoch()); + fileInfo->mutable_file()->set_is_public(isPublic); rc.setResponseExtension(re); } else { return Response::RespInvalidData; @@ -684,6 +1156,248 @@ Response::ResponseCode AbstractServerSocketInterface::cmdDeckDownload(const Comm return Response::RespOk; } +namespace +{ +/** @brief Builds a cryptographically random, URL-safe share token. */ +QString generateShareToken() +{ + QByteArray bytes(32, Qt::Uninitialized); + QRandomGenerator::system()->fillRange(reinterpret_cast(bytes.data()), bytes.size() / sizeof(quint32)); + return QString::fromLatin1(bytes.toBase64(QByteArray::Base64UrlEncoding | QByteArray::OmitTrailingEquals)); +} + +/** @brief Extracts the share metadata for a deck, materializing its content. */ +DeckShareItemRecord makeShareItemFromDeck(const DeckList &deck, const QString &colorIdentity) +{ + DeckShareItemRecord item; + item.name = deck.getName(); + if (item.name.isEmpty()) { + item.name = "Unnamed deck"; + } + item.tags = deck.getTags(); + item.bannerCard = deck.getBannerCard().name; + item.gameFormat = deck.getGameFormat(); + item.colorIdentity = sanitizeColorIdentity(colorIdentity); + item.content = deck.writeToString_Native(); + return item; +} +} // namespace + +Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareCreate(const Command_DeckShareCreate &cmd, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + sqlInterface->checkSql(); + + const int maxItems = servatrice->getDeckShareMaxDecksPerShare(); + if (maxItems > 0 && cmd.items_size() > maxItems) { + return Response::RespInvalidData; + } + + // Per-user rate limit so share links cannot be used to build an unbounded + // word-of-mouth leak of public decks. + const int maxSharesPerDay = servatrice->getDeckShareMaxSharesPerDay(); + if (maxSharesPerDay > 0) { + QSqlQuery *countQuery = sqlInterface->prepareQuery("select count(*) from {prefix}_deck_share where " + "created_by = :created_by and created_at >= " + "DATE_SUB(NOW(), INTERVAL 1 DAY)"); + countQuery->bindValue(":created_by", userInfo->id()); + if (!sqlInterface->execSqlQuery(countQuery) || !countQuery->next()) { + return Response::RespContextError; + } + if (countQuery->value(0).toInt() >= maxSharesPerDay) { + return Response::RespTooManyRequests; + } + } + + QList items; + if (cmd.items_size() > 0) { + for (const DeckShareItem &shareItem : cmd.items()) { + if (shareItem.has_deck_list()) { + DeckList deck; + if (!deck.loadFromString_Native(fileFromStdString(shareItem.deck_list()))) { + return Response::RespContextError; + } + items.append(makeShareItemFromDeck(deck, nameFromStdString(shareItem.color_identity()))); + } else if (shareItem.has_deck_id()) { + DeckList *deck; + try { + deck = sqlInterface->getDeckFromDatabase(shareItem.deck_id(), userInfo->id()); + } catch (Response::ResponseCode &r) { + return r; + } + items.append(makeShareItemFromDeck(*deck, nameFromStdString(shareItem.color_identity()))); + delete deck; + } else { + return Response::RespInvalidData; + } + } + } else if (cmd.has_folder_path()) { + const int folderId = getDeckPathId(nameFromStdString(cmd.folder_path())); + if (folderId == -1) { + return Response::RespNameNotFound; + } + + // Drain the deck list before resolving each deck: getDeckFromDatabase + // issues its own query on the same cached statement set. + QSqlQuery *query = + sqlInterface->prepareQuery("select id, color_identity from {prefix}_decklist_files where id_folder = " + ":id_folder and id_user = :id_user"); + query->bindValue(":id_folder", folderId); + query->bindValue(":id_user", userInfo->id()); + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespContextError; + } + QList> deckRows; + while (query->next()) { + deckRows.append({query->value(0).toInt(), query->value(1).toString()}); + } + for (const auto &[deckId, colorIdentity] : deckRows) { + DeckList *deck; + try { + deck = sqlInterface->getDeckFromDatabase(deckId, userInfo->id()); + } catch (Response::ResponseCode &r) { + return r; + } + items.append(makeShareItemFromDeck(*deck, colorIdentity)); + delete deck; + } + } else { + return Response::RespInvalidData; + } + + if (items.isEmpty() || (maxItems > 0 && items.size() > maxItems)) { + return Response::RespInvalidData; + } + + QString shareName = nameFromStdString(cmd.name()); + if (shareName.isEmpty()) { + shareName = "Shared decks"; + } + + const QString token = generateShareToken(); + qint64 expiresAt = 0; + if (!sqlInterface->createDeckShare(token, shareName, userInfo->id(), items, servatrice->getDeckShareExpiryDays(), + expiresAt)) { + return Response::RespInvalidData; + } + + Response_DeckShareCreate *re = new Response_DeckShareCreate; + re->set_token(token.toStdString()); + re->set_expires_at(expiresAt); + re->set_item_count(items.size()); + rc.setResponseExtension(re); + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareListMine(const Command_DeckShareListMine & /*cmd*/, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + sqlInterface->checkSql(); + + QList shares; + if (!sqlInterface->getDeckSharesForUser(userInfo->id(), shares)) { + return Response::RespContextError; + } + + Response_DeckShareListMine *re = new Response_DeckShareListMine; + for (const DeckShareSummaryRecord &share : shares) { + ServerInfo_DeckShareSummary *summary = re->add_shares(); + summary->set_id(share.id); + summary->set_name(share.name.toStdString()); + summary->set_creation_time(share.creationTime); + summary->set_expires_at(share.expiresAt); + summary->set_item_count(share.itemCount); + } + rc.setResponseExtension(re); + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareRemove(const Command_DeckShareRemove &cmd, + ResponseContainer & /*rc*/) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + if (!cmd.has_share_id()) { + return Response::RespInvalidData; + } + + sqlInterface->checkSql(); + + if (!sqlInterface->deleteDeckShare(cmd.share_id(), userInfo->id())) { + return Response::RespNameNotFound; + } + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareList(const Command_DeckShareList &cmd, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + sqlInterface->checkSql(); + + QString name; + qint64 expiresAt = 0; + QList items; + if (!sqlInterface->getDeckShareList(nameFromStdString(cmd.token()), name, expiresAt, items)) { + return Response::RespNameNotFound; + } + + Response_DeckShareList *re = new Response_DeckShareList; + re->set_name(name.toStdString()); + re->set_expires_at(expiresAt); + for (const DeckShareItemRecord &item : items) { + ServerInfo_DeckShareItem *itemInfo = re->add_items(); + itemInfo->set_id(item.id); + itemInfo->set_name(item.name.toStdString()); + for (const QString &tag : item.tags) { + itemInfo->add_tags(tag.toStdString()); + } + itemInfo->set_banner_card(item.bannerCard.toStdString()); + itemInfo->set_game_format(item.gameFormat.toStdString()); + itemInfo->set_color_identity(item.colorIdentity.toStdString()); + } + rc.setResponseExtension(re); + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdDeckShareDownload(const Command_DeckShareDownload &cmd, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + sqlInterface->checkSql(); + + QString content; + if (!sqlInterface->getDeckShareItem(nameFromStdString(cmd.token()), cmd.item_id(), content)) { + return Response::RespNameNotFound; + } + + Response_DeckShareDownload *re = new Response_DeckShareDownload; + re->set_deck(content.toStdString()); + rc.setResponseExtension(re); + + return Response::RespOk; +} + Response::ResponseCode AbstractServerSocketInterface::cmdReplayList(const Command_ReplayList & /*cmd*/, ResponseContainer &rc) { @@ -896,6 +1610,10 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReplayGetCode(const Com Response::ResponseCode AbstractServerSocketInterface::cmdReplaySubmitCode(const Command_ReplaySubmitCode &cmd, ResponseContainer & /*rc*/) { + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + // code is of the form - QString code = QString::fromStdString(cmd.replay_code()); QStringList split = code.split("-"); @@ -960,12 +1678,13 @@ Response::ResponseCode AbstractServerSocketInterface::cmdReplaySubmitCode(const // MODERATOR FUNCTIONS. // May be called by admins and moderators. Permission is checked by the calling function. Response::ResponseCode AbstractServerSocketInterface::cmdGetLogHistory(const Command_ViewLogHistory &cmd, - ResponseContainer &rc) + ResponseContainer &rc, + bool allowPrivateChat) { QList messageList; QString userName = nameFromStdString(cmd.user_name()); - QString ipAddress = nameFromStdString(cmd.ip_address()); + QString ipAddress = allowPrivateChat ? nameFromStdString(cmd.ip_address()) : QString(); QString gameName = nameFromStdString(cmd.game_name()); QString gameID = nameFromStdString(cmd.game_id()); QString message = textFromStdString(cmd.message()); @@ -980,11 +1699,21 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetLogHistory(const Com if (nameFromStdString(cmd.log_location(i)).simplified() == "game") { gameType = true; } - if (nameFromStdString(cmd.log_location(i)).simplified() == "chat") { + if (nameFromStdString(cmd.log_location(i)).simplified() == "chat" && allowPrivateChat) { chatType = true; } } + // For callers that must not see private conversations, never leave the + // target-type filter empty: if the request only asked for "chat" (or for + // nothing at all) the query below would carry no target_type restriction + // and would return every row, private messages included. Fall back to the + // game/room diagnostics the caller is allowed to see. + if (!allowPrivateChat && !gameType && !roomType) { + gameType = true; + roomType = true; + } + int dateRange = cmd.date_range(); int maximumResults = cmd.maximum_results(); @@ -994,7 +1723,11 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetLogHistory(const Com QListIterator messageIterator(sqlInterface->getMessageLogHistory( userName, ipAddress, gameName, gameID, message, chatType, gameType, roomType, dateRange, maximumResults)); while (messageIterator.hasNext()) { - re->add_log_message()->CopyFrom(messageIterator.next()); + ServerInfo_ChatMessage chatMessage = messageIterator.next(); + if (!allowPrivateChat) { + chatMessage.clear_sender_ip(); + } + re->add_log_message()->CopyFrom(chatMessage); } } else { ServerInfo_ChatMessage chatMessage; @@ -1063,9 +1796,10 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetWarnList(const Comma Response_WarnList *re = new Response_WarnList; QString officialWarnings = settingsCache->value("server/officialwarnings").toString(); - QStringList warningsList = officialWarnings.split(",", Qt::SkipEmptyParts); - for (const QString &warning : warningsList) { - re->add_warning(warning.toStdString()); + const QList categories = parseWarningCategories(officialWarnings); + for (const WarningCategory &category : categories) { + re->add_warning(category.name.toStdString()); + re->add_warning_il(category.startingIl); } re->set_user_name(nameFromStdString(cmd.user_name()).toStdString()); re->set_user_clientid(nameFromStdString(cmd.user_clientid()).toStdString()); @@ -1249,6 +1983,1083 @@ Response::ResponseCode AbstractServerSocketInterface::cmdBanFromServer(const Com return Response::RespOk; } +Response::ResponseCode AbstractServerSocketInterface::cmdReportList(const Command_ReportList &cmd, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const bool unresolvedOnly = cmd.unresolved_only(); + const int offset = static_cast(cmd.offset()); + const int limit = qMin(static_cast(cmd.limit()), 1000); + + // Columns: 0=id, 1=reporter_name, 2=reported_user_name, 3=game_id, + // 4=category, 5=description, 6=created_at, 7=status, + // 8=resolution_note, 9=assigned_mod_name, + // 10=room_id, 11=replay_id + QString whereClause; + if (unresolvedOnly) { + whereClause = "WHERE r.status = 'open' OR r.status = 'assigned' "; + } + + // Total count query + QSqlQuery *countQuery = sqlInterface->prepareQuery("SELECT COUNT(*) FROM {prefix}_reports r " + whereClause); + int totalCount = 0; + if (sqlInterface->execSqlQuery(countQuery) && countQuery->next()) { + totalCount = countQuery->value(0).toInt(); + } + + QString queryStr = "SELECT r.id, r.reporter_name, r.reported_user_name, r.game_id, " + "r.category, r.description, r.created_at, r.status, " + "r.resolution_note, u.name AS assigned_mod_name, r.room_id, " + "(SELECT id FROM {prefix}_replays WHERE id_game = r.game_id ORDER BY id DESC LIMIT 1) " + "AS replay_id " + "FROM {prefix}_reports r " + "LEFT JOIN {prefix}_users u ON r.assigned_to = u.id "; + + if (unresolvedOnly) { + queryStr += "WHERE r.status = 'open' OR r.status = 'assigned' "; + } + + queryStr += "ORDER BY r.created_at DESC LIMIT :limit OFFSET :offset"; + + QSqlQuery *query = sqlInterface->prepareQuery(queryStr); + query->bindValue(":limit", limit); + query->bindValue(":offset", offset); + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespInternalError; + } + + Response_ReportList *re = new Response_ReportList; + re->set_total_count(totalCount); + + while (query->next()) { + ServerInfo_Report *info = re->add_reports(); + + info->set_report_id(query->value(0).toInt()); + info->set_reporter_name(query->value(1).toString().toStdString()); + info->set_reported_user_name(query->value(2).toString().toStdString()); + + if (!query->value(3).isNull()) { + info->set_game_id(query->value(3).toInt()); + } + + info->set_category(query->value(4).toString().toStdString()); + info->set_description(query->value(5).toString().toStdString()); + info->set_report_time(query->value(6).toDateTime().toSecsSinceEpoch()); + info->set_status(query->value(7).toString().toStdString()); + + if (!query->value(8).isNull()) { + info->set_resolution_note(query->value(8).toString().toStdString()); + } + + if (!query->value(9).isNull()) { + info->set_assigned_mod_name(query->value(9).toString().toStdString()); + } + + if (!query->value(10).isNull()) { + info->set_room_id(query->value(10).toInt()); + } + + if (!query->value(11).isNull()) { + info->set_replay_id(query->value(11).toInt()); + } + } + + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdReportAssign(const Command_ReportAssign &cmd, + ResponseContainer & /*rc*/) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + int reportId = cmd.report_id(); + int modId = userInfo->id(); + + QSqlQuery *lookupQuery = sqlInterface->prepareQuery("SELECT status FROM {prefix}_reports WHERE id = :id"); + lookupQuery->bindValue(":id", reportId); + + if (!sqlInterface->execSqlQuery(lookupQuery)) { + return Response::RespInternalError; + } + + if (!lookupQuery->next()) { + return Response::RespNameNotFound; + } + + if (lookupQuery->value(0).toString() != "open") { + return Response::RespInvalidData; + } + + QSqlQuery *query = sqlInterface->prepareQuery("UPDATE {prefix}_reports " + "SET status = 'assigned', assigned_to = :mod_id " + "WHERE id = :id AND status = 'open'"); + + query->bindValue(":mod_id", modId); + query->bindValue(":id", reportId); + + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespInternalError; + } + + if (query->numRowsAffected() == 0) { + // The report was taken by another moderator between the lookup and this update. + return Response::RespInvalidData; + } + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdReportResolve(const Command_ReportResolve &cmd, + ResponseContainer & /*rc*/) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + int reportId = cmd.report_id(); + QString note = textFromStdString(cmd.resolution_note()); + bool dismissed = cmd.dismissed(); + + QString newStatus = dismissed ? "dismissed" : "resolved"; + + QSqlQuery *query = sqlInterface->prepareQuery("UPDATE {prefix}_reports " + "SET status = :status, resolution_note = :note, " + "resolution_time = NOW(), resolved_by = :mod_id " + "WHERE id = :id AND status IN ('open', 'assigned')"); + + query->bindValue(":status", newStatus); + query->bindValue(":note", note); + query->bindValue(":mod_id", userInfo->id()); + query->bindValue(":id", reportId); + + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespInternalError; + } + + if (query->numRowsAffected() == 0) { + return Response::RespInvalidData; + } + + sqlInterface->addAuditRecord(QString::number(reportId), this->getAddress(), + QString::fromStdString(userInfo->clientid()), + dismissed ? "REPORT_DISMISSED" : "REPORT_RESOLVED", + QString("Report #%1 %2").arg(reportId).arg(newStatus), true); + + // Notifying the reporter is best-effort: the resolve already succeeded. If any of the lookup + // queries below fail, the report is simply left with notified = 0 and the notification is + // delivered later via sendPendingReportNotifications. + QSqlQuery *lookupQuery = + sqlInterface->prepareQuery("SELECT reporter_id, reported_user_name FROM {prefix}_reports WHERE id = :id"); + lookupQuery->bindValue(":id", reportId); + + QString reporterName; + QString reportedUser; + if (sqlInterface->execSqlQuery(lookupQuery) && lookupQuery->next()) { + int reporterId = lookupQuery->value(0).toInt(); + reportedUser = lookupQuery->value(1).toString(); + + QSqlQuery *nameQuery = sqlInterface->prepareQuery("SELECT name FROM {prefix}_users WHERE id = :id"); + nameQuery->bindValue(":id", reporterId); + if (sqlInterface->execSqlQuery(nameQuery) && nameQuery->next()) { + reporterName = nameQuery->value(0).toString(); + } + } + + const QString ownName = QString::fromStdString(userInfo->name()); + if (!reporterName.isEmpty()) { + if (reporterName == ownName) { + // Resolving your own report: no self-notification needed, but mark it as notified + // so it is not delivered on the next login via sendPendingReportNotifications. + QSqlQuery *notifiedQuery = + sqlInterface->prepareQuery("UPDATE {prefix}_reports SET notified = 1 WHERE id = :id"); + notifiedQuery->bindValue(":id", reportId); + sqlInterface->execSqlQuery(notifiedQuery); + } else { + QReadLocker clientsLocker(&servatrice->clientsLock); + AbstractServerSocketInterface *reporter = + static_cast(server->getUsers().value(reporterName)); + if (reporter) { + Event_NotifyUser event; + event.set_type(Event_NotifyUser::REPORT_RESOLVED); + event.set_custom_title(dismissed ? tr("Report Dismissed").toStdString() + : tr("Report Resolved").toStdString()); + + QString content = dismissed ? tr("Your report about %1 has been dismissed.").arg(reportedUser) + : tr("Your report about %1 has been resolved.").arg(reportedUser); + if (!note.isEmpty()) { + content += "\n" + tr("Note: %1").arg(note); + } + event.set_custom_content(content.toStdString()); + + SessionEvent *se = reporter->prepareSessionEvent(event); + reporter->sendProtocolItem(*se); + delete se; + + // Only mark the report as notified if the reporter is still connected; otherwise the + // notification is delivered on their next login via sendPendingReportNotifications. + QSqlQuery *notifiedQuery = + sqlInterface->prepareQuery("UPDATE {prefix}_reports SET notified = 1 WHERE id = :id"); + notifiedQuery->bindValue(":id", reportId); + sqlInterface->execSqlQuery(notifiedQuery); + } + } + } + + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdReportUserInfo(const Command_ReportUserInfo &cmd, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + QString userName = nameFromStdString(cmd.user_name()); + + QSqlQuery *userQuery = sqlInterface->prepareQuery("SELECT u.admin, u.active, u.registrationDate, u.adminnotes " + "FROM {prefix}_users u " + "WHERE u.name = :name"); + userQuery->bindValue(":name", userName); + + if (!sqlInterface->execSqlQuery(userQuery)) { + return Response::RespInternalError; + } + + if (!userQuery->next()) { + return Response::RespNameNotFound; + } + + Response_ReportUserInfo *re = new Response_ReportUserInfo; + re->set_user_name(cmd.user_name()); + + re->set_is_admin(userQuery->value(0).toBool()); + re->set_is_active(userQuery->value(1).toBool()); + re->set_registration_time(userQuery->value(2).toDateTime().toSecsSinceEpoch()); + + if (!userQuery->value(3).isNull()) { + re->set_admin_notes(userQuery->value(3).toString().toStdString()); + } + + QSqlQuery *reportCountQuery = + sqlInterface->prepareQuery("SELECT COUNT(*) FROM {prefix}_reports WHERE reported_user_name = :name"); + reportCountQuery->bindValue(":name", userName); + if (sqlInterface->execSqlQuery(reportCountQuery) && reportCountQuery->next()) { + re->set_total_reports(reportCountQuery->value(0).toInt()); + } + + QSqlQuery *banCountQuery = sqlInterface->prepareQuery("SELECT COUNT(*) FROM {prefix}_bans WHERE user_name = :name"); + banCountQuery->bindValue(":name", userName); + if (sqlInterface->execSqlQuery(banCountQuery) && banCountQuery->next()) { + re->set_total_bans(banCountQuery->value(0).toInt()); + } + + QSqlQuery *warnCountQuery = + sqlInterface->prepareQuery("SELECT COUNT(*) FROM {prefix}_warnings WHERE user_name = :name"); + warnCountQuery->bindValue(":name", userName); + if (sqlInterface->execSqlQuery(warnCountQuery) && warnCountQuery->next()) { + re->set_total_warns(warnCountQuery->value(0).toInt()); + } + + QSqlQuery *lastLoginQuery = sqlInterface->prepareQuery("SELECT UNIX_TIMESTAMP(a.last_login) " + "FROM {prefix}_user_analytics a " + "JOIN {prefix}_users u ON u.id = a.id " + "WHERE u.name = :name"); + lastLoginQuery->bindValue(":name", userName); + if (sqlInterface->execSqlQuery(lastLoginQuery) && lastLoginQuery->next() && !lastLoginQuery->value(0).isNull()) { + re->set_last_login(lastLoginQuery->value(0).toLongLong()); + } + + QSqlQuery *recentQuery = + sqlInterface->prepareQuery("SELECT r.id, r.reporter_name, r.reported_user_name, r.game_id, " + "r.category, r.description, r.created_at, r.status, " + "r.resolution_note, ru.name AS assigned_mod_name " + "FROM {prefix}_reports r " + "LEFT JOIN {prefix}_users ru ON r.assigned_to = ru.id " + "WHERE r.reported_user_name = :name " + "ORDER BY r.created_at DESC LIMIT 10"); + recentQuery->bindValue(":name", userName); + + if (sqlInterface->execSqlQuery(recentQuery)) { + while (recentQuery->next()) { + ServerInfo_Report *info = re->add_recent_reports(); + info->set_report_id(recentQuery->value(0).toInt()); + info->set_reporter_name(recentQuery->value(1).toString().toStdString()); + info->set_reported_user_name(recentQuery->value(2).toString().toStdString()); + + if (!recentQuery->value(3).isNull()) { + info->set_game_id(recentQuery->value(3).toInt()); + } + + info->set_category(recentQuery->value(4).toString().toStdString()); + info->set_description(recentQuery->value(5).toString().toStdString()); + info->set_report_time(recentQuery->value(6).toDateTime().toSecsSinceEpoch()); + info->set_status(recentQuery->value(7).toString().toStdString()); + + if (!recentQuery->value(8).isNull()) { + info->set_resolution_note(recentQuery->value(8).toString().toStdString()); + } + + if (!recentQuery->value(9).isNull()) { + info->set_assigned_mod_name(recentQuery->value(9).toString().toStdString()); + } + } + } + + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdGetServerStats(const Command_GetServerStats & /*cmd */, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + // Servatrice::statusUpdate() periodically snapshots server health into the + // uptime table. Serve the freshest snapshot for this server. + const auto snapshot = sqlInterface->getLatestUptimeSnapshot(servatrice->getServerID()); + if (!snapshot.valid) { + // No snapshot yet (fresh server, or statusUpdate() has not ticked). + return Response::RespInternalError; + } + + auto *re = new Response_GetServerStats; + re->set_users_count(snapshot.usersCount); + re->set_mods_count(snapshot.modsCount); + re->set_games_count(snapshot.gamesCount); + re->set_tx_bytes(snapshot.txBytes); + re->set_rx_bytes(snapshot.rxBytes); + re->set_uptime_secs(snapshot.uptimeSecs); + re->set_timest(snapshot.timest); + + // Live metrics from the in-process MetricsRegistry (resets on server restart) + re->set_cards_in_games(static_cast(servatrice->getCardsInGamesTotal())); + re->set_eventloop_stalls_total(static_cast(servatrice->getEventLoopStallsTotal())); + re->set_eventloop_last_stall_ms(static_cast(servatrice->getEventLoopLastStallMs())); + re->set_eventloop_max_stall_ms(static_cast(servatrice->getEventLoopMaxStallMs())); + re->set_total_commands(static_cast(servatrice->getMetricsRegistry().totalCommands())); + re->set_total_command_time_ms( + static_cast(servatrice->getMetricsRegistry().totalTimeMs())); + re->set_active_command_types(servatrice->getMetricsRegistry().activeTypeCount()); + + const auto gameStart = servatrice->getMetricsRegistry().getGameStartSnapshot(); + re->set_game_start_count(static_cast(gameStart.count)); + re->set_game_start_total_ms(static_cast(gameStart.totalMs)); + + // Per-command breakdown: resolve protobuf extension names via the descriptor pool + static const char *messageNames[] = {"SessionCommand", "RoomCommand", "GameCommand", + "ModeratorCommand", "AdminCommand", "DeveloperCommand"}; + const auto activeStats = servatrice->getMetricsRegistry().collectActiveStats(); + for (const auto &stat : activeStats) { + const int kind = stat.typeId / MetricsRegistry::KindStride; + const int number = stat.typeId % MetricsRegistry::KindStride; + + QString label; + if (kind >= 0 && kind < MetricsRegistry::NumKinds) { + const google::protobuf::DescriptorPool *pool = google::protobuf::DescriptorPool::generated_pool(); + const google::protobuf::Descriptor *message = pool->FindMessageTypeByName(messageNames[kind]); + const google::protobuf::FieldDescriptor *extension = + message ? pool->FindExtensionByNumber(message, number) : nullptr; + if (extension) { + label = QString::fromLatin1(MetricsRegistry::KindNames[kind]) + QStringLiteral("/") + + QString::fromStdString(std::string(extension->message_type()->name())); + } + } + if (label.isEmpty()) { + label = QString::number(stat.typeId); + } + + CommandStats *cs = re->add_command_stats(); + cs->set_kind_index(static_cast(kind)); + cs->set_extension_number(static_cast(number)); + cs->set_command_name(label.toStdString()); + cs->set_count(static_cast(stat.count)); + cs->set_total_ms(static_cast(stat.totalMs)); + } + + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdReportStats(const Command_ReportStats & /*cmd */, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + if (const auto cached = servatrice->getCachedReportStats()) { + rc.setResponseExtension(new Response_ReportStats(*cached)); + return Response::RespOk; + } + + Response_ReportStats *re = new Response_ReportStats; + + QSqlQuery *overviewQuery = sqlInterface->prepareQuery( + "SELECT COUNT(*) AS total, " + "SUM(status IN ('open', 'assigned')) AS pending, " + "SUM(status = 'assigned') AS assigned, " + "SUM(status IN ('resolved', 'dismissed')) AS resolved, " + "SUM(created_at >= DATE_SUB(NOW(), INTERVAL 1 DAY)) AS last24h, " + "SUM(created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)) AS last7d, " + "SUM(created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)) AS last30d, " + "SUM(created_at >= DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY)) AS this_week " + "FROM {prefix}_reports"); + if (!sqlInterface->execSqlQuery(overviewQuery) || !overviewQuery->next()) { + delete re; + return Response::RespInternalError; + } + + re->set_total_reports(overviewQuery->value(0).toInt()); + re->set_total_pending(overviewQuery->value(1).toInt()); + re->set_total_assigned(overviewQuery->value(2).toInt()); + re->set_total_resolved(overviewQuery->value(3).toInt()); + re->set_reports_last_24h(overviewQuery->value(4).toInt()); + re->set_reports_last_7d(overviewQuery->value(5).toInt()); + re->set_reports_last_30d(overviewQuery->value(6).toInt()); + re->set_reports_this_week(overviewQuery->value(7).toInt()); + + bool allQueriesOk = true; + + QSqlQuery *lastWeekQuery = + sqlInterface->prepareQuery("SELECT COUNT(*) FROM {prefix}_reports WHERE created_at >= DATE_SUB(CURDATE(), " + "INTERVAL WEEKDAY(CURDATE()) + 7 DAY) " + "AND created_at < DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY)"); + if (sqlInterface->execSqlQuery(lastWeekQuery) && lastWeekQuery->next()) { + re->set_reports_last_week(lastWeekQuery->value(0).toInt()); + } else { + allQueriesOk = false; + } + + QSqlQuery *avgResQuery = sqlInterface->prepareQuery( + "SELECT AVG(TIMESTAMPDIFF(HOUR, created_at, resolution_time)) " + "FROM {prefix}_reports WHERE status IN ('resolved','dismissed') AND resolution_time IS NOT NULL"); + if (sqlInterface->execSqlQuery(avgResQuery) && avgResQuery->next()) { + if (!avgResQuery->value(0).isNull()) { + re->set_avg_resolution_hours(avgResQuery->value(0).toDouble()); + } + } else { + allQueriesOk = false; + } + + QSqlQuery *catQuery = sqlInterface->prepareQuery( + "SELECT category, COUNT(*) AS cnt FROM {prefix}_reports GROUP BY category ORDER BY cnt DESC LIMIT 10"); + if (sqlInterface->execSqlQuery(catQuery)) { + while (catQuery->next()) { + ReportCategoryCount *cc = re->add_category_counts(); + cc->set_category(catQuery->value(0).toString().toStdString()); + cc->set_count(catQuery->value(1).toInt()); + } + } else { + allQueriesOk = false; + } + + QSqlQuery *topReportedQuery = + sqlInterface->prepareQuery("SELECT reported_user_name, COUNT(*) AS cnt FROM {prefix}_reports " + "GROUP BY reported_user_name ORDER BY cnt DESC LIMIT 10"); + if (sqlInterface->execSqlQuery(topReportedQuery)) { + while (topReportedQuery->next()) { + ReportTopUser *tu = re->add_top_reported_users(); + tu->set_user_name(topReportedQuery->value(0).toString().toStdString()); + tu->set_count(topReportedQuery->value(1).toInt()); + } + } else { + allQueriesOk = false; + } + + QSqlQuery *topReporterQuery = + sqlInterface->prepareQuery("SELECT reporter_name, COUNT(*) AS cnt FROM {prefix}_reports " + "GROUP BY reporter_name ORDER BY cnt DESC LIMIT 10"); + if (sqlInterface->execSqlQuery(topReporterQuery)) { + while (topReporterQuery->next()) { + ReportTopUser *tu = re->add_top_reporters(); + tu->set_user_name(topReporterQuery->value(0).toString().toStdString()); + tu->set_count(topReporterQuery->value(1).toInt()); + } + } else { + allQueriesOk = false; + } + + if (allQueriesOk) { + servatrice->cacheReportStats(*re); + } + + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdGetUserSessions(const Command_GetUserSessions &cmd, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const QString userName = nameFromStdString(cmd.user_name()); + if (userName.isEmpty()) { + return Response::RespContextError; + } + + Response_UserSessions *re = new Response_UserSessions; + const int limit = qMin(static_cast(cmd.limit()), 500); + const QList sessions = sqlInterface->getUserSessions(userName, limit); + for (const ServerInfo_UserSession &session : sessions) { + re->add_sessions()->CopyFrom(session); + } + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdGetUserAlts(const Command_GetUserAlts &cmd, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const QString userName = nameFromStdString(cmd.user_name()); + if (userName.isEmpty()) { + return Response::RespContextError; + } + + Response_UserAlts *re = new Response_UserAlts; + const QList alts = sqlInterface->getUserAlts(userName); + for (const ServerInfo_UserAlt &alt : alts) { + re->add_alts()->CopyFrom(alt); + } + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdGetModeratorLastLogins(const Command_GetModeratorLastLogins &, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + Response_ModeratorLastLogins *re = new Response_ModeratorLastLogins; + const QList logins = sqlInterface->getModeratorLastLogins(); + for (const ServerInfo_ModeratorLogin &login : logins) { + re->add_logins()->CopyFrom(login); + } + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdResetUserPassword(const Command_ResetUserPassword &cmd, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const QString userName = nameFromStdString(cmd.user_name()).simplified(); + if (userName.isEmpty()) { + return Response::RespContextError; + } + + // Look up the target user's privilege level to prevent escalation. + QSqlQuery *privQuery = sqlInterface->prepareQuery("SELECT admin FROM {prefix}_users WHERE name = :name"); + privQuery->bindValue(":name", userName); + if (!sqlInterface->execSqlQuery(privQuery) || !privQuery->next()) { + return Response::RespNameNotFound; + } + const int targetAdmin = privQuery->value(0).toInt(); + const bool targetIsAdmin = targetAdmin & 1; + const bool targetIsMod = targetAdmin & 2; + + const bool callerIsAdmin = userInfo->user_level() & ServerInfo_User::IsAdmin; + + // A moderator must not reset the password of an admin or another moderator. + if (!callerIsAdmin && (targetIsAdmin || targetIsMod)) { + return Response::RespAccessDenied; + } + + const QString tempPassword = PasswordHasher::generateRandomSalt(); + if (!sqlInterface->changeUserPassword(userName, tempPassword, true)) { + return Response::RespInternalError; + } + sqlInterface->setForcePasswordChange(userName, true); + + sqlInterface->addAuditRecord(userName, this->getAddress(), QString::fromStdString(userInfo->clientid()), + "PASSWORD_RESET", "Admin password reset", true); + + // Notify the affected user if they are currently online. + QReadLocker clientsLocker(&servatrice->clientsLock); + AbstractServerSocketInterface *targetSession = + static_cast(server->getUsers().value(userName)); + if (targetSession) { + Event_NotifyUser event; + event.set_type(Event_NotifyUser::CUSTOM); + event.set_custom_title(tr("Password Reset").toStdString()); + event.set_custom_content(tr("An administrator has reset your password. Please log in with the new " + "password provided to you and change it immediately.") + .toStdString()); + SessionEvent *se = targetSession->prepareSessionEvent(event); + targetSession->sendProtocolItem(*se); + delete se; + } + + Response_ResetUserPassword *re = new Response_ResetUserPassword; + re->set_user_name(userName.toStdString()); + re->set_temporary_password(tempPassword.toStdString()); + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdRemoveUserAvatar(const Command_RemoveUserAvatar &cmd, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const QString userName = nameFromStdString(cmd.user_name()).simplified(); + if (userName.isEmpty()) { + return Response::RespContextError; + } + + if (!sqlInterface->removeUserAvatar(userName)) { + return Response::RespInternalError; + } + + sqlInterface->addAuditRecord(userName, this->getAddress(), QString::fromStdString(userInfo->clientid()), + "REMOVE_USER_AVATAR", "Moderator removed user avatar", true); + + Response_RemoveUserAvatar *re = new Response_RemoveUserAvatar; + re->set_user_name(userName.toStdString()); + rc.setResponseExtension(re); + return Response::RespOk; +} + +void AbstractServerSocketInterface::sendPendingReportNotifications(ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return; + } + + QSqlQuery *query = sqlInterface->prepareQuery("SELECT id, reported_user_name, status, resolution_note " + "FROM {prefix}_reports " + "WHERE reporter_id = :reporter_id AND notified = 0 " + "AND status IN ('resolved', 'dismissed')"); + query->bindValue(":reporter_id", userInfo->id()); + + if (!sqlInterface->execSqlQuery(query)) { + return; + } + + while (query->next()) { + const int reportId = query->value(0).toInt(); + const QString reportedUser = query->value(1).toString(); + const bool dismissed = query->value(2).toString() == "dismissed"; + const QString note = query->value(3).toString(); + + Event_NotifyUser event; + event.set_type(Event_NotifyUser::REPORT_RESOLVED); + event.set_custom_title(dismissed ? tr("Report Dismissed").toStdString() : tr("Report Resolved").toStdString()); + + QString content = dismissed ? tr("Your report about %1 has been dismissed.").arg(reportedUser) + : tr("Your report about %1 has been resolved.").arg(reportedUser); + if (!note.isEmpty()) { + content += "\n" + tr("Note: %1").arg(note); + } + event.set_custom_content(content.toStdString()); + + rc.enqueuePreResponseItem(ServerMessage::SESSION_EVENT, prepareSessionEvent(event)); + + QSqlQuery *notifiedQuery = + sqlInterface->prepareQuery("UPDATE {prefix}_reports SET notified = 1 WHERE id = :id"); + notifiedQuery->bindValue(":id", reportId); + sqlInterface->execSqlQuery(notifiedQuery); + } + + QSqlQuery *commentQuery = sqlInterface->prepareQuery("SELECT c.id, c.report_id, c.author_name, c.comment_text " + "FROM {prefix}_report_comments c " + "JOIN {prefix}_reports r ON r.id = c.report_id " + "WHERE c.notified = 0 " + "AND c.author_id != :user_id " + "AND ((c.is_moderator = 1 AND r.reporter_id = :user_id) " + "OR (c.is_moderator = 0 AND r.assigned_to = :user_id) " + "OR (c.is_moderator = 1 AND r.assigned_to = :user_id)) " + "ORDER BY c.created_at ASC"); + commentQuery->bindValue(":user_id", userInfo->id()); + + if (!sqlInterface->execSqlQuery(commentQuery)) { + return; + } + + while (commentQuery->next()) { + const qlonglong commentId = commentQuery->value(0).toLongLong(); + const int reportId = commentQuery->value(1).toInt(); + const QString authorName = commentQuery->value(2).toString(); + const QString commentText = commentQuery->value(3).toString(); + + Event_NotifyUser event; + event.set_type(Event_NotifyUser::REPORT_COMMENT); + event.set_custom_title(tr("New Comment on Report #%1").arg(reportId).toStdString()); + event.set_custom_content(tr("%1 commented:\n%2").arg(authorName, commentText).toStdString()); + + rc.enqueuePreResponseItem(ServerMessage::SESSION_EVENT, prepareSessionEvent(event)); + + QSqlQuery *notifiedQuery = + sqlInterface->prepareQuery("UPDATE {prefix}_report_comments SET notified = 1 WHERE id = :id"); + notifiedQuery->bindValue(":id", commentId); + sqlInterface->execSqlQuery(notifiedQuery); + } +} + +void AbstractServerSocketInterface::onLogin(ResponseContainer &rc) +{ + sendPendingReportNotifications(rc); +} + +Response::ResponseCode AbstractServerSocketInterface::cmdReportMyList(const Command_ReportMyList & /*cmd */, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + sendPendingReportNotifications(rc); + + QString queryStr = "SELECT r.id, r.reporter_name, r.reported_user_name, r.game_id, " + "r.category, r.description, r.created_at, r.status, " + "r.resolution_note, u.name AS assigned_mod_name, r.room_id, " + "(SELECT id FROM {prefix}_replays WHERE id_game = r.game_id ORDER BY id DESC LIMIT 1) " + "AS replay_id " + "FROM {prefix}_reports r " + "LEFT JOIN {prefix}_users u ON r.assigned_to = u.id " + "WHERE r.reporter_id = :reporter_id " + "ORDER BY r.created_at DESC LIMIT 200"; + + QSqlQuery *query = sqlInterface->prepareQuery(queryStr); + query->bindValue(":reporter_id", userInfo->id()); + + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespInternalError; + } + + Response_ReportMyList *re = new Response_ReportMyList; + + while (query->next()) { + ServerInfo_Report *info = re->add_reports(); + + info->set_report_id(query->value(0).toInt()); + info->set_reporter_name(query->value(1).toString().toStdString()); + info->set_reported_user_name(query->value(2).toString().toStdString()); + + if (!query->value(3).isNull()) { + info->set_game_id(query->value(3).toInt()); + } + + info->set_category(query->value(4).toString().toStdString()); + info->set_description(query->value(5).toString().toStdString()); + info->set_report_time(query->value(6).toDateTime().toSecsSinceEpoch()); + info->set_status(query->value(7).toString().toStdString()); + + if (!query->value(8).isNull()) { + info->set_resolution_note(query->value(8).toString().toStdString()); + } + + if (!query->value(9).isNull()) { + info->set_assigned_mod_name(query->value(9).toString().toStdString()); + } + + if (!query->value(10).isNull()) { + info->set_room_id(query->value(10).toInt()); + } + + if (!query->value(11).isNull()) { + info->set_replay_id(query->value(11).toInt()); + } + } + + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdReportDetails(const Command_ReportDetails &cmd, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const int reportId = cmd.report_id(); + + // Columns: 0=id, 1=reporter_id, 2=reporter_name, 3=reported_user_name, 4=game_id, + // 5=category, 6=description, 7=created_at, 8=status, + // 9=resolution_note, 10=assigned_mod_name, 11=chat_log, + // 12=room_id, 13=resolution_time, 14=replay_id + QString queryStr = "SELECT r.id, r.reporter_id, r.reporter_name, r.reported_user_name, r.game_id, " + "r.category, r.description, r.created_at, r.status, " + "r.resolution_note, u.name AS assigned_mod_name, r.chat_log, r.room_id, " + "r.resolution_time, " + "(SELECT id FROM {prefix}_replays WHERE id_game = r.game_id ORDER BY id DESC LIMIT 1) " + "AS replay_id " + "FROM {prefix}_reports r " + "LEFT JOIN {prefix}_users u ON r.assigned_to = u.id " + "WHERE r.id = :id"; + QSqlQuery *query = sqlInterface->prepareQuery(queryStr); + query->bindValue(":id", reportId); + + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespInternalError; + } + + if (!query->next()) { + return Response::RespNameNotFound; + } + + const bool isMod = userInfo->user_level() & ServerInfo_User::IsModerator; + const int reporterId = query->value(1).toInt(); + if (reporterId != userInfo->id() && !isMod) { + return Response::RespAccessDenied; + } + + ServerInfo_Report *info = new ServerInfo_Report; + info->set_report_id(query->value(0).toInt()); + info->set_reporter_name(query->value(2).toString().toStdString()); + info->set_reported_user_name(query->value(3).toString().toStdString()); + + if (!query->value(4).isNull()) { + info->set_game_id(query->value(4).toInt()); + } + + info->set_category(query->value(5).toString().toStdString()); + info->set_description(query->value(6).toString().toStdString()); + info->set_report_time(query->value(7).toDateTime().toSecsSinceEpoch()); + info->set_status(query->value(8).toString().toStdString()); + + if (!query->value(9).isNull()) { + info->set_resolution_note(query->value(9).toString().toStdString()); + } + + if (!query->value(10).isNull()) { + info->set_assigned_mod_name(query->value(10).toString().toStdString()); + } + + if (!query->value(11).isNull()) { + info->set_chat_log(query->value(11).toString().toStdString()); + } + + if (!query->value(12).isNull()) { + info->set_room_id(query->value(12).toInt()); + } + + if (!query->value(13).isNull()) { + info->set_resolution_time(query->value(13).toDateTime().toSecsSinceEpoch()); + } + + if (!query->value(14).isNull()) { + info->set_replay_id(query->value(14).toInt()); + } + + QSqlQuery *commentQuery = + sqlInterface->prepareQuery("SELECT c.author_name, c.comment_text, c.created_at, c.is_moderator " + "FROM {prefix}_report_comments c " + "WHERE c.report_id = :report_id ORDER BY c.created_at ASC"); + commentQuery->bindValue(":report_id", reportId); + + if (sqlInterface->execSqlQuery(commentQuery)) { + while (commentQuery->next()) { + ServerInfo_ReportComment *comment = info->add_comments(); + comment->set_author_name(commentQuery->value(0).toString().toStdString()); + comment->set_comment_text(commentQuery->value(1).toString().toStdString()); + comment->set_comment_time(commentQuery->value(2).toDateTime().toSecsSinceEpoch()); + comment->set_is_moderator(commentQuery->value(3).toBool()); + } + } + + Response_ReportDetails *re = new Response_ReportDetails; + re->set_allocated_report(info); + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdReportAddComment(const Command_ReportAddComment &cmd, + ResponseContainer & /*rc*/) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + int reportId = cmd.report_id(); + QString commentText = textFromStdString(cmd.comment()).trimmed(); + + if (commentText.isEmpty()) { + return Response::RespInvalidData; + } + + const int maxCommentsPerHour = settingsCache->value("reporting/max_comments_per_hour", 30).toInt(); + if (maxCommentsPerHour > 0) { + QSqlQuery *countQuery = + sqlInterface->prepareQuery("SELECT COUNT(*) FROM {prefix}_report_comments WHERE author_id = :id " + "AND created_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR)"); + countQuery->bindValue(":id", userInfo->id()); + if (sqlInterface->execSqlQuery(countQuery) && countQuery->next() && + countQuery->value(0).toInt() >= maxCommentsPerHour) { + return Response::RespTooManyRequests; + } + } + + QSqlQuery *checkQuery = sqlInterface->prepareQuery( + "SELECT reporter_id, reporter_name, assigned_to, status FROM {prefix}_reports WHERE id = :id"); + checkQuery->bindValue(":id", reportId); + + if (!sqlInterface->execSqlQuery(checkQuery)) { + return Response::RespInternalError; + } + + if (!checkQuery->next()) { + return Response::RespNameNotFound; + } + + int reporterId = checkQuery->value(0).toInt(); + QString reporterName = checkQuery->value(1).toString(); + int assignedToId = checkQuery->value(2).toInt(); + + bool isMod = userInfo->user_level() & ServerInfo_User::IsModerator; + + // Only the reporter (by id) or a moderator may comment on a report. + if (reporterId != userInfo->id() && !isMod) { + return Response::RespAccessDenied; + } + + QString reportStatus = checkQuery->value(3).toString(); + if (reportStatus == "resolved" || reportStatus == "dismissed") { + return Response::RespInvalidData; + } + + QSqlQuery *insertQuery = sqlInterface->prepareQuery( + "INSERT INTO {prefix}_report_comments (report_id, author_name, author_id, comment_text, created_at, " + "is_moderator) " + "VALUES (:report_id, :author_name, :author_id, :comment_text, NOW(), :is_moderator)"); + insertQuery->bindValue(":report_id", reportId); + insertQuery->bindValue(":author_name", QString::fromStdString(userInfo->name())); + insertQuery->bindValue(":author_id", userInfo->id()); + insertQuery->bindValue(":comment_text", commentText); + insertQuery->bindValue(":is_moderator", isMod); + + if (!sqlInterface->execSqlQuery(insertQuery)) { + return Response::RespInternalError; + } + + const qlonglong commentId = insertQuery->lastInsertId().toLongLong(); + + QStringList recipients; + if (isMod) { + // A moderator comment reaches both the reporter and (if assigned) the assigned moderator. + recipients.append(reporterName); + if (assignedToId > 0) { + QSqlQuery *modNameQuery = sqlInterface->prepareQuery("SELECT name FROM {prefix}_users WHERE id = :id"); + modNameQuery->bindValue(":id", assignedToId); + if (sqlInterface->execSqlQuery(modNameQuery) && modNameQuery->next()) { + recipients.append(modNameQuery->value(0).toString()); + } + } + } else if (assignedToId > 0) { + QSqlQuery *modNameQuery = sqlInterface->prepareQuery("SELECT name FROM {prefix}_users WHERE id = :id"); + modNameQuery->bindValue(":id", assignedToId); + if (sqlInterface->execSqlQuery(modNameQuery) && modNameQuery->next()) { + recipients.append(modNameQuery->value(0).toString()); + } + } + + const QString ownName = QString::fromStdString(userInfo->name()); + bool allNotified = !recipients.isEmpty(); + QReadLocker clientsLocker(&servatrice->clientsLock); + for (const QString ¬ifyName : recipients) { + if (notifyName == ownName) { + continue; + } + + AbstractServerSocketInterface *notify = + static_cast(server->getUsers().value(notifyName)); + if (!notify) { + // The recipient is offline; leave `notified` = 0 so the notification is + // delivered on their next login via sendPendingReportNotifications. + allNotified = false; + continue; + } + + Event_NotifyUser event; + event.set_type(Event_NotifyUser::REPORT_COMMENT); + event.set_custom_title(tr("New Comment on Report #%1").arg(reportId).toStdString()); + event.set_custom_content( + tr("%1 commented:\n%2").arg(QString::fromStdString(userInfo->name()), commentText).toStdString()); + + SessionEvent *se = notify->prepareSessionEvent(event); + notify->sendProtocolItem(*se); + delete se; + } + + if (allNotified) { + QSqlQuery *notifiedQuery = + sqlInterface->prepareQuery("UPDATE {prefix}_report_comments SET notified = 1 WHERE id = :id"); + notifiedQuery->bindValue(":id", commentId); + sqlInterface->execSqlQuery(notifiedQuery); + } + + return Response::RespOk; +} + +Response::ResponseCode +AbstractServerSocketInterface::cmdReplayDownloadByGameId(const Command_ReplayDownloadByGameId &cmd, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + QSqlQuery *query = sqlInterface->prepareQuery("SELECT r.id, r.replay FROM {prefix}_replays r " + "WHERE r.id_game = :game_id ORDER BY r.id DESC LIMIT 1"); + query->bindValue(":game_id", cmd.game_id()); + + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespInternalError; + } + + if (!query->next()) { + return Response::RespNameNotFound; + } + + int replayId = query->value(0).toInt(); + QByteArray data = query->value(1).toByteArray(); + + Response_ReplayDownloadByGameId *re = new Response_ReplayDownloadByGameId; + re->set_replay_data(data.data(), data.size()); + re->set_replay_id(replayId); + rc.setResponseExtension(re); + + return Response::RespOk; +} + Response::ResponseCode AbstractServerSocketInterface::cmdRegisterAccount(const Command_Register &cmd, ResponseContainer &rc) { @@ -1781,6 +3592,8 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAccountPassword(const C return Response::RespWrongPassword; } + databaseInterface->setForcePasswordChange(userName, false); + return Response::RespOk; } @@ -1917,6 +3730,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdForgotPasswordReset(con "PASSWORD_RESET", "", true); } + sqlInterface->setForcePasswordChange(nameFromStdString(cmd.user_name()), false); sqlInterface->removeForgotPassword(nameFromStdString(cmd.user_name())); return Response::RespOk; } @@ -1985,6 +3799,99 @@ Response::ResponseCode AbstractServerSocketInterface::cmdRequestPasswordSalt(con return Response::RespOk; } +Response::ResponseCode AbstractServerSocketInterface::cmdReport(const Command_Report &cmd, ResponseContainer & /*rc*/) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + if (!cmd.has_reported_user() || !cmd.has_category() || !cmd.has_description()) { + return Response::RespInvalidData; + } + + QString reporterName = QString::fromStdString(userInfo->name()); + QString reportedUser = nameFromStdString(cmd.reported_user()); + QString category = nameFromStdString(cmd.category()); + QString description = textFromStdString(cmd.description()); + QString chatLog = textTailFromStdString(cmd.chat_log()); + + if (reportedUser.isEmpty() || category.isEmpty() || description.isEmpty()) { + return Response::RespInvalidData; + } + + if (!ReportCategories::isValid(category)) { + return Response::RespInvalidData; + } + + const int maxReportsPerDay = settingsCache->value("reporting/max_reports_per_day", 10).toInt(); + if (maxReportsPerDay > 0) { + QSqlQuery *countQuery = + sqlInterface->prepareQuery("SELECT COUNT(*) FROM {prefix}_reports WHERE reporter_id = :id " + "AND created_at >= DATE_SUB(NOW(), INTERVAL 1 DAY)"); + countQuery->bindValue(":id", userInfo->id()); + if (sqlInterface->execSqlQuery(countQuery) && countQuery->next() && + countQuery->value(0).toInt() >= maxReportsPerDay) { + return Response::RespTooManyRequests; + } + } + + int reportedUserId = -1; + QSqlQuery *lookupQuery = sqlInterface->prepareQuery("SELECT id FROM {prefix}_users WHERE name = :name"); + lookupQuery->bindValue(":name", reportedUser); + if (sqlInterface->execSqlQuery(lookupQuery) && lookupQuery->next()) { + reportedUserId = lookupQuery->value(0).toInt(); + } + + if (reportedUserId == -1) { + return Response::RespNameNotFound; + } + + int roomId = 0; + const int gameId = cmd.game_id(); + if (gameId > 0) { + QReadLocker roomsLocker(&servatrice->roomsLock); + const QMap &rooms = servatrice->getRooms(); + for (auto it = rooms.constBegin(); it != rooms.constEnd(); ++it) { + QReadLocker gamesLocker(&it.value()->gamesLock); + if (it.value()->getGames().contains(gameId)) { + roomId = it.key(); + break; + } + } + } + + QSqlQuery *query = + sqlInterface->prepareQuery("insert into {prefix}_reports " + "(reporter_id, reporter_name, reported_user_id, reported_user_name, " + "game_id, room_id, category, description, chat_log, created_at, status) " + "values " + "(:reporter_id, :reporter_name, :reported_user_id, :reported_user_name, " + ":game_id, :room_id, :category, :description, :chat_log, NOW(), 'open')"); + + query->bindValue(":reporter_id", userInfo->id()); + query->bindValue(":reporter_name", reporterName); + query->bindValue(":reported_user_id", reportedUserId); + query->bindValue(":reported_user_name", reportedUser); + + query->bindValue(":game_id", gameId > 0 ? gameId : QVariant()); + + query->bindValue(":room_id", roomId > 0 ? roomId : QVariant()); + + query->bindValue(":category", category); + query->bindValue(":description", description); + query->bindValue(":chat_log", chatLog.isEmpty() ? QVariant() : chatLog); + + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespInternalError; + } + + return Response::RespOk; +} + // ADMIN FUNCTIONS. // Permission is checked by the calling function. @@ -2053,7 +3960,7 @@ bool AbstractServerSocketInterface::removeAdminFlagFromUser(const QString &userN if (user) { Event_ConnectionClosed event; event.set_reason(Event_ConnectionClosed::DEMOTED); - event.set_reason_str("Your moderator and/or judge status has been revoked."); + event.set_reason_str("Your moderator, judge, and/or developer status has been revoked."); event.set_end_time(QDateTime::currentDateTime().toSecsSinceEpoch()); SessionEvent *se = user->prepareSessionEvent(event); @@ -2095,6 +4002,18 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAdjustMod(const Command } } + if (cmd.has_should_be_developer()) { + if (cmd.should_be_developer()) { + if (!addAdminFlagToUser(userName, 8)) { + return Response::RespInternalError; + } + } else { + if (!removeAdminFlagFromUser(userName, 8)) { + return Response::RespInternalError; + } + } + } + return Response::RespOk; } @@ -2310,13 +4229,21 @@ void TcpServerSocketInterface::readClient() (((quint32)(unsigned char)inputBuffer[1]) << 16) + (((quint32)(unsigned char)inputBuffer[2]) << 8) + ((quint32)(unsigned char)inputBuffer[3]); + // Reject implausible lengths instead of buffering until they arrive. + // This also handles lengths that overflow into negative int values. + if (messageLength < 0 || messageLength > MAX_TCP_MESSAGE_SIZE) { + qCWarning(TcpServerSocketInterfaceLog) + << "Invalid message length" << messageLength << "from" << getAddress(); + prepareDestroy(); + return; + } inputBuffer.remove(0, 4); messageInProgress = true; } else { return; } } - if (inputBuffer.size() < messageLength || messageLength < 0) { + if (inputBuffer.size() < messageLength) { return; } @@ -2422,10 +4349,15 @@ void WebsocketServerSocketInterface::initConnection(void *_socket) QByteArray websocketIPHeader = settingsCache->value("server/web_socket_ip_header", "").toByteArray(); if (websocketIPHeader.length() > 0 && socket->request().hasRawHeader(websocketIPHeader)) { - QString header(socket->request().rawHeader(websocketIPHeader)); - QHostAddress parsed(header); - if (!parsed.isNull()) { - address = parsed; + // Only trust the forwarded header if the connection itself comes from a trusted proxy, + // otherwise a client could spoof its address to bypass bans and rate limits. + QString trustedSources = settingsCache->value("security/trusted_sources", "127.0.0.1,::1").toString(); + if (trustedSources.contains(socket->peerAddress().toString(), Qt::CaseInsensitive)) { + QString header(socket->request().rawHeader(websocketIPHeader)); + QHostAddress parsed(header); + if (!parsed.isNull()) { + address = parsed; + } } } diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index 0d66ae78f..27708ed27 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -20,40 +20,93 @@ #ifndef SERVERSOCKETINTERFACE_H #define SERVERSOCKETINTERFACE_H +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/server_message.pb.h" + +#include +#include #include +#include #include +#include +#include #include #include +#include +#include #include class Servatrice; class Servatrice_DatabaseInterface; class DeckList; class ServerInfo_DeckStorage_Folder; - class Command_AddToList; class Command_RemoveFromList; class Command_DeckList; +class Command_DeckListOtherUser; class Command_DeckNewDir; class Command_DeckDelDir; class Command_DeckDel; class Command_DeckDownload; +class Command_DeckDownloadPublic; class Command_DeckUpload; +class Command_DeckSetVisibility; +class Command_DeckShareCreate; +class Command_DeckShareList; +class Command_DeckShareListMine; +class Command_DeckShareRemove; +class Command_DeckShareDownload; class Command_ReplayList; class Command_ReplayDownload; class Command_ReplayModifyMatch; class Command_ReplayDeleteMatch; class Command_ReplayGetCode; class Command_ReplaySubmitCode; - class Command_BanFromServer; class Command_UpdateServerMessage; class Command_ShutdownServer; class Command_ReloadConfig; - +class Command_ReplayDownloadByGameId; class Command_AccountEdit; class Command_AccountImage; class Command_AccountPassword; +class Command_Activate; +class Command_AddCardArtRule; +class Command_AdjustMod; +class Command_ForceActivateUser; +class Command_ForgotPasswordChallenge; +class Command_ForgotPasswordRequest; +class Command_ForgotPasswordReset; +class Command_GetAdminNotes; +class Command_GetBanHistory; +class Command_GetModeratorLastLogins; +class Command_GetServerStats; +class Command_GetUserAlts; +class Command_GetUserSessions; +class Command_GetWarnHistory; +class Command_GetWarnList; +class Command_GrantReplayAccess; +class Command_ListCardArtRules; +class Command_Register; +class Command_RemoveCardArtRule; +class Command_RemoveUserAvatar; +class Command_Report; +class Command_ReportAddComment; +class Command_ReportAssign; +class Command_ReportDetails; +class Command_ReportList; +class Command_ReportMyList; +class Command_ReportResolve; +class Command_ReportStats; +class Command_ReportUserInfo; +class Command_RequestPasswordSalt; +class Command_ResetUserPassword; +class Command_SetCardArtParams; +class Command_UpdateAdminNotes; +class Command_ViewLogHistory; +class Command_WarnUser; +class QObject; +class ResponseContainer; class AbstractServerSocketInterface : public Server_ProtocolHandler { @@ -67,8 +120,9 @@ signals: void incTxBytes(qint64 amount); protected: - void logDebugMessage(const QString &message); + void logDebugMessage(const QString &message) override; bool tooManyRegistrationAttempts(const QString &ipAddress); + void processCommandContainer(const CommandContainer &cont) override; virtual void writeToSocket(QByteArray &data) = 0; virtual void flushSocket() = 0; @@ -84,8 +138,16 @@ private: Response::ResponseCode cmdRemoveFromList(const Command_RemoveFromList &cmd, ResponseContainer &rc); int getDeckPathId(int basePathId, QStringList path); int getDeckPathId(const QString &path); - bool deckListHelper(int folderId, ServerInfo_DeckStorage_Folder *folder); + bool deckListHelper(int folderId, + ServerInfo_DeckStorage_Folder *folder, + int userId, + bool inheritedPublic, + bool publicOnly); + int getDeckOwnerId(int deckId); + bool isDeckEffectivelyPublic(int deckId); Response::ResponseCode cmdDeckList(const Command_DeckList &cmd, ResponseContainer &rc); + Response::ResponseCode cmdDeckListOtherUser(const Command_DeckListOtherUser &cmd, ResponseContainer &rc); + Response::ResponseCode cmdDeckSetVisibility(const Command_DeckSetVisibility &cmd, ResponseContainer &rc); Response::ResponseCode cmdDeckNewDir(const Command_DeckNewDir &cmd, ResponseContainer &rc); void deckDelDirHelper(int basePathId); void sendServerMessage(const QString userName, const QString message); @@ -94,6 +156,12 @@ private: Response::ResponseCode cmdDeckUpload(const Command_DeckUpload &cmd, ResponseContainer &rc); DeckList *getDeckFromDatabase(int deckId); Response::ResponseCode cmdDeckDownload(const Command_DeckDownload &cmd, ResponseContainer &rc); + Response::ResponseCode cmdDeckDownloadPublic(const Command_DeckDownloadPublic &cmd, ResponseContainer &rc); + Response::ResponseCode cmdDeckShareCreate(const Command_DeckShareCreate &cmd, ResponseContainer &rc); + Response::ResponseCode cmdDeckShareList(const Command_DeckShareList &cmd, ResponseContainer &rc); + Response::ResponseCode cmdDeckShareListMine(const Command_DeckShareListMine &cmd, ResponseContainer &rc); + Response::ResponseCode cmdDeckShareRemove(const Command_DeckShareRemove &cmd, ResponseContainer &rc); + Response::ResponseCode cmdDeckShareDownload(const Command_DeckShareDownload &cmd, ResponseContainer &rc); Response::ResponseCode cmdReplayList(const Command_ReplayList &cmd, ResponseContainer &rc); Response::ResponseCode cmdReplayDownload(const Command_ReplayDownload &cmd, ResponseContainer &rc); Response::ResponseCode cmdReplayModifyMatch(const Command_ReplayModifyMatch &cmd, ResponseContainer &rc); @@ -102,13 +170,19 @@ private: Response::ResponseCode cmdReplayGetCode(const Command_ReplayGetCode &cmd, ResponseContainer &rc); Response::ResponseCode cmdReplaySubmitCode(const Command_ReplaySubmitCode &cmd, ResponseContainer &rc); Response::ResponseCode cmdBanFromServer(const Command_BanFromServer &cmd, ResponseContainer &rc); + Response::ResponseCode cmdReportList(const Command_ReportList &cmd, ResponseContainer &rc); Response::ResponseCode cmdWarnUser(const Command_WarnUser &cmd, ResponseContainer &rc); - Response::ResponseCode cmdGetLogHistory(const Command_ViewLogHistory &cmd, ResponseContainer &rc); + Response::ResponseCode + cmdGetLogHistory(const Command_ViewLogHistory &cmd, ResponseContainer &rc, bool allowPrivateChat); Response::ResponseCode cmdGetBanHistory(const Command_GetBanHistory &cmd, ResponseContainer &rc); Response::ResponseCode cmdGetWarnList(const Command_GetWarnList &cmd, ResponseContainer &rc); Response::ResponseCode cmdGetWarnHistory(const Command_GetWarnHistory &cmd, ResponseContainer &rc); Response::ResponseCode cmdShutdownServer(const Command_ShutdownServer &cmd, ResponseContainer &rc); Response::ResponseCode cmdUpdateServerMessage(const Command_UpdateServerMessage &cmd, ResponseContainer &rc); + Response::ResponseCode cmdReportAssign(const Command_ReportAssign &cmd, ResponseContainer &); + Response::ResponseCode cmdReportResolve(const Command_ReportResolve &cmd, ResponseContainer &); + Response::ResponseCode cmdReportUserInfo(const Command_ReportUserInfo &cmd, ResponseContainer &rc); + Response::ResponseCode cmdReportStats(const Command_ReportStats &cmd, ResponseContainer &rc); Response::ResponseCode cmdRegisterAccount(const Command_Register &cmd, ResponseContainer &rc); Response::ResponseCode cmdActivateAccount(const Command_Activate &cmd, ResponseContainer & /* rc */); Response::ResponseCode cmdReloadConfig(const Command_ReloadConfig & /* cmd */, ResponseContainer & /*rc*/); @@ -122,10 +196,21 @@ private: Response::ResponseCode cmdForgotPasswordChallenge(const Command_ForgotPasswordChallenge &cmd, ResponseContainer &rc); Response::ResponseCode cmdRequestPasswordSalt(const Command_RequestPasswordSalt &cmd, ResponseContainer &rc); - Response::ResponseCode processExtendedSessionCommand(int cmdType, const SessionCommand &cmd, ResponseContainer &rc); + Response::ResponseCode cmdReport(const Command_Report &cmd, ResponseContainer &); + Response::ResponseCode cmdReportMyList(const Command_ReportMyList &cmd, ResponseContainer &rc); + void sendPendingReportNotifications(ResponseContainer &rc); + void onLogin(ResponseContainer &rc) override; + Response::ResponseCode cmdReportDetails(const Command_ReportDetails &cmd, ResponseContainer &rc); + Response::ResponseCode cmdReportAddComment(const Command_ReportAddComment &cmd, ResponseContainer &rc); + Response::ResponseCode cmdReplayDownloadByGameId(const Command_ReplayDownloadByGameId &cmd, ResponseContainer &rc); Response::ResponseCode - processExtendedModeratorCommand(int cmdType, const ModeratorCommand &cmd, ResponseContainer &rc); - Response::ResponseCode processExtendedAdminCommand(int cmdType, const AdminCommand &cmd, ResponseContainer &rc); + processExtendedSessionCommand(int cmdType, const SessionCommand &cmd, ResponseContainer &rc) override; + Response::ResponseCode + processExtendedModeratorCommand(int cmdType, const ModeratorCommand &cmd, ResponseContainer &rc) override; + Response::ResponseCode + processExtendedAdminCommand(int cmdType, const AdminCommand &cmd, ResponseContainer &rc) override; + Response::ResponseCode + processExtendedDeveloperCommand(int cmdType, const DeveloperCommand &cmd, ResponseContainer &rc) override; Response::ResponseCode cmdAccountEdit(const Command_AccountEdit &cmd, ResponseContainer &rc); Response::ResponseCode cmdAccountImage(const Command_AccountImage &cmd, ResponseContainer &rc); @@ -141,6 +226,14 @@ private: Response::ResponseCode cmdGetAdminNotes(const Command_GetAdminNotes &cmd, ResponseContainer &rc); Response::ResponseCode cmdUpdateAdminNotes(const Command_UpdateAdminNotes &cmd, ResponseContainer &rc); + Response::ResponseCode cmdGetUserSessions(const Command_GetUserSessions &cmd, ResponseContainer &rc); + Response::ResponseCode cmdGetUserAlts(const Command_GetUserAlts &cmd, ResponseContainer &rc); + Response::ResponseCode cmdGetModeratorLastLogins(const Command_GetModeratorLastLogins &cmd, ResponseContainer &rc); + Response::ResponseCode cmdResetUserPassword(const Command_ResetUserPassword &cmd, ResponseContainer &rc); + Response::ResponseCode cmdRemoveUserAvatar(const Command_RemoveUserAvatar &cmd, ResponseContainer &rc); + + Response::ResponseCode cmdGetServerStats(const Command_GetServerStats &cmd, ResponseContainer &rc); + bool addAdminFlagToUser(const QString &user, int flag); bool removeAdminFlagFromUser(const QString &user, int flag); @@ -157,9 +250,9 @@ public: bool initSession(); virtual QHostAddress getPeerAddress() const = 0; - virtual QString getAddress() const = 0; + QString getAddress() const override = 0; - void transmitProtocolItem(const ServerMessage &item); + void transmitProtocolItem(const ServerMessage &item) override; }; class TcpServerSocketInterface : public AbstractServerSocketInterface diff --git a/servatrice/src/settingscache.cpp b/servatrice/src/settingscache.cpp index f6c862904..4ecac7319 100644 --- a/servatrice/src/settingscache.cpp +++ b/servatrice/src/settingscache.cpp @@ -4,6 +4,13 @@ #include #include #include +#include +#include +#include +#include +#include + +class QObject; SettingsCache::SettingsCache(const QString &fileName, QSettings::Format format, QObject *parent) : QSettings(fileName, format, parent) diff --git a/servatrice/src/settingscache.h b/servatrice/src/settingscache.h index 6922d3508..817a62bfa 100644 --- a/servatrice/src/settingscache.h +++ b/servatrice/src/settingscache.h @@ -5,6 +5,9 @@ #include #include #include +#include + +class QObject; class SettingsCache : public QSettings { diff --git a/servatrice/src/signalhandler.cpp b/servatrice/src/signalhandler.cpp index b4513979f..32cd5b206 100644 --- a/servatrice/src/signalhandler.cpp +++ b/servatrice/src/signalhandler.cpp @@ -5,6 +5,10 @@ #include "settingscache.h" #include +#include +#include +#include +#include #ifdef Q_OS_UNIX #include diff --git a/servatrice/src/signalhandler.h b/servatrice/src/signalhandler.h index bf8d9e52a..82652a43a 100644 --- a/servatrice/src/signalhandler.h +++ b/servatrice/src/signalhandler.h @@ -2,6 +2,7 @@ #define SIGNALHANDLER_H #include +#include class QSocketNotifier; diff --git a/servatrice/src/smtpclient.cpp b/servatrice/src/smtpclient.cpp index 3ec333413..a45588b8b 100644 --- a/servatrice/src/smtpclient.cpp +++ b/servatrice/src/smtpclient.cpp @@ -1,10 +1,18 @@ #include "smtpclient.h" #include "settingscache.h" +#include "smtp/qxtmailmessage.h" #include "smtp/qxtsmtp.h" +#include +#include +#include +#include #include #include +#include +#include +#include SmtpClient::SmtpClient(QObject *parent) : QObject(parent) { diff --git a/servatrice/src/smtpclient.h b/servatrice/src/smtpclient.h index be97ed44d..8fc4e35cb 100644 --- a/servatrice/src/smtpclient.h +++ b/servatrice/src/smtpclient.h @@ -2,9 +2,11 @@ #define SMTPCLIENT_H #include +#include +#include class QxtSmtp; -class QxtMailMessage; +class QByteArray; class SmtpClient : public QObject { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1c3f4c2c6..c48e6c957 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -2,30 +2,10 @@ # If a new test needs additional Qt modules, add them there — not in individual test CMakeLists.txt files. enable_testing() -add_test(NAME dummy_test COMMAND dummy_test) -add_test(NAME expression_test COMMAND expression_test) -add_test(NAME clamped_arithmetic_test COMMAND clamped_arithmetic_test) -add_test(NAME test_age_formatting COMMAND test_age_formatting) -add_test(NAME password_hash_test COMMAND password_hash_test) -add_test(NAME server_card_counter_test COMMAND server_card_counter_test) -add_test(NAME server_counter_test COMMAND server_counter_test) -add_test(NAME server_rate_limiter_test COMMAND server_rate_limiter_test) - -add_test(NAME deck_hash_performance_test COMMAND deck_hash_performance_test) -set_tests_properties(deck_hash_performance_test PROPERTIES TIMEOUT 5) +include(GoogleTest) # Find GTest -add_executable(dummy_test dummy_test.cpp) -add_executable(expression_test expression_test.cpp) -add_executable(clamped_arithmetic_test clamped_arithmetic_test.cpp) -add_executable(test_age_formatting test_age_formatting.cpp) -add_executable(password_hash_test password_hash_test.cpp) -add_executable(deck_hash_performance_test deck_hash_performance_test.cpp) -add_executable(server_card_counter_test server_card_counter_test.cpp) -add_executable(server_counter_test server_counter_test.cpp) -add_executable(server_rate_limiter_test server_rate_limiter_test.cpp) - find_package(GTest) if(NOT GTEST_FOUND) @@ -50,47 +30,348 @@ if(NOT GTEST_FOUND) target_include_directories(gtest INTERFACE "$") set(GTEST_INCLUDE_DIRS "${CMAKE_BINARY_DIR}/gtest-src/include") - set(GTEST_BOTH_LIBRARIES gtest) - add_dependencies(dummy_test gtest) - add_dependencies(expression_test gtest) - add_dependencies(clamped_arithmetic_test gtest) - add_dependencies(test_age_formatting gtest) - add_dependencies(password_hash_test gtest) - add_dependencies(deck_hash_performance_test gtest) - add_dependencies(server_card_counter_test gtest) - add_dependencies(server_counter_test gtest) - add_dependencies(server_rate_limiter_test gtest) + # gtest_main is not optional here. FindGTest sets GTEST_BOTH_LIBRARIES to both halves, so this + # has to match it or the two paths disagree about what provides main(). A test source that + # defines its own main() gets away with the omission; one that does not fails at link with + # `undefined reference to main` pointing at crt1.o, which names neither gtest nor this line. + set(GTEST_BOTH_LIBRARIES gtest gtest_main) endif() +# Directory-scoped on purpose: the test subdirectories below add targets that include the +# fetched gtest headers and do not repeat this path themselves. include_directories(${GTEST_INCLUDE_DIRS}) -target_link_libraries(dummy_test Threads::Threads ${GTEST_BOTH_LIBRARIES}) -target_link_libraries(expression_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}) -target_link_libraries( - clamped_arithmetic_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} + +# --- Test registration ---------------------------------------------------------- +# +# Every test target needs the same four steps: build the executable, depend on gtest when it +# was fetched from source, link the shared test libraries, and hand the cases to CTest. The +# `if(NOT GTEST_FOUND)` branch is the easy one to forget — omit it and the link fails with +# undefined gtest symbols — so it lives here once instead of being repeated per test. +# +# This also replaces `add_test(NAME x COMMAND x)`. Naming the binary as a single CTest entry +# hides which case failed and makes `ctest -R Suite.Case` impossible; discovery exposes each +# TEST() as its own CTest entry instead. +# +# Labels: exactly one per test, because gtest_discover_tests() cannot carry more than one. +# GoogleTestAddTests.cmake accumulates the generated script with `foreach(arg ${ARGN})`, so a +# label list serialises as bare `LABELS ci server` in the CTestTestfile, and CTest reads only +# the first value of a property and silently discards the rest. A label therefore classifies a +# test into exactly one group, and the three groups cover every selection worth making: +# +# ci the default hermetic set, so `ctest -LE bench` runs everything but benchmarks +# server servatrice-facing tests, so `ctest -L server` works +# bench the slow suites, kept out of the default run +# +# A second label is a configure-time FATAL_ERROR rather than a line CTest quietly reads as one. +# Anything orthogonal to a test's kind belongs in a helper flag beside the label, so it +# composes with the group instead of competing for the single slot. NO_SANITIZE is such a flag. +# +# TIMEOUT is bounded per test because CTest's implicit default is 1500s: one hung socket wait +# would otherwise stall CI for 25 minutes. +# +# EXTRA_LIBS is appended after the shared libraries so a caller adding a static archive (zlib, +# lzma) keeps it last, which is where the linker needs it. + +function(cockatrice_add_test) + cmake_parse_arguments( + PARSE_ARGV 0 ARG "MANUAL;NO_SANITIZE" "NAME" "SOURCES;LIBS;LABELS;DEFINITIONS;INCLUDE_DIRS;EXTRA_LIBS" + ) + + if(NOT ARG_NAME OR NOT ARG_SOURCES) + message(FATAL_ERROR "cockatrice_add_test requires at least NAME and SOURCES") + endif() + + add_executable(${ARG_NAME} ${ARG_SOURCES}) + + if(ARG_INCLUDE_DIRS) + target_include_directories(${ARG_NAME} PRIVATE ${ARG_INCLUDE_DIRS}) + endif() + + if(ARG_DEFINITIONS) + target_compile_definitions(${ARG_NAME} PRIVATE ${ARG_DEFINITIONS}) + endif() + + target_link_libraries( + ${ARG_NAME} ${ARG_LIBS} Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} ${ARG_EXTRA_LIBS} + ) + + # Only needed when gtest was downloaded and add_subdirectory'd above. An imported gtest + # target already carries its own dependency, so this must stay conditional. + if(NOT GTEST_FOUND) + add_dependencies(${ARG_NAME} gtest gtest_main) + endif() + + # MANUAL targets are built but deliberately kept out of ctest: benchmarks that need a card + # database, hit the network, or measure RAM. Leaving them unregistered keeps CI hermetic + # and fast. They still link gtest so they can use TEST() without a second code path. + if(ARG_MANUAL) + return() + endif() + + # NO_SANITIZE marks a target the sanitizer leg cannot run. Excluded by not handing its cases + # to CTest at all, rather than by a label, so the exclusion cannot rot into a line CTest + # quietly reads as something else. The target still builds, and still runs on the other legs. + if(ARG_NO_SANITIZE AND COCKATRICE_SANITIZERS) + return() + endif() + + # One label only, per the note above. Rejecting a second loudly beats emitting a + # CTestTestfile line that CTest will quietly read as one label. + list(LENGTH ARG_LABELS _label_count) + if(_label_count GREATER 1) + message(FATAL_ERROR "${ARG_NAME}: pass at most one label, got '${ARG_LABELS}'") + endif() + if(NOT ARG_LABELS) + set(ARG_LABELS ci) + endif() + + # GoogleTestAddTests defaults DISCOVERY_TIMEOUT to 5s, which is ample for a plain debug + # binary (--gtest_list_tests costs 0.04s) but not for an instrumented one (~2.8s cold, and + # discovery runs several binaries concurrently behind a -j build). Left at the default, a + # sanitizer build fails at the *link* step with a bare "Process terminated due to timeout" + # and no hint that discovery, not the test, is what is slow. + if(COCKATRICE_SANITIZERS) + set(_discovery_timeout 300) + else() + set(_discovery_timeout 60) + endif() + + gtest_discover_tests( + ${ARG_NAME} DISCOVERY_MODE POST_BUILD DISCOVERY_TIMEOUT ${_discovery_timeout} + PROPERTIES LABELS "${ARG_LABELS}" + TIMEOUT 60 + ) +endfunction() + +# Same wiring for targets that are built but deliberately kept out of ctest: benchmarks that +# need a card database, hit the network, or measure RAM. A separate name rather than a MANUAL +# flag spelled out at each call site, so "this is not in the CI suite" is visible in the CMake +# file. Note the unquoted expansions: an unset multi-value keyword expands to zero arguments, +# which is what lets this forward a caller's keywords verbatim without tripping the FATAL_ERROR +# guard above on an empty list. +function(cockatrice_add_manual_test) + cmake_parse_arguments(PARSE_ARGV 0 ARG "" "NAME" "SOURCES;LIBS;DEFINITIONS;INCLUDE_DIRS;EXTRA_LIBS") + + cockatrice_add_test( + NAME + ${ARG_NAME} + SOURCES + ${ARG_SOURCES} + LIBS + ${ARG_LIBS} + DEFINITIONS + ${ARG_DEFINITIONS} + INCLUDE_DIRS + ${ARG_INCLUDE_DIRS} + EXTRA_LIBS + ${ARG_EXTRA_LIBS} + MANUAL + ) +endfunction() + +# --- Unit tests ------------------------------------------------------------------ + +cockatrice_add_test(NAME expression_test SOURCES expression_test.cpp LIBS libcockatrice_utility) +cockatrice_add_test(NAME clamped_arithmetic_test SOURCES clamped_arithmetic_test.cpp LIBS libcockatrice_utility) +cockatrice_add_test(NAME test_age_formatting SOURCES test_age_formatting.cpp LIBS libcockatrice_utility) +cockatrice_add_test(NAME 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 warning_categories_test SOURCES warning_categories_test.cpp LIBS libcockatrice_utility) +cockatrice_add_test( + NAME + playmat_resolver_test + SOURCES + playmat_resolver_test.cpp + LIBS + libcockatrice_deck_list + libcockatrice_utility ) -target_link_libraries( - test_age_formatting libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} +cockatrice_add_test( + NAME + server_rate_limiter_test + SOURCES + server_rate_limiter_test.cpp + LIBS + libcockatrice_utility + LABELS + server ) -target_link_libraries( - password_hash_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} +cockatrice_add_test( + NAME + server_card_counter_test + SOURCES + server_card_counter_test.cpp + LIBS + libcockatrice_network + LABELS + server ) -target_link_libraries( - deck_hash_performance_test libcockatrice_deck_list libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} - ${TEST_QT_MODULES} +cockatrice_add_test( + NAME + server_counter_test + SOURCES + server_counter_test.cpp + LIBS + libcockatrice_network + LABELS + server ) -target_link_libraries( - server_card_counter_test libcockatrice_network Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} +cockatrice_add_test( + NAME + server_developer_role_test + NO_SANITIZE + SOURCES + server_developer_role_test.cpp + LIBS + libcockatrice_network + libcockatrice_rng + LABELS + server ) -target_link_libraries( - server_counter_test libcockatrice_network Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} +cockatrice_add_test( + NAME + server_game_join_test + SOURCES + server_game_join_test.cpp + LIBS + libcockatrice_network_server_remote + libcockatrice_rng + LABELS + server ) -target_link_libraries( - server_rate_limiter_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} +cockatrice_add_test( + NAME + latency_tracker_test + SOURCES + latency_tracker_test.cpp + LIBS + libcockatrice_network + LABELS + server ) +cockatrice_add_test( + NAME + lag_monitor_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/lag_monitor.cpp + lag_monitor_test.cpp + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src +) +cockatrice_add_test( + NAME + metrics_registry_test + SOURCES + ${CMAKE_SOURCE_DIR}/servatrice/src/metrics_registry.cpp + metrics_registry_test.cpp + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/servatrice/src + LABELS + server +) +cockatrice_add_test( + NAME + card_update_progress_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/card_database_update/card_update_progress.cpp + card_update_progress_test.cpp + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src +) +cockatrice_add_test( + NAME + loader_local_matching_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/cache_settings.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/card_counter_settings.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/settings/shortcuts_settings.cpp + ${CMAKE_SOURCE_DIR}/cockatrice/src/client/network/update/client/release_channel.cpp + ${VERSION_STRING_CPP} + loader_local_matching_test.cpp + LIBS + libcockatrice_settings + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src +) +cockatrice_add_test( + NAME + deck_hash_performance_test + SOURCES + deck_hash_performance_test.cpp + LIBS + libcockatrice_deck_list + libcockatrice_utility + LABELS + bench +) + +# The translation loader test compiles a .qm fixture, which needs lrelease from Qt Linguist. +if(TARGET Qt6::lrelease) + set(TRANSLATION_LOADER_TEST_QM "${CMAKE_CURRENT_BINARY_DIR}/translation_loader_test_de.qm") + add_custom_command( + OUTPUT "${TRANSLATION_LOADER_TEST_QM}" + COMMAND Qt6::lrelease "${CMAKE_CURRENT_SOURCE_DIR}/translation_loader_test.ts" -qm "${TRANSLATION_LOADER_TEST_QM}" + DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/translation_loader_test.ts" + COMMENT "Compiling translation_loader_test.ts" + ) + add_custom_target(translation_loader_test_qm DEPENDS "${TRANSLATION_LOADER_TEST_QM}") + + cockatrice_add_test( + NAME + translation_loader_test + SOURCES + translation_loader_test.cpp + LIBS + libcockatrice_utility + DEFINITIONS + TRANSLATION_LOADER_TEST_DIR="${CMAKE_CURRENT_BINARY_DIR}" + ) + add_dependencies(translation_loader_test translation_loader_test_qm) + + # A multi-config generator (MSBuild, Xcode) puts the executable in a per-config subdirectory while + # the .qm stays in the current binary directory, so copy the fixture next to the binary: that is the + # layout ABuildTreeFindsTheTranslationsNextToTheExecutable is about, and the only one it finds on + # those generators. + add_custom_command( + TARGET translation_loader_test + POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_if_different "${TRANSLATION_LOADER_TEST_QM}" + "$/" + VERBATIM + ) +endif() + +# Guard must match the condition for src/single_instance_manager.cpp in cockatrice/CMakeLists.txt. +if(WITH_CLIENT) + cockatrice_add_test( + NAME + single_instance_manager_test + SOURCES + ${CMAKE_SOURCE_DIR}/cockatrice/src/single_instance_manager.cpp + single_instance_manager_test.cpp + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src + ${CMAKE_SOURCE_DIR}/libcockatrice_utility + ) +endif() add_subdirectory(card_zone_algorithms) add_subdirectory(carddatabase) +add_subdirectory(deck_list_model) +add_subdirectory(deck_list_zones) add_subdirectory(loading_from_clipboard) add_subdirectory(movecard_tests) add_subdirectory(oracle) add_subdirectory(settings) + +# theme_migration_test compiles client sources, whose headers are only reachable from a client build. +if(WITH_CLIENT) + add_subdirectory(theme_migration) +endif() + +# picture_loader_benchmark links libcockatrice_settings, which only exists when +# a client/UI-capable target is being built. +if(WITH_ORACLE OR WITH_CLIENT) + add_subdirectory(picture_loader_benchmark) +endif() diff --git a/tests/card_update_progress_test.cpp b/tests/card_update_progress_test.cpp new file mode 100644 index 000000000..849cf623c --- /dev/null +++ b/tests/card_update_progress_test.cpp @@ -0,0 +1,93 @@ +#include "interface/card_database_update/card_update_progress.h" + +#include +#include +#include + +namespace +{ +std::optional parseLine(const QByteArray &line) +{ + return CardUpdateProgress::fromProtocolLine(line); +} +} // namespace + +TEST(CardUpdateProgressTest, ParsesAValidDownloadLine) +{ + const std::optional progress = parseLine("PROGRESS download 42 100"); + ASSERT_TRUE(progress.has_value()); + EXPECT_EQ(CardUpdateStage::Download, progress->stage); + EXPECT_EQ(42, progress->done); + EXPECT_EQ(100, progress->total); +} + +TEST(CardUpdateProgressTest, ParsesTrailingNewline) +{ + const std::optional progress = parseLine("PROGRESS scan 7 99\n"); + ASSERT_TRUE(progress.has_value()); + EXPECT_EQ(CardUpdateStage::Scan, progress->stage); + EXPECT_EQ(7, progress->done); + EXPECT_EQ(99, progress->total); +} + +TEST(CardUpdateProgressTest, ParsesWithSurroundingWhitespace) +{ + const std::optional progress = parseLine(QByteArray(" PROGRESS import 3 10 ")); + ASSERT_TRUE(progress.has_value()); + EXPECT_EQ(CardUpdateStage::Import, progress->stage); + EXPECT_EQ(3, progress->done); + EXPECT_EQ(10, progress->total); +} + +TEST(CardUpdateProgressTest, AcceptsZeroProgress) +{ + const std::optional progress = parseLine("PROGRESS download 0 0"); + ASSERT_TRUE(progress.has_value()); + EXPECT_EQ(0, progress->done); + EXPECT_EQ(0, progress->total); +} + +TEST(CardUpdateProgressTest, RejectsLinesWithoutProgressPrefix) +{ + EXPECT_FALSE(parseLine("download 42 100").has_value()); + EXPECT_FALSE(parseLine("PROGRESSdownload 42 100").has_value()); + EXPECT_FALSE(parseLine("").has_value()); +} + +TEST(CardUpdateProgressTest, RejectsMalformedLines) +{ + EXPECT_FALSE(parseLine("PROGRESS").has_value()); + EXPECT_FALSE(parseLine("PROGRESS download").has_value()); + EXPECT_FALSE(parseLine("PROGRESS download 42 100 extra").has_value()); + EXPECT_FALSE(parseLine("PROGRESS download 42").has_value()); + EXPECT_FALSE(parseLine("PROGRESS download notANumber 100").has_value()); +} + +TEST(CardUpdateProgressTest, RejectsNegativeValues) +{ + EXPECT_FALSE(parseLine("PROGRESS download -1 100").has_value()); + EXPECT_FALSE(parseLine("PROGRESS download 42 -100").has_value()); +} + +TEST(CardUpdateProgressTest, AcceptsUnknownStagesSchemalessly) +{ + const std::optional progress = parseLine("PROGRESS spoilers 1 2"); + ASSERT_TRUE(progress.has_value()); + EXPECT_EQ(CardUpdateStage::Unknown, progress->stage); + EXPECT_EQ(1, progress->done); + EXPECT_EQ(2, progress->total); +} + +TEST(CardUpdateProgressTest, StageTokensRoundTrip) +{ + EXPECT_EQ("download", CardUpdateProgress{CardUpdateStage::Download}.stageToken()); + EXPECT_EQ("scan", CardUpdateProgress{CardUpdateStage::Scan}.stageToken()); + EXPECT_EQ("import", CardUpdateProgress{CardUpdateStage::Import}.stageToken()); + EXPECT_EQ("unknown", CardUpdateProgress{CardUpdateStage::Unknown}.stageToken()); +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} \ No newline at end of file diff --git a/tests/card_zone_algorithms/CMakeLists.txt b/tests/card_zone_algorithms/CMakeLists.txt index e03d8f77d..945f8a0ed 100644 --- a/tests/card_zone_algorithms/CMakeLists.txt +++ b/tests/card_zone_algorithms/CMakeLists.txt @@ -1,15 +1,4 @@ -add_executable(card_zone_algorithms_test card_zone_algorithms_test.cpp) - -target_include_directories(card_zone_algorithms_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src/game/zones) - -target_link_libraries( - card_zone_algorithms_test - PRIVATE Threads::Threads - PRIVATE ${GTEST_BOTH_LIBRARIES} +cockatrice_add_test( + NAME card_zone_algorithms_test SOURCES card_zone_algorithms_test.cpp INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src/game/zones ) - -add_test(NAME card_zone_algorithms_test COMMAND card_zone_algorithms_test) - -if(NOT GTEST_FOUND) - add_dependencies(card_zone_algorithms_test gtest) -endif() diff --git a/tests/card_zone_algorithms/card_zone_algorithms_test.cpp b/tests/card_zone_algorithms/card_zone_algorithms_test.cpp index cc098cae9..4f5282235 100644 --- a/tests/card_zone_algorithms/card_zone_algorithms_test.cpp +++ b/tests/card_zone_algorithms/card_zone_algorithms_test.cpp @@ -1,6 +1,7 @@ #include "card_zone_algorithms.h" #include +#include #include struct MockCardRef @@ -134,6 +135,35 @@ TEST_F(AddCardAlgorithmTest, MidListInsertionPreservesOrder) EXPECT_EQ(knownList.at(2), &b); } +// Reconnecting to a game rebuilds zones from a ServerInfo_Zone. Non-coordinate zones +// (hand, piles, stack) report x == 0 on every card, so inserting each rebuilt card at +// that index would reverse the received server order. Appending (-1) keeps it. +TEST_F(AddCardAlgorithmTest, RebuildInsertAtZeroReversesServerOrder) +{ + MockCard a, b, c; + CardZoneAlgorithms::addCardToList(knownList, &a, 0, false); + CardZoneAlgorithms::addCardToList(knownList, &b, 0, false); + CardZoneAlgorithms::addCardToList(knownList, &c, 0, false); + + EXPECT_EQ(knownList.size(), 3); + EXPECT_EQ(knownList.at(0), &c); + EXPECT_EQ(knownList.at(1), &b); + EXPECT_EQ(knownList.at(2), &a); +} + +TEST_F(AddCardAlgorithmTest, RebuildAppendPreservesServerOrder) +{ + MockCard a, b, c; + CardZoneAlgorithms::addCardToList(knownList, &a, -1, false); + CardZoneAlgorithms::addCardToList(knownList, &b, -1, false); + CardZoneAlgorithms::addCardToList(knownList, &c, -1, false); + + EXPECT_EQ(knownList.size(), 3); + EXPECT_EQ(knownList.at(0), &a); + EXPECT_EQ(knownList.at(1), &b); + EXPECT_EQ(knownList.at(2), &c); +} + TEST_F(AddCardAlgorithmTest, KeepAnnotationsFalsePassedThrough) { MockCard card; diff --git a/tests/carddatabase/CMakeLists.txt b/tests/carddatabase/CMakeLists.txt index 811cebc1d..22171a9e8 100644 --- a/tests/carddatabase/CMakeLists.txt +++ b/tests/carddatabase/CMakeLists.txt @@ -9,61 +9,46 @@ add_definitions("-DCARDDB_DATADIR=\"${CMAKE_CURRENT_SOURCE_DIR}/data/\"") # ------------------------ # Card Database Test # ------------------------ -add_executable(carddatabase_test ${MOCKS_SOURCES} ${VERSION_STRING_CPP} carddatabase_test.cpp mocks.cpp) - -target_link_libraries( +cockatrice_add_test( + NAME carddatabase_test - PRIVATE libcockatrice_card - PRIVATE Threads::Threads - PRIVATE ${GTEST_BOTH_LIBRARIES} - PRIVATE ${TEST_QT_MODULES} + SOURCES + ${VERSION_STRING_CPP} + carddatabase_test.cpp + mocks.cpp + LIBS + libcockatrice_card ) -add_test(NAME carddatabase_test COMMAND carddatabase_test) - -# ------------------------ -# Load Benchmark (manual, not run in CI) -# ------------------------ -add_executable(load_benchmark ${MOCKS_SOURCES} ${VERSION_STRING_CPP} load_benchmark.cpp mocks.cpp) - -target_link_libraries( - load_benchmark - PRIVATE libcockatrice_card - PRIVATE libcockatrice_models - PRIVATE Threads::Threads - PRIVATE ${GTEST_BOTH_LIBRARIES} - PRIVATE ${TEST_QT_MODULES} -) - -if(NOT GTEST_FOUND) - add_dependencies(load_benchmark gtest) -endif() - # ------------------------ # Filter String Test # (guard must match the condition for libcockatrice_filters in the root CMakeLists.txt) # ------------------------ if(WITH_ORACLE OR WITH_CLIENT) - add_executable(filter_string_test ${MOCKS_SOURCES} ${VERSION_STRING_CPP} filter_string_test.cpp mocks.cpp) - - target_link_libraries( + cockatrice_add_test( + NAME filter_string_test - PRIVATE libcockatrice_filters - PRIVATE Threads::Threads - PRIVATE ${GTEST_BOTH_LIBRARIES} - PRIVATE ${TEST_QT_MODULES} + SOURCES + ${VERSION_STRING_CPP} + filter_string_test.cpp + mocks.cpp + LIBS + libcockatrice_filters ) - - add_test(NAME filter_string_test COMMAND filter_string_test) - - if(NOT GTEST_FOUND) - add_dependencies(filter_string_test gtest) - endif() endif() # ------------------------ -# Dependencies on gtest +# Load Benchmark (manual, not run in CI) # ------------------------ -if(NOT GTEST_FOUND) - add_dependencies(carddatabase_test gtest) -endif() +cockatrice_add_manual_test( + NAME + load_benchmark + SOURCES + ${VERSION_STRING_CPP} + load_benchmark.cpp + mocks.cpp + LIBS + libcockatrice_card + libcockatrice_models + MANUAL +) diff --git a/tests/carddatabase/carddatabase_test.cpp b/tests/carddatabase/carddatabase_test.cpp index 3fa0e3834..1ac259d9b 100644 --- a/tests/carddatabase/carddatabase_test.cpp +++ b/tests/carddatabase/carddatabase_test.cpp @@ -1,9 +1,35 @@ -#include "mocks.h" +#include "libcockatrice/card/database/card_database.h" +#include "libcockatrice/card/database/card_database_loader.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/set/card_set.h" +#include "libcockatrice/card/set/card_set_list.h" #include "test_card_database_path_provider.h" #include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include +#include namespace { @@ -33,6 +59,322 @@ TEST(CardDatabaseTest, LoadXml) ASSERT_EQ(0, db->query()->getAllMainCardTypes().size()) << "Types not empty after clear"; ASSERT_EQ(NotLoaded, db->getLoadStatus()) << "Incorrect status after clear"; } + +TEST(CardDatabaseTest, Xml4LocalizedDataRoundTrip) +{ + NoopCardSetPriorityController controller; + CardSetPtr set = + CardSet::newInstance(&controller, "TST", "Test Set", "expansion", QDate(), CardSet::PriorityPrimary); + + QHash props; + props["manacost"] = "1R"; + PrintingInfo printing(set, LazyPropertiesHash(props)); + SetToPrintingsMap setsInfo; + setsInfo["TST"].append(printing); + + CardInfo::UiAttributes attributes = {.tableRow = 1}; + CardInfoPtr card = + CardInfo::newInstance("Lightning Bolt", "Deal 3 damage.", false, {}, {}, {}, setsInfo, attributes); + card->setLocalizedName("de", "Blitzschlag"); + card->setLocalizedText("de", "Blitzschlag fügt 3 Schadenspunkte zu."); + + SetNameMap sets; + sets.insert("TST", set); + CardNameMap cards; + cards.insert("Lightning Bolt", card); + + QTemporaryDir tempDir; + const QString fileName = tempDir.filePath("cards.xml"); + CockatriceXml4Parser writer(&controller); + ASSERT_TRUE(writer.saveToFile({}, sets, cards, fileName)); + + CardDatabaseData data; + CockatriceXml4Parser parser(&controller); + QFile file(fileName); + ASSERT_TRUE(file.open(QIODevice::ReadOnly)); + parser.parseFileInto(file, data); + + CardInfoPtr loaded = data.cards.value("Lightning Bolt"); + ASSERT_FALSE(loaded.isNull()); + ASSERT_EQ(loaded->getName(), "Lightning Bolt"); + ASSERT_EQ(loaded->getLocalizedName("de"), "Blitzschlag"); + ASSERT_EQ(loaded->getLocalizedText("de"), "Blitzschlag fügt 3 Schadenspunkte zu."); + ASSERT_EQ(loaded->getLocalizedText("fr"), "Deal 3 damage."); +} + +/** + * @brief Path provider serving a sandboxed copy of the test card database. + */ +class SandboxPathProvider : public ICardDatabasePathProvider +{ +public: + explicit SandboxPathProvider(const QString &_root) : root(_root) + { + } + + QString getCardDatabasePath() const override + { + return root + "/cards.xml"; + } + + QString getCustomCardDatabasePath() const override + { + return root + "/customsets/"; + } + + QString getTokenDatabasePath() const override + { + return root + "/tokens.xml"; + } + + QString getSpoilerCardDatabasePath() const override + { + return root + "/spoiler.xml"; + } + +private: + QString root; +}; + +/** + * @brief Set priority controller whose enablement state the test drives directly. + * + * Seeded short names behave like entries present in cardDatabase.ini (enabled, + * known). Anything the parser discovers but the test did not seed falls back to + * the default SetOptions, i.e. disabled and unknown, mirroring + * CardDatabaseSettings for sets without an ini group. + */ +class SeededSetPriorityController : public ICardSetPriorityController +{ +public: + void seed(const QStringList &shortNames) + { + for (const QString &shortName : shortNames) { + SetOptions options; + options.enabled = true; + options.isKnown = true; + setOptions.insert(shortName, options); + } + } + + void setSortKey(QString shortName, unsigned int sortKey) override + { + setOptions[shortName].sortKey = sortKey; + } + + void setEnabled(QString shortName, bool enabled) override + { + setOptions[shortName].enabled = enabled; + } + + void setIsKnown(QString shortName, bool isknown) override + { + setOptions[shortName].isKnown = isknown; + } + + unsigned int getSortKey(QString shortName) const override + { + return setOptions.value(shortName).sortKey; + } + + bool isEnabled(QString shortName) const override + { + return setOptions.value(shortName).enabled; + } + + bool isKnown(QString shortName) const override + { + return setOptions.value(shortName).isKnown; + } + + SetOptions getSetOptions(QString shortName) const override + { + return setOptions.value(shortName); + } + + QStringList getEnabledSetNames() const override + { + QStringList names; + for (auto it = setOptions.constBegin(); it != setOptions.constEnd(); ++it) { + if (it.value().enabled) { + names << it.key(); + } + } + names.sort(); + return names; + } + + void saveSets(const QVector &data) override + { + for (const SetSaveData &entry : data) { + setOptions[entry.shortName].sortKey = entry.sortKey; + setOptions[entry.shortName].enabled = entry.enabled; + } + } + +private: + QHash setOptions; +}; + +/** + * @brief Sandboxed card database plus enablement controller for cache tests. + * + * Copies the checked-in fixture XML into a temporary directory so each test + * owns the files (and the binary cache written next to them). + */ +class CardDatabaseCacheInvalidationTest : public ::testing::Test +{ +protected: + void SetUp() override + { + ASSERT_TRUE(tempDir.isValid()); + root = tempDir.path(); + ASSERT_TRUE(QFile::copy(QString(CARDDB_DATADIR) + "cards.xml", root + "/cards.xml")); + ASSERT_TRUE(QFile::copy(QString(CARDDB_DATADIR) + "tokens.xml", root + "/tokens.xml")); + ASSERT_TRUE(QDir(root).mkpath("customsets")); + ASSERT_TRUE( + QFile::copy(QString(CARDDB_DATADIR) + "customsets/customset1.xml", root + "/customsets/customset1.xml")); + controller.seed({"CAT", "DOG", "WHO", " Not a Card", "BRD"}); + } + + /** + * @brief Contents of the binary cache written next to cards.xml. + * @return The cache file bytes, or an empty array if no cache exists. + */ + QByteArray cacheContents() const + { + QFile file(root + "/cards.xml.cache"); + if (!file.open(QIODevice::ReadOnly)) { + return {}; + } + return file.readAll(); + } + + QTemporaryDir tempDir; + QString root; + SeededSetPriorityController controller; +}; + +TEST_F(CardDatabaseCacheInvalidationTest, DisablingSetBustsCacheAndDropsPrintings) +{ + NoopCardPreferenceProvider prefs; + SandboxPathProvider pathProvider(root); + CardDatabase db(nullptr, &prefs, &pathProvider, &controller); + + db.loadCardDatabases(); + const QByteArray initialCache = cacheContents(); + ASSERT_FALSE(initialCache.isEmpty()) << "first load must write the binary cache"; + ASSERT_FALSE(db.getCardList().value("Cat").isNull()); + ASSERT_TRUE(db.getCardList().value("Cat")->getSets().contains("CAT")); + + // A cache-aware load with unchanged inputs keeps the cache: the parse + // result would be identical, so there is nothing to invalidate. + db.loadCardDatabases(); + EXPECT_EQ(initialCache, cacheContents()) << "load with unchanged inputs must keep the cache"; + + // Disabling a set changes the source hash, so the next load re-parses and + // the set's printings disappear everywhere. + controller.setEnabled("CAT", false); + db.reloadCardDatabasesAndNotify(); + EXPECT_NE(initialCache, cacheContents()) << "toggling set enablement must bust the cache"; + ASSERT_FALSE(db.getCardList().value("Cat").isNull()); + EXPECT_FALSE(db.getCardList().value("Cat")->getSets().contains("CAT")) << "disabled set printings must be dropped"; + ASSERT_FALSE(db.getCardList().value("Kitten").isNull()); + EXPECT_FALSE(db.getCardList().value("Kitten")->getSets().contains("CAT")); +} + +TEST_F(CardDatabaseCacheInvalidationTest, AddingAndEnablingCustomSetBustsCache) +{ + NoopCardPreferenceProvider prefs; + SandboxPathProvider pathProvider(root); + CardDatabase db(nullptr, &prefs, &pathProvider, &controller); + + db.loadCardDatabases(); + const QByteArray initialCache = cacheContents(); + ASSERT_FALSE(initialCache.isEmpty()) << "first load must write the binary cache"; + + // Drop a new custom set in: the input file list changes, so the next load + // must re-parse. BRD2 is unknown, so its set starts disabled. + QFile customSetFile(root + "/customsets/customset2.xml"); + ASSERT_TRUE(customSetFile.open(QIODevice::WriteOnly | QIODevice::Truncate)); + customSetFile.write("\n" + "\n" + " \n" + " \n" + " Finch\n" + " BRD2\n" + " 0\n" + " Chirp!\n" + " \n" + " \n" + "\n"); + customSetFile.close(); + + db.reloadCardDatabasesAndNotify(); + const QByteArray afterCustomSetAdded = cacheContents(); + EXPECT_NE(initialCache, afterCustomSetAdded) << "adding a custom card database must bust the cache"; + ASSERT_FALSE(db.getCardList().value("Finch").isNull()) << "the custom card must be loaded"; + EXPECT_TRUE(db.getCardList().value("Finch")->getSets().isEmpty()) + << "BRD2 starts disabled, so Finch has no printings yet"; + + // Enabling the freshly discovered set busts the cache again, so the next + // load finally parses its printings (#7455). + controller.setEnabled("BRD2", true); + db.reloadCardDatabasesAndNotify(); + EXPECT_NE(afterCustomSetAdded, cacheContents()) << "enabling a set must bust the cache"; + ASSERT_FALSE(db.getCardList().value("Finch").isNull()); + EXPECT_TRUE(db.getCardList().value("Finch")->getSets().contains("BRD2")); +} + +TEST_F(CardDatabaseCacheInvalidationTest, ExplicitReloadBypassesCache) +{ + NoopCardPreferenceProvider prefs; + SandboxPathProvider pathProvider(root); + CardDatabase db(nullptr, &prefs, &pathProvider, &controller); + + db.loadCardDatabases(); + ASSERT_FALSE(db.getCardList().value("Cat").isNull()); + ASSERT_TRUE(db.getCardList().value("Cat")->getSets().contains("CAT")); + + // Move Cat to the DOG set in place, keeping the file size identical and + // restoring the modification time afterwards: the source hash (path, size, + // mtime) still matches, so the cache stays valid by construction and only + // an explicit reload can pick the edit up. + const QString cardsPath = root + "/cards.xml"; + QFile file(cardsPath); + ASSERT_TRUE(file.open(QIODevice::ReadOnly)); + const qint64 stampedSecs = QFileInfo(cardsPath).lastModified().toSecsSinceEpoch(); + QByteArray contents = file.readAll(); + file.close(); + const QByteArray oldSetCode("CAT"); + const QByteArray newSetCode("DOG"); + ASSERT_EQ(contents.count(oldSetCode), 1) << "fixture must contain exactly one CAT printing"; + contents.replace(contents.indexOf(oldSetCode), oldSetCode.size(), newSetCode); + ASSERT_TRUE(file.open(QIODevice::ReadWrite)); + ASSERT_EQ(file.write(contents), contents.size()); + // Flush the pending write before stamping the time: close() would otherwise + // flush it afterwards and bump the modification time again, invalidating the + // cache on platforms that buffer writes (e.g. macOS). + ASSERT_TRUE(file.flush()); + ASSERT_TRUE(file.setFileTime(QDateTime::fromSecsSinceEpoch(stampedSecs), QFileDevice::FileModificationTime)); + file.close(); + ASSERT_EQ(QFileInfo(cardsPath).lastModified().toSecsSinceEpoch(), stampedSecs) + << "the sandbox file's modification time must be restored for the cache to stay valid"; + + // The cache is still valid for a regular load, which serves the stale data. + db.loadCardDatabases(); + ASSERT_FALSE(db.getCardList().value("Cat").isNull()); + EXPECT_TRUE(db.getCardList().value("Cat")->getSets().contains("CAT")) << "regular load must hit the cache"; + EXPECT_FALSE(db.getCardList().value("Cat")->getSets().contains("DOG")); + + // The explicit reload ignores the cache and re-reads the file. + const QByteArray staleCache = cacheContents(); + db.reloadCardDatabasesAndNotify(); + EXPECT_NE(staleCache, cacheContents()) << "explicit reload must rewrite the cache from disk"; + ASSERT_FALSE(db.getCardList().value("Cat").isNull()); + EXPECT_FALSE(db.getCardList().value("Cat")->getSets().contains("CAT")); + EXPECT_TRUE(db.getCardList().value("Cat")->getSets().contains("DOG")); +} } // namespace int main(int argc, char **argv) diff --git a/tests/carddatabase/filter_string_test.cpp b/tests/carddatabase/filter_string_test.cpp index c6d68be1f..95225e28c 100644 --- a/tests/carddatabase/filter_string_test.cpp +++ b/tests/carddatabase/filter_string_test.cpp @@ -1,10 +1,29 @@ -#include "mocks.h" +#include "libcockatrice/card/card_localization.h" +#include "libcockatrice/card/database/card_database.h" +#include "libcockatrice/card/database/card_database_querier.h" +#include "libcockatrice/card/set/card_set.h" #include "test_card_database_path_provider.h" #include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include +#include +#include + +namespace +{ +class CardQuery; +} #define QUERY(name, card, query, match) \ TEST_F(CardQuery, name) \ @@ -71,10 +90,142 @@ QUERY(Color2, cat, "c:gw", true) QUERY(Color3, cat, "c!g", true) QUERY(Color4, cat, "c!gw", false) +QUERY(SetCodeCaseInsensitive1, cat, "set:cat", true) +QUERY(SetCodeCaseInsensitive2, cat, "set:CAT", true) +QUERY(SetCodeCaseInsensitive3, cat, "set:CAt", true) +QUERY(SetCodeShortForm, cat, "e:cat", true) +QUERY(SetCodeWrongSet, cat, "set:who", false) +QUERY(SetCodeWrongSet2, doctor, "set:cat", false) + QUERY(BracketNextToUnquotedString, cat, "(o:woof OR o:meow)", true) +CardInfoPtr localizedCat() +{ + CardInfoPtr localized = CardInfo::newInstance("Cat", "Meow!", false, {}, {}, {}, {}, {}); + localized->setLocalizedName("de", "Kater"); + localized->setLocalizedText("de", "miaut"); + return localized; +} + +TEST_F(CardQuery, SearchLanguageEnglishMatchesOnlyEnglish) +{ + const CardData localized = localizedCat(); + ASSERT_TRUE(FilterString("Cat", CardSearchLanguage{"de", SearchLanguageMode::English}).check(localized)); + ASSERT_FALSE(FilterString("Kater", CardSearchLanguage{"de", SearchLanguageMode::English}).check(localized)); +} + +TEST_F(CardQuery, SearchLanguageSelectedMatchesLocalizedNameAndText) +{ + const CardData localized = localizedCat(); + ASSERT_TRUE(FilterString("Kater", CardSearchLanguage{"de", SearchLanguageMode::Selected}).check(localized)); + ASSERT_TRUE(FilterString("o:miaut", CardSearchLanguage{"de", SearchLanguageMode::Selected}).check(localized)); + ASSERT_FALSE(FilterString("Cat", CardSearchLanguage{"de", SearchLanguageMode::Selected}).check(localized)); +} + +TEST_F(CardQuery, SearchLanguageSelectedFallsBackToEnglishForUntranslatedCards) +{ + const CardData localized = localizedCat(); + ASSERT_TRUE(FilterString("Cat", CardSearchLanguage{"fr", SearchLanguageMode::Selected}).check(localized)); + ASSERT_FALSE(FilterString("Kater", CardSearchLanguage{"fr", SearchLanguageMode::Selected}).check(localized)); +} + +TEST_F(CardQuery, SearchLanguageBothMatchesEitherLanguage) +{ + const CardData localized = localizedCat(); + ASSERT_TRUE(FilterString("Cat", CardSearchLanguage{"de", SearchLanguageMode::Both}).check(localized)); + ASSERT_TRUE(FilterString("Kater", CardSearchLanguage{"de", SearchLanguageMode::Both}).check(localized)); +} + +TEST_F(CardQuery, SearchLanguageIsBoundPerInstance) +{ + const CardData localized = localizedCat(); + + FilterString germanQuery("Kater", CardSearchLanguage{"de", SearchLanguageMode::Selected}); + ASSERT_TRUE(germanQuery.check(localized)); + + // Constructing an English-bound instance afterwards must not change the + // language the earlier instance searches in. + FilterString englishQuery("Kater", CardSearchLanguage{"", SearchLanguageMode::English}); + ASSERT_FALSE(englishQuery.check(localized)); + ASSERT_TRUE(germanQuery.check(localized)); +} + } // namespace +class SetQuery : public ::testing::Test +{ +protected: + void SetUp() override + { + // EOE and EOC share a release date, like a set and its Commander counterpart. + const QDate sharedReleaseDate(2026, 3, 13); + mainSet = CardSet::newInstance(&controller, "EOE", "Edge of Eternities", "expansion", sharedReleaseDate, + CardSet::PriorityPrimary); + commanderSet = CardSet::newInstance(&controller, "EOC", "Edge of Eternities Commander", "commander", + sharedReleaseDate, CardSet::PrioritySecondary); + oldSet = CardSet::newInstance(&controller, "OLD", "Older Set", "expansion", QDate(2020, 1, 1), + CardSet::PriorityPrimary); + + inBothSets = newCardWithPrintings({{"EOE", mainSet}, {"EOC", commanderSet}}); + onlyInCommanderSet = newCardWithPrintings({{"EOC", commanderSet}}); + onlyInOldSet = newCardWithPrintings({{"OLD", oldSet}}); + + // SetExpression resolves set codes to release dates through the global set list. + auto *database = CardDatabaseManager::getInstance(); + database->addSet(mainSet); + database->addSet(commanderSet); + database->addSet(oldSet); + } + + CardInfoPtr newCardWithPrintings(const QList> &printings) + { + SetToPrintingsMap setsInfo; + for (const auto &printing : printings) { + setsInfo[printing.first].append(PrintingInfo(printing.second)); + } + return CardInfo::newInstance("Test Card", "", false, {}, {}, {}, setsInfo, CardInfo::UiAttributes()); + } + + NoopCardSetPriorityController controller; + CardSetPtr mainSet; + CardSetPtr commanderSet; + CardSetPtr oldSet; + CardInfoPtr inBothSets; + CardInfoPtr onlyInCommanderSet; + CardInfoPtr onlyInOldSet; +}; + +TEST_F(SetQuery, ExactMatchSeparatesSameDaySets) +{ + EXPECT_TRUE(FilterString("set:EOE").check(inBothSets)); + EXPECT_TRUE(FilterString("e:EOE").check(inBothSets)); + EXPECT_TRUE(FilterString("set:EOC").check(inBothSets)); + EXPECT_FALSE(FilterString("set:EOE").check(onlyInCommanderSet)); + EXPECT_TRUE(FilterString("set:EOC").check(onlyInCommanderSet)); +} + +TEST_F(SetQuery, ExactMatchIsCaseInsensitive) +{ + EXPECT_TRUE(FilterString("set:eoe").check(inBothSets)); + EXPECT_TRUE(FilterString("set:EoE").check(inBothSets)); + EXPECT_FALSE(FilterString("set:eoe").check(onlyInCommanderSet)); +} + +TEST_F(SetQuery, NotEqualsMatchesPrintingsOutsideTheSet) +{ + EXPECT_FALSE(FilterString("set!EOE").check(inBothSets)); + EXPECT_TRUE(FilterString("set!EOE").check(onlyInCommanderSet)); +} + +TEST_F(SetQuery, ComparisonMatchesByReleaseDate) +{ + // OLD (2020) predates EOE/EOC (2026-03-13), so comparison operators still use release dates. + EXPECT_TRUE(FilterString("setEOE").check(onlyInOldSet)); + EXPECT_TRUE(FilterString("set>=EOE").check(inBothSets)); + EXPECT_FALSE(FilterString("set #include -#include +#include #include #include -#include #include +#include +#include #include +#include +#include +#include #include -#include +#include #include #include #include #include #include #include +#include static QString defaultXdgPath(const QString &file) { diff --git a/tests/carddatabase/mocks.h b/tests/carddatabase/mocks.h index 016642005..b7cfe8243 100644 --- a/tests/carddatabase/mocks.h +++ b/tests/carddatabase/mocks.h @@ -7,7 +7,7 @@ #define PICTURELOADER_H -#include +#include "libcockatrice/card/card_info.h" class CardPictureLoader { diff --git a/tests/clamped_arithmetic_test.cpp b/tests/clamped_arithmetic_test.cpp index 2471d5870..e5fd070b0 100644 --- a/tests/clamped_arithmetic_test.cpp +++ b/tests/clamped_arithmetic_test.cpp @@ -6,6 +6,7 @@ #include #include #include +#include TEST(AddClamped, AddsWithinBounds) { diff --git a/tests/deck_hash_performance_test.cpp b/tests/deck_hash_performance_test.cpp index 154283e8e..135c218c5 100644 --- a/tests/deck_hash_performance_test.cpp +++ b/tests/deck_hash_performance_test.cpp @@ -1,6 +1,10 @@ #include "gtest/gtest.h" -#include +#include +#include +#include +#include #include +#include static constexpr int amount = 1e5; QString repeatDeck; diff --git a/tests/deck_list_model/CMakeLists.txt b/tests/deck_list_model/CMakeLists.txt new file mode 100644 index 000000000..cd7d693f3 --- /dev/null +++ b/tests/deck_list_model/CMakeLists.txt @@ -0,0 +1,23 @@ +cockatrice_add_test( + NAME + deck_list_model_custom_zones_test + SOURCES + ${VERSION_STRING_CPP} + deck_list_model_custom_zones_test.cpp + LIBS + libcockatrice_models + libcockatrice_card + libcockatrice_deck_list +) + +cockatrice_add_test( + NAME + deck_list_model_zone_integration_test + SOURCES + ${VERSION_STRING_CPP} + deck_list_model_zone_integration_test.cpp + LIBS + libcockatrice_models + libcockatrice_card + libcockatrice_deck_list +) diff --git a/tests/deck_list_model/deck_list_model_custom_zones_test.cpp b/tests/deck_list_model/deck_list_model_custom_zones_test.cpp new file mode 100644 index 000000000..95df74702 --- /dev/null +++ b/tests/deck_list_model/deck_list_model_custom_zones_test.cpp @@ -0,0 +1,285 @@ +/** + * @file deck_list_model_custom_zones_test.cpp + * @brief Tests for the deck list model's custom-zone shadow-tree helpers. + * + * DeckListModelCustomZones centralizes every "what is / where is a custom zone" + * decision for the model's shadow tree: type testing, mirroring from the deck + * tree, name lookup, and the sort-with-custom-zones-last ordering. These tests + * exercise that logic directly on hand-built shadow trees, independent of the + * full model and card database machinery. + */ + +#include "deck_list_model_custom_zones.h" +#include "libcockatrice/deck_list/tree/abstract_deck_list_node.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +DecklistModelCardNode *cardNode(InnerDecklistNode *parent, const QString &name, int number) +{ + // The underlying data node is detached; only the model wrapper is attached to the shadow tree. + auto *data = new DecklistCardNode(name, number, nullptr); + return new DecklistModelCardNode(data, parent); +} + +QStringList childNames(const InnerDecklistNode *node) +{ + QStringList names; + for (int i = 0; i < node->size(); ++i) { + names.append(node->at(i)->getName()); + } + return names; +} + +} // namespace + +// ===================================================================================================================== +// isCustomZone +// ===================================================================================================================== + +TEST(DeckListModelCustomZones, IsCustomZoneDistinguishesZoneFromGroup) +{ + InnerDecklistNode root; + auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); + auto *group = new InnerDecklistNode("Creature", board); + auto *zone = new DecklistModelSubZoneNode("Removal", board); + + auto *card = cardNode(group, "A", 1); + + EXPECT_FALSE(DeckListModelCustomZones::isCustomZone(board)); + EXPECT_FALSE(DeckListModelCustomZones::isCustomZone(group)); + EXPECT_FALSE(DeckListModelCustomZones::isCustomZone(card)); + EXPECT_TRUE(DeckListModelCustomZones::isCustomZone(zone)); +} + +// ===================================================================================================================== +// findSubZoneByName +// ===================================================================================================================== + +TEST(DeckListModelCustomZones, FindSubZoneByNameFindsAcrossBoards) +{ + InnerDecklistNode root; + auto *main = new InnerDecklistNode(DECK_ZONE_MAIN, &root); + auto *side = new InnerDecklistNode(DECK_ZONE_SIDE, &root); + new DecklistModelSubZoneNode("Removal", main); + new DecklistModelSubZoneNode("Utility", side); + new InnerDecklistNode("Plain", main); // not a custom zone + + auto *removal = DeckListModelCustomZones::findSubZoneByName(&root, "Removal"); + ASSERT_NE(removal, nullptr); + EXPECT_EQ(removal->getName(), QString("Removal")); + + auto *utility = DeckListModelCustomZones::findSubZoneByName(&root, "Utility"); + ASSERT_NE(utility, nullptr); + EXPECT_EQ(utility->getName(), QString("Utility")); + + // Names are deck-unique; a plain group or built-in board is not matched. + EXPECT_EQ(DeckListModelCustomZones::findSubZoneByName(&root, "Plain"), nullptr); + EXPECT_EQ(DeckListModelCustomZones::findSubZoneByName(&root, DECK_ZONE_MAIN), nullptr); + EXPECT_EQ(DeckListModelCustomZones::findSubZoneByName(&root, "Missing"), nullptr); +} + +// ===================================================================================================================== +// mirrorCustomZones +// ===================================================================================================================== + +TEST(DeckListModelCustomZones, MirrorCustomZonesCopiesCardsFlat) +{ + // Deck-tree board zone: one direct card plus one nested custom zone. + auto *deckBoard = new InnerDecklistNode(DECK_ZONE_MAIN); + new DecklistCardNode("Direct", 2, deckBoard); + + auto *deckZone = new InnerDecklistNode("Removal", deckBoard); + auto *deckCard1 = new DecklistCardNode("Bolt", 3, deckZone); + auto *deckCard2 = new DecklistCardNode("Swords", 1, deckZone); + + InnerDecklistNode shadowRoot; + auto *shadowBoard = new InnerDecklistNode(DECK_ZONE_MAIN, &shadowRoot); + + DeckListModelCustomZones::mirrorCustomZones(deckBoard, shadowBoard); + + // Only the custom zone is mirrored as a sub-zone; the direct card is not. + ASSERT_EQ(shadowBoard->size(), 1); + auto *shadowZone = dynamic_cast(shadowBoard->at(0)); + ASSERT_NE(shadowZone, nullptr); + EXPECT_EQ(shadowZone->getName(), QString("Removal")); + + // Cards live flat (un-grouped) inside the mirrored zone, wrapping the same data nodes. + ASSERT_EQ(shadowZone->size(), 2); + auto *shadowCard1 = dynamic_cast(shadowZone->at(0)); + auto *shadowCard2 = dynamic_cast(shadowZone->at(1)); + ASSERT_NE(shadowCard1, nullptr); + ASSERT_NE(shadowCard2, nullptr); + EXPECT_EQ(shadowCard1->getDataNode(), deckCard1); + EXPECT_EQ(shadowCard2->getDataNode(), deckCard2); +} + +TEST(DeckListModelCustomZones, MirrorCustomZonesWithNoCustomZonesIsNoop) +{ + // A board zone with only direct cards has nothing to mirror. + auto *deckBoard = new InnerDecklistNode(DECK_ZONE_MAIN); + new DecklistCardNode("Direct", 2, deckBoard); + + InnerDecklistNode shadowRoot; + auto *shadowBoard = new InnerDecklistNode(DECK_ZONE_MAIN, &shadowRoot); + + DeckListModelCustomZones::mirrorCustomZones(deckBoard, shadowBoard); + EXPECT_EQ(shadowBoard->size(), 0); +} + +TEST(DeckListModelCustomZones, MirrorCustomZonesFlattensNestedSubzones) +{ + // Cards deeper than one level under a custom zone still get a model row. + auto *deckBoard = new InnerDecklistNode(DECK_ZONE_MAIN); + auto *deckZone = new InnerDecklistNode("Removal", deckBoard); + auto *deckCard1 = new DecklistCardNode("Bolt", 1, deckZone); + auto *deeper = new InnerDecklistNode("Deeper", deckZone); + auto *deckCard2 = new DecklistCardNode("Swords", 1, deeper); + + InnerDecklistNode shadowRoot; + auto *shadowBoard = new InnerDecklistNode(DECK_ZONE_MAIN, &shadowRoot); + + DeckListModelCustomZones::mirrorCustomZones(deckBoard, shadowBoard); + + ASSERT_EQ(shadowBoard->size(), 1); + auto *shadowZone = dynamic_cast(shadowBoard->at(0)); + ASSERT_NE(shadowZone, nullptr); + EXPECT_EQ(shadowZone->getName(), QString("Removal")); + + // Both cards are flattened into the mirrored zone, preserving order. + ASSERT_EQ(shadowZone->size(), 2); + auto *shadowCard1 = dynamic_cast(shadowZone->at(0)); + auto *shadowCard2 = dynamic_cast(shadowZone->at(1)); + ASSERT_NE(shadowCard1, nullptr); + ASSERT_NE(shadowCard2, nullptr); + EXPECT_EQ(shadowCard1->getDataNode(), deckCard1); + EXPECT_EQ(shadowCard2->getDataNode(), deckCard2); +} + +// ===================================================================================================================== +// findGroupChild +// ===================================================================================================================== + +TEST(DeckListModelCustomZones, FindGroupChildSkipsCustomZones) +{ + InnerDecklistNode root; + auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); + auto *group = new InnerDecklistNode("Creature", board); + new DecklistModelSubZoneNode("Creature", board); + + EXPECT_EQ(DeckListModelCustomZones::findGroupChild(board, "Creature"), group); + EXPECT_EQ(DeckListModelCustomZones::findGroupChild(board, "Missing"), nullptr); + EXPECT_EQ(DeckListModelCustomZones::findGroupChild(&root, DECK_ZONE_MAIN), board); +} + +// ===================================================================================================================== +// sortWithCustomZonesLast +// ===================================================================================================================== + +TEST(DeckListModelCustomZones, SortBoardKeepsCustomZonesAfterGroupsAscending) +{ + InnerDecklistNode root; + auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); + new DecklistModelSubZoneNode("Zebra", board); + new InnerDecklistNode("Creature", board); + new InnerDecklistNode("Instant", board); + new DecklistModelSubZoneNode("Alpha", board); + + root.setSortMethod(DeckSortMethod::ByName); + + auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::AscendingOrder); + + // Groups sort first (by name), then custom zones (by name), always after groups. + EXPECT_EQ(childNames(board), (QStringList{"Creature", "Instant", "Alpha", "Zebra"})); + + // Some non-identity movement occurred. + EXPECT_FALSE(mapping.isEmpty()); +} + +TEST(DeckListModelCustomZones, SortBoardKeepsCustomZonesAfterGroupsDescending) +{ + InnerDecklistNode root; + auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); + new DecklistModelSubZoneNode("Zebra", board); + new InnerDecklistNode("Creature", board); + new InnerDecklistNode("Instant", board); + new DecklistModelSubZoneNode("Alpha", board); + + root.setSortMethod(DeckSortMethod::ByName); + + (void)DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::DescendingOrder); + + // Groups still lead (descending), custom zones still last. + EXPECT_EQ(childNames(board), (QStringList{"Instant", "Creature", "Zebra", "Alpha"})); +} + +TEST(DeckListModelCustomZones, SortBoardMappingIsConsistent) +{ + InnerDecklistNode root; + auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); + + QList originalOrder; + auto *g0 = new InnerDecklistNode("Creature", board); + originalOrder.append(g0); + auto *z0 = new DecklistModelSubZoneNode("Zebra", board); + originalOrder.append(z0); + auto *g1 = new InnerDecklistNode("Instant", board); + originalOrder.append(g1); + auto *z1 = new DecklistModelSubZoneNode("Alpha", board); + originalOrder.append(z1); + + root.setSortMethod(DeckSortMethod::ByName); + + auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(&root, board, Qt::AscendingOrder); + + // The mapping reports, for each final row, the original row of the node now sitting there. + ASSERT_EQ(mapping.size(), board->size()); + for (const auto &move : mapping) { + const int preSortRow = move.first; + const int finalRow = move.second; + ASSERT_GE(preSortRow, 0); + ASSERT_LT(preSortRow, originalOrder.size()); + EXPECT_EQ(board->at(finalRow), originalOrder[preSortRow]) << "row " << finalRow; + } + + // Final order sanity: groups first in name order, then custom zones. + EXPECT_EQ(childNames(board), (QStringList{"Creature", "Instant", "Alpha", "Zebra"})); +} + +TEST(DeckListModelCustomZones, SortPlainNodeDoesNotReorderCustomZones) +{ + // A non-board node (e.g. a group whose children are cards) is sorted plainly; + // custom zones are not a special case there. Cards sort by name. + InnerDecklistNode root; + auto *board = new InnerDecklistNode(DECK_ZONE_MAIN, &root); + auto *group = new InnerDecklistNode("Creature", board); + cardNode(group, "Swords", 1); + cardNode(group, "Bolt", 3); + + root.setSortMethod(DeckSortMethod::ByName); + + auto mapping = DeckListModelCustomZones::sortWithCustomZonesLast(&root, group, Qt::AscendingOrder); + EXPECT_EQ(childNames(group), (QStringList{"Bolt", "Swords"})); + ASSERT_EQ(mapping.size(), 2); + EXPECT_EQ(mapping[0].first, 1); // "Bolt" was originally at row 1 + EXPECT_EQ(mapping[0].second, 0); + EXPECT_EQ(mapping[1].first, 0); + EXPECT_EQ(mapping[1].second, 1); +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/deck_list_model/deck_list_model_zone_integration_test.cpp b/tests/deck_list_model/deck_list_model_zone_integration_test.cpp new file mode 100644 index 000000000..63c904910 --- /dev/null +++ b/tests/deck_list_model/deck_list_model_zone_integration_test.cpp @@ -0,0 +1,296 @@ +#include "libcockatrice/card/database/card_database.h" +#include "libcockatrice/deck_list/tree/abstract_deck_list_node.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class QModelIndex; + +namespace +{ + +int totalCustomZoneRows(const DeckListModel &model) +{ + int count = 0; + const int rootRows = model.rowCount(QModelIndex()); + for (int r = 0; r < rootRows; ++r) { + const QModelIndex board = model.index(r, 0, QModelIndex()); + const int childRows = model.rowCount(board); + for (int c = 0; c < childRows; ++c) { + const QModelIndex child = model.index(c, 0, board); + if (child.data(DeckRoles::IsCustomZoneRole).toBool()) { + ++count; + } + } + } + return count; +} + +QModelIndex findBoardIndex(const DeckListModel &model, const QString &boardName) +{ + for (int r = 0; r < model.rowCount(QModelIndex()); ++r) { + const QModelIndex idx = model.index(r, 0, QModelIndex()); + if (idx.data(DeckRoles::IsCardRole).toBool()) { + continue; + } + const QString name = idx.sibling(idx.row(), DeckListModelColumns::CARD_NAME).data(Qt::EditRole).toString(); + if (name == boardName) { + return idx; + } + } + return {}; +} + +QModelIndex findZoneRow(const DeckListModel &model, const QModelIndex &board) +{ + for (int r = 0; r < model.rowCount(board); ++r) { + const QModelIndex child = model.index(r, 0, board); + if (child.data(DeckRoles::IsCustomZoneRole).toBool()) { + return child; + } + } + return {}; +} + +} // namespace + +// The "Add to Zone" combobox/submenu lists getCustomZoneNames(), which reads the +// deck tree. These verify the source data a freshly-created zone populates. + +TEST(DeckListModelZoneIntegration, CreateZoneThenReadCustomZoneNames) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + EXPECT_EQ(model.getCustomZoneNames(DECK_ZONE_MAIN), (QStringList{"Removal"})); +} + +TEST(DeckListModelZoneIntegration, CreateTwoZonesThenReadBoth) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Utility"), nullptr); + EXPECT_EQ(model.getCustomZoneNames(DECK_ZONE_MAIN), (QStringList{"Removal", "Utility"})); +} + +// Mirroring regression: rebuildTree must mirror each custom zone exactly once. +TEST(DeckListModelZoneIntegration, RebuildTreeMirrorsEachZoneOnce) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + // One direct mainboard card plus two nested custom zones. + tree->addCard("Lightning Bolt", 2, DECK_ZONE_MAIN, -1); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + tree->addCard("Swords to Plowshares", 1, "Removal", -1); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Utility"), nullptr); + + model.rebuildTree(); + + EXPECT_EQ(model.getCustomZoneNames(DECK_ZONE_MAIN), (QStringList{"Removal", "Utility"})); + EXPECT_EQ(totalCustomZoneRows(model), 2); +} + +// ===================================================================================================================== +// Model behaviour: addCard routing, findCard lookup, removeRows guard, empty-zone survival. +// ===================================================================================================================== + +TEST(DeckListModelZoneIntegration, AddCardRoutesIntoMirroredCustomZone) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + model.rebuildTree(); + + QModelIndex added = model.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), "Removal"); + ASSERT_TRUE(added.isValid()); + + // The card is a direct child of the mirrored custom zone, not a new top-level zone. + const QModelIndex zoneParent = added.parent(); + ASSERT_TRUE(zoneParent.isValid()); + EXPECT_TRUE(zoneParent.data(DeckRoles::IsCustomZoneRole).toBool()); + EXPECT_EQ(zoneParent.sibling(zoneParent.row(), DeckListModelColumns::CARD_NAME).data(Qt::DisplayRole).toString(), + QString("Removal")); + + // No "Removal" top-level zone appeared in the deck tree. + auto *listRoot = tree->getRoot(); + bool topLevelRemoval = false; + for (int i = 0; i < listRoot->size(); ++i) { + if (auto *zone = dynamic_cast(listRoot->at(i))) { + topLevelRemoval |= zone->getName() == "Removal"; + } + } + EXPECT_FALSE(topLevelRemoval); +} + +TEST(DeckListModelZoneIntegration, AddCardToUnmirroredCustomZoneRebuildsNotCreatesTopLevel) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + // The zone exists on the deck tree but the shadow tree has never mirrored it. + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + + QModelIndex added = model.addCard(ExactCard(CardInfo::newInstance("Lightning Bolt")), "Removal"); + ASSERT_TRUE(added.isValid()); + + const QModelIndex zoneParent = added.parent(); + ASSERT_TRUE(zoneParent.isValid()); + EXPECT_TRUE(zoneParent.data(DeckRoles::IsCustomZoneRole).toBool()); + EXPECT_EQ(zoneParent.sibling(zoneParent.row(), DeckListModelColumns::CARD_NAME).data(Qt::DisplayRole).toString(), + QString("Removal")); +} + +TEST(DeckListModelZoneIntegration, AddCardCreatesGroupSeparatelyFromSameNamedZone) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + // A custom zone named exactly like a grouping criterion. + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Creature"), nullptr); + model.rebuildTree(); + + CardInfoPtr bear = CardInfo::newInstance("Grizzly Bears"); + bear->setProperty(Mtg::MainCardType, "Creature"); + + QModelIndex added = model.addCard(ExactCard(bear), DECK_ZONE_MAIN); + ASSERT_TRUE(added.isValid()); + + // The card lands in a *group* node called "Creature", not swallowed by the custom zone. + const QModelIndex groupParent = added.parent(); + ASSERT_TRUE(groupParent.isValid()); + EXPECT_FALSE(groupParent.data(DeckRoles::IsCustomZoneRole).toBool()); + EXPECT_EQ(groupParent.sibling(groupParent.row(), DeckListModelColumns::CARD_NAME).data(Qt::DisplayRole).toString(), + QString("Creature")); + + // The board keeps both rows: the "Creature" group and the "Creature" custom zone. + const QModelIndex boardIndex = groupParent.parent(); + ASSERT_TRUE(boardIndex.isValid()); + EXPECT_EQ(model.rowCount(boardIndex), 2); +} + +TEST(DeckListModelZoneIntegration, FindCardResolvesCardInsideCustomZone) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + model.rebuildTree(); + + // findCard resolves through the card database; register the card we add. + const QString cardName = "Swords to Plowshares"; + CardInfoPtr info = CardInfo::newInstance(cardName); + CardDatabaseManager::getInstance()->addCard(info); + + QModelIndex added = model.addCard(ExactCard(info), "Removal"); + ASSERT_TRUE(added.isValid()); + + QModelIndex found = model.findCard(cardName, "Removal"); + EXPECT_TRUE(found.isValid()); + EXPECT_EQ(found, added); +} + +TEST(DeckListModelZoneIntegration, RemoveRowsRefusesCustomZoneRow) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + tree->addCard("Lightning Bolt", 2, DECK_ZONE_MAIN, -1); + model.rebuildTree(); + + const QModelIndex mainIndex = findBoardIndex(model, DECK_ZONE_MAIN); + ASSERT_TRUE(mainIndex.isValid()); + const QModelIndex zoneRow = findZoneRow(model, mainIndex); + ASSERT_TRUE(zoneRow.isValid()); + + EXPECT_FALSE(model.removeRow(zoneRow.row(), zoneRow.parent())); + EXPECT_EQ(model.rowCount(mainIndex), 2); // the zone survives, alongside the card group +} + +TEST(DeckListModelZoneIntegration, EmptyCustomZoneSurvivesMirrorAndPruning) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + // An empty custom zone must be mirrored (the stack deliberately keeps it alive). + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + model.rebuildTree(); + + const QModelIndex mainIndex = findBoardIndex(model, DECK_ZONE_MAIN); + ASSERT_TRUE(mainIndex.isValid()); + EXPECT_EQ(model.rowCount(mainIndex), 1); + EXPECT_TRUE(findZoneRow(model, mainIndex).isValid()); +} + +// Regression: a board card named like the requested zone must not be mistaken for +// a zone. Previously `findChild` matched any child by name, so a mainboard card +// called "Lightning Bolt" made addCard believe a "Lightning Bolt" zone existed and +// recurse through rebuildTree forever. +TEST(DeckListModelZoneIntegration, AddCardToCardNamedZoneDoesNotRecurse) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + tree->addCard("Lightning Bolt", 2, DECK_ZONE_MAIN, -1); + + QModelIndex added = model.addCard(ExactCard(CardInfo::newInstance("Swords to Plowshares")), "Lightning Bolt"); + ASSERT_TRUE(added.isValid()); +} + +// Regression: adding to a custom zone that holds a nested sub-zone mirrored the +// nested cards as flattened shadow rows, so the sorted shadow row index pointed +// past the deck zone's direct children. The card must be appended to the deck +// zone instead of being written out of range. +TEST(DeckListModelZoneIntegration, AddCardToCustomZoneWithNestedSubZoneAppends) +{ + QSharedPointer deck(new DeckList()); + DeckListModel model(nullptr, deck); + auto *tree = deck->getTree(); + + auto *removal = tree->addCustomZone(DECK_ZONE_MAIN, "Removal"); + ASSERT_NE(removal, nullptr); + auto *deeper = new InnerDecklistNode("Deeper", removal); + new DecklistCardNode("Lightning Bolt", 2, deeper, -1); + model.rebuildTree(); + + QModelIndex added = model.addCard(ExactCard(CardInfo::newInstance("Swords to Plowshares")), "Removal"); + ASSERT_TRUE(added.isValid()); + ASSERT_TRUE(added.parent().data(DeckRoles::IsCustomZoneRole).toBool()); +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/deck_list_zones/CMakeLists.txt b/tests/deck_list_zones/CMakeLists.txt new file mode 100644 index 000000000..7af8fd187 --- /dev/null +++ b/tests/deck_list_zones/CMakeLists.txt @@ -0,0 +1 @@ +cockatrice_add_test(NAME deck_list_zones_test SOURCES deck_list_zones_test.cpp LIBS libcockatrice_deck_list) diff --git a/tests/deck_list_zones/deck_list_zones_test.cpp b/tests/deck_list_zones/deck_list_zones_test.cpp new file mode 100644 index 000000000..1903b70df --- /dev/null +++ b/tests/deck_list_zones/deck_list_zones_test.cpp @@ -0,0 +1,400 @@ +/** + * @file deck_list_zones_test.cpp + * @brief Tests for custom deck zones (deck-unique zones nested under a board zone). + * + * Custom zones allow players to organize cards within a board (e.g. "Removal" under + * the mainboard) without changing the board semantics: cards in a custom zone under + * "main" are still mainboard cards for hashing, sideboard size, legality and export. + */ + +#include "libcockatrice/deck_list/tree/abstract_deck_list_node.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +/** + * @brief Collects (board zone name, card node) pairs via forEachCard. + */ +struct BoardCardPair +{ + QString boardZone; + QString cardName; + int amount; +}; + +QList collectBoardCardPairs(const DeckList &deck) +{ + QList result; + deck.forEachCard([&result](InnerDecklistNode *boardZone, DecklistCardNode *card) { + result.append({boardZone->getName(), card->getName(), card->getNumber()}); + }); + return result; +} + +bool hasPair(const QList &pairs, const QString &boardZone, const QString &cardName) +{ + for (const auto &pair : pairs) { + if (pair.boardZone == boardZone && pair.cardName == cardName) { + return true; + } + } + return false; +} + +int totalCards(const QList &pairs) +{ + int total = 0; + for (const auto &pair : pairs) { + total += pair.amount; + } + return total; +} + +} // namespace + +// ===================================================================================================================== +// Zone creation +// ===================================================================================================================== + +TEST(DeckListZones, AddCustomZoneNestsUnderBoard) +{ + DeckList deck; + auto *tree = deck.getTree(); + + auto *zone = tree->addCustomZone(DECK_ZONE_MAIN, "Removal"); + ASSERT_NE(zone, nullptr); + EXPECT_EQ(zone->getName(), QString("Removal")); + ASSERT_NE(zone->getParent(), nullptr); + EXPECT_EQ(zone->getParent()->getName(), QString(DECK_ZONE_MAIN)); + + // The custom zone is nested, not a new top-level zone. + auto topLevelZones = tree->getZoneNodes(); + QStringList topLevelNames; + for (auto *node : topLevelZones) { + topLevelNames.append(node->getName()); + } + EXPECT_FALSE(topLevelNames.contains("Removal")); + + // It is discoverable through the board zone. + auto customZones = tree->getCustomZones(DECK_ZONE_MAIN); + ASSERT_EQ(customZones.size(), 1); + EXPECT_EQ(customZones.first()->getName(), QString("Removal")); +} + +TEST(DeckListZones, CustomZoneNamesAreDeckUnique) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + // Same name on a different board is rejected. + EXPECT_EQ(tree->addCustomZone(DECK_ZONE_SIDE, "Removal"), nullptr); + // A name that collides with a built-in board zone is rejected. + EXPECT_EQ(tree->addCustomZone(DECK_ZONE_MAIN, DECK_ZONE_MAIN), nullptr); + EXPECT_EQ(tree->addCustomZone(DECK_ZONE_MAIN, DECK_ZONE_SIDE), nullptr); + EXPECT_EQ(tree->addCustomZone(DECK_ZONE_MAIN, DECK_ZONE_MAYBEBOARD), nullptr); + EXPECT_EQ(tree->addCustomZone(DECK_ZONE_MAIN, DECK_ZONE_TOKENS), nullptr); +} + +TEST(DeckListZones, AddCustomZoneUnknownBoardFails) +{ + DeckList deck; + auto *tree = deck.getTree(); + + EXPECT_EQ(tree->addCustomZone("not_a_board", "Removal"), nullptr); +} + +TEST(DeckListZones, MaybeboardIsLazilyCreated) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAYBEBOARD, "Candidates"), nullptr); + + // The maybeboard board zone now exists, with the custom zone nested inside. + auto customZones = tree->getCustomZones(DECK_ZONE_MAYBEBOARD); + ASSERT_EQ(customZones.size(), 1); + EXPECT_EQ(customZones.first()->getName(), QString("Candidates")); +} + +// ===================================================================================================================== +// Card placement +// ===================================================================================================================== + +TEST(DeckListZones, AddCardToCustomZoneKeepsBoardSemantics) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + tree->addCard("Lightning Bolt", 4, "Removal", -1); + + // The card is reported as a mainboard card. + auto pairs = collectBoardCardPairs(deck); + EXPECT_TRUE(hasPair(pairs, DECK_ZONE_MAIN, "Lightning Bolt")); + EXPECT_FALSE(hasPair(pairs, DECK_ZONE_SIDE, "Lightning Bolt")); + + // It is physically nested inside the custom zone. + auto customZones = tree->getCustomZones(DECK_ZONE_MAIN); + ASSERT_EQ(customZones.size(), 1); + ASSERT_EQ(customZones.first()->size(), 1); + auto *card = dynamic_cast(customZones.first()->at(0)); + ASSERT_NE(card, nullptr); + EXPECT_EQ(card->getName(), QString("Lightning Bolt")); + EXPECT_EQ(card->getNumber(), 4); + + // Zone-scoped queries include it. + EXPECT_TRUE(deck.getCardList({DECK_ZONE_MAIN}).contains("Lightning Bolt")); + EXPECT_FALSE(deck.getCardList({DECK_ZONE_SIDE}).contains("Lightning Bolt")); + EXPECT_EQ(deck.getCardNodes({DECK_ZONE_MAIN}).size(), 1); +} + +TEST(DeckListZones, LegacyTopLevelZoneStillWorks) +{ + DeckList deck; + auto *tree = deck.getTree(); + + // Unknown zone names create a legacy top-level zone (backwards compatibility). + tree->addCard("Legacy Card", 2, "custom_legacy_zone", -1); + + auto pairs = collectBoardCardPairs(deck); + EXPECT_TRUE(hasPair(pairs, "custom_legacy_zone", "Legacy Card")); + EXPECT_EQ(deck.getCardList({}).count("Legacy Card"), 1); +} + +// ===================================================================================================================== +// Zone management +// ===================================================================================================================== + +TEST(DeckListZones, RenameCustomZone) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + tree->addCard("Lightning Bolt", 2, "Removal", -1); + + EXPECT_TRUE(tree->renameCustomZone("Removal", "Bolt Zone")); + EXPECT_TRUE(hasPair(collectBoardCardPairs(deck), DECK_ZONE_MAIN, "Lightning Bolt")); + EXPECT_EQ(tree->getCustomZones(DECK_ZONE_MAIN).size(), 1); + EXPECT_EQ(tree->getCustomZones(DECK_ZONE_MAIN).first()->getName(), QString("Bolt Zone")); + + // Renaming to a taken name fails. + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Other"), nullptr); + EXPECT_FALSE(tree->renameCustomZone("Bolt Zone", "Other")); + // Renaming a nonexistent zone fails. + EXPECT_FALSE(tree->renameCustomZone("Ghost Zone", "Whatever")); +} + +TEST(DeckListZones, MoveCustomZoneMovesCards) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + tree->addCard("Lightning Bolt", 2, "Removal", -1); + + EXPECT_TRUE(tree->moveCustomZone("Removal", DECK_ZONE_SIDE)); + + auto pairs = collectBoardCardPairs(deck); + EXPECT_TRUE(hasPair(pairs, DECK_ZONE_SIDE, "Lightning Bolt")); + EXPECT_FALSE(hasPair(pairs, DECK_ZONE_MAIN, "Lightning Bolt")); + + // The custom zone is now nested under side. + EXPECT_EQ(tree->getCustomZones(DECK_ZONE_MAIN).size(), 0); + EXPECT_EQ(tree->getCustomZones(DECK_ZONE_SIDE).size(), 1); + + // Moving to an unknown board fails. + EXPECT_FALSE(tree->moveCustomZone("Removal", "not_a_board")); +} + +TEST(DeckListZones, MoveCustomZoneFailsForUnknownBoard) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + tree->addCard("Lightning Bolt", 2, "Removal", -1); + + EXPECT_FALSE(tree->moveCustomZone("Removal", "not_a_board")); + + // The zone is still under main. + EXPECT_EQ(tree->getCustomZones(DECK_ZONE_MAIN).size(), 1); +} + +// Regression: findCustomZoneByName walks every top-level zone, so a custom zone +// an imported deck carries under a non-standard board (tokens) is still found and +// movable. The pre-fix manager-level moveCustomZone only scanned the standard +// boards and returned false for these with no feedback. +TEST(DeckListZones, MoveCustomZoneNestedUnderTokensBoard) +{ + DeckList deck; + auto *tree = deck.getTree(); + auto *root = tree->getRoot(); + + auto *tokens = new InnerDecklistNode(DECK_ZONE_TOKENS, root); + auto *removal = new InnerDecklistNode("Removal", tokens); + new DecklistCardNode("Lightning Bolt", 2, removal, -1); + + EXPECT_TRUE(tree->findCustomZoneByName("Removal")); + EXPECT_TRUE(tree->moveCustomZone("Removal", DECK_ZONE_SIDE)); + + auto pairs = collectBoardCardPairs(deck); + EXPECT_FALSE(hasPair(pairs, DECK_ZONE_TOKENS, "Lightning Bolt")); + EXPECT_TRUE(hasPair(pairs, DECK_ZONE_SIDE, "Lightning Bolt")); +} + +TEST(DeckListZones, RemoveCustomZoneRemovesCards) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + tree->addCard("Lightning Bolt", 2, "Removal", -1); + + EXPECT_TRUE(tree->removeCustomZone("Removal")); + EXPECT_EQ(tree->getCustomZones(DECK_ZONE_MAIN).size(), 0); + EXPECT_TRUE(deck.getCardList({DECK_ZONE_MAIN}).isEmpty()); + EXPECT_FALSE(tree->removeCustomZone("Removal")); +} + +TEST(DeckListZones, EmptyCustomZoneIsKeptOnCardDeletion) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + auto *card = tree->addCard("Lightning Bolt", 1, "Removal", -1); + + // Deleting the last card must not delete the empty custom zone. + EXPECT_TRUE(tree->deleteNode(card)); + EXPECT_EQ(tree->getCustomZones(DECK_ZONE_MAIN).size(), 1); +} + +// ===================================================================================================================== +// Deck-wide behavior +// ===================================================================================================================== + +TEST(DeckListZones, HashCountsCustomZoneCardsByBoard) +{ + // Deck A: cards directly in main and side. + DeckList direct; + direct.addCard("Mountain", DECK_ZONE_MAIN); + direct.addCard("Lightning Bolt", DECK_ZONE_MAIN); + direct.addCard("Island", DECK_ZONE_SIDE); + + // Deck B: identical, but organized in custom zones. + DeckList organized; + auto *tree = organized.getTree(); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Lands"), nullptr); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_SIDE, "Side Tech"), nullptr); + tree->addCard("Mountain", 1, "Lands", -1); + tree->addCard("Lightning Bolt", 1, "Removal", -1); + tree->addCard("Island", 1, "Side Tech", -1); + + EXPECT_EQ(direct.getDeckHash(), organized.getDeckHash()); +} + +TEST(DeckListZones, SideboardSizeCountsCustomZoneCards) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_SIDE, "Side Tech"), nullptr); + tree->addCard("Island", 3, "Side Tech", -1); + tree->addCard("Forest", 2, DECK_ZONE_SIDE, -1); + + EXPECT_EQ(deck.getSideboardSize(), 5); +} + +TEST(DeckListZones, PlainExportIncludesMainAndSideCustomZones) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_SIDE, "Side Tech"), nullptr); + tree->addCard("Lightning Bolt", 2, "Removal", -1); + tree->addCard("Island", 1, "Side Tech", -1); + + const QString plain = deck.writeToString_Plain(false, false); + EXPECT_TRUE(plain.contains("2 Lightning Bolt")); + EXPECT_TRUE(plain.contains("1 Island")); +} + +TEST(DeckListZones, PlainExportSkipsMaybeboardCustomZones) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAYBEBOARD, "Candidates"), nullptr); + tree->addCard("Wish Card", 4, "Candidates", -1); + tree->addCard("Mountain", 1, DECK_ZONE_MAIN, -1); + + const QString plain = deck.writeToString_Plain(false, false); + EXPECT_FALSE(plain.contains("Wish Card")); + EXPECT_TRUE(plain.contains("1 Mountain")); +} + +TEST(DeckListZones, NativeRoundTripPreservesCustomZones) +{ + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAIN, "Removal"), nullptr); + ASSERT_NE(tree->addCustomZone(DECK_ZONE_SIDE, "Side Tech"), nullptr); + tree->addCard("Lightning Bolt", 2, "Removal", -1); + tree->addCard("Island", 3, "Side Tech", -1); + tree->addCard("Mountain", 1, DECK_ZONE_MAIN, -1); + + // Round-trip through the native format. + DeckList restored(deck.writeToString_Native()); + auto *restoredTree = restored.getTree(); + + EXPECT_EQ(restored.getDeckHash(), deck.getDeckHash()); + EXPECT_EQ(restoredTree->getCustomZones(DECK_ZONE_MAIN).size(), 1); + EXPECT_EQ(restoredTree->getCustomZones(DECK_ZONE_MAIN).first()->getName(), QString("Removal")); + EXPECT_EQ(restoredTree->getCustomZones(DECK_ZONE_SIDE).size(), 1); + + auto pairs = collectBoardCardPairs(restored); + EXPECT_TRUE(hasPair(pairs, DECK_ZONE_MAIN, "Lightning Bolt")); + EXPECT_TRUE(hasPair(pairs, DECK_ZONE_SIDE, "Island")); + EXPECT_TRUE(hasPair(pairs, DECK_ZONE_MAIN, "Mountain")); + EXPECT_EQ(totalCards(pairs), 6); +} + +TEST(DeckListZones, MaybeboardCustomZoneCardsAreExcludedFromHash) +{ + // Maybeboard cards are editor-only and must never affect the deck hash. + DeckList deck; + auto *tree = deck.getTree(); + + ASSERT_NE(tree->addCustomZone(DECK_ZONE_MAYBEBOARD, "Candidates"), nullptr); + tree->addCard("Wish Card", 4, "Candidates", -1); + tree->addCard("Mountain", 1, DECK_ZONE_MAIN, -1); + + DeckList expected; + expected.addCard("Mountain", DECK_ZONE_MAIN); + + EXPECT_EQ(deck.getDeckHash(), expected.getDeckHash()); +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/dummy_test.cpp b/tests/dummy_test.cpp deleted file mode 100644 index 31a01bcff..000000000 --- a/tests/dummy_test.cpp +++ /dev/null @@ -1,19 +0,0 @@ -#include "gtest/gtest.h" - -namespace -{ -class FooTest : public ::testing::Test -{ -}; - -TEST(DummyTest, Works) -{ - ASSERT_EQ(1, 1) << "One is not equal to one"; -} -} // namespace - -int main(int argc, char **argv) -{ - ::testing::InitGoogleTest(&argc, argv); - return RUN_ALL_TESTS(); -} \ No newline at end of file diff --git a/tests/expression_test.cpp b/tests/expression_test.cpp index 4fbe98cb9..b3c4b586c 100644 --- a/tests/expression_test.cpp +++ b/tests/expression_test.cpp @@ -1,6 +1,7 @@ #include "gtest/gtest.h" #include #include +#include #define TEST_EXPR(name, a, b) \ TEST(ExpressionTest, name) \ diff --git a/tests/lag_monitor_test.cpp b/tests/lag_monitor_test.cpp new file mode 100644 index 000000000..b404a2c9e --- /dev/null +++ b/tests/lag_monitor_test.cpp @@ -0,0 +1,105 @@ +#include "client/lag_monitor.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +/// Timestamps are taken at recording time; allow generous scheduler slack. +constexpr qint64 TIMESTAMP_SLACK_MS = 10000; + +} // namespace + +class LagMonitorTest : public ::testing::Test +{ +protected: + LagMonitor monitor; +}; + +TEST_F(LagMonitorTest, GapAtOrBelowThresholdIsIgnored) +{ + monitor.recordGap(0); + monitor.recordGap(LagMonitor::TICK_INTERVAL_MS); + monitor.recordGap(LagMonitor::STALL_THRESHOLD_MS); + + EXPECT_TRUE(monitor.recentStalls().isEmpty()); +} + +TEST_F(LagMonitorTest, GapAboveThresholdIsRecorded) +{ + monitor.recordGap(LagMonitor::STALL_THRESHOLD_MS + 1); + + const QList stalls = monitor.recentStalls(); + ASSERT_EQ(1, stalls.size()); + EXPECT_EQ(LagMonitor::STALL_THRESHOLD_MS + 1, stalls.first().durationMs); +} + +TEST_F(LagMonitorTest, RecordedTimestampIsFresh) +{ + monitor.recordGap(LagMonitor::STALL_THRESHOLD_MS + 1); + + const qint64 now = QDateTime::currentMSecsSinceEpoch(); + ASSERT_EQ(1, monitor.recentStalls().size()); + EXPECT_LE(qAbs(monitor.recentStalls().first().timestampMsSinceEpoch - now), TIMESTAMP_SLACK_MS); +} + +TEST_F(LagMonitorTest, RecordsAreTrimmedToMaxOldestFirst) +{ + for (int i = 0; i < LagMonitor::MAX_RECORDED_STALLS + 5; ++i) { + monitor.recordGap(LagMonitor::STALL_THRESHOLD_MS + 1 + i); + } + + const QList stalls = monitor.recentStalls(); + ASSERT_EQ(LagMonitor::MAX_RECORDED_STALLS, stalls.size()); + EXPECT_EQ(LagMonitor::STALL_THRESHOLD_MS + 6, stalls.first().durationMs); + EXPECT_EQ(LagMonitor::STALL_THRESHOLD_MS + 5 + LagMonitor::MAX_RECORDED_STALLS, stalls.last().durationMs); +} + +TEST_F(LagMonitorTest, GapAtPlausibilityCapIsKept) +{ + monitor.recordGap(LagMonitor::MAX_PLAUSIBLE_STALL_MS); + + ASSERT_EQ(1, monitor.recentStalls().size()); + EXPECT_EQ(LagMonitor::MAX_PLAUSIBLE_STALL_MS, monitor.recentStalls().first().durationMs); +} + +TEST_F(LagMonitorTest, GapBeyondPlausibilityCapIsDropped) +{ + monitor.recordGap(LagMonitor::MAX_PLAUSIBLE_STALL_MS + 1); + + EXPECT_TRUE(monitor.recentStalls().isEmpty()); +} + +TEST_F(LagMonitorTest, ClearStallsEmptiesList) +{ + monitor.recordGap(LagMonitor::STALL_THRESHOLD_MS + 1); + ASSERT_EQ(1, monitor.recentStalls().size()); + + monitor.clearStalls(); + + EXPECT_TRUE(monitor.recentStalls().isEmpty()); +} + +TEST_F(LagMonitorTest, ApplicationStateChangeDoesNotRecordAStall) +{ + QObject probe; + QEvent event(QEvent::ApplicationStateChange); + + QCoreApplication::sendEvent(&probe, &event); + + EXPECT_TRUE(monitor.recentStalls().isEmpty()); +} + +int main(int argc, char **argv) +{ + QLoggingCategory::setFilterRules("lag_monitor.*=false"); + QCoreApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/latency_tracker_test.cpp b/tests/latency_tracker_test.cpp new file mode 100644 index 000000000..a77051799 --- /dev/null +++ b/tests/latency_tracker_test.cpp @@ -0,0 +1,153 @@ +#include +#include +#include +#include +#include +#include + +TEST(LatencyTrackerTest, EmptyTrackerYieldsZeroedStats) +{ + LatencyTracker tracker; + + const auto stats = tracker.stats(); + + EXPECT_EQ(0, stats.sampleCount); + EXPECT_EQ(0, stats.lastMs); + EXPECT_EQ(0, stats.medianMs); + EXPECT_EQ(0, stats.p95Ms); + EXPECT_EQ(0, stats.maxMs); +} + +TEST(LatencyTrackerTest, SingleSampleIsEveryStatistic) +{ + LatencyTracker tracker; + tracker.addSample(42); + + const auto stats = tracker.stats(); + + EXPECT_EQ(1, stats.sampleCount); + EXPECT_EQ(42, stats.lastMs); + EXPECT_EQ(42, stats.medianMs); + EXPECT_EQ(42, stats.p95Ms); + EXPECT_EQ(42, stats.maxMs); +} + +TEST(LatencyTrackerTest, OddSizedWindowMedianAndP95) +{ + LatencyTracker tracker; + for (qint64 ms : {qint64(50), qint64(10), qint64(30), qint64(20), qint64(40)}) { + tracker.addSample(ms); + } + + const auto stats = tracker.stats(); + + EXPECT_EQ(5, stats.sampleCount); + EXPECT_EQ(40, stats.lastMs); + EXPECT_EQ(30, stats.medianMs); + // nearest-rank p95 of 5 samples: ceil(4.75) = 5th smallest + EXPECT_EQ(50, stats.p95Ms); + EXPECT_EQ(50, stats.maxMs); +} + +TEST(LatencyTrackerTest, EvenSizedWindowMedianIsAverageOfMiddleTwo) +{ + LatencyTracker tracker; + for (qint64 ms : {qint64(10), qint64(20), qint64(30), qint64(40)}) { + tracker.addSample(ms); + } + + const auto stats = tracker.stats(); + + EXPECT_EQ(4, stats.sampleCount); + EXPECT_EQ(25, stats.medianMs); + // nearest-rank p95 of 4 samples: ceil(3.8) = 4th smallest + EXPECT_EQ(40, stats.p95Ms); +} + +TEST(LatencyTrackerTest, WindowEvictsOldestSamples) +{ + LatencyTracker tracker; + for (int i = 0; i <= 99; ++i) { + tracker.addSample(i); + } + + const auto stats = tracker.stats(); + + EXPECT_EQ(LatencyTracker::WindowSize, stats.sampleCount); + EXPECT_EQ(99, stats.lastMs); + EXPECT_EQ(99, stats.maxMs); + // window now contains 36..99 (64 samples) + EXPECT_EQ(67, stats.medianMs); // (67 + 68) / 2 with integer division + EXPECT_EQ(96, stats.p95Ms); // ceil(0.95 * 64) - 1 = index 60 -> 36 + 60 +} + +TEST(LatencyTrackerTest, ClearResetsAllState) +{ + LatencyTracker tracker; + for (int i = 0; i <= 99; ++i) { + tracker.addSample(i); + } + + tracker.clear(); + const auto cleared = tracker.stats(); + EXPECT_EQ(0, cleared.sampleCount); + + tracker.addSample(7); + const auto stats = tracker.stats(); + EXPECT_EQ(1, stats.sampleCount); + EXPECT_EQ(7, stats.lastMs); + EXPECT_EQ(7, stats.medianMs); +} + +TEST(LatencyTrackerTest, LastSampleSurvivesWraparound) +{ + LatencyTracker tracker; + for (int i = 0; i < LatencyTracker::WindowSize; ++i) { + tracker.addSample(i); + } + tracker.addSample(1000); + + EXPECT_EQ(1000, tracker.stats().lastMs); +} + +TEST(LatencyTrackerTest, RecentSamplesAreChronologicalOldestFirst) +{ + LatencyTracker tracker; + for (qint64 ms : {qint64(50), qint64(10), qint64(30)}) { + tracker.addSample(ms); + } + + const QList samples = tracker.recentSamples(); + + EXPECT_EQ((QList{50, 10, 30}), samples); +} + +TEST(LatencyTrackerTest, RecentSamplesFollowRingBufferWraparound) +{ + LatencyTracker tracker; + for (int i = 0; i <= 99; ++i) { + tracker.addSample(i); + } + + const QList samples = tracker.recentSamples(); + + ASSERT_EQ(LatencyTracker::WindowSize, samples.size()); + EXPECT_EQ(36, samples.first()); + EXPECT_EQ(99, samples.last()); +} + +TEST(LatencyTrackerTest, RecentSamplesEmptyOnFreshAndClearedTracker) +{ + LatencyTracker tracker; + EXPECT_TRUE(tracker.recentSamples().isEmpty()); + + tracker.addSample(5); + tracker.clear(); + EXPECT_TRUE(tracker.recentSamples().isEmpty()); +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/loader_local_matching_test.cpp b/tests/loader_local_matching_test.cpp new file mode 100644 index 000000000..13d3e5bb3 --- /dev/null +++ b/tests/loader_local_matching_test.cpp @@ -0,0 +1,230 @@ +#include "client/settings/cache_settings.h" +#include "interface/card_picture_loader/card_picture_loader_local.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/printing/printing_info.h" + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +/** + * @brief Builds an ExactCard with the requested identity fields. + * + * Mirrors how the client constructs cards: the set short name feeds tryLoad()'s + * setName, and the "num" printing property feeds the collector number. + */ +ExactCard cardFor(const QString &name, const QString &setShortName, const QString &collectorNumber) +{ + CardSetPtr set; + if (!setShortName.isEmpty()) { + set = CardSet::newInstance(new NoopCardSetPriorityController(), setShortName, setShortName); + } + + LazyPropertiesHash properties; + if (!collectorNumber.isEmpty()) { + properties.insert("num", collectorNumber); + } + + return ExactCard(CardInfo::newInstance(name), PrintingInfo(set, properties)); +} + +class LocalMatcherTest : public ::testing::Test +{ +protected: + QTemporaryDir tempDir; ///< Sandboxed "pics" root for every test. + CardPictureLoaderLocal *loader = nullptr; ///< Constructed per test against the sandboxed paths. + + QString picsPath() const + { + return tempDir.path() + "/pics"; + } + + void SetUp() override + { + // The loader ctor snapshots the global picture paths once, so point them at the + // sandbox before constructing it. + SettingsCache::instance().paths().setPicsPath(picsPath()); + SettingsCache::instance().paths().setCustomPicsPath(picsPath() + "/CUSTOM/"); + + loader = new CardPictureLoaderLocal(nullptr); + } + + void TearDown() override + { + delete loader; + loader = nullptr; + } + + /** + * @brief Destroys and rebuilds the loader so the CUSTOM index is re-scanned. + * + * The CUSTOM-folder index is snapshotted at construction; rebuild it after + * writing files so freshly placed images are discoverable. + */ + void rebuildLoader() + { + delete loader; + loader = new CardPictureLoaderLocal(nullptr); + } + + /** + * @brief Writes a valid 1x1 PNG under the sandboxed pics path. + */ + void writePngUnderPics(const QString &relativePath, const QColor &color = Qt::red) + { + const QString fullPath = picsPath() + "/" + relativePath; + ASSERT_TRUE(QDir().mkpath(QFileInfo(fullPath).absolutePath())); + + QImage image(1, 1, QImage::Format_RGB32); + image.fill(color); + + QImageWriter writer(fullPath, "PNG"); + ASSERT_TRUE(writer.write(image)); + } +}; + +TEST_F(LocalMatcherTest, ExactMatchBareFileWinsOverSuffixedCompanion) +{ + writePngUnderPics("downloadedPics/TestCard.png"); + writePngUnderPics("downloadedPics/TestCard (1).png"); + + const QImage image = loader->tryLoad(cardFor("TestCard", "", "")); + + EXPECT_FALSE(image.isNull()) << "The bare TestCard.png must be picked over its suffixed companion"; +} + +TEST_F(LocalMatcherTest, SuffixedFileWithoutExactMatchIsNotLoaded) +{ + // The pre-refactor prefix match would have accepted "TestCard (1).png" for "TestCard". + writePngUnderPics("downloadedPics/TestCard (1).png"); + + const QImage image = loader->tryLoad(cardFor("TestCard", "", "")); + + EXPECT_TRUE(image.isNull()) << "A suffixed file must not satisfy an exact card-name lookup"; +} + +TEST_F(LocalMatcherTest, SetFolderLookupIgnoresSuffixedFiles) +{ + writePngUnderPics("M10/TestCard (1).png"); + + const QImage image = loader->tryLoad(cardFor("TestCard", "M10", "")); + + EXPECT_TRUE(image.isNull()) << "Set-folder lookups must also require an exact name match"; +} + +TEST_F(LocalMatcherTest, SetFolderLookupStillResolvesExactFile) +{ + writePngUnderPics("M10/TestCard.png"); + writePngUnderPics("M10/TestCard (1).png"); + + const QImage image = loader->tryLoad(cardFor("TestCard", "M10", "")); + + EXPECT_FALSE(image.isNull()) << "The exact file in the set folder must still resolve"; +} + +TEST_F(LocalMatcherTest, RootDownloadedPicsFallbackResolvesSchemeFilename) +{ + // Non-set-folder export schemes (Name_Set_Collector) write straight into downloadedPics/. + writePngUnderPics("downloadedPics/TestCard_M10_1.png"); + + const QImage image = loader->tryLoad(cardFor("TestCard", "M10", "1")); + + EXPECT_FALSE(image.isNull()) << "downloadedPics/TestCard_M10_1.png must resolve via the root fallback"; +} + +TEST_F(LocalMatcherTest, RootFallbackResolvesDashSeparatedVariant) +{ + writePngUnderPics("downloadedPics/TestCard-M10-1.png"); + + const QImage image = loader->tryLoad(cardFor("TestCard", "M10", "1")); + + EXPECT_FALSE(image.isNull()) << "The dash-separated import variant must resolve via the root fallback"; +} + +TEST_F(LocalMatcherTest, DownloadedPicsSetSubfolderStillResolves) +{ + writePngUnderPics("downloadedPics/M10/TestCard_M10_1.png"); + + const QImage image = loader->tryLoad(cardFor("TestCard", "M10", "1")); + + EXPECT_FALSE(image.isNull()) << "The set-subfolder export scheme must keep resolving"; +} + +TEST_F(LocalMatcherTest, SetFolderCandidateTakesPrecedenceOverRootFallback) +{ + writePngUnderPics("M10/TestCard_M10_1.png", Qt::red); + writePngUnderPics("downloadedPics/TestCard_M10_1.png", Qt::blue); + + const QImage image = loader->tryLoad(cardFor("TestCard", "M10", "1")); + + ASSERT_FALSE(image.isNull()); + EXPECT_EQ(image.pixelColor(0, 0), QColor(Qt::red)) << "The set-folder candidate must be preferred"; +} + +TEST_F(LocalMatcherTest, CustomSubfolderResolvesExactFile) +{ + // Images in the CUSTOM folder are indexed by their full file path (extension included), + // unlike the extension-less candidate paths built from the naming schemes. + writePngUnderPics("CUSTOM/poker/TestCard.png"); + rebuildLoader(); + + const QImage image = loader->tryLoad(cardFor("TestCard", "", "")); + + EXPECT_FALSE(image.isNull()) << "A CUSTOM-folder image in a subdirectory must resolve"; +} + +TEST_F(LocalMatcherTest, CustomSubfolderIgnoresSuffixedFiles) +{ + writePngUnderPics("CUSTOM/poker/TestCard (1).png"); + rebuildLoader(); + + const QImage image = loader->tryLoad(cardFor("TestCard", "", "")); + + EXPECT_TRUE(image.isNull()) << "A suffixed CUSTOM-folder file must not satisfy an exact name lookup"; +} + +} // namespace + +int main(int argc, char **argv) +{ + // Redirect SettingsCache reads/writes (app-data location) away from the real user profile. + QStandardPaths::setTestModeEnabled(true); + + // Some CI containers run as a uid without a passwd entry (e.g. GitHub's docker + // runner), so HOME resolves to "/" and the test-mode qttest data dir cannot be + // created. SettingsCache's QSettings then silently drops every write, reads come + // back empty, and the paths the loader searches are "". Give the test a writable + // HOME for the duration of the run so settings behave like on a normal machine. + QTemporaryDir home; + if (home.isValid()) { + qputenv("HOME", home.path().toLocal8Bit()); + } + + QCoreApplication app(argc, argv); + QLoggingCategory::setFilterRules("card_picture_loader.*=false\nsettings_cache.*=false"); + + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} \ No newline at end of file diff --git a/tests/loading_from_clipboard/CMakeLists.txt b/tests/loading_from_clipboard/CMakeLists.txt index 719d62f45..88711e457 100644 --- a/tests/loading_from_clipboard/CMakeLists.txt +++ b/tests/loading_from_clipboard/CMakeLists.txt @@ -1,12 +1,13 @@ add_definitions("-DCARDDB_DATADIR=\"${CMAKE_CURRENT_SOURCE_DIR}/data/\"") -add_executable(loading_from_clipboard_test ${VERSION_STRING_CPP} clipboard_testing.cpp loading_from_clipboard_test.cpp) -if(NOT GTEST_FOUND) - add_dependencies(loading_from_clipboard_test gtest) -endif() - -target_link_libraries( - loading_from_clipboard_test libcockatrice_deck_list libcockatrice_card Threads::Threads ${GTEST_BOTH_LIBRARIES} - ${TEST_QT_MODULES} +cockatrice_add_test( + NAME + loading_from_clipboard_test + SOURCES + ${VERSION_STRING_CPP} + clipboard_testing.cpp + loading_from_clipboard_test.cpp + LIBS + libcockatrice_deck_list + libcockatrice_card ) -add_test(NAME loading_from_clipboard_test COMMAND loading_from_clipboard_test) diff --git a/tests/loading_from_clipboard/clipboard_testing.cpp b/tests/loading_from_clipboard/clipboard_testing.cpp index a9977b800..3f652a495 100644 --- a/tests/loading_from_clipboard/clipboard_testing.cpp +++ b/tests/loading_from_clipboard/clipboard_testing.cpp @@ -1,6 +1,12 @@ #include "clipboard_testing.h" +#include "libcockatrice/deck_list/deck_list.h" +#include "libcockatrice/deck_list/tree/inner_deck_list_node.h" + +#include #include +#include +#include #include #include diff --git a/tests/loading_from_clipboard/clipboard_testing.h b/tests/loading_from_clipboard/clipboard_testing.h index 41d8ea794..c9852e5d7 100644 --- a/tests/loading_from_clipboard/clipboard_testing.h +++ b/tests/loading_from_clipboard/clipboard_testing.h @@ -1,8 +1,11 @@ #ifndef CLIPBOARD_TESTING_H #define CLIPBOARD_TESTING_H -#include "gtest/gtest.h" -#include +#include +#include +#include +#include +#include // using std types because qt types aren't understood by gtest (without this you'll get less nice errors) using CardRows = QVector>; diff --git a/tests/loading_from_clipboard/loading_from_clipboard_test.cpp b/tests/loading_from_clipboard/loading_from_clipboard_test.cpp index fcfbb22db..840d926ff 100644 --- a/tests/loading_from_clipboard/loading_from_clipboard_test.cpp +++ b/tests/loading_from_clipboard/loading_from_clipboard_test.cpp @@ -1,5 +1,11 @@ #include "clipboard_testing.h" +#include +#include +#include +#include +#include + // Testing is done by using the DeckList::loadFromString_Plain function in common/decklist.h // It does not check if cards are in the database at all, so no comparisons to the database will be made. diff --git a/tests/metrics_registry_test.cpp b/tests/metrics_registry_test.cpp new file mode 100644 index 000000000..39a8f8aaf --- /dev/null +++ b/tests/metrics_registry_test.cpp @@ -0,0 +1,84 @@ +#include +#include +#include +#include +#include + +TEST(MetricsRegistryTest, EmptyRegistryHasZeroedCounters) +{ + MetricsRegistry registry; + + EXPECT_EQ(0, registry.totalCommands()); + EXPECT_EQ(0, registry.totalTimeMs()); + EXPECT_EQ(0, registry.activeTypeCount()); + EXPECT_EQ(0, registry.getGameStartSnapshot().count); +} + +TEST(MetricsRegistryTest, SampleIsRecordedInTotalsAndSlot) +{ + MetricsRegistry registry; + registry.observeCommand(MetricsRegistry::typeIdFor(0, 1000), 7); + + EXPECT_EQ(1, registry.totalCommands()); + EXPECT_EQ(7, registry.totalTimeMs()); + EXPECT_EQ(1, registry.activeTypeCount()); + + const auto stats = registry.collectActiveStats(); + ASSERT_EQ(1, stats.size()); + EXPECT_EQ(MetricsRegistry::typeIdFor(0, 1000), stats[0].typeId); + EXPECT_EQ(1, stats[0].count); + EXPECT_EQ(7, stats[0].totalMs); +} + +TEST(MetricsRegistryTest, KindEncodingSeparatesSameExtensionNumber) +{ + MetricsRegistry registry; + const int sessionPing = MetricsRegistry::typeIdFor(0, 1000); + const int roomLeaveRoom = MetricsRegistry::typeIdFor(1, 1000); + ASSERT_NE(sessionPing, roomLeaveRoom); + + registry.observeCommand(sessionPing, 1); + registry.observeCommand(roomLeaveRoom, 5000); + + EXPECT_EQ(2, registry.activeTypeCount()); +} + +TEST(MetricsRegistryTest, OutOfRangeIdsLandInOverflowSlot) +{ + MetricsRegistry registry; + registry.observeCommand(-1, 4); + registry.observeCommand(MetricsRegistry::MaxTypes + 12345, 4); + + EXPECT_EQ(2, registry.totalCommands()); + EXPECT_EQ(1, registry.activeTypeCount()); // both collapsed into one slot + EXPECT_EQ(8, registry.totalTimeMs()); +} + +TEST(MetricsRegistryTest, NegativeDurationsAreClamped) +{ + MetricsRegistry registry; + registry.observeCommand(MetricsRegistry::typeIdFor(0, 1000), -50); + + EXPECT_EQ(0, registry.totalTimeMs()); +} + +TEST(MetricsRegistryTest, GameStartTrackedSeparatelyFromCommands) +{ + MetricsRegistry registry; + registry.observeGameStartDurationMs(120); + + EXPECT_EQ(0, registry.totalCommands()); + EXPECT_EQ(0, registry.totalTimeMs()); + EXPECT_EQ(0, registry.activeTypeCount()); + + const auto snapshot = registry.getGameStartSnapshot(); + EXPECT_EQ(1, snapshot.count); + EXPECT_EQ(120, snapshot.totalMs); +} + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} \ No newline at end of file diff --git a/tests/movecard_tests/CMakeLists.txt b/tests/movecard_tests/CMakeLists.txt index 769047148..68d8fc84c 100755 --- a/tests/movecard_tests/CMakeLists.txt +++ b/tests/movecard_tests/CMakeLists.txt @@ -1,16 +1,11 @@ -add_executable(reverse_card_move_test reverse_card_move_test.cpp) - -if(NOT GTEST_FOUND) - add_dependencies(reverse_card_move_test gtest) -endif() - -target_link_libraries( +cockatrice_add_test( + NAME reverse_card_move_test - PRIVATE libcockatrice_network_server_remote - PRIVATE libcockatrice_rng - PRIVATE Threads::Threads - PRIVATE ${GTEST_BOTH_LIBRARIES} - PRIVATE ${TEST_QT_MODULES} + SOURCES + reverse_card_move_test.cpp + LIBS + libcockatrice_network_server_remote + libcockatrice_rng + LABELS + server ) - -add_test(NAME reverse_card_move_test COMMAND reverse_card_move_test) diff --git a/tests/movecard_tests/reverse_card_move_test.cpp b/tests/movecard_tests/reverse_card_move_test.cpp index 2231a7e3b..6bed20d18 100644 --- a/tests/movecard_tests/reverse_card_move_test.cpp +++ b/tests/movecard_tests/reverse_card_move_test.cpp @@ -1,16 +1,22 @@ +#include "game/game_config.h" #include "game/server_abstract_player.h" #include "game/server_card.h" #include "game/server_cardzone.h" #include "game/server_game.h" +#include "libcockatrice/protocol/pb/response.pb.h" +#include "libcockatrice/protocol/pb/serverinfo_zone.pb.h" #include "server_response_containers.h" #include "server_room.h" #include "server_test_helpers.h" +#include +#include #include #include #include #include #include +#include RNG_Abstract *rng = nullptr; // this needs to be defined due to other functions in server @@ -22,7 +28,21 @@ TEST(ReverseCardMoveTest, MoveCardFromBottomTest) // instantiate a fake server instance FakeServer server; Server_Room room(0, 0, "", "", "", "", false, "", {}, &server); - Server_Game game(user, 1, "", "", 2, QList(), false, false, false, false, false, false, 20, false, &room); + GameConfig config{.creatorInfo = user, + .gameId = 1, + .description = QString(), + .password = QString(), + .maxPlayers = 2, + .gameTypes = QList(), + .onlyBuddies = false, + .onlyRegistered = false, + .spectatorsAllowed = false, + .spectatorsNeedPassword = false, + .spectatorsCanTalk = false, + .spectatorsSeeEverything = false, + .startingLifeTotal = 20, + .shareDecklistsOnLoad = false}; + Server_Game game(config, &room); Server_AbstractPlayer player(&game, 1, user, false, nullptr); Server_CardZone deckZone(&player, ZoneNames::DECK, true, ServerInfo_Zone::PublicZone); Server_CardZone exileZone(&player, ZoneNames::EXILE, true, ServerInfo_Zone::PublicZone); diff --git a/tests/oracle/CMakeLists.txt b/tests/oracle/CMakeLists.txt index c5c1e9097..6958673f7 100644 --- a/tests/oracle/CMakeLists.txt +++ b/tests/oracle/CMakeLists.txt @@ -1,9 +1,64 @@ -add_executable(parse_cipt_test ../../oracle/src/parsehelpers.cpp parse_cipt_test.cpp) +cockatrice_add_test(NAME parse_cipt_test SOURCES ../../oracle/src/parsehelpers.cpp parse_cipt_test.cpp) -if(NOT GTEST_FOUND) - add_dependencies(parse_cipt_test gtest) +# Oracle importer unit tests +cockatrice_add_test( + NAME + oracle_importer_test + SOURCES + ${VERSION_STRING_CPP} + ../../oracle/src/oracleimporter.cpp + ../../oracle/src/parsehelpers.cpp + ../../oracle/src/raw_json_scanner.cpp + oracle_importer_test.cpp + LIBS + libcockatrice_card + libcockatrice_interfaces +) + +# Oracle importer benchmark tests (manual, not run in CI, incl. RAM benchmark) +# Optional compression libs, mirrored from oracle/CMakeLists.txt, so the benchmark +# can download and decompress whatever AllPrintings format the default URL selects. +# add_definitions() is directory-scoped and order-independent, so HAS_ZLIB/HAS_LZMA also +# reach the two unit tests above. That is harmless: those macros gate code only in +# oracle/src/pages.cpp and oracle/src/pagetemplates.cpp, which no test target compiles. +find_package(ZLIB) +if(ZLIB_FOUND) + add_definitions("-DHAS_ZLIB") + set(_ORACLE_BENCH_EXTRA_SOURCES ../../oracle/src/zip/unzip.cpp ../../oracle/src/zip/zipglobal.cpp) + cockatrice_disable_sanitizers_for(${_ORACLE_BENCH_EXTRA_SOURCES}) + cockatrice_disable_coverage_for(${_ORACLE_BENCH_EXTRA_SOURCES}) + set(_ORACLE_BENCH_EXTRA_LIBRARIES ${ZLIB_LIBRARIES}) + include_directories(${ZLIB_INCLUDE_DIRS}) +else() + message(STATUS "Oracle tests: zlib not found; zip download benchmark disabled") endif() -target_link_libraries(parse_cipt_test Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}) +find_package(LibLZMA) +if(LIBLZMA_FOUND) + add_definitions("-DHAS_LZMA") + list(APPEND _ORACLE_BENCH_EXTRA_SOURCES ../../oracle/src/lzma/decompress.cpp) + cockatrice_disable_sanitizers_for(../../oracle/src/lzma/decompress.cpp) + cockatrice_disable_coverage_for(../../oracle/src/lzma/decompress.cpp) + list(APPEND _ORACLE_BENCH_EXTRA_LIBRARIES ${LIBLZMA_LIBRARIES}) + include_directories(${LIBLZMA_INCLUDE_DIRS}) +else() + message(STATUS "Oracle tests: LibLZMA not found; xz download benchmark disabled") +endif() -add_test(NAME parse_cipt_test COMMAND parse_cipt_test) +cockatrice_add_manual_test( + NAME + oracle_importer_benchmark_test + SOURCES + ${VERSION_STRING_CPP} + ../../oracle/src/oracleimporter.cpp + ../../oracle/src/parsehelpers.cpp + ../../oracle/src/raw_json_scanner.cpp + oracle_importer_benchmark_test.cpp + ${_ORACLE_BENCH_EXTRA_SOURCES} + LIBS + libcockatrice_card + libcockatrice_interfaces + EXTRA_LIBS + ${_ORACLE_BENCH_EXTRA_LIBRARIES} + MANUAL +) diff --git a/tests/oracle/oracle_importer_benchmark_test.cpp b/tests/oracle/oracle_importer_benchmark_test.cpp new file mode 100644 index 000000000..c2a4aaf4b --- /dev/null +++ b/tests/oracle/oracle_importer_benchmark_test.cpp @@ -0,0 +1,592 @@ +#include "../../oracle/src/oracleimporter.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/set/card_set.h" + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(HAS_LZMA) +#include "../../oracle/src/lzma/decompress.h" +#endif +#if defined(HAS_ZLIB) +#include "../../oracle/src/zip/unzip.h" +#endif +#if defined(Q_OS_MACOS) +#include +#include +#endif + +// Helper: build a synthetic MTGJSON-style JSON with the given number of sets and cards per set +static QByteArray buildSyntheticData(int numSets, int cardsPerSet) +{ + QJsonObject dataObj; + for (int s = 0; s < numSets; ++s) { + QJsonArray cardsArray; + for (int c = 0; c < cardsPerSet; ++c) { + QJsonObject card; + card["name"] = QString("Card %1").arg(s * cardsPerSet + c); + card["text"] = "This is a test card with some rules text."; + card["layout"] = "normal"; + card["manaCost"] = "{W}"; + card["type"] = "Creature — Human"; + card["power"] = "2"; + card["toughness"] = "2"; + card["colors"] = QJsonArray{"W"}; + card["colorIdentity"] = QJsonArray{"W"}; + card["types"] = QJsonArray{"Creature"}; + // Real MTGJSON types: floats, not strings. This + // exercises the QVariant coercion in the property reader. + card["convertedManaCost"] = 1.0; + card["manaValue"] = 1.0; + + QJsonObject legalities; + legalities["standard"] = "legal"; + legalities["modern"] = "legal"; + legalities["legacy"] = "legal"; + legalities["vintage"] = "legal"; + legalities["commander"] = "legal"; + card["legalities"] = legalities; + + QJsonObject identifiers; + identifiers["scryfallId"] = QString("id-%1-%2").arg(s).arg(c); + card["identifiers"] = identifiers; + + // In AllPrintings, number and rarity are flat fields on the card + // object, exactly as set below. + card["number"] = QString::number(c + 1); + card["rarity"] = "common"; + + cardsArray.append(card); + } + + QJsonObject setObj; + setObj["code"] = QString("T%1").arg(s, 2, 10, QChar('0')); + setObj["name"] = QString("Test Set %1").arg(s); + setObj["type"] = "expansion"; + setObj["releaseDate"] = "2024-01-01"; + setObj["cards"] = cardsArray; + + dataObj[QString("T%1").arg(s, 2, 10, QChar('0'))] = setObj; + } + + QJsonObject root; + root["data"] = dataObj; + return QJsonDocument(root).toJson(QJsonDocument::Compact); +} + +// ============================================================================ +// Import throughput benchmark +// ============================================================================ + +TEST(OracleBenchmark, ImportThroughput) +{ + static constexpr int numSets = 10; + static constexpr int cardsPerSet = 500; + + QByteArray data = buildSyntheticData(numSets, cardsPerSet); + + OracleImporter importer; + + // Phase 1: Parse JSON + QElapsedTimer timer; + timer.start(); + bool ok = importer.readSetsFromByteArray(data); + ASSERT_TRUE(ok); + qint64 parseMs = timer.elapsed(); + + // Phase 2: Import cards + timer.restart(); + int importedSets = importer.startImport(); + qint64 importMs = timer.elapsed(); + + int totalImported = 0; + for (const auto &card : importer.getCardList()) { + Q_UNUSED(card); + totalImported++; + } + + // The fixture generates globally unique card names, so the expected + // counts are exact: a regression here means cards were dropped. + ASSERT_EQ(importedSets, numSets); + ASSERT_EQ(totalImported, numSets * cardsPerSet); + // Real-data probe: numeric convertedManaCost must be coerced to text + // (regression for the QJsonValue::toString() reader in #7214). + auto probeCard = importer.getCardList().value("Card 0"); + ASSERT_FALSE(probeCard.isNull()); + ASSERT_EQ(probeCard->getProperty("cmc"), "1"); + + qDebug().noquote() + << QString("Oracle Import Benchmark: %1 sets, %2 unique cards").arg(importedSets).arg(totalImported); + qDebug().noquote() << QString(" JSON parse: %1 ms").arg(parseMs); + qDebug().noquote() << QString(" Card import: %1 ms").arg(importMs); + qDebug().noquote() << QString(" Total: %1 ms").arg(parseMs + importMs); + if (importMs > 0) { + qDebug().noquote() << QString(" Throughput: %1 cards/sec") + .arg(static_cast(totalImported) / importMs * 1000.0, 0, 'f', 0); + } +} + +// ============================================================================ +// readSetsFromByteArray benchmark +// ============================================================================ + +TEST(OracleBenchmark, ParseJsonThroughput) +{ + static constexpr int numSets = 20; + static constexpr int cardsPerSet = 1000; + + QByteArray data = buildSyntheticData(numSets, cardsPerSet); + + // Run 5 iterations and report average + static constexpr int iterations = 5; + qint64 totalMs = 0; + + for (int i = 0; i < iterations; ++i) { + OracleImporter importer; + QByteArray source = data; + QElapsedTimer timer; + timer.start(); + bool ok = importer.readSetsFromByteArray(std::move(source)); + ASSERT_TRUE(ok); + totalMs += timer.elapsed(); + } + + qint64 avgMs = totalMs / iterations; + qDebug().noquote() << QString("Parse Benchmark (%1 iterations): avg %2 ms for %3 sets x %4 cards") + .arg(iterations) + .arg(avgMs) + .arg(numSets) + .arg(cardsPerSet); +} + +// ============================================================================ +// Split card merging benchmark +// ============================================================================ + +TEST(OracleBenchmark, SplitCardMerging) +{ + static constexpr int numSplitCards = 1000; + + QJsonArray cardsList; + for (int i = 0; i < numSplitCards; ++i) { + QJsonObject face1; + face1["name"] = QString("Fire %1 // Ice %1").arg(i); + face1["text"] = "Fire side text."; + face1["layout"] = "split"; + face1["side"] = "a"; + face1["faceName"] = QString("Fire %1").arg(i); + face1["colors"] = QJsonArray{"R"}; + face1["colorIdentity"] = QJsonArray{"R"}; + face1["types"] = QJsonArray{"Instant"}; + face1["manaCost"] = "{R}"; + face1["legalities"] = QJsonObject{{"standard", "not_legal"}}; + face1["identifiers"] = QJsonObject{{"scryfallId", QString("f-%1").arg(i)}}; + face1["number"] = QString::number(i + 1); + face1["rarity"] = "uncommon"; + + QJsonObject face2; + face2["name"] = QString("Fire %1 // Ice %1").arg(i); + face2["text"] = "Ice side text."; + face2["layout"] = "split"; + face2["side"] = "b"; + face2["faceName"] = QString("Ice %1").arg(i); + face2["colors"] = QJsonArray{"U"}; + face2["colorIdentity"] = QJsonArray{"U"}; + face2["types"] = QJsonArray{"Instant"}; + face2["manaCost"] = "{U}"; + face2["legalities"] = QJsonObject{{"standard", "not_legal"}}; + face2["identifiers"] = QJsonObject{{"scryfallId", QString("i-%1").arg(i)}}; + face2["number"] = QString::number(i + 1); + face2["rarity"] = "uncommon"; + + cardsList.append(face1); + cardsList.append(face2); + } + + NoopCardSetPriorityController controller; + OracleImporter importer; + CardSetPtr set = CardSet::newInstance(&controller, "TST", "Split Test"); + + QElapsedTimer timer; + timer.start(); + int count = importer.importCardsFromSet(set, cardsList); + qint64 ms = timer.elapsed(); + + ASSERT_EQ(count, numSplitCards); + qDebug().noquote() << QString("Split Card Merge Benchmark: %1 cards in %2 ms (%3 cards/sec)") + .arg(count) + .arg(ms) + .arg(ms > 0 ? static_cast(count) / ms * 1000.0 : 0.0, 0, 'f', 0); +} + +// ============================================================================ +// sortAndReduceColors microbenchmark +// ============================================================================ + +// We can't call sortAndReduceColors directly (it's static), so we benchmark +// through importCardsFromSet with color properties. + +TEST(OracleBenchmark, ImportCardsWithColors) +{ + static constexpr int numCards = 10000; + + NoopCardSetPriorityController controller; + OracleImporter importer; + CardSetPtr set = CardSet::newInstance(&controller, "TST", "Color Test"); + + QJsonArray cardsList; + for (int i = 0; i < numCards; ++i) { + QJsonObject card; + card["name"] = QString("Color Card %1").arg(i); + card["text"] = "Rules text."; + card["layout"] = "normal"; + card["manaCost"] = "{W}"; + card["type"] = "Creature — Human"; + card["types"] = QJsonArray{"Creature"}; + card["colors"] = QJsonArray{"B", "R", "G", "W", "U"}; + card["colorIdentity"] = QJsonArray{"B", "R", "G", "W", "U"}; + card["number"] = QString::number(i + 1); + card["rarity"] = "common"; + card["legalities"] = QJsonObject{{"standard", "legal"}}; + card["identifiers"] = QJsonObject{{"scryfallId", QString("c-%1").arg(i)}}; + cardsList.append(card); + } + + QElapsedTimer timer; + timer.start(); + int count = importer.importCardsFromSet(set, cardsList); + qint64 ms = timer.elapsed(); + + ASSERT_EQ(count, numCards); + qDebug().noquote() << QString("Import with Colors Benchmark: %1 cards in %2 ms (%3 cards/sec)") + .arg(count) + .arg(ms) + .arg(ms > 0 ? static_cast(count) / ms * 1000.0 : 0.0, 0, 'f', 0); +} + +// ============================================================================ +// RAM usage measurement +// ============================================================================ + +// Mirrors the default AllPrintings URL selection in oracle/src/pages.cpp. +#if defined(HAS_LZMA) +static const QUrl kDefaultAllPrintingsUrl("https://www.mtgjson.com/api/v5/AllPrintings.json.xz"); +#elif defined(HAS_ZLIB) +static const QUrl kDefaultAllPrintingsUrl("https://www.mtgjson.com/api/v5/AllPrintings.json.zip"); +#else +static const QUrl kDefaultAllPrintingsUrl("https://www.mtgjson.com/api/v5/AllPrintings.json"); +#endif + +// Magic bytes also from oracle/src/pages.cpp +static const QByteArray kXzSignature("\xFD\x37\x7A\x58\x5A", 6); +static const QByteArray kZipSignature("PK"); + +struct MemorySnapshot +{ + qint64 peakRssKb = -1; // process high-water mark (VmHWM on Linux, ru_maxrss on macOS) + qint64 rssKb = -1; // current resident set size + bool available = false; + + static MemorySnapshot current() + { + MemorySnapshot snap; +#if defined(Q_OS_LINUX) + QFile statusFile("/proc/self/status"); + if (statusFile.open(QIODevice::ReadOnly | QIODevice::Text)) { + // /proc files report size() == 0, so atEnd() is immediately true: read everything first. + const QList lines = statusFile.readAll().split('\n'); + for (const QByteArray &line : lines) { + if (line.startsWith("VmHWM:")) { + snap.peakRssKb = line.mid(6).trimmed().split(' ').value(0).toLongLong(); + } else if (line.startsWith("VmRSS:")) { + snap.rssKb = line.mid(6).trimmed().split(' ').value(0).toLongLong(); + } + } + snap.available = snap.peakRssKb >= 0; + } +#elif defined(Q_OS_MACOS) + struct rusage usage; + if (getrusage(RUSAGE_SELF, &usage) == 0) { + snap.peakRssKb = usage.ru_maxrss / 1024; // bytes -> kB + snap.available = snap.peakRssKb >= 0; + } + // getrusage has no current-RSS equivalent; task_info's resident_size + // is the closest macOS analog to Linux VmRSS. + mach_task_basic_info info = {}; + mach_msg_type_number_t count = MACH_TASK_BASIC_INFO_COUNT; + if (task_info(mach_task_self(), MACH_TASK_BASIC_INFO, reinterpret_cast(&info), &count) == + KERN_SUCCESS) { + snap.rssKb = info.resident_size / 1024; + } +#endif + return snap; + } +}; + +static QString formatKb(qint64 kb) +{ + if (kb < 0) { + return "N/A"; + } + return QString("%1 MB").arg(kb / 1024.0, 0, 'f', 1); +} + +static void logRamPhase(const QString &phase, const MemorySnapshot &baseline, const MemorySnapshot ¤t) +{ + if (!baseline.available || !current.available) { + qDebug().noquote() << QString(" %1: memory stats unavailable on this platform").arg(phase); + return; + } + // VmHWM / ru_maxrss are monotonically non-decreasing high-water marks, so a + // peak-based delta between phases is ~0.0 MB by construction once the + // fixture build has set the process peak. The live signals are current RSS + // and the process peak; the delta is meaningful only where the baseline was + // taken immediately before the phase it measures (e.g. the import phase, + // which compares afterParse against afterImport). + QString rssDelta = "N/A"; + if (current.rssKb >= 0 && baseline.rssKb >= 0) { + rssDelta = formatKb(current.rssKb - baseline.rssKb); + } + qDebug().noquote() << QString(" %1: current RSS %2 | delta vs baseline %3 | process peak %4") + .arg(phase) + .arg(formatKb(current.rssKb)) + .arg(rssDelta) + .arg(formatKb(current.peakRssKb)); +} + +// Decompresses the download payload when the default URL is a compressed build, +// mirroring the wizard's magic-byte handling in oracle/src/pages.cpp. +static QByteArray decompressSetsData(const QByteArray &payload) +{ + if (payload.startsWith(kXzSignature)) { +#if defined(HAS_LZMA) + QBuffer inBuffer(const_cast(&payload)); + QByteArray out; + QBuffer outBuffer(&out); + inBuffer.open(QIODevice::ReadOnly); + outBuffer.open(QIODevice::WriteOnly); + XzDecompressor xz; + if (!xz.decompress(&inBuffer, &outBuffer)) { + qDebug() << "RAM benchmark: xz decompression failed"; + return {}; + } + return out; +#else + qDebug() << "RAM benchmark: download is xz-compressed but this build has no LZMA support"; + return {}; +#endif + } + if (payload.startsWith(kZipSignature)) { +#if defined(HAS_ZLIB) + QBuffer inBuffer(const_cast(&payload)); + inBuffer.open(QIODevice::ReadOnly); + UnZip unzip; + if (unzip.openArchive(&inBuffer) != UnZip::Ok) { + qDebug() << "RAM benchmark: zip archive open failed"; + return {}; + } + if (unzip.fileList().size() != 1) { + qDebug() << "RAM benchmark: zip archive doesn't contain exactly one file"; + return {}; + } + QByteArray out; + QBuffer outBuffer(&out); + outBuffer.open(QIODevice::WriteOnly); + const auto errorCode = unzip.extractFile(unzip.fileList().value(0), &outBuffer); + unzip.closeArchive(); + if (errorCode != UnZip::Ok) { + qDebug() << "RAM benchmark: zip extraction failed"; + return {}; + } + return out; +#else + qDebug() << "RAM benchmark: download is zip-compressed but this build has no zlib support"; + return {}; +#endif + } + return payload; +} + +TEST(OracleBenchmark, ImportRamUsage) +{ + static constexpr int numSets = 30; + static constexpr int cardsPerSet = 2000; // ~60k cards, roughly AllPrintings scale + + // Baseline must precede the fixture build: a high-water mark set while + // generating the synthetic JSON would otherwise mask the importer phases. + // Where memory stats are unavailable (Windows), skip before doing the + // 60k-card fixture build, which would otherwise be pure wasted work. + const MemorySnapshot baseline = MemorySnapshot::current(); + if (!baseline.available) { + GTEST_SKIP() << "Memory stats unavailable on this platform"; + } + + const QByteArray data = buildSyntheticData(numSets, cardsPerSet); + + // The fixture build leaves freed-but-unreturned arenas behind (current RSS + // rarely falls once glibc allocates). Baseline immediately after it so the + // parse phase measures only the importer's own growth (~40 MB) rather than + // swallowing the fixture builder's spike. + const MemorySnapshot afterFixture = MemorySnapshot::current(); + logRamPhase("fixture build", baseline, afterFixture); + + NoopCardSetPriorityController controller; + OracleImporter importer; + + QElapsedTimer timer; + timer.start(); + ASSERT_TRUE(importer.readSetsFromByteArray(std::move(data))); + const qint64 parseMs = timer.elapsed(); + const MemorySnapshot afterParse = MemorySnapshot::current(); + + timer.restart(); + const int importedSets = importer.startImport(); + const qint64 importMs = timer.elapsed(); + const MemorySnapshot afterImport = MemorySnapshot::current(); + + importer.releaseSetData(); + const MemorySnapshot afterRelease = MemorySnapshot::current(); + + const int totalCards = importer.getCardList().size(); + qDebug().noquote() << QString("Oracle RAM Benchmark (synthetic): %1 sets, %2 cards, %3 MB JSON") + .arg(importedSets) + .arg(totalCards) + .arg(data.size() / (1024.0 * 1024.0), 0, 'f', 1); + qDebug().noquote() << QString(" JSON parse: %1 ms").arg(parseMs); + qDebug().noquote() << QString(" Card import: %1 ms").arg(importMs); + logRamPhase("parse", afterFixture, afterParse); + logRamPhase("import", afterParse, afterImport); + logRamPhase("after releaseSetData()", afterImport, afterRelease); + + // Freeing the parsed tree rarely moves current RSS (allocator reuse), so the + // meaningful signal that release actually dropped the buffers is emptiness, + // not an RSS delta. + ASSERT_TRUE(importer.getSets().isEmpty()); +} + +TEST(OracleBenchmark, ImportRamUsageAllPrintings) +{ + // Only "1" enables the download: unset (the default and the CI setup) and + // an explicit "0" both disable it. + bool envOk = false; + const int enabled = qEnvironmentVariableIntValue("COCKATRICE_ORACLE_RAM_BENCHMARK", &envOk); + if (!envOk || enabled == 0) { + GTEST_SKIP() << "Set COCKATRICE_ORACLE_RAM_BENCHMARK=1 to download the real AllPrintings dataset for this " + "RAM benchmark. Default URL: " + << kDefaultAllPrintingsUrl.toDisplayString().toStdString(); + } + + // Baseline must precede the request so the phase covers the download + + // decompress step, including the payload materialized by readAll(). + const MemorySnapshot baseline = MemorySnapshot::current(); + if (!baseline.available) { + GTEST_SKIP() << "Memory stats unavailable on this platform"; + } + + QNetworkAccessManager nam; + QNetworkRequest request(kDefaultAllPrintingsUrl); + request.setHeader(QNetworkRequest::UserAgentHeader, "Cockatrice Oracle RAM benchmark"); + QNetworkReply *reply = nam.get(request); + + QEventLoop loop; + QTimer timeoutTimer; + timeoutTimer.setSingleShot(true); + bool timedOut = false; + QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit); + QObject::connect(&timeoutTimer, &QTimer::timeout, &loop, [&] { + timedOut = true; + reply->abort(); + }); + timeoutTimer.start(10 * 60 * 1000); + loop.exec(); + timeoutTimer.stop(); + + // abort() leaves reply->error() as OperationCanceledError, so a timed-out + // download takes the same GTEST_SKIP path as any other network error + // instead of reading a truncated body and failing the parse below. + if (timedOut || reply->error() != QNetworkReply::NoError) { + GTEST_SKIP() << "Download failed: " << reply->errorString().toStdString(); + } + const QByteArray payload = reply->readAll(); + reply->deleteLater(); + + // mtgjson can answer 200 with an HTML page (mirrors the wizard's '<' check + // in pages.cpp); reject it before trying to decompress/parse. + if (payload.startsWith("<")) { + GTEST_SKIP() << "Download returned a non-JSON body (HTML page instead of data), skipping"; + } + + const QByteArray setsData = decompressSetsData(payload); + const MemorySnapshot afterDownload = MemorySnapshot::current(); + if (setsData.isEmpty()) { + GTEST_SKIP() << "No data to import (download or decompression failed)"; + } + + NoopCardSetPriorityController controller; + OracleImporter importer; + + QElapsedTimer timer; + timer.start(); + ASSERT_TRUE(importer.readSetsFromByteArray(std::move(setsData))); + const qint64 parseMs = timer.elapsed(); + const MemorySnapshot afterParse = MemorySnapshot::current(); + + timer.restart(); + const int importedSets = importer.startImport(); + const qint64 importMs = timer.elapsed(); + const MemorySnapshot afterImport = MemorySnapshot::current(); + + importer.releaseSetData(); + const MemorySnapshot afterRelease = MemorySnapshot::current(); + + const int totalCards = importer.getCardList().size(); + qDebug().noquote() << QString("Oracle RAM Benchmark (real AllPrintings): %1 sets, %2 unique cards") + .arg(importedSets) + .arg(totalCards); + qDebug().noquote() << QString(" URL: %1").arg(kDefaultAllPrintingsUrl.toDisplayString()); + qDebug().noquote() << QString(" Downloaded: %1 MB, decompressed: %2 MB") + .arg(payload.size() / (1024.0 * 1024.0), 0, 'f', 1) + .arg(setsData.size() / (1024.0 * 1024.0), 0, 'f', 1); + qDebug().noquote() << QString(" JSON parse: %1 ms").arg(parseMs); + qDebug().noquote() << QString(" Card import: %1 ms").arg(importMs); + logRamPhase("download+decompress", baseline, afterDownload); + logRamPhase("parse", afterDownload, afterParse); + logRamPhase("import", afterParse, afterImport); + logRamPhase("after releaseSetData()", afterImport, afterRelease); +} + +int main(int argc, char **argv) +{ + // Required for the event loop used by the real-AllPrintings download benchmark + QCoreApplication app(argc, argv); + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/oracle/oracle_importer_test.cpp b/tests/oracle/oracle_importer_test.cpp new file mode 100644 index 000000000..83d77d465 --- /dev/null +++ b/tests/oracle/oracle_importer_test.cpp @@ -0,0 +1,1147 @@ +#include "../../oracle/src/oracleimporter.h" +#include "../../oracle/src/raw_json_scanner.h" +#include "libcockatrice/card/card_info.h" + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class OracleImporterTest : public ::testing::Test +{ +protected: + void SetUp() override + { + controller = new NoopCardSetPriorityController(); + importer = new OracleImporter(); + set = CardSet::newInstance(controller, "TST", "Test Set"); + } + + void TearDown() override + { + delete importer; + delete controller; + } + + // Helper: build a minimal card JSON object + QJsonObject makeCard(const QString &name, + const QString &colors = "", + const QString &colorIdentity = "", + const QVariantMap &legalities = {}) + { + QJsonObject card; + card["name"] = name; + card["text"] = "Rules text."; + card["layout"] = "normal"; + card["manaCost"] = "{W}"; + card["type"] = "Creature — Human"; + card["types"] = QJsonArray{"Creature"}; + card["number"] = "1"; + card["rarity"] = "common"; + + if (!colors.isEmpty()) { + QJsonArray arr; + for (const QChar &c : colors) { + arr.append(QString(c)); + } + card["colors"] = arr; + } + if (!colorIdentity.isEmpty()) { + QJsonArray arr; + for (const QChar &c : colorIdentity) { + arr.append(QString(c)); + } + card["colorIdentity"] = arr; + } + if (!legalities.isEmpty()) { + QJsonObject legalObj; + for (auto it = legalities.constBegin(); it != legalities.constEnd(); ++it) { + legalObj[it.key()] = it.value().toString(); + } + card["legalities"] = legalObj; + } + + QJsonObject identifiers; + identifiers["scryfallId"] = QUuid::createUuid().toString(QUuid::WithoutBraces); + card["identifiers"] = identifiers; + + return card; + } + + // Helper: build a single MTGJSON foreignData entry + QJsonObject makeForeignEntry(const QString &language, const QString &name, const QString &text) + { + QJsonObject entry; + entry["language"] = language; + entry["name"] = name; + if (!text.isEmpty()) { + entry["text"] = text; + } + return entry; + } + + NoopCardSetPriorityController *controller; + OracleImporter *importer; + CardSetPtr set; +}; + +// ============================================================================ +// sortAndReduceColors tests (tested via importCardsFromSet) +// ============================================================================ + +TEST_F(OracleImporterTest, SortAndReduceColorsSingleColor) +{ + QJsonArray cards{makeCard("Red Card", "R", "R")}; + importer->importCardsFromSet(set, cards); + + auto card = importer->getCardList().value("Red Card"); + ASSERT_FALSE(card.isNull()); + ASSERT_EQ(card->getProperty("colors"), "R"); +} + +TEST_F(OracleImporterTest, SortAndReduceColorsDeduplicates) +{ + QJsonArray cards{makeCard("Dedup Card", "WWUUB", "WU")}; + importer->importCardsFromSet(set, cards); + + auto card = importer->getCardList().value("Dedup Card"); + ASSERT_FALSE(card.isNull()); + ASSERT_EQ(card->getProperty("colors"), "WUB"); +} + +TEST_F(OracleImporterTest, SortAndReduceColorsSortsWUBRG) +{ + QJsonArray cards{makeCard("Sort Card", "RGW", "RGW")}; + importer->importCardsFromSet(set, cards); + + auto card = importer->getCardList().value("Sort Card"); + ASSERT_FALSE(card.isNull()); + ASSERT_EQ(card->getProperty("colors"), "WRG"); +} + +TEST_F(OracleImporterTest, SortAndReduceColorsAllFive) +{ + QJsonArray cards{makeCard("Five Color", "BRGWU", "BRGWU")}; + importer->importCardsFromSet(set, cards); + + auto card = importer->getCardList().value("Five Color"); + ASSERT_FALSE(card.isNull()); + ASSERT_EQ(card->getProperty("colors"), "WUBRG"); +} + +TEST_F(OracleImporterTest, SortAndReduceColorIdentity) +{ + QJsonArray cards{makeCard("Color Id Card", "W", "GWR")}; + importer->importCardsFromSet(set, cards); + + auto card = importer->getCardList().value("Color Id Card"); + ASSERT_FALSE(card.isNull()); + ASSERT_EQ(card->getProperty("coloridentity"), "WRG"); +} + +TEST_F(OracleImporterTest, SingleColorNotSorted) +{ + QJsonArray cards{makeCard("Single Card", "B", "B")}; + importer->importCardsFromSet(set, cards); + + auto card = importer->getCardList().value("Single Card"); + ASSERT_FALSE(card.isNull()); + ASSERT_EQ(card->getProperty("colors"), "B"); +} + +// ============================================================================ +// Legality guard tests +// ============================================================================ + +TEST_F(OracleImporterTest, NewCardKeepsLegalityProperties) +{ + // Verifies that format-* properties survive addCard on a fresh card + // (not the combineLegalities guard, which only runs on existing printings). + QVariantMap leg; + leg["standard"] = "legal"; + leg["modern"] = "legal"; + QJsonArray cards{makeCard("Legal Card", "", "", leg)}; + + importer->importCardsFromSet(set, cards); + auto card = importer->getCardList().value("Legal Card"); + ASSERT_FALSE(card.isNull()); + ASSERT_EQ(card->getProperty("format-standard"), "legal"); + ASSERT_EQ(card->getProperty("format-modern"), "legal"); +} + +TEST_F(OracleImporterTest, LegalityMergeAllowedWhenCardHasNoLegalities) +{ + // First printing carries no legalities at all, so the guard's + // `properties.filter(formatRegex).empty()` predicate is true and the + // second printing's legalities must be merged in. + QJsonArray cards1{makeCard("Unmerged Card")}; + importer->importCardsFromSet(set, cards1); + + CardSetPtr set2 = CardSet::newInstance(controller, "TS2", "Second Set"); + QVariantMap leg; + leg["standard"] = "legal"; + QJsonArray cards2{makeCard("Unmerged Card", "", "", leg)}; + importer->importCardsFromSet(set2, cards2); + + auto card = importer->getCardList().value("Unmerged Card"); + ASSERT_FALSE(card.isNull()); + ASSERT_EQ(card->getProperty("format-standard"), "legal"); +} + +TEST_F(OracleImporterTest, LegalityGuardPreservesFirstPrinting) +{ + // First printing: standard=legal, modern=legal + QVariantMap leg1; + leg1["standard"] = "legal"; + leg1["modern"] = "legal"; + QJsonArray cards1{makeCard("Guarded Card", "", "", leg1)}; + importer->importCardsFromSet(set, cards1); + + // Second printing: standard=banned, modern=not_legal + CardSetPtr set2 = CardSet::newInstance(controller, "TS2", "Second Set"); + QVariantMap leg2; + leg2["standard"] = "banned"; + leg2["modern"] = "not_legal"; + QJsonArray cards2{makeCard("Guarded Card", "", "", leg2)}; + importer->importCardsFromSet(set2, cards2); + + auto card = importer->getCardList().value("Guarded Card"); + ASSERT_FALSE(card.isNull()); + // Guard should preserve first printing's legalities + ASSERT_EQ(card->getProperty("format-standard"), "legal"); + ASSERT_EQ(card->getProperty("format-modern"), "legal"); +} + +// ============================================================================ +// createDefaultMagicFormats tests +// ============================================================================ + +TEST_F(OracleImporterTest, CreateDefaultMagicFormatsContainsExpectedFormats) +{ + auto formats = importer->createDefaultMagicFormats(); + ASSERT_TRUE(formats.contains("standard")); + ASSERT_TRUE(formats.contains("modern")); + ASSERT_TRUE(formats.contains("legacy")); + ASSERT_TRUE(formats.contains("vintage")); + ASSERT_TRUE(formats.contains("commander")); + ASSERT_TRUE(formats.contains("pauper")); + ASSERT_TRUE(formats.contains("pioneer")); + ASSERT_TRUE(formats.contains("brawl")); + ASSERT_TRUE(formats.contains("historic")); + ASSERT_TRUE(formats.contains("timeless")); + ASSERT_TRUE(formats.contains("duel")); + ASSERT_TRUE(formats.contains("oathbreaker")); +} + +TEST_F(OracleImporterTest, CreateDefaultMagicFormatsSingletonDeckSizes) +{ + auto formats = importer->createDefaultMagicFormats(); + auto commander = formats.value("commander"); + ASSERT_FALSE(commander.isNull()); + ASSERT_EQ(commander->minDeckSize, 100); + ASSERT_EQ(commander->maxDeckSize, 100); + ASSERT_EQ(commander->maxSideboardSize, 15); + + auto brawl = formats.value("brawl"); + ASSERT_FALSE(brawl.isNull()); + ASSERT_EQ(brawl->minDeckSize, 60); + ASSERT_EQ(brawl->maxDeckSize, 60); +} + +TEST_F(OracleImporterTest, CreateDefaultMagicFormatsVintageHasRestricted) +{ + auto formats = importer->createDefaultMagicFormats(); + auto vintage = formats.value("vintage"); + ASSERT_FALSE(vintage.isNull()); + bool hasRestricted = false; + for (const auto &ac : vintage->allowedCounts) { + if (ac.label == "restricted") { + hasRestricted = true; + ASSERT_EQ(ac.max, 1); + } + } + ASSERT_TRUE(hasRestricted); +} + +TEST_F(OracleImporterTest, CreateDefaultMagicFormatsRegexMatchesBasicLands) +{ + auto formats = importer->createDefaultMagicFormats(); + auto standard = formats.value("standard"); + ASSERT_FALSE(standard.isNull()); + ASSERT_FALSE(standard->exceptions.isEmpty()); + + auto &basicLandsException = standard->exceptions.first(); + ASSERT_FALSE(basicLandsException.conditions.isEmpty()); + + auto &condition = basicLandsException.conditions.first(); + ASSERT_EQ(condition.field, "type"); + ASSERT_EQ(condition.matchType, "regex"); + + // Verify the regex actually works (was broken before: \b = backspace, not word boundary) + QRegularExpression regex(condition.value); + ASSERT_TRUE(regex.isValid()); + ASSERT_TRUE(regex.match("Basic Land — Forest").hasMatch()); + ASSERT_TRUE(regex.match("Basic Snow Land — Mountain").hasMatch()); + ASSERT_FALSE(regex.match("Creature — Elf Warrior").hasMatch()); +} + +TEST_F(OracleImporterTest, CreateDefaultMagicFormatsCaching) +{ + // The memoized map returns the same FormatRulesPtr instances, so the + // shared pointers must be identical across calls. This is the only + // observable effect of the cache: contents would match either way. + auto first = importer->createDefaultMagicFormats(); + auto second = importer->createDefaultMagicFormats(); + ASSERT_EQ(first.value("standard").data(), second.value("standard").data()); +} + +// ============================================================================ +// readSetsFromByteArray tests +// ============================================================================ + +TEST_F(OracleImporterTest, ReadSetsFromByteArrayValidJson) +{ + QJsonObject setObj; + setObj["code"] = "tst"; + setObj["name"] = "Test Set"; + setObj["type"] = "expansion"; + setObj["releaseDate"] = "2024-01-01"; + setObj["cards"] = QJsonArray(); + + QJsonObject root; + root["data"] = QJsonObject{{"TST", setObj}}; + + QByteArray data = QJsonDocument(root).toJson(); + ASSERT_TRUE(importer->readSetsFromByteArray(data)); + ASSERT_EQ(importer->getSets().size(), 1); + ASSERT_EQ(importer->getSets().first().getShortName(), "TST"); +} + +TEST_F(OracleImporterTest, ReadSetsFromByteArrayInvalidJson) +{ + QByteArray data = "not valid json"; + ASSERT_FALSE(importer->readSetsFromByteArray(data)); +} + +TEST_F(OracleImporterTest, ReadSetsFromByteArrayEmptyData) +{ + QJsonObject root; + root["data"] = QJsonObject(); + + QByteArray data = QJsonDocument(root).toJson(); + ASSERT_FALSE(importer->readSetsFromByteArray(data)); +} + +TEST_F(OracleImporterTest, ReadSetsFromByteArrayCapitalizesSetType) +{ + QJsonObject setObj; + setObj["code"] = "ftv"; + setObj["name"] = "From The Vault"; + setObj["type"] = "from_the_vault"; + setObj["releaseDate"] = "2024-01-01"; + setObj["cards"] = QJsonArray(); + + QJsonObject root; + root["data"] = QJsonObject{{"FTV", setObj}}; + + QByteArray data = QJsonDocument(root).toJson(); + ASSERT_TRUE(importer->readSetsFromByteArray(data)); + ASSERT_EQ(importer->getSets().first().getSetType(), "From the Vault"); +} + +TEST_F(OracleImporterTest, ReadSetsFromByteArraySortsSetsByName) +{ + // QJsonObject iterates keys in lexicographic order ("AAA" before "ZZZ"), + // so leaving the natural order matching the alphabetical sort makes the + // assertion pass trivially. Inverting it keeps the sort meaningful: + // iteration yields "AAA" (Zeta Set) first, then the sort by name must + // promote "ZZZ" (Alpha Set) to the front. + QJsonObject setA; + setA["code"] = "aaa"; + setA["name"] = "Zeta Set"; + setA["type"] = "expansion"; + setA["releaseDate"] = "2024-01-01"; + setA["cards"] = QJsonArray(); + + QJsonObject setB; + setB["code"] = "zzz"; + setB["name"] = "Alpha Set"; + setB["type"] = "expansion"; + setB["releaseDate"] = "2024-01-01"; + setB["cards"] = QJsonArray(); + + QJsonObject root; + root["data"] = QJsonObject{{"AAA", setA}, {"ZZZ", setB}}; + + QByteArray data = QJsonDocument(root).toJson(); + ASSERT_TRUE(importer->readSetsFromByteArray(data)); + auto sets = importer->getSets(); + ASSERT_GE(sets.size(), 2); + ASSERT_EQ(sets.first().getShortName(), "ZZZ"); +} + +// ============================================================================ +// Split card coloridentity tests +// ============================================================================ + +TEST_F(OracleImporterTest, SplitCardColorIdentityConcatenated) +{ + QJsonObject leg{{"standard", "not_legal"}}; + + QJsonObject face1; + face1["name"] = "Fire // Ice"; + face1["text"] = "Fire deals 2 damage."; + face1["layout"] = "split"; + face1["side"] = "a"; + face1["faceName"] = "Fire"; + face1["colors"] = QJsonArray{"R"}; + face1["colorIdentity"] = QJsonArray{"R"}; + face1["types"] = QJsonArray{"Instant"}; + face1["manaCost"] = "{R}"; + face1["legalities"] = leg; + face1["identifiers"] = QJsonObject{{"scryfallId", "aaa"}}; + face1["number"] = "1"; + face1["rarity"] = "uncommon"; + + QJsonObject face2; + face2["name"] = "Fire // Ice"; + face2["text"] = "Ice taps target artifact."; + face2["layout"] = "split"; + face2["side"] = "b"; + face2["faceName"] = "Ice"; + face2["colors"] = QJsonArray{"U"}; + face2["colorIdentity"] = QJsonArray{"U"}; + face2["types"] = QJsonArray{"Instant"}; + face2["manaCost"] = "{U}"; + face2["legalities"] = leg; + face2["identifiers"] = QJsonObject{{"scryfallId", "bbb"}}; + face2["number"] = "1"; + face2["rarity"] = "uncommon"; + + QJsonArray cardsList{face1, face2}; + int count = importer->importCardsFromSet(set, cardsList); + ASSERT_EQ(count, 1); + + auto card = importer->getCardList().value("Fire // Ice"); + ASSERT_FALSE(card.isNull()); + + // coloridentity should be "RU" (concatenated), then sorted to "UR" + // by sortAndReduceColors when it reaches addCard + ASSERT_EQ(card->getProperty("coloridentity"), "UR"); +} + +TEST_F(OracleImporterTest, SplitCardColorsConcatenated) +{ + QJsonObject leg{{"standard", "not_legal"}}; + + QJsonObject face1; + face1["name"] = "Fire // Ice"; + face1["text"] = "Fire deals 2 damage."; + face1["layout"] = "split"; + face1["side"] = "a"; + face1["faceName"] = "Fire"; + face1["colors"] = QJsonArray{"R"}; + face1["colorIdentity"] = QJsonArray{"R"}; + face1["types"] = QJsonArray{"Instant"}; + face1["manaCost"] = "{R}"; + face1["legalities"] = leg; + face1["identifiers"] = QJsonObject{{"scryfallId", "aaa"}}; + face1["number"] = "1"; + face1["rarity"] = "uncommon"; + + QJsonObject face2; + face2["name"] = "Fire // Ice"; + face2["text"] = "Ice taps target artifact."; + face2["layout"] = "split"; + face2["side"] = "b"; + face2["faceName"] = "Ice"; + face2["colors"] = QJsonArray{"U"}; + face2["colorIdentity"] = QJsonArray{"U"}; + face2["types"] = QJsonArray{"Instant"}; + face2["manaCost"] = "{U}"; + face2["legalities"] = leg; + face2["identifiers"] = QJsonObject{{"scryfallId", "bbb"}}; + face2["number"] = "1"; + face2["rarity"] = "uncommon"; + + QJsonArray cardsList{face1, face2}; + importer->importCardsFromSet(set, cardsList); + + auto card = importer->getCardList().value("Fire // Ice"); + ASSERT_FALSE(card.isNull()); + + QString colors = card->getProperty("colors"); + ASSERT_FALSE(colors.contains("//")) << "colors should not contain '//', got: " << colors.toStdString(); + ASSERT_TRUE(colors.contains("R")); + ASSERT_TRUE(colors.contains("U")); +} + +// ============================================================================ +// Mana cost formatting tests +// ============================================================================ + +TEST_F(OracleImporterTest, ManaCostStripsBraces) +{ + QJsonObject card = makeCard("Mana Card"); + card["manaCost"] = "{2}{W}{B}"; + QJsonArray cards{card}; + + importer->importCardsFromSet(set, cards); + auto result = importer->getCardList().value("Mana Card"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getProperty("manacost"), "2WB"); +} + +// cmc comes through as a JSON number ("convertedManaCost"/"manaValue" are +// floats in AllPrintings), so this pins the number-to-text coercion that +// QJsonValue::toString() dropped in #7214. +TEST_F(OracleImporterTest, NumericManaValueCoercedToCmc) +{ + QJsonObject card = makeCard("Cmc Card"); + card["manaValue"] = 3; + QJsonArray cards{card}; + + importer->importCardsFromSet(set, cards); + auto result = importer->getCardList().value("Cmc Card"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getProperty("cmc"), "3"); +} + +TEST_F(OracleImporterTest, LegacyConvertedManaCostCoercedToCmc) +{ + QJsonObject card = makeCard("Legacy Cmc Card"); + card["convertedManaCost"] = 3.0; + QJsonArray cards{card}; + + importer->importCardsFromSet(set, cards); + auto result = importer->getCardList().value("Legacy Cmc Card"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getProperty("cmc"), "3"); +} + +// ============================================================================ +// Card deduplication tests +// ============================================================================ + +TEST_F(OracleImporterTest, DuplicateCardNameReturnsExisting) +{ + QJsonArray cards{makeCard("Dupe Card")}; + importer->importCardsFromSet(set, cards); + + CardSetPtr set2 = CardSet::newInstance(controller, "TS2", "Second Set"); + QJsonArray cards2{makeCard("Dupe Card")}; + importer->importCardsFromSet(set2, cards2); + + ASSERT_EQ(importer->getCardList().size(), 1); +} + +TEST_F(OracleImporterTest, AELigatureReplaced) +{ + QJsonObject card = makeCard(QString::fromUtf8("\xC3\x86ther Vial")); // Æther Vial + QJsonArray cards{card}; + + importer->importCardsFromSet(set, cards); + // Æ is replaced with AE, resulting in "AEther Vial" + ASSERT_FALSE(importer->getCardList().contains(QString::fromUtf8("\xC3\x86ther Vial"))); + ASSERT_TRUE(importer->getCardList().contains("AEther Vial")); +} + +TEST_F(OracleImporterTest, ApostropheNormalized) +{ + QJsonObject card = makeCard(QString::fromUtf8("Jace\u2019s Ingenuity")); + QJsonArray cards{card}; + + importer->importCardsFromSet(set, cards); + ASSERT_TRUE(importer->getCardList().contains("Jace's Ingenuity")); +} + +// ============================================================================ +// RawJson scanner tests +// ============================================================================ + +TEST_F(OracleImporterTest, ScanSetRangesMatchFullJsonParse) +{ + QJsonObject root; + QJsonObject data; + data["AAA"] = makeCard("Alpha Card"); + data["BBB"] = makeCard("Beta Card"); + root["data"] = data; + + const QByteArray bytes = QJsonDocument(root).toJson(QJsonDocument::Compact); + + RawJson::ScanError error; + const QList ranges = RawJson::scanSetRanges(bytes, &error); + ASSERT_FALSE(error.isError()) << error.message.toStdString(); + ASSERT_EQ(ranges.size(), 2); + + const QJsonObject wholeData = QJsonDocument::fromJson(bytes).object().value("data").toObject(); + for (const RawJson::SetRange &range : ranges) { + QJsonParseError parseError; + const QJsonDocument sliceDoc = QJsonDocument::fromJson( + QByteArray(bytes.constData() + range.dataRange.start, range.dataRange.length), &parseError); + ASSERT_EQ(parseError.error, QJsonParseError::NoError) + << range.code.toStdString() << ": " << parseError.errorString().toStdString(); + ASSERT_EQ(sliceDoc.object(), wholeData.value(range.code).toObject()) << "set " << range.code.toStdString(); + } +} + +TEST_F(OracleImporterTest, ScanSetRangesDecodesEscapesAndCountsCards) +{ + const QByteArray json = "{\"data\":{\"KEY\":{\"code\":\"zzz\",\"name\":\"\\u00c9tude \\ud83d\\ude00\"," + "\"type\":\"expansion\",\"releaseDate\":\"2024-01-05\"," + "\"cards\":[{\"name\":\"a\"},{\"name\":\"b\"},{\"name\":\"c\"}]}}}"; + + RawJson::ScanError error; + const QList ranges = RawJson::scanSetRanges(json, &error); + ASSERT_FALSE(error.isError()); + ASSERT_EQ(ranges.size(), 1); + + const RawJson::SetRange &range = ranges.first(); + ASSERT_EQ(range.code, "zzz"); // inner "code" wins over the object key + const QString expectedName = QString::fromUtf8("\xC3\x89tude ") + QChar(0xD83D) + QChar(0xDE00); + ASSERT_EQ(range.name, expectedName); + ASSERT_EQ(range.type, "expansion"); + ASSERT_EQ(range.releaseDate, "2024-01-05"); + ASSERT_EQ(range.dataRange.cardCount, 3); + + QJsonParseError parseError; + const QJsonDocument sliceDoc = QJsonDocument::fromJson( + QByteArray(json.constData() + range.dataRange.start, range.dataRange.length), &parseError); + ASSERT_EQ(parseError.error, QJsonParseError::NoError); + ASSERT_EQ(sliceDoc.object().value("name").toString(), expectedName); + ASSERT_EQ(sliceDoc.object().value("cards").toArray().size(), 3); +} + +TEST_F(OracleImporterTest, ScanSetRangesRejectsInvalidJson) +{ + const QList invalid = {"not json", + "[]", + "{\"data\":[]}", + "{\"data\":{}}", + "{\"other\":{}}", + "{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"type\":\"x\"," + "\"releaseDate\":\"2024-01-01\",\"cards\":[]}}} trailing", + "{\"data\":{\"A\":{\"cards\":[{\"name\":\"\\uZZZZ\"}]}}}", + "{\"data\":{\"A\":{\"cards\":[{\"name\":\"bad \\q escape\"}]}}}", + "{\"data\":{\"A\":{\"cards\":[{\"name\":\"\\ud800\"}]}}}"}; + + for (const QByteArray &json : invalid) { + RawJson::ScanError error; + RawJson::scanSetRanges(json, &error); + EXPECT_TRUE(error.isError()) << "expected failure for: " << json.constData(); + } +} + +TEST_F(OracleImporterTest, ScanSetRangesMatchesFullJsonParseVerdicts) +{ + // Verdicts must agree with QJsonDocument::fromJson for the inputs below — + // including the metadata quirks ("name": null, "type": 7, "releaseDate": null, + // "cards": null) that used to make the scanner reject sets Qt accepts. + const QList inputs = { + "{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"type\":\"x\",\"releaseDate\":\"2024-01-01\",\"cards\":[{" + "\"n\":1}]}}}", + "{\"data\":{\"A\":{\"code\":\"a\",\"name\":null,\"type\":\"x\",\"releaseDate\":\"2024-01-01\",\"cards\":[]}}}", + "{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"type\":null,\"releaseDate\":\"2024-01-01\",\"cards\":[]}}}", + "{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"type\":7,\"releaseDate\":\"2024-01-01\",\"cards\":null}}}", + "{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"ok\",\"releaseDate\":\"2024-01-01\",\"cards\":[1,2,3]}}}", + // unescaped control character inside a string: QJsonDocument and + // skipString both accept it, so the scanner must not reject the whole doc + "{\"data\":{\"A\":{\"code\":\"a\",\"name\":\"N\tX\",\"releaseDate\":\"2024-01-01\",\"cards\":[{\"n\":1}]}}}", + // structurally invalid JSON (both parsers must reject) + "not json", + "{\"data\":{\"A\":{\"name\":\"unterminated}}", + }; + + for (const QByteArray &input : inputs) { + QJsonParseError qtError; + QJsonDocument::fromJson(input, &qtError); + const bool qtOk = qtError.error == QJsonParseError::NoError; + + RawJson::ScanError scanError; + const QList ranges = RawJson::scanSetRanges(input, &scanError); + EXPECT_EQ(qtOk, !scanError.isError()) << "verdict mismatch for: " << input.constData(); + if (scanError.isError()) { + continue; + } + for (const RawJson::SetRange &range : ranges) { + QJsonParseError sliceError; + QJsonDocument::fromJson(QByteArray(input.constData() + range.dataRange.start, range.dataRange.length), + &sliceError); + EXPECT_EQ(sliceError.error, QJsonParseError::NoError) << "bad range slice for: " << input.constData(); + } + } +} + +TEST_F(OracleImporterTest, ScanSetRangesRejectsDeepNesting) +{ + // Far beyond the shared 1024 container cap: Qt reports DeepNesting and the + // scanner must reject too, without overflowing the stack through its + // recursive skipValue walk. + QString nesting; + nesting.reserve(10000); + for (int i = 0; i < 5000; ++i) { + nesting += '['; + } + for (int i = 0; i < 5000; ++i) { + nesting += ']'; + } + const QByteArray json = ("{\"data\":{\"A\":{\"code\":\"a\",\"cards\":" + nesting + "}}}").toUtf8(); + + QJsonParseError qtError; + QJsonDocument::fromJson(json, &qtError); + ASSERT_NE(qtError.error, QJsonParseError::NoError) << "expected Qt to reject deep nesting"; + + RawJson::ScanError scanError; + RawJson::scanSetRanges(json, &scanError); + ASSERT_TRUE(scanError.isError()) << "scanner accepted a document Qt rejects as too deeply nested"; +} + +TEST_F(OracleImporterTest, ScanSetRangesAcceptsQtMaxNesting) +{ + // Pins the boundary rather than only the far-past case: a depth Qt still + // accepts must be accepted by the scanner too. Before the fix the scanner's + // cap was roughly half of Qt's (each level cost two decrements), so a + // depth of 1000 here was rejected even though QJsonDocument parses it. + constexpr int depth = 1000; + QString nesting; + nesting.reserve(2 * depth); + for (int i = 0; i < depth; ++i) { + nesting += '['; + } + for (int i = 0; i < depth; ++i) { + nesting += ']'; + } + const QByteArray json = ("{\"data\":{\"A\":{\"code\":\"a\",\"cards\":" + nesting + "}}}").toUtf8(); + + QJsonParseError qtError; + QJsonDocument::fromJson(json, &qtError); + ASSERT_EQ(qtError.error, QJsonParseError::NoError) << "expected Qt to accept depth " << depth; + + RawJson::ScanError scanError; + RawJson::scanSetRanges(json, &scanError); + ASSERT_FALSE(scanError.isError()) << "scanner rejected a document Qt accepts at depth " << depth; +} + +// ============================================================================ +// Lazy per-set parsing tests +// ============================================================================ + +TEST_F(OracleImporterTest, StartImportParsesSetsLazily) +{ + QJsonObject setObj = makeCard("Lazy Import Card"); + QJsonArray cards; + cards.append(setObj); + QJsonObject dataSet; + dataSet["code"] = "tst"; + dataSet["name"] = "Test Set"; + dataSet["type"] = "expansion"; + dataSet["releaseDate"] = "2024-01-01"; + dataSet["cards"] = cards; + + QJsonObject root; + root["data"] = QJsonObject{{"TST", dataSet}}; + + const QByteArray data = QJsonDocument(root).toJson(QJsonDocument::Compact); + ASSERT_TRUE(importer->readSetsFromByteArray(data)); + ASSERT_FALSE(importer->getRawSetsData().isEmpty()); + + const int importedSets = importer->startImport(); + ASSERT_EQ(importedSets, 1); + ASSERT_EQ(importer->getCardList().size(), 1); + ASSERT_FALSE(importer->getCardList().value("Lazy Import Card").isNull()); +} + +// ============================================================================ +// Scan progress reporting tests +// ============================================================================ + +TEST(OracleScanProgress, ScanProgressReportsMonotonicBytesToTotal) +{ + QJsonObject setObj; + setObj["code"] = "tst"; + setObj["name"] = "Test Set"; + setObj["type"] = "expansion"; + setObj["releaseDate"] = "2024-01-01"; + QJsonArray cards; + for (int i = 0; i < 40; ++i) { + QJsonObject card; + card["name"] = QString("Card %1").arg(i); + card["text"] = "Some rules text used to bulk up the card payload."; + card["layout"] = "normal"; + cards.append(card); + } + setObj["cards"] = cards; + + QJsonObject root; + root["data"] = QJsonObject{{"TST", setObj}}; + + const QByteArray data = QJsonDocument(root).toJson(QJsonDocument::Compact); + + QList> reports; + RawJson::ScanError error; + const QList ranges = + RawJson::scanSetRanges(data, &error, [&reports](qsizetype bytesRead, qsizetype totalBytes) { + reports.append({bytesRead, totalBytes}); + }); + + ASSERT_FALSE(error.isError()) << error.message.toStdString(); + ASSERT_EQ(ranges.size(), 1); + ASSERT_FALSE(reports.isEmpty()); + ASSERT_GT(reports.size(), 1); + + qsizetype last = 0; + for (const auto &[bytesRead, totalBytes] : reports) { + ASSERT_EQ(totalBytes, data.size()); + ASSERT_GE(bytesRead, last) << "scan progress must be monotonic"; + ASSERT_LE(bytesRead, totalBytes) << "scan progress must not overshoot the document size"; + last = bytesRead; + } + ASSERT_EQ(reports.constLast().first, data.size()) << "scan must end at 100%"; + ASSERT_LE(reports.size(), 160) << "scan reports must be throttled"; +} + +TEST(OracleScanProgress, ScanWithoutCallbackStillParses) +{ + QJsonObject setObj; + setObj["code"] = "tst"; + setObj["name"] = "Test Set"; + setObj["type"] = "expansion"; + setObj["releaseDate"] = "2024-01-01"; + setObj["cards"] = QJsonArray(); + + QJsonObject root; + root["data"] = QJsonObject{{"TST", setObj}}; + + const QByteArray data = QJsonDocument(root).toJson(QJsonDocument::Compact); + + RawJson::ScanError error; + const QList ranges = RawJson::scanSetRanges(data, &error); + + ASSERT_FALSE(error.isError()) << error.message.toStdString(); + ASSERT_EQ(ranges.size(), 1); + ASSERT_EQ(ranges.first().code, "tst"); +} + +TEST_F(OracleImporterTest, ReadSetsFromByteArrayEmitsScanProgress) +{ + QJsonObject setObj; + setObj["code"] = "tst"; + setObj["name"] = "Test Set"; + setObj["type"] = "expansion"; + setObj["releaseDate"] = "2024-01-01"; + QJsonArray cards; + for (int i = 0; i < 40; ++i) { + QJsonObject card; + card["name"] = QString("Card %1").arg(i); + cards.append(card); + } + setObj["cards"] = cards; + + QJsonObject root; + root["data"] = QJsonObject{{"TST", setObj}}; + + const QByteArray data = QJsonDocument(root).toJson(QJsonDocument::Compact); + + QList> emissions; + QObject::connect(importer, &OracleImporter::dataReadProgress, + [&emissions](int bytesRead, int totalBytes) { emissions.append({bytesRead, totalBytes}); }); + + ASSERT_TRUE(importer->readSetsFromByteArray(data)); + ASSERT_FALSE(emissions.isEmpty()); + for (const auto &[bytesRead, totalBytes] : emissions) { + ASSERT_EQ(totalBytes, data.size()); + ASSERT_GE(bytesRead, 0); + ASSERT_LE(bytesRead, totalBytes); + } + ASSERT_EQ(emissions.constLast().first, data.size()); +} + +TEST_F(OracleImporterTest, DisablingProgressReportingSuppressesScanEmissions) +{ + QJsonObject setObj; + setObj["code"] = "tst"; + setObj["name"] = "Test Set"; + setObj["type"] = "expansion"; + setObj["releaseDate"] = "2024-01-01"; + setObj["cards"] = QJsonArray(); + QJsonObject root; + root["data"] = QJsonObject{{"TST", setObj}}; + const QByteArray data = QJsonDocument(root).toJson(QJsonDocument::Compact); + + int emissions = 0; + QObject::connect(importer, &OracleImporter::dataReadProgress, [&emissions](int, int) { ++emissions; }); + + importer->setProgressReporting(false); + ASSERT_TRUE(importer->readSetsFromByteArray(data)); + ASSERT_EQ(emissions, 0); + + importer->setProgressReporting(true); + ASSERT_TRUE(importer->readSetsFromByteArray(data)); + ASSERT_GT(emissions, 0); +} + +// Localized card text tests +// ============================================================================ + +TEST_F(OracleImporterTest, ImportsLocalizedTextForRequestedLanguage) +{ + QJsonObject card = makeCard("Lightning Bolt"); + card["foreignData"] = + QJsonArray{makeForeignEntry("German", "Blitzschlag", "Blitzschlag fügt 3 Schadenspunkte zu.")}; + QJsonArray cards{card}; + + importer->setCardLang("de"); + importer->importCardsFromSet(set, cards); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getLocalizedName("de"), "Blitzschlag"); + ASSERT_EQ(result->getLocalizedText("de"), "Blitzschlag fügt 3 Schadenspunkte zu."); + // English identity untouched + ASSERT_EQ(result->getName(), "Lightning Bolt"); + ASSERT_EQ(result->getText(), "Rules text."); +} + +TEST_F(OracleImporterTest, ImportsLocalizedNameAndTextForMultiFaceCards) +{ + // MTGJSON reports multi-face cards (adventure/split/aftermath/prepare) as one + // card object per face; every face carries the joined name but only its own + // face's rules text in foreignData. The importer joins the per-face texts with + // the same separator as the English merge. + QJsonObject front = makeCard("Disruptive Stormbrood // Petty Revenge"); + front["layout"] = "adventure"; + front["faceName"] = "Disruptive Stormbrood"; + front["side"] = "a"; + front["foreignData"] = QJsonArray{ + makeForeignEntry("German", "Disruptive Stormbrood // Kleinliche Rache", + "Fliegend\nWenn diese Kreatur ins Spiel kommt, zerstöre bis zu ein Artefakt oder eine " + "Verzauberung deiner Wahl.")}; + QJsonObject back = makeCard("Disruptive Stormbrood // Petty Revenge"); + back["layout"] = "adventure"; + back["faceName"] = "Petty Revenge"; + back["side"] = "b"; + back["text"] = "Destroy target creature."; + back["foreignData"] = QJsonArray{makeForeignEntry("German", "Disruptive Stormbrood // Kleinliche Rache", + "Zerstöre eine Kreatur deiner Wahl mit Stärke 3 oder weniger.")}; + QJsonArray cards{front, back}; + + importer->setCardLang("de"); + importer->importCardsFromSet(set, cards); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Disruptive Stormbrood // Petty Revenge"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getLocalizedName("de"), "Disruptive Stormbrood // Kleinliche Rache"); + ASSERT_EQ(result->getLocalizedText("de"), + "Fliegend\nWenn diese Kreatur ins Spiel kommt, zerstöre bis zu ein Artefakt oder eine Verzauberung " + "deiner Wahl.\n\n---\n\nZerstöre eine Kreatur deiner Wahl mit Stärke 3 oder weniger."); + // English identity untouched + ASSERT_EQ(result->getName(), "Disruptive Stormbrood // Petty Revenge"); + ASSERT_EQ(result->getText(), "Rules text.\n\n---\n\nDestroy target creature."); +} + +TEST_F(OracleImporterTest, MultiFaceCardsWithoutCompleteForeignTextKeepEnglishText) +{ + // Both faces must carry a foreignData text for the joined text; otherwise the + // rules text stays English while the localized name (from a later complete + // printing) is still applied. + QJsonObject front = makeCard("Wear // Tear"); + front["layout"] = "split"; + front["faceName"] = "Wear"; + front["side"] = "a"; + front["foreignData"] = QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Verschleiß-Text.")}; + QJsonObject back = makeCard("Wear // Tear"); + back["layout"] = "split"; + back["faceName"] = "Tear"; + back["side"] = "b"; + back["text"] = "Tear rules text."; + back["foreignData"] = QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "")}; + QJsonArray cards{front, back}; + + importer->setCardLang("de"); + importer->importCardsFromSet(set, cards); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Wear // Tear"); + ASSERT_FALSE(result.isNull()); + // The joined name is still applied. + ASSERT_EQ(result->getLocalizedName("de"), "Verschleiß // Zerrreißung"); + // The incomplete text must not become the card's localized text. + ASSERT_TRUE(result->getLocalizedTexts().isEmpty()); + ASSERT_EQ(result->getText(), "Rules text.\n\n---\n\nTear rules text."); +} + +TEST_F(OracleImporterTest, DefaultLanguageSkipsForeignData) +{ + QJsonObject card = makeCard("Lightning Bolt"); + card["foreignData"] = + QJsonArray{makeForeignEntry("German", "Blitzschlag", "Blitzschlag fügt 3 Schadenspunkte zu.")}; + QJsonArray cards{card}; + + // cardLang defaults to "en" — foreignData must never be imported + importer->importCardsFromSet(set, cards); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_TRUE(result->getLocalizedNames().isEmpty()); + ASSERT_TRUE(result->getLocalizedTexts().isEmpty()); +} + +TEST_F(OracleImporterTest, UnsupportedLanguageSkipsForeignData) +{ + QJsonObject card = makeCard("Lightning Bolt"); + card["foreignData"] = QJsonArray{makeForeignEntry("xx", "Kochanie", "Grzmot uderza.")}; + QJsonArray cards{card}; + + importer->setCardLang("xx"); + importer->importCardsFromSet(set, cards); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_TRUE(result->getLocalizedNames().isEmpty()); +} + +TEST_F(OracleImporterTest, NonMatchingLanguageNotCollected) +{ + QJsonObject card = makeCard("Lightning Bolt"); + card["foreignData"] = QJsonArray{makeForeignEntry("French", "Éclair", "L'Éclair inflige 3 blessures.")}; + QJsonArray cards{card}; + + importer->setCardLang("de"); + importer->importCardsFromSet(set, cards); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_TRUE(result->getLocalizedNames().isEmpty()); +} + +TEST_F(OracleImporterTest, HigherPrioritySetWinsForReprint) +{ + // First printing in a reprint set, then another in a (more authoritative) + // core set: the core set's German text must win even though it was seen later. + QJsonObject reprintCard = makeCard("Lightning Bolt"); + reprintCard["foreignData"] = QJsonArray{makeForeignEntry("German", "Blitzschlag", "Älterer deutscher Text.")}; + CardSetPtr reprintSet = + CardSet::newInstance(controller, "TS2", "Second Set", QString(), QDate(), CardSet::PriorityReprint); + importer->setCardLang("de"); + importer->importCardsFromSet(reprintSet, QJsonArray{reprintCard}); + + QJsonObject primaryCard = makeCard("Lightning Bolt"); + primaryCard["foreignData"] = + QJsonArray{makeForeignEntry("German", "Blitzschlag", "Blitzschlag fügt 3 Schadenspunkte zu.")}; + CardSetPtr primarySet = + CardSet::newInstance(controller, "TS3", "Third Set", QString(), QDate(), CardSet::PriorityPrimary); + importer->importCardsFromSet(primarySet, QJsonArray{primaryCard}); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getLocalizedName("de"), "Blitzschlag"); + ASSERT_EQ(result->getLocalizedText("de"), "Blitzschlag fügt 3 Schadenspunkte zu."); + ASSERT_EQ(importer->getCardList().size(), 1); +} + +TEST_F(OracleImporterTest, HigherPrioritySplitSetWinsForReprint) +{ + // Split cards print each face as its own card object; the joined text is + // collected per set with the same priority policy as single-face cards, so a + // reprint set's German text must yield to the core set's even when reprints + // are imported first. + QJsonObject reprintFront = makeCard("Wear // Tear"); + reprintFront["layout"] = "split"; + reprintFront["faceName"] = "Wear"; + reprintFront["side"] = "a"; + reprintFront["foreignData"] = + QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Wear alter Text.")}; + QJsonObject reprintBack = makeCard("Wear // Tear"); + reprintBack["layout"] = "split"; + reprintBack["faceName"] = "Tear"; + reprintBack["side"] = "b"; + reprintBack["foreignData"] = + QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Tear alter Text.")}; + CardSetPtr reprintSet = + CardSet::newInstance(controller, "TS2", "Second Set", QString(), QDate(), CardSet::PriorityReprint); + importer->setCardLang("de"); + importer->importCardsFromSet(reprintSet, QJsonArray{reprintFront, reprintBack}); + + QJsonObject primaryFront = makeCard("Wear // Tear"); + primaryFront["layout"] = "split"; + primaryFront["faceName"] = "Wear"; + primaryFront["side"] = "a"; + primaryFront["foreignData"] = + QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Wear neuer Text.")}; + QJsonObject primaryBack = makeCard("Wear // Tear"); + primaryBack["layout"] = "split"; + primaryBack["faceName"] = "Tear"; + primaryBack["side"] = "b"; + primaryBack["foreignData"] = + QJsonArray{makeForeignEntry("German", "Verschleiß // Zerrreißung", "Tear neuer Text.")}; + CardSetPtr primarySet = + CardSet::newInstance(controller, "TS3", "Third Set", QString(), QDate(), CardSet::PriorityPrimary); + importer->importCardsFromSet(primarySet, QJsonArray{primaryFront, primaryBack}); + importer->applyLocalizedData(); + + auto result = importer->getCardList().value("Wear // Tear"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getLocalizedName("de"), "Verschleiß // Zerrreißung"); + ASSERT_EQ(result->getLocalizedText("de"), "Wear neuer Text.\n\n---\n\nTear neuer Text."); + ASSERT_EQ(importer->getCardList().size(), 1); +} + +TEST_F(OracleImporterTest, StartImportAppliesLocalizedData) +{ + QJsonObject card = makeCard("Lightning Bolt"); + card["foreignData"] = + QJsonArray{makeForeignEntry("Portuguese (Brazil)", "Raio", "Raio causa 3 de dano a qualquer alvo.")}; + QJsonObject dataSet; + dataSet["code"] = "tst"; + dataSet["name"] = "Test Set"; + dataSet["type"] = "expansion"; + dataSet["releaseDate"] = "2024-01-01"; + dataSet["cards"] = QJsonArray{card}; + + QJsonObject root; + root["data"] = QJsonObject{{"TST", dataSet}}; + + importer->setCardLang("pt"); + ASSERT_TRUE(importer->readSetsFromByteArray(QJsonDocument(root).toJson(QJsonDocument::Compact))); + ASSERT_EQ(importer->startImport(), 1); + + auto result = importer->getCardList().value("Lightning Bolt"); + ASSERT_FALSE(result.isNull()); + ASSERT_EQ(result->getLocalizedName("pt"), "Raio"); + ASSERT_EQ(result->getLocalizedText("pt"), "Raio causa 3 de dano a qualquer alvo."); +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/oracle/parse_cipt_test.cpp b/tests/oracle/parse_cipt_test.cpp index f5036f962..10268d659 100644 --- a/tests/oracle/parse_cipt_test.cpp +++ b/tests/oracle/parse_cipt_test.cpp @@ -1,6 +1,7 @@ #include "../../oracle/src/parsehelpers.h" #include "gtest/gtest.h" +#include TEST(ParseCiptTest, parsesThisEntersTapped) { diff --git a/tests/password_hash_test.cpp b/tests/password_hash_test.cpp index 38d9b6315..2a504b81c 100644 --- a/tests/password_hash_test.cpp +++ b/tests/password_hash_test.cpp @@ -1,25 +1,12 @@ #include "gtest/gtest.h" -#include -#include +#include +#include +#include #include - -RNG_Abstract *rng; +#include namespace { -class PasswordHashTest : public ::testing::Test -{ -protected: - void SetUp() override - { - rng = new RNG_SFMT; - } - - void TearDown() override - { - delete rng; - } -}; TEST(PasswordHashTest, RegressionTest) { @@ -29,6 +16,29 @@ TEST(PasswordHashTest, RegressionTest) QString hash = PasswordHasher::computeHash(password, salt); ASSERT_EQ(hash, salt + expected) << "The computed hash value remains the same"; } + +TEST(PasswordHashTest, SaltUsesAlphanumericCharset) +{ + static const char alphanum[] = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + const QString salt = PasswordHasher::generateRandomSalt(); + ASSERT_EQ(salt.size(), 16); + for (const QChar &c : salt) { + ASSERT_NE(strchr(alphanum, c.toLatin1()), nullptr); + } +} + +TEST(PasswordHashTest, SaltsAreUnique) +{ + const QString salt1 = PasswordHasher::generateRandomSalt(); + const QString salt2 = PasswordHasher::generateRandomSalt(); + ASSERT_NE(salt1, salt2); +} + +TEST(PasswordHashTest, TokenHasExpectedLength) +{ + const QString token = PasswordHasher::generateActivationToken(); + ASSERT_EQ(token.size(), 16); +} } // namespace int main(int argc, char **argv) diff --git a/tests/picture_loader_benchmark/CMakeLists.txt b/tests/picture_loader_benchmark/CMakeLists.txt new file mode 100644 index 000000000..0684f58f4 --- /dev/null +++ b/tests/picture_loader_benchmark/CMakeLists.txt @@ -0,0 +1,24 @@ +# Manual benchmark hitting the real card image hosts. Not registered with CTest: it requires a +# cards.xml, touches the network for minutes at a time, and needs network access. Build it +# explicitly and run by hand. +cockatrice_add_manual_test( + NAME + picture_loader_benchmark_test + SOURCES + ${VERSION_STRING_CPP} + ../../cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp + ../../cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp + ../../cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp + ../../cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp + ../../cockatrice/src/client/settings/cache_settings.h + picture_loader_benchmark.cpp + settings_cache_mock.cpp + LIBS + libcockatrice_card + libcockatrice_settings + libcockatrice_interfaces + libcockatrice_utility + INCLUDE_DIRS + ${CMAKE_SOURCE_DIR}/cockatrice/src + MANUAL +) diff --git a/tests/picture_loader_benchmark/picture_loader_benchmark.cpp b/tests/picture_loader_benchmark/picture_loader_benchmark.cpp new file mode 100644 index 000000000..587cef65f --- /dev/null +++ b/tests/picture_loader_benchmark/picture_loader_benchmark.cpp @@ -0,0 +1,561 @@ +/* + * Picture loader benchmark / regression suite against the real card image hosts. + * + * Deliberately not registered with ctest: it hits live Scryfall / Gatherer + * endpoints at ~10 requests per second and takes minutes. Run it by hand. + * + * picture_loader_benchmark_test --carddb /path/to/cards.xml [options] + * + * Modes + * ----- + * default : for every URL template in the configured download list (or for each + * --url given), load #count pictures twice: once cold (network) and + * once cached (served from the QNetworkDiskCache). Both passes must + * load every card with zero failures. The cached pass must complete + * well under the cold time, which is the regression gate for serving + * cached pictures instead of re-fetching them. The cold pass must stay + * above a pacing lower bound, the regression gate for burst-free + * request throttling. + * --stress: two CardPictureLoaderWorker instances loading the same cards + * concurrently against one host (~20 req/s aggregate), which forces + * real 429 responses. Both workers must still complete 100% of their + * cards via the shared backoff logic. + */ + +#include "client/settings/cache_settings.h" +#include "interface/card_picture_loader/card_picture_loader_worker.h" +#include "interface/card_picture_loader/card_picture_to_load.h" +#include "libcockatrice/card/card_info.h" +#include "libcockatrice/card/database/card_database_loader.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +class BenchmarkCardDatabasePathProvider : public ICardDatabasePathProvider +{ +public: + BenchmarkCardDatabasePathProvider(QString _cardsXml, QString _customSetsDir) + : cardsXml(std::move(_cardsXml)), customSetsDir(std::move(_customSetsDir)) + { + } + + QString getCardDatabasePath() const override + { + return cardsXml; + } + + QString getCustomCardDatabasePath() const override + { + return customSetsDir; + } + + QString getTokenDatabasePath() const override + { + return QString(); + } + + QString getSpoilerCardDatabasePath() const override + { + return QString(); + } + +private: + QString cardsXml; + QString customSetsDir; +}; + +struct PassResult +{ + int enqueued = 0; + int finished = 0; + int failed = 0; + qint64 elapsedMs = 0; + QStringList failedCards; +}; + +static QList selectCardsForTemplate(CardDatabase &db, const QString &urlTemplate, int maxCards) +{ + QList selected; + const QList cards = db.getCardList().values(); + for (const CardInfoPtr &card : cards) { + if (selected.size() >= maxCards) { + break; + } + const SetToPrintingsMap &sets = card->getSets(); + if (sets.isEmpty()) { + continue; + } + const QList printings = sets.first(); + if (printings.isEmpty()) { + continue; + } + const ExactCard cardToLoad(card, printings.first()); + if (CardPictureToLoad(cardToLoad).transformUrl(urlTemplate).isEmpty()) { + continue; + } + selected.append(cardToLoad); + } + return selected; +} + +static PassResult runPass(CardPictureLoaderWorker *worker, const QList &cards, int timeoutMs) +{ + PassResult result; + result.enqueued = cards.size(); + + QEventLoop loop; + QTimer watchdog; + watchdog.setSingleShot(true); + watchdog.setInterval(timeoutMs); + QObject::connect(&watchdog, &QTimer::timeout, &loop, &QEventLoop::quit); + + QElapsedTimer clock; + QObject::connect(worker, &CardPictureLoaderWorker::imageLoaded, &loop, + [&](const ExactCard &card, const QImage &image) { + ++result.finished; + if (image.isNull()) { + ++result.failed; + if (result.failedCards.size() < 10) { + result.failedCards.append(card.getName()); + } + } + if (result.finished >= result.enqueued) { + loop.quit(); + } + }); + + clock.start(); + for (const ExactCard &card : cards) { + worker->enqueueImageLoad(card); + } + watchdog.start(); + loop.exec(); + result.elapsedMs = clock.elapsed(); + return result; +} + +struct StressResult +{ + PassResult a; + PassResult b; + int http429Count = 0; +}; + +struct LogCounters +{ + int http429 = 0; + QMutex mutex; +}; + +static std::atomic s_activeCounters{nullptr}; +static QtMessageHandler s_previousMessageHandler = nullptr; + +static void stressLogHandler(QtMsgType type, const QMessageLogContext &context, const QString &msg) +{ + if (LogCounters *counters = s_activeCounters.load(std::memory_order_acquire); + counters && msg.contains(QStringLiteral("Too many requests from"))) { + QMutexLocker locker(&counters->mutex); + ++counters->http429; + } + if (s_previousMessageHandler) { + s_previousMessageHandler(type, context, msg); + } else { + // qInstallMessageHandler() reports the built-in handler as nullptr, so a plain `if` would + // swallow every message - including the 429 warnings this run is meant to surface. Fall + // back to Qt's message pattern written to stderr instead. + std::fprintf(stderr, "%s\n", qPrintable(qFormatLogMessage(type, context, msg))); + } +} + +// Stops a worker's thread and frees it. shutdownThread()'s bounded wait guarantees that the worker +// object was freed by its finished() -> deleteLater chain, so the thread itself can then be deleted +// safely. A worker whose thread refused to stop is left alone (and leaked) rather than freed while +// still running. +static void destroyWorker(CardPictureLoaderWorker *worker) +{ + if (!worker) { + return; + } + QThread *thread = worker->workerThread(); + if (worker->shutdownThread()) { + delete thread; + } +} + +static StressResult runStress(CardPictureLoaderWorker *workerA, + CardPictureLoaderWorker *workerB, + const QList &cards, + int timeoutMs) +{ + StressResult result; + result.a.enqueued = cards.size(); + result.b.enqueued = cards.size(); + + int completed = 0; + QMutex completedMutex; + + QEventLoop loop; + QTimer watchdog; + watchdog.setSingleShot(true); + watchdog.setInterval(timeoutMs); + QObject::connect(&watchdog, &QTimer::timeout, &loop, &QEventLoop::quit); + + const auto finishOne = [&](PassResult &pass, const ExactCard &card, const QImage &image) { + ++pass.finished; + if (image.isNull()) { + ++pass.failed; + if (pass.failedCards.size() < 10) { + pass.failedCards.append(card.getName()); + } + } + QMutexLocker locker(&completedMutex); + ++completed; + if (completed >= result.a.enqueued + result.b.enqueued) { + loop.quit(); + } + }; + + QElapsedTimer clock; + QObject::connect(workerA, &CardPictureLoaderWorker::imageLoaded, &loop, + [&](const ExactCard &card, const QImage &image) { finishOne(result.a, card, image); }); + QObject::connect(workerB, &CardPictureLoaderWorker::imageLoaded, &loop, + [&](const ExactCard &card, const QImage &image) { finishOne(result.b, card, image); }); + + // Count 429 responses as seen by the shared rate limiter. + LogCounters counters; + s_activeCounters = &counters; + s_previousMessageHandler = qInstallMessageHandler(stressLogHandler); + + clock.start(); + for (const ExactCard &card : cards) { + workerA->enqueueImageLoad(card); + workerB->enqueueImageLoad(card); + } + watchdog.start(); + loop.exec(); + const qint64 elapsedMs = clock.elapsed(); + result.a.elapsedMs = elapsedMs; + result.b.elapsedMs = elapsedMs; + + // Stop both workers before touching the counters or restoring the message handler: their + // threads log from stressLogHandler, and must not outlive the stack-local counters (which is + // guaranteed on the watchdog path, where requests and deferred retries are still pending). + destroyWorker(workerA); + destroyWorker(workerB); + + result.http429Count = counters.http429; + s_activeCounters.store(nullptr, std::memory_order_release); + qInstallMessageHandler(s_previousMessageHandler); + return result; +} + +static QString formatDuration(qint64 ms) +{ + return QStringLiteral("%1.%2 s").arg(ms / 1000).arg((ms % 1000) / 100); +} + +static bool likelyRedirects(const QString &urlTemplate) +{ + return urlTemplate.contains(QStringLiteral("api.scryfall.com")); +} + +static QString hostOf(const QString &urlTemplate) +{ + return QUrl(urlTemplate).host(); +} + +static void printUsage() +{ + std::printf("usage: picture_loader_benchmark_test --carddb [options]\n" + "\n" + "Loads card pictures from the real configured hosts (not a mock server) and\n" + "verifies the picture loader's pacing / cache 429 behavior.\n" + "\n" + "options:\n" + " --carddb cards.xml to load card data from (required)\n" + " --count cards to load per template (default 300)\n" + " --url