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..96dd10763 100644 --- a/.ci/Ubuntu26.04/Dockerfile +++ b/.ci/Ubuntu26.04/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/compile.sh b/.ci/compile.sh index be9923a66..379b4eac4 100755 --- a/.ci/compile.sh +++ b/.ci/compile.sh @@ -157,6 +157,9 @@ if [[ $MAKE_TEST ]]; then fi if [[ $USE_CCACHE == "1" ]]; then flags+=("-DUSE_CCACHE=1") + # PCH-aware caching is required or ccache refuses to cache any TU that + # consumes a precompiled header, silently recompiling everything on every run. + ccache --set-config sloppiness=pch_defines,time_macros if [[ -n $CCACHE_SIZE ]]; then # Note, this setting persists after running the script ccache --max-size "$CCACHE_SIZE" @@ -169,6 +172,7 @@ if [[ -n $PACKAGE_TYPE ]]; then fi if [[ $USE_VCPKG ]]; then flags+=("-DUSE_VCPKG=1") + flags+=("-DVCPKG_INSTALL_OPTIONS=--x-abi-tools-use-exact-versions") # if [[ $MAKE_PACKAGE && $RUNNER_OS == Windows ]]; then # flags+=("-DVCPKG_APPLOCAL_DEPS=OFF") # disable copying of runtime DLLs into build output # fi 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) LinuxUbuntu 26.04 LTS Resolute Racoon @@ -24,10 +24,10 @@ Available pre-compiled binaries for installation: • Fedora 44Fedora 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/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 56ad64283..589cae1d8 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -334,7 +334,7 @@ the tr() call, also you can add an extra string as a hint for translators: QString message = tr("Everyone draws %n cards", "english hint for translators", amount); ``` See [Qt's wiki on translations]( -https://doc.qt.io/qt-5/i18n-source-translation.html#handling-plurals) +https://doc.qt.io/qt-6/i18n-source-translation.html#handling-plurals) If you're about to propose a change that adds or modifies any translatable string in the code, you don't need to take care of adding the new strings to 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 6c1e475f3..6e60a77be 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 }} @@ -146,13 +146,13 @@ jobs: name: ${{ matrix.distro }} ${{ matrix.version }} 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 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 }} @@ -161,14 +161,11 @@ jobs: 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 }} path: ${{ env.CACHE }} - restore-keys: ccache-${{ matrix.distro }}${{ matrix.version }}- - name: "Build ${{ matrix.distro }} ${{ matrix.version }} Docker image" shell: bash @@ -179,8 +176,12 @@ jobs: shell: bash 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") + + RUN --server --debug --test "${args[@]}" - name: "Build release package" id: build @@ -203,10 +204,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 +218,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,73 +263,76 @@ 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: Ninja 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 - os: Windows @@ -339,8 +343,8 @@ jobs: cmake_generator_platform: x64 make_package: 1 package_suffix: "-Win10_VS" - 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' || '' }}${{ matrix.cmake_generator != 'Ninja' && ' VS' || '' }} @@ -349,7 +353,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" @@ -369,54 +373,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 }}" @@ -425,9 +429,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' @@ -450,6 +455,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 }} @@ -458,27 +464,28 @@ 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 # 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..967d94c58 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,7 +13,10 @@ on: - master paths: - '.github/workflows/docker-release.yml' + - '.dockerignore' - 'Dockerfile' + - 'docker-compose.yml' + - 'docker-compose.yml.windows' release: types: - released # publishing of stable releases @@ -23,36 +26,38 @@ concurrency: group: "${{ github.workflow }} @ ${{ github.ref_name }}" cancel-in-progress: ${{ github.event_name != 'release' }} +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 - - name: "Set up Docker buildx" uses: docker/setup-buildx-action@v4 @@ -61,18 +66,117 @@ jobs: 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-release 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: + archive: false + 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 + 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 + tags: | + type=ref,event=tag # if semver, also: type=semver,pattern={{version}} / {{major}}.{{minor}} + + # 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: ${{ 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..419cbfbfb 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 diff --git a/.github/workflows/translations-pull.yml b/.github/workflows/translations-pull.yml index a3db5f86d..71b0b4c22 100644 --- a/.github/workflows/translations-pull.yml +++ b/.github/workflows/translations-pull.yml @@ -1,5 +1,9 @@ name: Update Translations +permissions: + contents: read + pull-requests: write + on: pull_request: paths: diff --git a/.github/workflows/translations-push.yml b/.github/workflows/translations-push.yml index c4d3f61fb..41a7aef40 100644 --- a/.github/workflows/translations-push.yml +++ b/.github/workflows/translations-push.yml @@ -1,5 +1,9 @@ name: Update Translation Source +permissions: + contents: read + pull-requests: write + on: pull_request: paths: diff --git a/.gitignore b/.gitignore index 33c1ae31d..54939bdcf 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ mysql.cnf .DS_Store .idea/ *.aps +*.cache cmake-build* preferences compile_commands.json diff --git a/CMakeLists.txt b/CMakeLists.txt index 4ac9201ae..ab63028ae 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,23 +5,23 @@ # 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) # Use vcpkg regardless of OS option(USE_VCPKG "Use vcpkg regardless of OS" OFF) @@ -34,13 +34,24 @@ if(NOT CMAKE_BUILD_TYPE) ) 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) @@ -59,10 +70,11 @@ if(WIN32 OR USE_VCPKG) else() set(QTDIR "" - CACHE PATH "Path to Qt (e.g. C:/Qt/5.7/msvc2015_64)" + CACHE PATH "Path to Qt (e.g. C:/Qt/6.4.2/msvc2019_64)" ) message( - WARNING "QTDIR variable is missing. Please set this variable to specify path to Qt (e.g. C:/Qt/5.7/msvc2015_64)" + WARNING + "QTDIR variable is missing. Please set this variable to specify path to Qt (e.g. C:/Qt/6.4.2/msvc2019_64)" ) endif() endif() @@ -191,6 +203,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") @@ -199,6 +214,9 @@ 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() # GNU systems need to define the Mersenne exponent for the RNG to compile w/o warning @@ -246,11 +264,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() @@ -288,11 +301,7 @@ if(UNIX) if(CPACK_GENERATOR STREQUAL "RPM") set(CPACK_RPM_PACKAGE_LICENSE "GPLv2") set(CPACK_RPM_MAIN_COMPONENT "cockatrice") - if(Qt6_FOUND) - set(CPACK_RPM_PACKAGE_REQUIRES "protobuf, qt6-qttools, qt6-qtsvg, qt6-qtmultimedia, qt6-qtimageformats") - elseif(Qt5_FOUND) - set(CPACK_RPM_PACKAGE_REQUIRES "protobuf, qt5-qttools, qt5-qtsvg, qt5-qtmultimedia") - endif() + 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 @@ -310,12 +319,8 @@ if(UNIX) set(CPACK_DEBIAN_PACKAGE_SHLIBDEPS ON) set(CPACK_DEBIAN_PACKAGE_SECTION "games") set(CPACK_DEBIAN_PACKAGE_HOMEPAGE "http://github.com/Cockatrice/Cockatrice") - if(Qt6_FOUND) - set(CPACK_DEBIAN_PACKAGE_DEPENDS "libqt6multimedia6, libqt6svg6, qt6-qpa-plugins, qt6-image-formats-plugins") - set(CPACK_DEBIAN_PACKAGE_RECOMMENDS "libqt6sql6-mysql") # for connecting servatrice to a mysql db - elseif(Qt5_FOUND) - set(CPACK_DEBIAN_PACKAGE_DEPENDS "libqt5multimedia5-plugins, libqt5svg5") - endif() + 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() elseif(WIN32) 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 fc96b5f22..9b05682cc 100644 --- a/Doxyfile +++ b/Doxyfile @@ -349,7 +349,7 @@ OPTIMIZE_OUTPUT_SLICE = NO # # Note see also the list of default file extension mappings. -EXTENSION_MAPPING = +EXTENSION_MAPPING = proto=C++ # If the MARKDOWN_SUPPORT tag is enabled then Doxygen pre-processes all comments # according to the Markdown format, which allows for more readable @@ -1086,7 +1086,8 @@ FILE_PATTERNS = *.cc \ *.h++ \ *.markdown \ *.md \ - *.dox + *.dox \ + *.proto # The RECURSIVE tag can be used to specify whether or not subdirectories should # be searched for input files as well. @@ -1103,6 +1104,7 @@ RECURSIVE = YES EXCLUDE = build/ \ cmake/ \ + cmake-build-debug/ \ doc/doxygen/theme/docs/ \ doc/doxygen/theme/include/ \ vcpkg/ @@ -1195,7 +1197,7 @@ INPUT_FILTER = # need to set EXTENSION_MAPPING for the extension otherwise the files are not # properly processed by Doxygen. -FILTER_PATTERNS = +FILTER_PATTERNS = "*.proto=python doc/doxygen/filters/proto2cpp.py" # If the FILTER_SOURCE_FILES tag is set to YES, the input filter (if set using # INPUT_FILTER) will also be used to filter the input files that are used for diff --git a/README.md b/README.md index 9a27601cd..5935bb540 100644 --- a/README.md +++ b/README.md @@ -149,16 +149,15 @@ 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 | -| `-DFORCE_USE_QT5=1` | Skip looking for Qt6 before trying to find Qt5 | +| 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 | # Run diff --git a/cmake/FindQtRuntime.cmake b/cmake/FindQtRuntime.cmake index 485affe52..971c9094d 100644 --- a/cmake/FindQtRuntime.cmake +++ b/cmake/FindQtRuntime.cmake @@ -1,8 +1,7 @@ # Find a compatible Qt version -# Inputs: WITH_SERVER, WITH_CLIENT, WITH_ORACLE, FORCE_USE_QT5 +# Inputs: WITH_SERVER, WITH_CLIENT, WITH_ORACLE # Optional Input: QT6_DIR -- Hint as to where Qt6 lives on the system -# Optional Input: QT5_DIR -- Hint as to where Qt5 lives on the system -# Output: COCKATRICE_QT_VERSION_NAME -- Example values: Qt5, Qt6 +# Output: COCKATRICE_QT_VERSION_NAME -- Example values: Qt6 # Output: SERVATRICE_QT_MODULES # Output: COCKATRICE_QT_MODULES # Output: ORACLE_QT_MODULES @@ -19,14 +18,17 @@ if(WITH_CLIENT) 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). @@ -39,69 +41,37 @@ set(REQUIRED_QT_COMPONENTS ${REQUIRED_QT_COMPONENTS} ${_SERVATRICE_NEEDED} ${_CO ) list(REMOVE_DUPLICATES REQUIRED_QT_COMPONENTS) -if(NOT FORCE_USE_QT5) - # Linguist is now a component in Qt6 instead of an external package - find_package( - Qt6 6.4.2 - COMPONENTS ${REQUIRED_QT_COMPONENTS} Linguist - QUIET HINTS ${Qt6_DIR} - ) +# Linguist is now a component in Qt6 instead of an external package +find_package( + Qt6 6.4.2 + COMPONENTS ${REQUIRED_QT_COMPONENTS} Linguist + QUIET HINTS ${Qt6_DIR} +) +if(NOT Qt6_FOUND) + message(FATAL_ERROR "No suitable version of Qt was found") endif() -if(Qt6_FOUND) - set(COCKATRICE_QT_VERSION_NAME Qt6) +set(COCKATRICE_QT_VERSION_NAME Qt6) - list(FIND Qt6LinguistTools_TARGETS Qt6::lrelease QT6_LRELEASE_INDEX) - if(QT6_LRELEASE_INDEX EQUAL -1) - message(WARNING "Qt6 lrelease not found.") - endif() - - list(FIND Qt6LinguistTools_TARGETS Qt6::lupdate QT6_LUPDATE_INDEX) - if(QT6_LUPDATE_INDEX EQUAL -1) - message(WARNING "Qt6 lupdate not found.") - endif() -else() - find_package( - Qt5 5.15.2 - COMPONENTS ${REQUIRED_QT_COMPONENTS} - QUIET HINTS ${Qt5_DIR} - ) - if(Qt5_FOUND) - set(COCKATRICE_QT_VERSION_NAME Qt5) - else() - message(FATAL_ERROR "No suitable version of Qt was found") - endif() - - # Qt5 Linguist is in a separate package - find_package(Qt5LinguistTools QUIET) - if(Qt5LinguistTools_FOUND) - if(NOT Qt5_LRELEASE_EXECUTABLE) - message(WARNING "Qt5 lrelease not found.") - endif() - if(NOT Qt5_LUPDATE_EXECUTABLE) - message(WARNING "Qt5 lupdate not found.") - endif() - else() - message(WARNING "Linguist Tools not found, cannot handle translations") - endif() +list(FIND Qt6LinguistTools_TARGETS Qt6::lrelease QT6_LRELEASE_INDEX) +if(QT6_LRELEASE_INDEX EQUAL -1) + message(WARNING "Qt6 lrelease not found.") endif() -if(Qt5_POSITION_INDEPENDENT_CODE OR Qt6_FOUND) - set(CMAKE_POSITION_INDEPENDENT_CODE ON) +list(FIND Qt6LinguistTools_TARGETS Qt6::lupdate QT6_LUPDATE_INDEX) +if(QT6_LUPDATE_INDEX EQUAL -1) + message(WARNING "Qt6 lupdate not found.") endif() +set(CMAKE_POSITION_INDEPENDENT_CODE ON) + # Establish Qt Plugins directory & Library directories get_target_property(QT_LIBRARY_DIR ${COCKATRICE_QT_VERSION_NAME}::Core LOCATION) get_filename_component(QT_LIBRARY_DIR ${QT_LIBRARY_DIR} DIRECTORY) -if(Qt6_FOUND) - get_filename_component(QT_PLUGINS_DIR "${Qt6Core_DIR}/../../../${QT6_INSTALL_PLUGINS}" ABSOLUTE) - get_filename_component(QT_LIBRARY_DIR "${QT_LIBRARY_DIR}/../../.." ABSOLUTE) - if(UNIX AND APPLE) - # Mac needs a bit more help finding all necessary components - list(APPEND QT_LIBRARY_DIR "/usr/local/lib") - endif() -elseif(Qt5_FOUND) - get_filename_component(QT_PLUGINS_DIR "${Qt5Core_DIR}/../../../plugins" ABSOLUTE) - get_filename_component(QT_LIBRARY_DIR "${QT_LIBRARY_DIR}/.." ABSOLUTE) +get_filename_component(QT_PLUGINS_DIR "${Qt6Core_DIR}/../../../${QT6_INSTALL_PLUGINS}" ABSOLUTE) +get_filename_component(QT_LIBRARY_DIR "${QT_LIBRARY_DIR}/../../.." ABSOLUTE) +if(UNIX AND APPLE) + # Mac needs a bit more help finding all necessary components + list(APPEND QT_LIBRARY_DIR "/usr/local/lib") endif() message(DEBUG "QT_PLUGINS_DIR = ${QT_PLUGINS_DIR}") message(DEBUG "QT_LIBRARY_DIR = ${QT_LIBRARY_DIR}") diff --git a/cmake/Info.plist b/cmake/Info.plist index 614d82509..7f01befcb 100644 --- a/cmake/Info.plist +++ b/cmake/Info.plist @@ -1,38 +1,118 @@ - + + + + + + CFBundleDevelopmentRegion English + CFBundleExecutable ${MACOSX_BUNDLE_EXECUTABLE_NAME} + CFBundleGetInfoString ${MACOSX_BUNDLE_INFO_STRING} + CFBundleIconFile ${MACOSX_BUNDLE_ICON_FILE} + CFBundleIdentifier ${MACOSX_BUNDLE_GUI_IDENTIFIER} + CFBundleInfoDictionaryVersion 6.0 + CFBundleLongVersionString ${MACOSX_BUNDLE_LONG_VERSION_STRING} + CFBundleName ${MACOSX_BUNDLE_BUNDLE_NAME} + CFBundlePackageType APPL + CFBundleShortVersionString ${MACOSX_BUNDLE_SHORT_VERSION_STRING} + CFBundleSignature ???? + CFBundleVersion ${MACOSX_BUNDLE_BUNDLE_VERSION} - CSResourcesFileMapped - - LSRequiresCarbon - + NSHumanReadableCopyright ${MACOSX_BUNDLE_COPYRIGHT} + NSHighResolutionCapable + + + + + + UTExportedTypeDeclarations + + + UTTypeIdentifier + org.cockatrice.deck + + UTTypeDescription + Cockatrice Deck + + UTTypeConformsTo + + public.data + + + UTTypeTagSpecification + + public.filename-extension + + cod + + + + + + CFBundleDocumentTypes + + + CFBundleTypeName + Cockatrice Deck + + CFBundleTypeRole + Editor + + LSHandlerRank + Default + + LSItemContentTypes + + org.cockatrice.deck + + + + + + + + + CFBundleURLTypes + + + CFBundleURLName + Cockatrice URL Scheme + + CFBundleURLSchemes + + cockatrice + + + + diff --git a/cmake/pch/qtcore_pch.h b/cmake/pch/qtcore_pch.h new file mode 100644 index 000000000..cc3dd12ee --- /dev/null +++ b/cmake/pch/qtcore_pch.h @@ -0,0 +1,24 @@ +/** @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. + */ + +#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..2c63f450e --- /dev/null +++ b/cmake/pch/qtwidgets_pch.h @@ -0,0 +1,30 @@ +/** @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). + */ + +#include "qtcore_pch.h" + +#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 60ba06593..4263fc6e2 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,13 +39,16 @@ 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_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_settings.cpp src/interface/widgets/dialogs/dlg_startup_card_check.cpp @@ -97,7 +103,9 @@ set(cockatrice_SOURCES src/game_graphics/player/menu/rfg_menu.cpp src/game_graphics/player/menu/say_menu.cpp src/game_graphics/player/menu/sideboard_menu.cpp + src/game_graphics/player/menu/tally_menu.cpp src/game_graphics/player/menu/utility_menu.cpp + src/game_graphics/tally/stats_tally.cpp src/game_graphics/tally/subtype_tally.cpp src/game_graphics/tally/tally.cpp src/game/player/player_actions.cpp @@ -133,9 +141,19 @@ set(cockatrice_SOURCES src/interface/card_picture_loader/card_picture_loader_worker.cpp src/interface/card_picture_loader/card_picture_loader_worker_work.cpp src/interface/card_picture_loader/card_picture_to_load.cpp + src/interface/intents/intent.cpp + src/interface/intents/intent.h + src/interface/intents/intent_open_local_deck.cpp + src/interface/intents/intent_open_local_deck.h + src/interface/intents/intent_wait_for_database_load.cpp + 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 @@ -147,6 +165,8 @@ set(cockatrice_SOURCES src/interface/widgets/cards/additional_info/color_identity_widget.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 @@ -195,6 +215,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 @@ -211,6 +232,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 @@ -226,12 +248,19 @@ 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 + src/interface/widgets/replay/replay_quick_settings_widget.cpp src/interface/widgets/replay/replay_timeline_widget.cpp + 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 @@ -244,19 +273,26 @@ 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 src/interface/widgets/settings_page/deck_editor_settings_page.cpp src/interface/widgets/settings_page/general_settings_page.cpp src/interface/widgets/settings_page/messages_settings_page.cpp + src/interface/widgets/settings_page/settings_search_delegate.cpp + src/interface/widgets/settings_page/settings_search_model.cpp src/interface/widgets/settings_page/shortcut_settings_page.cpp src/interface/widgets/settings_page/sound_settings_page.cpp src/interface/widgets/settings_page/storage_settings_page.cpp src/interface/widgets/settings_page/user_interface_settings_page.cpp src/interface/widgets/utility/custom_line_edit.cpp src/interface/widgets/utility/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 @@ -282,13 +318,16 @@ set(cockatrice_SOURCES 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/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 src/interface/window_main.cpp src/main.cpp + src/single_instance_manager.cpp src/interface/widgets/tabs/abstract_tab_deck_editor.cpp src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp src/interface/widgets/tabs/api/archidekt/api_response/archidekt_deck_listing_api_response.cpp @@ -304,6 +343,13 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.cpp src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_listings_display_widget.cpp src/interface/widgets/tabs/api/archidekt/display/archidekt_deck_preview_image_display_widget.cpp + src/interface/widgets/tabs/api/commander_spellbook/api_response/card_in_deck_request.cpp + src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_deck_request.cpp + src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_card_result.cpp + src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_variant_result.cpp + src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_estimate_bracket_result.cpp + src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_bracket_explainer.cpp + src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.cpp src/interface/widgets/tabs/api/edhrec/api_response/archidekt_links/edhrec_api_response_archidekt_links.cpp src/interface/widgets/tabs/api/edhrec/api_response/average_deck/edhrec_average_deck_api_response.cpp src/interface/widgets/tabs/api/edhrec/api_response/average_deck/edhrec_deck_api_response.cpp @@ -336,10 +382,13 @@ 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_report.cpp src/interface/widgets/tabs/tab_replays.cpp src/interface/widgets/tabs/tab_room.cpp src/interface/widgets/tabs/tab_server.cpp @@ -350,12 +399,53 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/visual_deck_storage/tab_deck_storage_visual.cpp src/interface/key_signals.cpp src/interface/logger.cpp + 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 src/interface/widgets/tabs/api/edhrec/display/commander/edhrec_commander_api_response_budget_navigation_widget.h src/interface/widgets/utility/compact_push_button.cpp src/interface/widgets/utility/compact_push_button.h + src/single_instance_manager.h + src/client/url_scheme_event_filter.h + src/interface/intents/intent_connect_to_server.cpp + src/interface/intents/intent_connect_to_server.h + src/interface/intents/intent_disconnect_from_server.cpp + src/interface/intents/intent_disconnect_from_server.h + src/interface/intents/intent_join_server_game.cpp + src/interface/intents/intent_join_server_game.h + src/interface/intents/intent_join_server_room.cpp + 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/url_parser.cpp + src/interface/intents/url_parser.h src/interface/widgets/server/user/user_info_popup.cpp src/interface/widgets/server/user/user_info_popup.h ) @@ -399,11 +489,7 @@ if(APPLE) set(cockatrice_SOURCES ${cockatrice_SOURCES} ${CMAKE_CURRENT_SOURCE_DIR}/resources/appicon.icns) endif(APPLE) -if(Qt6_FOUND) - qt6_add_resources(cockatrice_RESOURCES_RCC ${cockatrice_RESOURCES}) -elseif(Qt5_FOUND) - qt5_add_resources(cockatrice_RESOURCES_RCC ${cockatrice_RESOURCES}) -endif() +qt6_add_resources(cockatrice_RESOURCES_RCC ${cockatrice_RESOURCES}) # Declare path variables set(ICONDIR @@ -415,71 +501,62 @@ set(DESKTOPDIR CACHE STRING "desktop file destination" ) +set(MIMEDIR + share/mime/packages + CACHE STRING "mime file destination" +) + set(COCKATRICE_MAC_QM_INSTALL_DIR "cockatrice.app/Contents/Resources/translations") set(COCKATRICE_UNIX_QM_INSTALL_DIR "share/cockatrice/translations") set(COCKATRICE_WIN32_QM_INSTALL_DIR "translations") -if(Qt6_FOUND) - qt6_add_executable( - cockatrice - WIN32 - MACOSX_BUNDLE - ${cockatrice_SOURCES} - ${cockatrice_RESOURCES_RCC} - ${cockatrice_MOC_SRCS} - MANUAL_FINALIZATION - ) -elseif(Qt5_FOUND) - # Qt5 Translations need to be linked at executable creation time - if(Qt5LinguistTools_FOUND) - if(UPDATE_TRANSLATIONS) - qt5_create_translation(cockatrice_QM ${translate_SRCS} ${cockatrice_TS}) - else() - qt5_add_translation(cockatrice_QM ${cockatrice_TS}) - endif() - endif() - add_executable( - cockatrice WIN32 MACOSX_BUNDLE ${cockatrice_MOC_SRCS} ${cockatrice_QM} ${cockatrice_RESOURCES_RCC} - ${cockatrice_SOURCES} - ) - if(UNIX) - if(APPLE) - install(FILES ${cockatrice_QM} DESTINATION ${COCKATRICE_MAC_QM_INSTALL_DIR}) - else() - install(FILES ${cockatrice_QM} DESTINATION ${COCKATRICE_UNIX_QM_INSTALL_DIR}) - endif() - elseif(WIN32) - install(FILES ${cockatrice_QM} DESTINATION ${COCKATRICE_WIN32_QM_INSTALL_DIR}) - endif() -endif() +qt6_add_executable( + cockatrice + WIN32 + MACOSX_BUNDLE + ${cockatrice_SOURCES} + ${cockatrice_RESOURCES_RCC} + ${cockatrice_MOC_SRCS} + MANUAL_FINALIZATION +) -if(Qt5_FOUND) - target_link_libraries( - cockatrice - libcockatrice_card - libcockatrice_deck_list - libcockatrice_filters - libcockatrice_utility - libcockatrice_network - libcockatrice_models - libcockatrice_rng - libcockatrice_settings - ${COCKATRICE_QT_MODULES} - ) -else() - target_link_libraries( - cockatrice - PUBLIC libcockatrice_card - libcockatrice_deck_list - libcockatrice_filters - libcockatrice_utility - libcockatrice_network - libcockatrice_models - libcockatrice_rng - libcockatrice_settings - ${COCKATRICE_QT_MODULES} - ) -endif() +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 + libcockatrice_deck_list + libcockatrice_filters + libcockatrice_utility + libcockatrice_network + libcockatrice_models + libcockatrice_rng + libcockatrice_settings + ${COCKATRICE_QT_MODULES} +) if(UNIX) if(APPLE) @@ -499,6 +576,23 @@ if(UNIX) install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/resources/cockatrice.png DESTINATION ${ICONDIR}/hicolor/48x48/apps) install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/resources/cockatrice.svg DESTINATION ${ICONDIR}/hicolor/scalable/apps) install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/cockatrice.desktop DESTINATION ${DESKTOPDIR}) + install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/cockatrice-cod.xml DESTINATION ${MIMEDIR}) + + # Refresh the freedesktop databases so the file associations and scheme + # handler register without requiring the user to run them manually. The + # tools may be missing on minimal systems; that is fine, packaging systems + # usually refresh these databases through their own triggers. + find_program(UPDATE_MIME_DATABASE update-mime-database) + if(UPDATE_MIME_DATABASE) + install(CODE "execute_process(COMMAND \"${UPDATE_MIME_DATABASE}\" \"${CMAKE_INSTALL_PREFIX}/share/mime\")") + endif() + + find_program(UPDATE_DESKTOP_DATABASE update-desktop-database) + if(UPDATE_DESKTOP_DATABASE) + install( + CODE "execute_process(COMMAND \"${UPDATE_DESKTOP_DATABASE}\" \"${CMAKE_INSTALL_PREFIX}/share/applications\")" + ) + endif() endif() elseif(WIN32) install(TARGETS cockatrice RUNTIME DESTINATION ./) @@ -509,7 +603,7 @@ if(APPLE) set(plugin_dest_dir cockatrice.app/Contents/Plugins) set(qtconf_dest_dir cockatrice.app/Contents/Resources) - # Qt plugins: audio (Qt5), iconengines, imageformats, multimedia (Qt6), platforms, printsupport (Qt5), styles, tls (Qt6) + # Qt plugins: audio, iconengines, imageformats, multimedia, platforms, printsupport, styles, tls install( DIRECTORY "${QT_PLUGINS_DIR}/" DESTINATION ${plugin_dest_dir} @@ -576,7 +670,7 @@ if(WIN32) PATTERN "*.ini" ) - # Qt plugins: audio (Qt5), iconengines, imageformats, multimedia (Qt6) platforms, printsupport (Qt5), styles, tls (Qt6) + # Qt plugins: audio, iconengines, imageformats, multimedia, platforms, printsupport, styles, tls install( DIRECTORY "${QT_PLUGINS_DIR}/" DESTINATION ${plugin_dest_dir} @@ -632,7 +726,7 @@ Data = Resources\") endif() endif() -if(Qt6_FOUND AND Qt6LinguistTools_FOUND) +if(Qt6LinguistTools_FOUND) #Qt6 Translations happen after the executable is built up if(UPDATE_TRANSLATIONS) qt6_add_translations( @@ -659,6 +753,4 @@ if(Qt6_FOUND AND Qt6LinguistTools_FOUND) endif() endif() -if(Qt6_FOUND) - qt6_finalize_target(cockatrice) -endif() +qt6_finalize_target(cockatrice) diff --git a/cockatrice/cockatrice-cod.xml b/cockatrice/cockatrice-cod.xml new file mode 100644 index 000000000..1a0199433 --- /dev/null +++ b/cockatrice/cockatrice-cod.xml @@ -0,0 +1,7 @@ + + + + Cockatrice Deck File + + + diff --git a/cockatrice/cockatrice.desktop b/cockatrice/cockatrice.desktop index 092d84ef5..4b15fa9c1 100644 --- a/cockatrice/cockatrice.desktop +++ b/cockatrice/cockatrice.desktop @@ -3,6 +3,8 @@ Version=1.0 Type=Application Name=Cockatrice -Exec=cockatrice +Exec=cockatrice %U Icon=cockatrice Categories=Game;CardGame; +MimeType=application/x-cockatrice; +X-Scheme-Handler/cockatrice=true diff --git a/cockatrice/cockatrice.qrc b/cockatrice/cockatrice.qrc index 9c34929b7..14cf15b2f 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 @@ -62,6 +63,8 @@ resources/icons/mana/W.svg resources/backgrounds/home.png + resources/backgrounds/home-dark.png + resources/backgrounds/home-light.png 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/resources/backgrounds/home-dark.png b/cockatrice/resources/backgrounds/home-dark.png new file mode 100644 index 000000000..68f48e2c2 Binary files /dev/null and b/cockatrice/resources/backgrounds/home-dark.png differ diff --git a/cockatrice/resources/backgrounds/home-light.png b/cockatrice/resources/backgrounds/home-light.png new file mode 100644 index 000000000..eaaaba932 Binary files /dev/null and b/cockatrice/resources/backgrounds/home-light.png differ diff --git a/cockatrice/resources/backgrounds/home.png b/cockatrice/resources/backgrounds/home.png index 68f48e2c2..eaaaba932 100644 Binary files a/cockatrice/resources/backgrounds/home.png and b/cockatrice/resources/backgrounds/home.png 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..383d64644 --- /dev/null +++ b/cockatrice/src/client/lag_monitor.cpp @@ -0,0 +1,63 @@ +#include "lag_monitor.h" + +#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..9fdf6b283 --- /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 + +inline Q_LOGGING_CATEGORY(LagMonitorLog, "lag_monitor"); + +class QEvent; +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..46d6ccdd3 --- /dev/null +++ b/cockatrice/src/client/latency_graph_widget.cpp @@ -0,0 +1,51 @@ +/** + * @file latency_graph_widget.cpp + * @ingroup Client + */ + +#include "latency_graph_widget.h" + +#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..4f6f38f8b --- /dev/null +++ b/cockatrice/src/client/latency_graph_widget.h @@ -0,0 +1,43 @@ +/** + * @file latency_graph_widget.h + * @ingroup Client + */ + +#ifndef LATENCY_GRAPH_WIDGET_H +#define LATENCY_GRAPH_WIDGET_H + +#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..779c726eb --- /dev/null +++ b/cockatrice/src/client/latency_status_widget.cpp @@ -0,0 +1,111 @@ +/** + * @file latency_status_widget.cpp + * @ingroup Client + */ + +#include "latency_status_widget.h" + +#include "latency_graph_widget.h" + +#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..d9e1d130c --- /dev/null +++ b/cockatrice/src/client/latency_status_widget.h @@ -0,0 +1,50 @@ +/** + * @file latency_status_widget.h + * @ingroup Client + */ + +#ifndef LATENCY_STATUS_WIDGET_H +#define LATENCY_STATUS_WIDGET_H + +#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 75cedcafc..c1598bd25 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" @@ -14,6 +15,7 @@ #include #include #include +#include ConnectionController::ConnectionController(QWidget *dialogParent, QObject *parent) : QObject(parent), dialogParent(dialogParent) @@ -43,6 +45,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); @@ -177,7 +181,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); @@ -295,6 +299,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..bae99a3e0 100644 --- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.h +++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.h @@ -54,6 +54,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..42292b2aa 100644 --- a/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp +++ b/cockatrice/src/client/network/interfaces/deck_stats_interface.cpp @@ -72,7 +72,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/tapped_out_interface.cpp b/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp index 5dc77fa2c..627b7fe34 100644 --- a/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp +++ b/cockatrice/src/client/network/interfaces/tapped_out_interface.cpp @@ -99,7 +99,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/update/card_spoiler/spoiler_background_updater.cpp b/cockatrice/src/client/network/update/card_spoiler/spoiler_background_updater.cpp index 7194c46e5..dae633717 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 @@ -14,6 +14,8 @@ #include #include #include +#include +#include #include #define SPOILERS_STATUS_URL "https://raw.githubusercontent.com/Cockatrice/Magic-Spoiler/files/SpoilerSeasonEnabled" @@ -21,7 +23,7 @@ SpoilerBackgroundUpdater::SpoilerBackgroundUpdater(QObject *apParent) : QObject(apParent), cardUpdateProcess(nullptr) { - isSpoilerDownloadEnabled = SettingsCache::instance().getDownloadSpoilersStatus(); + isSpoilerDownloadEnabled = SettingsCache::instance().downloads().getDownloadSpoilersStatus(); if (isSpoilerDownloadEnabled) { // Start the process of checking if we're in spoiler season // File exists means we're in spoiler season @@ -75,7 +77,7 @@ void SpoilerBackgroundUpdater::actDownloadFinishedSpoilersFile() bool SpoilerBackgroundUpdater::deleteSpoilerFile() { - QString fileName = SettingsCache::instance().getSpoilerCardDatabasePath(); + QString fileName = SettingsCache::instance().paths().getSpoilerCardDatabasePath(); QFileInfo fi(fileName); QDir fileDir(fi.path()); QFile file(fileName); @@ -126,7 +128,7 @@ void SpoilerBackgroundUpdater::actCheckIfSpoilerSeasonEnabled() bool SpoilerBackgroundUpdater::saveDownloadedFile(QByteArray data) { - QString fileName = SettingsCache::instance().getSpoilerCardDatabasePath(); + QString fileName = SettingsCache::instance().paths().getSpoilerCardDatabasePath(); QFileInfo fi(fileName); QDir fileDir(fi.path()); diff --git a/cockatrice/src/client/settings/cache_settings.cpp b/cockatrice/src/client/settings/cache_settings.cpp index b6bc8a47d..eaa5d96ac 100644 --- a/cockatrice/src/client/settings/cache_settings.cpp +++ b/cockatrice/src/client/settings/cache_settings.cpp @@ -1,20 +1,41 @@ #include "cache_settings.h" -#include "../../interface/card_picture_loader/card_picture_loader_cache_method.h" -#include "../../interface/card_picture_loader/card_picture_loader_local_schemes.h" #include "../network/update/client/release_channel.h" #include "card_counter_settings.h" +#include "shortcuts_settings.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 Q_GLOBAL_STATIC(SettingsCache, settingsCache) @@ -52,104 +73,6 @@ QString SettingsCache::getNetworkCachePath() const return getCachePath() + "/downloaded/"; } -void SettingsCache::translateLegacySettings() -{ - if (isPortableBuild) { - return; - } - - // Layouts - QFile layoutFile(getSettingsPath() + "layouts/deckLayout.ini"); - if (layoutFile.exists()) { - if (layoutFile.copy(getSettingsPath() + "layouts.ini")) { - layoutFile.remove(); - } - } - - QStringList usedKeys; - QSettings legacySetting; - - // Sets - legacySetting.beginGroup("sets"); - QStringList setsGroups = legacySetting.childGroups(); - for (int i = 0; i < setsGroups.size(); i++) { - legacySetting.beginGroup(setsGroups.at(i)); - cardDatabase()->setEnabled(setsGroups.at(i), legacySetting.value("enabled").toBool()); - cardDatabase()->setIsKnown(setsGroups.at(i), legacySetting.value("isknown").toBool()); - cardDatabase()->setSortKey(setsGroups.at(i), legacySetting.value("sortkey").toUInt()); - legacySetting.endGroup(); - } - QStringList setsKeys = legacySetting.allKeys(); - for (int i = 0; i < setsKeys.size(); ++i) { - usedKeys.append("sets/" + setsKeys.at(i)); - } - legacySetting.endGroup(); - - // Servers - legacySetting.beginGroup("server"); - servers().setPreviousHostLogin(legacySetting.value("previoushostlogin").toInt()); - servers().setPreviousHostList(legacySetting.value("previoushosts").toStringList()); - servers().setAutoConnect(legacySetting.value("auto_connect").toInt()); - servers().setFPHostName(legacySetting.value("fphostname").toString()); - servers().setFPPort(legacySetting.value("fpport").toString()); - servers().setFPPlayerName(legacySetting.value("fpplayername").toString()); - usedKeys.append(legacySetting.allKeys()); - QStringList allKeysServer = legacySetting.allKeys(); - for (int i = 0; i < allKeysServer.size(); ++i) { - usedKeys.append("server/" + allKeysServer.at(i)); - } - legacySetting.endGroup(); - - // Messages - legacySetting.beginGroup("messages"); - QStringList allMessages = legacySetting.allKeys(); - for (int i = 0; i < allMessages.size(); ++i) { - if (allMessages.at(i) != "count") { - QString temp = allMessages.at(i); - int index = temp.remove("msg").toInt(); - messages().setMessageAt(index, legacySetting.value(allMessages.at(i)).toString()); - } - } - messages().setCount(legacySetting.value("count").toInt()); - QStringList allKeysmessages = legacySetting.allKeys(); - for (int i = 0; i < allKeysmessages.size(); ++i) { - usedKeys.append("messages/" + allKeysmessages.at(i)); - } - legacySetting.endGroup(); - - // Game filters - legacySetting.beginGroup("filter_games"); - gameFilters().setGameNameFilter(legacySetting.value("game_name_filter").toString()); - gameFilters().setHideIgnoredUserGames(legacySetting.value("hide_ignored_user_games").toBool()); - gameFilters().setMinPlayers(legacySetting.value("min_players").toInt()); - - if (legacySetting.value("max_players").toInt() > 1) { - gameFilters().setMaxPlayers(legacySetting.value("max_players").toInt()); - } else { - gameFilters().setMaxPlayers(99); // This prevents a bug where no games will show if max was not set before - } - - QStringList allFilters = legacySetting.allKeys(); - for (int i = 0; i < allFilters.size(); ++i) { - if (allFilters.at(i).startsWith("game_type")) { - gameFilters().setGameHashedTypeEnabled(allFilters.at(i), legacySetting.value(allFilters.at(i)).toBool()); - } - } - QStringList allKeysfilter_games = legacySetting.allKeys(); - for (int i = 0; i < allKeysfilter_games.size(); ++i) { - usedKeys.append("filter_games/" + allKeysfilter_games.at(i)); - } - legacySetting.endGroup(); - - QStringList allLegacyKeys = legacySetting.allKeys(); - for (int i = 0; i < allLegacyKeys.size(); ++i) { - if (usedKeys.contains(allLegacyKeys.at(i))) { - continue; - } - settings->setValue(allLegacyKeys.at(i), legacySetting.value(allLegacyKeys.at(i))); - } -} - QString SettingsCache::getSafeConfigPath(QString configEntry, QString defaultPath) const { QString tmp = settings->value(configEntry).toString(); @@ -183,11 +106,18 @@ SettingsCache::SettingsCache() qCInfo(SettingsCacheLog) << "Portable mode enabled"; } - // define a dummy context that will be used where needed - QString dummy = QT_TRANSLATE_NOOP("i18n", "English"); - QString settingsPath = getSettingsPath(); + settings = new QSettings(settingsPath + "global.ini", QSettings::IniFormat, this); + + // Migrate from legacy NativeFormat settings to per-class INI files (runs at most once) + if (!isPortableBuild) { + SettingsMigration::migrateLegacySettings(settingsPath); + } + + // Migrate settings from global.ini to per-class INI files (runs at most once) + SettingsMigration::migrateSettingsFromGlobalIni(settingsPath); + shortcutsSettings = new ShortcutsSettings(settingsPath, this); cardDatabaseSettings = new CardDatabaseSettings(settingsPath, this); serversSettings = new ServersSettings(settingsPath, this); @@ -198,997 +128,51 @@ SettingsCache::SettingsCache() recentsSettings = new RecentsSettings(settingsPath, this); cardOverrideSettings = new CardOverrideSettings(settingsPath, this); debugSettings = new DebugSettings(settingsPath, this); - cardCounterSettings = new CardCounterSettings(settingsPath, this); - if (!QFile(settingsPath + "global.ini").exists()) { - translateLegacySettings(); - } + tabsSettings = new TabsSettings(settingsPath, this); + soundSettings = new SoundSettings(settingsPath, this); + gameSettings = new GameSettings(settingsPath, this); + chatSettings = new ChatSettings(settingsPath, this); + cacheStorageSettings = new CacheStorageSettings(settingsPath, this); + updatesSettings = new UpdatesSettings(settingsPath, this); + personalSettings = new PersonalSettings(settingsPath, this); + cardsDisplaySettings = new CardsDisplaySettings(settingsPath, this); + interfaceSettings = new InterfaceSettings(settingsPath, this); + deckEditorSettings = new DeckEditorSettings(settingsPath, this); + pathsSettings = new PathsSettings(settingsPath, this); + visualDeckStorageSettings = new VisualDeckStorageSettings(settingsPath, this); + appearanceSettings = new AppearanceSettings(settingsPath, this); + networkSettings = new NetworkSettings(settingsPath, this); + commanderBracketSettings = new CommanderBracketSettings(settingsPath, this); + + // Forward ICardDatabasePathProvider signal from PathsSettings + connect(pathsSettings, &PathsSettings::cardDatabasePathChanged, this, + &ICardDatabasePathProvider::cardDatabasePathChanged); // updates - don't reorder them or their index in the settings won't match // append channels one by one, or msvc will add them in the wrong order. releaseChannels << new StableReleaseChannel(); releaseChannels << new BetaReleaseChannel(); - mbDownloadSpoilers = settings->value("personal/downloadspoilers", false).toBool(); + themeName = appearanceSettings->getThemeName(); - if (settings->contains("personal/startupUpdateCheck")) { - checkUpdatesOnStartup = settings->value("personal/startupUpdateCheck", true).toBool(); - } else if (QString(VERSION_STRING).contains("custom", Qt::CaseInsensitive)) { - checkUpdatesOnStartup = false; // do not run auto updater on custom version - } else { - checkUpdatesOnStartup = true; // default to run auto updater - } - startupCardUpdateCheckPromptForUpdate = - settings->value("personal/startupCardUpdateCheckPromptForUpdate", true).toBool(); - startupCardUpdateCheckAlwaysUpdate = settings->value("personal/startupCardUpdateCheckAlwaysUpdate", false).toBool(); - cardUpdateCheckInterval = settings->value("personal/cardUpdateCheckInterval", 7).toInt(); - lastCardUpdateCheck = settings->value("personal/lastCardUpdateCheck", QDateTime::currentDateTime().date()).toDate(); - alwaysEnableNewSets = settings->value("personal/alwaysEnableNewSets", false).toBool(); - notifyAboutUpdates = settings->value("personal/updatenotification", true).toBool(); - notifyAboutNewVersion = settings->value("personal/newversionnotification", true).toBool(); - - if (settings->contains("personal/updatereleasechannel")) { - updateReleaseChannel = settings->value("personal/updatereleasechannel").toInt(); - } else if (QString(VERSION_STRING).contains("beta", Qt::CaseInsensitive)) { - // default to beta if this is a beta release - updateReleaseChannel = 1; - } else { - updateReleaseChannel = 0; // stable - } - - lang = settings->value("personal/lang").toString(); - keepalive = settings->value("personal/keepalive", 3).toInt(); - timeout = settings->value("personal/timeout", 5).toInt(); - - // tip of the day settings - showTipsOnStartup = settings->value("tipOfDay/showTips", true).toBool(); - for (const auto &tipNumber : settings->value("tipOfDay/seenTips").toList()) { - seenTips.append(tipNumber.toInt()); + auto definitions = commanderBracketSettings->loadDefinitions(); + if (definitions.isEmpty()) { + definitions = CommanderBracketSettings::defaultDefinitions(); } + commanderBracketSettings->reloadDefinitions(definitions); loadPaths(); - - themeName = settings->value("theme/name").toString(); - - homeTabBackgroundSource = settings->value("home/background", "themed").toString(); - homeTabBackgroundShuffleFrequency = settings->value("home/background/shuffleTimer", 0).toInt(); - homeTabDisplayCardName = settings->value("home/background/displayCardName", true).toBool(); - - tabVisualDeckStorageOpen = settings->value("tabs/visualDeckStorage", true).toBool(); - tabServerOpen = settings->value("tabs/server", true).toBool(); - tabAccountOpen = settings->value("tabs/account", true).toBool(); - tabDeckStorageOpen = settings->value("tabs/deckStorage", true).toBool(); - tabReplaysOpen = settings->value("tabs/replays", true).toBool(); - tabAdminOpen = settings->value("tabs/admin", true).toBool(); - tabLogOpen = settings->value("tabs/log", true).toBool(); - - // we only want to reset the cache once, then its up to the user - bool updateCache = settings->value("revert/pixmapCacheSize", false).toBool(); - if (!updateCache) { - pixmapCacheSize = PIXMAPCACHE_SIZE_DEFAULT; - settings->setValue("personal/pixmapCacheSize", pixmapCacheSize); - settings->setValue("personal/picturedownloadhq", false); - settings->setValue("revert/pixmapCacheSize", true); - } else { - pixmapCacheSize = settings->value("personal/pixmapCacheSize", PIXMAPCACHE_SIZE_DEFAULT).toInt(); - } - // sanity check - if (pixmapCacheSize < PIXMAPCACHE_SIZE_MIN || pixmapCacheSize > PIXMAPCACHE_SIZE_MAX) { - pixmapCacheSize = PIXMAPCACHE_SIZE_DEFAULT; - } - - networkCacheSize = settings->value("personal/networkCacheSize", NETWORK_CACHE_SIZE_DEFAULT).toInt(); - redirectCacheTtl = settings->value("personal/redirectCacheTtl", NETWORK_REDIRECT_CACHE_TTL_DEFAULT).toInt(); - cardPictureLoaderCacheMethod = - settings - ->value("personal/cardPictureLoaderCacheMethod", - static_cast(CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE)) - .toInt(); - localCardImageStorageNamingScheme = - settings - ->value("personal/localCardImageStorageNamingScheme", - static_cast(CardPictureLoaderLocalSchemes::NamingScheme::Set_Folder_Name_Set_Collector)) - .toInt(); - - picDownload = settings->value("personal/picturedownload", true).toBool(); - showStatusBar = settings->value("personal/showStatusBar", false).toBool(); - - notificationsEnabled = settings->value("interface/notificationsenabled", true).toBool(); - spectatorNotificationsEnabled = settings->value("interface/specnotificationsenabled", false).toBool(); - buddyConnectNotificationsEnabled = settings->value("interface/buddyconnectnotificationsenabled", true).toBool(); - doubleClickToPlay = settings->value("interface/doubleclicktoplay", true).toBool(); - clickPlaysAllSelected = settings->value("interface/clickPlaysAllSelected", true).toBool(); - playToStack = settings->value("interface/playtostack", true).toBool(); - doNotDeleteArrowsInSubPhases = settings->value("interface/doNotDeleteArrowsInSubPhases", true).toBool(); - startingHandSize = settings->value("interface/startinghandsize", 7).toInt(); - annotateTokens = settings->value("interface/annotatetokens", false).toBool(); - knownMissingFeatures = settings->value("interface/knownmissingfeatures", "").toString(); - useTearOffMenus = settings->value("interface/usetearoffmenus", true).toBool(); - cardViewInitialRowsMax = settings->value("interface/cardViewInitialRowsMax", 14).toInt(); - cardViewExpandedRowsMax = settings->value("interface/cardViewExpandedRowsMax", 20).toInt(); - closeEmptyCardView = settings->value("interface/closeEmptyCardView", true).toBool(); - focusCardViewSearchBar = settings->value("interface/focusCardViewSearchBar", true).toBool(); - keepGameChatFocus = settings->value("interface/keepGameChatFocus", false).toBool(); - - showDragSelectionCount = settings->value("interface/showlassoselectioncount", true).toBool(); - showTotalSelectionCount = settings->value("interface/showpersistentselectioncount", true).toBool(); - showSubtypeSelectionTally = settings->value("interface/showsubtypeselectiontally", true).toBool(); - - showShortcuts = settings->value("menu/showshortcuts", true).toBool(); - showGameSelectorFilterToolbar = settings->value("menu/showgameselectorfiltertoolbar", true).toBool(); - displayCardNames = settings->value("cards/displaycardnames", true).toBool(); - roundCardCorners = settings->value("cards/roundcardcorners", true).toBool(); - overrideAllCardArtWithPersonalPreference = - settings->value("cards/overrideallcardartwithpersonalpreference", false).toBool(); - bumpSetsWithCardsInDeckToTop = settings->value("cards/bumpsetswithcardsindecktotop", true).toBool(); - printingSelectorSortOrder = settings->value("cards/printingselectorsortorder", 1).toInt(); - printingSelectorCardSize = settings->value("cards/printingselectorcardsize", 100).toInt(); - includeRebalancedCards = settings->value("cards/includerebalancedcards", true).toBool(); - printingSelectorNavigationButtonsVisible = - settings->value("cards/printingselectornavigationbuttonsvisible", true).toBool(); - deckEditorBannerCardComboBoxVisible = - settings->value("interface/deckeditorbannercardcomboboxvisible", true).toBool(); - deckEditorTagsWidgetVisible = settings->value("interface/deckeditortagswidgetvisible", true).toBool(); - visualDeckStorageCardSize = settings->value("interface/visualdeckstoragecardsize", 100).toInt(); - visualDeckStorageSortingOrder = settings->value("interface/visualdeckstoragesortingorder", 0).toInt(); - visualDeckStorageShowFolders = settings->value("interface/visualdeckstorageshowfolders", true).toBool(); - visualDeckStorageShowTagFilter = settings->value("interface/visualdeckstorageshowtagfilter", true).toBool(); - visualDeckStorageDefaultTagsList = - settings->value("interface/visualdeckstoragedefaulttagslist", defaultTags).toStringList(); - visualDeckStorageSearchFolderNames = settings->value("interface/visualdeckstoragesearchfoldernames", true).toBool(); - visualDeckStorageShowColorIdentity = settings->value("interface/visualdeckstorageshowcoloridentity", true).toBool(); - visualDeckStorageShowBannerCardComboBox = - settings->value("interface/visualdeckstorageshowbannercardcombobox", true).toBool(); - visualDeckStorageShowTagsOnDeckPreviews = - settings->value("interface/visualdeckstorageshowtagsondeckpreviews", true).toBool(); - visualDeckStorageDrawUnusedColorIdentities = - settings->value("interface/visualdeckstoragedrawunusedcoloridentities", true).toBool(); - visualDeckStorageUnusedColorIdentitiesOpacity = - settings->value("interface/visualdeckstorageunusedcoloridentitiesopacity", 15).toInt(); - visualDeckStorageTooltipType = settings->value("interface/visualdeckstoragetooltiptype", 0).toInt(); - visualDeckStoragePromptForConversion = - settings->value("interface/visualdeckstoragepromptforconversion", true).toBool(); - visualDeckStorageAlwaysConvert = settings->value("interface/visualdeckstoragealwaysconvert", false).toBool(); - visualDeckStorageInGame = settings->value("interface/visualdeckstorageingame", true).toBool(); - visualDeckStorageSelectionAnimation = - settings->value("interface/visualdeckstorageselectionanimation", true).toBool(); - defaultDeckEditorType = settings->value("interface/defaultDeckEditorType", 1).toInt(); - visualDatabaseDisplayFilterToMostRecentSetsEnabled = - settings->value("interface/visualdatabasedisplayfiltertomostrecentsetsenabled", false).toBool(); - visualDatabaseDisplayFilterToMostRecentSetsAmount = - settings->value("interface/visualdatabasedisplayfiltertomostrecentsetsamount", 10).toInt(); - visualDeckEditorSampleHandSize = settings->value("interface/visualdeckeditorsamplehandsize", 7).toInt(); - visualDeckEditorCardSize = settings->value("interface/visualdeckeditorcardsize", 100).toInt(); - visualDatabaseDisplayCardSize = settings->value("interface/visualdatabasedisplaycardsize", 100).toInt(); - edhrecCardSize = settings->value("interface/edhreccardsize", 100).toInt(); - archidektPreviewSize = settings->value("interface/archidektpreviewsize", 100).toInt(); - - horizontalHand = settings->value("hand/horizontal", true).toBool(); - invertVerticalCoordinate = settings->value("table/invert_vertical", false).toBool(); - minPlayersForMultiColumnLayout = settings->value("interface/min_players_multicolumn", 4).toInt(); - tapAnimation = settings->value("cards/tapanimation", true).toBool(); - autoRotateSidewaysLayoutCards = settings->value("cards/autorotatesidewayslayoutcards", true).toBool(); - - openDeckInNewTab = settings->value("editor/openDeckInNewTab", false).toBool(); - rewindBufferingMs = settings->value("replay/rewindBufferingMs", 200).toInt(); - styleUserList = settings->value("appearance/styleUserList", true).toBool(); - chatMention = settings->value("chat/mention", true).toBool(); - chatMentionCompleter = settings->value("chat/mentioncompleter", true).toBool(); - chatMentionForeground = settings->value("chat/mentionforeground", true).toBool(); - chatHighlightForeground = settings->value("chat/highlightforeground", true).toBool(); - chatMentionColor = settings->value("chat/mentioncolor", "A6120D").toString(); - chatHighlightColor = settings->value("chat/highlightcolor", "A6120D").toString(); - - zoneViewGroupByIndex = settings->value("zoneview/groupby", 1).toInt(); - zoneViewSortByIndex = settings->value("zoneview/sortby", 1).toInt(); - zoneViewPileView = settings->value("zoneview/pileview", true).toBool(); - - soundEnabled = settings->value("sound/enabled", false).toBool(); - soundThemeName = settings->value("sound/theme").toString(); - - maxFontSize = settings->value("game/maxfontsize", DEFAULT_FONT_SIZE).toInt(); - - ignoreUnregisteredUsers = settings->value("chat/ignore_unregistered", false).toBool(); - ignoreUnregisteredUserMessages = settings->value("chat/ignore_unregistered_messages", false).toBool(); - ignoreNonBuddyUserMessages = settings->value("chat/ignore_nonbuddy_messages", false).toBool(); - - scaleCards = settings->value("cards/scaleCards", true).toBool(); - verticalCardOverlapPercent = settings->value("cards/verticalCardOverlapPercent", 33).toInt(); - showMessagePopups = settings->value("chat/showmessagepopups", true).toBool(); - showMentionPopups = settings->value("chat/showmentionpopups", true).toBool(); - roomHistory = settings->value("chat/roomhistory", true).toBool(); - - leftJustified = settings->value("interface/leftjustified", false).toBool(); - - masterVolume = settings->value("sound/mastervolume", 100).toInt(); - - cardInfoViewMode = settings->value("cards/cardinfoviewmode", 0).toInt(); - highlightWords = settings->value("personal/highlightWords", QString()).toString(); - gameDescription = settings->value("game/gamedescription", "").toString(); - maxPlayers = settings->value("game/maxplayers", 2).toInt(); - gameTypes = settings->value("game/gametypes", "").toString(); - onlyBuddies = settings->value("game/onlybuddies", false).toBool(); - onlyRegistered = settings->value("game/onlyregistered", true).toBool(); - spectatorsAllowed = settings->value("game/spectatorsallowed", true).toBool(); - spectatorsNeedPassword = settings->value("game/spectatorsneedpassword", false).toBool(); - spectatorsCanTalk = settings->value("game/spectatorscantalk", false).toBool(); - spectatorsCanSeeEverything = settings->value("game/spectatorscanseeeverything", false).toBool(); - createGameAsSpectator = settings->value("game/creategameasspectator", false).toBool(); - defaultStartingLifeTotal = settings->value("game/defaultstartinglifetotal", 20).toInt(); - shareDecklistsOnLoad = settings->value("game/sharedecklistsonload", false).toBool(); - rememberGameSettings = settings->value("game/remembergamesettings", true).toBool(); - - // Local game settings use "localgameoptions/" prefix to keep them separate - // from server game settings which use "game/" prefix - localGameRememberSettings = settings->value("localgameoptions/remembersettings", false).toBool(); - localGameMaxPlayers = settings->value("localgameoptions/maxplayers", 1).toInt(); - localGameStartingLifeTotal = settings->value("localgameoptions/startinglifetotal", 20).toInt(); - - clientID = settings->value("personal/clientid", CLIENT_INFO_NOT_SET).toString(); - clientVersion = settings->value("personal/clientversion", CLIENT_INFO_NOT_SET).toString(); -} - -void SettingsCache::setUseTearOffMenus(bool _useTearOffMenus) -{ - useTearOffMenus = _useTearOffMenus; - settings->setValue("interface/usetearoffmenus", useTearOffMenus); - emit useTearOffMenusChanged(useTearOffMenus); -} - -void SettingsCache::setCardViewInitialRowsMax(int _cardViewInitialRowsMax) -{ - cardViewInitialRowsMax = _cardViewInitialRowsMax; - settings->setValue("interface/cardViewInitialRowsMax", cardViewInitialRowsMax); -} - -void SettingsCache::setCardViewExpandedRowsMax(int value) -{ - cardViewExpandedRowsMax = value; - settings->setValue("interface/cardViewExpandedRowsMax", cardViewExpandedRowsMax); -} - -void SettingsCache::setCloseEmptyCardView(QT_STATE_CHANGED_T value) -{ - closeEmptyCardView = value; - settings->setValue("interface/closeEmptyCardView", closeEmptyCardView); -} - -void SettingsCache::setFocusCardViewSearchBar(QT_STATE_CHANGED_T value) -{ - focusCardViewSearchBar = value; - settings->setValue("interface/focusCardViewSearchBar", focusCardViewSearchBar); -} - -void SettingsCache::setKeepGameChatFocus(QT_STATE_CHANGED_T value) -{ - keepGameChatFocus = value; - settings->setValue("interface/keepGameChatFocus", keepGameChatFocus); - emit keepGameChatFocusChanged(keepGameChatFocus); -} - -void SettingsCache::setKnownMissingFeatures(const QString &_knownMissingFeatures) -{ - knownMissingFeatures = _knownMissingFeatures; - settings->setValue("interface/knownmissingfeatures", knownMissingFeatures); -} - -void SettingsCache::setCardInfoViewMode(const int _viewMode) -{ - cardInfoViewMode = _viewMode; - settings->setValue("cards/cardinfoviewmode", cardInfoViewMode); -} - -void SettingsCache::setHighlightWords(const QString &_highlightWords) -{ - highlightWords = _highlightWords; - settings->setValue("personal/highlightWords", highlightWords); -} - -void SettingsCache::setMasterVolume(int _masterVolume) -{ - masterVolume = _masterVolume; - settings->setValue("sound/mastervolume", masterVolume); - emit masterVolumeChanged(masterVolume); -} - -void SettingsCache::setLeftJustified(const QT_STATE_CHANGED_T _leftJustified) -{ - leftJustified = (bool)_leftJustified; - settings->setValue("interface/leftjustified", leftJustified); - emit handJustificationChanged(); -} - -void SettingsCache::setCardScaling(const QT_STATE_CHANGED_T _scaleCards) -{ - scaleCards = (bool)_scaleCards; - settings->setValue("cards/scaleCards", scaleCards); -} - -void SettingsCache::setStackCardOverlapPercent(const int _verticalCardOverlapPercent) -{ - verticalCardOverlapPercent = _verticalCardOverlapPercent; - settings->setValue("cards/verticalCardOverlapPercent", verticalCardOverlapPercent); -} - -void SettingsCache::setShowMessagePopups(const QT_STATE_CHANGED_T _showMessagePopups) -{ - showMessagePopups = (bool)_showMessagePopups; - settings->setValue("chat/showmessagepopups", showMessagePopups); -} - -void SettingsCache::setShowMentionPopups(const QT_STATE_CHANGED_T _showMentionPopus) -{ - showMentionPopups = (bool)_showMentionPopus; - settings->setValue("chat/showmentionpopups", showMentionPopups); -} - -void SettingsCache::setRoomHistory(const QT_STATE_CHANGED_T _roomHistory) -{ - roomHistory = (bool)_roomHistory; - settings->setValue("chat/roomhistory", roomHistory); -} - -void SettingsCache::setLang(const QString &_lang) -{ - lang = _lang; - settings->setValue("personal/lang", lang); - emit langChanged(); -} - -void SettingsCache::setShowTipsOnStartup(bool _showTipsOnStartup) -{ - showTipsOnStartup = _showTipsOnStartup; - settings->setValue("tipOfDay/showTips", showTipsOnStartup); -} - -void SettingsCache::setSeenTips(const QList &_seenTips) -{ - seenTips = _seenTips; - QList storedTipList; - for (auto tipNumber : seenTips) { - storedTipList.append(tipNumber); - } - settings->setValue("tipOfDay/seenTips", storedTipList); -} - -void SettingsCache::setDeckPath(const QString &_deckPath) -{ - deckPath = _deckPath; - settings->setValue("paths/decks", deckPath); -} - -void SettingsCache::setFiltersPath(const QString &_filtersPath) -{ - filtersPath = _filtersPath; - settings->setValue("paths/filters", filtersPath); -} - -void SettingsCache::setReplaysPath(const QString &_replaysPath) -{ - replaysPath = _replaysPath; - settings->setValue("paths/replays", replaysPath); -} - -void SettingsCache::setThemesPath(const QString &_themesPath) -{ - themesPath = _themesPath; - settings->setValue("paths/themes", themesPath); - emit themeChanged(); -} - -void SettingsCache::setCustomCardDatabasePath(const QString &_customCardDatabasePath) -{ - customCardDatabasePath = _customCardDatabasePath; - settings->setValue("paths/customsets", customCardDatabasePath); - emit cardDatabasePathChanged(); -} - -void SettingsCache::setPicsPath(const QString &_picsPath) -{ - picsPath = _picsPath; - settings->setValue("paths/pics", picsPath); - // get a new value for customPicsPath, currently derived from picsPath - if (picsPath.endsWith("/")) { - customPicsPath = getSafeConfigPath("paths/custompics", picsPath + "CUSTOM/"); - } else { - customPicsPath = getSafeConfigPath("paths/custompics", picsPath + "/CUSTOM/"); - } - emit picsPathChanged(); -} - -void SettingsCache::setCardDatabasePath(const QString &_cardDatabasePath) -{ - cardDatabasePath = _cardDatabasePath; - settings->setValue("paths/carddatabase", cardDatabasePath); - emit cardDatabasePathChanged(); -} - -void SettingsCache::setSpoilerDatabasePath(const QString &_spoilerDatabasePath) -{ - spoilerDatabasePath = _spoilerDatabasePath; - settings->setValue("paths/spoilerdatabase", spoilerDatabasePath); - emit cardDatabasePathChanged(); -} - -void SettingsCache::setTokenDatabasePath(const QString &_tokenDatabasePath) -{ - tokenDatabasePath = _tokenDatabasePath; - settings->setValue("paths/tokendatabase", tokenDatabasePath); - emit cardDatabasePathChanged(); } void SettingsCache::setThemeName(const QString &_themeName) { themeName = _themeName; - settings->setValue("theme/name", themeName); + appearanceSettings->setThemeName(themeName); emit themeChanged(); } -void SettingsCache::setHomeTabBackgroundSource(const QString &_backgroundSource) -{ - homeTabBackgroundSource = _backgroundSource; - settings->setValue("home/background", homeTabBackgroundSource); - emit homeTabBackgroundSourceChanged(); -} - -void SettingsCache::setHomeTabBackgroundShuffleFrequency(int _frequency) -{ - homeTabBackgroundShuffleFrequency = _frequency; - settings->setValue("home/background/shuffleTimer", homeTabBackgroundShuffleFrequency); - emit homeTabBackgroundShuffleFrequencyChanged(); -} - -void SettingsCache::setHomeTabDisplayCardName(QT_STATE_CHANGED_T _displayCardName) -{ - homeTabDisplayCardName = static_cast(_displayCardName); - settings->setValue("home/background/displayCardName", homeTabDisplayCardName); - emit homeTabDisplayCardNameChanged(); -} - -void SettingsCache::setTabVisualDeckStorageOpen(bool value) -{ - tabVisualDeckStorageOpen = value; - settings->setValue("tabs/visualDeckStorage", tabVisualDeckStorageOpen); -} - -void SettingsCache::setTabServerOpen(bool value) -{ - tabServerOpen = value; - settings->setValue("tabs/server", tabServerOpen); -} - -void SettingsCache::setTabAccountOpen(bool value) -{ - tabAccountOpen = value; - settings->setValue("tabs/account", tabAccountOpen); -} - -void SettingsCache::setTabDeckStorageOpen(bool value) -{ - tabDeckStorageOpen = value; - settings->setValue("tabs/deckStorage", tabDeckStorageOpen); -} - -void SettingsCache::setTabReplaysOpen(bool value) -{ - tabReplaysOpen = value; - settings->setValue("tabs/replays", tabReplaysOpen); -} - -void SettingsCache::setTabAdminOpen(bool value) -{ - tabAdminOpen = value; - settings->setValue("tabs/admin", tabAdminOpen); -} - -void SettingsCache::setTabLogOpen(bool value) -{ - tabLogOpen = value; - settings->setValue("tabs/log", tabLogOpen); -} - -void SettingsCache::setPicDownload(QT_STATE_CHANGED_T _picDownload) -{ - picDownload = static_cast(_picDownload); - settings->setValue("personal/picturedownload", picDownload); - emit picDownloadChanged(); -} - -void SettingsCache::setShowStatusBar(bool value) -{ - showStatusBar = value; - settings->setValue("personal/showStatusBar", showStatusBar); - emit showStatusBarChanged(value); -} - -void SettingsCache::setNotificationsEnabled(QT_STATE_CHANGED_T _notificationsEnabled) -{ - notificationsEnabled = static_cast(_notificationsEnabled); - settings->setValue("interface/notificationsenabled", notificationsEnabled); -} - -void SettingsCache::setSpectatorNotificationsEnabled(QT_STATE_CHANGED_T _spectatorNotificationsEnabled) -{ - spectatorNotificationsEnabled = static_cast(_spectatorNotificationsEnabled); - settings->setValue("interface/specnotificationsenabled", spectatorNotificationsEnabled); -} - -void SettingsCache::setBuddyConnectNotificationsEnabled(QT_STATE_CHANGED_T _buddyConnectNotificationsEnabled) -{ - buddyConnectNotificationsEnabled = static_cast(_buddyConnectNotificationsEnabled); - settings->setValue("interface/buddyconnectnotificationsenabled", buddyConnectNotificationsEnabled); -} - -void SettingsCache::setDoubleClickToPlay(QT_STATE_CHANGED_T _doubleClickToPlay) -{ - doubleClickToPlay = static_cast(_doubleClickToPlay); - settings->setValue("interface/doubleclicktoplay", doubleClickToPlay); -} - -void SettingsCache::setClickPlaysAllSelected(QT_STATE_CHANGED_T _clickPlaysAllSelected) -{ - clickPlaysAllSelected = static_cast(_clickPlaysAllSelected); - settings->setValue("interface/clickPlaysAllSelected", clickPlaysAllSelected); -} - -void SettingsCache::setPlayToStack(QT_STATE_CHANGED_T _playToStack) -{ - playToStack = static_cast(_playToStack); - settings->setValue("interface/playtostack", playToStack); -} - -void SettingsCache::setDoNotDeleteArrowsInSubPhases(QT_STATE_CHANGED_T _doNotDeleteArrowsInSubPhases) -{ - doNotDeleteArrowsInSubPhases = static_cast(_doNotDeleteArrowsInSubPhases); - settings->setValue("interface/doNotDeleteArrowsInSubPhases", doNotDeleteArrowsInSubPhases); -} - -void SettingsCache::setStartingHandSize(int _startingHandSize) -{ - startingHandSize = _startingHandSize; - settings->setValue("interface/startinghandsize", startingHandSize); -} - -void SettingsCache::setAnnotateTokens(QT_STATE_CHANGED_T _annotateTokens) -{ - annotateTokens = static_cast(_annotateTokens); - settings->setValue("interface/annotatetokens", annotateTokens); -} - -void SettingsCache::setShowShortcuts(QT_STATE_CHANGED_T _showShortcuts) -{ - showShortcuts = static_cast(_showShortcuts); - settings->setValue("menu/showshortcuts", showShortcuts); -} - -void SettingsCache::setShowGameSelectorFilterToolbar(QT_STATE_CHANGED_T _showGameSelectorFilterToolbar) -{ - showGameSelectorFilterToolbar = static_cast(_showGameSelectorFilterToolbar); - settings->setValue("menu/showgameselectorfiltertoolbar", showGameSelectorFilterToolbar); - emit showGameSelectorFilterToolbarChanged(showGameSelectorFilterToolbar); -} - -void SettingsCache::setDisplayCardNames(QT_STATE_CHANGED_T _displayCardNames) -{ - displayCardNames = static_cast(_displayCardNames); - settings->setValue("cards/displaycardnames", displayCardNames); - emit displayCardNamesChanged(); -} - -void SettingsCache::setOverrideAllCardArtWithPersonalPreference(QT_STATE_CHANGED_T _overrideAllCardArt) -{ - overrideAllCardArtWithPersonalPreference = static_cast(_overrideAllCardArt); - settings->setValue("cards/overrideallcardartwithpersonalpreference", overrideAllCardArtWithPersonalPreference); - emit overrideAllCardArtWithPersonalPreferenceChanged(overrideAllCardArtWithPersonalPreference); -} - -void SettingsCache::setBumpSetsWithCardsInDeckToTop(QT_STATE_CHANGED_T _bumpSetsWithCardsInDeckToTop) -{ - bumpSetsWithCardsInDeckToTop = static_cast(_bumpSetsWithCardsInDeckToTop); - settings->setValue("cards/bumpsetswithcardsindecktotop", bumpSetsWithCardsInDeckToTop); - emit bumpSetsWithCardsInDeckToTopChanged(); -} - -void SettingsCache::setPrintingSelectorSortOrder(int _printingSelectorSortOrder) -{ - printingSelectorSortOrder = _printingSelectorSortOrder; - settings->setValue("cards/printingselectorsortorder", printingSelectorSortOrder); - emit printingSelectorSortOrderChanged(); -} - -void SettingsCache::setPrintingSelectorCardSize(int _printingSelectorCardSize) -{ - printingSelectorCardSize = _printingSelectorCardSize; - settings->setValue("cards/printingselectorcardsize", printingSelectorCardSize); - emit printingSelectorCardSizeChanged(); -} - -void SettingsCache::setIncludeRebalancedCards(bool _includeRebalancedCards) -{ - if (includeRebalancedCards == _includeRebalancedCards) { - return; - } - - includeRebalancedCards = _includeRebalancedCards; - settings->setValue("cards/includerebalancedcards", includeRebalancedCards); - emit includeRebalancedCardsChanged(includeRebalancedCards); -} - -void SettingsCache::setPrintingSelectorNavigationButtonsVisible(QT_STATE_CHANGED_T _navigationButtonsVisible) -{ - printingSelectorNavigationButtonsVisible = _navigationButtonsVisible; - settings->setValue("cards/printingselectornavigationbuttonsvisible", printingSelectorNavigationButtonsVisible); - emit printingSelectorNavigationButtonsVisibleChanged(); -} - -void SettingsCache::setDeckEditorBannerCardComboBoxVisible(QT_STATE_CHANGED_T _deckEditorBannerCardComboBoxVisible) -{ - deckEditorBannerCardComboBoxVisible = _deckEditorBannerCardComboBoxVisible; - settings->setValue("interface/deckeditorbannercardcomboboxvisible", deckEditorBannerCardComboBoxVisible); - emit deckEditorBannerCardComboBoxVisibleChanged(deckEditorBannerCardComboBoxVisible); -} - -void SettingsCache::setDeckEditorTagsWidgetVisible(QT_STATE_CHANGED_T _deckEditorTagsWidgetVisible) -{ - deckEditorTagsWidgetVisible = _deckEditorTagsWidgetVisible; - settings->setValue("interface/deckeditortagswidgetvisible", deckEditorTagsWidgetVisible); - emit deckEditorTagsWidgetVisibleChanged(deckEditorTagsWidgetVisible); -} - -void SettingsCache::setVisualDeckStorageSortingOrder(int _visualDeckStorageSortingOrder) -{ - visualDeckStorageSortingOrder = _visualDeckStorageSortingOrder; - settings->setValue("interface/visualdeckstoragesortingorder", visualDeckStorageSortingOrder); -} - -void SettingsCache::setVisualDeckStorageShowFolders(QT_STATE_CHANGED_T value) -{ - visualDeckStorageShowFolders = value; - settings->setValue("interface/visualdeckstorageshowfolders", visualDeckStorageShowFolders); -} - -void SettingsCache::setVisualDeckStorageShowTagFilter(QT_STATE_CHANGED_T _showTags) -{ - visualDeckStorageShowTagFilter = _showTags; - settings->setValue("interface/visualdeckstorageshowtagfilter", visualDeckStorageShowTagFilter); - emit visualDeckStorageShowTagFilterChanged(visualDeckStorageShowTagFilter); -} - -void SettingsCache::setVisualDeckStorageDefaultTagsList(QStringList _defaultTagsList) -{ - visualDeckStorageDefaultTagsList = _defaultTagsList; - settings->setValue("interface/visualdeckstoragedefaulttagslist", visualDeckStorageDefaultTagsList); - emit visualDeckStorageDefaultTagsListChanged(); -} - -void SettingsCache::setVisualDeckStorageSearchFolderNames(QT_STATE_CHANGED_T value) -{ - visualDeckStorageSearchFolderNames = value; - settings->setValue("interface/visualdeckstoragesearchfoldernames", visualDeckStorageSearchFolderNames); -} - -void SettingsCache::setVisualDeckStorageShowColorIdentity(QT_STATE_CHANGED_T value) -{ - visualDeckStorageShowColorIdentity = value; - settings->setValue("interface/visualdeckstorageshowcoloridentity", visualDeckStorageShowColorIdentity); - emit visualDeckStorageShowColorIdentityChanged(visualDeckStorageShowColorIdentity); -} - -void SettingsCache::setVisualDeckStorageShowBannerCardComboBox(QT_STATE_CHANGED_T _showBannerCardComboBox) -{ - visualDeckStorageShowBannerCardComboBox = _showBannerCardComboBox; - settings->setValue("interface/visualdeckstorageshowbannercardcombobox", visualDeckStorageShowBannerCardComboBox); - emit visualDeckStorageShowBannerCardComboBoxChanged(visualDeckStorageShowBannerCardComboBox); -} - -void SettingsCache::setVisualDeckStorageShowTagsOnDeckPreviews(QT_STATE_CHANGED_T _showTags) -{ - visualDeckStorageShowTagsOnDeckPreviews = _showTags; - settings->setValue("interface/visualdeckstorageshowtagsondeckpreviews", visualDeckStorageShowTagsOnDeckPreviews); - emit visualDeckStorageShowTagsOnDeckPreviewsChanged(visualDeckStorageShowTagsOnDeckPreviews); -} - -void SettingsCache::setVisualDeckStorageCardSize(int _visualDeckStorageCardSize) -{ - visualDeckStorageCardSize = _visualDeckStorageCardSize; - settings->setValue("interface/visualdeckstoragecardsize", visualDeckStorageCardSize); - emit visualDeckStorageCardSizeChanged(); -} - -void SettingsCache::setVisualDeckStorageDrawUnusedColorIdentities( - QT_STATE_CHANGED_T _visualDeckStorageDrawUnusedColorIdentities) -{ - visualDeckStorageDrawUnusedColorIdentities = _visualDeckStorageDrawUnusedColorIdentities; - settings->setValue("interface/visualdeckstoragedrawunusedcoloridentities", - visualDeckStorageDrawUnusedColorIdentities); - emit visualDeckStorageDrawUnusedColorIdentitiesChanged(visualDeckStorageDrawUnusedColorIdentities); -} - -void SettingsCache::setVisualDeckStorageUnusedColorIdentitiesOpacity(int _visualDeckStorageUnusedColorIdentitiesOpacity) -{ - visualDeckStorageUnusedColorIdentitiesOpacity = _visualDeckStorageUnusedColorIdentitiesOpacity; - settings->setValue("interface/visualdeckstorageunusedcoloridentitiesopacity", - visualDeckStorageUnusedColorIdentitiesOpacity); - emit visualDeckStorageUnusedColorIdentitiesOpacityChanged(visualDeckStorageUnusedColorIdentitiesOpacity); -} - -void SettingsCache::setVisualDeckStorageTooltipType(int value) -{ - visualDeckStorageTooltipType = value; - settings->setValue("interface/visualdeckstoragetooltiptype", visualDeckStorageTooltipType); -} - -void SettingsCache::setVisualDeckStoragePromptForConversion(bool _visualDeckStoragePromptForConversion) -{ - visualDeckStoragePromptForConversion = _visualDeckStoragePromptForConversion; - settings->setValue("interface/visualdeckstoragepromptforconversion", visualDeckStoragePromptForConversion); -} - -void SettingsCache::setVisualDeckStorageAlwaysConvert(bool _visualDeckStorageAlwaysConvert) -{ - visualDeckStorageAlwaysConvert = _visualDeckStorageAlwaysConvert; - settings->setValue("interface/visualdeckstoragealwaysconvert", visualDeckStorageAlwaysConvert); -} - -void SettingsCache::setVisualDeckStorageInGame(QT_STATE_CHANGED_T value) -{ - visualDeckStorageInGame = value; - settings->setValue("interface/visualdeckstorageingame", visualDeckStorageInGame); - emit visualDeckStorageInGameChanged(visualDeckStorageInGame); -} - -void SettingsCache::setVisualDeckStorageSelectionAnimation(QT_STATE_CHANGED_T value) -{ - visualDeckStorageSelectionAnimation = value; - settings->setValue("interface/visualdeckstorageselectionanimation", visualDeckStorageSelectionAnimation); - emit visualDeckStorageSelectionAnimationChanged(visualDeckStorageSelectionAnimation); -} - -void SettingsCache::setVisualDeckEditorCardSize(int _visualDeckEditorCardSize) -{ - visualDeckEditorCardSize = _visualDeckEditorCardSize; - settings->setValue("interface/visualdeckeditorcardsize", visualDeckEditorCardSize); - emit visualDeckEditorCardSizeChanged(); -} - -void SettingsCache::setVisualDatabaseDisplayCardSize(int _visualDatabaseDisplayCardSize) -{ - visualDatabaseDisplayCardSize = _visualDatabaseDisplayCardSize; - settings->setValue("interface/visualdatabasedisplaycardsize", visualDatabaseDisplayCardSize); - emit visualDatabaseDisplayCardSizeChanged(); -} - -void SettingsCache::setEDHRecCardSize(int _edhrecCardSize) -{ - edhrecCardSize = _edhrecCardSize; - settings->setValue("interface/edhreccardsize", edhrecCardSize); - emit edhRecCardSizeChanged(); -} - -void SettingsCache::setArchidektPreviewCardSize(int _archidektPreviewCardSize) -{ - archidektPreviewSize = _archidektPreviewCardSize; - settings->setValue("interface/archidektpreviewsize", archidektPreviewSize); - emit archidektPreviewSizeChanged(); -} - -void SettingsCache::setDefaultDeckEditorType(int value) -{ - defaultDeckEditorType = value; - settings->setValue("interface/defaultDeckEditorType", defaultDeckEditorType); -} - -void SettingsCache::setVisualDatabaseDisplayFilterToMostRecentSetsEnabled(QT_STATE_CHANGED_T _enabled) -{ - visualDatabaseDisplayFilterToMostRecentSetsEnabled = _enabled; - settings->setValue("interface/visualdatabasedisplayfiltertomostrecentsetsenabled", - visualDatabaseDisplayFilterToMostRecentSetsEnabled); - emit visualDatabaseDisplayFilterToMostRecentSetsEnabledChanged(visualDatabaseDisplayFilterToMostRecentSetsEnabled); -} - -void SettingsCache::setVisualDatabaseDisplayFilterToMostRecentSetsAmount(int _amount) -{ - visualDatabaseDisplayFilterToMostRecentSetsAmount = _amount; - settings->setValue("interface/visualdatabasedisplayfiltertomostrecentsetsamount", - visualDatabaseDisplayFilterToMostRecentSetsAmount); - emit visualDatabaseDisplayFilterToMostRecentSetsAmountChanged(visualDatabaseDisplayFilterToMostRecentSetsAmount); -} - -void SettingsCache::setVisualDeckEditorSampleHandSize(int _amount) -{ - visualDeckEditorSampleHandSize = _amount; - settings->setValue("interface/visualdeckeditorsamplehandsize", visualDeckEditorSampleHandSize); - emit visualDeckEditorSampleHandSizeAmountChanged(visualDeckEditorSampleHandSize); -} - -void SettingsCache::setHorizontalHand(QT_STATE_CHANGED_T _horizontalHand) -{ - horizontalHand = static_cast(_horizontalHand); - settings->setValue("hand/horizontal", horizontalHand); - emit horizontalHandChanged(); -} - -void SettingsCache::setInvertVerticalCoordinate(QT_STATE_CHANGED_T _invertVerticalCoordinate) -{ - invertVerticalCoordinate = static_cast(_invertVerticalCoordinate); - settings->setValue("table/invert_vertical", invertVerticalCoordinate); - emit invertVerticalCoordinateChanged(); -} - -void SettingsCache::setMinPlayersForMultiColumnLayout(int _minPlayersForMultiColumnLayout) -{ - minPlayersForMultiColumnLayout = _minPlayersForMultiColumnLayout; - settings->setValue("interface/min_players_multicolumn", minPlayersForMultiColumnLayout); - emit minPlayersForMultiColumnLayoutChanged(); -} - -void SettingsCache::setTapAnimation(QT_STATE_CHANGED_T _tapAnimation) -{ - tapAnimation = static_cast(_tapAnimation); - settings->setValue("cards/tapanimation", tapAnimation); -} - -void SettingsCache::setAutoRotateSidewaysLayoutCards(QT_STATE_CHANGED_T _autoRotateSidewaysLayoutCards) -{ - autoRotateSidewaysLayoutCards = static_cast(_autoRotateSidewaysLayoutCards); - settings->setValue("cards/autorotatesidewayslayoutcards", autoRotateSidewaysLayoutCards); -} - -void SettingsCache::setOpenDeckInNewTab(QT_STATE_CHANGED_T _openDeckInNewTab) -{ - openDeckInNewTab = static_cast(_openDeckInNewTab); - settings->setValue("editor/openDeckInNewTab", openDeckInNewTab); -} - -void SettingsCache::setRewindBufferingMs(int _rewindBufferingMs) -{ - rewindBufferingMs = _rewindBufferingMs; - settings->setValue("replay/rewindBufferingMs", rewindBufferingMs); -} - -void SettingsCache::setStyleUserList(QT_STATE_CHANGED_T _styleUserList) -{ - styleUserList = static_cast(_styleUserList); - settings->setValue("appearance/styleUserList", styleUserList); - emit styleUserListChanged(); -} - -void SettingsCache::setChatMention(QT_STATE_CHANGED_T _chatMention) -{ - chatMention = static_cast(_chatMention); - settings->setValue("chat/mention", chatMention); -} - -void SettingsCache::setChatMentionCompleter(const QT_STATE_CHANGED_T _enableMentionCompleter) -{ - chatMentionCompleter = (bool)_enableMentionCompleter; - settings->setValue("chat/mentioncompleter", chatMentionCompleter); - emit chatMentionCompleterChanged(); -} - -void SettingsCache::setChatMentionForeground(QT_STATE_CHANGED_T _chatMentionForeground) -{ - chatMentionForeground = static_cast(_chatMentionForeground); - settings->setValue("chat/mentionforeground", chatMentionForeground); -} - -void SettingsCache::setChatHighlightForeground(QT_STATE_CHANGED_T _chatHighlightForeground) -{ - chatHighlightForeground = static_cast(_chatHighlightForeground); - settings->setValue("chat/highlightforeground", chatHighlightForeground); -} - -void SettingsCache::setChatMentionColor(const QString &_chatMentionColor) -{ - chatMentionColor = _chatMentionColor; - settings->setValue("chat/mentioncolor", chatMentionColor); -} - -void SettingsCache::setChatHighlightColor(const QString &_chatHighlightColor) -{ - chatHighlightColor = _chatHighlightColor; - settings->setValue("chat/highlightcolor", chatHighlightColor); -} - -void SettingsCache::setZoneViewGroupByIndex(int _zoneViewGroupByIndex) -{ - zoneViewGroupByIndex = _zoneViewGroupByIndex; - settings->setValue("zoneview/groupby", zoneViewGroupByIndex); -} - -void SettingsCache::setZoneViewSortByIndex(int _zoneViewSortByIndex) -{ - zoneViewSortByIndex = _zoneViewSortByIndex; - settings->setValue("zoneview/sortby", zoneViewSortByIndex); -} - -void SettingsCache::setZoneViewPileView(QT_STATE_CHANGED_T _zoneViewPileView) -{ - zoneViewPileView = static_cast(_zoneViewPileView); - settings->setValue("zoneview/pileview", zoneViewPileView); -} - -void SettingsCache::setSoundEnabled(QT_STATE_CHANGED_T _soundEnabled) -{ - soundEnabled = static_cast(_soundEnabled); - settings->setValue("sound/enabled", soundEnabled); - emit soundEnabledChanged(); -} - -void SettingsCache::setSoundThemeName(const QString &_soundThemeName) -{ - soundThemeName = _soundThemeName; - settings->setValue("sound/theme", soundThemeName); - emit soundThemeChanged(); -} - -void SettingsCache::setIgnoreUnregisteredUsers(QT_STATE_CHANGED_T _ignoreUnregisteredUsers) -{ - ignoreUnregisteredUsers = static_cast(_ignoreUnregisteredUsers); - settings->setValue("chat/ignore_unregistered", ignoreUnregisteredUsers); -} - -void SettingsCache::setIgnoreUnregisteredUserMessages(QT_STATE_CHANGED_T _ignoreUnregisteredUserMessages) -{ - ignoreUnregisteredUserMessages = static_cast(_ignoreUnregisteredUserMessages); - settings->setValue("chat/ignore_unregistered_messages", ignoreUnregisteredUserMessages); -} - -void SettingsCache::setIgnoreNonBuddyUserMessages(QT_STATE_CHANGED_T _ignoreNonBuddyUserMessages) -{ - ignoreNonBuddyUserMessages = static_cast(_ignoreNonBuddyUserMessages); - settings->setValue("chat/ignore_nonbuddy_messages", ignoreNonBuddyUserMessages); -} - -void SettingsCache::setPixmapCacheSize(const int _pixmapCacheSize) -{ - pixmapCacheSize = _pixmapCacheSize; - settings->setValue("personal/pixmapCacheSize", pixmapCacheSize); - emit pixmapCacheSizeChanged(pixmapCacheSize); -} - -void SettingsCache::setCardImageCacheMethod(const CardPictureLoaderCacheMethod::CacheMethod _cardImageCachingMethod) -{ - cardPictureLoaderCacheMethod = static_cast(_cardImageCachingMethod); - settings->setValue("personal/cardPictureLoaderCacheMethod", cardPictureLoaderCacheMethod); - emit cardPictureLoaderCacheMethodChanged(cardPictureLoaderCacheMethod); -} - -void SettingsCache::setNetworkCacheSizeInMB(const int _networkCacheSize) -{ - networkCacheSize = _networkCacheSize; - settings->setValue("personal/networkCacheSize", networkCacheSize); - emit networkCacheSizeChanged(networkCacheSize); -} - -void SettingsCache::setNetworkRedirectCacheTtl(const int _redirectCacheTtl) -{ - redirectCacheTtl = _redirectCacheTtl; - settings->setValue("personal/redirectCacheSize", redirectCacheTtl); - emit redirectCacheTtlChanged(redirectCacheTtl); -} - -void SettingsCache::setLocalCardImageStorageNamingScheme( - const CardPictureLoaderLocalSchemes::NamingScheme _localCardImageStorageNamingScheme) -{ - localCardImageStorageNamingScheme = static_cast(_localCardImageStorageNamingScheme); - settings->setValue("personal/localCardImageStorageNamingScheme", localCardImageStorageNamingScheme); - emit localCardImageStorageNamingSchemeChanged(localCardImageStorageNamingScheme); -} - -void SettingsCache::setClientID(const QString &_clientID) -{ - clientID = _clientID; - settings->setValue("personal/clientid", clientID); -} - -void SettingsCache::setClientVersion(const QString &_clientVersion) -{ - clientVersion = _clientVersion; - settings->setValue("personal/clientversion", clientVersion); -} - QStringList SettingsCache::getCountries() const { static const QStringList countries = { @@ -1210,236 +194,264 @@ QStringList SettingsCache::getCountries() const return countries; } -void SettingsCache::setGameDescription(const QString _gameDescription) -{ - gameDescription = _gameDescription; - settings->setValue("game/gamedescription", gameDescription); -} - -void SettingsCache::setMaxPlayers(const int _maxPlayers) -{ - maxPlayers = _maxPlayers; - settings->setValue("game/maxplayers", maxPlayers); -} - -void SettingsCache::setGameTypes(const QString _gameTypes) -{ - gameTypes = _gameTypes; - settings->setValue("game/gametypes", gameTypes); -} - -void SettingsCache::setOnlyBuddies(const bool _onlyBuddies) -{ - onlyBuddies = _onlyBuddies; - settings->setValue("game/onlybuddies", onlyBuddies); -} - -void SettingsCache::setOnlyRegistered(const bool _onlyRegistered) -{ - onlyRegistered = _onlyRegistered; - settings->setValue("game/onlyregistered", onlyRegistered); -} - -void SettingsCache::setSpectatorsAllowed(const bool _spectatorsAllowed) -{ - spectatorsAllowed = _spectatorsAllowed; - settings->setValue("game/spectatorsallowed", spectatorsAllowed); -} - -void SettingsCache::setSpectatorsNeedPassword(const bool _spectatorsNeedPassword) -{ - spectatorsNeedPassword = _spectatorsNeedPassword; - settings->setValue("game/spectatorsneedpassword", spectatorsNeedPassword); -} - -void SettingsCache::setSpectatorsCanTalk(const bool _spectatorsCanTalk) -{ - spectatorsCanTalk = _spectatorsCanTalk; - settings->setValue("game/spectatorscantalk", spectatorsCanTalk); -} - -void SettingsCache::setSpectatorsCanSeeEverything(const bool _spectatorsCanSeeEverything) -{ - spectatorsCanSeeEverything = _spectatorsCanSeeEverything; - settings->setValue("game/spectatorscanseeeverything", spectatorsCanSeeEverything); -} - -void SettingsCache::setCreateGameAsSpectator(const bool _createGameAsSpectator) -{ - createGameAsSpectator = _createGameAsSpectator; - settings->setValue("game/creategameasspectator", createGameAsSpectator); -} - -void SettingsCache::setDefaultStartingLifeTotal(const int _defaultStartingLifeTotal) -{ - defaultStartingLifeTotal = _defaultStartingLifeTotal; - settings->setValue("game/defaultstartinglifetotal", defaultStartingLifeTotal); -} - -void SettingsCache::setShareDecklistsOnLoad(const bool _shareDecklistsOnLoad) -{ - shareDecklistsOnLoad = _shareDecklistsOnLoad; - settings->setValue("game/sharedecklistsonload", shareDecklistsOnLoad); -} - -void SettingsCache::setCheckUpdatesOnStartup(QT_STATE_CHANGED_T value) -{ - checkUpdatesOnStartup = static_cast(value); - settings->setValue("personal/startupUpdateCheck", checkUpdatesOnStartup); -} - -void SettingsCache::setStartupCardUpdateCheckPromptForUpdate(bool value) -{ - startupCardUpdateCheckPromptForUpdate = value; - settings->setValue("personal/startupCardUpdateCheckPromptForUpdate", startupCardUpdateCheckPromptForUpdate); -} - -void SettingsCache::setStartupCardUpdateCheckAlwaysUpdate(bool value) -{ - startupCardUpdateCheckAlwaysUpdate = value; - settings->setValue("personal/startupCardUpdateCheckAlwaysUpdate", startupCardUpdateCheckAlwaysUpdate); -} - -void SettingsCache::setCardUpdateCheckInterval(int value) -{ - cardUpdateCheckInterval = value; - settings->setValue("personal/cardUpdateCheckInterval", cardUpdateCheckInterval); -} - -void SettingsCache::setLastCardUpdateCheck(QDate value) -{ - lastCardUpdateCheck = value; - settings->setValue("personal/lastCardUpdateCheck", lastCardUpdateCheck); -} - -void SettingsCache::setAlwaysEnableNewSets(bool value) -{ - alwaysEnableNewSets = value; - settings->setValue("personal/alwaysEnableNewSets", alwaysEnableNewSets); -} - -void SettingsCache::setRememberGameSettings(const bool _rememberGameSettings) -{ - rememberGameSettings = _rememberGameSettings; - settings->setValue("game/remembergamesettings", rememberGameSettings); -} - -void SettingsCache::setLocalGameRememberSettings(bool value) -{ - localGameRememberSettings = value; - settings->setValue("localgameoptions/remembersettings", value); -} - -void SettingsCache::setLocalGameMaxPlayers(int value) -{ - localGameMaxPlayers = value; - settings->setValue("localgameoptions/maxplayers", value); -} - -void SettingsCache::setLocalGameStartingLifeTotal(int value) -{ - localGameStartingLifeTotal = value; - settings->setValue("localgameoptions/startinglifetotal", value); -} - -void SettingsCache::setNotifyAboutUpdate(QT_STATE_CHANGED_T _notifyaboutupdate) -{ - notifyAboutUpdates = static_cast(_notifyaboutupdate); - settings->setValue("personal/updatenotification", notifyAboutUpdates); -} - -void SettingsCache::setNotifyAboutNewVersion(QT_STATE_CHANGED_T _notifyaboutnewversion) -{ - notifyAboutNewVersion = static_cast(_notifyaboutnewversion); - settings->setValue("personal/newversionnotification", notifyAboutNewVersion); -} - -void SettingsCache::setDownloadSpoilerStatus(bool _spoilerStatus) -{ - mbDownloadSpoilers = _spoilerStatus; - settings->setValue("personal/downloadspoilers", mbDownloadSpoilers); - emit downloadSpoilerStatusChanged(); -} - -void SettingsCache::setUpdateReleaseChannelIndex(int value) -{ - updateReleaseChannel = value; - settings->setValue("personal/updatereleasechannel", updateReleaseChannel); -} - -void SettingsCache::setMaxFontSize(int _max) -{ - maxFontSize = _max; - settings->setValue("game/maxfontsize", maxFontSize); -} - -void SettingsCache::setRoundCardCorners(bool _roundCardCorners) -{ - if (_roundCardCorners == roundCardCorners) { - return; - } - - roundCardCorners = _roundCardCorners; - settings->setValue("cards/roundcardcorners", _roundCardCorners); - emit roundCardCornersChanged(roundCardCorners); -} - -void SettingsCache::setShowDragSelectionCount(QT_STATE_CHANGED_T _showDragSelectionCount) -{ - showDragSelectionCount = static_cast(_showDragSelectionCount); - settings->setValue("interface/showlassoselectioncount", showDragSelectionCount); -} - -void SettingsCache::setShowTotalSelectionCount(QT_STATE_CHANGED_T _showTotalSelectionCount) -{ - showTotalSelectionCount = static_cast(_showTotalSelectionCount); - settings->setValue("interface/showpersistentselectioncount", showTotalSelectionCount); -} - -void SettingsCache::setShowSubtypeSelectionTally(QT_STATE_CHANGED_T _showSubtypeSelectionTally) -{ - showSubtypeSelectionTally = static_cast(_showSubtypeSelectionTally); - settings->setValue("interface/showsubtypeselectiontally", showSubtypeSelectionTally); -} - void SettingsCache::loadPaths() { QString dataPath = getDataPath(); - deckPath = getSafeConfigPath("paths/decks", dataPath + "/decks/"); - filtersPath = getSafeConfigPath("paths/filters", dataPath + "/filters/"); - replaysPath = getSafeConfigPath("paths/replays", dataPath + "/replays/"); - themesPath = getSafeConfigPath("paths/themes", dataPath + "/themes/"); - picsPath = getSafeConfigPath("paths/pics", dataPath + "/pics/"); - redirectCachePath = getSafeConfigPath("paths/redirects", getCachePath() + "/redirects/"); - // this has never been exposed as an user-configurable setting - if (picsPath.endsWith("/")) { - customPicsPath = getSafeConfigPath("paths/custompics", picsPath + "CUSTOM/"); - } else { - customPicsPath = getSafeConfigPath("paths/custompics", picsPath + "/CUSTOM/"); - } - customCardDatabasePath = getSafeConfigPath("paths/customsets", dataPath + "/customsets/"); + QSettings pathsIni(getSettingsPath() + "paths.ini", QSettings::IniFormat); - cardDatabasePath = getSafeConfigFilePath("paths/carddatabase", dataPath + "/cards.xml"); - tokenDatabasePath = getSafeConfigFilePath("paths/tokendatabase", dataPath + "/tokens.xml"); - spoilerDatabasePath = getSafeConfigFilePath("paths/spoilerdatabase", dataPath + "/spoiler.xml"); + auto computePath = [&](const QString &key, const QString &defaultPath) -> QString { + QString val = pathsIni.value("paths/" + key).toString(); + if (val.isEmpty() || !QDir(val).exists()) { + if (!QDir().mkpath(defaultPath)) { + qCInfo(SettingsCacheLog) << "[SettingsCache] Could not create folder:" << defaultPath; + } + val = defaultPath; + pathsIni.setValue("paths/" + key, val); + } + return val; + }; + + auto computeFilePath = [&](const QString &key, const QString &defaultPath) -> QString { + QString val = pathsIni.value("paths/" + key).toString(); + if (!QFile::exists(val) || val.isEmpty()) { + val = defaultPath; + pathsIni.setValue("paths/" + key, val); + } + return val; + }; + + computePath("decks", dataPath + "/decks/"); + computePath("filters", dataPath + "/filters/"); + computePath("replays", dataPath + "/replays/"); + computePath("themes", dataPath + "/themes/"); + computePath("pics", dataPath + "/pics/"); + computePath("redirects", getCachePath() + "/redirects/"); + + // customPicsPath derived from picsPath + QString picsPath = pathsIni.value("paths/pics").toString(); + if (picsPath.endsWith("/")) { + computePath("customPics", picsPath + "CUSTOM/"); + } else { + computePath("customPics", picsPath + "/CUSTOM/"); + } + + computePath("customSets", dataPath + "/customsets/"); + computeFilePath("cardDatabase", dataPath + "/cards.xml"); + computeFilePath("tokenDatabase", dataPath + "/tokens.xml"); + computeFilePath("spoilerDatabase", dataPath + "/spoiler.xml"); } void SettingsCache::resetPaths() { - QStringList databasePaths{customCardDatabasePath, cardDatabasePath, spoilerDatabasePath, tokenDatabasePath}; - QString picsPath_ = picsPath; - settings->remove("paths"); // removes all keys in paths/* + QStringList databasePaths{pathsSettings->getCustomCardDatabasePath(), pathsSettings->getCardDatabasePath(), + pathsSettings->getSpoilerCardDatabasePath(), pathsSettings->getTokenDatabasePath()}; + QString picsPath_ = pathsSettings->getPicsPath(); + + QSettings pathsIni(getSettingsPath() + "paths.ini", QSettings::IniFormat); + pathsIni.remove("paths"); + loadPaths(); - if (databasePaths != - QStringList{customCardDatabasePath, cardDatabasePath, spoilerDatabasePath, tokenDatabasePath}) { + + if (databasePaths != QStringList{pathsSettings->getCustomCardDatabasePath(), pathsSettings->getCardDatabasePath(), + pathsSettings->getSpoilerCardDatabasePath(), + pathsSettings->getTokenDatabasePath()}) { emit cardDatabasePathChanged(); } - if (picsPath_ != picsPath) { - emit picsPathChanged(); + if (picsPath_ != pathsSettings->getPicsPath()) { + emit pathsSettings->picsPathChanged(); } } +// ICardDatabasePathProvider - delegate to pathsSettings +QString SettingsCache::getCustomCardDatabasePath() const +{ + return pathsSettings->getCustomCardDatabasePath(); +} + +QString SettingsCache::getCardDatabasePath() const +{ + return pathsSettings->getCardDatabasePath(); +} + +QString SettingsCache::getSpoilerCardDatabasePath() const +{ + return pathsSettings->getSpoilerCardDatabasePath(); +} + +QString SettingsCache::getTokenDatabasePath() const +{ + return pathsSettings->getTokenDatabasePath(); +} + +// INetworkSettingsProvider - delegate to sub-objects +int SettingsCache::getKeepAlive() const +{ + return networkSettings->getKeepAlive(); +} + +int SettingsCache::getTimeOut() const +{ + return networkSettings->getTimeOut(); +} + +bool SettingsCache::getNotifyAboutUpdates() const +{ + return updatesSettings->getNotifyAboutUpdates(); +} + +void SettingsCache::setKnownMissingFeatures(const QString &_knownMissingFeatures) +{ + networkSettings->setKnownMissingFeatures(_knownMissingFeatures); +} + +QString SettingsCache::getKnownMissingFeatures() +{ + return networkSettings->getKnownMissingFeatures(); +} + +QString SettingsCache::getClientID() +{ + return networkSettings->getClientID(); +} + +// Release channels +ReleaseChannel *SettingsCache::getUpdateReleaseChannel() const +{ + return releaseChannels.at(qMax(0, updatesSettings->getUpdateReleaseChannelIndex())); +} + +QList SettingsCache::getUpdateReleaseChannels() const +{ + return releaseChannels; +} + +int SettingsCache::getUpdateReleaseChannelIndex() const +{ + return updatesSettings->getUpdateReleaseChannelIndex(); +} + +// Sub-settings accessors +ShortcutsSettings &SettingsCache::shortcuts() const +{ + return *shortcutsSettings; +} + +CardDatabaseSettings &SettingsCache::cardDatabase() const +{ + return *cardDatabaseSettings; +} + +ServersSettings &SettingsCache::servers() const +{ + return *serversSettings; +} + +MessageSettings &SettingsCache::messages() const +{ + return *messageSettings; +} + +GameFiltersSettings &SettingsCache::gameFilters() const +{ + return *gameFiltersSettings; +} + +LayoutsSettings &SettingsCache::layouts() const +{ + return *layoutsSettings; +} + +DownloadSettings &SettingsCache::downloads() const +{ + return *downloadSettings; +} + +RecentsSettings &SettingsCache::recents() const +{ + return *recentsSettings; +} + +CardOverrideSettings &SettingsCache::cardOverrides() const +{ + return *cardOverrideSettings; +} + +DebugSettings &SettingsCache::debug() const +{ + return *debugSettings; +} + CardCounterSettings &SettingsCache::cardCounters() const { return *cardCounterSettings; } + +TabsSettings &SettingsCache::tabs() const +{ + return *tabsSettings; +} + +SoundSettings &SettingsCache::sound() const +{ + return *soundSettings; +} + +GameSettings &SettingsCache::game() const +{ + return *gameSettings; +} + +ChatSettings &SettingsCache::chat() const +{ + return *chatSettings; +} + +CacheStorageSettings &SettingsCache::cacheStorage() const +{ + return *cacheStorageSettings; +} + +UpdatesSettings &SettingsCache::updates() const +{ + return *updatesSettings; +} + +PersonalSettings &SettingsCache::personal() const +{ + return *personalSettings; +} + +CardsDisplaySettings &SettingsCache::cardsDisplay() const +{ + return *cardsDisplaySettings; +} + +InterfaceSettings &SettingsCache::userInterface() const +{ + return *interfaceSettings; +} + +PathsSettings &SettingsCache::paths() const +{ + return *pathsSettings; +} + +DeckEditorSettings &SettingsCache::deckEditor() const +{ + return *deckEditorSettings; +} + +VisualDeckStorageSettings &SettingsCache::visualDeckStorage() const +{ + return *visualDeckStorageSettings; +} + +AppearanceSettings &SettingsCache::appearance() const +{ + return *appearanceSettings; +} + +NetworkSettings &SettingsCache::network() const +{ + return *networkSettings; +} diff --git a/cockatrice/src/client/settings/cache_settings.h b/cockatrice/src/client/settings/cache_settings.h index 29af89587..23cdb4dbf 100644 --- a/cockatrice/src/client/settings/cache_settings.h +++ b/cockatrice/src/client/settings/cache_settings.h @@ -7,196 +7,55 @@ #ifndef SETTINGSCACHE_H #define SETTINGSCACHE_H -#include "../../interface/card_picture_loader/card_picture_loader_cache_method.h" -#include "../../interface/card_picture_loader/card_picture_loader_local_schemes.h" -#include "shortcuts_settings.h" - -#include #include -#include #include #include #include -#include -#include -#include -#include -#include -#include -#include -#include -#include #include inline Q_LOGGING_CATEGORY(SettingsCacheLog, "settings_cache"); class ReleaseChannel; -// In MB (Increments of 64) -#define PIXMAPCACHE_SIZE_DEFAULT 2048 -#define PIXMAPCACHE_SIZE_MIN 64 -#define PIXMAPCACHE_SIZE_MAX 4096 - -// In MB -constexpr int NETWORK_CACHE_SIZE_DEFAULT = 1024 * 4; // 4 GB -constexpr int NETWORK_CACHE_SIZE_MIN = 1; // 1 MB -constexpr int NETWORK_CACHE_SIZE_MAX = 1024 * 1024; // 1 TB - -// In Days -#define NETWORK_REDIRECT_CACHE_TTL_DEFAULT 30 -#define NETWORK_REDIRECT_CACHE_TTL_MIN 1 -#define NETWORK_REDIRECT_CACHE_TTL_MAX 90 - #define DEFAULT_LANG_NAME "English" #define CLIENT_INFO_NOT_SET "notset" #define DEFAULT_FONT_SIZE 12 -inline QStringList defaultTags = { - // Strategies - "🏃️ Aggro", - "🧙‍️ Control", - "⚔️ Midrange", - "🌀 Combo", - "🪓 Mill", - "🔒 Stax", - "🗺️ Landfall", - "🛡️ Pillowfort", - "🌱 Ramp", - "⚡ Storm", - "💀 Aristocrats", - "☠️ Reanimator", - "👹 Sacrifice", - "🔥 Burn", - "🌟 Lifegain", - "🔮 Spellslinger", - "👥 Tokens", - "🎭 Blink", - "⏳ Time Manipulation", - "🌍 Domain", - "💫 Proliferate", - "📜 Saga", - "🎲 Chaos", - "🪄 Auras", - "🔫 Pingers", - - // Themes - "👑 Monarch", - "🚀 Vehicles", - "💉 Infect", - "🩸 Madness", - "🌀 Morph", - - // Card Types - "⚔️ Creature", - "💎 Artifact", - "🌔 Enchantment", - "📖 Sorcery", - "⚡ Instant", - "🌌 Planeswalker", - "🌏 Land", - "🪄 Aura", - - // Kindred Types - "🐉 Kindred", - "🧙 Humans", - "⚔️ Soldiers", - "🛡️ Knights", - "🎻 Bards", - "🧝 Elves", - "🌲 Dryads", - "😇 Angels", - "🎩 Wizards", - "🧛 Vampires", - "🦴 Skeletons", - "💀 Zombies", - "👹 Demons", - "👾 Eldrazi", - "🐉 Dragons", - "🐠 Merfolk", - "🦁 Cats", - "🐺 Wolves", - "🐺 Werewolves", - "🦇 Bats", - "🐀 Rats", - "🦅 Birds", - "🦗 Insects", - "🍄 Fungus", - "🐚 Sea Creatures", - "🐗 Boars", - "🦊 Foxes", - "🦄 Unicorns", - "🐘 Elephants", - "🐻 Bears", - "🦏 Rhinos", - "🦂 Scorpions", -}; - -class QSettings; +class CacheStorageSettings; class CardCounterSettings; +class CardDatabaseSettings; +class CardOverrideSettings; +class CardsDisplaySettings; +class ChatSettings; +class CommanderBracketSettings; +class DebugSettings; +class DeckEditorSettings; +class DownloadSettings; +class GameFiltersSettings; +class GameSettings; +class InterfaceSettings; +class LayoutsSettings; +class MessageSettings; +class PathsSettings; +class PersonalSettings; +class RecentsSettings; +class ServersSettings; +class ShortcutsSettings; +class SoundSettings; +class TabsSettings; +class UpdatesSettings; +class VisualDeckStorageSettings; +class AppearanceSettings; +class NetworkSettings; +class QSettings; class SettingsCache : public ICardDatabasePathProvider, public INetworkSettingsProvider { Q_OBJECT signals: - void langChanged(); - void picsPathChanged(); void themeChanged(); - void homeTabBackgroundSourceChanged(); - void homeTabBackgroundShuffleFrequencyChanged(); - void homeTabDisplayCardNameChanged(); - void picDownloadChanged(); - void showStatusBarChanged(bool state); - void showGameSelectorFilterToolbarChanged(bool state); - void displayCardNamesChanged(); - void overrideAllCardArtWithPersonalPreferenceChanged(bool _overrideAllCardArtWithPersonalPreference); - void bumpSetsWithCardsInDeckToTopChanged(); - void printingSelectorSortOrderChanged(); - void printingSelectorCardSizeChanged(); - void includeRebalancedCardsChanged(bool _includeRebalancedCards); - void printingSelectorNavigationButtonsVisibleChanged(); - void deckEditorBannerCardComboBoxVisibleChanged(bool _visible); - void deckEditorTagsWidgetVisibleChanged(bool _visible); - void visualDeckStorageShowTagFilterChanged(bool _visible); - void visualDeckStorageDefaultTagsListChanged(); - void visualDeckStorageShowColorIdentityChanged(bool _visible); - void visualDeckStorageShowBannerCardComboBoxChanged(bool _visible); - void visualDeckStorageShowTagsOnDeckPreviewsChanged(bool _visible); - void visualDeckStorageCardSizeChanged(); - void visualDeckStorageDrawUnusedColorIdentitiesChanged(bool _visible); - void visualDeckStorageUnusedColorIdentitiesOpacityChanged(bool value); - void visualDeckStorageInGameChanged(bool enabled); - void visualDeckStorageSelectionAnimationChanged(bool enabled); - void visualDatabaseDisplayFilterToMostRecentSetsEnabledChanged(bool enabled); - void visualDatabaseDisplayFilterToMostRecentSetsAmountChanged(int amount); - void visualDeckEditorSampleHandSizeAmountChanged(int amount); - void visualDeckEditorCardSizeChanged(); - void visualDatabaseDisplayCardSizeChanged(); - void edhRecCardSizeChanged(); - void archidektPreviewSizeChanged(); - void horizontalHandChanged(); - void handJustificationChanged(); - void invertVerticalCoordinateChanged(); - void minPlayersForMultiColumnLayoutChanged(); - void soundEnabledChanged(); - void soundThemeChanged(); - void ignoreUnregisteredUsersChanged(); - void ignoreUnregisteredUserMessagesChanged(); - void ignoreNonBuddyUserMessagesChanged(); - void pixmapCacheSizeChanged(int newSizeInMBs); - void networkCacheSizeChanged(int newSizeInMBs); - void redirectCacheTtlChanged(int newTtl); - void cardPictureLoaderCacheMethodChanged(int cardPictureLoaderCacheMethod); - void localCardImageStorageNamingSchemeChanged(int localCardImageStorageNamingScheme); - void masterVolumeChanged(int value); - void styleUserListChanged(); - void chatMentionCompleterChanged(); - void downloadSpoilerTimeIndexChanged(); - void downloadSpoilerStatusChanged(); - void useTearOffMenusChanged(bool state); - void roundCardCornersChanged(bool roundCardCorners); - void keepGameChatFocusChanged(bool value); private: QSettings *settings; @@ -211,151 +70,30 @@ private: 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; - QString lang; - QString deckPath, filtersPath, replaysPath, picsPath, redirectCachePath, customPicsPath, cardDatabasePath, - customCardDatabasePath, themesPath, spoilerDatabasePath, tokenDatabasePath, themeName, homeTabBackgroundSource; - bool tabVisualDeckStorageOpen, tabServerOpen, tabAccountOpen, tabDeckStorageOpen, tabReplaysOpen, tabAdminOpen, - tabLogOpen; - bool checkUpdatesOnStartup; - bool startupCardUpdateCheckPromptForUpdate; - bool startupCardUpdateCheckAlwaysUpdate; - bool checkCardUpdatesOnStartup; - int cardUpdateCheckInterval; - QDate lastCardUpdateCheck; - bool alwaysEnableNewSets; - bool notifyAboutUpdates; - bool notifyAboutNewVersion; - bool showTipsOnStartup; - QList seenTips; - int homeTabBackgroundShuffleFrequency; - bool homeTabDisplayCardName; - bool mbDownloadSpoilers; - int updateReleaseChannel; - int maxFontSize; - bool picDownload; - bool notificationsEnabled; - bool spectatorNotificationsEnabled; - bool buddyConnectNotificationsEnabled; - bool doubleClickToPlay; - bool clickPlaysAllSelected; - bool playToStack; - bool doNotDeleteArrowsInSubPhases; - int startingHandSize; - bool annotateTokens; - bool showShortcuts; - bool showGameSelectorFilterToolbar; - bool displayCardNames; - bool overrideAllCardArtWithPersonalPreference; - bool bumpSetsWithCardsInDeckToTop; - int printingSelectorSortOrder; - int printingSelectorCardSize; - bool includeRebalancedCards; - bool printingSelectorNavigationButtonsVisible; - bool deckEditorBannerCardComboBoxVisible; - bool deckEditorTagsWidgetVisible; - int visualDeckStorageSortingOrder; - bool visualDeckStorageShowFolders; - bool visualDeckStorageShowColorIdentity; - bool visualDeckStorageShowBannerCardComboBox; - bool visualDeckStorageShowTagsOnDeckPreviews; - bool visualDeckStorageShowTagFilter; - QStringList visualDeckStorageDefaultTagsList; - bool visualDeckStorageSearchFolderNames; - int visualDeckStorageCardSize; - int visualDeckEditorCardSize; - int visualDatabaseDisplayCardSize; - int edhrecCardSize; - int archidektPreviewSize; - bool visualDeckStorageDrawUnusedColorIdentities; - int visualDeckStorageUnusedColorIdentitiesOpacity; - int visualDeckStorageTooltipType; - bool visualDeckStoragePromptForConversion; - bool visualDeckStorageAlwaysConvert; - bool visualDeckStorageInGame; - bool visualDeckStorageSelectionAnimation; - int defaultDeckEditorType; - bool visualDatabaseDisplayFilterToMostRecentSetsEnabled; - int visualDatabaseDisplayFilterToMostRecentSetsAmount; - int visualDeckEditorSampleHandSize; - bool horizontalHand; - bool invertVerticalCoordinate; - int minPlayersForMultiColumnLayout; - bool tapAnimation; - bool autoRotateSidewaysLayoutCards; - bool openDeckInNewTab; - int rewindBufferingMs; - bool styleUserList; - bool chatMention; - bool chatMentionCompleter; - QString chatMentionColor; - QString chatHighlightColor; - bool chatMentionForeground; - bool chatHighlightForeground; - int zoneViewSortByIndex, zoneViewGroupByIndex; - bool zoneViewPileView; - bool soundEnabled; - QString soundThemeName; - bool ignoreUnregisteredUsers; - bool ignoreUnregisteredUserMessages; - bool ignoreNonBuddyUserMessages; - QString picUrl; - QString picUrlFallback; - QString clientID; - QString clientVersion; - QString knownMissingFeatures; - bool useTearOffMenus; - int cardViewInitialRowsMax; - int cardViewExpandedRowsMax; - bool closeEmptyCardView; - bool focusCardViewSearchBar; - bool keepGameChatFocus; - int pixmapCacheSize; - int networkCacheSize; - int redirectCacheTtl; - int cardPictureLoaderCacheMethod; - int localCardImageStorageNamingScheme; - bool scaleCards; - int verticalCardOverlapPercent; - bool showMessagePopups; - bool showMentionPopups; - bool roomHistory; - bool leftJustified; - int masterVolume; - int cardInfoViewMode; - QString highlightWords; - QString gameDescription; - int maxPlayers; - QString gameTypes; - bool onlyBuddies; - bool onlyRegistered; - bool spectatorsAllowed; - bool spectatorsNeedPassword; - bool spectatorsCanTalk; - bool spectatorsCanSeeEverything; - bool createGameAsSpectator; - int defaultStartingLifeTotal; - bool shareDecklistsOnLoad; - int keepalive; - int timeout; - void translateLegacySettings(); - [[nodiscard]] QString getSafeConfigPath(QString configEntry, QString defaultPath) const; - [[nodiscard]] QString getSafeConfigFilePath(QString configEntry, QString defaultPath) const; - void loadPaths(); - bool rememberGameSettings; - - // Local game settings (separate from server game settings in game/*) - bool localGameRememberSettings; - int localGameMaxPlayers; - int localGameStartingLifeTotal; + QString themeName; QList releaseChannels; bool isPortableBuild; - bool roundCardCorners; - bool showStatusBar; - bool showDragSelectionCount; - bool showTotalSelectionCount; - bool showSubtypeSelectionTally; + + [[nodiscard]] QString getSafeConfigPath(QString configEntry, QString defaultPath) const; + [[nodiscard]] QString getSafeConfigFilePath(QString configEntry, QString defaultPath) const; + void loadPaths(); public: SettingsCache(); @@ -363,824 +101,71 @@ public: QString getSettingsPath(); [[nodiscard]] QString getCachePath() const; [[nodiscard]] QString getNetworkCachePath() const; - [[nodiscard]] QString getLang() const - { - return lang; - } - [[nodiscard]] QString getDeckPath() const - { - return deckPath; - } - [[nodiscard]] QString getFiltersPath() const - { - return filtersPath; - } - [[nodiscard]] QString getReplaysPath() const - { - return replaysPath; - } - [[nodiscard]] QString getThemesPath() const - { - return themesPath; - } - [[nodiscard]] QString getPicsPath() const - { - return picsPath; - } - [[nodiscard]] QString getRedirectCachePath() const - { - return redirectCachePath; - } - [[nodiscard]] QString getCustomPicsPath() const - { - return customPicsPath; - } - [[nodiscard]] QString getCustomCardDatabasePath() const override - { - return customCardDatabasePath; - } - [[nodiscard]] QString getCardDatabasePath() const override - { - return cardDatabasePath; - } - [[nodiscard]] QString getSpoilerCardDatabasePath() const override - { - return spoilerDatabasePath; - } - [[nodiscard]] QString getTokenDatabasePath() const override - { - return tokenDatabasePath; - } + + // ICardDatabasePathProvider - delegate to pathsSettings + [[nodiscard]] QString getCustomCardDatabasePath() const override; + [[nodiscard]] QString getCardDatabasePath() const override; + [[nodiscard]] QString getSpoilerCardDatabasePath() const override; + [[nodiscard]] QString getTokenDatabasePath() const override; + + // INetworkSettingsProvider - delegate to sub-objects + [[nodiscard]] int getKeepAlive() const override; + [[nodiscard]] int getTimeOut() const override; + [[nodiscard]] bool getNotifyAboutUpdates() const override; + void setKnownMissingFeatures(const QString &_knownMissingFeatures) override; + [[nodiscard]] QString getKnownMissingFeatures() override; + QString getClientID() override; + [[nodiscard]] QString getThemeName() const { return themeName; } - [[nodiscard]] QString getHomeTabBackgroundSource() const - { - return homeTabBackgroundSource; - } - [[nodiscard]] int getHomeTabBackgroundShuffleFrequency() const - { - return homeTabBackgroundShuffleFrequency; - } - [[nodiscard]] bool getHomeTabDisplayCardName() const - { - return homeTabDisplayCardName; - } - [[nodiscard]] bool getTabVisualDeckStorageOpen() const - { - return tabVisualDeckStorageOpen; - } - [[nodiscard]] bool getTabServerOpen() const - { - return tabServerOpen; - } - [[nodiscard]] bool getTabAccountOpen() const - { - return tabAccountOpen; - } - [[nodiscard]] bool getTabDeckStorageOpen() const - { - return tabDeckStorageOpen; - } - [[nodiscard]] bool getTabReplaysOpen() const - { - return tabReplaysOpen; - } - [[nodiscard]] bool getTabAdminOpen() const - { - return tabAdminOpen; - } - [[nodiscard]] bool getTabLogOpen() const - { - return tabLogOpen; - } - [[nodiscard]] QString getChatMentionColor() const - { - return chatMentionColor; - } - [[nodiscard]] QString getChatHighlightColor() const - { - return chatHighlightColor; - } - [[nodiscard]] bool getPicDownload() const - { - return picDownload; - } - [[nodiscard]] bool getShowStatusBar() const - { - return showStatusBar; - } - [[nodiscard]] bool getShowDragSelectionCount() const - { - return showDragSelectionCount; - } - [[nodiscard]] bool getShowTotalSelectionCount() const - { - return showTotalSelectionCount; - } - [[nodiscard]] bool getShowSubtypeSelectionTally() const - { - return showSubtypeSelectionTally; - } - [[nodiscard]] bool getNotificationsEnabled() const - { - return notificationsEnabled; - } - [[nodiscard]] bool getSpectatorNotificationsEnabled() const - { - return spectatorNotificationsEnabled; - } - [[nodiscard]] bool getBuddyConnectNotificationsEnabled() const - { - return buddyConnectNotificationsEnabled; - } - [[nodiscard]] bool getCheckUpdatesOnStartup() const - { - return checkUpdatesOnStartup; - } - bool getStartupCardUpdateCheckPromptForUpdate() - { - return startupCardUpdateCheckPromptForUpdate; - } - bool getStartupCardUpdateCheckAlwaysUpdate() - { - return startupCardUpdateCheckAlwaysUpdate; - } - [[nodiscard]] int getCardUpdateCheckInterval() const - { - return cardUpdateCheckInterval; - } - [[nodiscard]] QDate getLastCardUpdateCheck() const - { - return lastCardUpdateCheck; - } - [[nodiscard]] bool getCardUpdateCheckRequired() const - { - return getLastCardUpdateCheck().daysTo(QDateTime::currentDateTime().date()) >= getCardUpdateCheckInterval() && - getLastCardUpdateCheck() != QDateTime::currentDateTime().date(); - } - [[nodiscard]] bool getAlwaysEnableNewSets() const - { - return alwaysEnableNewSets; - } - [[nodiscard]] bool getNotifyAboutUpdates() const override - { - return notifyAboutUpdates; - } - [[nodiscard]] bool getNotifyAboutNewVersion() const - { - return notifyAboutNewVersion; - } - [[nodiscard]] bool getShowTipsOnStartup() const - { - return showTipsOnStartup; - } - [[nodiscard]] QList getSeenTips() const - { - return seenTips; - } - [[nodiscard]] int getUpdateReleaseChannelIndex() const - { - return updateReleaseChannel; - } - [[nodiscard]] ReleaseChannel *getUpdateReleaseChannel() const - { - return releaseChannels.at(qMax(0, updateReleaseChannel)); - } - [[nodiscard]] QList getUpdateReleaseChannels() const - { - return releaseChannels; - } - [[nodiscard]] bool getDoubleClickToPlay() const - { - return doubleClickToPlay; - } - [[nodiscard]] bool getClickPlaysAllSelected() const - { - return clickPlaysAllSelected; - } - [[nodiscard]] bool getPlayToStack() const - { - return playToStack; - } - [[nodiscard]] bool getDoNotDeleteArrowsInSubPhases() const - { - return doNotDeleteArrowsInSubPhases; - } - [[nodiscard]] int getStartingHandSize() const - { - return startingHandSize; - } - [[nodiscard]] bool getAnnotateTokens() const - { - return annotateTokens; - } - [[nodiscard]] bool getShowShortcuts() const - { - return showShortcuts; - } - [[nodiscard]] bool getShowGameSelectorFilterToolbar() const - { - return showGameSelectorFilterToolbar; - } - [[nodiscard]] bool getDisplayCardNames() const - { - return displayCardNames; - } - [[nodiscard]] bool getOverrideAllCardArtWithPersonalPreference() const - { - return overrideAllCardArtWithPersonalPreference; - } - [[nodiscard]] bool getBumpSetsWithCardsInDeckToTop() const - { - return bumpSetsWithCardsInDeckToTop; - } - [[nodiscard]] int getPrintingSelectorSortOrder() const - { - return printingSelectorSortOrder; - } - [[nodiscard]] int getPrintingSelectorCardSize() const - { - return printingSelectorCardSize; - } - [[nodiscard]] bool getIncludeRebalancedCards() const - { - return includeRebalancedCards; - } - [[nodiscard]] bool getPrintingSelectorNavigationButtonsVisible() const - { - return printingSelectorNavigationButtonsVisible; - } - [[nodiscard]] bool getDeckEditorBannerCardComboBoxVisible() const - { - return deckEditorBannerCardComboBoxVisible; - } - [[nodiscard]] bool getDeckEditorTagsWidgetVisible() const - { - return deckEditorTagsWidgetVisible; - } - [[nodiscard]] int getVisualDeckStorageSortingOrder() const - { - return visualDeckStorageSortingOrder; - } - [[nodiscard]] bool getVisualDeckStorageShowFolders() const - { - return visualDeckStorageShowFolders; - } - [[nodiscard]] bool getVisualDeckStorageShowTagFilter() const - { - return visualDeckStorageShowTagFilter; - } - [[nodiscard]] QStringList getVisualDeckStorageDefaultTagsList() const - { - return visualDeckStorageDefaultTagsList; - } - [[nodiscard]] bool getVisualDeckStorageSearchFolderNames() const - { - return visualDeckStorageSearchFolderNames; - } - [[nodiscard]] bool getVisualDeckStorageShowColorIdentity() const - { - return visualDeckStorageShowColorIdentity; - } - [[nodiscard]] bool getVisualDeckStorageShowBannerCardComboBox() const - { - return visualDeckStorageShowBannerCardComboBox; - } - [[nodiscard]] bool getVisualDeckStorageShowTagsOnDeckPreviews() const - { - return visualDeckStorageShowTagsOnDeckPreviews; - } - [[nodiscard]] int getVisualDeckStorageCardSize() const - { - return visualDeckStorageCardSize; - } - [[nodiscard]] bool getVisualDeckStorageDrawUnusedColorIdentities() const - { - return visualDeckStorageDrawUnusedColorIdentities; - } - [[nodiscard]] int getVisualDeckStorageUnusedColorIdentitiesOpacity() const - { - return visualDeckStorageUnusedColorIdentitiesOpacity; - } - [[nodiscard]] int getVisualDeckStorageTooltipType() const - { - return visualDeckStorageTooltipType; - } - [[nodiscard]] bool getVisualDeckStoragePromptForConversion() const - { - return visualDeckStoragePromptForConversion; - } - [[nodiscard]] bool getVisualDeckStorageAlwaysConvert() const - { - return visualDeckStorageAlwaysConvert; - } - [[nodiscard]] bool getVisualDeckStorageInGame() const - { - return visualDeckStorageInGame; - } - [[nodiscard]] bool getVisualDeckStorageSelectionAnimation() const - { - return visualDeckStorageSelectionAnimation; - } - [[nodiscard]] int getVisualDeckEditorCardSize() const - { - return visualDeckEditorCardSize; - } - [[nodiscard]] int getVisualDatabaseDisplayCardSize() const - { - return visualDatabaseDisplayCardSize; - } - [[nodiscard]] int getEDHRecCardSize() const - { - return edhrecCardSize; - } - [[nodiscard]] int getArchidektPreviewSize() const - { - return archidektPreviewSize; - } - [[nodiscard]] int getDefaultDeckEditorType() const - { - return defaultDeckEditorType; - } - [[nodiscard]] bool getVisualDatabaseDisplayFilterToMostRecentSetsEnabled() const - { - return visualDatabaseDisplayFilterToMostRecentSetsEnabled; - } - [[nodiscard]] int getVisualDatabaseDisplayFilterToMostRecentSetsAmount() const - { - return visualDatabaseDisplayFilterToMostRecentSetsAmount; - } - [[nodiscard]] int getVisualDeckEditorSampleHandSize() const - { - return visualDeckEditorSampleHandSize; - } - [[nodiscard]] bool getHorizontalHand() const - { - return horizontalHand; - } - [[nodiscard]] bool getInvertVerticalCoordinate() const - { - return invertVerticalCoordinate; - } - [[nodiscard]] int getMinPlayersForMultiColumnLayout() const - { - return minPlayersForMultiColumnLayout; - } - [[nodiscard]] bool getTapAnimation() const - { - return tapAnimation; - } - [[nodiscard]] bool getAutoRotateSidewaysLayoutCards() const - { - return autoRotateSidewaysLayoutCards; - } - [[nodiscard]] bool getOpenDeckInNewTab() const - { - return openDeckInNewTab; - } - [[nodiscard]] int getRewindBufferingMs() const - { - return rewindBufferingMs; - } - [[nodiscard]] bool getStyleUserList() const - { - return styleUserList; - } - [[nodiscard]] bool getChatMention() const - { - return chatMention; - } - [[nodiscard]] bool getChatMentionCompleter() const - { - return chatMentionCompleter; - } - [[nodiscard]] bool getChatMentionForeground() const - { - return chatMentionForeground; - } - [[nodiscard]] bool getChatHighlightForeground() const - { - return chatHighlightForeground; - } - /** - * Currently selected index for the `Group by X` QComboBox - */ - [[nodiscard]] int getZoneViewGroupByIndex() const - { - return zoneViewGroupByIndex; - } - /** - * Currently selected index for the `Sort by X` QComboBox - */ - [[nodiscard]] int getZoneViewSortByIndex() const - { - return zoneViewSortByIndex; - } - /** - Returns if the view should be sorted into pile view. - @return zoneViewPileView if the view should be sorted into pile view. - */ - [[nodiscard]] bool getZoneViewPileView() const - { - return zoneViewPileView; - } - [[nodiscard]] bool getSoundEnabled() const - { - return soundEnabled; - } - [[nodiscard]] QString getSoundThemeName() const - { - return soundThemeName; - } - [[nodiscard]] bool getIgnoreUnregisteredUsers() const - { - return ignoreUnregisteredUsers; - } - [[nodiscard]] bool getIgnoreUnregisteredUserMessages() const - { - return ignoreUnregisteredUserMessages; - } - [[nodiscard]] bool getIgnoreNonBuddyUserMessages() const - { - return ignoreNonBuddyUserMessages; - } - [[nodiscard]] int getPixmapCacheSize() const - { - return pixmapCacheSize; - } - [[nodiscard]] CardPictureLoaderCacheMethod::CacheMethod getCardPictureLoaderCacheMethod() const - { - return static_cast(cardPictureLoaderCacheMethod); - } - [[nodiscard]] int getNetworkCacheSizeInMB() const - { - return networkCacheSize; - } - [[nodiscard]] int getRedirectCacheTtl() const - { - return redirectCacheTtl; - } - [[nodiscard]] CardPictureLoaderLocalSchemes::NamingScheme getLocalCardImageStorageNamingScheme() const - { - return static_cast(localCardImageStorageNamingScheme); - } - [[nodiscard]] bool getScaleCards() const - { - return scaleCards; - } - [[nodiscard]] int getStackCardOverlapPercent() const - { - return verticalCardOverlapPercent; - } - [[nodiscard]] bool getShowMessagePopup() const - { - return showMessagePopups; - } - [[nodiscard]] bool getShowMentionPopup() const - { - return showMentionPopups; - } - [[nodiscard]] bool getRoomHistory() const - { - return roomHistory; - } - [[nodiscard]] bool getLeftJustified() const - { - return leftJustified; - } - [[nodiscard]] int getMasterVolume() const - { - return masterVolume; - } - [[nodiscard]] int getCardInfoViewMode() const - { - return cardInfoViewMode; - } + [[nodiscard]] ReleaseChannel *getUpdateReleaseChannel() const; + [[nodiscard]] QList getUpdateReleaseChannels() const; + [[nodiscard]] int getUpdateReleaseChannelIndex() const; + [[nodiscard]] QStringList getCountries() const; - [[nodiscard]] QString getHighlightWords() const - { - return highlightWords; - } - [[nodiscard]] QString getGameDescription() const - { - return gameDescription; - } - [[nodiscard]] int getMaxPlayers() const - { - return maxPlayers; - } - [[nodiscard]] QString getGameTypes() const - { - return gameTypes; - } - [[nodiscard]] bool getOnlyBuddies() const - { - return onlyBuddies; - } - [[nodiscard]] bool getOnlyRegistered() const - { - return onlyRegistered; - } - [[nodiscard]] bool getSpectatorsAllowed() const - { - return spectatorsAllowed; - } - [[nodiscard]] bool getSpectatorsNeedPassword() const - { - return spectatorsNeedPassword; - } - [[nodiscard]] bool getSpectatorsCanTalk() const - { - return spectatorsCanTalk; - } - [[nodiscard]] bool getSpectatorsCanSeeEverything() const - { - return spectatorsCanSeeEverything; - } - [[nodiscard]] int getDefaultStartingLifeTotal() const - { - return defaultStartingLifeTotal; - } - [[nodiscard]] bool getShareDecklistsOnLoad() const - { - return shareDecklistsOnLoad; - } - [[nodiscard]] bool getCreateGameAsSpectator() const - { - return createGameAsSpectator; - } - [[nodiscard]] bool getRememberGameSettings() const - { - return rememberGameSettings; - } - [[nodiscard]] bool getLocalGameRememberSettings() const - { - return localGameRememberSettings; - } - [[nodiscard]] int getLocalGameMaxPlayers() const - { - return localGameMaxPlayers; - } - [[nodiscard]] int getLocalGameStartingLifeTotal() const - { - return localGameStartingLifeTotal; - } - [[nodiscard]] int getKeepAlive() const override - { - return keepalive; - } - [[nodiscard]] int getTimeOut() const override - { - return timeout; - } - [[nodiscard]] int getMaxFontSize() const - { - return maxFontSize; - } - void setClientID(const QString &clientID); - void setClientVersion(const QString &clientVersion); - void setKnownMissingFeatures(const QString &_knownMissingFeatures) override; - void setUseTearOffMenus(bool _useTearOffMenus); - void setCardViewInitialRowsMax(int _cardViewInitialRowsMax); - void setCardViewExpandedRowsMax(int value); - void setCloseEmptyCardView(QT_STATE_CHANGED_T value); - void setFocusCardViewSearchBar(QT_STATE_CHANGED_T value); - void setKeepGameChatFocus(QT_STATE_CHANGED_T value); - QString getClientID() override - { - return clientID; - } - QString getClientVersion() - { - return clientVersion; - } - QString getKnownMissingFeatures() override - { - return knownMissingFeatures; - } - bool getUseTearOffMenus() - { - return useTearOffMenus; - } - [[nodiscard]] int getCardViewInitialRowsMax() const - { - return cardViewInitialRowsMax; - } - [[nodiscard]] int getCardViewExpandedRowsMax() const - { - return cardViewExpandedRowsMax; - } - [[nodiscard]] bool getCloseEmptyCardView() const - { - return closeEmptyCardView; - } - [[nodiscard]] bool getFocusCardViewSearchBar() const - { - return focusCardViewSearchBar; - } - [[nodiscard]] bool getKeepGameChatFocus() const - { - return keepGameChatFocus; - } - [[nodiscard]] ShortcutsSettings &shortcuts() const - { - return *shortcutsSettings; - } - [[nodiscard]] CardDatabaseSettings *cardDatabase() const - { - return cardDatabaseSettings; - } - [[nodiscard]] ServersSettings &servers() const - { - return *serversSettings; - } - [[nodiscard]] MessageSettings &messages() const - { - return *messageSettings; - } - [[nodiscard]] GameFiltersSettings &gameFilters() const - { - return *gameFiltersSettings; - } - [[nodiscard]] LayoutsSettings &layouts() const - { - return *layoutsSettings; - } - [[nodiscard]] DownloadSettings &downloads() const - { - return *downloadSettings; - } - [[nodiscard]] RecentsSettings &recents() const - { - return *recentsSettings; - } - [[nodiscard]] CardOverrideSettings &cardOverrides() const - { - return *cardOverrideSettings; - } - [[nodiscard]] DebugSettings &debug() const - { - return *debugSettings; - } + + [[nodiscard]] ShortcutsSettings &shortcuts() const; + [[nodiscard]] CardDatabaseSettings &cardDatabase() const; + [[nodiscard]] ServersSettings &servers() const; + [[nodiscard]] MessageSettings &messages() const; + [[nodiscard]] GameFiltersSettings &gameFilters() const; + [[nodiscard]] LayoutsSettings &layouts() const; + [[nodiscard]] DownloadSettings &downloads() const; + [[nodiscard]] RecentsSettings &recents() const; + [[nodiscard]] CardOverrideSettings &cardOverrides() const; + [[nodiscard]] DebugSettings &debug() const; [[nodiscard]] CardCounterSettings &cardCounters() const; + [[nodiscard]] TabsSettings &tabs() const; + [[nodiscard]] SoundSettings &sound() const; + [[nodiscard]] GameSettings &game() const; + [[nodiscard]] ChatSettings &chat() const; + [[nodiscard]] CacheStorageSettings &cacheStorage() const; + [[nodiscard]] UpdatesSettings &updates() const; + [[nodiscard]] PersonalSettings &personal() const; + [[nodiscard]] CardsDisplaySettings &cardsDisplay() const; + [[nodiscard]] InterfaceSettings &userInterface() const; + [[nodiscard]] DeckEditorSettings &deckEditor() const; + [[nodiscard]] PathsSettings &paths() const; + [[nodiscard]] VisualDeckStorageSettings &visualDeckStorage() const; + [[nodiscard]] AppearanceSettings &appearance() const; + [[nodiscard]] NetworkSettings &network() const; + [[nodiscard]] CommanderBracketSettings &commanderBrackets() const + { + return *commanderBracketSettings; + } [[nodiscard]] bool getIsPortableBuild() const { return isPortableBuild; } - [[nodiscard]] bool getDownloadSpoilersStatus() const - { - return mbDownloadSpoilers; - } - [[nodiscard]] bool getRoundCardCorners() const - { - return roundCardCorners; - } static SettingsCache &instance(); void resetPaths(); public slots: - void setDownloadSpoilerStatus(bool _spoilerStatus); - - void setLang(const QString &_lang); - void setShowTipsOnStartup(bool _showTipsOnStartup); - void setSeenTips(const QList &_seenTips); - void setDeckPath(const QString &_deckPath); - void setFiltersPath(const QString &_filtersPath); - void setReplaysPath(const QString &_replaysPath); - void setThemesPath(const QString &_themesPath); - void setCustomCardDatabasePath(const QString &_customCardDatabasePath); - void setPicsPath(const QString &_picsPath); - void setCardDatabasePath(const QString &_cardDatabasePath); - void setSpoilerDatabasePath(const QString &_spoilerDatabasePath); - void setTokenDatabasePath(const QString &_tokenDatabasePath); void setThemeName(const QString &_themeName); - void setHomeTabBackgroundSource(const QString &_backgroundSource); - void setHomeTabBackgroundShuffleFrequency(int _frequency); - void setHomeTabDisplayCardName(QT_STATE_CHANGED_T _displayCardName); - void setTabVisualDeckStorageOpen(bool value); - void setTabServerOpen(bool value); - void setTabAccountOpen(bool value); - void setTabDeckStorageOpen(bool value); - void setTabReplaysOpen(bool value); - void setTabAdminOpen(bool value); - void setTabLogOpen(bool value); - void setChatMentionColor(const QString &_chatMentionColor); - void setChatHighlightColor(const QString &_chatHighlightColor); - void setPicDownload(QT_STATE_CHANGED_T _picDownload); - void setShowStatusBar(bool value); - void setNotificationsEnabled(QT_STATE_CHANGED_T _notificationsEnabled); - void setSpectatorNotificationsEnabled(QT_STATE_CHANGED_T _spectatorNotificationsEnabled); - void setBuddyConnectNotificationsEnabled(QT_STATE_CHANGED_T _buddyConnectNotificationsEnabled); - void setDoubleClickToPlay(QT_STATE_CHANGED_T _doubleClickToPlay); - void setClickPlaysAllSelected(QT_STATE_CHANGED_T _clickPlaysAllSelected); - void setPlayToStack(QT_STATE_CHANGED_T _playToStack); - void setDoNotDeleteArrowsInSubPhases(QT_STATE_CHANGED_T _doNotDeleteArrowsInSubPhases); - void setStartingHandSize(int _startingHandSize); - void setAnnotateTokens(QT_STATE_CHANGED_T _annotateTokens); - void setShowShortcuts(QT_STATE_CHANGED_T _showShortcuts); - void setShowGameSelectorFilterToolbar(QT_STATE_CHANGED_T _showGameSelectorFilterToolbar); - void setDisplayCardNames(QT_STATE_CHANGED_T _displayCardNames); - void setOverrideAllCardArtWithPersonalPreference(QT_STATE_CHANGED_T _overrideAllCardArt); - void setBumpSetsWithCardsInDeckToTop(QT_STATE_CHANGED_T _bumpSetsWithCardsInDeckToTop); - void setPrintingSelectorSortOrder(int _printingSelectorSortOrder); - void setPrintingSelectorCardSize(int _printingSelectorCardSize); - void setIncludeRebalancedCards(bool _includeRebalancedCards); - void setPrintingSelectorNavigationButtonsVisible(QT_STATE_CHANGED_T _navigationButtonsVisible); - void setDeckEditorBannerCardComboBoxVisible(QT_STATE_CHANGED_T _deckEditorBannerCardComboBoxVisible); - void setDeckEditorTagsWidgetVisible(QT_STATE_CHANGED_T _deckEditorTagsWidgetVisible); - void setVisualDeckStorageSortingOrder(int _visualDeckStorageSortingOrder); - void setVisualDeckStorageShowFolders(QT_STATE_CHANGED_T value); - void setVisualDeckStorageShowTagFilter(QT_STATE_CHANGED_T _showTags); - void setVisualDeckStorageDefaultTagsList(QStringList _defaultTagsList); - void setVisualDeckStorageSearchFolderNames(QT_STATE_CHANGED_T value); - void setVisualDeckStorageShowColorIdentity(QT_STATE_CHANGED_T value); - void setVisualDeckStorageShowBannerCardComboBox(QT_STATE_CHANGED_T _showBannerCardComboBox); - void setVisualDeckStorageShowTagsOnDeckPreviews(QT_STATE_CHANGED_T _showTags); - void setVisualDeckStorageCardSize(int _visualDeckStorageCardSize); - void setVisualDeckStorageDrawUnusedColorIdentities(QT_STATE_CHANGED_T _visualDeckStorageDrawUnusedColorIdentities); - void setVisualDeckStorageUnusedColorIdentitiesOpacity(int _visualDeckStorageUnusedColorIdentitiesOpacity); - void setVisualDeckStorageTooltipType(int value); - void setVisualDeckStoragePromptForConversion(bool _visualDeckStoragePromptForConversion); - void setVisualDeckStorageAlwaysConvert(bool _visualDeckStorageAlwaysConvert); - void setVisualDeckStorageInGame(QT_STATE_CHANGED_T value); - void setVisualDeckStorageSelectionAnimation(QT_STATE_CHANGED_T value); - void setVisualDeckEditorCardSize(int _visualDeckEditorCardSize); - void setVisualDatabaseDisplayCardSize(int _visualDatabaseDisplayCardSize); - void setEDHRecCardSize(int _EDHRecCardSize); - void setArchidektPreviewCardSize(int _archidektPreviewCardSize); - void setDefaultDeckEditorType(int value); - void setVisualDatabaseDisplayFilterToMostRecentSetsEnabled(QT_STATE_CHANGED_T _enabled); - void setVisualDatabaseDisplayFilterToMostRecentSetsAmount(int _amount); - void setVisualDeckEditorSampleHandSize(int _amount); - void setHorizontalHand(QT_STATE_CHANGED_T _horizontalHand); - void setInvertVerticalCoordinate(QT_STATE_CHANGED_T _invertVerticalCoordinate); - void setMinPlayersForMultiColumnLayout(int _minPlayersForMultiColumnLayout); - void setTapAnimation(QT_STATE_CHANGED_T _tapAnimation); - void setAutoRotateSidewaysLayoutCards(QT_STATE_CHANGED_T _autoRotateSidewaysLayoutCards); - void setOpenDeckInNewTab(QT_STATE_CHANGED_T _openDeckInNewTab); - void setRewindBufferingMs(int _rewindBufferingMs); - void setStyleUserList(QT_STATE_CHANGED_T _styleUserList); - void setChatMention(QT_STATE_CHANGED_T _chatMention); - void setChatMentionCompleter(QT_STATE_CHANGED_T _chatMentionCompleter); - void setChatMentionForeground(QT_STATE_CHANGED_T _chatMentionForeground); - void setChatHighlightForeground(QT_STATE_CHANGED_T _chatHighlightForeground); - void setZoneViewGroupByIndex(const int _zoneViewGroupByIndex); - void setZoneViewSortByIndex(const int _zoneViewSortByIndex); - void setZoneViewPileView(QT_STATE_CHANGED_T _zoneViewPileView); - void setSoundEnabled(QT_STATE_CHANGED_T _soundEnabled); - void setSoundThemeName(const QString &_soundThemeName); - void setIgnoreUnregisteredUsers(QT_STATE_CHANGED_T _ignoreUnregisteredUsers); - void setIgnoreUnregisteredUserMessages(QT_STATE_CHANGED_T _ignoreUnregisteredUserMessages); - void setIgnoreNonBuddyUserMessages(QT_STATE_CHANGED_T _ignoreNonBuddyUserMessages); - void setPixmapCacheSize(const int _pixmapCacheSize); - void setCardImageCacheMethod(CardPictureLoaderCacheMethod::CacheMethod _cardImageCachingMethod); - void setNetworkCacheSizeInMB(const int _networkCacheSize); - void setNetworkRedirectCacheTtl(const int _redirectCacheTtl); - void setLocalCardImageStorageNamingScheme( - const CardPictureLoaderLocalSchemes::NamingScheme _localCardImageStorageNamingScheme); - void setCardScaling(const QT_STATE_CHANGED_T _scaleCards); - void setStackCardOverlapPercent(const int _verticalCardOverlapPercent); - void setShowMessagePopups(const QT_STATE_CHANGED_T _showMessagePopups); - void setShowMentionPopups(const QT_STATE_CHANGED_T _showMentionPopups); - void setRoomHistory(const QT_STATE_CHANGED_T _roomHistory); - void setLeftJustified(const QT_STATE_CHANGED_T _leftJustified); - void setMasterVolume(const int _masterVolume); - void setCardInfoViewMode(const int _viewMode); - void setHighlightWords(const QString &_highlightWords); - void setGameDescription(const QString _gameDescription); - void setMaxPlayers(const int _maxPlayers); - void setGameTypes(const QString _gameTypes); - void setOnlyBuddies(const bool _onlyBuddies); - void setOnlyRegistered(const bool _onlyRegistered); - void setSpectatorsAllowed(const bool _spectatorsAllowed); - void setSpectatorsNeedPassword(const bool _spectatorsNeedPassword); - void setSpectatorsCanTalk(const bool _spectatorsCanTalk); - void setSpectatorsCanSeeEverything(const bool _spectatorsCanSeeEverything); - void setCreateGameAsSpectator(const bool _createGameAsSpectator); - void setDefaultStartingLifeTotal(const int _defaultStartingLifeTotal); - void setShareDecklistsOnLoad(const bool _shareDecklistsOnLoad); - void setRememberGameSettings(const bool _rememberGameSettings); - void setLocalGameRememberSettings(bool value); - void setLocalGameMaxPlayers(int value); - void setLocalGameStartingLifeTotal(int value); - void setCheckUpdatesOnStartup(QT_STATE_CHANGED_T value); - void setStartupCardUpdateCheckPromptForUpdate(bool value); - void setStartupCardUpdateCheckAlwaysUpdate(bool value); - void setCardUpdateCheckInterval(int value); - void setLastCardUpdateCheck(QDate value); - void setAlwaysEnableNewSets(bool value); - void setNotifyAboutUpdate(QT_STATE_CHANGED_T _notifyaboutupdate); - void setNotifyAboutNewVersion(QT_STATE_CHANGED_T _notifyaboutnewversion); - void setUpdateReleaseChannelIndex(int value); - void setMaxFontSize(int _max); - void setRoundCardCorners(bool _roundCardCorners); - void setShowDragSelectionCount(QT_STATE_CHANGED_T _showDragSelectionCount); - void setShowTotalSelectionCount(QT_STATE_CHANGED_T _showTotalSelectionCount); - void setShowSubtypeSelectionTally(QT_STATE_CHANGED_T _showSubtypeSelectionTally); }; #endif diff --git a/cockatrice/src/client/settings/card_counter_settings.cpp b/cockatrice/src/client/settings/card_counter_settings.cpp index 662ae0c7d..d4030c174 100644 --- a/cockatrice/src/client/settings/card_counter_settings.cpp +++ b/cockatrice/src/client/settings/card_counter_settings.cpp @@ -5,7 +5,7 @@ #include CardCounterSettings::CardCounterSettings(const QString &settingsPath, QObject *parent) - : SettingsManager(settingsPath + "global.ini", "cards", "counters", parent) + : SettingsManager(settingsPath + "card_counters.ini", "cards", "counters", parent) { } diff --git a/cockatrice/src/client/settings/shortcuts_settings.h b/cockatrice/src/client/settings/shortcuts_settings.h index 95155b8d1..f4ebc204e 100644 --- a/cockatrice/src/client/settings/shortcuts_settings.h +++ b/cockatrice/src/client/settings/shortcuts_settings.h @@ -786,6 +786,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 e592b5ea0..96cafa3d3 100644 --- a/cockatrice/src/client/sound_engine.cpp +++ b/cockatrice/src/client/sound_engine.cpp @@ -2,12 +2,11 @@ #include "settings/cache_settings.h" +#include +#include #include #include - -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) -#include -#endif +#include #define DEFAULT_THEME_NAME "Default" #define TEST_SOUND_FILENAME "player_join" @@ -15,8 +14,10 @@ SoundEngine::SoundEngine(QObject *parent) : QObject(parent), audioOutput(nullptr), player(nullptr) { ensureThemeDirectoryExists(); - connect(&SettingsCache::instance(), &SettingsCache::soundThemeChanged, this, &SoundEngine::themeChangedSlot); - connect(&SettingsCache::instance(), &SettingsCache::soundEnabledChanged, this, &SoundEngine::soundEnabledChanged); + connect(&SettingsCache::instance().sound(), &SoundSettings::soundThemeChanged, this, + &SoundEngine::themeChangedSlot); + connect(&SettingsCache::instance().sound(), &SoundSettings::soundEnabledChanged, this, + &SoundEngine::soundEnabledChanged); soundEnabledChanged(); themeChangedSlot(); @@ -36,14 +37,12 @@ SoundEngine::~SoundEngine() void SoundEngine::soundEnabledChanged() { - if (SettingsCache::instance().getSoundEnabled()) { + if (SettingsCache::instance().sound().getSoundEnabled()) { qCInfo(SoundEngineLog) << "SoundEngine: enabling sound with" << audioData.size() << "sounds"; if (!player) { player = new QMediaPlayer; -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) audioOutput = new QAudioOutput(player); player->setAudioOutput(audioOutput); -#endif } } else { qCInfo(SoundEngineLog) << "SoundEngine: disabling sound"; @@ -70,14 +69,9 @@ void SoundEngine::playSound(const QString &fileName) } player->stop(); - int volumeSliderValue = SettingsCache::instance().getMasterVolume(); -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) + int volumeSliderValue = SettingsCache::instance().sound().getMasterVolume(); player->audioOutput()->setVolume(qreal(volumeSliderValue) / 100); player->setSource(QUrl::fromLocalFile(audioData[fileName])); -#else - player->setVolume(volumeSliderValue); - player->setMedia(QUrl::fromLocalFile(audioData[fileName])); -#endif player->play(); } @@ -88,10 +82,10 @@ void SoundEngine::testSound() void SoundEngine::ensureThemeDirectoryExists() { - if (SettingsCache::instance().getSoundThemeName().isEmpty() || - !getAvailableThemes().contains(SettingsCache::instance().getSoundThemeName())) { + if (SettingsCache::instance().sound().getSoundThemeName().isEmpty() || + !getAvailableThemes().contains(SettingsCache::instance().sound().getSoundThemeName())) { qCInfo(SoundEngineLog) << "Sounds theme name not set, setting default value"; - SettingsCache::instance().setSoundThemeName(DEFAULT_THEME_NAME); + SettingsCache::instance().sound().setSoundThemeName(DEFAULT_THEME_NAME); } } @@ -100,7 +94,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"); @@ -110,7 +104,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" @@ -132,7 +126,7 @@ QStringMap &SoundEngine::getAvailableThemes() void SoundEngine::themeChangedSlot() { - QString themeName = SettingsCache::instance().getSoundThemeName(); + QString themeName = SettingsCache::instance().sound().getSoundThemeName(); qCInfo(SoundEngineLog) << "Sound theme changed:" << themeName; QDir dir = getAvailableThemes().value(themeName); diff --git a/cockatrice/src/client/url_scheme_event_filter.h b/cockatrice/src/client/url_scheme_event_filter.h new file mode 100644 index 000000000..9e96502ca --- /dev/null +++ b/cockatrice/src/client/url_scheme_event_filter.h @@ -0,0 +1,69 @@ +#ifndef COCKATRICE_URL_SCHEME_EVENT_FILTER_H +#define COCKATRICE_URL_SCHEME_EVENT_FILTER_H + +#include +#include +#include +#include +#include + +/** + * @brief Event filter that catches QFileOpenEvent URLs matching a scheme and + * re-emits them as urlReceived(). + * + * On macOS, when the application is registered as a URL scheme handler, the + * OS delivers incoming URLs via QFileOpenEvent on the QApplication object. + * Install this filter on QApplication to intercept them: + * + * @code + * UrlSchemeEventFilter filter(QStringList{QStringLiteral("cockatrice")}); + * QObject::connect(&filter, &UrlSchemeEventFilter::urlReceived, + * &mainWindow, &MainWindow::handleUrl); + * app.installEventFilter(&filter); + * @endcode + * + * Note: the strings are compared against QUrl::scheme(), so they must be + * written without the "://" suffix (e.g. "cockatrice", not "cockatrice://"). + */ +class UrlSchemeEventFilter : public QObject +{ + Q_OBJECT + +public: + explicit UrlSchemeEventFilter(const QStringList &schemes, QObject *parent = nullptr) + : QObject(parent), prefixes(schemes) + { + } + +signals: + void urlReceived(const QString &url); + +public: + bool eventFilter(QObject *watched, QEvent *event) override + { + if (event->type() == QEvent::FileOpen) { + auto *fileEvent = static_cast(event); + + const QUrl url = fileEvent->url(); + + for (const auto &prefix : prefixes) { + if (url.scheme() == prefix) { + emit urlReceived(url.toString()); + return true; + } + } + + if (url.isLocalFile()) { + emit urlReceived(url.toLocalFile()); + return true; + } + } + + return QObject::eventFilter(watched, event); + } + +private: + QStringList prefixes; +}; + +#endif // COCKATRICE_URL_SCHEME_EVENT_FILTER_H diff --git a/cockatrice/src/database/interface/settings_card_database_path_provider.h b/cockatrice/src/database/interface/settings_card_database_path_provider.h new file mode 100644 index 000000000..1c7d02460 --- /dev/null +++ b/cockatrice/src/database/interface/settings_card_database_path_provider.h @@ -0,0 +1,41 @@ +#ifndef SETTINGS_CARD_DATABASE_PATH_PROVIDER_H +#define SETTINGS_CARD_DATABASE_PATH_PROVIDER_H + +#include "../../client/settings/cache_settings.h" + +#include +#include + +class SettingsCardDatabasePathProvider : public ICardDatabasePathProvider +{ + Q_OBJECT + +public: + explicit SettingsCardDatabasePathProvider(QObject *parent = nullptr) : ICardDatabasePathProvider(parent) + { + connect(&SettingsCache::instance().paths(), &PathsSettings::cardDatabasePathChanged, this, + &ICardDatabasePathProvider::cardDatabasePathChanged); + } + + [[nodiscard]] QString getCardDatabasePath() const override + { + return SettingsCache::instance().paths().getCardDatabasePath(); + } + + [[nodiscard]] QString getCustomCardDatabasePath() const override + { + return SettingsCache::instance().paths().getCustomCardDatabasePath(); + } + + [[nodiscard]] QString getTokenDatabasePath() const override + { + return SettingsCache::instance().paths().getTokenDatabasePath(); + } + + [[nodiscard]] virtual QString getSpoilerCardDatabasePath() const override + { + return SettingsCache::instance().paths().getSpoilerCardDatabasePath(); + } +}; + +#endif // SETTINGS_CARD_DATABASE_PATH_PROVIDER_H diff --git a/cockatrice/src/database/interface/settings_card_preference_provider.h b/cockatrice/src/database/interface/settings_card_preference_provider.h index 6c31b9d6d..64502e3a7 100644 --- a/cockatrice/src/database/interface/settings_card_preference_provider.h +++ b/cockatrice/src/database/interface/settings_card_preference_provider.h @@ -3,6 +3,8 @@ #include "../../client/settings/cache_settings.h" #include +#include +#include class SettingsCardPreferenceProvider : public ICardPreferenceProvider { @@ -14,7 +16,7 @@ public: [[nodiscard]] bool getIncludeRebalancedCards() const override { - return SettingsCache::instance().getIncludeRebalancedCards(); + return SettingsCache::instance().cardsDisplay().getIncludeRebalancedCards(); } }; diff --git a/cockatrice/src/filters/deck_filter_string.cpp b/cockatrice/src/filters/deck_filter_string.cpp index dd873cfa5..4abb8210c 100644 --- a/cockatrice/src/filters/deck_filter_string.cpp +++ b/cockatrice/src/filters/deck_filter_string.cpp @@ -52,18 +52,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 +67,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 { @@ -125,9 +119,9 @@ static void setupParserRules() auto cardFilter = FilterString(std::any_cast(sv[0])); 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,53 +140,49 @@ 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"; } @@ -205,7 +195,7 @@ DeckFilterString::DeckFilterString(const QString &expr) _error = QString(); if (ba.isEmpty()) { - filter = [](const DeckPreviewWidget *, const ExtraDeckSearchInfo &) { return true; }; + filter = [](const DeckSearchData &) { return true; }; return; } @@ -215,6 +205,6 @@ DeckFilterString::DeckFilterString(const QString &expr) 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..90a6a17eb 100644 --- a/cockatrice/src/filters/deck_filter_string.h +++ b/cockatrice/src/filters/deck_filter_string.h @@ -7,7 +7,7 @@ #ifndef DECK_FILTER_STRING_H #define DECK_FILTER_STRING_H -#include "../interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h" +#include "../interface/deck_loader/loaded_deck.h" #include #include @@ -16,26 +16,29 @@ 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 + 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..f109fbcd3 100644 --- a/cockatrice/src/filters/filter_builder.cpp +++ b/cockatrice/src/filters/filter_builder.cpp @@ -1,5 +1,6 @@ #include "filter_builder.h" +#include "../interface/pixel_map_generator.h" #include "../interface/widgets/utility/custom_line_edit.h" #include @@ -21,7 +22,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/game/abstract_game.cpp b/cockatrice/src/game/abstract_game.cpp index c20003ece..6aa2ab28f 100644 --- a/cockatrice/src/game/abstract_game.cpp +++ b/cockatrice/src/game/abstract_game.cpp @@ -31,7 +31,7 @@ AbstractClient *AbstractGame::getClientForPlayer(int playerId) const } } -void AbstractGame::loadReplay(GameReplay *replay) +void AbstractGame::loadReplay(const GameReplay *replay) { gameMetaInfo->setFromProto(replay->game_info()); gameMetaInfo->setSpectatorsOmniscient(true); diff --git a/cockatrice/src/game/abstract_game.h b/cockatrice/src/game/abstract_game.h index 5115ed5ca..fcf764492 100644 --- a/cockatrice/src/game/abstract_game.h +++ b/cockatrice/src/game/abstract_game.h @@ -53,7 +53,7 @@ public: AbstractClient *getClientForPlayer(int playerId) const; - void loadReplay(GameReplay *replay); + void loadReplay(const GameReplay *replay); CardItem *getCard(int playerId, const QString &zoneName, int cardId) const; diff --git a/cockatrice/src/game/board/counter_state.cpp b/cockatrice/src/game/board/counter_state.cpp index 6da18b662..0970e4272 100644 --- a/cockatrice/src/game/board/counter_state.cpp +++ b/cockatrice/src/game/board/counter_state.cpp @@ -13,12 +13,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..4c7b34473 100644 --- a/cockatrice/src/game/board/counter_state.h +++ b/cockatrice/src/game/board/counter_state.h @@ -35,10 +35,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_event_handler.cpp b/cockatrice/src/game/game_event_handler.cpp index 4a96eebdb..f146cdbb4 100644 --- a/cockatrice/src/game/game_event_handler.cpp +++ b/cockatrice/src/game/game_event_handler.cpp @@ -229,7 +229,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 +285,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 +355,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 +430,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 f47116949..99277abb7 100644 --- a/cockatrice/src/game/game_event_handler.h +++ b/cockatrice/src/game/game_event_handler.h @@ -1,8 +1,16 @@ /** * @file game_event_handler.h * @ingroup GameLogic + * @brief Game-level command sender and event dispatcher. + * + * GameEventHandler sends commands initiated by the local client to the server + * and processes incoming game-wide events. It bridges the networking layer + * (protobuf events received via AbstractClient) with the game model and UI + * (GameState, PlayerManager, logging, widgets). + * + * Player-scoped events are forwarded to PlayerEventHandler instances, while + * spectator and global game events are handled directly here. */ -//! \todo Document this file. #ifndef COCKATRICE_GAME_EVENT_HANDLER_H #define COCKATRICE_GAME_EVENT_HANDLER_H @@ -15,92 +23,310 @@ #include class AbstractClient; -class Response; +class AbstractGame; +class CommandContainer; +class GameCommand; class GameEventContainer; class GameEventContext; -class GameCommand; -class GameState; -class MessageLogWidget; -class CommandContainer; -class Event_GameJoined; +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_GameStart; class Event_SetActivePlayer; class Event_SetActivePhase; -class Event_Ping; class Event_GameSay; class Event_Kicked; class Event_ReverseTurn; -class AbstractGame; -class PendingCommand; -class PlayerLogic; +class Event_Ping; inline Q_LOGGING_CATEGORY(GameEventHandlerLog, "game_event_handler"); +/** + * @class GameEventHandler + * @brief Central dispatcher for game-wide commands and events. + * + * This class owns no game state itself. Instead, it: + * - Sends commands to the server on behalf of local players + * - Receives and dispatches server-side game events + * - Updates the game model indirectly via Player, GameState, and PlayerManager + * - Emits high-level signals for UI updates and logging + */ class GameEventHandler : public QObject { Q_OBJECT private: + /** Pointer to the owning game instance. */ AbstractGame *game; public: + /** @name Construction + * Lifecycle and ownership. + * @{ + */ + + /** + * @brief Construct a GameEventHandler. + * + * The handler is owned by the AbstractGame instance and uses it to + * access the game state, players, and network clients. + * + * @param _game Owning game instance (also used as QObject parent). + */ explicit GameEventHandler(AbstractGame *_game); + /** @} */ + + /** @name Outgoing game commands + * Commands initiated locally and sent to the server. + * + * These methods construct and send protobuf commands corresponding + * to user actions in the UI. + * @{ + */ + + /** @brief Request advancing the game to the next turn. */ void handleNextTurn(); + + /** @brief Request reversing the current turn order. */ void handleReverseTurn(); + /** @brief Concede the game for the currently active local player. */ void handleActiveLocalPlayerConceded(); + + /** @brief Undo a previous concede for the active local player. */ void handleActiveLocalPlayerUnconceded(); + + /** + * @brief Set the active phase of the game. + * + * Typically triggered by the active player selecting a new phase. + * + * @param phase Phase identifier. + */ void handleActivePhaseChanged(int phase); + + /** @brief Leave the current game session. */ void handleGameLeft(); + + /** + * @brief Send a chat message to all players and spectators. + * + * @param chatMessage Message text. + */ void handleChatMessageSent(const QString &chatMessage); + + /** + * @brief Delete an existing arrow. + * + * @param arrowId Unique identifier of the arrow to delete. + */ void handleArrowDeletion(int creatorId, int arrowId); void handleArrowDeletionFinished(const Response &response, int creatorId, int arrowId); + /** @} */ + + /** @name Incoming event processing + * Entry points for server-sent events. + * @{ + */ + + /** + * @brief Process a container of game events received from the server. + * + * This is the main dispatch function for incoming game events. + * Events are routed to spectator handlers, game-level handlers, + * or forwarded to PlayerEventHandler instances as appropriate. + * + * @param cont Game event container from the server. + * @param client Client that received the container. + * @param options Processing flags (e.g. silent, replay). + */ + void + processGameEventContainer(const GameEventContainer &cont, AbstractClient *client, EventProcessingOptions options); + + /** @} */ + + /** @name Command preparation helpers + * Internal helpers for building command containers. + * @{ + */ + + /** + * @brief Wrap a single protobuf command in a PendingCommand. + * + * @param cmd Protobuf command message. + * @return Newly allocated PendingCommand (caller takes ownership). + */ + PendingCommand *prepareGameCommand(const ::google::protobuf::Message &cmd); + + /** + * @brief Wrap multiple protobuf commands in a single PendingCommand. + * + * Ownership of the messages in cmdList is transferred to the handler. + * + * @param cmdList List of protobuf command messages. + * @return Newly allocated PendingCommand. + */ + PendingCommand *prepareGameCommand(const QList &cmdList); + + /** @} */ + + /** @name Spectator event handlers + * Events originating from spectators. + * @{ + */ + + /** + * @brief Handle a spectator chat message. + */ void eventSpectatorSay(const Event_GameSay &event, int eventPlayerId, const GameEventContext &context); + + /** + * @brief Handle a spectator leaving the game. + */ void eventSpectatorLeave(const Event_Leave &event, int eventPlayerId, const GameEventContext &context); + /** @} */ + + /** @name Game state event handlers + * Events that affect global game state. + * @{ + */ + + /** + * @brief Handle a full game state update from the server. + * + * Used during game startup, reconnection, and resynchronization. + */ void eventGameStateChanged(const Event_GameStateChanged &event, int eventPlayerId, const GameEventContext &context); + + /** + * @brief Update card attachment relationships for all players. + * + * Called after a game state update to ensure attachments are resolved + * consistently across all zones. + */ void processCardAttachmentsForPlayers(const Event_GameStateChanged &event); + + /** @brief Handle a change in game host. */ + void eventGameHostChanged(const Event_GameHostChanged &event, int eventPlayerId, const GameEventContext &context); + + /** @brief Handle the game being closed by the server. */ + void eventGameClosed(const Event_GameClosed &event, int eventPlayerId, const GameEventContext &context); + + /** @brief Handle a change of the active player. */ + void eventSetActivePlayer(const Event_SetActivePlayer &event, int eventPlayerId, const GameEventContext &context); + + /** @brief Handle a change of the active phase. */ + void eventSetActivePhase(const Event_SetActivePhase &event, int eventPlayerId, const GameEventContext &context); + + /** @brief Handle a turn reversal event. */ + void eventReverseTurn(const Event_ReverseTurn &event, int eventPlayerId, const GameEventContext &context); + + /** @brief Handle ping / latency updates. */ + void eventPing(const Event_Ping &event, int eventPlayerId, const GameEventContext &context); + + /** @} */ + + /** @name Player lifecycle and property handlers + * Events related to players joining, leaving, or changing state. + * @{ + */ + + /** + * @brief Handle updates to a player's properties. + * + * Includes readiness, concede state, deck selection, sideboard lock, + * and connection state changes. + */ void eventPlayerPropertiesChanged(const Event_PlayerPropertiesChanged &event, int eventPlayerId, const GameEventContext &context); + + /** @brief Handle a player or spectator joining the game. */ void eventJoin(const Event_Join &event, int eventPlayerId, const GameEventContext &context); + + /** @brief Handle a player leaving the game. */ void eventLeave(const Event_Leave &event, int eventPlayerId, const GameEventContext &context); - QString getLeaveReason(Event_Leave::LeaveReason reason); + + /** @brief Handle the local player being kicked from the game. */ void eventKicked(const Event_Kicked &event, int eventPlayerId, const GameEventContext &context); - void eventGameHostChanged(const Event_GameHostChanged &event, int eventPlayerId, const GameEventContext &context); - void eventGameClosed(const Event_GameClosed &event, int eventPlayerId, const GameEventContext &context); - void eventSetActivePlayer(const Event_SetActivePlayer &event, int eventPlayerId, const GameEventContext &context); - void eventSetActivePhase(const Event_SetActivePhase &event, int eventPlayerId, const GameEventContext &context); - void eventPing(const Event_Ping &event, int eventPlayerId, const GameEventContext &context); - void eventReverseTurn(const Event_ReverseTurn &event, int eventPlayerId, const GameEventContext & /*context*/); + /** + * @brief Convert a leave reason enum to a human-readable string. + */ + QString getLeaveReason(Event_Leave::LeaveReason reason); - void commandFinished(const Response &response); + /** @} */ - void - processGameEventContainer(const GameEventContainer &cont, AbstractClient *client, EventProcessingOptions options); - PendingCommand *prepareGameCommand(const ::google::protobuf::Message &cmd); - PendingCommand *prepareGameCommand(const QList &cmdList); public slots: + /** @name Command dispatch slots + * Low-level command transmission. + * @{ + */ + + /** + * @brief Send a prepared PendingCommand. + * + * @param pend Pending command to send. + * @param playerId Player whose client should send the command. + */ void sendGameCommand(PendingCommand *pend, int playerId = -1); + + /** + * @brief Send a single protobuf command. + * + * @param command Protobuf command message. + * @param playerId Player whose client should send the command. + */ void sendGameCommand(const ::google::protobuf::Message &command, int playerId = -1); + /** + * @brief Called when a PendingCommand finishes execution. + * + * Used to detect server-side errors such as chat flood protection. + */ + void commandFinished(const Response &response); + + /** @} */ + signals: + /** @name Core state signals + * @{ + */ + void emitUserEvent(); + void containerProcessingStarted(GameEventContext context); + void containerProcessingDone(); + void gameFlooded(); + void setContextJudgeName(QString judgeName); + + /** @} */ + + /** @name Player and spectator signals + * @{ + */ + void addPlayerToAutoCompleteList(QString playerName); void localPlayerDeckSelected(PlayerLogic *localPlayer, int playerId, ServerInfo_Player playerInfo); void remotePlayerDeckSelected(QString deckList, int playerId, QString playerName); void remotePlayersDecksSelected(QVector>> opponentDecks); void localPlayerSideboardLocked(int playerId, bool sideboardLocked); void localPlayerReadyStateChanged(int playerId, bool ready); + + /** @} */ + + /** @name Game flow signals + * @{ + */ + void gameStopped(); void gameClosed(); void playerPropertiesChanged(const ServerInfo_PlayerProperties &prop, int playerId); @@ -109,11 +335,15 @@ signals: void playerKicked(); void spectatorJoined(const ServerInfo_PlayerProperties &spectatorInfo); void spectatorLeft(int leavingSpectatorId); - void gameFlooded(); - void containerProcessingStarted(GameEventContext context); - void setContextJudgeName(QString judgeName); - void containerProcessingDone(); void arrowDeleted(int creatorId, int arrowId); + + /** @} */ + + /** @name Logging signals + * Signals consumed by MessageLogWidget. + * @{ + */ + void logSpectatorSay(ServerInfo_User userInfo, QString message); void logSpectatorLeave(QString name, QString reason); void logGameStart(); @@ -132,6 +362,8 @@ signals: void logActivePhaseChanged(int activePhase); void logConcede(int playerId); void logUnconcede(int playerId); + + /** @} */ }; #endif // COCKATRICE_GAME_EVENT_HANDLER_H 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 df9eb8a1d..67c3295d6 100644 --- a/cockatrice/src/game/player/player_actions.cpp +++ b/cockatrice/src/game/player/player_actions.cpp @@ -27,6 +27,8 @@ #include #include #include +#include +#include #include #include #include @@ -67,7 +69,7 @@ void PlayerActions::playCard(CardItem *card, bool faceDown) const CardInfo &info = exactCard.getInfo(); int tableRow = info.getUiAttributes().tableRow; - bool playToStack = SettingsCache::instance().getPlayToStack(); + bool playToStack = SettingsCache::instance().userInterface().getPlayToStack(); QString currentZone = card->getZone()->getName(); if (!faceDown && currentZone == ZoneNames::STACK && tableRow == 3) { cmd.set_target_zone(ZoneNames::GRAVE); @@ -310,7 +312,7 @@ void PlayerActions::actDrawCard() void PlayerActions::actRequestMulliganDialog() { - int startSize = SettingsCache::instance().getStartingHandSize(); + int startSize = SettingsCache::instance().userInterface().getStartingHandSize(); int handSize = player->getHandZone()->getCards().size(); int deckSize = player->getDeckZone()->getCards().size() + handSize; @@ -326,7 +328,7 @@ void PlayerActions::actMulligan(int number) } doMulligan(number); - SettingsCache::instance().setStartingHandSize(number); + SettingsCache::instance().userInterface().setStartingHandSize(number); } void PlayerActions::actMulliganSameSize() @@ -930,13 +932,13 @@ void PlayerActions::setLastTokenInfo(CardInfoPtr cardInfo) return; } - lastTokenInfo = {.name = cardInfo->getName(), - .color = cardInfo->getColors().isEmpty() ? QString() : cardInfo->getColors().left(1).toLower(), - .pt = cardInfo->getPowTough(), - .annotation = SettingsCache::instance().getAnnotateTokens() ? cardInfo->getText() : "", - .destroy = true, - .providerId = - SettingsCache::instance().cardOverrides().getCardPreferenceOverride(cardInfo->getName())}; + lastTokenInfo = { + .name = cardInfo->getName(), + .color = cardInfo->getColors().isEmpty() ? QString() : cardInfo->getColors().left(1).toLower(), + .pt = cardInfo->getPowTough(), + .annotation = SettingsCache::instance().userInterface().getAnnotateTokens() ? cardInfo->getText() : "", + .destroy = true, + .providerId = SettingsCache::instance().cardOverrides().getCardPreferenceOverride(cardInfo->getName())}; lastTokenTableRow = TableZone::tableRowToGridY(cardInfo->getUiAttributes().tableRow); @@ -1169,7 +1171,7 @@ void PlayerActions::createCard(const CardItem *sourceCard, } cmd.set_pt(cardInfo->getPowTough().toStdString()); - if (SettingsCache::instance().getAnnotateTokens()) { + if (SettingsCache::instance().userInterface().getAnnotateTokens()) { cmd.set_annotation(cardInfo->getText().toStdString()); } else { cmd.set_annotation(""); @@ -1352,11 +1354,7 @@ void PlayerActions::actSetPT(QList selectedCards, const QString &pt) const auto oldpt = CardItem::parsePT(card->getPT()); int ptIter = 0; for (const auto &_item : ptList) { -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) if (_item.typeId() == QMetaType::Type::Int) { -#else - if (_item.type() == QVariant::Int) { -#endif int oldItem = ptIter < oldpt.size() ? oldpt.at(ptIter).toInt() : 0; newpt += '/' + QString::number(oldItem + _item.toInt()); } else { diff --git a/cockatrice/src/game/player/player_event_handler.cpp b/cockatrice/src/game/player/player_event_handler.cpp index bc48298f7..277b8b1d4 100644 --- a/cockatrice/src/game/player/player_event_handler.cpp +++ b/cockatrice/src/game/player/player_event_handler.cpp @@ -262,14 +262,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 +626,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 cfd82933f..300cacd08 100644 --- a/cockatrice/src/game/player/player_event_handler.h +++ b/cockatrice/src/game/player/player_event_handler.h @@ -1,11 +1,24 @@ /** * @file player_event_handler.h * @ingroup GameLogicPlayers + * @brief Player-scoped game event handler. + * + * PlayerEventHandler applies game events that affect a single Player’s + * board state, zones, cards, counters, arrows, and related UI/log output. + * + * It is invoked by GameEventHandler after basic routing and validation. + * Each instance is bound 1:1 to a Player and must never mutate state + * belonging to other players except where explicitly required by events + * (e.g. moving cards between players, attaching cards, arrows). + * + * This class is intentionally stateful and tightly coupled to Player, + * PlayerActions, and the board/zones implementation. It performs both + * model mutation and UI-side bookkeeping (zone views, arrows, menus). */ -//! \todo Document this file. #ifndef COCKATRICE_PLAYER_EVENT_HANDLER_H #define COCKATRICE_PLAYER_EVENT_HANDLER_H + #include "event_processing_options.h" #include @@ -16,6 +29,7 @@ class CardItem; class CardZoneLogic; class PlayerLogic; + class Event_AttachCard; class Event_ChangeZoneProperties; class Event_CreateArrow; @@ -37,11 +51,176 @@ class Event_SetCounter; class Event_Shuffle; class Event_GameLogNotice; +/** + * @class PlayerEventHandler + * @brief Applies player-specific game events and emits corresponding log signals. + * + * Design notes: + * - All event handlers assume events are authoritative and already validated + * by the server. + * - Most handlers mutate both logical state (CardItem, CardZoneLogic, counters) + * and visual/UI state (views, arrows, menus). + * - Logging signals are emitted *after* or *during* state mutation, depending + * on whether later mutations would invalidate log data. + */ class PlayerEventHandler : public QObject { - Q_OBJECT + +public: + /** + * @brief Construct a PlayerEventHandler bound to a Player. + * @param player Owning player instance. + */ + explicit PlayerEventHandler(PlayerLogic *player); + + /** @name Event dispatch + * @{ + */ + + /** + * @brief Dispatch a generic GameEvent to the appropriate handler. + * + * This is the single entry point used by GameEventHandler. It extracts + * the correct protobuf extension and forwards the event to a typed + * handler method. + * + * @param type Game event type enum. + * @param event Generic protobuf container. + * @param context Additional context (undo, judge, etc.). + * @param options Processing options (UI suppression, reveal behavior). + */ + void processGameEvent(GameEvent::GameEventType type, + const GameEvent &event, + const GameEventContext &context, + EventProcessingOptions options); + + /** @} */ + + /** @name Chat and randomization events + * @{ + */ + + /// Handle in-game chat messages from this player. + void eventGameSay(const Event_GameSay &event); + + /// Handle zone shuffle events (typically libraries). + void eventShuffle(const Event_Shuffle &event); + + /// Handle die roll events. + void eventRollDie(const Event_RollDie &event); + + /** @} */ + + /** @name Arrow and targeting events + * @{ + */ + + /// Create a visual arrow between cards or players. + void eventCreateArrow(const Event_CreateArrow &event); + + /// Delete an existing arrow. + void eventDeleteArrow(const Event_DeleteArrow &event); + + /** @} */ + + /** @name Token and card creation + * @{ + */ + + /// Create a token card in a target zone. + void eventCreateToken(const Event_CreateToken &event); + + /** @} */ + + /** @name Card attribute and counter updates + * @{ + */ + + /** + * @brief Set a card attribute (tapped, PT, annotation, etc.). + * + * May apply to a single card or all cards in a zone if no card ID + * is provided by the event. + */ + void + eventSetCardAttr(const Event_SetCardAttr &event, const GameEventContext &context, EventProcessingOptions options); + + /// Update a counter attached to a card. + void eventSetCardCounter(const Event_SetCardCounter &event); + + /// Create a player-level counter. + void eventCreateCounter(const Event_CreateCounter &event); + + /// Set a player-level counter value. + void eventSetCounter(const Event_SetCounter &event, EventProcessingOptions options); + + /// Delete a player-level counter. + void eventDelCounter(const Event_DelCounter &event); + + /** @} */ + + /** @name Zone-level operations + * @{ + */ + + /// Log a zone dump (e.g. reveal graveyard/library contents). + void eventDumpZone(const Event_DumpZone &event); + + /** + * @brief Move a card between zones and/or players. + * + * This is one of the most complex handlers: + * - Removes the card from the start zone + * - Updates card identity and ownership if needed + * - Handles attachments and arrows + * - Emits appropriate move or undo-draw logs + * - Inserts the card into the target zone + */ + void eventMoveCard(const Event_MoveCard &event, const GameEventContext &context); + + /// Flip a card face up or face down. + void eventFlipCard(const Event_FlipCard &event); + + /// Destroy a card and clean up attachments. + void eventDestroyCard(const Event_DestroyCard &event); + + /// Attach or detach a card to/from another card. + void eventAttachCard(const Event_AttachCard &event); + + /** @} */ + + /** @name Draw and reveal operations + * @{ + */ + + /// Draw one or more cards from the deck. + void eventDrawCards(const Event_DrawCards &event); + + /** + * @brief Reveal cards from a zone. + * + * Handles peeking, in-place top-card reveals, full reveal windows, + * and write-access granting. + */ + void eventRevealCards(const Event_RevealCards &event, EventProcessingOptions options); + + /** @} */ + + /** @name Zone configuration + * @{ + */ + + /// Update zone visibility and reveal behavior. + void eventChangeZoneProperties(const Event_ChangeZoneProperties &event); + + /** @} */ + + void eventGameLogNotice(const Event_GameLogNotice &event); signals: + /** @name Logging signals + * @{ + */ void logSay(PlayerLogic *player, QString message); void logShuffle(PlayerLogic *player, CardZoneLogic *zone, int start, int end); void logRollDie(PlayerLogic *player, int sides, const QList &rolls); @@ -82,40 +261,13 @@ signals: bool isLentToAnotherPlayer = false); void logAlwaysRevealTopCard(PlayerLogic *player, CardZoneLogic *zone, bool reveal); void logAlwaysLookAtTopCard(PlayerLogic *player, CardZoneLogic *zone, bool reveal); + /** @} */ + void cardZoneChanged(CardItem *card, bool sameZone); void requestCardMenuUpdate(const CardItem *card); -public: - PlayerEventHandler(PlayerLogic *player); - - void processGameEvent(GameEvent::GameEventType type, - const GameEvent &event, - const GameEventContext &context, - EventProcessingOptions options); - - void eventGameSay(const Event_GameSay &event); - void eventShuffle(const Event_Shuffle &event); - void eventRollDie(const Event_RollDie &event); - void eventCreateArrow(const Event_CreateArrow &event); - void eventDeleteArrow(const Event_DeleteArrow &event); - void eventCreateToken(const Event_CreateToken &event); - void - eventSetCardAttr(const Event_SetCardAttr &event, const GameEventContext &context, EventProcessingOptions options); - void eventSetCardCounter(const Event_SetCardCounter &event); - void eventCreateCounter(const Event_CreateCounter &event); - void eventSetCounter(const Event_SetCounter &event); - void eventDelCounter(const Event_DelCounter &event); - void eventDumpZone(const Event_DumpZone &event); - void eventMoveCard(const Event_MoveCard &event, const GameEventContext &context); - void eventFlipCard(const Event_FlipCard &event); - void eventDestroyCard(const Event_DestroyCard &event); - void eventAttachCard(const Event_AttachCard &event); - void eventDrawCards(const Event_DrawCards &event); - void eventRevealCards(const Event_RevealCards &event, EventProcessingOptions options); - void eventChangeZoneProperties(const Event_ChangeZoneProperties &event); - void eventGameLogNotice(const Event_GameLogNotice &event); - private: + /** Owning player instance. */ PlayerLogic *player; void setCardAttrHelper(const GameEventContext &context, diff --git a/cockatrice/src/game/player/player_logic.cpp b/cockatrice/src/game/player/player_logic.cpp index 485e2fc5c..143df5c57 100644 --- a/cockatrice/src/game/player/player_logic.cpp +++ b/cockatrice/src/game/player/player_logic.cpp @@ -175,7 +175,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 +258,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..6923b3afe 100644 --- a/cockatrice/src/game/player/player_logic.h +++ b/cockatrice/src/game/player/player_logic.h @@ -17,6 +17,7 @@ #include "../zones/table_zone_logic.h" #include "player_event_handler.h" #include "player_info.h" +#include "player_manager.h" #include #include @@ -72,6 +73,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 +229,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 +260,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..8486efbeb 100644 --- a/cockatrice/src/game/player/player_manager.cpp +++ b/cockatrice/src/game/player/player_manager.cpp @@ -75,6 +75,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..504e65396 100644 --- a/cockatrice/src/game/player/player_manager.h +++ b/cockatrice/src/game/player/player_manager.h @@ -100,6 +100,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 69f9d8b20..dcf3e9b9b 100644 --- a/cockatrice/src/game/replay.cpp +++ b/cockatrice/src/game/replay.cpp @@ -2,7 +2,7 @@ #include "../interface/widgets/tabs/tab_game.h" -Replay::Replay(QObject *_parent, GameReplay *_replay, bool isLocalGame) : AbstractGame(_parent) +Replay::Replay(QObject *_parent, const GameReplay *_replay, bool isLocalGame) : AbstractGame(_parent) { gameState = new GameState(this, 0, -1, isLocalGame, {}, false, false, -1, false); connect(gameMetaInfo, &GameMetaInfo::startedChanged, gameState, &GameState::onStartedChanged); diff --git a/cockatrice/src/game/replay.h b/cockatrice/src/game/replay.h index ecb3a10d0..1c269b273 100644 --- a/cockatrice/src/game/replay.h +++ b/cockatrice/src/game/replay.h @@ -15,7 +15,7 @@ class Replay : public AbstractGame Q_OBJECT public: - explicit Replay(QObject *_parent, GameReplay *_replay, bool isLocalGame); + explicit Replay(QObject *_parent, const GameReplay *_replay, bool isLocalGame); }; #endif // COCKATRICE_REPLAY_H diff --git a/cockatrice/src/game/zones/view_zone_logic.cpp b/cockatrice/src/game/zones/view_zone_logic.cpp index 8782a1762..2ef04284a 100644 --- a/cockatrice/src/game/zones/view_zone_logic.cpp +++ b/cockatrice/src/game/zones/view_zone_logic.cpp @@ -3,6 +3,7 @@ #include "../../client/settings/cache_settings.h" #include "../../game_graphics/board/card_item.h" +#include /** * @param _player the player that the cards are revealed to. * @param _origZone the zone the cards were revealed from. @@ -57,7 +58,7 @@ bool ZoneViewZoneLogic::prepareAddCard(int x) // autoclose check is done both here and in removeCard - if (cards.isEmpty() && !doInsert && SettingsCache::instance().getCloseEmptyCardView()) { + if (cards.isEmpty() && !doInsert && SettingsCache::instance().userInterface().getCloseEmptyCardView()) { emit closeView(); } @@ -144,7 +145,7 @@ void ZoneViewZoneLogic::removeCard(int position, bool toNewZone) // card gets dragged within the view. // Another autoclose check is done in prepareAddCard so that the view autocloses if the last card was moved to an // unrevealed portion of the same zone. - if (cards.isEmpty() && SettingsCache::instance().getCloseEmptyCardView() && toNewZone) { + if (cards.isEmpty() && SettingsCache::instance().userInterface().getCloseEmptyCardView() && toNewZone) { emit closeView(); return; } 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 026efd60d..2f43591ac 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_drag_item.cpp +++ b/cockatrice/src/game_graphics/board/abstract_card_drag_item.cpp @@ -7,6 +7,7 @@ #include #include #include +#include const QColor GHOST_MASK = QColor(255, 255, 255, 50); @@ -34,12 +35,13 @@ AbstractCardDragItem::AbstractCardDragItem(AbstractCardItem *_item, setCacheMode(DeviceCoordinateCache); - connect(&SettingsCache::instance(), &SettingsCache::roundCardCornersChanged, this, [this](bool _roundCardCorners) { - Q_UNUSED(_roundCardCorners); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::roundCardCornersChanged, this, + [this](bool _roundCardCorners) { + Q_UNUSED(_roundCardCorners); - prepareGeometryChange(); - update(); - }); + prepareGeometryChange(); + update(); + }); connect(item, &QObject::destroyed, this, &AbstractCardDragItem::deleteLater); } @@ -47,7 +49,8 @@ AbstractCardDragItem::AbstractCardDragItem(AbstractCardItem *_item, QPainterPath AbstractCardDragItem::shape() const { QPainterPath shape; - qreal cardCornerRadius = SettingsCache::instance().getRoundCardCorners() ? 0.05 * CardDimensions::WIDTH_F : 0.0; + qreal cardCornerRadius = + SettingsCache::instance().cardsDisplay().getRoundCardCorners() ? 0.05 * CardDimensions::WIDTH_F : 0.0; shape.addRoundedRect(boundingRect(), cardCornerRadius, cardCornerRadius); return shape; } diff --git a/cockatrice/src/game_graphics/board/abstract_card_item.cpp b/cockatrice/src/game_graphics/board/abstract_card_item.cpp index 86b3e27c8..3969b7d03 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.cpp +++ b/cockatrice/src/game_graphics/board/abstract_card_item.cpp @@ -1,6 +1,7 @@ #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 "../game_scene.h" #include "../z_values.h" @@ -12,6 +13,9 @@ #include #include #include +#include +#include +#include AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef, PlayerLogic *_owner, int _id) : ArrowTarget(_owner, parent), id(_id), cardRef(cardRef), tapped(false), facedown(false), tapAngle(0), @@ -21,15 +25,19 @@ AbstractCardItem::AbstractCardItem(QGraphicsItem *parent, const CardRef &cardRef setFlag(ItemIsSelectable); setCacheMode(DeviceCoordinateCache); - connect(&SettingsCache::instance(), &SettingsCache::displayCardNamesChanged, this, [this] { update(); }); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::displayCardNamesChanged, this, + [this] { update(); }); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, + [this] { update(); }); refreshCardInfo(); - connect(&SettingsCache::instance(), &SettingsCache::roundCardCornersChanged, this, [this](bool _roundCardCorners) { - Q_UNUSED(_roundCardCorners); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::roundCardCornersChanged, this, + [this](bool _roundCardCorners) { + Q_UNUSED(_roundCardCorners); - prepareGeometryChange(); - update(); - }); + prepareGeometryChange(); + update(); + }); } AbstractCardItem::~AbstractCardItem() @@ -45,7 +53,8 @@ QRectF AbstractCardItem::boundingRect() const QPainterPath AbstractCardItem::shape() const { QPainterPath shape; - qreal cardCornerRadius = SettingsCache::instance().getRoundCardCorners() ? 0.05 * CardDimensions::WIDTH_F : 0.0; + qreal cardCornerRadius = + SettingsCache::instance().cardsDisplay().getRoundCardCorners() ? 0.05 * CardDimensions::WIDTH_F : 0.0; shape.addRoundedRect(boundingRect(), cardCornerRadius, cardCornerRadius); return shape; } @@ -101,7 +110,7 @@ QSizeF AbstractCardItem::getTranslatedSize(QPainter *painter) const void AbstractCardItem::transformPainter(QPainter *painter, const QSizeF &translatedSize, int angle) { - const int MAX_FONT_SIZE = SettingsCache::instance().getMaxFontSize(); + const int MAX_FONT_SIZE = SettingsCache::instance().appearance().getMaxFontSize(); const int fontSize = std::max(9, MAX_FONT_SIZE); QRectF totalBoundingRect = painter->combinedTransform().mapRect(boundingRect()); @@ -151,7 +160,7 @@ void AbstractCardItem::paintPicture(QPainter *painter, const QSizeF &translatedS painter->drawPath(shape()); } - if (translatedPixmap.isNull() || SettingsCache::instance().getDisplayCardNames() || facedown) { + if (translatedPixmap.isNull() || SettingsCache::instance().cardsDisplay().getDisplayCardNames() || facedown) { painter->save(); transformPainter(painter, translatedSize, angle); painter->setPen(Qt::white); @@ -165,7 +174,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), @@ -234,7 +243,7 @@ void AbstractCardItem::setHovered(bool _hovered) isHovered = _hovered; setZValue(_hovered ? ZValues::HOVERED_CARD : realZValue); - setScale(_hovered && SettingsCache::instance().getScaleCards() ? 1.1 : 1); + setScale(_hovered && SettingsCache::instance().cardsDisplay().getScaleCards() ? 1.1 : 1); setTransformOriginPoint(_hovered ? CardDimensions::WIDTH_HALF_F : 0, _hovered ? CardDimensions::HEIGHT_HALF_F : 0); update(); } @@ -287,7 +296,7 @@ void AbstractCardItem::setTapped(bool _tapped, bool canAnimate) } tapped = _tapped; - if (SettingsCache::instance().getTapAnimation() && canAnimate) { + if (SettingsCache::instance().cardsDisplay().getTapAnimation() && canAnimate) { static_cast(scene())->registerAnimationItem(this); } else { tapAngle = tapped ? 90 : 0; @@ -299,6 +308,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..8cbe95282 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.h +++ b/cockatrice/src/game_graphics/board/abstract_card_item.h @@ -7,6 +7,7 @@ #ifndef ABSTRACTCARDITEM_H #define ABSTRACTCARDITEM_H +#include "../animated_item.h" #include "../card_dimensions.h" #include "arrow_target.h" #include "graphics_item_type.h" @@ -16,7 +17,7 @@ class PlayerLogic; -class AbstractCardItem : public ArrowTarget +class AbstractCardItem : public ArrowTarget, public IAnimatedItem { Q_OBJECT protected: @@ -126,6 +127,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 219dd456e..4ba04804f 100644 --- a/cockatrice/src/game_graphics/board/abstract_counter.cpp +++ b/cockatrice/src/game_graphics/board/abstract_counter.cpp @@ -1,6 +1,7 @@ #include "abstract_counter.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_graphics/board/translate_counter_name.h" @@ -28,8 +29,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(); }); @@ -227,3 +229,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..67b5b4074 100644 --- a/cockatrice/src/game_graphics/board/abstract_counter.h +++ b/cockatrice/src/game_graphics/board/abstract_counter.h @@ -35,6 +35,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/arrow_item.cpp b/cockatrice/src/game_graphics/board/arrow_item.cpp index af6a6bf36..664d44ecc 100644 --- a/cockatrice/src/game_graphics/board/arrow_item.cpp +++ b/cockatrice/src/game_graphics/board/arrow_item.cpp @@ -4,12 +4,14 @@ #include "../../client/settings/cache_settings.h" #include "../../game/player/player_actions.h" #include "../../game/player/player_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 +#include #include #include #include @@ -18,9 +20,27 @@ #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) { @@ -46,8 +66,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); } @@ -90,16 +125,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)); @@ -110,20 +150,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); @@ -132,8 +241,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) @@ -225,6 +392,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; } @@ -261,7 +434,7 @@ void ArrowDragItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) if (startZone->getName() == ZoneNames::HAND) { startCard->playCard(false); CardInfoPtr ci = startCard->getCard().getCardPtr(); - bool playToStack = SettingsCache::instance().getPlayToStack(); + bool playToStack = SettingsCache::instance().userInterface().getPlayToStack(); if (ci && ((!playToStack && ci->getUiAttributes().tableRow == 3) || (playToStack && ci->getUiAttributes().tableRow != 0 && startCard->getZone()->getName() != ZoneNames::STACK))) { @@ -348,6 +521,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..76a2d5d6c 100644 --- a/cockatrice/src/game_graphics/board/arrow_item.h +++ b/cockatrice/src/game_graphics/board/arrow_item.h @@ -2,9 +2,13 @@ #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 @@ -12,7 +16,7 @@ class CardItem; class QGraphicsSceneMouseEvent; class PlayerLogic; -class ArrowItem : public QObject, public QGraphicsItem +class ArrowItem : public QObject, public QGraphicsItem, public IAnimatedItem { Q_OBJECT Q_INTERFACES(QGraphicsItem) @@ -21,6 +25,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 +49,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 +134,4 @@ protected: void mouseReleaseEvent(QGraphicsSceneMouseEvent *event) override; }; -#endif \ No newline at end of file +#endif diff --git a/cockatrice/src/game_graphics/board/card_item.cpp b/cockatrice/src/game_graphics/board/card_item.cpp index cabe988c2..c2dc455cc 100644 --- a/cockatrice/src/game_graphics/board/card_item.cpp +++ b/cockatrice/src/game_graphics/board/card_item.cpp @@ -1,6 +1,7 @@ #include "card_item.h" #include "../../client/settings/cache_settings.h" +#include "../../client/settings/card_counter_settings.h" #include "../../game/phase.h" #include "../../game/player/player_actions.h" #include "../../game/player/player_logic.h" @@ -19,6 +20,7 @@ #include #include #include +#include CardItem::CardItem(PlayerLogic *_owner, QGraphicsItem *parent, @@ -279,7 +281,7 @@ void CardItem::drawArrow(const QColor &arrowColor) auto *game = owner->getGame(); PlayerLogic *arrowOwner = game->getPlayerManager()->getActiveLocalPlayer(game->getGameState()->getActivePlayer()); int phase = 0; // 0 means to not set the phase - if (SettingsCache::instance().getDoNotDeleteArrowsInSubPhases()) { + if (SettingsCache::instance().userInterface().getDoNotDeleteArrowsInSubPhases()) { int currentPhase = game->getGameState()->getCurrentPhase(); phase = Phases::getLastSubphase(currentPhase) + 1; } @@ -314,7 +316,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()) { @@ -398,7 +400,7 @@ void CardItem::playCard(bool faceDown) if (tz) { emit tz->toggleTapped(); } else { - if (SettingsCache::instance().getClickPlaysAllSelected()) { + if (SettingsCache::instance().userInterface().getClickPlaysAllSelected()) { if (faceDown) { emit playSelectedFaceDown(this); } else { @@ -462,7 +464,7 @@ static bool isUnwritableRevealZone(CardZoneLogic *zone) void CardItem::handleClickedToPlay(bool shiftHeld) { if (isUnwritableRevealZone(state->getZone())) { - if (SettingsCache::instance().getClickPlaysAllSelected()) { + if (SettingsCache::instance().userInterface().getClickPlaysAllSelected()) { emit hideSelected(this); } else { state->getZone()->removeCard(this); @@ -479,7 +481,7 @@ void CardItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) return; } if ((event->modifiers() != Qt::AltModifier) && (event->button() == Qt::LeftButton) && - (!SettingsCache::instance().getDoubleClickToPlay())) { + (!SettingsCache::instance().userInterface().getDoubleClickToPlay())) { handleClickedToPlay(event->modifiers().testFlag(Qt::ShiftModifier)); } if (owner != nullptr) { @@ -491,7 +493,7 @@ void CardItem::mouseReleaseEvent(QGraphicsSceneMouseEvent *event) void CardItem::mouseDoubleClickEvent(QGraphicsSceneMouseEvent *event) { if ((event->modifiers() != Qt::AltModifier) && (event->buttons() == Qt::LeftButton) && - (SettingsCache::instance().getDoubleClickToPlay())) { + (SettingsCache::instance().userInterface().getDoubleClickToPlay())) { handleClickedToPlay(event->modifiers().testFlag(Qt::ShiftModifier)); } event->accept(); diff --git a/cockatrice/src/game_graphics/board/card_item.h b/cockatrice/src/game_graphics/board/card_item.h index 37f3bab50..2ba43d03d 100644 --- a/cockatrice/src/game_graphics/board/card_item.h +++ b/cockatrice/src/game_graphics/board/card_item.h @@ -137,7 +137,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/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/deckview/deck_view.cpp b/cockatrice/src/game_graphics/deckview/deck_view.cpp index a5d0fa3bc..1acd02a75 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view.cpp @@ -10,7 +10,7 @@ #include #include #include -#include +#include DeckViewCardDragItem::DeckViewCardDragItem(DeckViewCard *_item, const QPointF &_hotSpot, @@ -77,11 +77,12 @@ DeckViewCard::DeckViewCard(QGraphicsItem *parent, const CardRef &cardRef, const { setAcceptHoverEvents(true); - connect(&SettingsCache::instance(), &SettingsCache::roundCardCornersChanged, this, [this](bool _roundCardCorners) { - Q_UNUSED(_roundCardCorners); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::roundCardCornersChanged, this, + [this](bool _roundCardCorners) { + Q_UNUSED(_roundCardCorners); - update(); - }); + update(); + }); } DeckViewCard::~DeckViewCard() @@ -99,7 +100,8 @@ void DeckViewCard::paint(QPainter *painter, const QStyleOptionGraphicsItem *opti pen.setJoinStyle(Qt::MiterJoin); pen.setColor(originZone == DECK_ZONE_MAIN ? Qt::green : Qt::red); painter->setPen(pen); - qreal cardRadius = SettingsCache::instance().getRoundCardCorners() ? 0.05 * (CardDimensions::WIDTH_F - 3) : 0.0; + qreal cardRadius = + SettingsCache::instance().cardsDisplay().getRoundCardCorners() ? 0.05 * (CardDimensions::WIDTH_F - 3) : 0.0; painter->drawRoundedRect(QRectF(1.5, 1.5, CardDimensions::WIDTH_F - 3, CardDimensions::HEIGHT_F - 3), cardRadius, cardRadius); painter->restore(); @@ -378,12 +380,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_container.cpp b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp index d476a5012..bc07ac183 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp @@ -1,6 +1,7 @@ #include "deck_view_container.h" #include "../../client/settings/cache_settings.h" +#include "../../client/settings/shortcuts_settings.h" #include "../../interface/card_picture_loader/card_picture_loader.h" #include "../../interface/deck_loader/deck_loader.h" #include "../../interface/widgets/dialogs/dlg_load_deck.h" @@ -8,17 +9,22 @@ #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 "deck_view.h" #include #include #include +#include #include #include +#include #include #include #include #include +#include +#include #include ToggleButton::ToggleButton(QWidget *parent) : QPushButton(parent), state(false) @@ -95,8 +101,11 @@ DeckViewContainer::DeckViewContainer(int _playerId, TabGame *parent) &DeckViewContainer::refreshShortcuts); refreshShortcuts(); - connect(&SettingsCache::instance(), &SettingsCache::visualDeckStorageInGameChanged, this, - &DeckViewContainer::setVisualDeckStorageExists); + connect(&SettingsCache::instance().visualDeckStorage(), &VisualDeckStorageSettings::visualDeckStorageInGameChanged, + this, &DeckViewContainer::setVisualDeckStorageExists); + + connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::playmatSettingsChanged, this, + &DeckViewContainer::onPlaymatSettingsChanged); switchToDeckSelectView(); } @@ -138,7 +147,7 @@ static void setVisibility(QPushButton *button, bool visible) void DeckViewContainer::switchToDeckSelectView() { - if (SettingsCache::instance().getVisualDeckStorageInGame()) { + if (SettingsCache::instance().visualDeckStorage().getVisualDeckStorageInGame()) { deckView->setHidden(true); tryCreateVisualDeckStorageWidget(); @@ -275,6 +284,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) { @@ -287,6 +298,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() @@ -377,6 +434,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); @@ -414,6 +475,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 ec024bace..b5317c39a 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.h +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.h @@ -57,6 +57,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); @@ -75,6 +78,7 @@ private slots: void sideboardLockButtonClicked(); void updateSideboardLockButtonText(); void refreshShortcuts(); + void onPlaymatSettingsChanged(); signals: void newCardAdded(AbstractCardItem *card); void notIdle(); @@ -87,6 +91,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/dialogs/dlg_create_token.cpp b/cockatrice/src/game_graphics/dialogs/dlg_create_token.cpp index 1a9dc59b2..b311d2ebd 100644 --- a/cockatrice/src/game_graphics/dialogs/dlg_create_token.cpp +++ b/cockatrice/src/game_graphics/dialogs/dlg_create_token.cpp @@ -20,6 +20,9 @@ #include #include #include +#include +#include +#include #include DlgCreateToken::DlgCreateToken(const QStringList &_predefinedTokens, QWidget *parent) @@ -186,7 +189,7 @@ void DlgCreateToken::tokenSelectionChanged(const QModelIndex ¤t, const QMo const QChar cardColor = cardInfo->getColorChar(); colorEdit->setCurrentIndex(colorEdit->findData(cardColor, Qt::UserRole, Qt::MatchFixedString)); ptEdit->setText(cardInfo->getPowTough()); - if (SettingsCache::instance().getAnnotateTokens()) { + if (SettingsCache::instance().userInterface().getAnnotateTokens()) { annotationEdit->setText(cardInfo->getText()); } } else { diff --git a/cockatrice/src/game_graphics/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index b9816a602..17af7618b 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -17,8 +17,8 @@ #include #include #include -#include #include +#include #include #include @@ -36,7 +36,7 @@ GameScene::GameScene(PhasesToolbar *_phasesToolbar, QObject *parent) { animationTimer = new QBasicTimer; addItem(phasesToolbar); - connect(&SettingsCache::instance(), &SettingsCache::minPlayersForMultiColumnLayoutChanged, this, + connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::minPlayersForMultiColumnLayoutChanged, this, &GameScene::rearrange); rearrange(); @@ -44,7 +44,30 @@ 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. + // If a PlayerTarget is destroyed before an ArrowItem pointing to it, + // ArrowItem::onTargetDestroyed fires and emits on the partially-destroyed + // GameScene, causing a segfault. + for (auto *item : items()) { + if (auto *arrow = qgraphicsitem_cast(item)) { + delete arrow; + } + } // DO NOT call clearViews() here // clearViews calls close() on the zoneViews, which sends signals; sending signals in destructors leads to segfaults @@ -198,7 +221,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(); } @@ -234,17 +262,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 ---------- @@ -324,7 +362,7 @@ QList GameScene::rotatePlayers(const QList &active int GameScene::determineColumnCount(int playerCount) { - return playerCount < SettingsCache::instance().getMinPlayersForMultiColumnLayout() ? 1 : 2; + return playerCount < SettingsCache::instance().userInterface().getMinPlayersForMultiColumnLayout() ? 1 : 2; } /** @@ -441,8 +479,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()); } @@ -484,6 +528,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); } @@ -529,7 +574,9 @@ void GameScene::clearArrowsForPlayer(int playerId) void GameScene::clearArrowsForPlayerLocally(int playerId) { for (int arrowId : arrowRegistry.idsForPlayer(playerId)) { - arrowRegistry.take(playerId, arrowId)->delArrow(); + if (auto *arrow = arrowRegistry.take(playerId, arrowId)) { + arrow->delArrow(); + } } } @@ -622,7 +669,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) { @@ -722,30 +772,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..859d7a6eb 100644 --- a/cockatrice/src/game_graphics/game_scene.h +++ b/cockatrice/src/game_graphics/game_scene.h @@ -4,13 +4,14 @@ #include "../game/arrow_registry.h" #include "../game/board/arrow_data.h" #include "../game/zones/card_zone_logic.h" +#include "animated_item.h" #include "board/arrow_item.h" #include +#include #include #include #include -#include inline Q_LOGGING_CATEGORY(GameSceneLog, "game_scene"); inline Q_LOGGING_CATEGORY(GameScenePlayerAdditionRemovalLog, "game_scene.player_addition_removal"); @@ -24,6 +25,7 @@ class CardItem; class ServerInfo_Card; class PhasesToolbar; class QBasicTimer; +class QObject; /** * @class GameScene @@ -50,9 +52,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 +188,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); diff --git a/cockatrice/src/game_graphics/game_view.cpp b/cockatrice/src/game_graphics/game_view.cpp index db17aaead..ed6355157 100644 --- a/cockatrice/src/game_graphics/game_view.cpp +++ b/cockatrice/src/game_graphics/game_view.cpp @@ -1,6 +1,7 @@ #include "game_view.h" #include "../client/settings/cache_settings.h" +#include "../client/settings/shortcuts_settings.h" #include "game_scene.h" #include @@ -9,6 +10,7 @@ #include #include #include +#include #include // QRubberBand calls raise() in showEvent() and changeEvent() to stay on top of siblings. @@ -45,9 +47,12 @@ GameView::GameView(GameScene *scene, QWidget *parent) : QGraphicsView(scene, par connect(scene, &GameScene::sigResizeRubberBand, this, &GameView::resizeRubberBand); connect(scene, &GameScene::sigStopRubberBand, this, &GameView::stopRubberBand); connect(scene, &QGraphicsScene::selectionChanged, this, [this]() { updateTotalSelectionCount(); }); + connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::tallyTypeChanged, this, + [this] { updateTotalSelectionCount(); }); - setFocusDisabled(SettingsCache::instance().getKeepGameChatFocus()); - connect(&SettingsCache::instance(), &SettingsCache::keepGameChatFocusChanged, this, &GameView::setFocusDisabled); + setFocusDisabled(SettingsCache::instance().userInterface().getKeepGameChatFocus()); + connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::keepGameChatFocusChanged, this, + &GameView::setFocusDisabled); aCloseMostRecentZoneView = new QAction(this); @@ -109,6 +114,7 @@ void GameView::startRubberBand(const QPointF &_selectionOrigin) } selectionOrigin = _selectionOrigin; + previousBandRect = QRect(); rubberBand->setGeometry(QRect(mapFromScene(selectionOrigin), QSize(0, 0))); rubberBand->show(); } @@ -123,9 +129,19 @@ void GameView::resizeRubberBand(const QPointF &cursorPoint, int selectedCount) QPoint cursor = cursorPoint.toPoint(); QRect rect = QRect(mapFromScene(selectionOrigin), cursor).normalized(); - rubberBand->setGeometry(rect); - if (!SettingsCache::instance().getShowDragSelectionCount()) { + 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(); return; } @@ -166,7 +182,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(); } @@ -234,7 +256,7 @@ void GameView::updateTotalSelectionCount(const QSize &viewSize) int count = scene()->selectedItems().count(); - if (!SettingsCache::instance().getShowTotalSelectionCount() || count <= 1) { + if (!SettingsCache::instance().userInterface().getShowTotalSelectionCount() || count <= 1) { totalCountLabel->hide(); } else { totalCountLabel->setText(QString::number(count)); @@ -246,13 +268,12 @@ void GameView::updateTotalSelectionCount(const QSize &viewSize) totalCountLabel->show(); } - TallyType tallyType = - SettingsCache::instance().getShowSubtypeSelectionTally() ? TallyType::Subtypes : TallyType::None; + TallyType tallyType = Tally::intToType(SettingsCache::instance().userInterface().getTallyType()); GameScene *gameScene = static_cast(scene()); QList entries = Tally::compute(gameScene->selectedCards(), tallyType); - if (entries.isEmpty() || count <= 1) { + if (entries.isEmpty()) { tallyContainer->hide(); cachedTallyRows.clear(); return; diff --git a/cockatrice/src/game_graphics/game_view.h b/cockatrice/src/game_graphics/game_view.h index 3f6b60dbc..a23655513 100644 --- a/cockatrice/src/game_graphics/game_view.h +++ b/cockatrice/src/game_graphics/game_view.h @@ -27,6 +27,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..8dcbcfdaa 100644 --- a/cockatrice/src/game_graphics/hand_counter.cpp +++ b/cockatrice/src/game_graphics/hand_counter.cpp @@ -1,5 +1,6 @@ #include "hand_counter.h" +#include "../interface/pixel_map_generator.h" #include "zones/card_zone.h" #include @@ -32,7 +33,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/player/menu/card_menu.cpp b/cockatrice/src/game_graphics/player/menu/card_menu.cpp index aa94c3be7..75ca1b28b 100644 --- a/cockatrice/src/game_graphics/player/menu/card_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/card_menu.cpp @@ -1,6 +1,7 @@ #include "card_menu.h" #include "../../../client/settings/card_counter_settings.h" +#include "../../../client/settings/shortcuts_settings.h" #include "../../../interface/widgets/tabs/tab_game.h" #include "../../board/card_item.h" #include "../../game/player/player_actions.h" diff --git a/cockatrice/src/game_graphics/player/menu/grave_menu.cpp b/cockatrice/src/game_graphics/player/menu/grave_menu.cpp index 698481f7a..6d9364579 100644 --- a/cockatrice/src/game_graphics/player/menu/grave_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/grave_menu.cpp @@ -1,5 +1,6 @@ #include "grave_menu.h" +#include "../../../client/settings/shortcuts_settings.h" #include "../../game/abstract_game.h" #include "../../game/player/player_actions.h" #include "../../game/player/player_logic.h" diff --git a/cockatrice/src/game_graphics/player/menu/move_menu.cpp b/cockatrice/src/game_graphics/player/menu/move_menu.cpp index 5b7209a9f..632e0ddda 100644 --- a/cockatrice/src/game_graphics/player/menu/move_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/move_menu.cpp @@ -1,5 +1,6 @@ #include "move_menu.h" +#include "../../../client/settings/shortcuts_settings.h" #include "../../game/player/player_actions.h" #include "../../game/player/player_logic.h" #include "../card_menu_action_type.h" diff --git a/cockatrice/src/game_graphics/player/menu/player_menu.cpp b/cockatrice/src/game_graphics/player/menu/player_menu.cpp index 17b791222..7c90211df 100644 --- a/cockatrice/src/game_graphics/player/menu/player_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/player_menu.cpp @@ -1,5 +1,6 @@ #include "player_menu.h" +#include "../../../client/settings/shortcuts_settings.h" #include "../../../game_graphics/zones/hand_zone.h" #include "../../../game_graphics/zones/pile_zone.h" #include "../../../game_graphics/zones/table_zone.h" @@ -44,6 +45,8 @@ PlayerMenu::PlayerMenu(PlayerGraphicsItem *_player) : QObject(_player), player(_ utilityMenu = nullptr; } + tallyMenu = addManagedMenu(); + if (player->getLogic()->getPlayerInfo()->getLocal()) { sayMenu = addManagedMenu(player); } else { diff --git a/cockatrice/src/game_graphics/player/menu/player_menu.h b/cockatrice/src/game_graphics/player/menu/player_menu.h index 62ba66df7..a6e89fab0 100644 --- a/cockatrice/src/game_graphics/player/menu/player_menu.h +++ b/cockatrice/src/game_graphics/player/menu/player_menu.h @@ -15,11 +15,13 @@ #include "rfg_menu.h" #include "say_menu.h" #include "sideboard_menu.h" +#include "tally_menu.h" #include "utility_menu.h" #include #include #include +#include class CardItem; class CardMenu; @@ -87,6 +89,7 @@ private: GraveyardMenu *graveMenu; RfgMenu *rfgMenu; UtilityMenu *utilityMenu; + TallyMenu *tallyMenu; SayMenu *sayMenu; CustomZoneMenu *customZonesMenu; diff --git a/cockatrice/src/game_graphics/player/menu/pt_menu.cpp b/cockatrice/src/game_graphics/player/menu/pt_menu.cpp index a01be9424..152461c18 100644 --- a/cockatrice/src/game_graphics/player/menu/pt_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/pt_menu.cpp @@ -1,5 +1,6 @@ #include "pt_menu.h" +#include "../../../client/settings/shortcuts_settings.h" #include "../../game/player/player_actions.h" #include "../../game/player/player_logic.h" #include "../player_graphics_item.h" diff --git a/cockatrice/src/game_graphics/player/menu/say_menu.cpp b/cockatrice/src/game_graphics/player/menu/say_menu.cpp index 336b70f0d..e7b0b3826 100644 --- a/cockatrice/src/game_graphics/player/menu/say_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/say_menu.cpp @@ -5,6 +5,7 @@ #include "../../game/player/player_logic.h" #include "../player_graphics_item.h" +#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/sideboard_menu.cpp b/cockatrice/src/game_graphics/player/menu/sideboard_menu.cpp index 0dd7894d2..6753f743e 100644 --- a/cockatrice/src/game_graphics/player/menu/sideboard_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/sideboard_menu.cpp @@ -1,5 +1,6 @@ #include "sideboard_menu.h" +#include "../../../client/settings/shortcuts_settings.h" #include "../../game/player/player_actions.h" #include "../../game/player/player_logic.h" #include "../player_graphics_item.h" diff --git a/cockatrice/src/game_graphics/player/menu/tally_menu.cpp b/cockatrice/src/game_graphics/player/menu/tally_menu.cpp new file mode 100644 index 000000000..08cb6cac9 --- /dev/null +++ b/cockatrice/src/game_graphics/player/menu/tally_menu.cpp @@ -0,0 +1,60 @@ +#include "tally_menu.h" + +#include "../../../client/settings/cache_settings.h" + +#include + +TallyMenu::TallyMenu() +{ + actionGroup = new QActionGroup(this); + actionGroup->setExclusive(true); + + 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(); +} + +QAction *TallyMenu::createTallyAction(TallyType tallyType) +{ + TallyType currentType = Tally::intToType(SettingsCache::instance().userInterface().getTallyType()); + + QAction *action = new QAction(this); + action->setCheckable(true); + action->setChecked(tallyType == currentType); + + connect(action, &QAction::triggered, &SettingsCache::instance().userInterface(), + [tallyType] { SettingsCache::instance().userInterface().setTallyType(static_cast(tallyType)); }); + + actionGroup->addAction(action); + + return action; +} + +void TallyMenu::setShortcutsActive() +{ + // no-op because we haven't decided if we're adding shortcuts for tally types +} + +void TallyMenu::setShortcutsInactive() +{ + // no-op because we haven't decided if we're adding shortcuts for tally types +} + +void TallyMenu::retranslateUi() +{ + setTitle(tr("Tally")); + + 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 new file mode 100644 index 000000000..11802fd20 --- /dev/null +++ b/cockatrice/src/game_graphics/player/menu/tally_menu.h @@ -0,0 +1,32 @@ +#ifndef COCKATRICE_TALLY_MENU_H +#define COCKATRICE_TALLY_MENU_H + +#include "../../../interface/widgets/menus/tearoff_menu.h" +#include "../../tally/tally.h" +#include "abstract_player_component.h" + +#include + +class TallyMenu : public TearOffMenu, public AbstractPlayerComponent +{ + Q_OBJECT + +public: + TallyMenu(); + + void setShortcutsActive() override; + void setShortcutsInactive() override; + void retranslateUi() override; + +private: + QActionGroup *actionGroup = nullptr; + + QAction *aTallyNone = nullptr; + QAction *aTallySubtypes = nullptr; + QAction *aTallyTotalPower = nullptr; + QAction *aTallyTotalToughness = nullptr; + + QAction *createTallyAction(TallyType tallyType); +}; + +#endif // COCKATRICE_TALLY_MENU_H diff --git a/cockatrice/src/game_graphics/player/menu/utility_menu.cpp b/cockatrice/src/game_graphics/player/menu/utility_menu.cpp index 61a822b21..a7ea38d9a 100644 --- a/cockatrice/src/game_graphics/player/menu/utility_menu.cpp +++ b/cockatrice/src/game_graphics/player/menu/utility_menu.cpp @@ -1,5 +1,6 @@ #include "utility_menu.h" +#include "../../../client/settings/shortcuts_settings.h" #include "../../../interface/deck_loader/deck_loader.h" #include "../../game/player/player_actions.h" #include "../../game/player/player_logic.h" diff --git a/cockatrice/src/game_graphics/player/player_graphics_item.cpp b/cockatrice/src/game_graphics/player/player_graphics_item.cpp index e0194abda..122ab83be 100644 --- a/cockatrice/src/game_graphics/player/player_graphics_item.cpp +++ b/cockatrice/src/game_graphics/player/player_graphics_item.cpp @@ -1,6 +1,10 @@ #include "player_graphics_item.h" #include "../../game/player/player_actions.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 "../../interface/widgets/tabs/tab_game.h" #include "../board/abstract_card_item.h" #include "../board/counter_general.h" @@ -13,12 +17,16 @@ #include "player_dialogs.h" #include +#include +#include +#include +#include PlayerGraphicsItem::PlayerGraphicsItem(PlayerLogic *_player) : player(_player) { - connect(&SettingsCache::instance(), &SettingsCache::horizontalHandChanged, this, + connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::horizontalHandChanged, this, &PlayerGraphicsItem::rearrangeZones); - connect(&SettingsCache::instance(), &SettingsCache::handJustificationChanged, this, + connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::handJustificationChanged, this, &PlayerGraphicsItem::rearrangeZones); connect(player, &PlayerLogic::rearrangeCounters, this, &PlayerGraphicsItem::rearrangeCounters); connect(player, &PlayerLogic::activeChanged, this, &PlayerGraphicsItem::onPlayerActiveChanged); @@ -27,6 +35,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); @@ -66,6 +78,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(); @@ -111,7 +126,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 = @@ -148,16 +162,67 @@ qreal PlayerGraphicsItem::getMinimumWidth() const { qreal result = tableZoneGraphicsItem->getMinimumWidth() + CardDimensions::HEIGHT_F + 15 + counterAreaWidth + stackZoneGraphicsItem->boundingRect().width(); - if (!SettingsCache::instance().getHorizontalHand()) { + if (!SettingsCache::instance().userInterface().getHorizontalHand()) { result += handZoneGraphicsItem->boundingRect().width(); } 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) @@ -165,7 +230,7 @@ void PlayerGraphicsItem::processSceneSizeChange(int newPlayerWidth) // Extend table (and hand, if horizontal) to accommodate the new player width. qreal tableWidth = newPlayerWidth - CardDimensions::HEIGHT_F - 15 - counterAreaWidth - stackZoneGraphicsItem->boundingRect().width(); - if (!SettingsCache::instance().getHorizontalHand()) { + if (!SettingsCache::instance().userInterface().getHorizontalHand()) { tableWidth -= handZoneGraphicsItem->boundingRect().width(); } @@ -187,6 +252,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); } @@ -233,7 +303,7 @@ void PlayerGraphicsItem::rearrangeCounters() void PlayerGraphicsItem::rearrangeZones() { auto base = QPointF(CardDimensions::HEIGHT_F + counterAreaWidth + 15, 0); - if (SettingsCache::instance().getHorizontalHand()) { + if (SettingsCache::instance().userInterface().getHorizontalHand()) { if (mirrored) { if (player->getHandZone()->contentsKnown()) { handVisible = true; @@ -284,7 +354,7 @@ void PlayerGraphicsItem::updateBoundingRect() { prepareGeometryChange(); qreal width = CardDimensions::HEIGHT_F + 15 + counterAreaWidth + stackZoneGraphicsItem->boundingRect().width(); - if (SettingsCache::instance().getHorizontalHand()) { + if (SettingsCache::instance().userInterface().getHorizontalHand()) { qreal handHeight = handVisible ? handZoneGraphicsItem->boundingRect().height() : 0; bRect = QRectF(0, 0, width + tableZoneGraphicsItem->boundingRect().width(), tableZoneGraphicsItem->boundingRect().height() + handHeight); @@ -297,3 +367,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..e5ae59a61 100644 --- a/cockatrice/src/game_graphics/player/player_graphics_item.h +++ b/cockatrice/src/game_graphics/player/player_graphics_item.h @@ -11,6 +11,7 @@ #include "../game_scene.h" #include +#include class HandZone; class PileZone; @@ -126,6 +127,7 @@ signals: void playerCountChanged(); void mirroredChanged(bool isMirrored); void cardInfoRequested(const CardRef &cardRef); + void playmatChanged(bool hasPlaymat); private: PlayerLogic *player; @@ -146,9 +148,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 6b1cf6cc6..a7ec2e4a9 100644 --- a/cockatrice/src/game_graphics/player/player_list_widget.cpp +++ b/cockatrice/src/game_graphics/player/player_list_widget.cpp @@ -25,11 +25,7 @@ bool PlayerListItemDelegate::editorEvent(QEvent *event, if ((event->type() == QEvent::MouseButtonPress) && index.isValid()) { auto *const mouseEvent = static_cast(event); if (mouseEvent->button() == Qt::RightButton) { -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) static_cast(parent())->showContextMenu(mouseEvent->globalPosition().toPoint(), index); -#else - static_cast(parent())->showContextMenu(mouseEvent->globalPos(), index); -#endif return true; } } @@ -57,13 +53,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); @@ -96,6 +92,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); @@ -180,6 +181,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..f2f0be5fd 100644 --- a/cockatrice/src/game_graphics/player/player_list_widget.h +++ b/cockatrice/src/game_graphics/player/player_list_widget.h @@ -66,6 +66,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..d6c28370d 100644 --- a/cockatrice/src/game_graphics/player/player_target.cpp +++ b/cockatrice/src/game_graphics/player/player_target.cpp @@ -1,8 +1,11 @@ #include "player_target.h" +#include "../../client/settings/cache_settings.h" #include "../../game/player/player_logic.h" #include "../../interface/pixel_map_generator.h" +#include "../game_scene.h" +#include #include #include #include @@ -21,17 +24,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 +55,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 +137,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..1d06c6274 100644 --- a/cockatrice/src/game_graphics/player/player_target.h +++ b/cockatrice/src/game_graphics/player/player_target.h @@ -7,21 +7,34 @@ #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 class PlayerLogic; -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 new file mode 100644 index 000000000..7e05c3fb1 --- /dev/null +++ b/cockatrice/src/game_graphics/tally/stats_tally.cpp @@ -0,0 +1,64 @@ +#include "stats_tally.h" + +#include "../board/card_item.h" + +#include +#include +#include + +static int sumPowers(const QList &cards) +{ + // calculate total power; + int total = 0; + for (auto card : cards) { + QVariantList parsed = CardItem::parsePT(card->getPT()); + if (!parsed.isEmpty()) { + int power = parsed.first().toInt(); // toInt will default to 0 if it's not an int + total += qMax(power, 0); + } + } + return total; +} + +QList StatsTally::computeTotalPower(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 = sumPowers(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 new file mode 100644 index 000000000..e499587eb --- /dev/null +++ b/cockatrice/src/game_graphics/tally/stats_tally.h @@ -0,0 +1,29 @@ +#ifndef COCKATRICE_STATS_TALLY_H +#define COCKATRICE_STATS_TALLY_H +#include "tally.h" + +/** + * @brief Extracts and tallies stats from selected cards. + */ +namespace StatsTally +{ + +/** + * @brief Sums the power 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 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 804443b15..2241eb3b2 100644 --- a/cockatrice/src/game_graphics/tally/subtype_tally.cpp +++ b/cockatrice/src/game_graphics/tally/subtype_tally.cpp @@ -67,7 +67,7 @@ QList countSubtypes(const QList &cards) // convert entries into TallyRows QList rows; - rows.reserve(entries.size()); // for backwards compatibility with Qt5 + rows.reserve(entries.size()); std::transform(entries.begin(), entries.end(), std::back_inserter(rows), [](const SubtypeEntry &e) { return TallyRow{e.name, QString::number(e.count)}; }); diff --git a/cockatrice/src/game_graphics/tally/tally.cpp b/cockatrice/src/game_graphics/tally/tally.cpp index 521417ae6..21806ee84 100644 --- a/cockatrice/src/game_graphics/tally/tally.cpp +++ b/cockatrice/src/game_graphics/tally/tally.cpp @@ -1,7 +1,17 @@ #include "tally.h" +#include "stats_tally.h" #include "subtype_tally.h" +TallyType Tally::intToType(int value) +{ + if (value < static_cast(TallyType::None) || value > static_cast(TallyType::MaxValue)) { + return TallyType::None; + } + + return static_cast(value); +} + QList Tally::compute(const QList &cards, const TallyType type) { switch (type) { @@ -9,6 +19,10 @@ QList Tally::compute(const QList &cards, const TallyType t return {}; case TallyType::Subtypes: 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 1ccb7939f..84c54918f 100644 --- a/cockatrice/src/game_graphics/tally/tally.h +++ b/cockatrice/src/game_graphics/tally/tally.h @@ -20,11 +20,22 @@ enum class TallyType { None, Subtypes, + TotalPower, + TotalToughness, + MaxValue = TotalToughness // sentinel value }; namespace Tally { +/** + * Safely converts an int into the corresponding TallyType. + * + * @param value The int value + * @return The TallyType. Returns TallyType::None if the value is not within range + */ +TallyType intToType(int value); + /** * @brief Analyzes the selected cards according to the tally type and builds the resulting tally rows. * This forwards the cards to the code for that tally type. diff --git a/cockatrice/src/game_graphics/zones/hand_zone.cpp b/cockatrice/src/game_graphics/zones/hand_zone.cpp index 5885e3630..1a8f7a910 100644 --- a/cockatrice/src/game_graphics/zones/hand_zone.cpp +++ b/cockatrice/src/game_graphics/zones/hand_zone.cpp @@ -9,6 +9,7 @@ #include #include +#include HandZone::HandZone(HandZoneLogic *_logic, int _zoneHeight, QGraphicsItem *parent) : SelectZone(_logic, parent), zoneHeight(_zoneHeight) @@ -33,14 +34,15 @@ void HandZone::handleDropEvent(const QList &dragItems, QPoint point = dropPoint + scenePos().toPoint(); int x = -1; - if (SettingsCache::instance().getHorizontalHand()) { + if (SettingsCache::instance().userInterface().getHorizontalHand()) { for (x = 0; x < getLogic()->getCards().size(); x++) { if (point.x() < static_cast(getLogic()->getCards().at(x))->scenePos().x()) { break; } } } else { - x = calcDropIndexFromY(dropPoint.y()); + bool sameZone = startZone == getLogic(); + x = calcDropIndexFromY(dropPoint.y(), !sameZone); } Command_MoveCard cmd; @@ -60,7 +62,7 @@ void HandZone::handleDropEvent(const QList &dragItems, QRectF HandZone::boundingRect() const { - if (SettingsCache::instance().getHorizontalHand()) { + if (SettingsCache::instance().userInterface().getHorizontalHand()) { return QRectF(0, 0, width, CardDimensions::HEIGHT_F + 10); } else { return QRectF(0, 0, CardDimensions::WIDTH_F * 1.5, zoneHeight); @@ -77,8 +79,8 @@ void HandZone::reorganizeCards() { if (!getLogic()->getCards().isEmpty()) { const int cardCount = getLogic()->getCards().size(); - if (SettingsCache::instance().getHorizontalHand()) { - bool leftJustified = SettingsCache::instance().getLeftJustified(); + if (SettingsCache::instance().userInterface().getHorizontalHand()) { + bool leftJustified = SettingsCache::instance().userInterface().getLeftJustified(); qreal cardWidth = getLogic()->getCards().at(0)->boundingRect().width(); const int xPadding = leftJustified ? cardWidth * 1.4 : 5; qreal totalWidth = @@ -126,7 +128,7 @@ void HandZone::sortHand(const QList &options) void HandZone::setWidth(qreal _width) { - if (SettingsCache::instance().getHorizontalHand()) { + if (SettingsCache::instance().userInterface().getHorizontalHand()) { prepareGeometryChange(); width = _width; reorganizeCards(); diff --git a/cockatrice/src/game_graphics/zones/pile_zone.cpp b/cockatrice/src/game_graphics/zones/pile_zone.cpp index 7bb0e695a..abdf262f4 100644 --- a/cockatrice/src/game_graphics/zones/pile_zone.cpp +++ b/cockatrice/src/game_graphics/zones/pile_zone.cpp @@ -1,5 +1,6 @@ #include "pile_zone.h" +#include "../../client/settings/cache_settings.h" #include "../../game/player/player_actions.h" #include "../../game/player/player_logic.h" #include "../../game/zones/pile_zone_logic.h" @@ -11,6 +12,7 @@ #include #include #include +#include PileZone::PileZone(PileZoneLogic *_logic, QGraphicsItem *parent) : CardZone(_logic, parent) { @@ -23,12 +25,13 @@ PileZone::PileZone(PileZoneLogic *_logic, QGraphicsItem *parent) : CardZone(_log .rotate(90) .translate(-CardDimensions::WIDTH_HALF_F, -CardDimensions::HEIGHT_HALF_F)); - connect(&SettingsCache::instance(), &SettingsCache::roundCardCornersChanged, this, [this](bool _roundCardCorners) { - Q_UNUSED(_roundCardCorners); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::roundCardCornersChanged, this, + [this](bool _roundCardCorners) { + Q_UNUSED(_roundCardCorners); - prepareGeometryChange(); - update(); - }); + prepareGeometryChange(); + update(); + }); } QRectF PileZone::boundingRect() const @@ -39,7 +42,8 @@ QRectF PileZone::boundingRect() const QPainterPath PileZone::shape() const { QPainterPath shape; - qreal cardCornerRadius = SettingsCache::instance().getRoundCardCorners() ? 0.05 * CardDimensions::WIDTH_F : 0.0; + qreal cardCornerRadius = + SettingsCache::instance().cardsDisplay().getRoundCardCorners() ? 0.05 * CardDimensions::WIDTH_F : 0.0; shape.addRoundedRect(boundingRect(), cardCornerRadius, cardCornerRadius); return shape; } diff --git a/cockatrice/src/game_graphics/zones/select_zone.cpp b/cockatrice/src/game_graphics/zones/select_zone.cpp index f2e720686..470c70fcf 100644 --- a/cockatrice/src/game_graphics/zones/select_zone.cpp +++ b/cockatrice/src/game_graphics/zones/select_zone.cpp @@ -7,10 +7,11 @@ #include #include #include +#include static qreal stackingOffset(qreal cardHeight) { - const qreal overlapPercent = SettingsCache::instance().getStackCardOverlapPercent(); + const qreal overlapPercent = SettingsCache::instance().cardsDisplay().getStackCardOverlapPercent(); return cardHeight * (100.0 - overlapPercent) / 100.0; } @@ -21,40 +22,32 @@ SelectZone::ZoneLayout SelectZone::computeZoneLayout(const StackLayoutParams &pa } qreal effectiveOffset = params.desiredOffset; if (params.cardCount > 1) { - qreal fitOffset; - if (params.totalHeight < params.cardHeight && params.minOffset > 0.0) { - // Zone is shorter than a card (e.g. minimized). Compress offsets so - // every card has at least minOffset pixels of its top visible. - fitOffset = (params.totalHeight - params.minOffset) / (params.cardCount - 1); - effectiveOffset = qMax(0.0, qMin(params.desiredOffset, fitOffset)); + qreal reservedForBottomCard; + if (params.allowBottomOverflow) { + // Allow the bottom card to partially overflow in tight zones, scaling the + // overflow allowance by sqrt(cardCount-1) so offsets decrease smoothly + // as cards are added rather than dropping by 1/(n-1) each time. + // The 0.75 ratio was tuned experimentally to balance card visibility vs. overflow. + constexpr qreal bottomCardZoneRatio = 0.75; + const qreal adjustedRatio = bottomCardZoneRatio / qSqrt(static_cast(params.cardCount - 1)); + reservedForBottomCard = qMin(params.cardHeight, params.totalHeight * adjustedRatio); } else { - qreal reservedForBottomCard; - if (params.allowBottomOverflow) { - // Allow the bottom card to partially overflow in tight zones, scaling the - // overflow allowance by sqrt(cardCount-1) so offsets decrease smoothly - // as cards are added rather than dropping by 1/(n-1) each time. - // The 0.75 ratio was tuned experimentally to balance card visibility vs. overflow. - constexpr qreal bottomCardZoneRatio = 0.75; - const qreal adjustedRatio = bottomCardZoneRatio / qSqrt(static_cast(params.cardCount - 1)); - reservedForBottomCard = qMin(params.cardHeight, params.totalHeight * adjustedRatio); - } else { - // No overflow: reserve full card height for the bottom card - reservedForBottomCard = params.cardHeight; - } - fitOffset = (params.totalHeight - reservedForBottomCard) / (params.cardCount - 1); + // No overflow: reserve full card height for the bottom card + reservedForBottomCard = params.cardHeight; + } + qreal fitOffset = (params.totalHeight - reservedForBottomCard) / (params.cardCount - 1); - if (!params.allowBottomOverflow) { - // Constrain offset so all card tops remain within zone bounds. - // With start=0, last card top at (cardCount-1) * effectiveOffset must be < totalHeight. - qreal maxOffsetForTops = params.totalHeight / (params.cardCount - 1); - fitOffset = qMin(fitOffset, maxOffsetForTops); - } + if (!params.allowBottomOverflow) { + // Constrain offset so all card tops remain within zone bounds. + // With start=0, last card top at (cardCount-1) * effectiveOffset must be < totalHeight. + qreal maxOffsetForTops = params.totalHeight / (params.cardCount - 1); + fitOffset = qMin(fitOffset, maxOffsetForTops); + } - // Apply minOffset only if it fits; otherwise compress further to keep all card tops visible. - effectiveOffset = qMin(params.desiredOffset, fitOffset); - if (fitOffset >= params.minOffset) { - effectiveOffset = qMax(params.minOffset, effectiveOffset); - } + // Apply minOffset only if it fits; otherwise compress further to keep all card tops visible. + effectiveOffset = qMin(params.desiredOffset, fitOffset); + if (fitOffset >= params.minOffset) { + effectiveOffset = qMax(params.minOffset, effectiveOffset); } } qreal stackHeight = (params.cardCount - 1) * effectiveOffset + params.cardHeight; @@ -90,7 +83,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()) { @@ -101,7 +94,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..b5d3ca37a 100644 --- a/cockatrice/src/game_graphics/zones/select_zone.h +++ b/cockatrice/src/game_graphics/zones/select_zone.h @@ -104,8 +104,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..ff62097c7 100644 --- a/cockatrice/src/game_graphics/zones/stack_zone.cpp +++ b/cockatrice/src/game_graphics/zones/stack_zone.cpp @@ -31,8 +31,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 +57,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..96b3f96a6 100644 --- a/cockatrice/src/game_graphics/zones/stack_zone.h +++ b/cockatrice/src/game_graphics/zones/stack_zone.h @@ -15,9 +15,13 @@ 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 e886f62e9..88e9abe6c 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.cpp +++ b/cockatrice/src/game_graphics/zones/table_zone.cpp @@ -8,6 +8,7 @@ #include "../board/arrow_item.h" #include "../board/card_drag_item.h" #include "../board/card_item.h" +#include "../game_scene.h" #include "../z_values.h" #include @@ -15,6 +16,7 @@ #include #include #include +#include #include const QColor TableZone::BACKGROUND_COLOR = QColor(100, 100, 100); @@ -28,7 +30,7 @@ TableZone::TableZone(TableZoneLogic *_logic, bool _mirrored, QGraphicsItem *pare connect(_logic, &TableZoneLogic::contentSizeChanged, this, &TableZone::resizeToContents); connect(_logic, &TableZoneLogic::toggleTapped, this, &TableZone::toggleTapped); connect(themeManager, &ThemeManager::themeChanged, this, &TableZone::updateBg); - connect(&SettingsCache::instance(), &SettingsCache::invertVerticalCoordinateChanged, this, + connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::invertVerticalCoordinateChanged, this, &TableZone::reorganizeCards); updateBg(); @@ -46,6 +48,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); @@ -59,26 +86,49 @@ void TableZone::setMirrored(bool isMirrored) bool TableZone::isInverted() const { - return ((mirrored && !SettingsCache::instance().getInvertVerticalCoordinate()) || - (!mirrored && SettingsCache::instance().getInvertVerticalCoordinate())); + return ((mirrored && !SettingsCache::instance().userInterface().getInvertVerticalCoordinate()) || + (!mirrored && SettingsCache::instance().userInterface().getInvertVerticalCoordinate())); } 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..92915a2ed 100644 --- a/cockatrice/src/game_graphics/zones/table_zone.h +++ b/cockatrice/src/game_graphics/zones/table_zone.h @@ -8,16 +8,19 @@ #define TABLEZONE_H #include "../../game/zones/table_zone_logic.h" +#include "../animated_item.h" #include "../board/abstract_card_item.h" #include "select_zone.h" +#include + /** * @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 +86,7 @@ private: */ bool active = false; bool mirrored = false; + bool playmatActive = false; [[nodiscard]] bool isInverted() const; @@ -92,6 +96,9 @@ private slots: */ void updateBg(); +public slots: + void onPlaymatChanged(bool active); + public slots: /** Reorganizes CardItems in the TableZone @@ -121,6 +128,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 +188,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 +211,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_widget.cpp b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp index 14537a826..c13b79fea 100644 --- a/cockatrice/src/game_graphics/zones/view_zone_widget.cpp +++ b/cockatrice/src/game_graphics/zones/view_zone_widget.cpp @@ -21,6 +21,7 @@ #include #include #include +#include namespace { @@ -61,11 +62,11 @@ 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); }); - if (SettingsCache::instance().getFocusCardViewSearchBar()) { + if (SettingsCache::instance().userInterface().getFocusCardViewSearchBar()) { this->setActive(true); searchEdit.setFocus(); } @@ -76,9 +77,9 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player, vbox->addItem(searchEditProxy); // hide search bar if chat autofocus setting is enabled, since typing into it will no longer work anyway - searchEditProxy->setVisible(!SettingsCache::instance().getKeepGameChatFocus()); - connect(&SettingsCache::instance(), &SettingsCache::keepGameChatFocusChanged, searchEditProxy, - [searchEditProxy](bool keepFocus) { searchEditProxy->setVisible(!keepFocus); }); + searchEditProxy->setVisible(!SettingsCache::instance().userInterface().getKeepGameChatFocus()); + connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::keepGameChatFocusChanged, + searchEditProxy, [searchEditProxy](bool keepFocus) { searchEditProxy->setVisible(!keepFocus); }); // top row QGraphicsLinearLayout *hTopRow = new QGraphicsLinearLayout(Qt::Horizontal); @@ -158,9 +159,9 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player, connect(&sortBySelector, static_cast(&QComboBox::currentIndexChanged), this, &ZoneViewWidget::processSortBy); connect(&pileViewCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &ZoneViewWidget::processSetPileView); - groupBySelector.setCurrentIndex(SettingsCache::instance().getZoneViewGroupByIndex()); - sortBySelector.setCurrentIndex(SettingsCache::instance().getZoneViewSortByIndex()); - pileViewCheckBox.setChecked(SettingsCache::instance().getZoneViewPileView()); + groupBySelector.setCurrentIndex(SettingsCache::instance().userInterface().getZoneViewGroupByIndex()); + sortBySelector.setCurrentIndex(SettingsCache::instance().userInterface().getZoneViewSortByIndex()); + pileViewCheckBox.setChecked(SettingsCache::instance().userInterface().getZoneViewPileView()); if (CardList::NoSort == static_cast(groupBySelector.currentData().toInt())) { pileViewCheckBox.setEnabled(false); @@ -190,7 +191,7 @@ ZoneViewWidget::ZoneViewWidget(PlayerLogic *_player, void ZoneViewWidget::processGroupBy(int index) { auto option = static_cast(groupBySelector.itemData(index).toInt()); - SettingsCache::instance().setZoneViewGroupByIndex(index); + SettingsCache::instance().userInterface().setZoneViewGroupByIndex(index); zone->setGroupBy(option); // disable pile view checkbox if we're not grouping by anything @@ -214,13 +215,13 @@ void ZoneViewWidget::processSortBy(int index) return; } - SettingsCache::instance().setZoneViewSortByIndex(index); + SettingsCache::instance().userInterface().setZoneViewSortByIndex(index); zone->setSortBy(option); } void ZoneViewWidget::processSetPileView(QT_STATE_CHANGED_T value) { - SettingsCache::instance().setZoneViewPileView(value); + SettingsCache::instance().userInterface().setZoneViewPileView(value); zone->setPileView(value); } @@ -477,7 +478,7 @@ static qreal rowsToHeight(int rows) **/ static qreal calcMaxInitialHeight() { - return rowsToHeight(SettingsCache::instance().getCardViewInitialRowsMax()); + return rowsToHeight(SettingsCache::instance().userInterface().getCardViewInitialRowsMax()); } /** @@ -548,7 +549,7 @@ void ZoneViewWidget::initStyleOption(QStyleOption *option) const { QStyleOptionTitleBar *titleBar = qstyleoption_cast(option); if (titleBar) { - titleBar->icon = QPixmap("theme:cockatrice"); + titleBar->icon = themePixmap(QStringLiteral("cockatrice")); } } @@ -559,7 +560,7 @@ void ZoneViewWidget::initStyleOption(QStyleOption *option) const void ZoneViewWidget::expandWindow() { qreal maxInitialHeight = calcMaxInitialHeight(); - qreal maxExpandedHeight = rowsToHeight(SettingsCache::instance().getCardViewExpandedRowsMax()); + qreal maxExpandedHeight = rowsToHeight(SettingsCache::instance().userInterface().getCardViewExpandedRowsMax()); qreal height = rect().height() - extraHeight - 10; qreal maxHeight = maximumHeight() - extraHeight - 10; diff --git a/cockatrice/src/interface/card_localization.h b/cockatrice/src/interface/card_localization.h new file mode 100644 index 000000000..0bfe4a764 --- /dev/null +++ b/cockatrice/src/interface/card_localization.h @@ -0,0 +1,59 @@ +#ifndef COCKATRICE_CARD_LOCALIZATION_H +#define COCKATRICE_CARD_LOCALIZATION_H + +#include "../client/settings/cache_settings.h" + +#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()); +} +} // 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 bd427a6c8..b8a54761a 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader.cpp @@ -1,11 +1,14 @@ #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 #include #include -#include +#include #include #include #include @@ -16,19 +19,29 @@ #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 +// wait at least this long before retrying a card whose picture failed to load +static constexpr int RETRY_FAILED_CARDS_SECS = 300; + CardPictureLoader::CardPictureLoader() : QObject(nullptr) { worker = new CardPictureLoaderWorker; - connect(&SettingsCache::instance(), &SettingsCache::picsPathChanged, this, &CardPictureLoader::picsPathChanged); - connect(&SettingsCache::instance(), &SettingsCache::picDownloadChanged, this, + connect(&SettingsCache::instance().paths(), &PathsSettings::picsPathChanged, this, + &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); statusBar = new CardPictureLoaderStatusBar(nullptr); @@ -53,7 +66,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."; @@ -74,7 +87,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."; @@ -96,7 +109,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."; @@ -129,7 +142,15 @@ void CardPictureLoader::getPixmap(QPixmap &pixmap, const ExactCard &card, QSize QPixmap bigPixmap; if (QPixmapCache::find(key, &bigPixmap)) { if (bigPixmap.isNull()) { - qCDebug(CardPictureLoaderLog) << "Cached pixmap for key" << key << "is NULL!"; + // 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()) { + getInstance().failedAt.remove(key); + QPixmapCache::remove(key); + getInstance().worker->enqueueImageLoad(card); + } return; } @@ -153,8 +174,10 @@ void CardPictureLoader::imageLoaded(const ExactCard &card, const QImage &image) QPixmap finalPixmap; if (image.isNull()) { + getInstance().failedAt.insert(card.getPixmapCacheKey(), QDateTime::currentDateTime()); qCDebug(CardPictureLoaderLog) << "Caching NULL pixmap for" << card.getName(); } else { + getInstance().failedAt.remove(card.getPixmapCacheKey()); if (card.getInfo().getUiAttributes().upsideDownArt) { #if (QT_VERSION >= QT_VERSION_CHECK(6, 9, 0)) QImage mirrorImage = image.flipped(Qt::Horizontal | Qt::Vertical); @@ -169,7 +192,8 @@ void CardPictureLoader::imageLoaded(const ExactCard &card, const QImage &image) QPixmapCache::insert(card.getPixmapCacheKey(), finalPixmap); - if (SettingsCache::instance().getCardPictureLoaderCacheMethod() == + if (static_cast( + SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) == CardPictureLoaderCacheMethod::CacheMethod::FILESYSTEM_CACHE) { saveCardImageToLocalStorage(card, finalPixmap); } @@ -177,21 +201,65 @@ void CardPictureLoader::imageLoaded(const ExactCard &card, const QImage &image) // imageLoaded should only be reached if the exactCard isn't already in cache. // (plus there's a deduplication mechanism in CardPictureLoaderWorker) // It should be safe to connect the CardInfo here without worrying about redundant connections. - connect(card.getCardPtr().data(), &QObject::destroyed, this, - [cacheKey = card.getPixmapCacheKey()] { QPixmapCache::remove(cacheKey); }); + connect(card.getCardPtr().data(), &QObject::destroyed, this, [cacheKey = card.getPixmapCacheKey()] { + QPixmapCache::remove(cacheKey); + getInstance().failedAt.remove(cacheKey); + }); 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; } - const QString picsRoot = SettingsCache::instance().getPicsPath(); - CardPictureLoaderLocalSchemes::NamingScheme scheme = - SettingsCache::instance().getLocalCardImageStorageNamingScheme(); + const QString picsRoot = SettingsCache::instance().paths().getPicsPath(); + CardPictureLoaderLocalSchemes::NamingScheme scheme = static_cast( + SettingsCache::instance().cacheStorage().getLocalCardImageStorageNamingScheme()); QString pattern; @@ -243,8 +311,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; } @@ -265,6 +334,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(); @@ -304,31 +489,11 @@ void CardPictureLoader::picsPathChanged() QPixmapCache::clear(); } -bool CardPictureLoader::hasCustomArt() +void CardPictureLoader::cardLangChanged() { - auto picsPath = SettingsCache::instance().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 0c114ae92..0a4934e6d 100644 --- a/cockatrice/src/interface/card_picture_loader/card_picture_loader.h +++ b/cockatrice/src/interface/card_picture_loader/card_picture_loader.h @@ -4,6 +4,8 @@ #include "card_picture_loader_status_bar.h" #include "card_picture_loader_worker.h" +#include +#include #include inline Q_LOGGING_CATEGORY(CardPictureLoaderLog, "card_picture_loader"); @@ -56,6 +58,7 @@ private: CardPictureLoaderWorker *worker; ///< Worker thread for async image loading CardPictureLoaderStatusBar *statusBar; ///< Status bar widget showing load progress + QHash failedAt; ///< Timestamp of the last failed load attempt per pixmap cache key public: /** @@ -94,10 +97,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. @@ -117,7 +127,9 @@ 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); private slots: /** @@ -131,6 +143,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 bb10d1c42..c82fca403 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 @@ -7,15 +7,16 @@ #include #include #include +#include static constexpr int REFRESH_INTERVAL_MS = 10 * 1000; CardPictureLoaderLocal::CardPictureLoaderLocal(QObject *parent) - : QObject(parent), picsPath(SettingsCache::instance().getPicsPath()), - customPicsPath(SettingsCache::instance().getCustomPicsPath()) + : QObject(parent), picsPath(SettingsCache::instance().paths().getPicsPath()), + customPicsPath(SettingsCache::instance().paths().getCustomPicsPath()) { // Hook up signals to settings - connect(&SettingsCache::instance(), &SettingsCache::picsPathChanged, this, + connect(&SettingsCache::instance().paths(), &PathsSettings::picsPathChanged, this, &CardPictureLoaderLocal::picsPathChanged); refreshIndex(); @@ -93,6 +94,10 @@ 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(); @@ -104,7 +109,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; } @@ -127,6 +133,6 @@ QImage CardPictureLoaderLocal::tryLoadCardImageFromDisk(const QString &setName, void CardPictureLoaderLocal::picsPathChanged() { - picsPath = SettingsCache::instance().getPicsPath(); - customPicsPath = SettingsCache::instance().getCustomPicsPath(); + picsPath = SettingsCache::instance().paths().getPicsPath(); + customPicsPath = SettingsCache::instance().paths().getCustomPicsPath(); } 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 2f51ba986..34092f361 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 @@ -1,6 +1,7 @@ #include "card_picture_loader_worker.h" #include "../../client/settings/cache_settings.h" +#include "card_picture_loader_cache_method.h" #include "card_picture_loader_local.h" #include "card_picture_loader_worker_work.h" @@ -9,13 +10,19 @@ #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 CardPictureLoaderWorker::CardPictureLoaderWorker() - : QObject(nullptr), picDownload(SettingsCache::instance().getPicDownload()), requestQuota(MAX_REQUESTS_PER_SEC) + : QObject(nullptr), picDownload(SettingsCache::instance().downloads().getPicDownload()), + requestQuota(MAX_REQUESTS_PER_SEC) { networkManager = new QNetworkAccessManager(this); // We need a timeout to ensure requests don't hang indefinitely in case of @@ -25,13 +32,14 @@ CardPictureLoaderWorker::CardPictureLoaderWorker() cache = new QNetworkDiskCache(this); cache->setCacheDirectory(SettingsCache::instance().getNetworkCachePath()); cache->setMaximumCacheSize(1024L * 1024L * - static_cast(SettingsCache::instance().getNetworkCacheSizeInMB())); + static_cast(SettingsCache::instance().cacheStorage().getNetworkCacheSizeInMB())); - connect(&SettingsCache::instance(), &SettingsCache::networkCacheSizeChanged, cache, [this](int newSizeInMB) { - if (cache) { - cache->setMaximumCacheSize(1024L * 1024L * static_cast(newSizeInMB)); - } - }); + connect(&SettingsCache::instance().cacheStorage(), &CacheStorageSettings::networkCacheSizeChanged, cache, + [this](int newSizeInMB) { + if (cache) { + cache->setMaximumCacheSize(1024L * 1024L * static_cast(newSizeInMB)); + } + }); networkManager->setCache(cache); @@ -39,7 +47,7 @@ CardPictureLoaderWorker::CardPictureLoaderWorker() // We can't use NoLessSafeRedirectPolicy because it is not applied with AlwaysCache networkManager->setRedirectPolicy(QNetworkRequest::ManualRedirectPolicy); - cacheFilePath = SettingsCache::instance().getRedirectCachePath() + REDIRECT_CACHE_FILENAME; + cacheFilePath = SettingsCache::instance().paths().getRedirectCachePath() + REDIRECT_CACHE_FILENAME; loadRedirectCache(); cleanStaleEntries(); @@ -72,11 +80,12 @@ void CardPictureLoaderWorker::queueRequest(const QUrl &url, CardPictureLoaderWor queueRequest(cachedRedirect, worker); return; } - if (SettingsCache::instance().getCardPictureLoaderCacheMethod() == + if (static_cast( + 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; } @@ -98,8 +107,13 @@ 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 && SettingsCache::instance().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 = static_cast( + SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) == + CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE && + (cache->metaData(url).isValid() || !picDownload); req.setAttribute(QNetworkRequest::CacheLoadControlAttribute, useNetworkCache ? QNetworkRequest::AlwaysCache : QNetworkRequest::AlwaysNetwork); @@ -115,6 +129,19 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture void CardPictureLoaderWorker::resetRequestQuota() { requestQuota = MAX_REQUESTS_PER_SEC; + + QDateTime now = QDateTime::currentDateTime(); + for (auto it = hostRequestQuota.begin(); it != hostRequestQuota.end(); ++it) { + if (!hostLast429.contains(it.key()) || now.msecsTo(hostLast429.value(it.key())) < -QUOTA_RECOVER_MS) { + it.value() = qMin(MAX_REQUESTS_PER_SEC, it.value() + 1); + } + } + + for (const auto &request : requestLoadQueue) { + const QString host = request.first.host(); + hostQuotaRemaining.insert(host, hostRequestQuota.value(host, MAX_REQUESTS_PER_SEC)); + } + processQueuedRequests(); } @@ -127,14 +154,26 @@ void CardPictureLoaderWorker::processQueuedRequests() bool CardPictureLoaderWorker::processSingleRequest() { - if (!requestLoadQueue.isEmpty()) { - auto request = requestLoadQueue.takeFirst(); - makeRequest(request.first, request.second); - return true; + 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); + if (allowance > 0) { + hostQuotaRemaining.insert(host, allowance - 1); + makeRequest(request.first, request.second); + requestLoadQueue.removeAt(i); + return true; + } } return false; } +void CardPictureLoaderWorker::onHostRateLimited(const QString &host) +{ + hostRequestQuota.insert(host, qMax(MIN_HOST_QUOTA, hostRequestQuota.value(host, MAX_REQUESTS_PER_SEC) / 2)); + hostLast429.insert(host, QDateTime::currentDateTime()); +} + void CardPictureLoaderWorker::enqueueImageLoad(const ExactCard &card) { // Send call through a connection to ensure the handling is run on the pictureLoader thread @@ -229,7 +268,7 @@ void CardPictureLoaderWorker::cleanStaleEntries() auto it = redirectCache.begin(); while (it != redirectCache.end()) { - if (it.value().second.addDays(SettingsCache::instance().getRedirectCacheTtl()) < now) { + if (it.value().second.addDays(SettingsCache::instance().cacheStorage().getRedirectCacheTtl()) < now) { it = redirectCache.erase(it); // Remove stale entry } else { ++it; 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 f927abde6..d1c519b7a 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 @@ -5,6 +5,8 @@ #include "card_picture_loader_worker_work.h" #include "card_picture_to_load.h" +#include +#include #include #include #include @@ -66,6 +68,12 @@ public: */ void queueRequest(const QUrl &url, CardPictureLoaderWorkerWork *worker); + /** + * @brief Handles a server returning HTTP 429 by reducing that host's request quota. + * @param host The host that returned 429 + */ + void onHostRateLimited(const QString &host); + /** @brief Clears the network cache and redirect cache. */ void clearNetworkCache(); @@ -110,8 +118,11 @@ 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 + int requestQuota; ///< Remaining requests allowed per second + QTimer requestTimer; ///< Timer to reset the request quota + QHash hostRequestQuota; ///< Sustained per-host request allowance + QHash hostQuotaRemaining; ///< Per-host allowance left in the current second + QHash hostLast429; ///< When each host was last rate limited CardPictureLoaderLocal *localLoader; ///< Loader for local images QSet currentlyLoading; ///< Deduplication: contains pixmapCacheKey currently being loaded 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 6bd57c3d5..66c56337c 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 @@ -8,8 +8,13 @@ #include #include #include +#include #include #include +#include + +ServerRateLimiter CardPictureLoaderWorkerWork::s_rateLimiter; +#include // Card back returned by gatherer when card is not found static const QStringList MD5_BLACKLIST = { @@ -19,7 +24,7 @@ static const QStringList MD5_BLACKLIST = { CardPictureLoaderWorkerWork::CardPictureLoaderWorkerWork(const CardPictureLoaderWorker *worker, const ExactCard &toLoad) : QObject(nullptr), cardToDownload(CardPictureToLoad(toLoad)), - picDownload(SettingsCache::instance().getPicDownload()) + picDownload(SettingsCache::instance().downloads().getPicDownload()) { // Hook up signals to the orchestrator connect(this, &CardPictureLoaderWorkerWork::requestImageDownload, worker, &CardPictureLoaderWorker::queueRequest); @@ -29,19 +34,48 @@ CardPictureLoaderWorkerWork::CardPictureLoaderWorkerWork(const CardPictureLoader connect(this, &CardPictureLoaderWorkerWork::imageLoaded, worker, &CardPictureLoaderWorker::handleImageLoaded); connect(this, &CardPictureLoaderWorkerWork::requestSucceeded, worker, &CardPictureLoaderWorker::imageRequestSucceeded); + connect(this, &CardPictureLoaderWorkerWork::rateLimited, worker, &CardPictureLoaderWorker::onHostRateLimited); // Hook up signals to settings - connect(&SettingsCache::instance(), SIGNAL(picDownloadChanged()), this, SLOT(picDownloadChanged())); + connect(&SettingsCache::instance().downloads(), SIGNAL(picDownloadChanged()), this, SLOT(picDownloadChanged())); startNextPicDownload(); } void CardPictureLoaderWorkerWork::startNextPicDownload() { + QDateTime now = QDateTime::currentDateTime(); + while (!cardToDownload.getCurrentUrl().isEmpty() && + s_rateLimiter.isRateLimited(QUrl(cardToDownload.getCurrentUrl()).host(), now)) { + QString host = QUrl(cardToDownload.getCurrentUrl()).host(); + if (s_rateLimiter.rounds(host) == 1) { + // First 429 round for this server: wait out the backoff and give it + // one more chance instead of immediately falling through to a worse + // source. A second 429 makes us fall through instead. + qCDebug(CardPictureLoaderWorkerWorkLog).nospace() + << "PictureLoader: [card: " << cardToDownload.getCard().getInfo().getCorrectedName() + << " set: " << cardToDownload.getSetName() << "]: Waiting out backoff for " << host << " to retry " + << cardToDownload.getCurrentUrl(); + scheduleDeferredRetry(); + return; + } + + // The server has already 429'd us at least twice, so further retries are + // unlikely to succeed: move on to the other configured sources. + qCDebug(CardPictureLoaderWorkerWorkLog).nospace() + << "PictureLoader: [card: " << cardToDownload.getCard().getInfo().getCorrectedName() + << " set: " << cardToDownload.getSetName() << "]: Skipping rate-limited URL " + << cardToDownload.getCurrentUrl() << " (server " << host << " still rate limiting)"; + if (!cardToDownload.nextUrl() && !cardToDownload.nextSet()) { + scheduleDeferredRetry(); + return; + } + } + QString picUrl = cardToDownload.getCurrentUrl(); if (picUrl.isEmpty()) { - picDownloadFailed(); + scheduleDeferredRetry(); } else { QUrl url(picUrl); qCDebug(CardPictureLoaderWorkerWorkLog).nospace() @@ -107,7 +141,41 @@ static bool imageIsBlackListed(const QByteArray &picData) void CardPictureLoaderWorkerWork::handleFailedReply(const QNetworkReply *reply) { if (reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 429) { - qCWarning(CardPictureLoaderWorkerWorkLog) << "Too many requests."; + QString host = reply->url().host(); + QDateTime now = QDateTime::currentDateTime(); + + qint64 retryAfterMs = 0; + const QByteArray retryAfterHeader = reply->rawHeader("Retry-After"); + if (!retryAfterHeader.isEmpty()) { + bool ok = false; + int seconds = retryAfterHeader.toInt(&ok); + if (ok && seconds > 0) { + retryAfterMs = static_cast(seconds) * 1000; + } else { + QDateTime retryAfterDate = + QDateTime::fromString(QString::fromLatin1(retryAfterHeader), Qt::RFC2822Date); + if (retryAfterDate.isValid()) { + retryAfterMs = qMax(0, now.msecsTo(retryAfterDate)); + } + } + } + + QDateTime backoffUntil = s_rateLimiter.on429(host, now, retryAfterMs); + emit rateLimited(host); + + if (s_rateLimiter.rounds(host) == 1) { + qCWarning(CardPictureLoaderWorkerWorkLog).nospace() + << "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(); + } else { + qCWarning(CardPictureLoaderWorkerWorkLog).nospace() + << "PictureLoader: [card: " << cardToDownload.getCard().getName() + << " set: " << cardToDownload.getSetName() << "]: Too many requests from " << host + << ", retry already attempted, falling through to other sources"; + picDownloadFailed(); + } } else { bool isFromCache = reply->attribute(QNetworkRequest::SourceIsFromCacheAttribute).toBool(); @@ -148,6 +216,9 @@ void CardPictureLoaderWorkerWork::handleSuccessfulReply(QNetworkReply *reply) return; } + // A non-redirect successful response means the server is not rate limiting us anymore. + s_rateLimiter.onSuccess(reply->url().host()); + // peek is used to keep the data in the buffer for use by QImageReader const QByteArray &picData = reply->peek(reply->size()); @@ -202,6 +273,42 @@ QImage CardPictureLoaderWorkerWork::tryLoadImageFromReply(QNetworkReply *reply) return imgReader.read(); } +void CardPictureLoaderWorkerWork::scheduleDeferredRetry() +{ + 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)) { + backoffUntil = s_rateLimiter.earliestDeadline(now); + } + + if (!backoffUntil.isValid()) { + qCWarning(CardPictureLoaderWorkerWorkLog).nospace() + << "PictureLoader: [card: " << cardToDownload.getCard().getInfo().getCorrectedName() + << " set: " << cardToDownload.getSetName() << "]: All URLs exhausted, no servers in backoff: BAILING OUT"; + concludeImageLoad(QImage()); + return; + } + + qint64 waitMs = qMax(0, now.msecsTo(backoffUntil)); + // Add some jitter to desynchronize concurrent retries and avoid a thundering herd. + waitMs += QRandomGenerator::global()->bounded(5000); + + qCDebug(CardPictureLoaderWorkerWorkLog).nospace() + << "PictureLoader: [card: " << cardToDownload.getCard().getInfo().getCorrectedName() + << " set: " << cardToDownload.getSetName() << "]: All URLs exhausted, scheduling deferred retry in " << waitMs + << "ms"; + + QTimer::singleShot(waitMs, this, [this] { + s_rateLimiter.clearExpired(QDateTime::currentDateTime()); + + cardToDownload.resetIndices(); + startNextPicDownload(); + }); +} + void CardPictureLoaderWorkerWork::concludeImageLoad(const QImage &image) { emit imageLoaded(cardToDownload.getCard(), image); @@ -210,5 +317,5 @@ void CardPictureLoaderWorkerWork::concludeImageLoad(const QImage &image) void CardPictureLoaderWorkerWork::picDownloadChanged() { - picDownload = SettingsCache::instance().getPicDownload(); + picDownload = SettingsCache::instance().downloads().getPicDownload(); } 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 cdffc1dff..1e56a4373 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 @@ -4,12 +4,15 @@ #include "card_picture_loader_worker.h" #include "card_picture_to_load.h" +#include #include #include #include #include +#include #include #include +#include inline Q_LOGGING_CATEGORY(CardPictureLoaderWorkerWorkLog, "card_picture_loader.worker"); @@ -50,6 +53,8 @@ public slots: private: bool picDownload; ///< Whether network downloading is enabled + static ServerRateLimiter s_rateLimiter; ///< Shared per-server 429 backoff state + /** @brief Starts downloading the next URL for this card. */ void startNextPicDownload(); @@ -77,6 +82,16 @@ 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(); @@ -100,6 +115,9 @@ signals: /** @brief Emitted when a URL has been redirected. */ void urlRedirected(const QUrl &originalUrl, const QUrl &redirectUrl); + /** @brief Emitted when a server returned HTTP 429. */ + void rateLimited(const QString &host); + /** @brief Emitted when a cached URL is invalid and must be removed. */ void cachedUrlInvalidated(const QUrl &url); }; 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 63846e5fd..7cb502e92 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 @@ -9,14 +9,17 @@ #include #include #include +#include +#include CardPictureToLoad::CardPictureToLoad(const ExactCard &_card) : card(_card), urlTemplates(SettingsCache::instance().downloads().getAllURLs()) { if (card) { sortedSets = extractSetsSorted(card); - // The first time called, nextSet will also populate the Urls for the first set. - nextSet(); + currentSetIndex = 0; + currentSet = sortedSets.first(); + populateSetUrls(); } } @@ -34,7 +37,7 @@ QList CardPictureToLoad::extractSetsSorted(const ExactCard &card) std::sort(sortedSets.begin(), sortedSets.end(), SetPriorityComparator()); // If the user hasn't disabled arts other than their personal preference... - if (!SettingsCache::instance().getOverrideAllCardArtWithPersonalPreference()) { + if (!SettingsCache::instance().cardsDisplay().getOverrideAllCardArtWithPersonalPreference()) { // If the pixmapCacheKey corresponds to a specific set, we have to try to load it first. qsizetype setIndex = sortedSets.indexOf(card.getPrinting().getSet()); if (setIndex > 0) { // we don't need to move the set if it's already first @@ -91,7 +94,8 @@ void CardPictureToLoad::populateSetUrls() } } - for (const QString &urlTemplate : urlTemplates) { + const QStringList orderedTemplates = urlTemplates; + for (const QString &urlTemplate : orderedTemplates) { QString transformedUrl = transformUrl(urlTemplate); if (!transformedUrl.isEmpty()) { @@ -99,15 +103,19 @@ void CardPictureToLoad::populateSetUrls() } } - /* Call nextUrl to make sure currentUrl is up-to-date - but we don't need the result here. */ - (void)nextUrl(); + currentUrlIndex = 0; + if (!currentSetUrls.isEmpty()) { + currentUrl = currentSetUrls.first(); + } else { + currentUrl = QString(); + } } bool CardPictureToLoad::nextSet() { - if (!sortedSets.isEmpty()) { - currentSet = sortedSets.takeFirst(); + currentSetIndex++; + if (currentSetIndex < sortedSets.size()) { + currentSet = sortedSets.at(currentSetIndex); populateSetUrls(); return true; } @@ -117,8 +125,9 @@ bool CardPictureToLoad::nextSet() bool CardPictureToLoad::nextUrl() { - if (!currentSetUrls.isEmpty()) { - currentUrl = currentSetUrls.takeFirst(); + currentUrlIndex++; + if (currentUrlIndex < currentSetUrls.size()) { + currentUrl = currentSetUrls.at(currentUrlIndex); return true; } currentUrl = QString(); @@ -134,6 +143,28 @@ QString CardPictureToLoad::getSetName() const } } +QString CardPictureToLoad::peekNextUrl() const +{ + int nextIndex = currentUrlIndex + 1; + if (nextIndex < currentSetUrls.size()) { + return currentSetUrls.at(nextIndex); + } + return QString(); +} + +void CardPictureToLoad::resetIndices() +{ + currentSetIndex = 0; + if (!sortedSets.isEmpty()) { + currentSet = sortedSets.first(); + populateSetUrls(); + } else { + currentSet = {}; + currentSetUrls.clear(); + currentUrl = QString(); + } +} + static int parse(const QString &urlTemplate, const QString &propType, const QString &cardName, @@ -252,8 +283,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 b57e57644..9e0e7449c 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 @@ -25,6 +25,8 @@ private: QList currentSetUrls; ///< URLs for the current set being attempted QString currentUrl; ///< Currently active URL to download CardSetPtr currentSet; ///< Currently active set + int currentSetIndex = 0; ///< Current position in sortedSets + int currentUrlIndex = 0; ///< Current position in currentSetUrls public: /** @@ -56,6 +58,9 @@ public: /** @return The short name of the current set, or empty string if no set. */ [[nodiscard]] QString getSetName() const; + /** @return The next URL in the current set's list without advancing, or empty if at end. */ + [[nodiscard]] QString peekNextUrl() const; + /** * @brief Transforms a URL template into a concrete URL for this card/set. * @param urlTemplate The URL template to transform @@ -88,6 +93,14 @@ public: */ void populateSetUrls(); + /** + * @brief Resets iteration indices to the beginning. + * + * Restarts URL/set iteration from the first set and first URL. + * Used for deferred retry after server backoff expires. + */ + void resetIndices(); + /** * @brief Extract all sets from the card and sort them by priority. * @param card The card to extract sets from 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..f29b4eed2 100644 --- a/cockatrice/src/interface/deck_loader/deck_loader.cpp +++ b/cockatrice/src/interface/deck_loader/deck_loader.cpp @@ -50,7 +50,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 +375,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 +428,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 +532,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 +562,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 +579,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 +604,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..be0df311d 100644 --- a/cockatrice/src/interface/deck_loader/deck_loader.h +++ b/cockatrice/src/interface/deck_loader/deck_loader.h @@ -131,7 +131,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 +159,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_connect_to_server.h b/cockatrice/src/interface/intents/contexts/context_connect_to_server.h new file mode 100644 index 000000000..c7c40b261 --- /dev/null +++ b/cockatrice/src/interface/intents/contexts/context_connect_to_server.h @@ -0,0 +1,14 @@ +#ifndef COCKATRICE_CONTEXT_CONNECT_TO_SERVER_H +#define COCKATRICE_CONTEXT_CONNECT_TO_SERVER_H + +#include + +struct ContextConnectToServer +{ + QString hostname; + QString port; + QString username; + QString password; +}; + +#endif // COCKATRICE_CONTEXT_CONNECT_TO_SERVER_H diff --git a/cockatrice/src/interface/intents/contexts/context_join_game.h b/cockatrice/src/interface/intents/contexts/context_join_game.h new file mode 100644 index 000000000..2e5a88ea2 --- /dev/null +++ b/cockatrice/src/interface/intents/contexts/context_join_game.h @@ -0,0 +1,12 @@ +#ifndef COCKATRICE_CONTEXT_JOIN_GAME_H +#define COCKATRICE_CONTEXT_JOIN_GAME_H +#include "context_join_room.h" + +struct ContextJoinGame +{ + ContextJoinRoom roomContext; + int gameId; + bool asSpectator = false; +}; + +#endif // COCKATRICE_CONTEXT_JOIN_GAME_H diff --git a/cockatrice/src/interface/intents/contexts/context_join_room.h b/cockatrice/src/interface/intents/contexts/context_join_room.h new file mode 100644 index 000000000..23ae05e81 --- /dev/null +++ b/cockatrice/src/interface/intents/contexts/context_join_room.h @@ -0,0 +1,14 @@ +#ifndef COCKATRICE_CONTEXT_JOIN_ROOM_H +#define COCKATRICE_CONTEXT_JOIN_ROOM_H + +#include "context_connect_to_server.h" + +#include + +struct ContextJoinRoom +{ + ContextConnectToServer serverContext; + int roomId; +}; + +#endif // COCKATRICE_CONTEXT_JOIN_ROOM_H diff --git a/cockatrice/src/interface/intents/intent.cpp b/cockatrice/src/interface/intents/intent.cpp new file mode 100644 index 000000000..c02a89f35 --- /dev/null +++ b/cockatrice/src/interface/intents/intent.cpp @@ -0,0 +1,48 @@ +#include "intent.h" + +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. + connect(this, &Intent::finished, this, &QObject::deleteLater); + connect(this, &Intent::failed, this, &QObject::deleteLater); +} + +Intent::~Intent() = default; + +void Intent::execute() +{ + if (checkPrecondition()) { + onPreconditionSatisfied(); + } else { + onPreconditionNotSatisfied(); + } +} + +void Intent::runDependency(Intent *dependency) +{ + dependency->setParent(this); + connect(dependency, &Intent::finished, this, [this]() { + // Re-check after dependency finishes + this->execute(); + }); + connect(dependency, &Intent::failed, this, &Intent::failed); + + dependency->execute(); +} + +void Intent::emitFinished() +{ + if (!completed) { + completed = true; + emit finished(); + } +} + +void Intent::emitFailed(const QString &reason) +{ + if (!completed) { + completed = true; + emit failed(reason); + } +} diff --git a/cockatrice/src/interface/intents/intent.h b/cockatrice/src/interface/intents/intent.h new file mode 100644 index 000000000..125900ecd --- /dev/null +++ b/cockatrice/src/interface/intents/intent.h @@ -0,0 +1,37 @@ +#ifndef COCKATRICE_INTENT_H +#define COCKATRICE_INTENT_H + +#include + +class Intent : public QObject +{ + Q_OBJECT + +public: + explicit Intent(QObject *parent = nullptr); + ~Intent() override; + + void execute(); + +signals: + void finished(); + void failed(QString reason); + +protected: + // --- Subclasses must implement these --- + virtual bool checkPrecondition() const = 0; + virtual void onPreconditionSatisfied() = 0; + virtual void onPreconditionNotSatisfied() = 0; + + // Helper to chain another intent + void runDependency(Intent *dependency); + + // Emit the outcome exactly once; ignore late signals after the intent is done. + void emitFinished(); + void emitFailed(const QString &reason); + +private: + bool completed = false; +}; + +#endif // COCKATRICE_INTENT_H diff --git a/cockatrice/src/interface/intents/intent_connect_to_server.cpp b/cockatrice/src/interface/intents/intent_connect_to_server.cpp new file mode 100644 index 000000000..1cccc5a23 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_connect_to_server.cpp @@ -0,0 +1,46 @@ +#include "intent_connect_to_server.h" + +#include "intent_disconnect_from_server.h" + +#include + +IntentConnectToServer::IntentConnectToServer(RemoteClient *_remoteClient, ContextConnectToServer *_context) + : Intent(), remoteClient(_remoteClient), context(_context) +{ +} + +bool IntentConnectToServer::checkPrecondition() const +{ + return remoteClient->getStatus() == ClientStatus::StatusDisconnected; +} + +void IntentConnectToServer::onPreconditionSatisfied() +{ + remoteClient->connectToServer(context->hostname, context->port.toUInt(), context->username, context->password); + connect(remoteClient, &RemoteClient::statusChanged, this, &IntentConnectToServer::onStatusChanged); + connect(remoteClient, &RemoteClient::socketError, this, &IntentConnectToServer::onSocketError); + connect( + remoteClient, &RemoteClient::loginError, this, + [this](Response::ResponseCode, const QString &reason, quint32, const QList &) { emitFailed(reason); }); + + QTimer::singleShot(15000, this, [this]() { + emitFailed(tr("Timed out while connecting to %1:%2").arg(context->hostname, context->port)); + }); +} + +void IntentConnectToServer::onPreconditionNotSatisfied() +{ + runDependency(new IntentDisconnectFromServer(remoteClient)); +} + +void IntentConnectToServer::onStatusChanged(ClientStatus status) +{ + if (status == ClientStatus::StatusLoggedIn) { + emitFinished(); + } +} + +void IntentConnectToServer::onSocketError(const QString &errorString) +{ + emitFailed(tr("Failed to connect to %1:%2: %3").arg(context->hostname, context->port, errorString)); +} diff --git a/cockatrice/src/interface/intents/intent_connect_to_server.h b/cockatrice/src/interface/intents/intent_connect_to_server.h new file mode 100644 index 000000000..eab4d1a21 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_connect_to_server.h @@ -0,0 +1,29 @@ +#ifndef COCKATRICE_INTENT_CONNECT_TO_SERVER_H +#define COCKATRICE_INTENT_CONNECT_TO_SERVER_H + +#include "contexts/context_connect_to_server.h" +#include "intent.h" +#include "remote_client.h" + +class IntentConnectToServer : public Intent +{ + Q_OBJECT + +public: + IntentConnectToServer(RemoteClient *_remoteClient, ContextConnectToServer *_context); + +protected: + bool checkPrecondition() const override; + void onPreconditionSatisfied() override; + void onPreconditionNotSatisfied() override; + +private: + RemoteClient *remoteClient; + ContextConnectToServer *context; + +private slots: + void onStatusChanged(ClientStatus status); + void onSocketError(const QString &errorString); +}; + +#endif // COCKATRICE_INTENT_CONNECT_TO_SERVER_H diff --git a/cockatrice/src/interface/intents/intent_disconnect_from_server.cpp b/cockatrice/src/interface/intents/intent_disconnect_from_server.cpp new file mode 100644 index 000000000..cb39d7bab --- /dev/null +++ b/cockatrice/src/interface/intents/intent_disconnect_from_server.cpp @@ -0,0 +1,29 @@ +#include "intent_disconnect_from_server.h" + +IntentDisconnectFromServer::IntentDisconnectFromServer(RemoteClient *_remoteClient) + : Intent(), remoteClient(_remoteClient) +{ +} + +bool IntentDisconnectFromServer::checkPrecondition() const +{ + return remoteClient->getStatus() == ClientStatus::StatusDisconnected; +} + +void IntentDisconnectFromServer::onPreconditionSatisfied() +{ + emitFinished(); +} + +void IntentDisconnectFromServer::onPreconditionNotSatisfied() +{ + connect(remoteClient, &RemoteClient::statusChanged, this, &IntentDisconnectFromServer::onStatusChanged); + remoteClient->disconnectFromServer(); +} + +void IntentDisconnectFromServer::onStatusChanged(ClientStatus status) +{ + if (status == ClientStatus::StatusDisconnected) { + emitFinished(); + } +} diff --git a/cockatrice/src/interface/intents/intent_disconnect_from_server.h b/cockatrice/src/interface/intents/intent_disconnect_from_server.h new file mode 100644 index 000000000..6e1dfd0c1 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_disconnect_from_server.h @@ -0,0 +1,26 @@ +#ifndef COCKATRICE_INTENT_DISCONNECT_FROM_SERVER_H +#define COCKATRICE_INTENT_DISCONNECT_FROM_SERVER_H + +#include "intent.h" +#include "remote_client.h" + +class IntentDisconnectFromServer : public Intent +{ + Q_OBJECT + +public: + IntentDisconnectFromServer(RemoteClient *_remoteClient); + +protected: + bool checkPrecondition() const override; + void onPreconditionSatisfied() override; + void onPreconditionNotSatisfied() override; + +private: + RemoteClient *remoteClient; + +private slots: + void onStatusChanged(ClientStatus status); +}; + +#endif // COCKATRICE_INTENT_DISCONNECT_FROM_SERVER_H diff --git a/cockatrice/src/interface/intents/intent_join_server_game.cpp b/cockatrice/src/interface/intents/intent_join_server_game.cpp new file mode 100644 index 000000000..205c4dc70 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_join_server_game.cpp @@ -0,0 +1,76 @@ +#include "intent_join_server_game.h" + +#include "../widgets/server/game_selector.h" +#include "../widgets/tabs/tab_room.h" +#include "../widgets/tabs/tab_supervisor.h" +#include "intent_join_server_room.h" + +#include + +IntentJoinServerGame::IntentJoinServerGame(TabSupervisor *_tabSupervisor, + RemoteClient *_remoteClient, + std::unique_ptr _context) + : Intent(), tabSupervisor(_tabSupervisor), remoteClient(_remoteClient), context(_context.release()) +{ +} + +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) { + return false; + } + if (QString::number(remoteClient->peerPort()) != context->roomContext.serverContext.port) { + return false; + } + + if (!tabSupervisor->getRoomTabs().contains(context->roomContext.roomId)) { + return false; + } + + return true; +} + +void IntentJoinServerGame::onPreconditionSatisfied() +{ + TabRoom *room = tabSupervisor->getRoomTabs().value(context->roomContext.roomId); + if (!tryJoinGame(room)) { + waitForGame(room); + } +} + +void IntentJoinServerGame::onPreconditionNotSatisfied() +{ + runDependency(new IntentJoinServerRoom(tabSupervisor, remoteClient, &context->roomContext)); +} + +bool IntentJoinServerGame::tryJoinGame(TabRoom *room) +{ + if (!room) { + return false; + } + + if (room->getGameSelector()->joinGameById(context->gameId, context->asSpectator)) { + emitFinished(); + return true; + } + + return false; +} + +void IntentJoinServerGame::waitForGame(TabRoom *room) +{ + connect(room, &TabRoom::gameListUpdated, this, [this]() { + TabRoom *updatedRoom = tabSupervisor->getRoomTabs().value(context->roomContext.roomId); + if (updatedRoom) { + tryJoinGame(updatedRoom); + } + }); + + QTimer::singleShot(15000, this, [this]() { emitFailed(tr("Game %1 not found in the room").arg(context->gameId)); }); +} diff --git a/cockatrice/src/interface/intents/intent_join_server_game.h b/cockatrice/src/interface/intents/intent_join_server_game.h new file mode 100644 index 000000000..5e196df38 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_join_server_game.h @@ -0,0 +1,37 @@ +#ifndef COCKATRICE_INTENT_JOIN_SERVER_GAME_H +#define COCKATRICE_INTENT_JOIN_SERVER_GAME_H + +#include "contexts/context_join_game.h" +#include "intent.h" +#include "remote_client.h" + +#include +#include + +class TabRoom; +class TabSupervisor; + +class IntentJoinServerGame : public Intent +{ + Q_OBJECT + +public: + IntentJoinServerGame(TabSupervisor *_tabSupervisor, + RemoteClient *_remoteClient, + std::unique_ptr _context); + +protected: + bool checkPrecondition() const override; + void onPreconditionSatisfied() override; + void onPreconditionNotSatisfied() override; + +private: + bool tryJoinGame(TabRoom *room); + void waitForGame(TabRoom *room); + + TabSupervisor *tabSupervisor; + RemoteClient *remoteClient; + QScopedPointer context; +}; + +#endif // COCKATRICE_INTENT_JOIN_SERVER_GAME_H diff --git a/cockatrice/src/interface/intents/intent_join_server_room.cpp b/cockatrice/src/interface/intents/intent_join_server_room.cpp new file mode 100644 index 000000000..d25bc8d17 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_join_server_room.cpp @@ -0,0 +1,74 @@ +#include "intent_join_server_room.h" + +#include "../widgets/tabs/tab_room.h" +#include "../widgets/tabs/tab_server.h" +#include "../widgets/tabs/tab_supervisor.h" +#include "intent_connect_to_server.h" + +#include +#include + +IntentJoinServerRoom::IntentJoinServerRoom(TabSupervisor *_tabSupervisor, + RemoteClient *_remoteClient, + ContextJoinRoom *_context) + : Intent(), tabSupervisor(_tabSupervisor), remoteClient(_remoteClient), context(_context) +{ +} + +bool IntentJoinServerRoom::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 IntentJoinServerRoom::onPreconditionSatisfied() +{ + if (tabSupervisor->getRoomTabs().contains(context->roomId)) { + tabSupervisor->setCurrentWidget(tabSupervisor->getRoomTabs().value(context->roomId)); + emitFinished(); + return; + } + + TabServer *tabServer = tabSupervisor->getTabServer(); + if (!tabServer) { + tabSupervisor->openTabServer(); + tabServer = tabSupervisor->getTabServer(); + } + if (!tabServer) { + emitFailed(tr("No server tab available")); + return; + } + + const int roomId = context->roomId; + tabServer->joinRoom(roomId, true); + connect(tabServer, &TabServer::roomJoined, this, [this, roomId](const ServerInfo_Room &info, bool) { + if (info.room_id() == roomId) { + emitFinished(); + } + }); + connect(tabServer, &TabServer::roomJoinFailed, this, [this, roomId](int failedRoomId) { + if (failedRoomId == roomId) { + emitFailed(tr("Failed to join the server room %1").arg(roomId)); + } + }); + + QTimer::singleShot(15000, this, + [this, roomId]() { emitFailed(tr("Timed out while joining the server room %1").arg(roomId)); }); +} + +void IntentJoinServerRoom::onPreconditionNotSatisfied() +{ + runDependency(new IntentConnectToServer(remoteClient, &context->serverContext)); +} diff --git a/cockatrice/src/interface/intents/intent_join_server_room.h b/cockatrice/src/interface/intents/intent_join_server_room.h new file mode 100644 index 000000000..4a5599896 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_join_server_room.h @@ -0,0 +1,28 @@ +#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" + +class TabSupervisor; + +class IntentJoinServerRoom : public Intent +{ + Q_OBJECT + +public: + IntentJoinServerRoom(TabSupervisor *_tabSupervisor, RemoteClient *_remoteClient, ContextJoinRoom *_context); + +protected: + bool checkPrecondition() const override; + void onPreconditionSatisfied() override; + void onPreconditionNotSatisfied() override; + +private: + TabSupervisor *tabSupervisor; + RemoteClient *remoteClient; + ContextJoinRoom *context; +}; + +#endif // COCKATRICE_INTENT_JOIN_SERVER_ROOM_H diff --git a/cockatrice/src/interface/intents/intent_login.cpp b/cockatrice/src/interface/intents/intent_login.cpp new file mode 100644 index 000000000..ff871fd03 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_login.cpp @@ -0,0 +1,33 @@ +#include "intent_login.h" + +#include "../../client/settings/cache_settings.h" +#include "libcockatrice/settings/servers_settings.h" + +IntentGetLoginCredentials::IntentGetLoginCredentials(ContextConnectToServer *_context) : Intent(), context(_context) +{ +} + +bool IntentGetLoginCredentials::checkPrecondition() const +{ + ServersSettings &servers = SettingsCache::instance().servers(); + return servers.hasLoginData(context->hostname, context->port); +} + +void IntentGetLoginCredentials::onPreconditionSatisfied() +{ + ServersSettings &servers = SettingsCache::instance().servers(); + const int index = servers.findServerIndex(context->hostname, context->port); + + if (index >= 0) { + context->username = servers.getValue(QString("username%1").arg(index), "server", "server_details").toString(); + context->password = servers.getValue(QString("password%1").arg(index), "server", "server_details").toString(); + emitFinished(); + } else { + emitFailed(tr("No saved credentials for this server")); + } +} + +void IntentGetLoginCredentials::onPreconditionNotSatisfied() +{ + emitFailed(tr("No saved credentials for this server")); +} diff --git a/cockatrice/src/interface/intents/intent_login.h b/cockatrice/src/interface/intents/intent_login.h new file mode 100644 index 000000000..c7fec92b7 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_login.h @@ -0,0 +1,23 @@ +#ifndef COCKATRICE_INTENT_LOGIN_H +#define COCKATRICE_INTENT_LOGIN_H + +#include "contexts/context_connect_to_server.h" +#include "intent.h" + +class IntentGetLoginCredentials : public Intent +{ + Q_OBJECT + +public: + IntentGetLoginCredentials(ContextConnectToServer *_context); + +protected: + bool checkPrecondition() const override; + void onPreconditionSatisfied() override; + void onPreconditionNotSatisfied() override; + +private: + ContextConnectToServer *context; +}; + +#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 new file mode 100644 index 000000000..2457bec72 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_open_local_deck.cpp @@ -0,0 +1,34 @@ +#include "intent_open_local_deck.h" + +#include "../deck_loader/deck_file_format.h" +#include "../deck_loader/deck_loader.h" +#include "../widgets/tabs/tab_supervisor.h" +#include "intent_wait_for_database_load.h" + +#include + +IntentOpenLocalDeck::IntentOpenLocalDeck(TabSupervisor *_tabSupervisor, const QString &_file) + : Intent(), tabSupervisor(_tabSupervisor), file(_file) +{ +} + +bool IntentOpenLocalDeck::checkPrecondition() const +{ + return CardDatabaseManager::getInstance()->getLoadStatus() == LoadStatus::Ok; +} + +void IntentOpenLocalDeck::onPreconditionSatisfied() +{ + std::optional deckOpt = DeckLoader::loadFromFile(file, DeckFileFormat::getFormatFromName(file), true); + if (deckOpt) { + tabSupervisor->openDeckInNewTab(deckOpt.value()); + emitFinished(); + } else { + emitFailed(tr("Unable to load deck file %1").arg(file)); + } +} + +void IntentOpenLocalDeck::onPreconditionNotSatisfied() +{ + runDependency(new IntentWaitForDatabaseLoad); +} diff --git a/cockatrice/src/interface/intents/intent_open_local_deck.h b/cockatrice/src/interface/intents/intent_open_local_deck.h new file mode 100644 index 000000000..97f875e39 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_open_local_deck.h @@ -0,0 +1,27 @@ +#ifndef COCKATRICE_INTENT_OPEN_LOCAL_DECK_H +#define COCKATRICE_INTENT_OPEN_LOCAL_DECK_H + +#include "intent.h" + +#include + +class TabSupervisor; + +class IntentOpenLocalDeck : public Intent +{ + Q_OBJECT + +public: + IntentOpenLocalDeck(TabSupervisor *_tabSupervisor, const QString &_file); + +protected: + bool checkPrecondition() const override; + void onPreconditionSatisfied() override; + void onPreconditionNotSatisfied() override; + +private: + TabSupervisor *tabSupervisor; + QString file; +}; + +#endif // COCKATRICE_INTENT_OPEN_LOCAL_DECK_H 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..d50f509a0 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_open_server_room_by_name.cpp @@ -0,0 +1,183 @@ +#include "intent_open_server_room_by_name.h" + +#include "../widgets/tabs/tab_room.h" +#include "../widgets/tabs/tab_supervisor.h" +#include "intent_connect_to_server.h" + +#include +#include +#include +#include + +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..2f9e716af --- /dev/null +++ b/cockatrice/src/interface/intents/intent_open_server_room_by_name.h @@ -0,0 +1,61 @@ +#ifndef COCKATRICE_INTENT_OPEN_SERVER_ROOM_BY_NAME_H +#define COCKATRICE_INTENT_OPEN_SERVER_ROOM_BY_NAME_H + +#include "contexts/context_join_room.h" +#include "intent.h" +#include "remote_client.h" + +#include +#include +#include +#include + +class TabRoom; +class TabSupervisor; +class Event_ListRooms; +class ServerInfo_Room; + +/** + * @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_wait_for_database_load.cpp b/cockatrice/src/interface/intents/intent_wait_for_database_load.cpp new file mode 100644 index 000000000..c36378818 --- /dev/null +++ b/cockatrice/src/interface/intents/intent_wait_for_database_load.cpp @@ -0,0 +1,19 @@ +#include "intent_wait_for_database_load.h" + +#include + +bool IntentWaitForDatabaseLoad::checkPrecondition() const +{ + return CardDatabaseManager::getInstance()->getLoadStatus() == LoadStatus::Ok; +} + +void IntentWaitForDatabaseLoad::onPreconditionSatisfied() +{ + emitFinished(); +} + +void IntentWaitForDatabaseLoad::onPreconditionNotSatisfied() +{ + connect(CardDatabaseManager::getInstance(), &CardDatabase::cardDatabaseLoadingFinished, this, + [this]() { emitFinished(); }); +} diff --git a/cockatrice/src/interface/intents/intent_wait_for_database_load.h b/cockatrice/src/interface/intents/intent_wait_for_database_load.h new file mode 100644 index 000000000..72f4a1ffc --- /dev/null +++ b/cockatrice/src/interface/intents/intent_wait_for_database_load.h @@ -0,0 +1,16 @@ +#ifndef COCKATRICE_INTENT_WAIT_FOR_DATABASE_LOAD_H +#define COCKATRICE_INTENT_WAIT_FOR_DATABASE_LOAD_H + +#include "intent.h" + +class IntentWaitForDatabaseLoad : public Intent +{ + Q_OBJECT + +protected: + bool checkPrecondition() const override; + void onPreconditionSatisfied() override; + void onPreconditionNotSatisfied() override; +}; + +#endif // COCKATRICE_INTENT_WAIT_FOR_DATABASE_LOAD_H diff --git a/cockatrice/src/interface/intents/url_parser.cpp b/cockatrice/src/interface/intents/url_parser.cpp new file mode 100644 index 000000000..509390611 --- /dev/null +++ b/cockatrice/src/interface/intents/url_parser.cpp @@ -0,0 +1,136 @@ +#include "url_parser.h" + +#include "../widgets/tabs/tab_room.h" +#include "../widgets/tabs/tab_supervisor.h" +#include "../window_main.h" +#include "contexts/context_join_game.h" +#include "intent_join_server_game.h" +#include "intent_login.h" + +#include +#include +#include +#include +#include +#include + +IntentUrlParser::IntentUrlParser(QObject *parent, MainWindow *_mainWindow) : QObject(parent), mainWindow(_mainWindow) +{ +} + +void IntentUrlParser::handle(const QString &urlStr) +{ + QUrl url(urlStr); + + if (url.scheme() != "cockatrice") { + return; + } + + const QString action = url.host(); + QUrlQuery query(url); + + if (action == "joingame") { + handleJoinGame(query); + } else if (action == "opendeck") { + // handleOpenDeck(query); + } else { + qWarning() << "Unknown intent:" << action; + } +} + +void IntentUrlParser::handleJoinGame(const QUrlQuery &query) +{ + auto showError = [this](const QString &message) { QMessageBox::warning(mainWindow, tr("Open game"), message); }; + + auto ctx = std::make_unique(); + + ctx->roomContext.serverContext.hostname = query.queryItemValue("hostname"); + ctx->roomContext.serverContext.port = query.queryItemValue("port"); + + if (ctx->roomContext.serverContext.hostname.isEmpty()) { + showError(tr("Missing or empty hostname in the game link")); + return; + } + + bool ok = false; + ctx->roomContext.serverContext.port.toUShort(&ok); + if (!ok) { + showError(tr("Invalid or missing port in the game link")); + return; + } + + ctx->roomContext.roomId = query.queryItemValue("roomid").toInt(&ok); + + if (!ok) { + showError(tr("Invalid or missing room id in the game link")); + return; + } + + ok = false; + ctx->gameId = query.queryItemValue("gameid").toInt(&ok); + + if (!ok) { + showError(tr("Invalid or missing game id in the game link")); + return; + } + + 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; + } + + // 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)); + 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); + connect(joinGameIntent, &Intent::failed, this, [showError](const QString &reason) { showError(reason); }); + + getLoginCredentialsIntent->execute(); +} + +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); +} diff --git a/cockatrice/src/interface/intents/url_parser.h b/cockatrice/src/interface/intents/url_parser.h new file mode 100644 index 000000000..6d705e013 --- /dev/null +++ b/cockatrice/src/interface/intents/url_parser.h @@ -0,0 +1,23 @@ +#ifndef COCKATRICE_URL_PARSER_H +#define COCKATRICE_URL_PARSER_H +#include +#include + +class MainWindow; +struct ContextJoinGame; +class IntentUrlParser : public QObject +{ + Q_OBJECT + +public: + IntentUrlParser(QObject *parent, MainWindow *mainWindow); + void handle(const QString &urlStr); + void handleJoinGame(const QUrlQuery &query); + +private: + QString generateJoinGameMessage(const ContextJoinGame &context, const QString &gameDescription); + + MainWindow *mainWindow; +}; + +#endif // COCKATRICE_URL_PARSER_H diff --git a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp index 1b9be1dd4..d5a168708 100644 --- a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp +++ b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp @@ -8,12 +8,12 @@ #include #include #include +#include +#include #include -#include #include #include #include -#include #include PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QString &_themeName, QWidget *parent) @@ -22,6 +22,11 @@ PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QSt setMinimumSize(740, 220); setupUi(); + // 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. + saveDir = ThemeManager::writableThemeDir(themeName); + // Load both scheme configs upfront so switching is instant loadSchemes(); @@ -31,7 +36,9 @@ PaletteEditorDialog::PaletteEditorDialog(const QString &_themeDirPath, const QSt schemeComboBox->setCurrentText(loadedScheme); schemeComboBox->blockSignals(false); + paletteGrid->blockSignals(true); paletteGrid->loadPalette(workingConfig[loadedScheme]); + paletteGrid->blockSignals(false); seedAccentFromScheme(loadedScheme); retranslateUi(); @@ -124,8 +131,7 @@ void PaletteEditorDialog::setupUi() buttonBox = new QDialogButtonBox; resetBtn = buttonBox->addButton(tr("Reset"), QDialogButtonBox::ResetRole); - applyBtn = buttonBox->addButton(tr("Apply"), QDialogButtonBox::ApplyRole); - saveBtn = buttonBox->addButton(tr("Save && Apply"), QDialogButtonBox::AcceptRole); + saveBtn = buttonBox->addButton(tr("Save"), QDialogButtonBox::AcceptRole); closeBtn = buttonBox->addButton(QDialogButtonBox::Close); footerLayout->addWidget(revertButton); @@ -135,10 +141,14 @@ void PaletteEditorDialog::setupUi() // Connections connect(schemeComboBox, &QComboBox::currentTextChanged, this, &PaletteEditorDialog::onSchemeChanged); - connect(quickSetupPanel, &QuickSetupPanel::generateRequested, this, &PaletteEditorDialog::onGenerateFromAccent); + autoApplyTimer = new QTimer(this); + autoApplyTimer->setSingleShot(true); + autoApplyTimer->setInterval(150); + connect(autoApplyTimer, &QTimer::timeout, this, &PaletteEditorDialog::onApply); + connect(quickSetupPanel, &QuickSetupPanel::valueChanged, this, &PaletteEditorDialog::onGenerateFromAccent); + connect(paletteGrid, &PaletteGridWidget::paletteChanged, this, [this] { autoApplyTimer->start(); }); connect(revertButton, &QPushButton::clicked, this, &PaletteEditorDialog::onRevertToDefault); connect(resetBtn, &QPushButton::clicked, this, &PaletteEditorDialog::onReset); - connect(applyBtn, &QPushButton::clicked, this, &PaletteEditorDialog::onApply); connect(saveBtn, &QPushButton::clicked, this, &PaletteEditorDialog::onSave); connect(closeBtn, &QPushButton::clicked, this, &QDialog::reject); @@ -162,15 +172,8 @@ void PaletteEditorDialog::retranslateUi() setWindowTitle(tr("Palette Editor — %1").arg(themeName)); titleLabel->setText(tr("Palette Editor  ·  %1").arg(themeName)); - // Revert button only makes sense when the theme ships default palette files - const bool hasDefault = PaletteConfig::fromDefault(themeDirPath, "Light").hasPalette() || - PaletteConfig::fromDefault(themeDirPath, "Dark").hasPalette(); - revertButton->setEnabled(hasDefault); - if (!hasDefault) { - revertButton->setToolTip(tr("This theme ships no default palette files")); - } else { - revertButton->setToolTip(tr("Replace current colours with the theme author's defaults")); - } + revertButton->setToolTip( + tr("Delete this scheme's custom palette and revert to the theme default (or the application palette)")); schemeComboBox->setToolTip(tr("Switch between the light and dark palette files")); editingLabel->setText(tr("Editing:")); @@ -179,15 +182,13 @@ void PaletteEditorDialog::retranslateUi() revertButton->setText(tr("↺ Revert to theme default")); resetBtn->setText(tr("Reset")); - applyBtn->setText(tr("Apply")); - saveBtn->setText(tr("Save && Apply")); + saveBtn->setText(tr("Save")); resetBtn->setToolTip(tr("Discard unsaved edits and restore the last saved palette")); - applyBtn->setToolTip(tr("Preview this palette without saving to disk")); saveBtn->setToolTip(tr("Write palette-%1.toml and reload the theme").arg(loadedScheme.toLower())); - if (themeDirPath.isEmpty()) { + if (saveDir.isEmpty() || !ThemeManager::isDirReallyWritable(saveDir)) { saveBtn->setEnabled(false); - saveBtn->setToolTip(tr("Cannot save: this theme has no directory on disk")); + saveBtn->setToolTip(tr("Cannot save: this theme has no writable directory")); } } @@ -198,7 +199,7 @@ void PaletteEditorDialog::loadSchemes() PaletteConfig cfg = PaletteConfig::fromScheme(themeDirPath, scheme); if (!cfg.hasPalette()) { - cfg = PaletteConfig::fromDefault(themeDirPath, scheme); + cfg = ThemeManager::loadDefaultPaletteConfig(themeDirPath, themeName, scheme); } if (!cfg.hasPalette()) { @@ -235,7 +236,6 @@ void PaletteEditorDialog::onSchemeChanged(const QString &scheme) loadedScheme = scheme; paletteGrid->loadPalette(workingConfig.value(scheme)); seedAccentFromScheme(scheme); - onApply(); } void PaletteEditorDialog::onGenerateFromAccent(const QColor &accent, int intensity) @@ -256,20 +256,48 @@ void PaletteEditorDialog::onSave() return; } - PaletteConfig cfg = paletteGrid->currentPaletteConfig(); + // Snapshot the currently displayed scheme so unsaved edits are not lost. + workingConfig[loadedScheme] = paletteGrid->currentPaletteConfig(); - if (!ThemeManager::savePaletteConfig(themeDirPath, loadedScheme, cfg)) { - QMessageBox::warning(this, tr("Save failed"), - tr("Could not write %1 to:\n%2").arg(PaletteConfig::fileName(loadedScheme), themeDirPath)); - return; + // 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) { + if (it.key() == loadedScheme) { + continue; + } + 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(it.key()), saveDir)); + return; + } } - ThemeConfig globalCfg = ThemeConfig::fromThemeDir(themeDirPath); - globalCfg.colorScheme = loadedScheme; - globalCfg.save(themeDirPath); + // 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(); + } - savedConfig[loadedScheme] = cfg; - workingConfig[loadedScheme] = cfg; themeManager->reloadCurrentTheme(); accept(); } @@ -278,18 +306,40 @@ void PaletteEditorDialog::onReset() { workingConfig[loadedScheme] = savedConfig[loadedScheme]; paletteGrid->loadPalette(savedConfig[loadedScheme]); + seedAccentFromScheme(loadedScheme); } void PaletteEditorDialog::onRevertToDefault() { - PaletteConfig def = PaletteConfig::fromDefault(themeDirPath, loadedScheme); + // Delete this scheme's custom palette file so the theme falls back to its + // default (or, when it ships none, the application palette). + // Note: shipped defaults use palette-default-.toml so this only + // removes user-written custom palette files; the theme author's defaults + // are left untouched. + QFile::remove(QDir(saveDir).absoluteFilePath(PaletteConfig::fileName(loadedScheme))); + + // Reload the live theme so the revert takes effect immediately. + themeManager->reloadCurrentTheme(); + + // Reflect the resolved palette (theme default, else current app palette) in + // the editor so it no longer shows the deleted custom colours. + PaletteConfig def = ThemeManager::loadDefaultPaletteConfig(themeDirPath, themeName, loadedScheme); if (!def.hasPalette()) { - QMessageBox::information(this, tr("No default found"), - tr("No default palette file found for the \"%1\" scheme.").arg(loadedScheme)); - return; + const QPalette appPal = qApp->palette(); + for (auto group : {QPalette::Active, QPalette::Disabled, QPalette::Inactive}) { + for (int i = 0; i < QPalette::NColorRoles; ++i) { + auto role = static_cast(i); + if (role != QPalette::NoRole) { + def.colors[group][role] = appPal.color(group, role); + } + } + } } + + savedConfig[loadedScheme] = def; workingConfig[loadedScheme] = def; paletteGrid->loadPalette(def); + seedAccentFromScheme(loadedScheme); } void PaletteEditorDialog::changeEvent(QEvent *e) diff --git a/cockatrice/src/interface/palette_editor/palette_editor_dialog.h b/cockatrice/src/interface/palette_editor/palette_editor_dialog.h index cec4c1700..953902c06 100644 --- a/cockatrice/src/interface/palette_editor/palette_editor_dialog.h +++ b/cockatrice/src/interface/palette_editor/palette_editor_dialog.h @@ -7,6 +7,8 @@ #include #include +class QTimer; + class QLabel; class QComboBox; class QDialogButtonBox; @@ -48,7 +50,6 @@ private: QComboBox *schemeComboBox = nullptr; QDialogButtonBox *buttonBox = nullptr; QPushButton *resetBtn = nullptr; - QPushButton *applyBtn = nullptr; QPushButton *saveBtn = nullptr; QPushButton *closeBtn = nullptr; QPushButton *revertButton = nullptr; @@ -57,10 +58,15 @@ private: QString themeDirPath; QString themeName; QString loadedScheme; + // Directory writes are directed to; may differ from themeDirPath when the + // latter is read-only (e.g. a built-in / system theme directory). + QString saveDir; QMap workingConfig; QMap savedConfig; + QTimer *autoApplyTimer = nullptr; + protected: void changeEvent(QEvent *e) override; }; diff --git a/cockatrice/src/interface/palette_editor/palette_generator.cpp b/cockatrice/src/interface/palette_editor/palette_generator.cpp index d30dd14f1..822e57250 100644 --- a/cockatrice/src/interface/palette_editor/palette_generator.cpp +++ b/cockatrice/src/interface/palette_editor/palette_generator.cpp @@ -150,6 +150,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 e4ac5a16f..97d28b731 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.cpp @@ -1,5 +1,7 @@ #include "palette_grid_widget.h" +#include "../theme_manager.h" + #include #include #include @@ -45,6 +47,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); @@ -117,10 +124,51 @@ void PaletteGridWidget::buildGrid(QWidget *host) for (int col = 0; col < 3; ++col) { auto group = ALL_GROUPS[col]; auto *btn = new ColorButton(host); + connect(btn, &ColorButton::colorChanged, this, [this] { emit paletteChanged(); }); colorButtons[group][role] = btn; 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) @@ -165,6 +213,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 @@ -175,5 +233,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 1d85f1ac8..77cbf1c62 100644 --- a/cockatrice/src/interface/palette_editor/palette_grid_widget.h +++ b/cockatrice/src/interface/palette_editor/palette_grid_widget.h @@ -22,12 +22,16 @@ public: void loadPalette(const PaletteConfig &cfg); PaletteConfig currentPaletteConfig() const; +signals: + void paletteChanged(); + private: void buildGrid(QWidget *host); void changeEvent(QEvent *e); 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 fe3af9b52..85098bde5 100644 --- a/cockatrice/src/interface/palette_editor/quick_setup_panel.cpp +++ b/cockatrice/src/interface/palette_editor/quick_setup_panel.cpp @@ -3,7 +3,6 @@ #include #include #include -#include #include QuickSetupPanel::QuickSetupPanel(QWidget *parent) : QWidget(parent) @@ -41,8 +40,6 @@ QuickSetupPanel::QuickSetupPanel(QWidget *parent) : QWidget(parent) intensityPercentageLabel->setFixedWidth(34); intensityPercentageLabel->setAlignment(Qt::AlignRight | Qt::AlignVCenter); - generateButton = new QPushButton(this); - layout->addWidget(heading); layout->addSpacing(6); layout->addWidget(accentLabel); @@ -54,12 +51,13 @@ QuickSetupPanel::QuickSetupPanel(QWidget *parent) : QWidget(parent) layout->addWidget(labelHigh); layout->addWidget(intensityPercentageLabel); layout->addStretch(); - layout->addWidget(generateButton); - connect(intensitySlider, &QSlider::valueChanged, this, - [this](int v) { intensityPercentageLabel->setText(tr("%1%").arg(v)); }); - connect(generateButton, &QPushButton::clicked, this, - [this] { emit generateRequested(accentButton->getColor(), intensitySlider->value()); }); + connect(intensitySlider, &QSlider::valueChanged, this, [this](int v) { + intensityPercentageLabel->setText(tr("%1%").arg(v)); + emit valueChanged(accentButton->getColor(), v); + }); + connect(accentButton, &ColorButton::colorChanged, this, + [this](const QColor &c) { emit valueChanged(c, intensitySlider->value()); }); retranslateUi(); } @@ -77,10 +75,6 @@ void QuickSetupPanel::retranslateUi() "30–70 Accented — buttons, tooltips, and borders join in\n" "70–100 Full colour — backgrounds, everything")); intensityPercentageLabel->setText(tr("70%")); - - generateButton->setText(tr("Generate ↓")); - generateButton->setToolTip(tr("Derive all palette roles from the accent colour above.\n" - "Fine-tune individual colours in the grid afterwards.")); } QColor QuickSetupPanel::accentColor() const @@ -95,5 +89,7 @@ int QuickSetupPanel::intensity() const void QuickSetupPanel::setAccentColor(const QColor &c) { + accentButton->blockSignals(true); accentButton->setColor(c); -} \ No newline at end of file + accentButton->blockSignals(false); +} diff --git a/cockatrice/src/interface/palette_editor/quick_setup_panel.h b/cockatrice/src/interface/palette_editor/quick_setup_panel.h index dddd0aaa3..c83e27cf2 100644 --- a/cockatrice/src/interface/palette_editor/quick_setup_panel.h +++ b/cockatrice/src/interface/palette_editor/quick_setup_panel.h @@ -5,7 +5,6 @@ #include -class QPushButton; class QHBoxLayout; class QLabel; class QSlider; @@ -16,12 +15,11 @@ class QSlider; * * The panel contains: * - an accent color picker, - * - an intensity slider, - * - and a generate button. + * - an intensity slider. * - * When the user clicks the generate button, the panel emits - * generateRequested() with the currently selected accent color - * and intensity value. + * Whenever either value changes the panel emits valueChanged() with + * the current accent colour and intensity, which the parent dialog + * uses to auto-apply the generated palette. * * Typically used together with PaletteGenerator::fromAccent() * to quickly generate color schemes from a chosen accent color. @@ -71,12 +69,12 @@ public: signals: /** - * @brief Emitted when the user requests palette generation. + * @brief Emitted whenever the accent colour or intensity changes. * - * @param accent The selected accent color. - * @param intensity The selected intensity value. + * The parent dialog consumes this to auto-apply the generated palette + * without requiring an explicit Generate click. */ - void generateRequested(QColor accent, int intensity); + void valueChanged(QColor accent, int intensity); private: QHBoxLayout *layout; @@ -88,7 +86,6 @@ private: QLabel *labelHigh; QSlider *intensitySlider; QLabel *intensityPercentageLabel; - QPushButton *generateButton; }; -#endif // COCKATRICE_QUICK_SETUP_PANEL_H \ No newline at end of file +#endif // COCKATRICE_QUICK_SETUP_PANEL_H diff --git a/cockatrice/src/interface/pixel_map_generator.cpp b/cockatrice/src/interface/pixel_map_generator.cpp index d3b0252a6..b70dc576f 100644 --- a/cockatrice/src/interface/pixel_map_generator.cpp +++ b/cockatrice/src/interface/pixel_map_generator.cpp @@ -1,8 +1,11 @@ #include "pixel_map_generator.h" +#include "theme_manager.h" + #include #include #include +#include #include #include #include @@ -14,6 +17,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 +64,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 +84,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 +98,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 +132,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 +299,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 +361,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 +422,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 +438,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 +447,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 +517,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..b6e822fd9 100644 --- a/cockatrice/src/interface/pixel_map_generator.h +++ b/cockatrice/src/interface/pixel_map_generator.h @@ -7,6 +7,7 @@ #ifndef PIXMAPGENERATOR_H #define PIXMAPGENERATOR_H +#include #include #include #include @@ -125,6 +126,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 b79c91265..8293a82cf 100644 --- a/cockatrice/src/interface/theme_config.cpp +++ b/cockatrice/src/interface/theme_config.cpp @@ -16,7 +16,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 +96,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 +133,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 +170,7 @@ PaletteConfig PaletteConfig::fromFile(const QString &filePath) } QMetaEnum roleEnum = QMetaEnum::fromType(); + QMetaEnum appEnum = QMetaEnum::fromType(); QString currentSection; QPalette::ColorGroup currentGroup = QPalette::Active; @@ -202,6 +221,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 +255,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; @@ -245,14 +280,11 @@ PaletteConfig PaletteConfig::fromDefault(const QString &themeDirPath, const QStr bool wantDark = colorScheme.compare("Dark", Qt::CaseInsensitive) == 0; - PaletteConfig cfg = - fromFile(dir.absoluteFilePath(wantDark ? "palette-default-dark.toml" : "palette-default-light.toml")); - - if (!cfg.hasPalette()) { - cfg = fromFile(dir.absoluteFilePath(wantDark ? "palette-default-light.toml" : "palette-default-dark.toml")); - } - - return cfg; + // Only the default file matching the requested scheme is used. Falling back + // to the opposite scheme's default would silently apply dark colours to a + // "Light" scheme (or vice versa). Callers already fall back to the OS / + // application palette when no default palette is available. + return fromFile(dir.absoluteFilePath(wantDark ? "palette-default-dark.toml" : "palette-default-light.toml")); } QPalette PaletteConfig::apply(QPalette base) const diff --git a/cockatrice/src/interface/theme_config.h b/cockatrice/src/interface/theme_config.h index 07bf55b7a..567aeccda 100644 --- a/cockatrice/src/interface/theme_config.h +++ b/cockatrice/src/interface/theme_config.h @@ -3,9 +3,25 @@ #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 { QString colorScheme; @@ -21,7 +37,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 7dc757062..d86ed77f9 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -1,10 +1,13 @@ #include "theme_manager.h" #include "../../client/settings/cache_settings.h" +#include "pixel_map_generator.h" #include #include #include +#include +#include #include #include #include @@ -17,8 +20,9 @@ #include #include #include +#include -#define NONE_THEME_NAME "Default" +#define SYSTEM_THEME_NAME "System" #define FUSION_THEME_NAME "Fusion" #define STYLE_CSS_NAME "style.css" #define HANDZONE_BG_NAME "handzone" @@ -89,16 +93,25 @@ struct PaletteColorInfo } } +static QString usableDefaultStyle(const QString &style) +{ + // The Windows 11 native style is broken: when the OS default + // ("System" theme selection) would use it, fall back to the Vista style. + // Explicitly choosing "windows11" in a theme is still honored. + return style.compare("windows11", Qt::CaseInsensitive) == 0 ? QStringLiteral("windowsvista") : 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, &ThemeManager::themeChangedSlot); + connect(QGuiApplication::styleHints(), &QStyleHints::colorSchemeChanged, this, [this] { + defaultPalette = qApp->palette(); + themeChangedSlot(); + }); #endif connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, &ThemeManager::themeChangedSlot); themeChangedSlot(); @@ -106,14 +119,20 @@ ThemeManager::ThemeManager(QObject *parent) : QObject(parent) void ThemeManager::ensureThemeDirectoryExists() { - if (SettingsCache::instance().getThemeName().isEmpty() || - !getAvailableThemes().contains(SettingsCache::instance().getThemeName())) { + auto &settings = SettingsCache::instance(); + + // Migrate the old "Default" theme name to "System" + if (settings.getThemeName() == "Default") { + 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) { @@ -130,11 +149,88 @@ 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. +static QString systemThemesBasePath() +{ + QString base = qApp->applicationDirPath(); +#ifdef Q_OS_MAC + base += "/../Resources/themes"; +#elif defined(Q_OS_WIN) + base += "/themes"; +#else // linux + base += "/../share/cockatrice/themes"; +#endif + return base; } QStringMap &ThemeManager::getAvailableThemes() @@ -143,11 +239,9 @@ QStringMap &ThemeManager::getAvailableThemes() availableThemes.clear(); // load themes from user profile dir - dir.setPath(SettingsCache::instance().getThemesPath()); - - // add default value - availableThemes.insert(NONE_THEME_NAME, dir.absoluteFilePath("Default")); + dir.setPath(SettingsCache::instance().paths().getThemesPath()); + 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)) { @@ -156,16 +250,8 @@ QStringMap &ThemeManager::getAvailableThemes() } } - // load themes from cockatrice system dir - dir.setPath(qApp->applicationDirPath() + -#ifdef Q_OS_MAC - "/../Resources/themes" -#elif defined(Q_OS_WIN) - "/themes" -#else // linux - "/../share/cockatrice/themes" -#endif - ); + // Load themes from Cockatrice system dir + dir.setPath(systemThemesBasePath()); for (QString themeName : dir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot, QDir::Name)) { if (!availableThemes.contains(themeName)) { @@ -179,7 +265,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); @@ -193,7 +279,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; @@ -242,9 +328,36 @@ bool ThemeManager::savePaletteConfig(const QString &themeDirPath, const QString return true; } +PaletteConfig ThemeManager::loadDefaultPaletteConfig(const QString &themeDirPath, + const QString &themeName, + const QString &colorScheme) +{ + PaletteConfig cfg = PaletteConfig::fromDefault(themeDirPath, colorScheme); + if (!cfg.hasPalette()) { + // The shipped default may live in the system theme directory rather + // than the resolved (user) theme directory, so built-in themes still + // get their curated defaults. + cfg = PaletteConfig::fromDefault(QDir(systemThemesBasePath()).absoluteFilePath(themeName), colorScheme); + } + 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; @@ -253,6 +366,17 @@ void ThemeManager::setColorScheme(const QString &scheme) reloadCurrentTheme(); } +void ThemeManager::setStyleName(const QString &styleName) +{ + const QString dirPath = writableThemeDir(SettingsCache::instance().getThemeName()); + ThemeConfig cfg = ThemeConfig::fromThemeDir(dirPath); + + cfg.styleName = styleName; + + cfg.save(dirPath); + reloadCurrentTheme(); +} + void ThemeManager::reloadCurrentTheme() { themeChangedSlot(); @@ -275,17 +399,17 @@ 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; + styleName = usableDefaultStyle(defaultStyleName); } } QStyle *style = QStyleFactory::create(styleName); if (!style) { - style = QStyleFactory::create(defaultStyleName); + style = QStyleFactory::create(usableDefaultStyle(defaultStyleName)); } // Base palette @@ -298,7 +422,11 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName, } #endif } else { - base = qApp->palette(); + // Use the pristine startup palette rather than qApp->palette(): the + // latter may already carry a previously-applied custom (e.g. dark) + // palette, which would otherwise persist when switching to a scheme + // that supplies no palette of its own. + base = defaultPalette; } // Overlay custom palette colours @@ -314,6 +442,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. @@ -326,6 +456,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() @@ -335,9 +494,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(""); } @@ -352,8 +521,19 @@ void ThemeManager::themeChangedSlot() // ── Load palette: custom first, then theme default ──────────────────── PaletteConfig palette = PaletteConfig::fromScheme(dirPath, activeScheme); - if (!palette.hasPalette()) { - palette = PaletteConfig::fromDefault(dirPath, 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); @@ -362,6 +542,16 @@ void ThemeManager::themeChangedSlot() if (!dirPath.isEmpty()) { resources << dir.absolutePath(); } + + // When the resolved dir is a user copy (e.g. user/), also + // include the system theme dir as a fallback so shipped assets like + // zones/*.png and style.css still resolve for themes that ship only + // those files (e.g. Leather, Plasma, Fabric, VelvetMarble). + const QString sysPath = QDir(systemThemesBasePath()).absoluteFilePath(themeName); + if (sysPath != dirPath && QDir(sysPath).exists()) { + resources << sysPath; + } + resources << DEFAULT_RESOURCE_PATHS; QDir::setSearchPaths("theme", resources); @@ -378,6 +568,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 b9e764d08..aadb38ee9 100644 --- a/cockatrice/src/interface/theme_manager.h +++ b/cockatrice/src/interface/theme_manager.h @@ -43,9 +43,14 @@ public: private: QString defaultStyleName; + // Pristine application palette captured at startup, before any custom theme + // palette is applied. Used as the base when a theme supplies no palette, so + // switching away from a custom palette restores the original colours. + QPalette defaultPalette; QString currentThemePath; std::array brushes; QStringMap availableThemes; + QMap currentAppColors; /* Internal cache for multiple backgrounds */ @@ -61,8 +66,24 @@ 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); + // 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 @@ -76,17 +97,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 1c38214bd..1ea1bcb10 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 @@ -8,6 +8,7 @@ #include #include #include +#include #include ColorIdentityWidget::ColorIdentityWidget(QWidget *parent, const QString &_colorIdentity) @@ -21,7 +22,8 @@ ColorIdentityWidget::ColorIdentityWidget(QWidget *parent, const QString &_colorI populateManaSymbolWidgets(); - connect(&SettingsCache::instance(), &SettingsCache::visualDeckStorageDrawUnusedColorIdentitiesChanged, this, + connect(&SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::visualDeckStorageDrawUnusedColorIdentitiesChanged, this, &ColorIdentityWidget::toggleUnusedVisibility); } @@ -39,8 +41,13 @@ 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().getVisualDeckStorageDrawUnusedColorIdentities()) { + if (SettingsCache::instance().visualDeckStorage().getVisualDeckStorageDrawUnusedColorIdentities()) { for (const QString symbol : fullColorIdentity) { auto *manaSymbol = new ManaSymbolWidget(this, symbol, symbols.contains(symbol)); layout->addWidget(manaSymbol); @@ -71,20 +78,33 @@ 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 = 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 == 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..315ac07d6 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 @@ -30,6 +30,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/mana_cost_widget.h b/cockatrice/src/interface/widgets/cards/additional_info/mana_cost_widget.h index b2f6b62c0..8bd2e9bc0 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 @@ -17,7 +17,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 07cc382ad..18011909f 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,14 +1,15 @@ #include "mana_symbol_widget.h" #include "../../../../client/settings/cache_settings.h" +#include "../../../pixel_map_generator.h" #include +#include 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 @@ -16,7 +17,8 @@ ManaSymbolWidget::ManaSymbolWidget(QWidget *parent, QString _symbol, bool _isAct setGraphicsEffect(opacityEffect); updateOpacity(); - connect(&SettingsCache::instance(), &SettingsCache::visualDeckStorageUnusedColorIdentitiesOpacityChanged, this, + connect(&SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::visualDeckStorageUnusedColorIdentitiesOpacityChanged, this, &ManaSymbolWidget::updateOpacity); } @@ -42,7 +44,11 @@ void ManaSymbolWidget::updateOpacity() opacity = isActive ? 1.0 : 0.5; } else { // It's just for display, they can do whatever they want. - opacity = isActive ? 1.0 : SettingsCache::instance().getVisualDeckStorageUnusedColorIdentitiesOpacity() / 100.0; + opacity = + isActive + ? 1.0 + : SettingsCache::instance().visualDeckStorage().getVisualDeckStorageUnusedColorIdentitiesOpacity() / + 100.0; } opacityEffect->setOpacity(opacity); } @@ -58,16 +64,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..705873dff 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 @@ -33,8 +33,6 @@ public: return symbol[0]; } - void loadManaIcon(); - public slots: void resizeEvent(QResizeEvent *event) override; void mousePressEvent(QMouseEvent *event) override; @@ -44,7 +42,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..8ab18ddd9 --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/art_crop_attribution.cpp @@ -0,0 +1,61 @@ +#include "art_crop_attribution.h" + +#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..0b3fb3d56 --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/art_crop_attribution.h @@ -0,0 +1,41 @@ +#ifndef COCKATRICE_ART_CROP_ATTRIBUTION_H +#define COCKATRICE_ART_CROP_ATTRIBUTION_H + +#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..b26b73593 --- /dev/null +++ b/cockatrice/src/interface/widgets/cards/card_art_utils.cpp @@ -0,0 +1,18 @@ +#include "card_art_utils.h" + +#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..bfbdd7e42 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 @@ -174,16 +174,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)); } } 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..a3bf70981 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 @@ -35,6 +35,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; 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 2e7c62461..fcccf445a 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_frame_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_frame_widget.cpp @@ -10,6 +10,7 @@ #include #include #include +#include CardInfoFrameWidget::CardInfoFrameWidget(QWidget *parent) : QTabWidget(parent), viewTransformationButton(nullptr), cardTextOnly(false) @@ -60,7 +61,7 @@ CardInfoFrameWidget::CardInfoFrameWidget(QWidget *parent) tab3Layout->addWidget(splitter); tab3->setLayout(tab3Layout); - setViewMode(SettingsCache::instance().getCardInfoViewMode()); + setViewMode(SettingsCache::instance().cardsDisplay().getCardInfoViewMode()); } void CardInfoFrameWidget::retranslateUi() @@ -127,7 +128,7 @@ void CardInfoFrameWidget::setViewMode(int mode) refreshLayout(); - SettingsCache::instance().setCardInfoViewMode(mode); + SettingsCache::instance().cardsDisplay().setCardInfoViewMode(mode); } static bool hasTransformation(const CardInfo &info) 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 185bb20d1..40bef3df3 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 @@ -5,6 +5,7 @@ #include #include +#include /** * @brief Constructs a CardPictureEnlargedWidget. @@ -17,11 +18,12 @@ CardInfoPictureEnlargedWidget::CardInfoPictureEnlargedWidget(QWidget *parent) : setWindowFlags(Qt::ToolTip); // Keeps this widget on top of everything setAttribute(Qt::WA_TranslucentBackground); - connect(&SettingsCache::instance(), &SettingsCache::roundCardCornersChanged, this, [this](bool _roundCardCorners) { - Q_UNUSED(_roundCardCorners); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::roundCardCornersChanged, this, + [this](bool _roundCardCorners) { + Q_UNUSED(_roundCardCorners); - update(); - }); + update(); + }); } /** @@ -50,12 +52,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 @@ -99,7 +118,8 @@ void CardInfoPictureEnlargedWidget::paintEvent(QPaintEvent *event) QPoint topLeft{(width() - scaledLogicalSize.width()) / 2, (height() - scaledLogicalSize.height()) / 2}; // Rounded corner radius based on logical width - qreal radius = SettingsCache::instance().getRoundCardCorners() ? 0.05 * scaledLogicalSize.width() : 0.0; + qreal radius = + SettingsCache::instance().cardsDisplay().getRoundCardCorners() ? 0.05 * scaledLogicalSize.width() : 0.0; QStylePainter painter(this); // Fill the background with transparent color to ensure rounded corners are rendered properly 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 3bfd9ce7d..2dd21e78a 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.cpp @@ -5,6 +5,7 @@ #include "../../../interface/card_picture_loader/card_picture_loader.h" #include "../../../interface/widgets/tabs/tab_supervisor.h" #include "../../window_main.h" +#include "card_art_utils.h" #include #include @@ -13,6 +14,7 @@ #include #include #include +#include #include static constexpr qreal MTG_CARD_ASPECT_RATIO = 1.396; @@ -66,11 +68,14 @@ CardInfoPictureWidget::CardInfoPictureWidget(QWidget *parent, const bool _hoverT animation->setStartValue(originalPos); animation->setEndValue(originalPos - QPoint(0, ANIMATION_OFFSET)); - connect(&SettingsCache::instance(), &SettingsCache::roundCardCornersChanged, this, [this](bool _roundCardCorners) { - Q_UNUSED(_roundCardCorners); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::roundCardCornersChanged, this, + [this](bool _roundCardCorners) { + Q_UNUSED(_roundCardCorners); - update(); - }); + update(); + }); + connect(&SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::cardLangChanged, this, + &CardInfoPictureWidget::updatePixmap); } /** @@ -190,13 +195,8 @@ void CardInfoPictureWidget::paintEvent(QPaintEvent *event) } QPixmap transformedPixmap = resizedPixmap; // Default pixmap - if (SettingsCache::instance().getAutoRotateSidewaysLayoutCards()) { - if (exactCard.getInfo().getUiAttributes().landscapeOrientation) { - // Rotate pixmap 90 degrees to the left - QTransform transform; - transform.rotate(90); - transformedPixmap = resizedPixmap.transformed(transform, Qt::SmoothTransformation); - } + if (SettingsCache::instance().cardsDisplay().getAutoRotateSidewaysLayoutCards()) { + transformedPixmap = CardArtUtils::rotateSidewaysLayoutArt(resizedPixmap, exactCard); } // Handle DPI scaling @@ -220,7 +220,9 @@ void CardInfoPictureWidget::paintEvent(QPaintEvent *event) // Compute rounded corner radius // Ensure consistent rounding - qreal radius = SettingsCache::instance().getRoundCardCorners() ? 0.05 * static_cast(targetRect.width()) : 0.; + qreal radius = SettingsCache::instance().cardsDisplay().getRoundCardCorners() + ? 0.05 * static_cast(targetRect.width()) + : 0.; // Draw the pixmap with rounded corners QStylePainter painter(this); @@ -246,11 +248,7 @@ QSize CardInfoPictureWidget::sizeHint() const * @brief Starts the hover timer to show the enlarged pixmap on hover. * @param event The enter event. */ -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) void CardInfoPictureWidget::enterEvent(QEnterEvent *event) -#else -void CardInfoPictureWidget::enterEvent(QEvent *event) -#endif { QWidget::enterEvent(event); // Call the base class implementation 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 1f065eed9..4fe84ed0b 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_info_picture_widget.h @@ -48,11 +48,7 @@ signals: protected: void resizeEvent(QResizeEvent *event) override; void paintEvent(QPaintEvent *) override; -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) - void enterEvent(QEnterEvent *event) override; // Qt6 signature -#else - void enterEvent(QEvent *event) override; // Qt5 signature -#endif + void enterEvent(QEnterEvent *event) override; void leaveEvent(QEvent *event) override; void moveEvent(QMoveEvent *event) override; void mouseMoveEvent(QMouseEvent *event) override; 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..e98c3c02a 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp +++ b/cockatrice/src/interface/widgets/cards/card_info_text_widget.cpp @@ -1,6 +1,7 @@ #include "card_info_text_widget.h" #include "../../../game_graphics/board/card_item.h" +#include "../../card_localization.h" #include #include @@ -10,7 +11,7 @@ #include #include -CardInfoTextWidget::CardInfoTextWidget(QWidget *parent) : QFrame(parent), info(nullptr) +CardInfoTextWidget::CardInfoTextWidget(QWidget *parent) : QFrame(parent) { propsLabel = new QLabel; propsLabel->setOpenExternalLinks(false); @@ -39,6 +40,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 +67,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 +101,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..683be5ab5 100644 --- a/cockatrice/src/interface/widgets/cards/card_info_text_widget.h +++ b/cockatrice/src/interface/widgets/cards/card_info_text_widget.h @@ -23,7 +23,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/deck_card_zone_display_widget.cpp b/cockatrice/src/interface/widgets/cards/deck_card_zone_display_widget.cpp index eaf3a67b0..b00d9db1e 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 @@ -5,6 +5,7 @@ #include "libcockatrice/card/database/card_database_manager.h" #include +#include #include DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent, @@ -51,11 +52,6 @@ DeckCardZoneDisplayWidget::DeckCardZoneDisplayWidget(QWidget *parent, // 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 +91,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 +113,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 +128,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); } } 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..53f3fa7cf 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 @@ -42,7 +42,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); 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 b27fe274f..1614836fc 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 @@ -8,6 +8,7 @@ #include #include #include +#include /** * @brief Constructs a CardPictureWithTextOverlay widget. @@ -38,7 +39,8 @@ DeckPreviewCardPictureWidget::DeckPreviewCardPictureWidget(QWidget *parent, singleClickTimer = new QTimer(this); singleClickTimer->setSingleShot(true); connect(singleClickTimer, &QTimer::timeout, this, [this]() { emit imageClicked(lastMouseEvent, this); }); - connect(&SettingsCache::instance(), &SettingsCache::visualDeckStorageSelectionAnimationChanged, this, + connect(&SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::visualDeckStorageSelectionAnimationChanged, this, &CardInfoPictureWidget::setRaiseOnEnterEnabled); } 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..483fc71b0 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,5 +1,6 @@ #include "abstract_analytics_panel_widget.h" +#include "../../pixel_map_generator.h" #include "deck_list_statistics_analyzer.h" #include @@ -20,7 +21,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/resizable_panel.cpp b/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp index f7bb5ac35..250fe1ad3 100644 --- a/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp +++ b/cockatrice/src/interface/widgets/deck_analytics/resizable_panel.cpp @@ -147,11 +147,7 @@ bool ResizablePanel::eventFilter(QObject *obj, QEvent *event) if (event->type() == QEvent::MouseButtonPress) { auto *mouseEvent = static_cast(event); if (mouseEvent->button() == Qt::LeftButton) { -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) dragStartPos = mouseEvent->globalPosition().toPoint(); -#else - dragStartPos = mouseEvent->globalPos(); -#endif isDraggingPanel = false; dragButton->setCursor(Qt::ClosedHandCursor); } @@ -159,11 +155,7 @@ bool ResizablePanel::eventFilter(QObject *obj, QEvent *event) } else if (event->type() == QEvent::MouseMove) { auto *mouseEvent = static_cast(event); if (mouseEvent->buttons() & Qt::LeftButton) { -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) QPoint currentPos = mouseEvent->globalPosition().toPoint(); -#else - QPoint currentPos = mouseEvent->globalPos(); -#endif int distance = (currentPos - dragStartPos).manhattanLength(); if (distance >= 5 && !isDraggingPanel) { isDraggingPanel = true; @@ -182,22 +174,14 @@ bool ResizablePanel::eventFilter(QObject *obj, QEvent *event) if (obj == resizeHandle) { if (event->type() == QEvent::MouseButtonPress) { auto *mouseEvent = static_cast(event); -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) resizeStartY = mouseEvent->globalPosition().y(); -#else - resizeStartY = mouseEvent->globalPos().y(); -#endif isResizing = true; resizeStartHeight = currentHeight; resizeHandle->grabMouse(); return true; } else if (event->type() == QEvent::MouseMove && isResizing) { auto *mouseEvent = static_cast(event); -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) int deltaY = mouseEvent->globalPosition().y() - resizeStartY; -#else - int deltaY = mouseEvent->globalPos().y() - resizeStartY; -#endif int newHeight = resizeStartHeight + deltaY; int minAllowed = getMinimumAllowedHeight(); @@ -221,11 +205,7 @@ void ResizablePanel::dragEnterEvent(QDragEnterEvent *event) { if (event->mimeData()->hasFormat("application/x-resizablepanel")) { event->acceptProposedAction(); -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) showDropIndicator(event->position().y()); -#else - showDropIndicator(event->pos().y()); -#endif } } @@ -233,13 +213,8 @@ void ResizablePanel::dragMoveEvent(QDragMoveEvent *event) { if (event->mimeData()->hasFormat("application/x-resizablepanel")) { event->acceptProposedAction(); -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) showDropIndicator(event->position().y()); lastDragPos = mapToGlobal(event->position().toPoint()); -#else - showDropIndicator(event->pos().y()); - lastDragPos = mapToGlobal(event->pos()); -#endif if (!autoScrollTimer->isActive()) { autoScrollTimer->start(); @@ -265,11 +240,7 @@ void ResizablePanel::dropEvent(QDropEvent *event) ResizablePanel *draggedPanel = reinterpret_cast(ptr); if (draggedPanel && draggedPanel != this) { -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) bool insertBefore = (event->position().y() < height() / 2); -#else - bool insertBefore = (event->pos().y() < height() / 2); -#endif emit dropRequested(draggedPanel, this, insertBefore); event->acceptProposedAction(); } 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 a1c29e241..00388a3cd 100644 --- a/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/card_database_view.cpp @@ -11,6 +11,7 @@ #include #include #include +#include static bool canBeCommander(const CardInfo &cardInfo) { @@ -89,6 +90,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()) { @@ -141,6 +149,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..668444199 100644 --- a/cockatrice/src/interface/widgets/deck_editor/card_database_view.h +++ b/cockatrice/src/interface/widgets/deck_editor/card_database_view.h @@ -4,6 +4,7 @@ #include "../../key_signals.h" #include +#include #include class CardDatabaseModel; @@ -19,6 +20,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 +41,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..6269f0323 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,12 @@ #include "deck_editor_card_database_dock_widget.h" +#include "../../../interface/widgets/tabs/abstract_tab_deck_editor.h" +#include "card_database_view.h" +#include "deck_state_manager.h" +#include "deck_zone_dialog.h" + +#include + DeckEditorCardDatabaseDockWidget::DeckEditorCardDatabaseDockWidget(AbstractTabDeckEditor *parent) : QDockWidget(parent) { setObjectName("databaseDisplayDock"); @@ -15,6 +22,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_database_display_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_database_display_widget.cpp index 9da821813..c8bdacf0d 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 @@ -28,7 +28,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); @@ -59,13 +59,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_deck_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp index f61a01168..4b141e255 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 @@ -1,16 +1,28 @@ #include "deck_editor_deck_dock_widget.h" #include "../../../client/settings/cache_settings.h" +#include "../../../client/settings/shortcuts_settings.h" +#include "../../pixel_map_generator.h" +#include "../playmat/playmat_settings_dialog.h" +#include "../settings_page/user_interface_settings_page.h" +#include "../tabs/api/commander_spellbook/commander_bracket_widget.h" #include "deck_list_style_proxy.h" #include "deck_state_manager.h" +#include "deck_zone_dialog.h" #include #include #include #include +#include +#include #include #include #include +#include +#include +#include +#include #include static int findRestoreIndex(const CardRef &wanted, const QComboBox *combo) @@ -108,18 +120,18 @@ void DeckEditorDeckDockWidget::createDeckDock() showBannerCardCheckBox = new QCheckBox(); showBannerCardCheckBox->setObjectName("showBannerCardCheckBox"); - showBannerCardCheckBox->setChecked(SettingsCache::instance().getDeckEditorBannerCardComboBoxVisible()); - connect(showBannerCardCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setDeckEditorBannerCardComboBoxVisible); - connect(&SettingsCache::instance(), &SettingsCache::deckEditorBannerCardComboBoxVisibleChanged, this, + showBannerCardCheckBox->setChecked(SettingsCache::instance().deckEditor().getBannerCardComboBoxVisible()); + connect(showBannerCardCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().deckEditor(), + &DeckEditorSettings::setBannerCardComboBoxVisible); + connect(&SettingsCache::instance().deckEditor(), &DeckEditorSettings::bannerCardComboBoxVisibleChanged, this, &DeckEditorDeckDockWidget::updateShowBannerCardComboBox); showTagsWidgetCheckBox = new QCheckBox(); showTagsWidgetCheckBox->setObjectName("showTagsWidgetCheckBox"); - showTagsWidgetCheckBox->setChecked(SettingsCache::instance().getDeckEditorTagsWidgetVisible()); - connect(showTagsWidgetCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setDeckEditorTagsWidgetVisible); - connect(&SettingsCache::instance(), &SettingsCache::deckEditorTagsWidgetVisibleChanged, this, + showTagsWidgetCheckBox->setChecked(SettingsCache::instance().deckEditor().getTagsWidgetVisible()); + connect(showTagsWidgetCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().deckEditor(), + &DeckEditorSettings::setTagsWidgetVisible); + connect(&SettingsCache::instance().deckEditor(), &DeckEditorSettings::tagsWidgetVisibleChanged, this, &DeckEditorDeckDockWidget::updateShowTagsWidget); quickSettingsWidget->addSettingsWidget(showBannerCardCheckBox); @@ -131,6 +143,8 @@ void DeckEditorDeckDockWidget::createDeckDock() formatComboBox->addItem(tr("Loading Database...")); formatComboBox->setEnabled(false); // Disable until loaded + commanderBracketWidget = new CommanderBracketWidget(this); + commentsLabel = new QLabel(); commentsLabel->setObjectName("commentsLabel"); commentsEdit = new QTextEdit; @@ -151,7 +165,7 @@ void DeckEditorDeckDockWidget::createDeckDock() bannerCardLabel = new QLabel(); bannerCardLabel->setObjectName("bannerCardLabel"); bannerCardLabel->setText(tr("Banner Card")); - bannerCardLabel->setHidden(!SettingsCache::instance().getDeckEditorBannerCardComboBoxVisible()); + bannerCardLabel->setHidden(!SettingsCache::instance().deckEditor().getBannerCardComboBoxVisible()); bannerCardComboBox = new QComboBox(this); connect(getModel(), &DeckListModel::cardNodesChanged, this, [this]() { // Delay the update to avoid race conditions @@ -162,10 +176,10 @@ void DeckEditorDeckDockWidget::createDeckDock() connect(bannerCardComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, &DeckEditorDeckDockWidget::writeBannerCard); - bannerCardComboBox->setHidden(!SettingsCache::instance().getDeckEditorBannerCardComboBoxVisible()); + bannerCardComboBox->setHidden(!SettingsCache::instance().deckEditor().getBannerCardComboBoxVisible()); deckTagsDisplayWidget = new DeckPreviewDeckTagsDisplayWidget(this, {}); - deckTagsDisplayWidget->setHidden(!SettingsCache::instance().getDeckEditorTagsWidgetVisible()); + deckTagsDisplayWidget->setHidden(!SettingsCache::instance().deckEditor().getTagsWidgetVisible()); connect(deckTagsDisplayWidget, &DeckPreviewDeckTagsDisplayWidget::tagsChanged, deckStateManager, &DeckStateManager::setTags); @@ -179,25 +193,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); @@ -216,13 +230,23 @@ void DeckEditorDeckDockWidget::createDeckDock() upperLayout->addWidget(formatLabel, 2, 0); upperLayout->addWidget(formatComboBox, 2, 1); - upperLayout->addWidget(bannerCardLabel, 3, 0); - upperLayout->addWidget(bannerCardComboBox, 3, 1); + upperLayout->addWidget(commanderBracketWidget, 3, 0, 1, 2); - upperLayout->addWidget(deckTagsDisplayWidget, 4, 1); + upperLayout->addWidget(bannerCardLabel, 4, 0); + upperLayout->addWidget(bannerCardComboBox, 4, 1); - upperLayout->addWidget(activeGroupCriteriaLabel, 5, 0); - upperLayout->addWidget(activeGroupCriteriaComboBox, 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(deckTagsDisplayWidget, 6, 1); + + upperLayout->addWidget(activeGroupCriteriaLabel, 7, 0); + upperLayout->addWidget(activeGroupCriteriaComboBox, 7, 1); hashLabel1 = new QLabel(); hashLabel1->setObjectName("hashLabel1"); @@ -300,15 +324,19 @@ void DeckEditorDeckDockWidget::initializeFormats() // Ensure no selection is visible initially formatComboBox->setCurrentIndex(-1); } - connect(formatComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int index) { + QString formatKey; if (index >= 0) { - QString formatKey = formatComboBox->itemData(index).toString(); + formatKey = formatComboBox->itemData(index).toString(); deckStateManager->setFormat(formatKey); } else { deckStateManager->setFormat(""); // clear format if deselected } + + commanderBracketWidget->setDeck(deckStateManager->getDeckListShared()); }); + + commanderBracketWidget->setDeck(deckStateManager->getDeckListShared()); } ExactCard DeckEditorDeckDockWidget::getCurrentCard() @@ -317,7 +345,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(); @@ -427,6 +457,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( @@ -461,8 +520,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) { @@ -484,12 +546,15 @@ void DeckEditorDeckDockWidget::syncDisplayWidgetsToModel() syncBannerCardComboBoxSelectionWithDeck(); updateBannerCardComboBox(); bannerCardComboBox->blockSignals(false); + updatePlaymatLabel(); updateHash(); formatComboBox->blockSignals(true); formatComboBox->setCurrentIndex(formatComboBox->findData(deckStateManager->getMetadata().gameFormat)); formatComboBox->blockSignals(false); + commanderBracketWidget->setDeck(deckStateManager->getDeckListShared()); + deckTagsDisplayWidget->blockSignals(true); deckTagsDisplayWidget->setTags(deckStateManager->getMetadata().tags); deckTagsDisplayWidget->blockSignals(false); @@ -716,14 +781,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(); @@ -743,6 +1007,7 @@ void DeckEditorDeckDockWidget::retranslateUi() commentsLabel->setText(tr("&Comments:")); activeGroupCriteriaLabel->setText(tr("Group by:")); formatLabel->setText(tr("Format:")); + commanderBracketWidget->retranslateUi(); hashLabel1->setText(tr("Hash:")); @@ -750,4 +1015,4 @@ void DeckEditorDeckDockWidget::retranslateUi() aDecrement->setText(tr("&Decrement number")); aRemoveCard->setText(tr("&Remove row")); aSwapCard->setText(tr("Swap card to/from sideboard")); -} \ No newline at end of file +} 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 8dddf5882..1e5f4e677 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 @@ -15,12 +15,18 @@ #include "deck_list_history_manager_widget.h" #include "deck_list_style_proxy.h" +#include +#include #include #include +#include +#include #include #include #include +#include +class CommanderBracketWidget; class DeckListModel; class AbstractTabDeckEditor; class DeckEditorDeckDockWidget : public QDockWidget @@ -32,6 +38,8 @@ public: DeckListStyleProxy *proxy; QTreeView *deckView; QComboBox *bannerCardComboBox; + QLabel *playmatLabel; + QPushButton *playmatSettingsButton; void createDeckDock(); ExactCard getCurrentCard(); void retranslateUi(); @@ -89,16 +97,25 @@ private: QAction *aRemoveCard, *aIncrement, *aDecrement, *aSwapCard; + CommanderBracketWidget *commanderBracketWidget; + DeckListModel *getModel() const; [[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 6b18db82e..c6b6e4416 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 @@ -1,8 +1,10 @@ #include "deck_editor_filter_dock_widget.h" #include "../../../client/settings/cache_settings.h" +#include "../../../client/settings/shortcuts_settings.h" #include "../../../filters/filter_builder.h" #include "../../../filters/filter_tree_model.h" +#include "../../pixel_map_generator.h" #include #include @@ -41,11 +43,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_printing_selector_dock_widget.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_editor_printing_selector_dock_widget.cpp index a91601f2e..06d8d1de7 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 @@ -5,6 +5,7 @@ #include "printing_disabled_info_widget.h" #include +#include DeckEditorPrintingSelectorDockWidget::DeckEditorPrintingSelectorDockWidget(AbstractTabDeckEditor *parent) : QDockWidget(parent), deckEditor(parent) @@ -18,8 +19,9 @@ DeckEditorPrintingSelectorDockWidget::DeckEditorPrintingSelectorDockWidget(Abstr createPrintingSelectorDock(); printingDisabledInfoWidget = new PrintingDisabledInfoWidget(this); - setVisibleWidget(SettingsCache::instance().getOverrideAllCardArtWithPersonalPreference()); - connect(&SettingsCache::instance(), &SettingsCache::overrideAllCardArtWithPersonalPreferenceChanged, this, + setVisibleWidget(SettingsCache::instance().cardsDisplay().getOverrideAllCardArtWithPersonalPreference()); + connect(&SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::overrideAllCardArtWithPersonalPreferenceChanged, this, &DeckEditorPrintingSelectorDockWidget::setVisibleWidget); retranslateUi(); 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..c93f12b34 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,5 +1,6 @@ #include "deck_list_history_manager_widget.h" +#include "../../pixel_map_generator.h" #include "deck_state_manager.h" DeckListHistoryManagerWidget::DeckListHistoryManagerWidget(DeckStateManager *_deckStateManager, @@ -10,7 +11,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 +20,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 +32,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_state_manager.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp index f8fb450ce..cfe989e9f 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,20 @@ #include "deck_state_manager.h" +#include "../../../client/settings/cache_settings.h" + #include #include +#include +#include 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 +150,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 +267,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 +287,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 +301,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 +317,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 +541,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 10312d0a0..2c8b34a39 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h @@ -5,6 +5,7 @@ #include "deck_list_model.h" #include +#include #include class DeckListHistoryManager; @@ -57,6 +58,14 @@ public: */ const DeckList &getDeckList() const; + /** + * @brief Gets the underlying DeckList. + */ + QSharedPointer getDeckListShared() const + { + return deckList; + } + /** * @brief Creates a LoadedDeck containing the contents of the current deck and the current LoadInfo. * @@ -163,6 +172,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); ///@} @@ -227,6 +237,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. @@ -248,6 +320,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..9a0be2570 --- /dev/null +++ b/cockatrice/src/interface/widgets/deck_editor/deck_zone_dialog.cpp @@ -0,0 +1,145 @@ +#include "deck_zone_dialog.h" + +#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..6f55617a8 --- /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/dialogs/dlg_connect.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp index 2a7fc6c06..700012d45 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_connect.cpp @@ -1,6 +1,7 @@ #include "dlg_connect.h" #include "../../../client/settings/cache_settings.h" +#include "../../pixel_map_generator.h" #include #include @@ -12,6 +13,7 @@ #include #include #include +#include #include DlgConnect::DlgConnect(QWidget *parent) : QDialog(parent) @@ -20,7 +22,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); @@ -28,7 +30,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); @@ -98,7 +100,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); @@ -270,6 +272,7 @@ void DlgConnect::updateDisplayInfo(const QString &saveName) hostEdit->setText(_data.at(1)); portEdit->setText(_data.at(2)); playernameEdit->setText(_data.at(3)); + playernameEdit->setFocus(); savePasswordCheckBox->setChecked(savePasswordStatus); if (savePasswordStatus) { 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..a4a31d78d 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,17 @@ #include "dlg_convert_deck_to_cod_format.h" +#include "../../../client/settings/cache_settings.h" +#include "../../deck_loader/deck_loader.h" + #include #include +#include +#include +#include #include +#include #include +#include DialogConvertDeckToCodFormat::DialogConvertDeckToCodFormat(QWidget *parent) : QDialog(parent) { @@ -38,3 +46,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..526582135 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 @@ -13,6 +13,9 @@ #include #include #include +#include + +class QWidget; class DialogConvertDeckToCodFormat : public QDialog { @@ -24,6 +27,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 f892801f1..59f3e033d 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp @@ -17,6 +17,7 @@ #include #include #include +#include #include void DlgCreateGame::sharedCtor() @@ -49,7 +50,7 @@ void DlgCreateGame::sharedCtor() auto *gameTypeRadioButton = new QRadioButton(gameTypeIterator.value(), this); gameTypeLayout->addWidget(gameTypeRadioButton); gameTypeCheckBoxes.insert(gameTypeIterator.key(), gameTypeRadioButton); - bool isChecked = SettingsCache::instance().getGameTypes().contains(gameTypeIterator.value() + ", "); + bool isChecked = SettingsCache::instance().game().getGameTypes().contains(gameTypeIterator.value() + ", "); gameTypeCheckBoxes[gameTypeIterator.key()]->setChecked(isChecked); } auto *gameTypeGroupBox = new QGroupBox(tr("Game type")); @@ -154,23 +155,23 @@ DlgCreateGame::DlgCreateGame(TabRoom *_room, const QMap &_gameType { sharedCtor(); - rememberGameSettings->setChecked(SettingsCache::instance().getRememberGameSettings()); - descriptionEdit->setText(SettingsCache::instance().getGameDescription()); - maxPlayersEdit->setValue(SettingsCache::instance().getMaxPlayers()); + rememberGameSettings->setChecked(SettingsCache::instance().game().getRememberGameSettings()); + descriptionEdit->setText(SettingsCache::instance().game().getGameDescription()); + maxPlayersEdit->setValue(SettingsCache::instance().game().getMaxPlayers()); if (room && room->getUserInfo()->user_level() & ServerInfo_User::IsRegistered) { - onlyBuddiesCheckBox->setChecked(SettingsCache::instance().getOnlyBuddies()); - onlyRegisteredCheckBox->setChecked(SettingsCache::instance().getOnlyRegistered()); + onlyBuddiesCheckBox->setChecked(SettingsCache::instance().game().getOnlyBuddies()); + onlyRegisteredCheckBox->setChecked(SettingsCache::instance().game().getOnlyRegistered()); } else { onlyBuddiesCheckBox->setEnabled(false); onlyRegisteredCheckBox->setEnabled(false); } - spectatorsAllowedCheckBox->setChecked(SettingsCache::instance().getSpectatorsAllowed()); - spectatorsNeedPasswordCheckBox->setChecked(SettingsCache::instance().getSpectatorsNeedPassword()); - spectatorsCanTalkCheckBox->setChecked(SettingsCache::instance().getSpectatorsCanTalk()); - spectatorsSeeEverythingCheckBox->setChecked(SettingsCache::instance().getSpectatorsCanSeeEverything()); - createGameAsSpectatorCheckBox->setChecked(SettingsCache::instance().getCreateGameAsSpectator()); - startingLifeTotalEdit->setValue(SettingsCache::instance().getDefaultStartingLifeTotal()); - shareDecklistsOnLoadCheckBox->setChecked(SettingsCache::instance().getShareDecklistsOnLoad()); + spectatorsAllowedCheckBox->setChecked(SettingsCache::instance().game().getSpectatorsAllowed()); + spectatorsNeedPasswordCheckBox->setChecked(SettingsCache::instance().game().getSpectatorsNeedPassword()); + spectatorsCanTalkCheckBox->setChecked(SettingsCache::instance().game().getSpectatorsCanTalk()); + spectatorsSeeEverythingCheckBox->setChecked(SettingsCache::instance().game().getSpectatorsCanSeeEverything()); + createGameAsSpectatorCheckBox->setChecked(SettingsCache::instance().game().getCreateGameAsSpectator()); + startingLifeTotalEdit->setValue(SettingsCache::instance().game().getDefaultStartingLifeTotal()); + shareDecklistsOnLoadCheckBox->setChecked(SettingsCache::instance().game().getShareDecklistsOnLoad()); if (!rememberGameSettings->isChecked()) { actReset(); @@ -213,6 +214,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) { @@ -291,20 +293,20 @@ void DlgCreateGame::actOK() } } - SettingsCache::instance().setRememberGameSettings(rememberGameSettings->isChecked()); + SettingsCache::instance().game().setRememberGameSettings(rememberGameSettings->isChecked()); if (rememberGameSettings->isChecked()) { - SettingsCache::instance().setGameDescription(descriptionEdit->text()); - SettingsCache::instance().setMaxPlayers(maxPlayersEdit->value()); - SettingsCache::instance().setOnlyBuddies(onlyBuddiesCheckBox->isChecked()); - SettingsCache::instance().setOnlyRegistered(onlyRegisteredCheckBox->isChecked()); - SettingsCache::instance().setSpectatorsAllowed(spectatorsAllowedCheckBox->isChecked()); - SettingsCache::instance().setSpectatorsNeedPassword(spectatorsNeedPasswordCheckBox->isChecked()); - SettingsCache::instance().setSpectatorsCanTalk(spectatorsCanTalkCheckBox->isChecked()); - SettingsCache::instance().setSpectatorsCanSeeEverything(spectatorsSeeEverythingCheckBox->isChecked()); - SettingsCache::instance().setCreateGameAsSpectator(createGameAsSpectatorCheckBox->isChecked()); - SettingsCache::instance().setDefaultStartingLifeTotal(startingLifeTotalEdit->value()); - SettingsCache::instance().setShareDecklistsOnLoad(shareDecklistsOnLoadCheckBox->isChecked()); - SettingsCache::instance().setGameTypes(_gameTypes); + SettingsCache::instance().game().setGameDescription(descriptionEdit->text()); + SettingsCache::instance().game().setMaxPlayers(maxPlayersEdit->value()); + SettingsCache::instance().game().setOnlyBuddies(onlyBuddiesCheckBox->isChecked()); + SettingsCache::instance().game().setOnlyRegistered(onlyRegisteredCheckBox->isChecked()); + SettingsCache::instance().game().setSpectatorsAllowed(spectatorsAllowedCheckBox->isChecked()); + SettingsCache::instance().game().setSpectatorsNeedPassword(spectatorsNeedPasswordCheckBox->isChecked()); + SettingsCache::instance().game().setSpectatorsCanTalk(spectatorsCanTalkCheckBox->isChecked()); + SettingsCache::instance().game().setSpectatorsCanSeeEverything(spectatorsSeeEverythingCheckBox->isChecked()); + SettingsCache::instance().game().setCreateGameAsSpectator(createGameAsSpectatorCheckBox->isChecked()); + SettingsCache::instance().game().setDefaultStartingLifeTotal(startingLifeTotalEdit->value()); + SettingsCache::instance().game().setShareDecklistsOnLoad(shareDecklistsOnLoadCheckBox->isChecked()); + SettingsCache::instance().game().setGameTypes(_gameTypes); } PendingCommand *pend = room->prepareRoomCommand(cmd); connect(pend, &PendingCommand::finished, this, &DlgCreateGame::checkResponse); 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 2d6ee7909..e9c73b025 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_default_tags_editor.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_default_tags_editor.cpp @@ -5,6 +5,7 @@ #include #include #include +#include DlgDefaultTagsEditor::DlgDefaultTagsEditor(QWidget *parent) : QDialog(parent) { @@ -54,7 +55,7 @@ void DlgDefaultTagsEditor::retranslateUi() void DlgDefaultTagsEditor::loadStringList() { listWidget->clear(); - QStringList tags = SettingsCache::instance().getVisualDeckStorageDefaultTagsList(); + QStringList tags = SettingsCache::instance().visualDeckStorage().getVisualDeckStorageDefaultTagsList(); for (const QString &tag : tags) { auto *item = new QListWidgetItem(); // Create item but don't insert yet @@ -147,6 +148,6 @@ void DlgDefaultTagsEditor::confirmChanges() updatedList.append(lineEdit->text()); } } - SettingsCache::instance().setVisualDeckStorageDefaultTagsList(updatedList); + SettingsCache::instance().visualDeckStorage().setVisualDeckStorageDefaultTagsList(updatedList); accept(); // Close dialog and confirm changes } diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_password.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_edit_password.cpp index 63181b44f..4310c03fc 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_password.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_password.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include DlgEditPassword::DlgEditPassword(QWidget *parent) : QDialog(parent) diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp index f249976c2..a56aa8e35 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_edit_tokens.cpp @@ -1,5 +1,6 @@ #include "dlg_edit_tokens.h" +#include "../../pixel_map_generator.h" #include "../interface/widgets/utility/get_text_with_max.h" #include @@ -90,10 +91,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_filter_games.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_filter_games.cpp index 8f498cc2c..7e174a228 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_filter_games.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_filter_games.cpp @@ -30,7 +30,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 +57,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 +193,7 @@ GameFilterConfigs DlgFilterGames::getFilters() const hideNotBuddyCreatedGames->isChecked(), hideOpenDecklistGames->isChecked(), gameNameFilterEdit->text(), - getCreatorNameFilters(), + getHostNameFilters(), getGameTypeFilter(), maxPlayersFilterMinSpinBox->value(), maxPlayersFilterMaxSpinBox->value(), @@ -216,9 +216,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..1cf822b89 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_filter_games.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_filter_games.h @@ -35,7 +35,7 @@ private: QCheckBox *hideNotBuddyCreatedGames; QCheckBox *hideOpenDecklistGames; QLineEdit *gameNameFilterEdit; - QLineEdit *creatorNameFilterEdit; + QLineEdit *hostNameFilterEdit; QMap gameTypeFilterCheckBoxes; QSpinBox *maxPlayersFilterMinSpinBox; QSpinBox *maxPlayersFilterMaxSpinBox; @@ -50,7 +50,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 14c3cda47..c21955373 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_challenge.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_challenge.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include DlgForgotPasswordChallenge::DlgForgotPasswordChallenge(QWidget *parent) : QDialog(parent) 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 fc934d082..4ee0c9c88 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_request.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_request.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include DlgForgotPasswordRequest::DlgForgotPasswordRequest(QWidget *parent) : QDialog(parent) 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 d30887988..2800d5c0c 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_reset.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_forgot_password_reset.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include DlgForgotPasswordReset::DlgForgotPasswordReset(QWidget *parent) : QDialog(parent) 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..b5451bb2c --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_invite_to_game.cpp @@ -0,0 +1,112 @@ +#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 + +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..bbb589bdb --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_invite_to_game.h @@ -0,0 +1,46 @@ +/** + * @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 + +class QLineEdit; +class QPushButton; +class TabSupervisor; +class UserListWidget; + +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 b87800909..626ebed50 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_load_deck.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_load_deck.cpp @@ -3,11 +3,13 @@ #include "../../../client/settings/cache_settings.h" #include "../../deck_loader/deck_loader.h" +#include +#include DlgLoadDeck::DlgLoadDeck(QWidget *parent) : QFileDialog(parent, tr("Load Deck")) { QString startingDir = SettingsCache::instance().recents().getLatestDeckDirPath(); if (startingDir.isEmpty()) { - startingDir = SettingsCache::instance().getDeckPath(); + startingDir = SettingsCache::instance().paths().getDeckPath(); } setDirectory(startingDir); 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 9ceb35d78..a3242c797 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 @@ -1,9 +1,9 @@ #include "dlg_load_deck_from_clipboard.h" #include "../../../client/settings/cache_settings.h" +#include "../../../client/settings/shortcuts_settings.h" #include "../../deck_loader/card_node_function.h" #include "../../deck_loader/deck_loader.h" -#include "dlg_settings.h" #include #include 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 d2d291556..eca0ec658 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_local_game_options.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_local_game_options.cpp @@ -9,6 +9,7 @@ #include #include #include +#include DlgLocalGameOptions::DlgLocalGameOptions(QWidget *parent) : QDialog(parent) { @@ -53,10 +54,10 @@ DlgLocalGameOptions::DlgLocalGameOptions(QWidget *parent) : QDialog(parent) mainLayout->addWidget(buttonBox); setLayout(mainLayout); - rememberSettingsCheckBox->setChecked(SettingsCache::instance().getLocalGameRememberSettings()); + rememberSettingsCheckBox->setChecked(SettingsCache::instance().game().getLocalGameRememberSettings()); if (rememberSettingsCheckBox->isChecked()) { - numberPlayersEdit->setValue(SettingsCache::instance().getLocalGameMaxPlayers()); - startingLifeTotalEdit->setValue(SettingsCache::instance().getLocalGameStartingLifeTotal()); + numberPlayersEdit->setValue(SettingsCache::instance().game().getLocalGameMaxPlayers()); + startingLifeTotalEdit->setValue(SettingsCache::instance().game().getLocalGameStartingLifeTotal()); } setWindowTitle(tr("Local game options")); @@ -67,10 +68,10 @@ DlgLocalGameOptions::DlgLocalGameOptions(QWidget *parent) : QDialog(parent) void DlgLocalGameOptions::actOK() { - SettingsCache::instance().setLocalGameRememberSettings(rememberSettingsCheckBox->isChecked()); + SettingsCache::instance().game().setLocalGameRememberSettings(rememberSettingsCheckBox->isChecked()); if (rememberSettingsCheckBox->isChecked()) { - SettingsCache::instance().setLocalGameMaxPlayers(numberPlayersEdit->value()); - SettingsCache::instance().setLocalGameStartingLifeTotal(startingLifeTotalEdit->value()); + SettingsCache::instance().game().setLocalGameMaxPlayers(numberPlayersEdit->value()); + SettingsCache::instance().game().setLocalGameStartingLifeTotal(startingLifeTotalEdit->value()); } accept(); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp index b17a9306f..556c270be 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_manage_sets.cpp @@ -1,6 +1,7 @@ #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" @@ -20,6 +21,9 @@ #include #include #include +#include +#include +#include WndSets::WndSets(QWidget *parent) : QMainWindow(parent) { @@ -32,28 +36,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); @@ -63,7 +67,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); @@ -153,7 +157,7 @@ WndSets::WndSets(QWidget *parent) : QMainWindow(parent) sortWarning->setLayout(sortWarningLayout); sortWarning->setVisible(false); - includeRebalancedCards = SettingsCache::instance().getIncludeRebalancedCards(); + includeRebalancedCards = SettingsCache::instance().cardsDisplay().getIncludeRebalancedCards(); QCheckBox *includeRebalancedCardsCheckBox = new QCheckBox(tr("Include cards rebalanced for Alchemy [requires restart]")); includeRebalancedCardsCheckBox->setChecked(includeRebalancedCards); @@ -253,7 +257,7 @@ void WndSets::includeRebalancedCardsChanged(bool _includeRebalancedCards) void WndSets::actSave() { model->save(CardDatabaseManager::getInstance()); - SettingsCache::instance().setIncludeRebalancedCards(includeRebalancedCards); + SettingsCache::instance().cardsDisplay().setIncludeRebalancedCards(includeRebalancedCards); CardPictureLoader::clearPixmapCache(); const auto reloadOk1 = QtConcurrent::run([] { CardDatabaseManager::getInstance()->reloadCardDatabasesAndNotify(); 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..1af83bfb5 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.cpp @@ -0,0 +1,297 @@ +#include "dlg_my_reports.h" + +#include "../utility/report_utils.h" +#include "abstract_client.h" + +#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_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..09b1963dd --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_my_reports.h @@ -0,0 +1,52 @@ +#ifndef COCKATRICE_DLG_MY_REPORTS_H +#define COCKATRICE_DLG_MY_REPORTS_H + +#include +#include +#include +#include + +class AbstractClient; +class QTableWidget; +class QTextEdit; +class QLineEdit; +class QPushButton; +class QLabel; + +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 942782403..6d022861c 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp @@ -1,17 +1,63 @@ #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 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.")); @@ -320,26 +366,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); @@ -351,13 +399,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; @@ -374,5 +524,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..ce14eb427 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_register.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_register.h @@ -1,25 +1,24 @@ -/** - * @file dlg_register.h - * @ingroup AccountDialogs - */ -//! \todo Document this file. - #ifndef DLG_REGISTER_H #define DLG_REGISTER_H #include #include #include +#include +class HandlePublicServers; class QLabel; class QPushButton; -class QCheckBox; +class QRadioButton; +class UserConnection_Information; class DlgRegister : public QDialog { Q_OBJECT public: explicit DlgRegister(QWidget *parent = nullptr); + ~DlgRegister() override; + [[nodiscard]] QString getHost() const { return hostEdit->text(); @@ -48,15 +47,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..9519846e2 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.cpp @@ -0,0 +1,191 @@ +#include "dlg_report_user.h" + +#include "abstract_client.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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; + categoryBox->addItem(tr("Cheating / Unsporting behavior"), "cheating"); + categoryBox->setItemData(categoryBox->count() - 1, + tr("Using external tools, card marked manipulation, or exploiting game bugs"), + Qt::ToolTipRole); + categoryBox->addItem(tr("Harassment / Abuse"), "harassment"); + categoryBox->setItemData(categoryBox->count() - 1, tr("Threatening, bullying, or persistent unwanted contact"), + Qt::ToolTipRole); + categoryBox->addItem(tr("Hate speech"), "hate_speech"); + categoryBox->setItemData(categoryBox->count() - 1, + tr("Discriminatory language targeting race, gender, religion, etc."), Qt::ToolTipRole); + categoryBox->addItem(tr("Spam"), "spam"); + categoryBox->setItemData(categoryBox->count() - 1, tr("Repeated unwanted messages or advertisements"), + Qt::ToolTipRole); + categoryBox->addItem(tr("Other"), "other"); + categoryBox->setItemData(categoryBox->count() - 1, tr("Any behavior not covered by the above categories"), + 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.")); + } +} 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..c59fc5084 --- /dev/null +++ b/cockatrice/src/interface/widgets/dialogs/dlg_report_user.h @@ -0,0 +1,42 @@ +#ifndef COCKATRICE_DLG_REPORT_USER_H +#define COCKATRICE_DLG_REPORT_USER_H + +#include +#include + +class AbstractClient; +class QComboBox; +class QDialogButtonBox; +class QLineEdit; +class QTextEdit; +class QLabel; + +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: + 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 bc846bf3d..8eed083e1 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 @@ -321,11 +321,7 @@ void DlgSelectSetForCards::dropEvent(QDropEvent *event) { QByteArray itemData = event->mimeData()->data("application/x-setentrywidget"); QString draggedSetName = QString::fromUtf8(itemData); -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) QPoint adjustedPos = event->position().toPoint() + QPoint(0, scrollArea->verticalScrollBar()->value()); -#else - QPoint adjustedPos = event->pos() + QPoint(0, scrollArea->verticalScrollBar()->value()); -#endif int dropIndex = -1; for (int i = 0; i < listLayout->count(); ++i) { QWidget *widget = listLayout->itemAt(i)->widget(); @@ -491,11 +487,7 @@ void SetEntryWidget::mousePressEvent(QMouseEvent *event) } } -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) void SetEntryWidget::enterEvent(QEnterEvent *event) -#else -void SetEntryWidget::enterEvent(QEvent *event) -#endif { QWidget::enterEvent(event); // Call the base class handler // Highlight the widget by changing the background color only for the widget itself 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 92f285aa0..4528552d0 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 @@ -87,11 +87,7 @@ public: public slots: void mousePressEvent(QMouseEvent *event) override; -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) void enterEvent(QEnterEvent *event) override; -#else - void enterEvent(QEvent *event) override; -#endif void leaveEvent(QEvent *event) override; void dragMoveEvent(QDragMoveEvent *event) override; diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp index ff42596ea..581920dbc 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.cpp @@ -1,11 +1,19 @@ +/** + * @file dlg_settings.cpp + * @brief Implementation of the main settings dialog + * @ingroup Dialogs + */ #include "dlg_settings.h" #include "../../../client/settings/cache_settings.h" +#include "../../pixel_map_generator.h" #include "../main.h" #include "../settings_page/appearance_settings_page.h" #include "../settings_page/deck_editor_settings_page.h" #include "../settings_page/general_settings_page.h" #include "../settings_page/messages_settings_page.h" +#include "../settings_page/settings_search_delegate.h" +#include "../settings_page/settings_search_model.h" #include "../settings_page/shortcut_settings_page.h" #include "../settings_page/sound_settings_page.h" #include "../settings_page/storage_settings_page.h" @@ -13,18 +21,36 @@ #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 +/** + * @brief Wraps a widget in a scroll area for long settings pages + * @param widget The widget to wrap + * @return The scroll area containing the widget + */ static QScrollArea *makeScrollable(QWidget *widget) { widget->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Maximum); @@ -38,121 +64,412 @@ static QScrollArea *makeScrollable(QWidget *widget) return scrollArea; } -DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent) +/** + * @brief Returns the theme icon resources for each settings page, indexed by SettingsPage order + */ +static QStringList pageIconResources() +{ + return {QStringLiteral("theme:config/general"), QStringLiteral("theme:config/appearance"), + QStringLiteral("theme:config/interface"), QStringLiteral("theme:config/deckeditor"), + QStringLiteral("theme:config/storage"), QStringLiteral("theme:config/messages"), + QStringLiteral("theme:config/sound"), QStringLiteral("theme:config/shorcuts")}; +} + +DlgSettings::DlgSettings(QWidget *parent) : QDialog(parent), currentTabIndex(0), searchActive(false) { auto rec = QGuiApplication::primaryScreen()->availableGeometry(); - this->setMinimumSize(qMin(700, rec.width()), qMin(700, rec.height())); + setMinimumSize(qMin(750, rec.width()), qMin(700, rec.height())); - connect(&SettingsCache::instance(), &SettingsCache::langChanged, this, &DlgSettings::updateLanguage); + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::updateLanguage); - contentsWidget = new QListWidget; - contentsWidget->setViewMode(QListView::IconMode); - contentsWidget->setIconSize(QSize(58, 50)); - contentsWidget->setMovement(QListView::Static); - contentsWidget->setMinimumHeight(85); - contentsWidget->setMaximumHeight(85); - contentsWidget->setSpacing(5); + setupUi(); - pagesWidget = new QStackedWidget; - pagesWidget->addWidget(makeScrollable(new GeneralSettingsPage)); - pagesWidget->addWidget(makeScrollable(new AppearanceSettingsPage)); - pagesWidget->addWidget(makeScrollable(new UserInterfaceSettingsPage)); - pagesWidget->addWidget(new DeckEditorSettingsPage); - pagesWidget->addWidget(makeScrollable(new StorageSettingsPage)); - pagesWidget->addWidget(new MessagesSettingsPage); - pagesWidget->addWidget(new SoundSettingsPage); - pagesWidget->addWidget(new ShortcutSettingsPage); - - createIcons(); - contentsWidget->setCurrentRow(0); - - auto *vboxLayout = new QVBoxLayout; - vboxLayout->addWidget(contentsWidget); - vboxLayout->addWidget(pagesWidget); - - auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok); - connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgSettings::close); - - auto *mainLayout = new QVBoxLayout; - mainLayout->addLayout(vboxLayout); - mainLayout->addSpacing(2); - mainLayout->addWidget(buttonBox); - setLayout(mainLayout); - - connect(&SettingsCache::instance(), &SettingsCache::langChanged, this, &DlgSettings::retranslateUi); + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &DlgSettings::retranslateUi); retranslateUi(); + searchEdit->setFocus(); + adjustSize(); } -void DlgSettings::createIcons() +void DlgSettings::setupUi() { - generalButton = new QListWidgetItem(contentsWidget); - generalButton->setTextAlignment(Qt::AlignHCenter); - generalButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); - generalButton->setIcon(QPixmap("theme:config/general")); + // Search bar + searchEdit = new QLineEdit; + searchEdit->setClearButtonEnabled(true); + searchEdit->addAction(themePixmap(QStringLiteral("icons/search")), QLineEdit::LeadingPosition); + searchEdit->installEventFilter(this); + connect(searchEdit, &QLineEdit::textChanged, this, &DlgSettings::onSearchTextChanged); - appearanceButton = new QListWidgetItem(contentsWidget); - appearanceButton->setTextAlignment(Qt::AlignHCenter); - appearanceButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); - appearanceButton->setIcon(QPixmap("theme:config/appearance")); + auto *searchLayout = new QHBoxLayout; + searchLayout->addWidget(searchEdit); - userInterfaceButton = new QListWidgetItem(contentsWidget); - userInterfaceButton->setTextAlignment(Qt::AlignHCenter); - userInterfaceButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); - userInterfaceButton->setIcon(QPixmap("theme:config/interface")); + // Tab bar (built in setupTabBar) + setupTabBar(); - deckEditorButton = new QListWidgetItem(contentsWidget); - deckEditorButton->setTextAlignment(Qt::AlignHCenter); - deckEditorButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); - deckEditorButton->setIcon(QPixmap("theme:config/deckeditor")); + // Pages stacked widget + pagesWidget = new QStackedWidget; - storageButton = new QListWidgetItem(contentsWidget); - storageButton->setTextAlignment(Qt::AlignHCenter); - storageButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); - storageButton->setIcon(QPixmap("theme:config/storage")); + auto *generalPage = new GeneralSettingsPage; + auto *appearancePage = new AppearanceSettingsPage; + auto *userInterfacePage = new UserInterfaceSettingsPage; + auto *deckEditorPage = new DeckEditorSettingsPage; + auto *storagePage = new StorageSettingsPage; + auto *messagesPage = new MessagesSettingsPage; + auto *soundPage = new SoundSettingsPage; + auto *shortcutsPage = new ShortcutSettingsPage; - messagesButton = new QListWidgetItem(contentsWidget); - messagesButton->setTextAlignment(Qt::AlignHCenter); - messagesButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); - messagesButton->setIcon(QPixmap("theme:config/messages")); + pages.append(generalPage); + pages.append(appearancePage); + pages.append(userInterfacePage); + pages.append(deckEditorPage); + pages.append(storagePage); + pages.append(messagesPage); + pages.append(soundPage); + pages.append(shortcutsPage); - soundButton = new QListWidgetItem(contentsWidget); - soundButton->setTextAlignment(Qt::AlignHCenter); - soundButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); - soundButton->setIcon(QPixmap("theme:config/sound")); + pagesWidget->addWidget(makeScrollable(generalPage)); + pagesWidget->addWidget(makeScrollable(appearancePage)); + pagesWidget->addWidget(makeScrollable(userInterfacePage)); + pagesWidget->addWidget(makeScrollable(deckEditorPage)); + pagesWidget->addWidget(makeScrollable(storagePage)); + pagesWidget->addWidget(makeScrollable(messagesPage)); + pagesWidget->addWidget(soundPage); + pagesWidget->addWidget(shortcutsPage); - shortcutsButton = new QListWidgetItem(contentsWidget); - shortcutsButton->setTextAlignment(Qt::AlignHCenter); - shortcutsButton->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); - shortcutsButton->setIcon(QPixmap("theme:config/shorcuts")); + Q_ASSERT(pages.size() == NumPages); - connect(contentsWidget, &QListWidget::currentItemChanged, this, &DlgSettings::changePage); + // Search results view (hidden by default) + searchResultsView = new QListView; + searchResultsView->setUniformItemSizes(false); + searchResultsView->setSelectionMode(QAbstractItemView::SingleSelection); + searchResultsView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); + searchResultsView->setVisible(false); + searchResultsView->setStyleSheet( + "QListView::item:selected { background: palette(highlight); color: palette(highlighted-text); }"); + + searchModel = new SettingsSearchModel(this); + searchDelegate = new SettingsSearchDelegate(this); + searchResultsView->setModel(searchModel); + searchResultsView->setItemDelegate(searchDelegate); + connect(searchResultsView, &QListView::clicked, this, &DlgSettings::onSearchResultClicked); + + connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, [this] { + const QStringList icons = pageIconResources(); + for (int i = 0; i < tabButtons.size() && i < icons.size(); ++i) { + tabButtons[i]->setIcon(QPixmap(icons[i])); + } + searchDelegate->setPageIcons(icons); + searchResultsView->viewport()->update(); + }); + + // Build search index after pages are created + buildSearchIndex(); + + // Pages container (stacked widget + search results overlay) + pagesContainer = new QWidget; + auto *containerLayout = new QStackedLayout; + containerLayout->setStackingMode(QStackedLayout::StackAll); + containerLayout->addWidget(pagesWidget); + containerLayout->addWidget(searchResultsView); + pagesContainer->setLayout(containerLayout); + + // Bottom buttons + auto *buttonBox = new QHBoxLayout; + buttonBox->addStretch(); + okButton = new QPushButton; + okButton->setDefault(true); + connect(okButton, &QPushButton::clicked, this, &DlgSettings::close); + buttonBox->addWidget(okButton); + + // Main layout + auto *mainLayout = new QVBoxLayout; + mainLayout->addLayout(searchLayout); + mainLayout->addWidget(tabBarWidget); + auto *separator = new QFrame; + separator->setFrameShape(QFrame::HLine); + separator->setFrameShadow(QFrame::Sunken); + mainLayout->addWidget(separator); + mainLayout->addWidget(pagesContainer); + mainLayout->addSpacing(4); + mainLayout->addLayout(buttonBox); + setLayout(mainLayout); + + // Keyboard shortcuts + auto *searchShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_F), this); + connect(searchShortcut, &QShortcut::activated, searchEdit, qOverload<>(&QLineEdit::setFocus)); + + auto *nextTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::Key_Tab), this); + connect(nextTabShortcut, &QShortcut::activated, this, [this] { + int next = (currentTabIndex + 1) % tabButtons.size(); + setActiveTab(next); + }); + + auto *prevTabShortcut = new QShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_Tab), this); + connect(prevTabShortcut, &QShortcut::activated, this, [this] { + int prev = (currentTabIndex - 1 + tabButtons.size()) % tabButtons.size(); + setActiveTab(prev); + }); + + // Initialize to first tab + setActiveTab(0); } -void DlgSettings::changePage(QListWidgetItem *current, QListWidgetItem *previous) +void DlgSettings::setupTabBar() { - if (!current) { - current = previous; + tabBarWidget = new QWidget; + auto *tabLayout = new QHBoxLayout; + tabLayout->setContentsMargins(0, 0, 0, 0); + tabLayout->setSpacing(2); + + const QStringList iconResources = pageIconResources(); + + for (int i = 0; i < iconResources.size(); ++i) { + auto *tabButton = new QToolButton; + tabButton->setCheckable(true); + tabButton->setIcon(QPixmap(iconResources[i])); + tabButton->setIconSize(QSize(48, 48)); + tabButton->setToolButtonStyle(Qt::ToolButtonTextUnderIcon); + tabButton->setAutoExclusive(true); + tabButton->setMinimumHeight(85); + tabButton->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Fixed); + + connect(tabButton, &QToolButton::clicked, this, [this, idx = i] { onTabClicked(idx); }); + + tabButtons.append(tabButton); + tabLayout->addWidget(tabButton); } - pagesWidget->setCurrentIndex(contentsWidget->row(current)); + tabBarWidget->setLayout(tabLayout); +} + +void DlgSettings::buildSearchIndex() +{ + QList allEntries; + + const QStringList pageNames = translatedPageNames(); + searchDelegate->setPageNames(pageNames); + searchDelegate->setPageIcons(pageIconResources()); + + for (int i = 0; i < pages.size(); ++i) { + QList pageEntries = pages[i]->getSearchEntries(); + for (auto &entry : pageEntries) { + if (entry.pageIndex == -1) { + entry.pageIndex = i; + } + } + allEntries.append(pageEntries); + } + + searchModel->setSourceEntries(allEntries); +} + +void DlgSettings::onTabClicked(int index) +{ + if (searchActive) { + switchToTabMode(); + } + setActiveTab(index); +} + +void DlgSettings::setActiveTab(int index) +{ + if (index < 0 || index >= tabButtons.size()) { + return; + } + + currentTabIndex = index; + pagesWidget->setCurrentIndex(index); + + for (int i = 0; i < tabButtons.size(); ++i) { + tabButtons[i]->setChecked(i == index); + } + + // Style active tab with a thick accent border + subtle background tint + for (int i = 0; i < tabButtons.size(); ++i) { + if (i == index) { + tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 3px solid palette(highlight); " + "border-top-left-radius: 4px; border-top-right-radius: 4px; " + "background: palette(window); padding-bottom: 1px; }"); + } else { + tabButtons[i]->setStyleSheet("QToolButton { border: none; border-bottom: 1px solid transparent; " + "border-top-left-radius: 4px; border-top-right-radius: 4px; " + "background: transparent; }"); + } + } +} + +void DlgSettings::flashWidget(QWidget *widget) +{ + auto *overlay = new QWidget(widget); + overlay->setGeometry(widget->rect()); + overlay->setAttribute(Qt::WA_TransparentForMouseEvents, true); + + QPalette pal = overlay->palette(); + QColor flashColor = pal.color(QPalette::Highlight); + flashColor.setAlpha(100); + pal.setBrush(QPalette::Window, flashColor); + overlay->setPalette(pal); + overlay->setAutoFillBackground(true); + + auto *effect = new QGraphicsOpacityEffect(overlay); + effect->setOpacity(0.0); + overlay->setGraphicsEffect(effect); + overlay->show(); + overlay->raise(); + + auto *flashIn = new QPropertyAnimation(effect, "opacity"); + flashIn->setDuration(120); + flashIn->setStartValue(0.0); + flashIn->setEndValue(0.6); + flashIn->setEasingCurve(QEasingCurve::OutCubic); + + auto *fadeOut = new QPropertyAnimation(effect, "opacity"); + fadeOut->setDuration(900); + fadeOut->setStartValue(0.6); + fadeOut->setEndValue(0.0); + fadeOut->setEasingCurve(QEasingCurve::InCubic); + + auto *group = new QSequentialAnimationGroup(overlay); + group->addAnimation(flashIn); + group->addAnimation(fadeOut); + + connect(group, &QSequentialAnimationGroup::finished, overlay, &QWidget::deleteLater); + + group->start(QAbstractAnimation::DeleteWhenStopped); +} + +void DlgSettings::onSearchTextChanged(const QString &text) +{ + searchModel->setFilterString(text); + + if (searchModel->isFilterActive() && !text.trimmed().isEmpty()) { + if (!searchActive) { + switchToSearchMode(); + } + if (searchModel->rowCount(QModelIndex()) > 0) { + searchResultsView->setCurrentIndex(searchModel->index(0)); + } + } else if (searchActive) { + switchToTabMode(); + } +} + +void DlgSettings::switchToSearchMode() +{ + searchActive = true; + tabBarWidget->setVisible(false); + pagesWidget->setVisible(false); + searchResultsView->setVisible(true); + if (searchModel->rowCount(QModelIndex()) > 0) { + searchResultsView->setCurrentIndex(searchModel->index(0)); + } +} + +void DlgSettings::switchToTabMode() +{ + searchActive = false; + tabBarWidget->setVisible(true); + pagesWidget->setVisible(true); + searchResultsView->setVisible(false); + searchEdit->blockSignals(true); + searchEdit->clear(); + searchEdit->blockSignals(false); + setActiveTab(currentTabIndex); +} + +void DlgSettings::onSearchResultClicked(const QModelIndex &index) +{ + navigateToSearchResult(index); +} + +void DlgSettings::navigateToSearchResult(const QModelIndex &index) +{ + SettingsSearchEntry entry = searchModel->entryForIndex(index); + if (entry.pageIndex < 0 || entry.pageIndex >= pages.size()) { + return; + } + + // Switch to the page + switchToTabMode(); + setActiveTab(entry.pageIndex); + + // Scroll to the widget, focus it, and flash to highlight it + if (entry.widget) { + QWidget *widget = entry.widget; + while (widget) { + if (auto *scrollArea = qobject_cast(widget)) { + scrollArea->ensureWidgetVisible(entry.widget); + break; + } + widget = widget->parentWidget(); + } + entry.widget->setFocus(); + flashWidget(entry.widget); + } } void DlgSettings::setTab(int index) { - if (index <= contentsWidget->count() - 1 && index >= 0) { - changePage(contentsWidget->item(index), contentsWidget->currentItem()); - contentsWidget->setCurrentRow(index); + if (index >= 0 && index < tabButtons.size()) { + setActiveTab(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) installNewTranslator(); } +bool DlgSettings::eventFilter(QObject *watched, QEvent *event) +{ + if (watched == searchEdit && event->type() == QEvent::KeyPress) { + auto *keyEvent = static_cast(event); + if (keyEvent->key() == Qt::Key_Escape) { + if (searchActive) { + switchToTabMode(); + return true; + } + } else if (keyEvent->key() == Qt::Key_Return || keyEvent->key() == Qt::Key_Enter) { + if (searchActive) { + if (searchResultsView->currentIndex().isValid()) { + navigateToSearchResult(searchResultsView->currentIndex()); + } + return true; + } + } else if (keyEvent->key() == Qt::Key_Down) { + if (searchActive) { + int nextRow = searchResultsView->currentIndex().row() + 1; + if (nextRow >= searchModel->rowCount()) { + nextRow = 0; + } + searchResultsView->setCurrentIndex(searchModel->index(nextRow)); + searchResultsView->scrollTo(searchModel->index(nextRow)); + return true; + } + } else if (keyEvent->key() == Qt::Key_Up) { + if (searchActive) { + int prevRow = searchResultsView->currentIndex().row() - 1; + if (prevRow < 0) { + prevRow = searchModel->rowCount() - 1; + } + if (prevRow >= 0) { + searchResultsView->setCurrentIndex(searchModel->index(prevRow)); + searchResultsView->scrollTo(searchModel->index(prevRow)); + } + return true; + } + } + } + return QDialog::eventFilter(watched, event); +} + void DlgSettings::closeEvent(QCloseEvent *event) { bool showLoadError = true; @@ -166,13 +483,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: @@ -205,8 +522,8 @@ void DlgSettings::closeEvent(QCloseEvent *event) } } - if (!QDir(SettingsCache::instance().getDeckPath()).exists() || SettingsCache::instance().getDeckPath().isEmpty()) { - //! \todo Prompt to create the deck directory. + if (!QDir(SettingsCache::instance().paths().getDeckPath()).exists() || + SettingsCache::instance().paths().getDeckPath().isEmpty()) { if (QMessageBox::critical( this, tr("Error"), tr("The path to your deck directory is invalid. Would you like to go back and set the correct path?"), @@ -216,8 +533,8 @@ void DlgSettings::closeEvent(QCloseEvent *event) } } - if (!QDir(SettingsCache::instance().getPicsPath()).exists() || SettingsCache::instance().getPicsPath().isEmpty()) { - //! \todo Prompt to create the pictures directory. + if (!QDir(SettingsCache::instance().paths().getPicsPath()).exists() || + SettingsCache::instance().paths().getPicsPath().isEmpty()) { if (QMessageBox::critical(this, tr("Error"), tr("The path to your card pictures directory is invalid. Would you like to go back " "and set the correct path?"), @@ -232,15 +549,26 @@ void DlgSettings::closeEvent(QCloseEvent *event) void DlgSettings::retranslateUi() { setWindowTitle(tr("Settings")); + retranslateTabNames(); - generalButton->setText(tr("General")); - appearanceButton->setText(tr("Appearance")); - userInterfaceButton->setText(tr("User Interface")); - storageButton->setText(tr("Storage")); - deckEditorButton->setText(tr("Card Sources")); - messagesButton->setText(tr("Chat")); - soundButton->setText(tr("Sound")); - shortcutsButton->setText(tr("Shortcuts")); + searchEdit->setPlaceholderText(tr("Search settings...")); + okButton->setText(tr("OK")); - contentsWidget->reset(); + // Rebuild search index for translated text + buildSearchIndex(); +} + +QStringList DlgSettings::translatedPageNames() +{ + return {tr("General"), tr("Appearance"), tr("User Interface"), tr("Card Sources"), + tr("Storage"), tr("Chat"), tr("Sound"), tr("Shortcuts")}; +} + +void DlgSettings::retranslateTabNames() +{ + const QStringList tabLabels = translatedPageNames(); + + for (int i = 0; i < tabButtons.size() && i < tabLabels.size(); ++i) { + tabButtons[i]->setText(tabLabels[i]); + } } diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_settings.h b/cockatrice/src/interface/widgets/dialogs/dlg_settings.h index 3ffee6388..845c0b4d6 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_settings.h +++ b/cockatrice/src/interface/widgets/dialogs/dlg_settings.h @@ -1,43 +1,100 @@ /** * @file dlg_settings.h + * @brief Main settings dialog for the Cockatrice client * @ingroup Dialogs */ -//! \todo Document this file. - #ifndef DLG_SETTINGS_H #define DLG_SETTINGS_H -#include #include #include +class QPushButton; + inline Q_LOGGING_CATEGORY(DlgSettingsLog, "dlg_settings"); -class QListWidget; class QStackedWidget; -class QListWidgetItem; +class QToolButton; +class QListView; +class QLineEdit; +class AbstractSettingsPage; +class SettingsSearchModel; +class SettingsSearchDelegate; + +/** + * @brief Main application settings dialog with tabbed navigation and search + * + * Provides a modern settings interface organized into tabbed pages. Users can + * either navigate by clicking tabs or search for specific settings using the + * built-in search bar. Search results are filtered and ranked by relevance. + */ class DlgSettings : public QDialog { Q_OBJECT public: + /** + * @brief Page order in the tab bar, matching the order pages are added in setupUi() + * + * Use these values instead of raw indices so reordering pages never silently + * breaks external callers like tab_room.cpp. + */ + enum SettingsPage + { + GeneralPage = 0, + AppearancePage, + UserInterfacePage, + DeckEditorPage, + StoragePage, + MessagesPage, + SoundPage, + ShortcutsPage, + NumPages + }; + explicit DlgSettings(QWidget *parent = nullptr); void setTab(int index); + AbstractSettingsPage *page(SettingsPage which) const; private slots: - void changePage(QListWidgetItem *current, QListWidgetItem *previous); + void onTabClicked(int index); + void onSearchTextChanged(const QString &text); + void onSearchResultClicked(const QModelIndex &index); void updateLanguage(); private: - QListWidget *contentsWidget; - QStackedWidget *pagesWidget; - QListWidgetItem *generalButton, *appearanceButton, *userInterfaceButton, *deckEditorButton, *storageButton, - *messagesButton, *soundButton, *shortcutsButton; - void createIcons(); + // UI elements + QLineEdit *searchEdit; ///< Search bar for filtering settings + QWidget *tabBarWidget; ///< Container widget for the tab buttons + QList tabButtons; ///< Navigation tab buttons + QStackedWidget *pagesWidget; ///< Stacked widget containing settings pages + QListView *searchResultsView; ///< Search results list view + QWidget *pagesContainer; ///< Container stacking pages and search results + QPushButton *okButton; ///< Button to close the dialog + + // Data + QList pages; ///< All settings page instances + SettingsSearchModel *searchModel; ///< Model for search results + SettingsSearchDelegate *searchDelegate; ///< Delegate for search result rendering + int currentTabIndex; ///< Currently active tab index + bool searchActive; ///< Whether search mode is active + + void setupUi(); + void setupTabBar(); + void buildSearchIndex(); + void switchToTabMode(); + void switchToSearchMode(); + void navigateToSearchResult(const QModelIndex &index); + void setActiveTab(int index); + static void flashWidget(QWidget *widget); + static QStringList translatedPageNames(); + void retranslateUi(); + void retranslateTabNames(); protected: void closeEvent(QCloseEvent *event) override; + bool eventFilter(QObject *watched, QEvent *event) override; }; -#endif +#endif // DLG_SETTINGS_H 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 ac1922a9d..4322beb8c 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_startup_card_check.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_startup_card_check.cpp @@ -3,6 +3,7 @@ #include "../../../client/settings/cache_settings.h" #include +#include DlgStartupCardCheck::DlgStartupCardCheck(QWidget *parent) : QDialog(parent) { @@ -10,7 +11,7 @@ DlgStartupCardCheck::DlgStartupCardCheck(QWidget *parent) : QDialog(parent) layout = new QVBoxLayout(this); - QDate lastCheckDate = SettingsCache::instance().getLastCardUpdateCheck(); + QDate lastCheckDate = SettingsCache::instance().updates().getLastCardUpdateCheck(); int daysAgo = lastCheckDate.daysTo(QDate::currentDate()); instructionLabel = new QLabel( 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 59330e27e..3d3619bf0 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 @@ -9,6 +9,7 @@ #include #include #include +#include #define MIN_TIP_IMAGE_HEIGHT 200 #define MIN_TIP_IMAGE_WIDTH 200 @@ -41,7 +42,7 @@ DlgTipOfTheDay::DlgTipOfTheDay(QWidget *parent) : QDialog(parent) tipNumber = new QLabel(); tipNumber->setAlignment(Qt::AlignCenter); - QList seenTips = SettingsCache::instance().getSeenTips(); + QList seenTips = SettingsCache::instance().personal().getSeenTips(); newTipsAvailable = false; currentTip = 0; for (int i = 0; i < tipDatabase->rowCount(); i++) { @@ -73,9 +74,9 @@ DlgTipOfTheDay::DlgTipOfTheDay(QWidget *parent) : QDialog(parent) connect(previousButton, &QPushButton::clicked, this, &DlgTipOfTheDay::previousClicked); showTipsOnStartupCheck = new QCheckBox("Show tips on startup"); - showTipsOnStartupCheck->setChecked(SettingsCache::instance().getShowTipsOnStartup()); - connect(showTipsOnStartupCheck, &QCheckBox::clicked, &SettingsCache::instance(), - &SettingsCache::setShowTipsOnStartup); + showTipsOnStartupCheck->setChecked(SettingsCache::instance().personal().getShowTipsOnStartup()); + connect(showTipsOnStartupCheck, &QCheckBox::clicked, &SettingsCache::instance().personal(), + &PersonalSettings::setShowTipsOnStartup); buttonBar = new QHBoxLayout(); buttonBar->addWidget(showTipsOnStartupCheck); buttonBar->addWidget(tipNumber); @@ -130,10 +131,10 @@ void DlgTipOfTheDay::updateTip(int tipId) } // Store tip id as seen - QList seenTips = SettingsCache::instance().getSeenTips(); + QList seenTips = SettingsCache::instance().personal().getSeenTips(); if (!seenTips.contains(tipId)) { seenTips.append(tipId); - SettingsCache::instance().setSeenTips(seenTips); + SettingsCache::instance().personal().setSeenTips(seenTips); } TipOfTheDay tip = tipDatabase->getTip(tipId); diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp index ee2149309..7cf58d3e0 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_update.cpp @@ -134,7 +134,7 @@ void DlgUpdate::finishedUpdateCheck(bool needToUpdate, bool isCompatible, Releas // If there's no need to update, tell them that. However we still allow them to run the // downloader themselves if there's a compatible build QMessageBox::information( - this, tr("No Update Available"), + window(), tr("No Update Available"), tr("Cockatrice is up to date!") + "

" + tr("You are already running the latest version available in the chosen release channel.") + "
" + "" + tr("Current version") + QString(": %1
").arg(VERSION_STRING) + "" + @@ -147,7 +147,7 @@ void DlgUpdate::finishedUpdateCheck(bool needToUpdate, bool isCompatible, Releas if (isCompatible) { int reply; reply = QMessageBox::question( - this, tr("Update Available"), + window(), tr("Update Available"), tr("A new version of Cockatrice is available!") + "

" + "" + tr("New version") + QString(": %1
").arg(release->getName()) + "" + tr("Released") + QString(": %1 (").arg(publishDate, release->getDescriptionUrl()) + tr("Changelog") + @@ -156,10 +156,12 @@ void DlgUpdate::finishedUpdateCheck(bool needToUpdate, bool isCompatible, Releas if (reply == QMessageBox::Yes) { downloadUpdate(release->getName()); + } else { + closeDialog(); } } else { QMessageBox::information( - this, tr("Update Available"), + window(), tr("Update Available"), tr("A new version of Cockatrice is available!") + "

" + "" + tr("New version") + QString(": %1
").arg(release->getName()) + "" + tr("Released") + QString(": %1 (
").arg(publishDate, release->getDescriptionUrl()) + tr("Changelog") + diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_view_log.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_view_log.cpp index 4eb054647..957e039c0 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_view_log.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_view_log.cpp @@ -3,11 +3,13 @@ #include "../../../client/settings/cache_settings.h" #include "../../logger.h" +#include #include #include #include #include #include +#include DlgViewLog::DlgViewLog(QWidget *parent) : QDialog(parent) { diff --git a/cockatrice/src/interface/widgets/dialogs/override_printing_warning.cpp b/cockatrice/src/interface/widgets/dialogs/override_printing_warning.cpp index d7699fbe4..33d102f5e 100644 --- a/cockatrice/src/interface/widgets/dialogs/override_printing_warning.cpp +++ b/cockatrice/src/interface/widgets/dialogs/override_printing_warning.cpp @@ -3,6 +3,7 @@ #include "../../card_picture_loader/card_picture_loader.h" #include "../../client/settings/cache_settings.h" +#include bool OverridePrintingWarning::execMessageBox(QWidget *parent, bool enable) { QString message; @@ -27,7 +28,7 @@ bool OverridePrintingWarning::execMessageBox(QWidget *parent, bool enable) QMessageBox::question(parent, QObject::tr("Confirm Change"), message, QMessageBox::Yes | QMessageBox::No); if (result == QMessageBox::Yes) { - SettingsCache::instance().setOverrideAllCardArtWithPersonalPreference(static_cast(enable)); + SettingsCache::instance().cardsDisplay().setOverrideAllCardArtWithPersonalPreference(enable); // Caches are now invalid. CardPictureLoader::clearPixmapCache(); CardPictureLoader::clearNetworkCache(); diff --git a/cockatrice/src/interface/widgets/general/display/banner_widget.cpp b/cockatrice/src/interface/widgets/general/display/banner_widget.cpp index 5de5457ea..31f384cac 100644 --- a/cockatrice/src/interface/widgets/general/display/banner_widget.cpp +++ b/cockatrice/src/interface/widgets/general/display/banner_widget.cpp @@ -32,6 +32,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/charts/bars/bar_chart_widget.cpp b/cockatrice/src/interface/widgets/general/display/charts/bars/bar_chart_widget.cpp index d9e108e6a..04315d0ce 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 @@ -207,11 +207,7 @@ void BarChartWidget::mouseMoveEvent(QMouseEvent *e) if (hoveredSegment >= 0) { const auto &s = segments[hoveredSegment]; QString text = QString("%1: %2 cards\n\n%3").arg(s.category).arg(s.value).arg(s.cards.join("\n")); -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) QToolTip::showText(e->globalPosition().toPoint(), text, this); -#else - QToolTip::showText(e->globalPos(), text, this); -#endif } else { QToolTip::hideText(); } 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 12ab5bb3b..1087c621e 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 @@ -83,19 +83,11 @@ void ColorBar::paintEvent(QPaintEvent *) } } -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) void ColorBar::enterEvent(QEnterEvent *event) { Q_UNUSED(event); isHovered = true; } -#else -void ColorBar::enterEvent(QEvent *event) -{ - Q_UNUSED(event); - isHovered = true; -} -#endif void ColorBar::leaveEvent(QEvent *) { @@ -108,13 +100,8 @@ void ColorBar::mouseMoveEvent(QMouseEvent *event) return; } -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) int x = int(event->position().x()); QPoint gp = event->globalPosition().toPoint(); -#else - int x = event->pos().x(); - QPoint gp = event->globalPos(); -#endif QString text = tooltipForPosition(x); if (!text.isEmpty()) { 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 100f95310..1d435cec1 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 @@ -76,17 +76,10 @@ protected: */ void paintEvent(QPaintEvent *event) override; -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) /** - * @brief Handles mouse hover entering (Qt6 version). + * @brief Handles mouse hover entering. */ void enterEvent(QEnterEvent *event) override; -#else - /** - * @brief Handles mouse hover entering (Qt5 version). - */ - void enterEvent(QEvent *event) override; -#endif /** * @brief Handles mouse hover leaving. 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 9bad32bda..03c69373a 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 @@ -134,9 +134,5 @@ void SegmentedBarWidget::mouseMoveEvent(QMouseEvent *e) const Segment &s = segments[idx]; QString text = QString("%1: %2 cards\n%3").arg(s.category).arg(s.value).arg(s.cards.join(", ")); -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) QToolTip::showText(e->globalPosition().toPoint(), text, this); -#else - QToolTip::showText(e->globalPos(), text, this); -#endif } 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 b129fbe18..bd4641981 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 @@ -82,11 +82,7 @@ void ColorPie::paintEvent(QPaintEvent *) QString label = QString("%1%").arg(int(ratio * 100 + 0.5)); QFontMetrics fm(p.font()); -#if QT_VERSION >= QT_VERSION_CHECK(5, 11, 0) int labelWidth = fm.horizontalAdvance(label); -#else - int labelWidth = fm.width(label); -#endif QRectF textRect(labelPos.x() - labelWidth / 2.0, labelPos.y() - fm.height() / 2.0, labelWidth, fm.height()); p.setPen(Qt::black); @@ -96,19 +92,11 @@ void ColorPie::paintEvent(QPaintEvent *) } } -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) void ColorPie::enterEvent(QEnterEvent *event) { Q_UNUSED(event); isHovered = true; } -#else -void ColorPie::enterEvent(QEvent *event) -{ - Q_UNUSED(event); - isHovered = true; -} -#endif void ColorPie::leaveEvent(QEvent *) { @@ -121,13 +109,8 @@ void ColorPie::mouseMoveEvent(QMouseEvent *event) return; } -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) QPoint p = event->position().toPoint(); QPoint gp = event->globalPosition().toPoint(); -#else - QPoint p = event->pos(); - QPoint gp = event->globalPos(); -#endif QString text = tooltipForPoint(p); if (!text.isEmpty()) { 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 7d71ea3b9..60d8e18f2 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 @@ -22,11 +22,7 @@ public: protected: void paintEvent(QPaintEvent *) override; -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) void enterEvent(QEnterEvent *event) override; -#else - void enterEvent(QEvent *event) override; -#endif void leaveEvent(QEvent *) override; void mouseMoveEvent(QMouseEvent *event) override; 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 e1687f997..648d315f9 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.cpp +++ b/cockatrice/src/interface/widgets/general/home_widget.cpp @@ -2,27 +2,34 @@ #include "../../../client/settings/cache_settings.h" #include "../../../interface/widgets/tabs/tab_supervisor.h" +#include "../../pixel_map_generator.h" #include "../../theme_manager.h" #include "../../window_main.h" +#include "../cards/art_crop_attribution.h" #include "background_sources.h" #include "home_styled_button.h" +#include "home_tab_button_color.h" #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); @@ -41,32 +48,47 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor) updateConnectButton(tabSupervisor->getClient()->getStatus()); connect(tabSupervisor->getClient(), &RemoteClient::statusChanged, this, &HomeWidget::updateConnectButton); - connect(&SettingsCache::instance(), &SettingsCache::homeTabBackgroundSourceChanged, this, + connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabBackgroundSourceChanged, this, &HomeWidget::initializeBackgroundFromSource); - connect(&SettingsCache::instance(), &SettingsCache::homeTabBackgroundShuffleFrequencyChanged, this, - &HomeWidget::onBackgroundShuffleFrequencyChanged); + connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabBackgroundShuffleFrequencyChanged, + this, &HomeWidget::onBackgroundShuffleFrequencyChanged); // Lambda is cleaner to read than overloading this - connect(&SettingsCache::instance(), &SettingsCache::homeTabDisplayCardNameChanged, this, [this] { repaint(); }); + 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::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); return; } - auto backgroundSourceType = BackgroundSources::fromId(SettingsCache::instance().getHomeTabBackgroundSource()); + auto backgroundSourceType = + BackgroundSources::fromId(SettingsCache::instance().appearance().getHomeTabBackgroundSource()); switch (backgroundSourceType) { case BackgroundSources::Theme: cardChangeTimer->stop(); - background = QPixmap("theme:backgrounds/home"); + background = themePixmap(QStringLiteral("backgrounds/home")); backgroundSourceDeck = DeckList(); backgroundSourceCard->setCard(ExactCard()); updateButtonsToBackgroundColor(); @@ -88,10 +110,30 @@ void HomeWidget::initializeBackgroundFromSource() void HomeWidget::loadBackgroundSourceDeck() { std::optional deckOpt = DeckLoader::loadFromFile( - SettingsCache::instance().getDeckPath() + "background.cod", DeckFileFormat::Cockatrice, false); + SettingsCache::instance().paths().getDeckPath() + "background.cod", DeckFileFormat::Cockatrice, false); 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; @@ -108,7 +150,8 @@ void HomeWidget::setRandomCard(ExactCard &newCard) void HomeWidget::updateRandomCard() { - auto backgroundSourceType = BackgroundSources::fromId(SettingsCache::instance().getHomeTabBackgroundSource()); + auto backgroundSourceType = + BackgroundSources::fromId(SettingsCache::instance().appearance().getHomeTabBackgroundSource()); ExactCard newCard; @@ -151,8 +194,8 @@ void HomeWidget::updateRandomCard() void HomeWidget::onBackgroundShuffleFrequencyChanged() { cardChangeTimer->stop(); - if (SettingsCache::instance().getHomeTabBackgroundShuffleFrequency() > 0) { - cardChangeTimer->start(SettingsCache::instance().getHomeTabBackgroundShuffleFrequency() * 1000); + if (SettingsCache::instance().appearance().getHomeTabBackgroundShuffleFrequency() > 0) { + cardChangeTimer->start(SettingsCache::instance().appearance().getHomeTabBackgroundShuffleFrequency() * 1000); } } @@ -165,7 +208,7 @@ void HomeWidget::updateBackgroundProperties() void HomeWidget::updateButtonsToBackgroundColor() { - gradientColors = extractDominantColors(background); + gradientColors = determineButtonColor(); for (HomeStyledButton *button : findChildren()) { button->updateStylesheet(gradientColors); button->update(); @@ -192,10 +235,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); @@ -260,11 +303,6 @@ void HomeWidget::updateConnectButton(const ClientStatus status) QPair HomeWidget::extractDominantColors(const QPixmap &pixmap) { - if (themeManager->isBuiltInTheme() && - SettingsCache::instance().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) @@ -328,16 +366,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(); @@ -345,9 +386,28 @@ void HomeWidget::paintEvent(QPaintEvent *event) cardName += " (" + card.getPrinting().getSet()->getCorrectedShortName() + ") " + card.getPrinting().getProperty("num"); } + attribution = buildArtAttribution(card); } - if (!cardName.isEmpty() && SettingsCache::instance().getHomeTabDisplayCardName()) { + // 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); font.setBold(true); @@ -355,24 +415,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 90d003aa7..1cadc4a67 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.h +++ b/cockatrice/src/interface/widgets/general/home_widget.h @@ -15,6 +15,9 @@ #include #include +class QGridLayout; +class QLabel; + class HomeWidget : public QWidget { @@ -23,7 +26,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; @@ -41,12 +44,14 @@ private: QPixmap background; CardInfoPictureArtCropWidget *backgroundSourceCard = nullptr; DeckList backgroundSourceDeck; - QPixmap overlay; + QLabel *logoLabel = nullptr; QPair gradientColors; HomeStyledButton *connectButton; void setRandomCard(ExactCard &newCard); void loadBackgroundSourceDeck(); + QPair determineButtonColor() const; + void updateLogoOverlay(); }; #endif // HOME_WIDGET_H diff --git a/cockatrice/src/interface/widgets/menus/deck_editor_menu.cpp b/cockatrice/src/interface/widgets/menus/deck_editor_menu.cpp index d6df694df..f057680be 100644 --- a/cockatrice/src/interface/widgets/menus/deck_editor_menu.cpp +++ b/cockatrice/src/interface/widgets/menus/deck_editor_menu.cpp @@ -4,6 +4,7 @@ #include "../../../client/settings/shortcuts_settings.h" #include "../tabs/abstract_tab_deck_editor.h" +#include DeckEditorMenu::DeckEditorMenu(AbstractTabDeckEditor *parent) : QMenu(parent), deckEditor(parent) { aNewDeck = new QAction(QString(), this); diff --git a/cockatrice/src/interface/widgets/menus/tearoff_menu.h b/cockatrice/src/interface/widgets/menus/tearoff_menu.h index ef1dfbcb3..9a9c4ff01 100644 --- a/cockatrice/src/interface/widgets/menus/tearoff_menu.h +++ b/cockatrice/src/interface/widgets/menus/tearoff_menu.h @@ -9,22 +9,23 @@ #include "../../../client/settings/cache_settings.h" #include +#include class TearOffMenu : public QMenu { public: explicit TearOffMenu(const QString &title, QWidget *parent = nullptr) : QMenu(title, parent) { - connect(&SettingsCache::instance(), &SettingsCache::useTearOffMenusChanged, this, + connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::useTearOffMenusChanged, this, [this](const bool state) { setTearOffEnabled(state); }); - setTearOffEnabled(SettingsCache::instance().getUseTearOffMenus()); + setTearOffEnabled(SettingsCache::instance().userInterface().getUseTearOffMenus()); } explicit TearOffMenu(QWidget *parent = nullptr) : QMenu(parent) { - connect(&SettingsCache::instance(), &SettingsCache::useTearOffMenusChanged, this, + connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::useTearOffMenusChanged, this, [this](const bool state) { setTearOffEnabled(state); }); - setTearOffEnabled(SettingsCache::instance().getUseTearOffMenus()); + setTearOffEnabled(SettingsCache::instance().userInterface().getUseTearOffMenus()); } TearOffMenu *addTearOffMenu(const QString &title) 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..4af02fe4f --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp @@ -0,0 +1,225 @@ +#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 + +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 QString &stage, qint64 done, qint64 total) +{ + if (cardDatabasePage) { + cardDatabasePage->onUpdateProgress(stage, done, total); + } +} + +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..21d7b6e06 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h @@ -0,0 +1,74 @@ +#ifndef FIRST_RUN_WIZARD_H +#define FIRST_RUN_WIZARD_H + +#include +#include + +class BannerHost; +class FirstRunWizardPage; +class StepIndicatorWidget; +class CardDatabaseSetupPage; +class QLabel; +class QPushButton; +class QStackedWidget; + +/** @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 QString &stage, qint64 done, qint64 total); + +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..bdcd123bd --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard_page.h @@ -0,0 +1,75 @@ +#ifndef FIRST_RUN_WIZARD_PAGE_H +#define FIRST_RUN_WIZARD_PAGE_H + +#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..2107ea8bf --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/account_setup_page.cpp @@ -0,0 +1,56 @@ +#include "account_setup_page.h" + +#include +#include +#include + +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..0d9b76699 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/account_setup_page.h @@ -0,0 +1,38 @@ +#ifndef ACCOUNT_SETUP_PAGE_H +#define ACCOUNT_SETUP_PAGE_H + +#include "../first_run_wizard_page.h" + +class QLabel; +class QPushButton; + +/** @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..50e8ff63d --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.cpp @@ -0,0 +1,334 @@ +#include "card_database_setup_page.h" + +#include "../../client/settings/cache_settings.h" + +#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 QString &stage, qint64 done, qint64 total) +{ + if (state != State::Running) { + return; + } + 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); + if (stage == QLatin1String("download")) { + statusLabel->setText(tr("Downloading the card database (%1%)…").arg(percent)); + } else if (stage == QLatin1String("scan")) { + statusLabel->setText(tr("Parsing the card database (%1%)…").arg(percent)); + } else if (stage == QLatin1String("import")) { + statusLabel->setText(tr("Importing cards (%1%)…").arg(percent)); + } + } +} + +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..870e759ea --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.h @@ -0,0 +1,80 @@ +#ifndef CARD_DATABASE_SETUP_PAGE_H +#define CARD_DATABASE_SETUP_PAGE_H + +#include "../first_run_wizard_page.h" + +#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 QString &stage, qint64 done, qint64 total); + +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..4205fa532 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/finish_page.cpp @@ -0,0 +1,30 @@ +#include "finish_page.h" + +#include +#include + +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..40ebc6ed0 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/finish_page.h @@ -0,0 +1,22 @@ +#ifndef FINISH_PAGE_H +#define FINISH_PAGE_H + +#include "../first_run_wizard_page.h" + +class QLabel; + +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..fceb34deb --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.cpp @@ -0,0 +1,173 @@ +#include "preferences_setup_page.h" + +#include "../../client/settings/cache_settings.h" +#include "../../client/sound_engine.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 + +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..eed1b3aed --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/preferences_setup_page.h @@ -0,0 +1,41 @@ +#ifndef PREFERENCES_SETUP_PAGE_H +#define PREFERENCES_SETUP_PAGE_H + +#include "../first_run_wizard_page.h" + +class QCheckBox; +class QComboBox; +class QGroupBox; +class QLabel; + +/** @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..797fe4425 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.cpp @@ -0,0 +1,276 @@ +#include "theme_setup_page.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 "libcockatrice/settings/appearance_settings.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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..16a3c9a5d --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.h @@ -0,0 +1,61 @@ +#ifndef THEME_SETUP_PAGE_H +#define THEME_SETUP_PAGE_H + +#include "../first_run_wizard_page.h" + +class QComboBox; +class QGroupBox; +class QuickSetupPanel; + +/** @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..16e0719d2 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.cpp @@ -0,0 +1,79 @@ +#include "welcome_page.h" + +#include "../../../../main.h" +#include "../../client/settings/cache_settings.h" +#include "../../settings_page/general_settings_page.h" +#include "libcockatrice/settings/personal_settings.h" + +#include +#include +#include +#include +#include +#include +#include + +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..93b24d83d --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/welcome_page.h @@ -0,0 +1,28 @@ +#ifndef WELCOME_PAGE_H +#define WELCOME_PAGE_H + +#include "../first_run_wizard_page.h" + +class QComboBox; +class QLabel; + +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..0b9f65783 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp @@ -0,0 +1,326 @@ +#include "shader_banner_widget.h" + +#include "../../theme_manager.h" +#include "banner_shader_config.h" +#include "brand_colors.h" + +#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..ac47b1141 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.h @@ -0,0 +1,91 @@ +#ifndef SHADER_BANNER_WIDGET_H +#define SHADER_BANNER_WIDGET_H + +#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..d25e8544b --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/step_indicator_widget.cpp @@ -0,0 +1,84 @@ +#include "step_indicator_widget.h" + +#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..1b85be04f --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/step_indicator_widget.h @@ -0,0 +1,34 @@ +#ifndef STEP_INDICATOR_WIDGET_H +#define STEP_INDICATOR_WIDGET_H + +#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..e970f8413 --- /dev/null +++ b/cockatrice/src/interface/widgets/playmat/playmat_collection_dialog.cpp @@ -0,0 +1,188 @@ +#include "playmat_collection_dialog.h" + +#include "../../../client/settings/cache_settings.h" +#include "playmat_settings_dialog.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +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..167893f8e --- /dev/null +++ b/cockatrice/src/interface/widgets/playmat/playmat_collection_dialog.h @@ -0,0 +1,54 @@ +#ifndef COCKATRICE_PLAYMAT_COLLECTION_DIALOG_H +#define COCKATRICE_PLAYMAT_COLLECTION_DIALOG_H + +#include +#include + +class QComboBox; +class QLabel; +class QListWidget; +class QListWidgetItem; +class QPushButton; + +/** + * @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..130daadf4 --- /dev/null +++ b/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.cpp @@ -0,0 +1,381 @@ +#include "playmat_preview_widget.h" + +#include "../cards/art_crop_attribution.h" +#include "playmat_utils.h" + +#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..88b9ec41d --- /dev/null +++ b/cockatrice/src/interface/widgets/playmat/playmat_preview_widget.h @@ -0,0 +1,61 @@ +#ifndef COCKATRICE_PLAYMAT_PREVIEW_WIDGET_H +#define COCKATRICE_PLAYMAT_PREVIEW_WIDGET_H + +#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..9459c5ea9 --- /dev/null +++ b/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.cpp @@ -0,0 +1,325 @@ +#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 "playmat_preview_widget.h" + +#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..9ef306a5f --- /dev/null +++ b/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.h @@ -0,0 +1,94 @@ +#ifndef COCKATRICE_PLAYMAT_SETTINGS_DIALOG_H +#define COCKATRICE_PLAYMAT_SETTINGS_DIALOG_H + +#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 05e269174..579106540 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 @@ -141,11 +141,7 @@ bool AllZonesCardAmountWidget::isNonZero() * * @param event The event information for the mouse entry. */ -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) void AllZonesCardAmountWidget::enterEvent(QEnterEvent *event) -#else -void AllZonesCardAmountWidget::enterEvent(QEvent *event) -#endif { QWidget::enterEvent(event); update(); 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 de4a984be..325dd1c1b 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 @@ -26,11 +26,7 @@ public: int getTokensboardAmount(); bool isNonZero(); -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) void enterEvent(QEnterEvent *event) override; -#else - void enterEvent(QEvent *event) override; -#endif public slots: void adjustFontSize(int scalePercentage); diff --git a/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp index 76a416587..0c3bbbe00 100644 --- a/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp +++ b/cockatrice/src/interface/widgets/printing_selector/printing_selector.cpp @@ -12,6 +12,8 @@ #include #include +#include +#include /** * @brief Constructs a PrintingSelector widget to display and manage card printings. @@ -45,16 +47,17 @@ PrintingSelector::PrintingSelector(QWidget *parent, AbstractTabDeckEditor *_deck // Create the checkbox for navigation buttons visibility navigationCheckBox = new QCheckBox(this); - navigationCheckBox->setChecked(SettingsCache::instance().getPrintingSelectorNavigationButtonsVisible()); + navigationCheckBox->setChecked( + SettingsCache::instance().cardsDisplay().getPrintingSelectorNavigationButtonsVisible()); connect(navigationCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &PrintingSelector::toggleVisibilityNavigationButtons); - connect(navigationCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setPrintingSelectorNavigationButtonsVisible); + connect(navigationCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setPrintingSelectorNavigationButtonsVisible); - cardSizeWidget = - new CardSizeWidget(displayOptionsWidget, flowWidget, SettingsCache::instance().getPrintingSelectorCardSize()); - connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance(), - &SettingsCache::setPrintingSelectorCardSize); + cardSizeWidget = new CardSizeWidget(displayOptionsWidget, flowWidget, + SettingsCache::instance().cardsDisplay().getPrintingSelectorCardSize()); + connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setPrintingSelectorCardSize); displayOptionsWidget->addSettingsWidget(sortToolBar); displayOptionsWidget->addSettingsWidget(navigationCheckBox); @@ -81,7 +84,8 @@ PrintingSelector::PrintingSelector(QWidget *parent, AbstractTabDeckEditor *_deck flowWidget->setVisible(false); cardSelectionBar = new PrintingSelectorCardSelectionWidget(this, deckStateManager); - cardSelectionBar->setVisible(SettingsCache::instance().getPrintingSelectorNavigationButtonsVisible()); + cardSelectionBar->setVisible( + SettingsCache::instance().cardsDisplay().getPrintingSelectorNavigationButtonsVisible()); layout->addWidget(cardSelectionBar); // Connect deck model data change signal to update display @@ -98,7 +102,7 @@ void PrintingSelector::retranslateUi() void PrintingSelector::printingsInDeckChanged() { - if (SettingsCache::instance().getBumpSetsWithCardsInDeckToTop()) { + if (SettingsCache::instance().cardsDisplay().getBumpSetsWithCardsInDeckToTop()) { // Delay the update to avoid race conditions QTimer::singleShot(100, this, &PrintingSelector::updateDisplay); } @@ -211,7 +215,7 @@ void PrintingSelector::getAllSetsForCurrentCard() sortToolBar->filterSets(sortedPrintings, searchBar->getSearchText().trimmed().toLower()); QList printingsToUse; - if (SettingsCache::instance().getBumpSetsWithCardsInDeckToTop()) { + if (SettingsCache::instance().cardsDisplay().getBumpSetsWithCardsInDeckToTop()) { printingsToUse = sortToolBar->prependPrintingsInDeck(filteredPrintings, selectedCard, deckStateManager->getModel()); } else { 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 dd5f6dd7f..e8816847e 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,69 @@ #include "printing_selector_card_overlay_widget.h" #include "../../../client/settings/cache_settings.h" +#include "../../card_picture_loader/card_picture_loader.h" +#include "../cards/card_info_picture_widget.h" #include "printing_selector_card_display_widget.h" +#include +#include #include #include #include +#include #include +#include +#include #include #include #include #include +#include #include +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. * @@ -48,6 +99,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); @@ -105,11 +181,7 @@ void PrintingSelectorCardOverlayWidget::resizeEvent(QResizeEvent *event) * * @param event The event triggered when the mouse enters the widget. */ -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) void PrintingSelectorCardOverlayWidget::enterEvent(QEnterEvent *event) -#else -void PrintingSelectorCardOverlayWidget::enterEvent(QEvent *event) -#endif { QWidget::enterEvent(event); deckEditor->updateCard(rootCard); @@ -187,19 +259,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 = @@ -221,8 +297,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()) { @@ -237,7 +371,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; } /** @@ -293,3 +431,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 52a43d220..fbcfa9230 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 @@ -13,6 +13,9 @@ #include +class QAction; +class QMenu; + class PrintingSelectorCardOverlayWidget : public QWidget { Q_OBJECT @@ -26,11 +29,7 @@ public: protected: void resizeEvent(QResizeEvent *event) override; -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) void enterEvent(QEnterEvent *event) override; -#else - void enterEvent(QEvent *event) override; -#endif void leaveEvent(QEvent *event) override; void mousePressEvent(QMouseEvent *event) override; void customMenu(QPoint point); @@ -47,11 +46,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_sorting_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_sorting_widget.cpp index c04d6bbf1..f4a05cfc7 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 @@ -3,6 +3,9 @@ #include "../../../client/settings/cache_settings.h" #include +#include +#include +#include const QString PrintingSelectorCardSortingWidget::SORT_OPTIONS_ALPHABETICAL = tr("Alphabetical"); const QString PrintingSelectorCardSortingWidget::SORT_OPTIONS_PREFERENCE = tr("Preference"); @@ -26,7 +29,7 @@ PrintingSelectorCardSortingWidget::PrintingSelectorCardSortingWidget(PrintingSel sortOptionsSelector = new QComboBox(this); sortOptionsSelector->setFocusPolicy(Qt::StrongFocus); sortOptionsSelector->addItems(SORT_OPTIONS); - sortOptionsSelector->setCurrentIndex(SettingsCache::instance().getPrintingSelectorSortOrder()); + sortOptionsSelector->setCurrentIndex(SettingsCache::instance().cardsDisplay().getPrintingSelectorSortOrder()); connect(sortOptionsSelector, &QComboBox::currentTextChanged, this, &PrintingSelectorCardSortingWidget::updateSortSetting); connect(sortOptionsSelector, &QComboBox::currentTextChanged, parent, &PrintingSelector::updateDisplay); @@ -62,7 +65,7 @@ void PrintingSelectorCardSortingWidget::updateSortOrder() */ void PrintingSelectorCardSortingWidget::updateSortSetting() { - SettingsCache::instance().setPrintingSelectorSortOrder(sortOptionsSelector->currentIndex()); + SettingsCache::instance().cardsDisplay().setPrintingSelectorSortOrder(sortOptionsSelector->currentIndex()); } /** @@ -90,7 +93,7 @@ QList PrintingSelectorCardSortingWidget::sortSets(const SetToPrint } if (sortedSets.empty()) { - sortedSets << CardSet::newInstance(SettingsCache::instance().cardDatabase(), "", "", "", QDate()); + sortedSets << CardSet::newInstance(&SettingsCache::instance().cardDatabase(), "", "", "", QDate()); } if (sortOptionsSelector->currentText() == SORT_OPTIONS_PREFERENCE) { 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 badc437ee..881c3e3ac 100644 --- a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp +++ b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.cpp @@ -1,5 +1,7 @@ #include "settings_button_widget.h" +#include "../../pixel_map_generator.h" + #include #include #include @@ -8,7 +10,7 @@ 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); @@ -20,6 +22,13 @@ SettingsButtonWidget::SettingsButtonWidget(QWidget *parent) setLayout(layout); } +SettingsButtonWidget::~SettingsButtonWidget() +{ + // We don't parent the popup because it might lead to better behavior on certain window managers. + // So we have to manually delete it + popup->deleteLater(); +} + void SettingsButtonWidget::addSettingsWidget(QWidget *toAdd) const { popup->addSettingsWidget(toAdd); @@ -103,11 +112,7 @@ void SettingsButtonWidget::onPopupClosed() const void SettingsButtonWidget::mousePressEvent(QMouseEvent *event) { -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) if (popup->isVisible() && !popup->geometry().contains(event->globalPosition().toPoint())) { -#else - if (popup->isVisible() && !popup->geometry().contains(event->globalPos())) { -#endif popup->close(); } QWidget::mousePressEvent(event); 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 5dcbe059a..1dc52ac72 100644 --- a/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.h +++ b/cockatrice/src/interface/widgets/quick_settings/settings_button_widget.h @@ -19,6 +19,9 @@ class SettingsButtonWidget : public QWidget public: explicit SettingsButtonWidget(QWidget *parent = nullptr); + + ~SettingsButtonWidget() override; + void addSettingsWidget(QWidget *toAdd) const; void removeSettingsWidget(QWidget *toRemove) const; void setButtonIcon(QPixmap iconMap); diff --git a/cockatrice/src/interface/widgets/replay/replay_manager.cpp b/cockatrice/src/interface/widgets/replay/replay_manager.cpp index a1330a82d..c51b96b6c 100644 --- a/cockatrice/src/interface/widgets/replay/replay_manager.cpp +++ b/cockatrice/src/interface/widgets/replay/replay_manager.cpp @@ -1,152 +1,236 @@ #include "replay_manager.h" -#include "../interface/widgets/tabs/tab_game.h" +#include "../../../client/settings/cache_settings.h" -#include -#include +#include +#include +#include -ReplayManager::ReplayManager(TabGame *parent, GameReplay *_replay) - : QWidget(parent), game(parent), replay(_replay), replayPlayButton(nullptr), replayFastForwardButton(nullptr), - aReplaySkipForward(nullptr), aReplaySkipBackward(nullptr), aReplaySkipForwardBig(nullptr), - aReplaySkipBackwardBig(nullptr) +static constexpr int TIMER_INTERVAL_MS = 200; +static constexpr int EMPTY_SECTION_MARGIN_MS = 500; + +static QList createReplayTimeline(const GameReplay *replay) { - if (replay) { - game->getGame()->loadReplay(replay); + // Create list: event number -> time [ms] + unsigned int lastEventTimestamp = 0; + const int eventCount = replay->event_list_size(); - // Create list: event number -> time [ms] - // Distribute simultaneous events evenly across 1 second. - unsigned int lastEventTimestamp = 0; - const int eventCount = replay->event_list_size(); - for (int i = 0; i < eventCount; ++i) { - int j = i + 1; - while ((j < eventCount) && (replay->event_list(j).seconds_elapsed() == lastEventTimestamp)) { - ++j; - } - - const int numberEventsThisSecond = j - i; - for (int k = 0; k < numberEventsThisSecond; ++k) { - replayTimeline.append(replay->event_list(i + k).seconds_elapsed() * 1000 + - (int)((qreal)k / (qreal)numberEventsThisSecond * 1000)); - } - - if (j < eventCount) { - lastEventTimestamp = replay->event_list(j).seconds_elapsed(); - } - i += numberEventsThisSecond - 1; + QList replayTimeline; + for (int i = 0; i < eventCount; ++i) { + int nextSecondIndex = i + 1; + while (nextSecondIndex < eventCount && + replay->event_list(nextSecondIndex).seconds_elapsed() == lastEventTimestamp) { + ++nextSecondIndex; } + + // Distribute simultaneous events evenly across 1 second. + const int numberEventsThisSecond = nextSecondIndex - i; + for (int k = 0; k < numberEventsThisSecond; ++k) { + int eventMs = replay->event_list(i + k).seconds_elapsed() * 1000; + int distributionMs = static_cast(static_cast(k) / numberEventsThisSecond * 1000); + replayTimeline.append(eventMs + distributionMs); + } + + if (nextSecondIndex < eventCount) { + lastEventTimestamp = replay->event_list(nextSecondIndex).seconds_elapsed(); + } + i += numberEventsThisSecond - 1; } - // timeline widget - timelineWidget = new ReplayTimelineWidget; - timelineWidget->setTimeline(replayTimeline); - connect(timelineWidget, &ReplayTimelineWidget::processNextEvent, this, &ReplayManager::replayNextEvent); - connect(timelineWidget, &ReplayTimelineWidget::replayFinished, this, &ReplayManager::replayFinished); - connect(timelineWidget, &ReplayTimelineWidget::rewound, this, &ReplayManager::replayRewind); - - // timeline skip shortcuts - aReplaySkipForward = new QAction(timelineWidget); - timelineWidget->addAction(aReplaySkipForward); - connect(aReplaySkipForward, &QAction::triggered, this, - [this] { timelineWidget->skipByAmount(ReplayTimelineWidget::SMALL_SKIP_MS); }); - - aReplaySkipBackward = new QAction(timelineWidget); - timelineWidget->addAction(aReplaySkipBackward); - connect(aReplaySkipBackward, &QAction::triggered, this, - [this] { timelineWidget->skipByAmount(-ReplayTimelineWidget::SMALL_SKIP_MS); }); - - aReplaySkipForwardBig = new QAction(timelineWidget); - timelineWidget->addAction(aReplaySkipForwardBig); - connect(aReplaySkipForwardBig, &QAction::triggered, this, - [this] { timelineWidget->skipByAmount(ReplayTimelineWidget::BIG_SKIP_MS); }); - - aReplaySkipBackwardBig = new QAction(timelineWidget); - timelineWidget->addAction(aReplaySkipBackwardBig); - connect(aReplaySkipBackwardBig, &QAction::triggered, this, - [this] { timelineWidget->skipByAmount(-ReplayTimelineWidget::BIG_SKIP_MS); }); - - // buttons - 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); - replayPlayButton->setIcon(playButtonIcon); - replayPlayButton->setCheckable(true); - connect(replayPlayButton, &QToolButton::toggled, this, &ReplayManager::replayPlayButtonToggled); - - replayFastForwardButton = new QToolButton; - replayFastForwardButton->setIconSize(QSize(32, 32)); - replayFastForwardButton->setIcon(QPixmap("theme:replay/fastforward")); - replayFastForwardButton->setCheckable(true); - connect(replayFastForwardButton, &QToolButton::toggled, this, &ReplayManager::replayFastForwardButtonToggled); - - // putting everything together - auto replayControlLayout = new QHBoxLayout; - replayControlLayout->addWidget(timelineWidget, 10); - replayControlLayout->addWidget(replayPlayButton); - replayControlLayout->addWidget(replayFastForwardButton); - - setObjectName("replayControlWidget"); - setLayout(replayControlLayout); - - connect(this, &ReplayManager::requestChatAndPhaseReset, game, &TabGame::resetChatAndPhase); - - connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this, - &ReplayManager::refreshShortcuts); - refreshShortcuts(); + return replayTimeline; } -void ReplayManager::replayNextEvent(EventProcessingOptions options) +ReplayManager::ReplayManager(QObject *parent, GameReplay *replay) + : QObject(parent), replay(replay), replayTimeline(createReplayTimeline(replay)) { - emit eventReplayed(replay->event_list(timelineWidget->getCurrentEvent()), options); + maxTime = replayTimeline.isEmpty() ? 0 : replayTimeline.last(); + + replayTimer = new QTimer(this); + replayTimer->setInterval(TIMER_INTERVAL_MS); + connect(replayTimer, &QTimer::timeout, this, &ReplayManager::replayTimerTimeout); + + rewindBufferingTimer = new QTimer(this); + rewindBufferingTimer->setSingleShot(true); + connect(rewindBufferingTimer, &QTimer::timeout, this, &ReplayManager::processRewind); } -void ReplayManager::replayFinished() +ReplayManager::~ReplayManager() { - replayPlayButton->setChecked(false); + delete replay; } -void ReplayManager::replayPlayButtonToggled(bool checked) +void ReplayManager::skipToTime(int newTime, bool doRewindBuffering) { - if (checked) { // start replay - timelineWidget->startReplay(); - } else { // pause replay - timelineWidget->stopReplay(); + // check boundary conditions + if (newTime < 0) { + newTime = 0; + } + if (newTime > maxTime) { + newTime = maxTime; } -} -void ReplayManager::replayFastForwardButtonToggled(bool checked) -{ - timelineWidget->setTimeScaleFactor(checked ? ReplayTimelineWidget::FAST_FORWARD_SCALE_FACTOR : 1.0); + newTime -= newTime % TIMER_INTERVAL_MS; // Time should always be a multiple of the interval + + const bool isBackwardsSkip = newTime < currentProcessedTime; + currentVisualTime = newTime; + + if (isBackwardsSkip) { + handleBackwardsSkip(doRewindBuffering); + } else { + processNewEvents(FORWARD_SKIP); + } + + timeChanged(currentVisualTime); } /** - * @brief Handles everything that needs to be reset when doing a replay rewind. + * @brief Handles a backwards skip in the replay timeline. + * + * @param doRewindBuffering When true, if multiple backward skips are made in quick succession, only a single rewind + * is processed at the end. When false, the backwards skip will always cause an immediate rewind. */ -void ReplayManager::replayRewind() +void ReplayManager::handleBackwardsSkip(bool doRewindBuffering) { - emit requestChatAndPhaseReset(); + if (doRewindBuffering) { + // We use a one-shot timer to implement the rewind buffering. + // The rewind only happens once the timer runs out. + // If another backwards skip happens, the timer will just get reset instead of rewinding. + rewindBufferingTimer->stop(); + rewindBufferingTimer->start(SettingsCache::instance().userInterface().getRewindBufferingMs()); + } else { + // otherwise, process the rewind immediately + processRewind(); + } } -void ReplayManager::refreshShortcuts() +void ReplayManager::processRewind() { - ShortcutsSettings &shortcuts = SettingsCache::instance().shortcuts(); - if (aReplaySkipForward) { - aReplaySkipForward->setShortcuts(shortcuts.getShortcut("Replays/aSkipForward")); + // stop any queued-up rewinds + rewindBufferingTimer->stop(); + + // process the rewind + currentEvent = 0; + emit rewound(); + processNewEvents(BACKWARD_SKIP); +} + +void ReplayManager::replayTimerTimeout() +{ + currentVisualTime += TIMER_INTERVAL_MS; + + processNewEvents(NORMAL_PLAYBACK); + + timeChanged(currentVisualTime); + + if (skipEmptySections) { + handleSkipEmptySection(); } - if (aReplaySkipBackward) { - aReplaySkipBackward->setShortcuts(shortcuts.getShortcut("Replays/aSkipBackward")); +} + +/** @brief Processes all unprocessed events up to the current time. */ +void ReplayManager::processNewEvents(PlaybackMode playbackMode) +{ + currentProcessedTime = currentVisualTime; + + while (currentEvent < replayTimeline.size() && replayTimeline[currentEvent] < currentProcessedTime) { + EventProcessingOptions options; + + // backwards skip => always skip reveal windows + // forwards skip => skip reveal windows that don't happen within a big skip of the target + if (playbackMode == BACKWARD_SKIP || currentProcessedTime - replayTimeline[currentEvent] > BIG_SKIP_MS) { + options |= SKIP_REVEAL_WINDOW; + } + + // 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); + ++currentEvent; } - if (aReplaySkipForwardBig) { - aReplaySkipForwardBig->setShortcuts(shortcuts.getShortcut("Replays/aSkipForwardBig")); + if (currentEvent == replayTimeline.size()) { + emit replayFinished(); + replayTimer->stop(); } - if (aReplaySkipBackwardBig) { - aReplaySkipBackwardBig->setShortcuts(shortcuts.getShortcut("Replays/aSkipBackwardBig")); +} + +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; + } } - if (replayPlayButton) { - replayPlayButton->setShortcut(shortcuts.getSingleShortcut("Replays/playButton")); + + return false; +} + +void ReplayManager::handleSkipEmptySection() +{ + if (currentEvent == replayTimeline.size()) { + return; } - if (replayFastForwardButton) { - replayFastForwardButton->setShortcut(shortcuts.getSingleShortcut("Replays/fastForwardButton")); + + // 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; + int interval = std::max(1, qRound(TIMER_INTERVAL_MS / timeScaleFactor)); + replayTimer->setInterval(interval); +} + +void ReplayManager::setSkipEmptySections(bool value) +{ + skipEmptySections = value; +} + +void ReplayManager::startReplay() +{ + replayTimer->start(); +} + +void ReplayManager::stopReplay() +{ + replayTimer->stop(); +} + +void ReplayManager::setTime(int time) +{ + // don't buffer rewinds from clicks, since clicks usually don't happen fast enough to require buffering + skipToTime(time, false); +} + +void ReplayManager::skipByAmount(int amount) +{ + skipToTime(currentVisualTime + amount, amount < 0); } \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/replay/replay_manager.h b/cockatrice/src/interface/widgets/replay/replay_manager.h index a3e0126c7..81e66824d 100644 --- a/cockatrice/src/interface/widgets/replay/replay_manager.h +++ b/cockatrice/src/interface/widgets/replay/replay_manager.h @@ -1,49 +1,83 @@ -/** - * @file replay_manager.h - * @ingroup Core - * @ingroup Replay - */ -//! \todo Document this file. +#ifndef COCKATRICE_REPLAY_MANAGER_H +#define COCKATRICE_REPLAY_MANAGER_H -#ifndef REPLAY_MANAGER_H -#define REPLAY_MANAGER_H +#include "../../../game/player/event_processing_options.h" -#include "replay_timeline_widget.h" - -#include -#include +#include #include -class TabGame; +class GameReplay; +class QTimer; -class ReplayManager : public QWidget +/** + * @brief This class handles all logic to do with playing back replays + */ +class ReplayManager : public QObject { Q_OBJECT -public: - ReplayManager(TabGame *parent, GameReplay *replay); - TabGame *game; + enum PlaybackMode + { + NORMAL_PLAYBACK, + FORWARD_SKIP, + BACKWARD_SKIP + }; + GameReplay *replay; + QList replayTimeline; ///< timestamp of each event, with the indexes corresponding + int maxTime; -signals: - void requestChatAndPhaseReset(); - void eventReplayed(const GameEventContainer &cont, EventProcessingOptions options); + QTimer *replayTimer; + QTimer *rewindBufferingTimer; -private: - // Replay related members - int currentReplayStep = 0; - QList replayTimeline; - ReplayTimelineWidget *timelineWidget; - QToolButton *replayPlayButton, *replayFastForwardButton; - QAction *aReplaySkipForward, *aReplaySkipBackward, *aReplaySkipForwardBig, *aReplaySkipBackwardBig; + 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 + ///< to rewind buffering + int currentEvent = 0; ///< current event's index + + void skipToTime(int newTime, bool doRewindBuffering); + void handleBackwardsSkip(bool doRewindBuffering); + void processRewind(); + void processNewEvents(PlaybackMode playbackMode); + void handleSkipEmptySection(); private slots: - void replayNextEvent(EventProcessingOptions options); + void replayTimerTimeout(); + +public: + static constexpr int SMALL_SKIP_MS = 1000; + static constexpr int BIG_SKIP_MS = 10000; + + /** + * @param parent The parent QObject + * @param replay Cannot be null. Takes ownership of the object. + */ + explicit ReplayManager(QObject *parent, GameReplay *replay); + + ~ReplayManager() override; + + const QList &getReplayTimeline() const + { + return replayTimeline; + } + + void setTimeScaleFactor(qreal _timeScaleFactor); + void setSkipEmptySections(bool value); + +public slots: + void startReplay(); + void stopReplay(); + void setTime(int time); + void skipByAmount(int amount); // use a negative amount to skip backwards + +signals: + void timeChanged(int time); + void eventReplayed(const GameEventContainer &cont, EventProcessingOptions options); void replayFinished(); - void replayPlayButtonToggled(bool checked); - void replayFastForwardButtonToggled(bool checked); - void replayRewind(); - void refreshShortcuts(); + void rewound(); }; -#endif // REPLAY_MANAGER_H +#endif // COCKATRICE_REPLAY_MANAGER_H diff --git a/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.cpp b/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.cpp new file mode 100644 index 000000000..5d58705d2 --- /dev/null +++ b/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.cpp @@ -0,0 +1,58 @@ +#include "replay_quick_settings_widget.h" + +#include "../../../client/settings/cache_settings.h" + +#include +#include +#include +#include +#include + +ReplayQuickSettingsWidget::ReplayQuickSettingsWidget(QWidget *parent) : SettingsButtonWidget(parent) +{ + // fast forward speed + fastForwardSpeedBox.setMinimum(1); + fastForwardSpeedBox.setMaximum(99.9); + fastForwardSpeedBox.setDecimals(1); + fastForwardSpeedBox.setValue(SettingsCache::instance().userInterface().getFastForwardSpeed()); + 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); + + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, + &ReplayQuickSettingsWidget::retranslateUi); + retranslateUi(); +} + +void ReplayQuickSettingsWidget::retranslateUi() +{ + fastForwardSpeedLabel.setText(tr("Fast forward speed:")); + fastForwardSpeedBox.setSuffix("x"); + + skipEmptyCheckBox.setText(tr("Skip empty sections")); +} + +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 new file mode 100644 index 000000000..a337ea0a6 --- /dev/null +++ b/cockatrice/src/interface/widgets/replay/replay_quick_settings_widget.h @@ -0,0 +1,34 @@ +#ifndef COCKATRICE_REPLAY_QUICK_SETTINGS_WIDGET_H +#define COCKATRICE_REPLAY_QUICK_SETTINGS_WIDGET_H + +#include "../../interface/widgets/quick_settings/settings_button_widget.h" + +#include +#include +#include + +class ReplayQuickSettingsWidget : public SettingsButtonWidget +{ + Q_OBJECT + +public: + explicit ReplayQuickSettingsWidget(QWidget *parent); + + void retranslateUi(); + +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 46a3dff40..fe4ac330b 100644 --- a/cockatrice/src/interface/widgets/replay/replay_timeline_widget.cpp +++ b/cockatrice/src/interface/widgets/replay/replay_timeline_widget.cpp @@ -4,24 +4,19 @@ #include #include -#include -ReplayTimelineWidget::ReplayTimelineWidget(QWidget *parent) - : QWidget(parent), maxBinValue(1), maxTime(1), timeScaleFactor(1.0), currentVisualTime(0), currentProcessedTime(0), - currentEvent(0) +static constexpr int BIN_LENGTH = 5000; +static constexpr int MIN_RESOLUTION_MS = 1000; + +ReplayTimelineWidget::ReplayTimelineWidget(QWidget *parent) : QWidget(parent) { - replayTimer = new QTimer(this); - connect(replayTimer, &QTimer::timeout, this, &ReplayTimelineWidget::replayTimerTimeout); - - rewindBufferingTimer = new QTimer(this); - rewindBufferingTimer->setSingleShot(true); - connect(rewindBufferingTimer, &QTimer::timeout, this, &ReplayTimelineWidget::processRewind); } -void ReplayTimelineWidget::setTimeline(const QList &_replayTimeline) +void ReplayTimelineWidget::setTimeline(const QList &replayTimeline) { - replayTimeline = _replayTimeline; histogram.clear(); + currentTime = 0; + int binEndTime = BIN_LENGTH - 1; int binValue = 0; for (int i : replayTimeline) { @@ -64,74 +59,31 @@ void ReplayTimelineWidget::paintEvent(QPaintEvent * /* event */) painter.fillPath(path, Qt::black); const QColor barColor = QColor::fromHsv(120, 255, 255, 100); - quint64 w = (quint64)(width() - 1) * (quint64)currentVisualTime / maxTime; + quint64 w = (quint64)(width() - 1) * (quint64)currentTime / maxTime; painter.fillRect(0, 0, static_cast(w), height() - 1, barColor); } void ReplayTimelineWidget::mousePressEvent(QMouseEvent *event) { -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) int newTime = static_cast((qint64)maxTime * (qint64)event->position().x() / width()); -#else - int newTime = static_cast((qint64)maxTime * (qint64)event->x() / width()); -#endif - // don't buffer rewinds from clicks, since clicks usually don't happen fast enough to require buffering - skipToTime(newTime, false); + emit timeClicked(newTime); } -void ReplayTimelineWidget::skipToTime(int newTime, bool doRewindBuffering) +void ReplayTimelineWidget::setCurrentTime(int time) { - // check boundary conditions - if (newTime < 0) { - newTime = 0; - } - if (newTime > maxTime) { - newTime = maxTime; + int newTime = qBound(0, time, maxTime); + + if (currentTime == newTime) { + return; } - newTime -= newTime % TIMER_INTERVAL_MS; // Time should always be a multiple of the interval + bool doUpdate = currentTime / MIN_RESOLUTION_MS != newTime / MIN_RESOLUTION_MS; - const bool isBackwardsSkip = newTime < currentProcessedTime; - currentVisualTime = newTime; + currentTime = newTime; - if (isBackwardsSkip) { - handleBackwardsSkip(doRewindBuffering); - } else { - processNewEvents(FORWARD_SKIP); + if (doUpdate) { + update(); } - - update(); -} - -/** - * @brief Handles a backwards skip in the replay timeline. - * - * @param doRewindBuffering When true, if multiple backward skips are made in quick succession, only a single rewind - * is processed at the end. When false, the backwards skip will always cause an immediate rewind. - */ -void ReplayTimelineWidget::handleBackwardsSkip(bool doRewindBuffering) -{ - if (doRewindBuffering) { - // We use a one-shot timer to implement the rewind buffering. - // The rewind only happens once the timer runs out. - // If another backwards skip happens, the timer will just get reset instead of rewinding. - rewindBufferingTimer->stop(); - rewindBufferingTimer->start(SettingsCache::instance().getRewindBufferingMs()); - } else { - // otherwise, process the rewind immediately - processRewind(); - } -} - -void ReplayTimelineWidget::processRewind() -{ - // stop any queued-up rewinds - rewindBufferingTimer->stop(); - - // process the rewind - currentEvent = 0; - emit rewound(); - processNewEvents(BACKWARD_SKIP); } QSize ReplayTimelineWidget::sizeHint() const @@ -143,63 +95,3 @@ QSize ReplayTimelineWidget::minimumSizeHint() const { return {400, 50}; } - -void ReplayTimelineWidget::replayTimerTimeout() -{ - currentVisualTime += TIMER_INTERVAL_MS; - - processNewEvents(NORMAL_PLAYBACK); - - if (!(currentVisualTime % 1000)) { - update(); - } -} - -/** @brief Processes all unprocessed events up to the current time. */ -void ReplayTimelineWidget::processNewEvents(PlaybackMode playbackMode) -{ - currentProcessedTime = currentVisualTime; - - while ((currentEvent < replayTimeline.size()) && (replayTimeline[currentEvent] < currentProcessedTime)) { - EventProcessingOptions options; - - // backwards skip => always skip reveal windows - // forwards skip => skip reveal windows that don't happen within a big skip of the target - if (playbackMode == BACKWARD_SKIP || currentProcessedTime - replayTimeline[currentEvent] > BIG_SKIP_MS) { - options |= SKIP_REVEAL_WINDOW; - } - - // backwards skip => always skip tap animation - if (playbackMode == BACKWARD_SKIP) { - options |= SKIP_TAP_ANIMATION; - } - - emit processNextEvent(options); - ++currentEvent; - } - if (currentEvent == replayTimeline.size()) { - emit replayFinished(); - replayTimer->stop(); - } -} - -void ReplayTimelineWidget::setTimeScaleFactor(qreal _timeScaleFactor) -{ - timeScaleFactor = _timeScaleFactor; - replayTimer->setInterval(static_cast(TIMER_INTERVAL_MS / timeScaleFactor)); -} - -void ReplayTimelineWidget::startReplay() -{ - replayTimer->start(static_cast(TIMER_INTERVAL_MS / timeScaleFactor)); -} - -void ReplayTimelineWidget::stopReplay() -{ - replayTimer->stop(); -} - -void ReplayTimelineWidget::skipByAmount(int amount) -{ - skipToTime(currentVisualTime + amount, amount < 0); -} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/replay/replay_timeline_widget.h b/cockatrice/src/interface/widgets/replay/replay_timeline_widget.h index 418d0c7e0..47d19a741 100644 --- a/cockatrice/src/interface/widgets/replay/replay_timeline_widget.h +++ b/cockatrice/src/interface/widgets/replay/replay_timeline_widget.h @@ -18,58 +18,25 @@ class QTimer; class ReplayTimelineWidget : public QWidget { Q_OBJECT + signals: - void processNextEvent(EventProcessingOptions options); - void replayFinished(); - void rewound(); + void timeClicked(int newTime); private: - enum PlaybackMode - { - NORMAL_PLAYBACK, - FORWARD_SKIP, - BACKWARD_SKIP - }; - - static constexpr int TIMER_INTERVAL_MS = 200; - static constexpr int BIN_LENGTH = 5000; - - QTimer *replayTimer; - QTimer *rewindBufferingTimer; - QList replayTimeline; QList histogram; - int maxBinValue, maxTime; - qreal timeScaleFactor; - int currentVisualTime; // time currently displayed by the timeline - int currentProcessedTime; // time that events are currently processed up to. Could differ from visual time due to - // rewind buffering - int currentEvent; + int maxBinValue = 1; + int maxTime = 1; - void skipToTime(int newTime, bool doRewindBuffering); - void handleBackwardsSkip(bool doRewindBuffering); - void processRewind(); - void processNewEvents(PlaybackMode playbackMode); -private slots: - void replayTimerTimeout(); + int currentTime = 0; public: - static constexpr int SMALL_SKIP_MS = 1000; - static constexpr int BIG_SKIP_MS = 10000; - static constexpr qreal FAST_FORWARD_SCALE_FACTOR = 10.0; - explicit ReplayTimelineWidget(QWidget *parent = nullptr); - void setTimeline(const QList &_replayTimeline); + void setTimeline(const QList &replayTimeline); [[nodiscard]] QSize sizeHint() const override; [[nodiscard]] QSize minimumSizeHint() const override; - void setTimeScaleFactor(qreal _timeScaleFactor); - [[nodiscard]] int getCurrentEvent() const - { - return currentEvent; - } + public slots: - void startReplay(); - void stopReplay(); - void skipByAmount(int amount); // use a negative amount to skip backwards + void setCurrentTime(int time); protected: void paintEvent(QPaintEvent *event) override; diff --git a/cockatrice/src/interface/widgets/replay/replay_widget.cpp b/cockatrice/src/interface/widgets/replay/replay_widget.cpp new file mode 100644 index 000000000..c92771f8c --- /dev/null +++ b/cockatrice/src/interface/widgets/replay/replay_widget.cpp @@ -0,0 +1,131 @@ +#include "replay_widget.h" + +#include "../../../client/settings/cache_settings.h" +#include "../../../client/settings/shortcuts_settings.h" +#include "../../pixel_map_generator.h" +#include "../interface/widgets/tabs/tab_game.h" +#include "replay_manager.h" +#include "replay_quick_settings_widget.h" + +#include +#include + +ReplayWidget::ReplayWidget(QWidget *parent, GameReplay *replay) + : QWidget(parent), replayPlayButton(nullptr), replayFastForwardButton(nullptr), aReplaySkipForward(nullptr), + aReplaySkipBackward(nullptr), aReplaySkipForwardBig(nullptr), aReplaySkipBackwardBig(nullptr) +{ + // replay manager + replayManager = new ReplayManager(this, replay); + connect(replayManager, &ReplayManager::eventReplayed, this, &ReplayWidget::eventReplayed); + connect(replayManager, &ReplayManager::replayFinished, this, &ReplayWidget::replayFinished); + connect(replayManager, &ReplayManager::rewound, this, &ReplayWidget::rewound); + + // timeline widget + timelineWidget = new ReplayTimelineWidget; + timelineWidget->setTimeline(replayManager->getReplayTimeline()); + connect(replayManager, &ReplayManager::timeChanged, timelineWidget, &ReplayTimelineWidget::setCurrentTime); + connect(timelineWidget, &ReplayTimelineWidget::timeClicked, replayManager, &ReplayManager::setTime); + + // timeline skip shortcuts + aReplaySkipForward = new QAction(timelineWidget); + timelineWidget->addAction(aReplaySkipForward); + connect(aReplaySkipForward, &QAction::triggered, this, + [this] { replayManager->skipByAmount(ReplayManager::SMALL_SKIP_MS); }); + + aReplaySkipBackward = new QAction(timelineWidget); + timelineWidget->addAction(aReplaySkipBackward); + connect(aReplaySkipBackward, &QAction::triggered, this, + [this] { replayManager->skipByAmount(-ReplayManager::SMALL_SKIP_MS); }); + + aReplaySkipForwardBig = new QAction(timelineWidget); + timelineWidget->addAction(aReplaySkipForwardBig); + connect(aReplaySkipForwardBig, &QAction::triggered, this, + [this] { replayManager->skipByAmount(ReplayManager::BIG_SKIP_MS); }); + + aReplaySkipBackwardBig = new QAction(timelineWidget); + timelineWidget->addAction(aReplaySkipBackwardBig); + connect(aReplaySkipBackwardBig, &QAction::triggered, this, + [this] { replayManager->skipByAmount(-ReplayManager::BIG_SKIP_MS); }); + + // buttons + replayPlayButton = new QToolButton; + replayPlayButton->setIconSize(QSize(32, 32)); + QIcon playButtonIcon = QIcon(); + 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(themePixmap(QStringLiteral("replay/fastforward"))); + replayFastForwardButton->setCheckable(true); + connect(replayFastForwardButton, &QToolButton::toggled, this, &ReplayWidget::updateTimeScaleFactor); + + settingsWidget = new ReplayQuickSettingsWidget(this); + 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; + replayControlLayout->addWidget(timelineWidget, 10); + replayControlLayout->addWidget(replayPlayButton); + replayControlLayout->addWidget(replayFastForwardButton); + replayControlLayout->addWidget(settingsWidget); + + setObjectName("replayControlWidget"); + setLayout(replayControlLayout); + + connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this, + &ReplayWidget::refreshShortcuts); + refreshShortcuts(); +} + +void ReplayWidget::replayFinished() +{ + replayPlayButton->setChecked(false); +} + +void ReplayWidget::replayPlayButtonToggled(bool checked) +{ + if (checked) { // start replay + replayManager->startReplay(); + } else { // pause replay + replayManager->stopReplay(); + } +} + +void ReplayWidget::updateTimeScaleFactor(bool isFastForward) +{ + qreal factor = isFastForward ? SettingsCache::instance().userInterface().getFastForwardSpeed() : 1.0; + replayManager->setTimeScaleFactor(factor); +} + +void ReplayWidget::refreshShortcuts() +{ + ShortcutsSettings &shortcuts = SettingsCache::instance().shortcuts(); + if (aReplaySkipForward) { + aReplaySkipForward->setShortcuts(shortcuts.getShortcut("Replays/aSkipForward")); + } + if (aReplaySkipBackward) { + aReplaySkipBackward->setShortcuts(shortcuts.getShortcut("Replays/aSkipBackward")); + } + if (aReplaySkipForwardBig) { + aReplaySkipForwardBig->setShortcuts(shortcuts.getShortcut("Replays/aSkipForwardBig")); + } + if (aReplaySkipBackwardBig) { + aReplaySkipBackwardBig->setShortcuts(shortcuts.getShortcut("Replays/aSkipBackwardBig")); + } + if (replayPlayButton) { + replayPlayButton->setShortcut(shortcuts.getSingleShortcut("Replays/playButton")); + } + if (replayFastForwardButton) { + replayFastForwardButton->setShortcut(shortcuts.getSingleShortcut("Replays/fastForwardButton")); + } +} \ No newline at end of file diff --git a/cockatrice/src/interface/widgets/replay/replay_widget.h b/cockatrice/src/interface/widgets/replay/replay_widget.h new file mode 100644 index 000000000..6d2b7a043 --- /dev/null +++ b/cockatrice/src/interface/widgets/replay/replay_widget.h @@ -0,0 +1,55 @@ +/** + * @file replay_widget.h + * @ingroup Core + * @ingroup Replay + */ +//! \todo Document this file. + +#ifndef REPLAY_WIDGET_H +#define REPLAY_WIDGET_H + +#include "replay_timeline_widget.h" + +#include +#include +#include + +class ReplayManager; +class ReplayQuickSettingsWidget; +class TabGame; + +/** + * @brief The top-level widget that is put in the replay dock widget. + * Contains the replay timeline as well as the buttons. + */ +class ReplayWidget : public QWidget +{ + Q_OBJECT + +public: + /** + * @param parent The parent widget + * @param replay Cannot be null. Takes ownership of the replay. + */ + ReplayWidget(QWidget *parent, GameReplay *replay); + +signals: + void rewound(); + void eventReplayed(const GameEventContainer &cont, EventProcessingOptions options); + +private: + ReplayManager *replayManager; + + ReplayTimelineWidget *timelineWidget; + QToolButton *replayPlayButton, *replayFastForwardButton; + ReplayQuickSettingsWidget *settingsWidget; + QAction *aReplaySkipForward, *aReplaySkipBackward, *aReplaySkipForwardBig, *aReplaySkipBackwardBig; + +private slots: + void replayFinished(); + void replayPlayButtonToggled(bool checked); + void updateTimeScaleFactor(bool checked); + void refreshShortcuts(); +}; + +#endif // REPLAY_WIDGET_H 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 ccbbe9246..bebc2e3c4 100644 --- a/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp +++ b/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp @@ -13,7 +13,12 @@ #include #include #include +#include +#include +#include +#include #include +#include const QColor DEFAULT_MENTION_COLOR = QColor(194, 31, 47); @@ -46,9 +51,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() @@ -61,12 +69,14 @@ void ChatView::adjustColorsToPalette() serverMessageColor = QColor(0xFF, 0x73, 0x83); otherUserColor = otherUserColor.lighter(150); linkColor = QColor(71, 158, 252); + unresolvedCardTagColor = QColor(0xFF, 0xA5, 0x00); } else { document()->setDefaultStyleSheet(R"( a { text-decoration: none; color: blue; } .blue { color: blue } )"); linkColor = palette().link().color(); + unresolvedCardTagColor = QColor(0xA0, 0x52, 0x2D); } QTimer::singleShot(0, this, &ChatView::refreshBlockColors); @@ -147,7 +157,7 @@ void ChatView::appendHtml(const QString &html) bool atBottom = verticalScrollBar()->value() >= verticalScrollBar()->maximum(); prepareBlock().insertHtml(html); if (atBottom) { - verticalScrollBar()->setValue(verticalScrollBar()->maximum()); + scrollToBottom(); } } @@ -165,20 +175,29 @@ void ChatView::appendHtmlServerMessage(const QString &html, bool optionalIsBold, prepareBlock().insertHtml(htmlText); if (atBottom) { - verticalScrollBar()->setValue(verticalScrollBar()->maximum()); + scrollToBottom(); } } void ChatView::appendCardTag(QTextCursor &cursor, const QString &cardName) { QTextCharFormat oldFormat = cursor.charFormat(); - QTextCharFormat anchorFormat = oldFormat; - anchorFormat.setForeground(linkColor); - anchorFormat.setAnchor(true); - anchorFormat.setAnchorHref("card://" + cardName); - anchorFormat.setFontItalic(true); + QTextCharFormat cardFormat = oldFormat; + cardFormat.setFontItalic(true); - cursor.setCharFormat(anchorFormat); + if (!CardDatabaseManager::query()->lookupCardByName(cardName)) { + cardFormat.setForeground(unresolvedCardTagColor); + cursor.setCharFormat(cardFormat); + cursor.insertText(cardName); + cursor.setCharFormat(oldFormat); + return; + } + + cardFormat.setForeground(linkColor); + cardFormat.setAnchor(true); + cardFormat.setAnchorHref("card://" + cardName); + + cursor.setCharFormat(cardFormat); cursor.insertText(cardName); cursor.setCharFormat(oldFormat); } @@ -202,6 +221,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, @@ -214,6 +273,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; @@ -292,8 +359,8 @@ void ChatView::appendMessage(QString message, } cursor.setCharFormat(defaultFormat); - bool mentionEnabled = SettingsCache::instance().getChatMention(); - highlightedWords = SettingsCache::instance().getHighlightWords().split(' ', Qt::SkipEmptyParts); + bool mentionEnabled = SettingsCache::instance().chat().getChatMention(); + highlightedWords = SettingsCache::instance().chat().getHighlightWords().split(' ', Qt::SkipEmptyParts); // parse the message while (message.size()) { @@ -325,11 +392,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]")) { @@ -395,8 +487,9 @@ void ChatView::checkMention(QTextCursor &cursor, QString &message, const QString // You have received a valid mention!! soundEngine->playSound("chat_mention"); mentionFormat.setBackground(QBrush(getCustomMentionColor())); - mentionFormat.setForeground(SettingsCache::instance().getChatMentionForeground() ? QBrush(Qt::white) - : QBrush(Qt::black)); + mentionFormat.setForeground(SettingsCache::instance().chat().getChatMentionForeground() + ? QBrush(Qt::white) + : QBrush(Qt::black)); cursor.insertText(mention, mentionFormat); message = message.mid(mention.size()); showSystemPopup(userName); @@ -417,8 +510,8 @@ void ChatView::checkMention(QTextCursor &cursor, QString &message, const QString // Moderator Sending Global Message soundEngine->playSound("all_mention"); mentionFormat.setBackground(QBrush(getCustomMentionColor())); - mentionFormat.setForeground(SettingsCache::instance().getChatMentionForeground() ? QBrush(Qt::white) - : QBrush(Qt::black)); + mentionFormat.setForeground( + SettingsCache::instance().chat().getChatMentionForeground() ? QBrush(Qt::white) : QBrush(Qt::black)); cursor.insertText("@" + fullMentionUpToSpaceOrEnd, mentionFormat); message = message.mid(fullMentionUpToSpaceOrEnd.size() + 1); showSystemPopup(userName); @@ -460,13 +553,24 @@ 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) { // You have received a valid mention of custom word!! highlightFormat.setBackground(QBrush(getCustomHighlightColor())); - highlightFormat.setForeground(SettingsCache::instance().getChatHighlightForeground() ? QBrush(Qt::white) - : QBrush(Qt::black)); + highlightFormat.setForeground( + SettingsCache::instance().chat().getChatHighlightForeground() ? QBrush(Qt::white) : QBrush(Qt::black)); cursor.insertText(fullWordUpToSpaceOrEnd, highlightFormat); cursor.insertText(rest, defaultFormat); QApplication::alert(this); @@ -522,7 +626,7 @@ void ChatView::actMessageClicked() void ChatView::showSystemPopup(const QString &userName) { QApplication::alert(this); - if (SettingsCache::instance().getShowMentionPopup()) { + if (SettingsCache::instance().chat().getShowMentionPopup()) { emit showMentionPopup(userName); } } @@ -530,10 +634,10 @@ void ChatView::showSystemPopup(const QString &userName) QColor ChatView::getCustomMentionColor() { #if (QT_VERSION >= QT_VERSION_CHECK(6, 4, 0)) - QColor customColor = QColor::fromString("#" + SettingsCache::instance().getChatMentionColor()); + QColor customColor = QColor::fromString("#" + SettingsCache::instance().chat().getChatMentionColor()); #else QColor customColor; - customColor.setNamedColor("#" + SettingsCache::instance().getChatMentionColor()); + customColor.setNamedColor("#" + SettingsCache::instance().chat().getChatMentionColor()); #endif return customColor.isValid() ? customColor : DEFAULT_MENTION_COLOR; } @@ -541,10 +645,10 @@ QColor ChatView::getCustomMentionColor() QColor ChatView::getCustomHighlightColor() { #if (QT_VERSION >= QT_VERSION_CHECK(6, 4, 0)) - QColor customColor = QColor::fromString("#" + SettingsCache::instance().getChatMentionColor()); + QColor customColor = QColor::fromString("#" + SettingsCache::instance().chat().getChatMentionColor()); #else QColor customColor; - customColor.setNamedColor("#" + SettingsCache::instance().getChatMentionColor()); + customColor.setNamedColor("#" + SettingsCache::instance().chat().getChatMentionColor()); #endif return customColor.isValid() ? customColor : DEFAULT_MENTION_COLOR; } @@ -554,6 +658,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) @@ -578,11 +695,7 @@ void ChatView::redactMessages(const QString &userName, int amount) } } -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) void ChatView::enterEvent(QEnterEvent * /*event*/) -#else -void ChatView::enterEvent(QEvent * /*event*/) -#endif { setMouseTracking(true); } @@ -639,12 +752,9 @@ void ChatView::mousePressEvent(QMouseEvent *event) { switch (hoveredItemType) { case HoveredCard: { - if ((event->button() == Qt::MiddleButton) || (event->button() == Qt::LeftButton)) -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) + if ((event->button() == Qt::MiddleButton) || (event->button() == Qt::LeftButton)) { emit showCardInfoPopup(event->globalPosition().toPoint(), {hoveredContent}); -#else - emit showCardInfoPopup(event->globalPos(), {hoveredContent}); -#endif + } break; } case HoveredUser: { @@ -654,11 +764,7 @@ void ChatView::mousePressEvent(QMouseEvent *event) switch (event->button()) { case Qt::RightButton: { UserLevelFlags userLevel(hoveredContent.left(delimiterIndex).toInt()); -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) userContextMenu->showContextMenu(event->globalPosition().toPoint(), userName, userLevel, this); -#else - userContextMenu->showContextMenu(event->globalPos(), userName, userLevel, this); -#endif break; } case Qt::LeftButton: { @@ -692,6 +798,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 c5ae2b81a..671b35163 100644 --- a/cockatrice/src/interface/widgets/server/chat_view/chat_view.h +++ b/cockatrice/src/interface/widgets/server/chat_view/chat_view.h @@ -33,6 +33,13 @@ public: QTextBlock block; }; +struct ChatLogEntry +{ + QString userName; + QString message; + QDateTime timestamp; +}; + class ChatView : public QTextBrowser { Q_OBJECT @@ -60,15 +67,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); @@ -81,12 +93,15 @@ private: QColor otherUserColor = QColor(0, 65, 255); // dark blue QColor serverMessageColor = QColor(0x85, 0x15, 0x15); QColor linkColor; + QColor unresolvedCardTagColor; private slots: void openLink(const QUrl &link); void actMessageClicked(); void adjustColorsToPalette(); void refreshBlockColors(); + void onScrollBarRangeChanged(); + void onScrollBarValueChanged(int value); public: ChatView(TabSupervisor *_tabSupervisor, AbstractGame *_game, bool _showTimestamps, QWidget *parent = nullptr); @@ -101,13 +116,10 @@ public: bool playerBold = false); void clearChat(); void redactMessages(const QString &userName, int amount); + QString getRecentChatLog(int maxMessages = 50) const; protected: -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) void enterEvent(QEnterEvent *event) override; -#else - void enterEvent(QEvent *event) override; -#endif void leaveEvent(QEvent *event) override; void mouseMoveEvent(QMouseEvent *event) override; void mousePressEvent(QMouseEvent *event) override; @@ -120,6 +132,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..5cb048669 100644 --- a/cockatrice/src/interface/widgets/server/game_filter_configs.h +++ b/cockatrice/src/interface/widgets/server/game_filter_configs.h @@ -19,7 +19,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 9a41ca6ce..659325987 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector.cpp @@ -1,5 +1,6 @@ #include "game_selector.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" @@ -7,10 +8,13 @@ #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 "games_model.h" #include "user/user_list_manager.h" +#include #include +#include #include #include #include @@ -21,6 +25,8 @@ #include #include #include +#include +#include GameSelector::GameSelector(AbstractClient *_client, TabSupervisor *_tabSupervisor, @@ -78,20 +84,22 @@ GameSelector::GameSelector(AbstractClient *_client, if (showFilters && restoresettings) { quickFilterToolBar = new GameSelectorQuickFilterToolBar(this, tabSupervisor, gameListProxyModel, gameTypeMap); quickFilterToolBar->setVisible(showFilters && restoresettings && - SettingsCache::instance().getShowGameSelectorFilterToolbar()); + SettingsCache::instance().userInterface().getShowGameSelectorFilterToolbar()); - connect(&SettingsCache::instance(), &SettingsCache::showGameSelectorFilterToolbarChanged, this, [this] { - quickFilterToolBar->setVisible(SettingsCache::instance().getShowGameSelectorFilterToolbar()); - }); + connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::showGameSelectorFilterToolbarChanged, + this, [this] { + quickFilterToolBar->setVisible( + SettingsCache::instance().userInterface().getShowGameSelectorFilterToolbar()); + }); } else { quickFilterToolBar = nullptr; } 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); @@ -312,6 +320,14 @@ void GameSelector::customContextMenu(const QPoint &point) dlg.exec(); }); + QAction copyLink(tr("Cop&y game link")); + connect(©Link, &QAction::triggered, this, [=, this]() { + const ServerInfo_Game &gameInfo = gameListModel->getGame(index.data(Qt::UserRole).toInt()); + QGuiApplication::clipboard()->setText(makeGameJoinLink(client->serverName(), client->serverPort(), + gameInfo.room_id(), gameInfo.game_id(), + QString::fromStdString(gameInfo.description()))); + }); + QMenu menu; menu.addAction(&joinGame); @@ -329,6 +345,11 @@ void GameSelector::customContextMenu(const QPoint &point) menu.addAction(&spectateGame); menu.addAction(&getGameInfo); + + if (!client->serverName().isEmpty()) { + menu.addAction(©Link); + } + menu.exec(gameListView->mapToGlobal(point)); } @@ -339,18 +360,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; } @@ -376,6 +419,22 @@ void GameSelector::joinGame(const bool asSpectator, const bool asJudge) disableButtons(); } +bool GameSelector::joinGameById(const int gameId, const bool asSpectator) +{ + 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"; + return false; +} + void GameSelector::disableButtons() { if (createButton) { @@ -403,7 +462,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 fa91e5f96..9af39cf50 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.h +++ b/cockatrice/src/interface/widgets/server/game_selector.h @@ -171,6 +171,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,6 +213,16 @@ public: * @param info The ServerInfo_Game object containing information about the game to update. */ void processGameInfo(const ServerInfo_Game &info); + /** + * @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/games_model.cpp b/cockatrice/src/interface/widgets/server/games_model.cpp index 1d30aa7ac..127badbb2 100644 --- a/cockatrice/src/interface/widgets/server/games_model.cpp +++ b/cockatrice/src/interface/widgets/server/games_model.cpp @@ -10,19 +10,34 @@ #include #include #include +#include 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}; @@ -109,16 +124,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(); @@ -232,8 +247,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: @@ -270,6 +285,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)); } @@ -346,7 +364,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()}); @@ -363,7 +381,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()) { @@ -408,11 +426,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()) { @@ -434,10 +452,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/handle_public_servers.cpp b/cockatrice/src/interface/widgets/server/handle_public_servers.cpp index 8ebb70e83..f5d682c36 100644 --- a/cockatrice/src/interface/widgets/server/handle_public_servers.cpp +++ b/cockatrice/src/interface/widgets/server/handle_public_servers.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #define PUBLIC_SERVERS_JSON "https://cockatrice.github.io/public-servers.json" 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..d62bf81aa 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,5 +1,7 @@ #include "remote_replay_list_tree_widget.h" +#include "../../../pixel_map_generator.h" + #include #include #include @@ -37,7 +39,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/user/user_card_art_provider.cpp b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp index 67fb4f684..2ba745715 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,6 +1,7 @@ #include "user_card_art_provider.h" #include "../../../card_picture_loader/card_picture_loader.h" +#include "../../cards/card_art_utils.h" #include #include @@ -39,7 +40,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 +53,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 +121,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 +145,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..e8283a891 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 @@ -6,6 +6,7 @@ #include #include #include +#include class UserCardArtProvider : public QObject { @@ -16,7 +17,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 +27,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..d49e3d540 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,7 +1,8 @@ #include "user_card_settings_dialog.h" #include "../../../card_picture_loader/card_picture_loader.h" -#include "card/card_completer_proxy_model.h" +#include "../../cards/art_crop_attribution.h" +#include "../../utility/completer_utils.h" #include "card/card_search_model.h" #include "card_database_display_model.h" #include "card_database_model.h" @@ -14,19 +15,46 @@ #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 +66,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 +95,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 +121,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 +138,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 +354,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 +393,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 +429,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 +510,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 +540,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 +550,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 +560,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..24e14dce5 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 @@ -8,13 +8,29 @@ #include class QCompleter; +class QFocusEvent; +class QGroupBox; +class QKeyEvent; +class QMouseEvent; class QLineEdit; class QDoubleSpinBox; +class QLabel; +class QPushButton; +class QWheelEvent; class CardDatabaseModel; class CardDatabaseDisplayModel; class CardSearchModel; class CardCompleterProxyModel; +/** + * @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 +40,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 +85,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 +101,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..0d2267a63 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp @@ -1,14 +1,15 @@ #include "user_context_menu.h" +#include "../../dialogs/dlg_report_user.h" #include "../../interface/widgets/tabs/tab_account.h" #include "../../interface/widgets/tabs/tab_game.h" #include "../../interface/widgets/tabs/tab_supervisor.h" #include "../chat_view/chat_view.h" #include "../game_selector.h" #include "user_info_box.h" +#include "user_list_dialog.h" #include "user_list_manager.h" #include "user_list_proxy.h" -#include "user_list_widget.h" #include #include @@ -41,6 +42,7 @@ UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent, 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 +51,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(); } @@ -64,6 +69,7 @@ void UserContextMenu::retranslateUi() 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 +78,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 +153,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 +272,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 +280,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 +365,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); @@ -366,6 +377,17 @@ void UserContextMenu::showContextMenu(const QPoint &pos, menu->addAction(aDetails); menu->addAction(aShowGames); 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 +405,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 +421,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 +441,20 @@ 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); aAddToBuddyList->setEnabled(anotherUser); aRemoveFromBuddyList->setEnabled(anotherUser); aAddToIgnoreList->setEnabled(anotherUser); @@ -430,8 +465,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) { @@ -451,12 +491,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 +516,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 +533,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()), @@ -518,7 +625,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 +702,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 +725,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..6abbc057a 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.h +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.h @@ -7,9 +7,12 @@ #ifndef USER_CONTEXT_MENU_H #define USER_CONTEXT_MENU_H -#include -#include +#include "../../interface/widgets/server/game_link.h" +#include +#include +#include +#include class AbstractGame; class UserListProxy; class AbstractClient; @@ -38,11 +41,15 @@ private: 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,9 +87,28 @@ 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 execAddToBuddy(const QString &userName); @@ -95,8 +121,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..3d89cecf5 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp @@ -122,6 +122,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 +321,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_connection.cpp b/cockatrice/src/interface/widgets/server/user/user_info_connection.cpp index 919bc2707..f934960f8 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_connection.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_connection.cpp @@ -3,6 +3,7 @@ #include "../../../../client/settings/cache_settings.h" #include +#include #include UserConnection_Information::UserConnection_Information() = default; 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 2b4dcb8ed..8be76eea0 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp @@ -1,6 +1,8 @@ #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 "user_list_painter.h" @@ -17,11 +19,48 @@ #include #include #include +#include #include #include #include #include +/// 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 ───────────────────────────────────────────────── class PopupGameDelegate : public QStyledItemDelegate @@ -48,8 +87,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 +109,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 +119,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 +142,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 +172,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 +229,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 +245,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 +266,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 +291,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 +303,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 +324,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 +516,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 +566,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 +582,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 +617,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,8 +630,9 @@ 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(); QAction *join = menu.addAction(tr("Join game")); @@ -513,7 +643,7 @@ void UserInfoPopup::onGamesContextMenu(const QPoint &pos) 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 +657,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 +706,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,49 +747,41 @@ 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(); } // ── Mouse events ────────────────────────────────────────────────────────────── -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) void UserInfoPopup::enterEvent(QEnterEvent *e) { QFrame::enterEvent(e); emit mouseEnteredPopup(); } -#else -void UserInfoPopup::enterEvent(QEvent *e) -{ - QFrame::enterEvent(e); - emit mouseEnteredPopup(); -} -#endif 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 0e03147c4..ed7320fba 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_popup.h +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.h @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -26,6 +27,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 +81,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 +124,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 +139,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 +171,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); @@ -143,11 +189,7 @@ signals: void demoteFromJudgeRequested(const QString &userName); protected: -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) void enterEvent(QEnterEvent *e) override; -#else - void enterEvent(QEvent *e) override; -#endif void leaveEvent(QEvent *e) override; private slots: @@ -157,25 +199,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..fc5b520ad --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_list_dialog.cpp @@ -0,0 +1,302 @@ +#include "user_list_dialog.h" + +#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.\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..fa7d5de92 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_list_dialog.h @@ -0,0 +1,79 @@ +#ifndef COCKATRICE_USER_LIST_DIALOG_H +#define COCKATRICE_USER_LIST_DIALOG_H + +#include +#include + +class QComboBox; +class QLabel; +class QPlainTextEdit; +class QRadioButton; +class QSpinBox; +class QLineEdit; +class QCheckBox; + +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_painter.cpp b/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp index 8891ff268..34a3d6ae1 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp @@ -3,6 +3,7 @@ #include "../../interface/pixel_map_generator.h" #include +#include #include #include #include @@ -19,12 +20,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 +85,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 +155,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 +178,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 +219,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 +240,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 +253,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 +269,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 +278,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 +301,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 +323,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 +346,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 +369,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 +389,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 +399,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..352a01f6b 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_painter.h +++ b/cockatrice/src/interface/widgets/server/user/user_list_painter.h @@ -6,6 +6,7 @@ #include #include #include +#include #include #include #include @@ -28,13 +29,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 +79,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 +93,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 +103,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 +111,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..937058024 --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_list_panel_widget.cpp @@ -0,0 +1,88 @@ +#include "user_list_panel_widget.h" + +#include "../../../../client/settings/cache_settings.h" +#include "user_list_manager.h" +#include "user_list_widget.h" + +#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..7ae14dfcf --- /dev/null +++ b/cockatrice/src/interface/widgets/server/user/user_list_panel_widget.h @@ -0,0 +1,43 @@ +/** + * @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 + +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 09480b1a1..1bb7c5288 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -1,320 +1,21 @@ #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_context_menu.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 - -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(); -} +#include +#include +#include +#include namespace UserListRoles { @@ -322,11 +23,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) { } @@ -339,11 +45,10 @@ bool UserListItemDelegate::editorEvent(QEvent *event, if ((event->type() == QEvent::MouseButtonPress) && index.isValid()) { QMouseEvent *const mouseEvent = static_cast(event); if (mouseEvent->button() == Qt::RightButton) { -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) - static_cast(parent())->showContextMenu(mouseEvent->globalPosition().toPoint(), index); -#else - static_cast(parent())->showContextMenu(mouseEvent->globalPos(), index); -#endif + // Dialog mode has no context menu: consume the press, show nothing. + if (owner->getHasUserInfoPopup()) { + owner->showContextMenu(mouseEvent->globalPosition().toPoint(), index); + } return true; } } @@ -352,28 +57,132 @@ bool UserListItemDelegate::editorEvent(QEvent *event, QSize UserListItemDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const { - if (!SettingsCache::instance().getStyleUserList()) { + 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().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) @@ -397,8 +206,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)); } /** @@ -423,9 +234,11 @@ 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, Developers & Mods need no additional comparison checks, just to see if they're an admin, a developer + // or a moderator static const QList userLevelWithNoOtherPrefOrder = { - ServerInfo_User_UserLevelFlag_IsAdmin, ServerInfo_User_UserLevelFlag_IsModerator}; + ServerInfo_User_UserLevelFlag_IsAdmin, ServerInfo_User_UserLevelFlag_IsDeveloper, + ServerInfo_User_UserLevelFlag_IsModerator}; for (const auto &userLevelEntry : userLevelWithNoOtherPrefOrder) { if (lhsUserLevelFlags.testFlag(userLevelEntry) && lhsUserLevelFlags.testFlag(userLevelEntry) == rhsUserLevelFlags.testFlag(userLevelEntry)) { @@ -461,15 +274,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); @@ -480,6 +292,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); @@ -487,78 +301,178 @@ 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().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); }); - connect(&SettingsCache::instance(), &SettingsCache::styleUserListChanged, this, &UserListWidget::applyDisplayMode); + // Hide popup when list scrolls (reference row has moved) + connect(userTree->verticalScrollBar(), &QScrollBar::valueChanged, this, [this] { + showPopupTimer->stop(); + hidePopup(true); + requestVisibleItemResources(); + }); + + // Forward join requests from popup upward + connect(userInfoPopup, &UserInfoPopup::joinGameRequested, this, &UserListWidget::joinGameRequested); + } 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); @@ -567,6 +481,11 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, retranslateUi(); } +UserListWidget::~UserListWidget() +{ + qApp->removeEventFilter(this); +} + void UserListWidget::bind(UserListManager *mgr) { manager = mgr; @@ -574,50 +493,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); } }; @@ -635,8 +574,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; @@ -647,29 +600,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().getStyleUserList(); + 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); @@ -680,72 +656,126 @@ 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); }); } 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().getStyleUserList()) { + 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(); } } } @@ -753,41 +783,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; } @@ -798,12 +863,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; @@ -832,31 +902,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(); @@ -877,17 +969,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) { @@ -903,19 +1078,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; @@ -927,9 +1101,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) { @@ -942,37 +1127,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; @@ -980,26 +1220,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); @@ -1007,5 +1379,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..412271160 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.h +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.h @@ -8,6 +8,7 @@ #define USERLIST_H #include "../../cards/card_info_picture_art_crop_widget.h" +#include "../../interface/widgets/server/game_link.h" #include "user_avatar_provider.h" #include "user_card_art_provider.h" #include "user_info_popup.h" @@ -15,12 +16,12 @@ #include "user_list_painter.h" #include -#include #include -#include +#include #include #include #include +#include #include #include @@ -28,86 +29,23 @@ 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 QShowEvent; 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 +84,12 @@ public: BuddyList, IgnoreList }; + enum class Section + { + Buddy, + Online, + Ignore + }; private: UserListManager *manager = nullptr; @@ -153,30 +97,75 @@ 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); @@ -184,29 +173,60 @@ signals: 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 new file mode 100644 index 000000000..9a70e94c5 --- /dev/null +++ b/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.cpp @@ -0,0 +1,196 @@ +#include "abstract_settings_page.h" + +#include "settings_search_model.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +/** + * @brief Recursively collects all widgets within a layout + * @param layout The layout to walk + * @param widgets Output list of (widget, containing layout) pairs + */ +static void collectWidgets(QLayout *layout, QList> &widgets) +{ + for (int i = 0; i < layout->count(); ++i) { + QLayoutItem *item = layout->itemAt(i); + if (!item) { + continue; + } + if (QWidget *widget = item->widget()) { + widgets.append({widget, layout}); + } else if (QLayout *subLayout = item->layout()) { + collectWidgets(subLayout, widgets); + } + } +} + +/** + * @brief Rejects QLabels that are not setting names + * + * HTML link labels, path values, and excessively long labels are filtered out. + */ +static bool isValidSettingLabel(const QLabel *label) +{ + const QString &text = label->text(); + if (Qt::mightBeRichText(text)) { + return false; + } + if (text.contains(QLatin1Char('/')) || text.contains(QLatin1Char('\\'))) { + return false; + } + if (text.size() > 60) { + return false; + } + return true; +} + +/** + * @brief Finds the control associated with a setting label + * + * Uses the explicit buddy if set, otherwise the widget in the cell (or slot) + * immediately following the label within the same layout. Returns nullptr when + * no obvious control is found. + */ +static QWidget *controlForLabel(QLabel *label, QLayout *containingLayout) +{ + if (QWidget *buddy = label->buddy()) { + return buddy; + } + + if (auto *grid = qobject_cast(containingLayout)) { + int index = grid->indexOf(label); + if (index != -1) { + int row = 0; + int column = 0; + int rowSpan = 1; + int columnSpan = 1; + grid->getItemPosition(index, &row, &column, &rowSpan, &columnSpan); + if (QLayoutItem *next = grid->itemAtPosition(row, column + columnSpan)) { + if (QWidget *nextWidget = next->widget()) { + return nextWidget; + } + } + } + } else { + int index = containingLayout->indexOf(label); + if (index != -1) { + for (int i = index + 1; i < containingLayout->count(); ++i) { + if (QLayoutItem *next = containingLayout->itemAt(i)) { + if (QWidget *nextWidget = next->widget()) { + return nextWidget; + } + } + } + } + } + + return nullptr; +} + +/** + * @brief Builds the extended search text for an entry + * + * Combines the group title, label, and any extra searchable text derived from + * the associated control (placeholder, prefix/suffix, tooltip). Combo values + * and numeric tooltips are excluded since they change at runtime and would + * make the search index stale. + */ +static QString buildFullSearchText(const QString &groupTitle, const QString &cleanLabel, QWidget *control) +{ + QStringList parts = {groupTitle, cleanLabel}; + if (control) { + if (auto *lineEdit = qobject_cast(control)) { + parts.append(lineEdit->placeholderText()); + } else if (auto *spinBox = qobject_cast(control)) { + parts.append(spinBox->prefix()); + parts.append(spinBox->suffix()); + } + if (!control->toolTip().isEmpty()) { + bool isNumeric = false; + control->toolTip().toInt(&isNumeric); + if (!isNumeric) { + parts.append(control->toolTip()); + } + } + } + parts.removeAll(QString()); + return parts.join(QLatin1Char(' ')); +} + +QList AbstractSettingsPage::getSearchEntries() +{ + return autoDetectSearchEntries(this, -1); +} + +QList AbstractSettingsPage::autoDetectSearchEntries(QWidget *page, int pageIndex) +{ + QList entries; + + const auto children = page->children(); + for (QObject *child : children) { + auto *groupBox = qobject_cast(child); + if (!groupBox) { + continue; + } + + QString groupTitle = groupBox->title(); + if (groupTitle.isEmpty()) { + continue; + } + + QLayout *groupLayout = groupBox->layout(); + if (!groupLayout) { + continue; + } + + QList> widgets; + collectWidgets(groupLayout, widgets); + + for (const auto &pair : widgets) { + QWidget *widget = pair.first; + QString label; + + auto *checkBox = qobject_cast(widget); + if (checkBox) { + label = checkBox->text(); + } else { + auto *labelWidget = qobject_cast(widget); + if (!labelWidget || labelWidget->text().isEmpty() || !isValidSettingLabel(labelWidget)) { + continue; + } + label = labelWidget->text(); + } + + if (label.isEmpty()) { + continue; + } + + // Strip accelerator markers (&) for search + QString cleanLabel = label; + cleanLabel.remove(QLatin1Char('&')); + + QWidget *control = widget; + if (auto *labelWidget = qobject_cast(widget)) { + control = controlForLabel(labelWidget, pair.second); + if (!control) { + continue; + } + } + + entries.append(SettingsSearchEntry{.pageIndex = pageIndex, + .groupTitle = groupTitle, + .widgetLabel = cleanLabel, + .fullSearchText = buildFullSearchText(groupTitle, cleanLabel, control), + .widget = control}); + } + } + + return entries; +} 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 4cbf2d71a..3140f94f3 100644 --- a/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/abstract_settings_page.h @@ -1,16 +1,24 @@ #ifndef COCKATRICE_ABSTRACT_SETTINGS_PAGE_H #define COCKATRICE_ABSTRACT_SETTINGS_PAGE_H +#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" #define WIKI_TRANSLATION_FAQ "https://github.com/Cockatrice/Cockatrice/wiki/Translation-FAQ" +struct SettingsSearchEntry; + class AbstractSettingsPage : public QWidget { + Q_OBJECT public: virtual void retranslateUi() = 0; + virtual QList getSearchEntries(); + +protected: + static QList autoDetectSearchEntries(QWidget *page, int pageIndex); }; #endif // COCKATRICE_ABSTRACT_SETTINGS_PAGE_H 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 e00484ebf..f161f0f19 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp @@ -1,17 +1,29 @@ #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 "../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 "../playmat/playmat_settings_dialog.h" +#include #include #include #include +#include #include +#include #include +#include +#include +#include +#include +#include AppearanceSettingsPage::AppearanceSettingsPage() { @@ -47,6 +59,19 @@ AppearanceSettingsPage::AppearanceSettingsPage() connect(&schemeCombo, &QComboBox::currentIndexChanged, this, [this] { themeManager->setColorScheme(schemeCombo.currentData().toString()); }); + // Qt widget style; "System" lets the application decide + styleCombo.addItem(tr("System"), QStringLiteral("System")); + for (const QString &key : QStyleFactory::keys()) { + styleCombo.addItem(key, key); + } + + const QString currentStyle = cfg.styleName; + const int styleSeedIdx = currentStyle.isEmpty() ? 0 : styleCombo.findData(currentStyle); + styleCombo.setCurrentIndex(styleSeedIdx >= 0 ? styleSeedIdx : 0); + + connect(&styleCombo, &QComboBox::currentIndexChanged, this, + [this] { themeManager->setStyleName(styleCombo.currentData().toString()); }); + connect(themeManager, &ThemeManager::themeChanged, this, [this, dirPath] { const QString newDir = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName()); const ThemeConfig cfg = ThemeConfig::fromThemeDir(newDir); @@ -56,6 +81,12 @@ AppearanceSettingsPage::AppearanceSettingsPage() const int idx = schemeCombo.findData(current); schemeCombo.setCurrentIndex(idx >= 0 ? idx : 0); schemeCombo.blockSignals(false); + + styleCombo.blockSignals(true); + const QString currentStyle = cfg.styleName; + const int styleIdx = currentStyle.isEmpty() ? 0 : styleCombo.findData(currentStyle); + styleCombo.setCurrentIndex(styleIdx >= 0 ? styleIdx : 0); + styleCombo.blockSignals(false); }); connect(&editPaletteButton, &QPushButton::clicked, this, &AppearanceSettingsPage::editPalette); @@ -66,7 +97,9 @@ AppearanceSettingsPage::AppearanceSettingsPage() themeGrid->addWidget(&openThemeButton, 1, 1); themeGrid->addWidget(&schemeComboLabel, 2, 0); themeGrid->addWidget(&schemeCombo, 2, 1); - themeGrid->addWidget(&editPaletteButton, 3, 1); + themeGrid->addWidget(&styleComboLabel, 3, 0); + themeGrid->addWidget(&styleCombo, 3, 1); + themeGrid->addWidget(&editPaletteButton, 4, 1); themeGroupBox = new QGroupBox; themeGroupBox->setLayout(themeGrid); @@ -76,7 +109,7 @@ AppearanceSettingsPage::AppearanceSettingsPage() homeTabBackgroundSourceBox.addItem(QObject::tr(entry.trKey), QVariant::fromValue(entry.type)); } - QString homeTabBackgroundSource = SettingsCache::instance().getHomeTabBackgroundSource(); + QString homeTabBackgroundSource = settings.appearance().getHomeTabBackgroundSource(); int homeTabBackgroundSourceId = homeTabBackgroundSourceBox.findData(BackgroundSources::fromId(homeTabBackgroundSource)); if (homeTabBackgroundSourceId != -1) { @@ -85,19 +118,31 @@ AppearanceSettingsPage::AppearanceSettingsPage() connect(&homeTabBackgroundSourceBox, QOverload::of(&QComboBox::currentIndexChanged), this, [this]() { auto type = homeTabBackgroundSourceBox.currentData().value(); - SettingsCache::instance().setHomeTabBackgroundSource(BackgroundSources::toId(type)); + SettingsCache::instance().appearance().setHomeTabBackgroundSource(BackgroundSources::toId(type)); updateHomeTabSettingsVisibility(); }); homeTabBackgroundShuffleFrequencySpinBox.setRange(0, 3600); homeTabBackgroundShuffleFrequencySpinBox.setSuffix(tr(" seconds")); - homeTabBackgroundShuffleFrequencySpinBox.setValue(SettingsCache::instance().getHomeTabBackgroundShuffleFrequency()); - connect(&homeTabBackgroundShuffleFrequencySpinBox, qOverload(&QSpinBox::valueChanged), - &SettingsCache::instance(), &SettingsCache::setHomeTabBackgroundShuffleFrequency); + homeTabBackgroundShuffleFrequencySpinBox.setValue(settings.appearance().getHomeTabBackgroundShuffleFrequency()); + connect(&homeTabBackgroundShuffleFrequencySpinBox, qOverload(&QSpinBox::valueChanged), &settings.appearance(), + &AppearanceSettings::setHomeTabBackgroundShuffleFrequency); - homeTabDisplayCardNameCheckBox.setChecked(settings.getHomeTabDisplayCardName()); - connect(&homeTabDisplayCardNameCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings, - &SettingsCache::setHomeTabDisplayCardName); + homeTabDisplayCardNameCheckBox.setChecked(settings.appearance().getHomeTabDisplayCardName()); + 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(); @@ -107,12 +152,58 @@ 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); - styleUserListCheckBox.setChecked(settings.getStyleUserList()); - connect(&styleUserListCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings, &SettingsCache::setStyleUserList); + // 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); auto stylingTabGrid = new QGridLayout; stylingTabGrid->addWidget(&styleUserListCheckBox, 0, 0, 1, 2); @@ -121,12 +212,12 @@ AppearanceSettingsPage::AppearanceSettingsPage() stylingGroupBox->setLayout(stylingTabGrid); // Menu settings - showShortcutsCheckBox.setChecked(settings.getShowShortcuts()); + showShortcutsCheckBox.setChecked(settings.userInterface().getShowShortcuts()); connect(&showShortcutsCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &AppearanceSettingsPage::showShortcutsChanged); - showGameSelectorFilterToolbarCheckBox.setChecked(settings.getShowGameSelectorFilterToolbar()); - connect(&showGameSelectorFilterToolbarCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings, - &SettingsCache::setShowGameSelectorFilterToolbar); + showGameSelectorFilterToolbarCheckBox.setChecked(settings.userInterface().getShowGameSelectorFilterToolbar()); + connect(&showGameSelectorFilterToolbarCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.userInterface(), + &InterfaceSettings::setShowGameSelectorFilterToolbar); auto *menuGrid = new QGridLayout; menuGrid->addWidget(&showShortcutsCheckBox, 0, 0); @@ -136,13 +227,14 @@ AppearanceSettingsPage::AppearanceSettingsPage() menuGroupBox->setLayout(menuGrid); // Printings settings - overrideAllCardArtWithPersonalPreferenceCheckBox.setChecked(settings.getOverrideAllCardArtWithPersonalPreference()); + overrideAllCardArtWithPersonalPreferenceCheckBox.setChecked( + settings.cardsDisplay().getOverrideAllCardArtWithPersonalPreference()); connect(&overrideAllCardArtWithPersonalPreferenceCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &AppearanceSettingsPage::overrideAllCardArtWithPersonalPreferenceToggled); - bumpSetsWithCardsInDeckToTopCheckBox.setChecked(settings.getBumpSetsWithCardsInDeckToTop()); - connect(&bumpSetsWithCardsInDeckToTopCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings, - &SettingsCache::setBumpSetsWithCardsInDeckToTop); + bumpSetsWithCardsInDeckToTopCheckBox.setChecked(settings.cardsDisplay().getBumpSetsWithCardsInDeckToTop()); + connect(&bumpSetsWithCardsInDeckToTopCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.cardsDisplay(), + &CardsDisplaySettings::setBumpSetsWithCardsInDeckToTop); auto *printingsGrid = new QGridLayout; printingsGrid->addWidget(&overrideAllCardArtWithPersonalPreferenceCheckBox, 0, 0, 1, 2); @@ -152,22 +244,25 @@ AppearanceSettingsPage::AppearanceSettingsPage() printingsGroupBox->setLayout(printingsGrid); // Card rendering - displayCardNamesCheckBox.setChecked(settings.getDisplayCardNames()); - connect(&displayCardNamesCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings, &SettingsCache::setDisplayCardNames); + displayCardNamesCheckBox.setChecked(settings.cardsDisplay().getDisplayCardNames()); + connect(&displayCardNamesCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.cardsDisplay(), + &CardsDisplaySettings::setDisplayCardNames); - autoRotateSidewaysLayoutCardsCheckBox.setChecked(settings.getAutoRotateSidewaysLayoutCards()); - connect(&autoRotateSidewaysLayoutCardsCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings, - &SettingsCache::setAutoRotateSidewaysLayoutCards); + autoRotateSidewaysLayoutCardsCheckBox.setChecked(settings.cardsDisplay().getAutoRotateSidewaysLayoutCards()); + connect(&autoRotateSidewaysLayoutCardsCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.cardsDisplay(), + &CardsDisplaySettings::setAutoRotateSidewaysLayoutCards); - cardScalingCheckBox.setChecked(settings.getScaleCards()); - connect(&cardScalingCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings, &SettingsCache::setCardScaling); + cardScalingCheckBox.setChecked(settings.cardsDisplay().getScaleCards()); + connect(&cardScalingCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.cardsDisplay(), + &CardsDisplaySettings::setCardScaling); - roundCardCornersCheckBox.setChecked(settings.getRoundCardCorners()); - connect(&roundCardCornersCheckBox, &QAbstractButton::toggled, &settings, &SettingsCache::setRoundCardCorners); + roundCardCornersCheckBox.setChecked(settings.cardsDisplay().getRoundCardCorners()); + connect(&roundCardCornersCheckBox, &QAbstractButton::toggled, &settings.cardsDisplay(), + &CardsDisplaySettings::setRoundCardCorners); - connect(&maxFontSizeForCardsEdit, qOverload(&QSpinBox::valueChanged), &settings, - &SettingsCache::setMaxFontSize); - maxFontSizeForCardsEdit.setValue(settings.getMaxFontSize()); + connect(&maxFontSizeForCardsEdit, qOverload(&QSpinBox::valueChanged), &settings.appearance(), + &AppearanceSettings::setMaxFontSize); + maxFontSizeForCardsEdit.setValue(settings.appearance().getMaxFontSize()); maxFontSizeForCardsLabel.setBuddy(&maxFontSizeForCardsEdit); maxFontSizeForCardsEdit.setMinimum(9); maxFontSizeForCardsEdit.setMaximum(100); @@ -184,18 +279,18 @@ AppearanceSettingsPage::AppearanceSettingsPage() cardsGroupBox->setLayout(cardsGrid); // Card layout - verticalCardOverlapPercentBox.setValue(settings.getStackCardOverlapPercent()); + verticalCardOverlapPercentBox.setValue(settings.cardsDisplay().getStackCardOverlapPercent()); verticalCardOverlapPercentBox.setRange(0, 80); - connect(&verticalCardOverlapPercentBox, qOverload(&QSpinBox::valueChanged), &settings, - &SettingsCache::setStackCardOverlapPercent); + connect(&verticalCardOverlapPercentBox, qOverload(&QSpinBox::valueChanged), &settings.cardsDisplay(), + &CardsDisplaySettings::setStackCardOverlapPercent); cardViewInitialRowsMaxBox.setRange(1, 999); - cardViewInitialRowsMaxBox.setValue(SettingsCache::instance().getCardViewInitialRowsMax()); + cardViewInitialRowsMaxBox.setValue(SettingsCache::instance().userInterface().getCardViewInitialRowsMax()); connect(&cardViewInitialRowsMaxBox, qOverload(&QSpinBox::valueChanged), this, &AppearanceSettingsPage::cardViewInitialRowsMaxChanged); cardViewExpandedRowsMaxBox.setRange(1, 999); - cardViewExpandedRowsMaxBox.setValue(SettingsCache::instance().getCardViewExpandedRowsMax()); + cardViewExpandedRowsMaxBox.setValue(SettingsCache::instance().userInterface().getCardViewExpandedRowsMax()); connect(&cardViewExpandedRowsMaxBox, qOverload(&QSpinBox::valueChanged), this, &AppearanceSettingsPage::cardViewExpandedRowsMaxChanged); @@ -211,7 +306,6 @@ AppearanceSettingsPage::AppearanceSettingsPage() cardLayoutGroupBox->setLayout(cardLayoutGrid); // Card counter colors - auto *cardCounterColorsLayout = new QGridLayout; cardCounterColorsLayout->setColumnStretch(1, 1); cardCounterColorsLayout->setColumnStretch(3, 1); @@ -257,11 +351,13 @@ AppearanceSettingsPage::AppearanceSettingsPage() cardCountersGroupBox->setLayout(cardCountersLayout); // Hand layout - horizontalHandCheckBox.setChecked(settings.getHorizontalHand()); - connect(&horizontalHandCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings, &SettingsCache::setHorizontalHand); + horizontalHandCheckBox.setChecked(settings.userInterface().getHorizontalHand()); + connect(&horizontalHandCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.userInterface(), + &InterfaceSettings::setHorizontalHand); - leftJustifiedHandCheckBox.setChecked(settings.getLeftJustified()); - connect(&leftJustifiedHandCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings, &SettingsCache::setLeftJustified); + leftJustifiedHandCheckBox.setChecked(settings.userInterface().getLeftJustified()); + connect(&leftJustifiedHandCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.userInterface(), + &InterfaceSettings::setLeftJustified); auto *handGrid = new QGridLayout; handGrid->addWidget(&horizontalHandCheckBox, 0, 0, 1, 2); @@ -271,14 +367,14 @@ AppearanceSettingsPage::AppearanceSettingsPage() handGroupBox->setLayout(handGrid); // table grid layout - invertVerticalCoordinateCheckBox.setChecked(settings.getInvertVerticalCoordinate()); - connect(&invertVerticalCoordinateCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings, - &SettingsCache::setInvertVerticalCoordinate); + invertVerticalCoordinateCheckBox.setChecked(settings.userInterface().getInvertVerticalCoordinate()); + connect(&invertVerticalCoordinateCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.userInterface(), + &InterfaceSettings::setInvertVerticalCoordinate); minPlayersForMultiColumnLayoutEdit.setMinimum(2); - minPlayersForMultiColumnLayoutEdit.setValue(settings.getMinPlayersForMultiColumnLayout()); - connect(&minPlayersForMultiColumnLayoutEdit, qOverload(&QSpinBox::valueChanged), &settings, - &SettingsCache::setMinPlayersForMultiColumnLayout); + minPlayersForMultiColumnLayoutEdit.setValue(settings.userInterface().getMinPlayersForMultiColumnLayout()); + connect(&minPlayersForMultiColumnLayoutEdit, qOverload(&QSpinBox::valueChanged), &settings.userInterface(), + &InterfaceSettings::setMinPlayersForMultiColumnLayout); minPlayersForMultiColumnLayoutLabel.setBuddy(&minPlayersForMultiColumnLayoutEdit); auto *tableGrid = new QGridLayout; @@ -293,6 +389,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); @@ -305,7 +402,8 @@ AppearanceSettingsPage::AppearanceSettingsPage() setLayout(mainLayout); - connect(&SettingsCache::instance(), &SettingsCache::langChanged, this, &AppearanceSettingsPage::retranslateUi); + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, + &AppearanceSettingsPage::retranslateUi); retranslateUi(); } @@ -319,7 +417,7 @@ void AppearanceSettingsPage::themeBoxChanged(int index) void AppearanceSettingsPage::openThemeLocation() { - QString dir = SettingsCache::instance().getThemesPath(); + QString dir = SettingsCache::instance().paths().getThemesPath(); QDir dirDir = dir; dirDir.cdUp(); // open if dir exists, create if parent dir does exist @@ -338,8 +436,8 @@ void AppearanceSettingsPage::editPalette() void AppearanceSettingsPage::updateHomeTabSettingsVisibility() { - bool visible = - SettingsCache::instance().getHomeTabBackgroundSource() != BackgroundSources::toId(BackgroundSources::Theme); + QString sourceId = SettingsCache::instance().appearance().getHomeTabBackgroundSource(); + bool visible = BackgroundSources::fromId(sourceId) != BackgroundSources::Theme; homeTabBackgroundShuffleFrequencyLabel.setVisible(visible); homeTabBackgroundShuffleFrequencySpinBox.setVisible(visible); @@ -348,7 +446,7 @@ void AppearanceSettingsPage::updateHomeTabSettingsVisibility() void AppearanceSettingsPage::showShortcutsChanged(QT_STATE_CHANGED_T value) { - SettingsCache::instance().setShowShortcuts(value); + SettingsCache::instance().userInterface().setShowShortcuts(value); qApp->setAttribute(Qt::AA_DontShowShortcutsInContextMenus, value == 0); // 0 = unchecked } @@ -375,7 +473,7 @@ void AppearanceSettingsPage::overrideAllCardArtWithPersonalPreferenceToggled(QT_ */ void AppearanceSettingsPage::cardViewInitialRowsMaxChanged(int value) { - SettingsCache::instance().setCardViewInitialRowsMax(value); + SettingsCache::instance().userInterface().setCardViewInitialRowsMax(value); if (cardViewExpandedRowsMaxBox.value() < value) { cardViewExpandedRowsMaxBox.setValue(value); } @@ -388,18 +486,26 @@ void AppearanceSettingsPage::cardViewInitialRowsMaxChanged(int value) */ void AppearanceSettingsPage::cardViewExpandedRowsMaxChanged(int value) { - SettingsCache::instance().setCardViewExpandedRowsMax(value); + SettingsCache::instance().userInterface().setCardViewExpandedRowsMax(value); if (cardViewInitialRowsMaxBox.value() > value) { cardViewInitialRowsMaxBox.setValue(value); } } +void AppearanceSettingsPage::openPlaymatCollectionDialog() +{ + PlaymatCollectionDialog dialog(this); + dialog.exec(); +} + void AppearanceSettingsPage::retranslateUi() { themeGroupBox->setTitle(tr("Theme settings")); themeLabel.setText(tr("Current theme:")); 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)")); editPaletteButton.setText(tr("Edit theme palette")); homeTabGroupBox->setTitle(tr("Home tab settings")); @@ -407,6 +513,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")); @@ -450,4 +568,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 e223d70f8..bec4cd72f 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h @@ -24,6 +24,7 @@ private slots: void cardViewInitialRowsMaxChanged(int value); void cardViewExpandedRowsMaxChanged(int value); + void openPlaymatCollectionDialog(); private: QLabel themeLabel; @@ -31,45 +32,68 @@ private: QPushButton openThemeButton; QLabel schemeComboLabel; QComboBox schemeCombo; + QLabel styleComboLabel; + QComboBox styleCombo; 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; - QLabel minPlayersForMultiColumnLayoutLabel; - QLabel maxFontSizeForCardsLabel; + QCheckBox showShortcutsCheckBox; QCheckBox showGameSelectorFilterToolbarCheckBox; + 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 b7d699ebc..f3eac05b8 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,6 +1,7 @@ #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 @@ -10,12 +11,16 @@ #include #include #include +#include +#include +#include +#include DeckEditorSettingsPage::DeckEditorSettingsPage() { - picDownloadCheckBox.setChecked(SettingsCache::instance().getPicDownload()); - connect(&picDownloadCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setPicDownload); + picDownloadCheckBox.setChecked(SettingsCache::instance().downloads().getPicDownload()); + connect(&picDownloadCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().downloads(), + &DownloadSettings::setPicDownload); urlLinkLabel.setTextInteractionFlags(Qt::LinksAccessibleByMouse); urlLinkLabel.setOpenExternalLinks(true); @@ -25,7 +30,7 @@ DeckEditorSettingsPage::DeckEditorSettingsPage() auto *lpGeneralGrid = new QGridLayout; auto *lpSpoilerGrid = new QGridLayout; - mcDownloadSpoilersCheckBox.setChecked(SettingsCache::instance().getDownloadSpoilersStatus()); + mcDownloadSpoilersCheckBox.setChecked(SettingsCache::instance().downloads().getDownloadSpoilersStatus()); mpSpoilerSavePathLineEdit = new QLineEdit(SettingsCache::instance().getSpoilerCardDatabasePath()); mpSpoilerSavePathLineEdit->setReadOnly(true); @@ -49,15 +54,15 @@ DeckEditorSettingsPage::DeckEditorSettingsPage() urlList->addItems(SettingsCache::instance().downloads().getAllURLs()); 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); auto *urlToolBar = new QToolBar; @@ -87,8 +92,8 @@ DeckEditorSettingsPage::DeckEditorSettingsPage() lpSpoilerGrid->addWidget(&infoOnSpoilersLabel, 3, 0, 1, 3, Qt::AlignTop); // On a change to the checkbox, hide/un-hide the other fields - connect(&mcDownloadSpoilersCheckBox, &QCheckBox::toggled, &SettingsCache::instance(), - &SettingsCache::setDownloadSpoilerStatus); + connect(&mcDownloadSpoilersCheckBox, &QCheckBox::toggled, &SettingsCache::instance().downloads(), + &DownloadSettings::setDownloadSpoilerStatus); connect(&mcDownloadSpoilersCheckBox, &QCheckBox::toggled, this, &DeckEditorSettingsPage::setSpoilersEnabled); mpGeneralGroupBox = new QGroupBox; @@ -103,7 +108,8 @@ DeckEditorSettingsPage::DeckEditorSettingsPage() setLayout(lpMainLayout); - connect(&SettingsCache::instance(), &SettingsCache::langChanged, this, &DeckEditorSettingsPage::retranslateUi); + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, + &DeckEditorSettingsPage::retranslateUi); retranslateUi(); } @@ -203,7 +209,7 @@ void DeckEditorSettingsPage::spoilerPathButtonClicked() } mpSpoilerSavePathLineEdit->setText(lsPath + "/spoiler.xml"); - SettingsCache::instance().setSpoilerDatabasePath(lsPath + "/spoiler.xml"); + SettingsCache::instance().paths().setSpoilerDatabasePath(lsPath + "/spoiler.xml"); } void DeckEditorSettingsPage::setSpoilersEnabled(bool anInput) 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 9faf67578..36436f8a3 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp @@ -1,14 +1,26 @@ #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 enum startupCardUpdateCheckBehaviorIndex { @@ -40,27 +52,45 @@ 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); + 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(&advertiseTranslationPageLabel, 3, 1, Qt::AlignRight); + + cardLanguageNoteLabel.setWordWrap(true); + cardLanguageNoteLabel.setAlignment(Qt::AlignLeft | Qt::AlignVCenter); languageGroupBox = new QGroupBox; languageGroupBox->setLayout(languageGrid); // version settings SettingsCache &settings = SettingsCache::instance(); - startupUpdateCheckCheckBox.setChecked(settings.getCheckUpdatesOnStartup()); + startupUpdateCheckCheckBox.setChecked(settings.updates().getCheckUpdatesOnStartup()); - connect(&startupUpdateCheckCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings, - &SettingsCache::setCheckUpdatesOnStartup); + connect(&startupUpdateCheckCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.updates(), + &UpdatesSettings::setCheckUpdatesOnStartup); updateNotificationCheckBox.setChecked(settings.getNotifyAboutUpdates()); - connect(&updateNotificationCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings, &SettingsCache::setNotifyAboutUpdate); + connect(&updateNotificationCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.updates(), + &UpdatesSettings::setNotifyAboutUpdates); - connect(&newVersionOracleCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings, - &SettingsCache::setNotifyAboutNewVersion); + connect(&newVersionOracleCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.updates(), + &UpdatesSettings::setNotifyAboutNewVersion); auto *versionGrid = new QGridLayout; versionGrid->addWidget(&updateReleaseChannelLabel, 0, 0); @@ -76,9 +106,9 @@ GeneralSettingsPage::GeneralSettingsPage() startupCardUpdateCheckBehaviorSelector.addItem(""); // these will be set in retranslateUI startupCardUpdateCheckBehaviorSelector.addItem(""); startupCardUpdateCheckBehaviorSelector.addItem(""); - if (SettingsCache::instance().getStartupCardUpdateCheckPromptForUpdate()) { + if (SettingsCache::instance().updates().getStartupCardUpdateCheckPromptForUpdate()) { startupCardUpdateCheckBehaviorSelector.setCurrentIndex(startupCardUpdateCheckBehaviorIndexPrompt); - } else if (SettingsCache::instance().getStartupCardUpdateCheckAlwaysUpdate()) { + } else if (SettingsCache::instance().updates().getStartupCardUpdateCheckAlwaysUpdate()) { startupCardUpdateCheckBehaviorSelector.setCurrentIndex(startupCardUpdateCheckBehaviorIndexAlways); } else { startupCardUpdateCheckBehaviorSelector.setCurrentIndex(startupCardUpdateCheckBehaviorIndexNone); @@ -86,20 +116,20 @@ GeneralSettingsPage::GeneralSettingsPage() connect(&startupCardUpdateCheckBehaviorSelector, QOverload::of(&QComboBox::currentIndexChanged), this, [](int index) { - SettingsCache::instance().setStartupCardUpdateCheckPromptForUpdate( + SettingsCache::instance().updates().setStartupCardUpdateCheckPromptForUpdate( index == startupCardUpdateCheckBehaviorIndexPrompt); - SettingsCache::instance().setStartupCardUpdateCheckAlwaysUpdate( + SettingsCache::instance().updates().setStartupCardUpdateCheckAlwaysUpdate( index == startupCardUpdateCheckBehaviorIndexAlways); }); cardUpdateCheckIntervalSpinBox.setMinimum(1); cardUpdateCheckIntervalSpinBox.setMaximum(30); - cardUpdateCheckIntervalSpinBox.setValue(settings.getCardUpdateCheckInterval()); + cardUpdateCheckIntervalSpinBox.setValue(settings.updates().getCardUpdateCheckInterval()); - connect(&cardUpdateCheckIntervalSpinBox, qOverload(&QSpinBox::valueChanged), &settings, - &SettingsCache::setCardUpdateCheckInterval); + connect(&cardUpdateCheckIntervalSpinBox, qOverload(&QSpinBox::valueChanged), &settings.updates(), + &UpdatesSettings::setCardUpdateCheckInterval); - newVersionOracleCheckBox.setChecked(settings.getNotifyAboutNewVersion()); + newVersionOracleCheckBox.setChecked(settings.updates().getNotifyAboutNewVersion()); auto *cardDatabaseGrid = new QGridLayout; cardDatabaseGrid->addWidget(&startupCardUpdateCheckBehaviorLabel, 0, 0); @@ -112,33 +142,86 @@ GeneralSettingsPage::GeneralSettingsPage() cardDatabaseGroupBox->setLayout(cardDatabaseGrid); // startup settings - showTipsOnStartup.setChecked(settings.getShowTipsOnStartup()); + showTipsOnStartup.setChecked(settings.personal().getShowTipsOnStartup()); - connect(&showTipsOnStartup, &QCheckBox::clicked, &settings, &SettingsCache::setShowTipsOnStartup); + 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); // paths settings - deckPathEdit = new QLineEdit(settings.getDeckPath()); + deckPathEdit = new QLineEdit(settings.paths().getDeckPath()); deckPathEdit->setReadOnly(true); auto *deckPathButton = new QPushButton("..."); connect(deckPathButton, &QPushButton::clicked, this, &GeneralSettingsPage::deckPathButtonClicked); - filtersPathEdit = new QLineEdit(settings.getFiltersPath()); + filtersPathEdit = new QLineEdit(settings.paths().getFiltersPath()); filtersPathEdit->setReadOnly(true); auto *filtersPathButton = new QPushButton("..."); connect(filtersPathButton, &QPushButton::clicked, this, &GeneralSettingsPage::filtersPathButtonClicked); - replaysPathEdit = new QLineEdit(settings.getReplaysPath()); + replaysPathEdit = new QLineEdit(settings.paths().getReplaysPath()); replaysPathEdit->setReadOnly(true); auto *replaysPathButton = new QPushButton("..."); connect(replaysPathButton, &QPushButton::clicked, this, &GeneralSettingsPage::replaysPathButtonClicked); - picsPathEdit = new QLineEdit(settings.getPicsPath()); + picsPathEdit = new QLineEdit(settings.paths().getPicsPath()); picsPathEdit->setReadOnly(true); auto *picsPathButton = new QPushButton("..."); connect(picsPathButton, &QPushButton::clicked, this, &GeneralSettingsPage::picsPathButtonClicked); @@ -224,13 +307,14 @@ GeneralSettingsPage::GeneralSettingsPage() GeneralSettingsPage::retranslateUi(); // connect the ReleaseChannel combo box only after the entries are inserted in retranslateUi - connect(&updateReleaseChannelBox, qOverload(&QComboBox::currentIndexChanged), &settings, - &SettingsCache::setUpdateReleaseChannelIndex); + connect(&updateReleaseChannelBox, qOverload(&QComboBox::currentIndexChanged), &settings.updates(), + &UpdatesSettings::setUpdateReleaseChannelIndex); updateReleaseChannelBox.setCurrentIndex(settings.getUpdateReleaseChannelIndex()); setLayout(mainLayout); - connect(&SettingsCache::instance(), &SettingsCache::langChanged, this, &GeneralSettingsPage::retranslateUi); + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, + &GeneralSettingsPage::retranslateUi); retranslateUi(); } @@ -264,7 +348,7 @@ void GeneralSettingsPage::deckPathButtonClicked() } deckPathEdit->setText(path); - SettingsCache::instance().setDeckPath(path); + SettingsCache::instance().paths().setDeckPath(path); } void GeneralSettingsPage::filtersPathButtonClicked() @@ -275,7 +359,7 @@ void GeneralSettingsPage::filtersPathButtonClicked() } filtersPathEdit->setText(path); - SettingsCache::instance().setFiltersPath(path); + SettingsCache::instance().paths().setFiltersPath(path); } void GeneralSettingsPage::replaysPathButtonClicked() @@ -286,7 +370,7 @@ void GeneralSettingsPage::replaysPathButtonClicked() } replaysPathEdit->setText(path); - SettingsCache::instance().setReplaysPath(path); + SettingsCache::instance().paths().setReplaysPath(path); } void GeneralSettingsPage::picsPathButtonClicked() @@ -297,7 +381,7 @@ void GeneralSettingsPage::picsPathButtonClicked() } picsPathEdit->setText(path); - SettingsCache::instance().setPicsPath(path); + SettingsCache::instance().paths().setPicsPath(path); } void GeneralSettingsPage::cardDatabasePathButtonClicked() @@ -308,7 +392,7 @@ void GeneralSettingsPage::cardDatabasePathButtonClicked() } cardDatabasePathEdit->setText(path); - SettingsCache::instance().setCardDatabasePath(path); + SettingsCache::instance().paths().setCardDatabasePath(path); } void GeneralSettingsPage::customCardDatabaseButtonClicked() @@ -319,7 +403,7 @@ void GeneralSettingsPage::customCardDatabaseButtonClicked() } customCardDatabasePathEdit->setText(path); - SettingsCache::instance().setCustomCardDatabasePath(path); + SettingsCache::instance().paths().setCustomCardDatabasePath(path); } void GeneralSettingsPage::tokenDatabasePathButtonClicked() @@ -330,16 +414,16 @@ void GeneralSettingsPage::tokenDatabasePathButtonClicked() } tokenDatabasePathEdit->setText(path); - SettingsCache::instance().setTokenDatabasePath(path); + SettingsCache::instance().paths().setTokenDatabasePath(path); } void GeneralSettingsPage::resetAllPathsClicked() { SettingsCache &settings = SettingsCache::instance(); settings.resetPaths(); - deckPathEdit->setText(settings.getDeckPath()); - replaysPathEdit->setText(settings.getReplaysPath()); - picsPathEdit->setText(settings.getPicsPath()); + deckPathEdit->setText(settings.paths().getDeckPath()); + replaysPathEdit->setText(settings.paths().getReplaysPath()); + picsPathEdit->setText(settings.paths().getPicsPath()); cardDatabasePathEdit->setText(settings.getCardDatabasePath()); customCardDatabasePathEdit->setText(settings.getCustomCardDatabasePath()); tokenDatabasePathEdit->setText(settings.getTokenDatabasePath()); @@ -348,54 +432,84 @@ void GeneralSettingsPage::resetAllPathsClicked() void GeneralSettingsPage::languageBoxChanged(int index) { - SettingsCache::instance().setLang(languageBox.itemData(index).toString()); + 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::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.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).")); + 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(); @@ -404,4 +518,50 @@ 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")); + + 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..7afba158f 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.h @@ -20,6 +20,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 +37,51 @@ private slots: void tokenDatabasePathButtonClicked(); void resetAllPathsClicked(); void languageBoxChanged(int index); + void cardLanguageBoxChanged(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 updateReleaseChannelLabel; + QComboBox updateReleaseChannelBox; + QCheckBox startupUpdateCheckCheckBox; + QCheckBox updateNotificationCheckBox; + QCheckBox newVersionOracleCheckBox; + + QLabel startupCardUpdateCheckBehaviorLabel; + QComboBox startupCardUpdateCheckBehaviorSelector; + QLabel cardUpdateCheckIntervalLabel; + QSpinBox cardUpdateCheckIntervalSpinBox; + QLabel lastCardUpdateCheckDateLabel; + + 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 +91,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 831cb442c..a3b89f8c4 100644 --- a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.cpp @@ -1,63 +1,73 @@ #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 #include #include +#include +#include +#include #include MessagesSettingsPage::MessagesSettingsPage() { - chatMentionCheckBox.setChecked(SettingsCache::instance().getChatMention()); - connect(&chatMentionCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setChatMention); + chatMentionCheckBox.setChecked(SettingsCache::instance().chat().getChatMention()); + connect(&chatMentionCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().chat(), + &ChatSettings::setChatMention); - chatMentionCompleterCheckbox.setChecked(SettingsCache::instance().getChatMentionCompleter()); - connect(&chatMentionCompleterCheckbox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setChatMentionCompleter); + chatMentionCompleterCheckbox.setChecked(SettingsCache::instance().chat().getChatMentionCompleter()); + connect(&chatMentionCompleterCheckbox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().chat(), + &ChatSettings::setChatMentionCompleter); explainMessagesLabel.setTextInteractionFlags(Qt::LinksAccessibleByMouse); explainMessagesLabel.setOpenExternalLinks(true); - ignoreUnregUsersMainChat.setChecked(SettingsCache::instance().getIgnoreUnregisteredUsers()); - ignoreUnregUserMessages.setChecked(SettingsCache::instance().getIgnoreUnregisteredUserMessages()); - ignoreNonBuddyUserMessages.setChecked(SettingsCache::instance().getIgnoreNonBuddyUserMessages()); + ignoreUnregUsersMainChat.setChecked(SettingsCache::instance().chat().getIgnoreUnregisteredUsers()); + ignoreUnregUserMessages.setChecked(SettingsCache::instance().chat().getIgnoreUnregisteredUserMessages()); + ignoreNonBuddyUserMessages.setChecked(SettingsCache::instance().chat().getIgnoreNonBuddyUserMessages()); - connect(&ignoreUnregUsersMainChat, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setIgnoreUnregisteredUsers); - connect(&ignoreUnregUserMessages, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setIgnoreUnregisteredUserMessages); - connect(&ignoreNonBuddyUserMessages, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setIgnoreNonBuddyUserMessages); + connect(&ignoreUnregUsersMainChat, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().chat(), + &ChatSettings::setIgnoreUnregisteredUsers); + connect(&ignoreUnregUserMessages, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().chat(), + &ChatSettings::setIgnoreUnregisteredUserMessages); + connect(&ignoreNonBuddyUserMessages, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().chat(), + &ChatSettings::setIgnoreNonBuddyUserMessages); - invertMentionForeground.setChecked(SettingsCache::instance().getChatMentionForeground()); + invertMentionForeground.setChecked(SettingsCache::instance().chat().getChatMentionForeground()); connect(&invertMentionForeground, &QCheckBox::QT_STATE_CHANGED, this, &MessagesSettingsPage::updateTextColor); - invertHighlightForeground.setChecked(SettingsCache::instance().getChatHighlightForeground()); + invertHighlightForeground.setChecked(SettingsCache::instance().chat().getChatHighlightForeground()); connect(&invertHighlightForeground, &QCheckBox::QT_STATE_CHANGED, this, &MessagesSettingsPage::updateTextHighlightColor); mentionColor = new QLineEdit(); - mentionColor->setText(SettingsCache::instance().getChatMentionColor()); + mentionColor->setText(SettingsCache::instance().chat().getChatMentionColor()); updateMentionPreview(); connect(mentionColor, &QLineEdit::textChanged, this, &MessagesSettingsPage::updateColor); - messagePopups.setChecked(SettingsCache::instance().getShowMessagePopup()); - connect(&messagePopups, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setShowMessagePopups); + messagePopups.setChecked(SettingsCache::instance().chat().getShowMessagePopup()); + connect(&messagePopups, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().chat(), + &ChatSettings::setShowMessagePopups); - mentionPopups.setChecked(SettingsCache::instance().getShowMentionPopup()); - connect(&mentionPopups, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setShowMentionPopups); + mentionPopups.setChecked(SettingsCache::instance().chat().getShowMentionPopup()); + connect(&mentionPopups, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().chat(), + &ChatSettings::setShowMentionPopups); - roomHistory.setChecked(SettingsCache::instance().getRoomHistory()); - connect(&roomHistory, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), &SettingsCache::setRoomHistory); + roomHistory.setChecked(SettingsCache::instance().chat().getRoomHistory()); + 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); customAlertString = new QLineEdit(); - customAlertString->setText(SettingsCache::instance().getHighlightWords()); - connect(customAlertString, &QLineEdit::textChanged, &SettingsCache::instance(), &SettingsCache::setHighlightWords); + customAlertString->setText(SettingsCache::instance().chat().getHighlightWords()); + connect(customAlertString, &QLineEdit::textChanged, &SettingsCache::instance().chat(), + &ChatSettings::setHighlightWords); auto *chatGrid = new QGridLayout; chatGrid->addWidget(&chatMentionCheckBox, 0, 0); @@ -71,11 +81,12 @@ MessagesSettingsPage::MessagesSettingsPage() chatGrid->addWidget(&messagePopups, 5, 0); chatGrid->addWidget(&mentionPopups, 6, 0); chatGrid->addWidget(&roomHistory, 7, 0); + chatGrid->addWidget(&ignoreAllPrivateMessagesCheckBox, 8, 0); chatGroupBox = new QGroupBox; chatGroupBox->setLayout(chatGrid); highlightColor = new QLineEdit(); - highlightColor->setText(SettingsCache::instance().getChatHighlightColor()); + highlightColor->setText(SettingsCache::instance().chat().getChatHighlightColor()); updateHighlightPreview(); connect(highlightColor, &QLineEdit::textChanged, this, &MessagesSettingsPage::updateHighlightColor); @@ -85,6 +96,7 @@ MessagesSettingsPage::MessagesSettingsPage() highlightNotice->addWidget(&hexHighlightLabel, 1, 2); highlightNotice->addWidget(customAlertString, 0, 0); highlightNotice->addWidget(&customAlertStringLabel, 1, 0); + customAlertStringLabel.setBuddy(customAlertString); highlightGroupBox = new QGroupBox; highlightGroupBox->setLayout(highlightNotice); @@ -96,15 +108,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; @@ -132,7 +144,8 @@ MessagesSettingsPage::MessagesSettingsPage() setLayout(mainLayout); - connect(&SettingsCache::instance(), &SettingsCache::langChanged, this, &MessagesSettingsPage::retranslateUi); + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, + &MessagesSettingsPage::retranslateUi); retranslateUi(); } @@ -145,7 +158,7 @@ void MessagesSettingsPage::updateColor(const QString &value) colorToSet.setNamedColor("#" + value); #endif if (colorToSet.isValid()) { - SettingsCache::instance().setChatMentionColor(value); + SettingsCache::instance().chat().setChatMentionColor(value); updateMentionPreview(); } } @@ -159,35 +172,35 @@ void MessagesSettingsPage::updateHighlightColor(const QString &value) colorToSet.setNamedColor("#" + value); #endif if (colorToSet.isValid()) { - SettingsCache::instance().setChatHighlightColor(value); + SettingsCache::instance().chat().setChatHighlightColor(value); updateHighlightPreview(); } } void MessagesSettingsPage::updateTextColor(QT_STATE_CHANGED_T value) { - SettingsCache::instance().setChatMentionForeground(value); + SettingsCache::instance().chat().setChatMentionForeground(value); updateMentionPreview(); } void MessagesSettingsPage::updateTextHighlightColor(QT_STATE_CHANGED_T value) { - SettingsCache::instance().setChatHighlightForeground(value); + SettingsCache::instance().chat().setChatHighlightForeground(value); updateHighlightPreview(); } void MessagesSettingsPage::updateMentionPreview() { mentionColor->setStyleSheet( - "QLineEdit{background:#" + SettingsCache::instance().getChatMentionColor() + - ";color: " + (SettingsCache::instance().getChatMentionForeground() ? "white" : "black") + ";}"); + "QLineEdit{background:#" + SettingsCache::instance().chat().getChatMentionColor() + + ";color: " + (SettingsCache::instance().chat().getChatMentionForeground() ? "white" : "black") + ";}"); } void MessagesSettingsPage::updateHighlightPreview() { highlightColor->setStyleSheet( - "QLineEdit{background:#" + SettingsCache::instance().getChatHighlightColor() + - ";color: " + (SettingsCache::instance().getChatHighlightForeground() ? "white" : "black") + ";}"); + "QLineEdit{background:#" + SettingsCache::instance().chat().getChatHighlightColor() + + ";color: " + (SettingsCache::instance().chat().getChatHighlightForeground() ? "white" : "black") + ";}"); } void MessagesSettingsPage::storeSettings() @@ -249,6 +262,7 @@ 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")); 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..436ebbad9 100644 --- a/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h +++ b/cockatrice/src/interface/widgets/settings_page/messages_settings_page.h @@ -40,6 +40,7 @@ private: QCheckBox messagePopups; QCheckBox mentionPopups; QCheckBox roomHistory; + QCheckBox ignoreAllPrivateMessagesCheckBox; 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 new file mode 100644 index 000000000..2fdff3bce --- /dev/null +++ b/cockatrice/src/interface/widgets/settings_page/settings_search_delegate.cpp @@ -0,0 +1,125 @@ +/** + * @file settings_search_delegate.cpp + * @brief Implementation of the custom settings search result delegate + * @ingroup Dialogs + */ +#include "settings_search_delegate.h" + +#include "settings_search_model.h" + +#include + +SettingsSearchDelegate::SettingsSearchDelegate(QObject *parent) : QStyledItemDelegate(parent) +{ +} + +void SettingsSearchDelegate::setPageNames(const QStringList &names) +{ + pageNames = names; +} + +void SettingsSearchDelegate::setPageIcons(const QStringList &iconResources) +{ + pageIcons.clear(); + for (const QString &resource : iconResources) { + pageIcons.append(QPixmap(resource)); + } +} + +void SettingsSearchDelegate::paint(QPainter *painter, + const QStyleOptionViewItem &option, + const QModelIndex &index) const +{ + painter->save(); + + SettingsSearchEntry entry = index.data(SettingsSearchModel::EntryRole).value(); + + bool isSelected = option.state & QStyle::State_Selected; + bool isHovered = option.state & QStyle::State_MouseOver; + + // Background + QColor bgColor = isSelected ? option.palette.color(QPalette::Highlight) + : isHovered ? option.palette.color(QPalette::Midlight) + : option.palette.color(QPalette::Base); + painter->fillRect(option.rect, bgColor); + + if (isSelected) { + // Accent bar on the left to make the selection unmistakable + painter->fillRect(QRect(option.rect.left(), option.rect.top(), 4, option.rect.height()), + option.palette.color(QPalette::Highlight).darker(150)); + } + + int leftMargin = 12; + int topMargin = 8; + int rightMargin = 12; + int bottomMargin = 4; + + QRect contentRect = option.rect.adjusted(leftMargin, topMargin, -rightMargin, -bottomMargin); + int yPos = contentRect.top(); + + // Icon of the related settings page + const int iconSize = 24; + QPixmap pageIcon = + (entry.pageIndex >= 0 && entry.pageIndex < pageIcons.size()) ? pageIcons.at(entry.pageIndex) : QPixmap(); + int iconOffset = pageIcon.isNull() ? 0 : iconSize + 8; + if (!pageIcon.isNull()) { + QRect iconRect(contentRect.left(), contentRect.top() + (contentRect.height() - iconSize) / 2, iconSize, + iconSize); + painter->drawPixmap(iconRect, pageIcon); + } + + QRect textRect = contentRect.adjusted(iconOffset, 0, 0, 0); + + // Breadcrumb: "Page > Group" + QFont breadcrumbFont = option.font; + breadcrumbFont.setPointSize(breadcrumbFont.pointSize() - 1); + breadcrumbFont.setBold(true); + + QColor breadcrumbColor = + isSelected ? option.palette.color(QPalette::HighlightedText) : option.palette.color(QPalette::Text); + if (!isSelected) { + breadcrumbColor.setAlpha(180); + } + + QString pageName; + if (entry.pageIndex >= 0 && entry.pageIndex < pageNames.size()) { + pageName = pageNames[entry.pageIndex]; + } else { + pageName = QString::number(entry.pageIndex); + } + + QString breadcrumbText = QStringLiteral("%1 > %2").arg(pageName, entry.groupTitle); + painter->setFont(breadcrumbFont); + painter->setPen(breadcrumbColor); + painter->drawText(QRect(textRect.left(), yPos, textRect.width(), 20), Qt::AlignLeft | Qt::AlignVCenter, + breadcrumbText); + yPos += 20; + + // Setting label + QFont labelFont = option.font; + labelFont.setPointSize(labelFont.pointSize() + 1); + labelFont.setBold(isSelected); + + QColor labelColor = + isSelected ? option.palette.color(QPalette::HighlightedText) : option.palette.color(QPalette::Text); + + painter->setFont(labelFont); + painter->setPen(labelColor); + painter->drawText(QRect(textRect.left(), yPos, textRect.width(), 24), Qt::AlignLeft | Qt::AlignVCenter, + entry.widgetLabel); + yPos += 24; + + // Bottom separator + QPen separatorPen(option.palette.color(QPalette::Mid), 1); + painter->setPen(separatorPen); + painter->drawLine(option.rect.left() + leftMargin, option.rect.bottom(), option.rect.right() - rightMargin, + option.rect.bottom()); + + painter->restore(); +} + +QSize SettingsSearchDelegate::sizeHint(const QStyleOptionViewItem &option, const QModelIndex &index) const +{ + Q_UNUSED(index); + return QSize(option.rect.width(), 56); +} diff --git a/cockatrice/src/interface/widgets/settings_page/settings_search_delegate.h b/cockatrice/src/interface/widgets/settings_page/settings_search_delegate.h new file mode 100644 index 000000000..aa296049a --- /dev/null +++ b/cockatrice/src/interface/widgets/settings_page/settings_search_delegate.h @@ -0,0 +1,38 @@ +/** + * @file settings_search_delegate.h + * @brief Custom delegate for rendering settings search results + * @ingroup Dialogs + */ +#ifndef COCKATRICE_SETTINGS_SEARCH_DELEGATE_H +#define COCKATRICE_SETTINGS_SEARCH_DELEGATE_H + +#include +#include + +/** + * @brief Custom paint delegate for settings search result items + * + * Renders each search result with a breadcrumb line ("Page > Group"), + * the setting label, and a subtle separator. Supports selected/hovered states. + */ +class SettingsSearchDelegate : public QStyledItemDelegate +{ + Q_OBJECT +public: + explicit SettingsSearchDelegate(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; + + /** @brief Sets the translated page names for breadcrumb display */ + void setPageNames(const QStringList &names); + + /** @brief Sets the icons shown in front of results, indexed by page position */ + void setPageIcons(const QStringList &iconResources); + +private: + QStringList pageNames; ///< Translated page names indexed by page position + QList pageIcons; ///< Icons of the related settings pages +}; + +#endif // COCKATRICE_SETTINGS_SEARCH_DELEGATE_H diff --git a/cockatrice/src/interface/widgets/settings_page/settings_search_model.cpp b/cockatrice/src/interface/widgets/settings_page/settings_search_model.cpp new file mode 100644 index 000000000..71f859d4b --- /dev/null +++ b/cockatrice/src/interface/widgets/settings_page/settings_search_model.cpp @@ -0,0 +1,145 @@ +/** + * @file settings_search_model.cpp + * @brief Implementation of the settings search list model + * @ingroup Dialogs + */ +#include "settings_search_model.h" + +#include +#include + +SettingsSearchModel::SettingsSearchModel(QObject *parent) : QAbstractListModel(parent) +{ +} + +void SettingsSearchModel::setSourceEntries(const QList &entries) +{ + beginResetModel(); + sourceEntries = entries; + endResetModel(); + rebuildFilter(); +} + +void SettingsSearchModel::setFilterString(const QString &text) +{ + filterActive = !text.trimmed().isEmpty(); + if (filterActive) { + filterQuery = text.trimmed(); + filterRegex = + QRegularExpression(QRegularExpression::escape(filterQuery), QRegularExpression::CaseInsensitiveOption); + } + rebuildFilter(); +} + +bool SettingsSearchModel::isFilterActive() const +{ + return filterActive; +} + +/** + * @brief Calculates a relevance score for a single entry against the query + * + * Scoring priorities (highest to lowest): + * 1. Label starts with query -> 100 + * 2. Label contains query -> 80 + * 3. Group title starts with query -> 60 + * 4. Group title contains query -> 40 + * 5. Full text regex match -> 20 + * 6. No match -> 0 (excluded from results) + */ +static int relevanceScore(const SettingsSearchEntry &entry, const QString &query, const QRegularExpression ®ex) +{ + QString lowerQuery = query.toLower(); + + // Label matches are most relevant + QString label = entry.widgetLabel.toLower(); + if (label.startsWith(lowerQuery)) { + return 100; + } + if (label.contains(lowerQuery)) { + return 80; + } + + // Group title matches are next + QString group = entry.groupTitle.toLower(); + if (group.startsWith(lowerQuery)) { + return 60; + } + if (group.contains(lowerQuery)) { + return 40; + } + + // Full text match is least relevant + if (entry.fullSearchText.contains(regex)) { + return 20; + } + + return 0; +} + +void SettingsSearchModel::rebuildFilter() +{ + beginResetModel(); + filteredIndices.clear(); + + if (!filterActive) { + for (int i = 0; i < sourceEntries.size(); ++i) { + filteredIndices.append(i); + } + } else { + QList> scored; // + for (int i = 0; i < sourceEntries.size(); ++i) { + const SettingsSearchEntry &entry = sourceEntries[i]; + // Skip conditional settings that are currently disabled or hidden + if (entry.widget && (!entry.widget->isEnabled() || entry.widget->isHidden())) { + continue; + } + int score = relevanceScore(entry, filterQuery, filterRegex); + if (score > 0) { + scored.append({-score, i}); // negative for descending sort + } + } + std::sort(scored.begin(), scored.end()); + for (const auto &pair : scored) { + filteredIndices.append(pair.second); + } + } + endResetModel(); +} + +int SettingsSearchModel::rowCount(const QModelIndex &parent) const +{ + if (parent.isValid()) { + return 0; + } + return filteredIndices.size(); +} + +QVariant SettingsSearchModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() >= filteredIndices.size()) { + return {}; + } + + const SettingsSearchEntry &entry = sourceEntries[filteredIndices[index.row()]]; + + switch (role) { + case EntryRole: + return QVariant::fromValue(entry); + case Qt::DisplayRole: + return entry.widgetLabel; + case Qt::ToolTipRole: + return QStringLiteral("%1 > %2 > %3") + .arg(QString::number(entry.pageIndex), entry.groupTitle, entry.widgetLabel); + default: + return {}; + } +} + +SettingsSearchEntry SettingsSearchModel::entryForIndex(const QModelIndex &index) const +{ + if (!index.isValid() || index.row() >= filteredIndices.size()) { + return {}; + } + return sourceEntries[filteredIndices[index.row()]]; +} diff --git a/cockatrice/src/interface/widgets/settings_page/settings_search_model.h b/cockatrice/src/interface/widgets/settings_page/settings_search_model.h new file mode 100644 index 000000000..0b590f180 --- /dev/null +++ b/cockatrice/src/interface/widgets/settings_page/settings_search_model.h @@ -0,0 +1,76 @@ +/** + * @file settings_search_model.h + * @brief Data model for the settings search feature + * @ingroup Dialogs + */ +#ifndef COCKATRICE_SETTINGS_SEARCH_MODEL_H +#define COCKATRICE_SETTINGS_SEARCH_MODEL_H + +#include +#include +#include +#include + +/** + * @brief Represents a single searchable setting entry + * + * Each settings page provides a list of these entries via getSearchEntries(). + * The model uses them for filtering, relevance scoring, and display. + */ +struct SettingsSearchEntry +{ + int pageIndex; ///< Index of the settings page this entry belongs to + QString groupTitle; ///< Title of the group/section within the page + QString widgetLabel; ///< Display label for the setting widget + QString fullSearchText; ///< Extended search text (label, control text, tooltip) for full-text matching + QWidget *widget; ///< Pointer to the setting widget for focus/scrolling +}; + +/** + * @brief List model providing filtered, ranked search results + * + * Manages a list of SettingsSearchEntry items. When a filter string is set, + * entries are scored by relevance and sorted so the best matches appear first. + * Supports custom roles for accessing entry fields from views and delegates. + */ +class SettingsSearchModel : public QAbstractListModel +{ + Q_OBJECT +public: + /** + * @brief Custom data role for accessing the full entry + */ + enum Roles + { + EntryRole = Qt::UserRole + 1, ///< Full SettingsSearchEntry object + }; + + explicit SettingsSearchModel(QObject *parent = nullptr); + + /** @brief Replaces the source entries and rebuilds the filter */ + void setSourceEntries(const QList &entries); + /** @brief Sets the filter string and recalculates the results */ + void setFilterString(const QString &text); + /** @brief Whether a non-empty filter is currently active */ + bool isFilterActive() const; + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + /** @brief Returns the full entry for a given model index */ + SettingsSearchEntry entryForIndex(const QModelIndex &index) const; + +private: + QList sourceEntries; ///< Complete unfiltered entry list + QList filteredIndices; ///< Indices into sourceEntries matching the filter + QRegularExpression filterRegex; ///< Compiled regex for the current filter + QString filterQuery; ///< Current filter query string + bool filterActive = false; ///< Whether filtering is active + + /** @brief Recalculates the filtered index list and ranking */ + void rebuildFilter(); +}; + +Q_DECLARE_METATYPE(SettingsSearchEntry) + +#endif // COCKATRICE_SETTINGS_SEARCH_MODEL_H 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 e7a04ef79..1277d0e49 100644 --- a/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/shortcut_settings_page.cpp @@ -2,11 +2,14 @@ #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 ShortcutSettingsPage::ShortcutSettingsPage() { @@ -45,8 +48,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; @@ -78,7 +81,8 @@ ShortcutSettingsPage::ShortcutSettingsPage() connect(shortcutsTable, &ShortcutTreeView::currentItemChanged, this, &ShortcutSettingsPage::currentItemChanged); - connect(&SettingsCache::instance(), &SettingsCache::langChanged, this, &ShortcutSettingsPage::retranslateUi); + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, + &ShortcutSettingsPage::retranslateUi); retranslateUi(); } @@ -120,6 +124,7 @@ void ShortcutSettingsPage::retranslateUi() currentActionGroupLabel->setText(tr("Section:")); currentActionLabel->setText(tr("Action:")); currentShortcutLabel->setText(tr("Shortcut:")); + editShortcutGroupBox->setTitle(tr("Shortcut editor")); editTextBox->retranslateUi(); faqLabel->setText(QString("%2").arg(WIKI_CUSTOM_SHORTCUTS).arg(tr("How to set custom shortcuts"))); btnResetAll->setText(tr("Restore all default shortcuts")); 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 e7e92ea15..2bd776671 100644 --- a/cockatrice/src/interface/widgets/settings_page/sound_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/sound_settings_page.cpp @@ -4,14 +4,17 @@ #include "../client/sound_engine.h" #include +#include +#include +#include SoundSettingsPage::SoundSettingsPage() { - soundEnabledCheckBox.setChecked(SettingsCache::instance().getSoundEnabled()); - connect(&soundEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setSoundEnabled); + soundEnabledCheckBox.setChecked(SettingsCache::instance().sound().getSoundEnabled()); + connect(&soundEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().sound(), + &SoundSettings::setSoundEnabled); - QString themeName = SettingsCache::instance().getSoundThemeName(); + QString themeName = SettingsCache::instance().sound().getSoundThemeName(); QStringList themeDirs = soundEngine->getAvailableThemes().keys(); for (int i = 0; i < themeDirs.size(); i++) { @@ -27,17 +30,18 @@ SoundSettingsPage::SoundSettingsPage() masterVolumeSlider = new QSlider(Qt::Horizontal); masterVolumeSlider->setMinimum(0); masterVolumeSlider->setMaximum(100); - masterVolumeSlider->setValue(SettingsCache::instance().getMasterVolume()); - masterVolumeSlider->setToolTip(QString::number(SettingsCache::instance().getMasterVolume())); - connect(&SettingsCache::instance(), &SettingsCache::masterVolumeChanged, this, + masterVolumeSlider->setValue(SettingsCache::instance().sound().getMasterVolume()); + masterVolumeSlider->setToolTip(QString::number(SettingsCache::instance().sound().getMasterVolume())); + connect(&SettingsCache::instance().sound(), &SoundSettings::masterVolumeChanged, this, &SoundSettingsPage::masterVolumeChanged); connect(masterVolumeSlider, &QSlider::sliderReleased, soundEngine, &SoundEngine::testSound); - connect(masterVolumeSlider, &QSlider::valueChanged, &SettingsCache::instance(), &SettingsCache::setMasterVolume); + connect(masterVolumeSlider, &QSlider::valueChanged, &SettingsCache::instance().sound(), + &SoundSettings::setMasterVolume); masterVolumeSpinBox = new QSpinBox(); masterVolumeSpinBox->setMinimum(0); masterVolumeSpinBox->setMaximum(100); - masterVolumeSpinBox->setValue(SettingsCache::instance().getMasterVolume()); + masterVolumeSpinBox->setValue(SettingsCache::instance().sound().getMasterVolume()); connect(masterVolumeSlider, &QSlider::valueChanged, masterVolumeSpinBox, &QSpinBox::setValue); connect(masterVolumeSpinBox, qOverload(&QSpinBox::valueChanged), masterVolumeSlider, &QSlider::setValue); @@ -59,7 +63,8 @@ SoundSettingsPage::SoundSettingsPage() setLayout(mainLayout); - connect(&SettingsCache::instance(), &SettingsCache::langChanged, this, &SoundSettingsPage::retranslateUi); + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, + &SoundSettingsPage::retranslateUi); retranslateUi(); } @@ -67,7 +72,7 @@ void SoundSettingsPage::themeBoxChanged(int index) { QStringList themeDirs = soundEngine->getAvailableThemes().keys(); if (index >= 0 && index < themeDirs.count()) { - SettingsCache::instance().setSoundThemeName(themeDirs.at(index)); + SettingsCache::instance().sound().setSoundThemeName(themeDirs.at(index)); } } 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 c9d3c7789..17838e501 100644 --- a/cockatrice/src/interface/widgets/settings_page/storage_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/storage_settings_page.cpp @@ -1,11 +1,16 @@ #include "storage_settings_page.h" #include "../../../client/settings/cache_settings.h" +#include "../../card_picture_loader/card_picture_loader_cache_method.h" +#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 StorageSettingsPage::StorageSettingsPage() { @@ -27,7 +32,7 @@ StorageSettingsPage::StorageSettingsPage() // 2047 is the max value to avoid overflowing of QPixmapCache::setCacheLimit(int size) pixmapCacheEdit.setMaximum(PIXMAPCACHE_SIZE_MAX); pixmapCacheEdit.setSingleStep(64); - pixmapCacheEdit.setValue(SettingsCache::instance().getPixmapCacheSize()); + pixmapCacheEdit.setValue(SettingsCache::instance().cacheStorage().getPixmapCacheSize()); pixmapCacheEdit.setSuffix(" MB"); // Caching method @@ -37,7 +42,8 @@ StorageSettingsPage::StorageSettingsPage() cardPictureLoaderCacheMethodComboBox->addItem(method.displayName, static_cast(method.id)); } - int currentCacheMethod = static_cast(SettingsCache::instance().getCardPictureLoaderCacheMethod()); + int currentCacheMethod = + static_cast(SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()); int currentIndex = cardPictureLoaderCacheMethodComboBox->findData(currentCacheMethod); if (currentIndex >= 0) { @@ -60,7 +66,7 @@ StorageSettingsPage::StorageSettingsPage() mpNetworkCacheGroupBox->setEnabled(useNetworkCache); mpImageBackupGroupBox->setEnabled(!useNetworkCache); - SettingsCache::instance().setCardImageCacheMethod(cacheMethod); + SettingsCache::instance().cacheStorage().setCardImageCacheMethod(static_cast(cacheMethod)); }); // Network Cache @@ -68,13 +74,13 @@ StorageSettingsPage::StorageSettingsPage() networkCacheEdit.setMinimum(NETWORK_CACHE_SIZE_MIN); networkCacheEdit.setMaximum(NETWORK_CACHE_SIZE_MAX); networkCacheEdit.setSingleStep(1); - networkCacheEdit.setValue(SettingsCache::instance().getNetworkCacheSizeInMB()); + networkCacheEdit.setValue(SettingsCache::instance().cacheStorage().getNetworkCacheSizeInMB()); networkCacheEdit.setSuffix(" MB"); networkRedirectCacheTtlEdit.setMinimum(NETWORK_REDIRECT_CACHE_TTL_MIN); networkRedirectCacheTtlEdit.setMaximum(NETWORK_REDIRECT_CACHE_TTL_MAX); networkRedirectCacheTtlEdit.setSingleStep(1); - networkRedirectCacheTtlEdit.setValue(SettingsCache::instance().getRedirectCacheTtl()); + networkRedirectCacheTtlEdit.setValue(SettingsCache::instance().cacheStorage().getRedirectCacheTtl()); // Image Backup localCardImageStorageNamingSchemeComboBox = new QComboBox; @@ -82,7 +88,7 @@ StorageSettingsPage::StorageSettingsPage() localCardImageStorageNamingSchemeComboBox->addItem(scheme.displayName, static_cast(scheme.id)); } - int current = static_cast(SettingsCache::instance().getLocalCardImageStorageNamingScheme()); + int current = static_cast(SettingsCache::instance().cacheStorage().getLocalCardImageStorageNamingScheme()); int index = localCardImageStorageNamingSchemeComboBox->findData(current); if (index >= 0) { @@ -93,7 +99,7 @@ StorageSettingsPage::StorageSettingsPage() [this](int index) { auto scheme = static_cast( localCardImageStorageNamingSchemeComboBox->itemData(index).toInt()); - SettingsCache::instance().setLocalCardImageStorageNamingScheme(scheme); + SettingsCache::instance().cacheStorage().setLocalCardImageStorageNamingScheme(static_cast(scheme)); }); connect(&clearBackupsButton, &QPushButton::clicked, this, &StorageSettingsPage::clearImageBackupsButtonClicked); @@ -132,12 +138,12 @@ StorageSettingsPage::StorageSettingsPage() lpPixmapCacheGrid->addWidget(&pixmapCacheExplainerLabel, 0, 0); lpPixmapCacheGrid->addLayout(pixmapCacheLayout, 1, 0); - connect(&pixmapCacheEdit, qOverload(&QSpinBox::valueChanged), &SettingsCache::instance(), - &SettingsCache::setPixmapCacheSize); - connect(&networkCacheEdit, qOverload(&QSpinBox::valueChanged), &SettingsCache::instance(), - &SettingsCache::setNetworkCacheSizeInMB); - connect(&networkRedirectCacheTtlEdit, qOverload(&QSpinBox::valueChanged), &SettingsCache::instance(), - &SettingsCache::setNetworkRedirectCacheTtl); + connect(&pixmapCacheEdit, qOverload(&QSpinBox::valueChanged), &SettingsCache::instance().cacheStorage(), + &CacheStorageSettings::setPixmapCacheSize); + connect(&networkCacheEdit, qOverload(&QSpinBox::valueChanged), &SettingsCache::instance().cacheStorage(), + &CacheStorageSettings::setNetworkCacheSizeInMB); + connect(&networkRedirectCacheTtlEdit, qOverload(&QSpinBox::valueChanged), + &SettingsCache::instance().cacheStorage(), &CacheStorageSettings::setNetworkRedirectCacheTtl); mpCacheMethodGroupBox = new QGroupBox; mpCacheMethodGroupBox->setLayout(cacheMethodLayout); @@ -161,13 +167,15 @@ StorageSettingsPage::StorageSettingsPage() setLayout(lpMainLayout); - bool useNetworkCache = SettingsCache::instance().getCardPictureLoaderCacheMethod() == + bool useNetworkCache = static_cast( + SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) == CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE; mpNetworkCacheGroupBox->setEnabled(useNetworkCache); mpImageBackupGroupBox->setEnabled(!useNetworkCache); - connect(&SettingsCache::instance(), &SettingsCache::langChanged, this, &StorageSettingsPage::retranslateUi); + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, + &StorageSettingsPage::retranslateUi); retranslateUi(); } @@ -180,7 +188,7 @@ void StorageSettingsPage::clearDownloadedPicsButtonClicked() void StorageSettingsPage::clearImageBackupsButtonClicked() { - QString picsPath = SettingsCache::instance().getPicsPath() + "/downloadedPics"; + QString picsPath = SettingsCache::instance().paths().getPicsPath() + "/downloadedPics"; QDir dir(picsPath); bool success = dir.removeRecursively(); 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 44b30d29c..2c6e062da 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 @@ -2,8 +2,14 @@ #include "../../../client/settings/cache_settings.h" #include "../interface/widgets/tabs/tab_supervisor.h" +#include "../tabs/api/commander_spellbook/commander_spellbook_bracket_explainer.h" #include +#include +#include +#include +#include +#include enum visualDeckStoragePromptForConversionIndex { @@ -14,71 +20,53 @@ enum visualDeckStoragePromptForConversionIndex UserInterfaceSettingsPage::UserInterfaceSettingsPage() { - // general settings and notification settings - notificationsEnabledCheckBox.setChecked(SettingsCache::instance().getNotificationsEnabled()); - connect(¬ificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setNotificationsEnabled); - connect(¬ificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, this, - &UserInterfaceSettingsPage::setNotificationEnabled); + // general settings + doubleClickToPlayCheckBox.setChecked(SettingsCache::instance().userInterface().getDoubleClickToPlay()); + connect(&doubleClickToPlayCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setDoubleClickToPlay); - specNotificationsEnabledCheckBox.setChecked(SettingsCache::instance().getSpectatorNotificationsEnabled()); - specNotificationsEnabledCheckBox.setEnabled(SettingsCache::instance().getNotificationsEnabled()); - connect(&specNotificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setSpectatorNotificationsEnabled); + clickPlaysAllSelectedCheckBox.setChecked(SettingsCache::instance().userInterface().getClickPlaysAllSelected()); + connect(&clickPlaysAllSelectedCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setClickPlaysAllSelected); - buddyConnectNotificationsEnabledCheckBox.setChecked( - SettingsCache::instance().getBuddyConnectNotificationsEnabled()); - buddyConnectNotificationsEnabledCheckBox.setEnabled(SettingsCache::instance().getNotificationsEnabled()); - connect(&buddyConnectNotificationsEnabledCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setBuddyConnectNotificationsEnabled); + playToStackCheckBox.setChecked(SettingsCache::instance().userInterface().getPlayToStack()); + connect(&playToStackCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setPlayToStack); - doubleClickToPlayCheckBox.setChecked(SettingsCache::instance().getDoubleClickToPlay()); - connect(&doubleClickToPlayCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setDoubleClickToPlay); + doNotDeleteArrowsInSubPhasesCheckBox.setChecked( + SettingsCache::instance().userInterface().getDoNotDeleteArrowsInSubPhases()); + connect(&doNotDeleteArrowsInSubPhasesCheckBox, &QCheckBox::QT_STATE_CHANGED, + &SettingsCache::instance().userInterface(), &InterfaceSettings::setDoNotDeleteArrowsInSubPhases); - clickPlaysAllSelectedCheckBox.setChecked(SettingsCache::instance().getClickPlaysAllSelected()); - connect(&clickPlaysAllSelectedCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setClickPlaysAllSelected); + closeEmptyCardViewCheckBox.setChecked(SettingsCache::instance().userInterface().getCloseEmptyCardView()); + connect(&closeEmptyCardViewCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setCloseEmptyCardView); - playToStackCheckBox.setChecked(SettingsCache::instance().getPlayToStack()); - connect(&playToStackCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setPlayToStack); + focusCardViewSearchBarCheckBox.setChecked(SettingsCache::instance().userInterface().getFocusCardViewSearchBar()); + connect(&focusCardViewSearchBarCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setFocusCardViewSearchBar); - doNotDeleteArrowsInSubPhasesCheckBox.setChecked(SettingsCache::instance().getDoNotDeleteArrowsInSubPhases()); - connect(&doNotDeleteArrowsInSubPhasesCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setDoNotDeleteArrowsInSubPhases); + annotateTokensCheckBox.setChecked(SettingsCache::instance().userInterface().getAnnotateTokens()); + connect(&annotateTokensCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setAnnotateTokens); - closeEmptyCardViewCheckBox.setChecked(SettingsCache::instance().getCloseEmptyCardView()); - connect(&closeEmptyCardViewCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setCloseEmptyCardView); + showDragSelectionCountCheckBox.setChecked(SettingsCache::instance().userInterface().getShowDragSelectionCount()); + connect(&showDragSelectionCountCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setShowDragSelectionCount); - focusCardViewSearchBarCheckBox.setChecked(SettingsCache::instance().getFocusCardViewSearchBar()); - connect(&focusCardViewSearchBarCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setFocusCardViewSearchBar); + showTotalSelectionCountCheckBox.setChecked(SettingsCache::instance().userInterface().getShowTotalSelectionCount()); + connect(&showTotalSelectionCountCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setShowTotalSelectionCount); - annotateTokensCheckBox.setChecked(SettingsCache::instance().getAnnotateTokens()); - connect(&annotateTokensCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setAnnotateTokens); + useTearOffMenusCheckBox.setChecked(SettingsCache::instance().userInterface().getUseTearOffMenus()); + connect(&useTearOffMenusCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + [](const QT_STATE_CHANGED_T state) { + SettingsCache::instance().userInterface().setUseTearOffMenus(state == Qt::Checked); + }); - showDragSelectionCountCheckBox.setChecked(SettingsCache::instance().getShowDragSelectionCount()); - connect(&showDragSelectionCountCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setShowDragSelectionCount); - - showTotalSelectionCountCheckBox.setChecked(SettingsCache::instance().getShowTotalSelectionCount()); - connect(&showTotalSelectionCountCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setShowTotalSelectionCount); - - showSubtypeSelectionTallyCheckBox.setChecked(SettingsCache::instance().getShowSubtypeSelectionTally()); - connect(&showSubtypeSelectionTallyCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setShowSubtypeSelectionTally); - - useTearOffMenusCheckBox.setChecked(SettingsCache::instance().getUseTearOffMenus()); - connect(&useTearOffMenusCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - [](const QT_STATE_CHANGED_T state) { SettingsCache::instance().setUseTearOffMenus(state == Qt::Checked); }); - - keepGameChatFocusCheckBox.setChecked(SettingsCache::instance().getKeepGameChatFocus()); - connect(&keepGameChatFocusCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setKeepGameChatFocus); + keepGameChatFocusCheckBox.setChecked(SettingsCache::instance().userInterface().getKeepGameChatFocus()); + connect(&keepGameChatFocusCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setKeepGameChatFocus); auto *generalGrid = new QGridLayout; generalGrid->addWidget(&doubleClickToPlayCheckBox, 0, 0); @@ -90,13 +78,32 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() generalGrid->addWidget(&annotateTokensCheckBox, 6, 0); generalGrid->addWidget(&showDragSelectionCountCheckBox, 7, 0); generalGrid->addWidget(&showTotalSelectionCountCheckBox, 8, 0); - generalGrid->addWidget(&showSubtypeSelectionTallyCheckBox, 9, 0); - generalGrid->addWidget(&useTearOffMenusCheckBox, 10, 0); - generalGrid->addWidget(&keepGameChatFocusCheckBox, 11, 0); + generalGrid->addWidget(&useTearOffMenusCheckBox, 9, 0); + generalGrid->addWidget(&keepGameChatFocusCheckBox, 10, 0); 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); @@ -106,53 +113,134 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() notificationsGroupBox->setLayout(notificationsGrid); // animation settings - tapAnimationCheckBox.setChecked(SettingsCache::instance().getTapAnimation()); - connect(&tapAnimationCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setTapAnimation); + tapAnimationCheckBox.setChecked(SettingsCache::instance().cardsDisplay().getTapAnimation()); + 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); // deck editor settings - openDeckInNewTabCheckBox.setChecked(SettingsCache::instance().getOpenDeckInNewTab()); - connect(&openDeckInNewTabCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setOpenDeckInNewTab); + openDeckInNewTabCheckBox.setChecked(SettingsCache::instance().deckEditor().getOpenDeckInNewTab()); + connect(&openDeckInNewTabCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().deckEditor(), + &DeckEditorSettings::setOpenDeckInNewTab); - visualDeckStorageInGameCheckBox.setChecked(SettingsCache::instance().getVisualDeckStorageInGame()); - connect(&visualDeckStorageInGameCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setVisualDeckStorageInGame); + visualDeckStorageInGameCheckBox.setChecked( + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageInGame()); + connect(&visualDeckStorageInGameCheckBox, &QCheckBox::QT_STATE_CHANGED, + &SettingsCache::instance().visualDeckStorage(), &VisualDeckStorageSettings::setVisualDeckStorageInGame); visualDeckStorageSelectionAnimationCheckBox.setChecked( - SettingsCache::instance().getVisualDeckStorageSelectionAnimation()); - connect(&visualDeckStorageSelectionAnimationCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setVisualDeckStorageSelectionAnimation); + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageSelectionAnimation()); + connect(&visualDeckStorageSelectionAnimationCheckBox, &QCheckBox::QT_STATE_CHANGED, + &SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::setVisualDeckStorageSelectionAnimation); visualDeckStoragePromptForConversionSelector.addItem(""); // these will be set in retranslateUI visualDeckStoragePromptForConversionSelector.addItem(""); visualDeckStoragePromptForConversionSelector.addItem(""); - if (SettingsCache::instance().getVisualDeckStoragePromptForConversion()) { + if (SettingsCache::instance().visualDeckStorage().getVisualDeckStoragePromptForConversion()) { visualDeckStoragePromptForConversionSelector.setCurrentIndex(visualDeckStoragePromptForConversionIndexPrompt); - } else if (SettingsCache::instance().getVisualDeckStorageAlwaysConvert()) { + } else if (SettingsCache::instance().visualDeckStorage().getVisualDeckStorageAlwaysConvert()) { visualDeckStoragePromptForConversionSelector.setCurrentIndex(visualDeckStoragePromptForConversionIndexAlways); } else { visualDeckStoragePromptForConversionSelector.setCurrentIndex(visualDeckStoragePromptForConversionIndexNone); } connect(&visualDeckStoragePromptForConversionSelector, QOverload::of(&QComboBox::currentIndexChanged), this, [](int index) { - SettingsCache::instance().setVisualDeckStoragePromptForConversion( + SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion( index == visualDeckStoragePromptForConversionIndexPrompt); - SettingsCache::instance().setVisualDeckStorageAlwaysConvert( + SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert( index == visualDeckStoragePromptForConversionIndexAlways); }); defaultDeckEditorTypeSelector.addItem(""); // these will be set in retranslateUI defaultDeckEditorTypeSelector.addItem(""); - defaultDeckEditorTypeSelector.setCurrentIndex(SettingsCache::instance().getDefaultDeckEditorType()); + defaultDeckEditorTypeSelector.setCurrentIndex(SettingsCache::instance().deckEditor().getDefaultDeckEditorType()); connect(&defaultDeckEditorTypeSelector, QOverload::of(&QComboBox::currentIndexChanged), - &SettingsCache::instance(), &SettingsCache::setDefaultDeckEditorType); + &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); + + // Add items with userData = internal enum + commanderSpellbookIntegrationEnabledSelector.addItem(tr("Disabled"), + commanderSpellbookIntegrationEnabledIndexDisabled); + commanderSpellbookIntegrationEnabledSelector.addItem(tr("Enabled"), + commanderSpellbookIntegrationEnabledIndexEnabled); + commanderSpellbookIntegrationEnabledSelector.addItem(tr("Automatic"), + commanderSpellbookIntegrationEnabledIndexAutomatic); + + int storedMode = SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationEnabled(); + for (int i = 0; i < commanderSpellbookIntegrationEnabledSelector.count(); ++i) { + if (commanderSpellbookIntegrationEnabledSelector.itemData(i).toInt() == storedMode) { + commanderSpellbookIntegrationEnabledSelector.setCurrentIndex(i); + break; + } + } + + connect(&commanderSpellbookIntegrationEnabledSelector, QOverload::of(&QComboBox::currentIndexChanged), this, + [this](int index) { + int mode = commanderSpellbookIntegrationEnabledSelector.itemData(index).toInt(); + SettingsCache::instance().deckEditor().setCommanderSpellbookIntegrationEnabled(mode); + updateCommanderSpellbookUiState(); + }); + + commanderSpellbookIntegrationBracketNamingSelector.addItem( + tr("CommanderSpellbook bracket names")); // index 0 = false + commanderSpellbookIntegrationBracketNamingSelector.addItem( + tr("Official Commander bracket names (approximate)")); // index 1 = true + + commanderSpellbookIntegrationBracketNamingSelector.setCurrentIndex( + SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationUseOfficialBracketNames() ? 1 : 0); + + connect(&commanderSpellbookIntegrationBracketNamingSelector, QOverload::of(&QComboBox::currentIndexChanged), + &SettingsCache::instance(), [](int index) { + SettingsCache::instance().deckEditor().setCommanderSpellbookIntegrationUseOfficialBracketNames(index == + 1); + }); + + updateCommanderSpellbookUiState(); + + auto *labelLayout = new QHBoxLayout; + labelLayout->setContentsMargins(0, 0, 0, 0); + labelLayout->addWidget(&commanderSpellbookIntegrationUseOfficialBracketNamesLabel); + labelLayout->addWidget(&commanderSpellbookIntegrationUseOfficialBracketNamesExplainer); + + auto *labelWidget = new QWidget; + labelWidget->setLayout(labelLayout); auto *deckEditorGrid = new QGridLayout; deckEditorGrid->addWidget(&openDeckInNewTabCheckBox, 0, 0); @@ -162,15 +250,21 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() deckEditorGrid->addWidget(&visualDeckStoragePromptForConversionSelector, 3, 1); deckEditorGrid->addWidget(&defaultDeckEditorTypeLabel, 4, 0); deckEditorGrid->addWidget(&defaultDeckEditorTypeSelector, 4, 1); + deckEditorGrid->addWidget(&vdeStartupTabLabel, 5, 0); + deckEditorGrid->addWidget(&vdeStartupTabSelector, 5, 1); + deckEditorGrid->addWidget(&commanderSpellbookIntegrationEnabledLabel, 6, 0); + deckEditorGrid->addWidget(&commanderSpellbookIntegrationEnabledSelector, 6, 1); + deckEditorGrid->addWidget(labelWidget, 7, 0); + deckEditorGrid->addWidget(&commanderSpellbookIntegrationBracketNamingSelector, 7, 1); deckEditorGroupBox = new QGroupBox; deckEditorGroupBox->setLayout(deckEditorGrid); // replay settings rewindBufferingMsBox.setRange(0, 9999); - rewindBufferingMsBox.setValue(SettingsCache::instance().getRewindBufferingMs()); - connect(&rewindBufferingMsBox, qOverload(&QSpinBox::valueChanged), &SettingsCache::instance(), - &SettingsCache::setRewindBufferingMs); + rewindBufferingMsBox.setValue(SettingsCache::instance().userInterface().getRewindBufferingMs()); + connect(&rewindBufferingMsBox, qOverload(&QSpinBox::valueChanged), &SettingsCache::instance().userInterface(), + &InterfaceSettings::setRewindBufferingMs); auto *replayGrid = new QGridLayout; replayGrid->addWidget(&rewindBufferingMsLabel, 0, 0, 1, 1); @@ -190,7 +284,8 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() setLayout(mainLayout); - connect(&SettingsCache::instance(), &SettingsCache::langChanged, this, &UserInterfaceSettingsPage::retranslateUi); + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, + &UserInterfaceSettingsPage::retranslateUi); retranslateUi(); } @@ -204,6 +299,43 @@ 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(); + + const bool enabled = mode != commanderSpellbookIntegrationEnabledIndexDisabled && + mode != commanderSpellbookIntegrationEnabledIndexUnprompted; + + commanderSpellbookIntegrationBracketNamingSelector.setEnabled(enabled); + commanderSpellbookIntegrationUseOfficialBracketNamesExplainer.setEnabled(enabled); + commanderSpellbookIntegrationUseOfficialBracketNamesLabel.setVisible(enabled); + commanderSpellbookIntegrationUseOfficialBracketNamesExplainer.setVisible(enabled); + commanderSpellbookIntegrationBracketNamingSelector.setVisible(enabled); + + if (enabled) { + // Sync selector with the current stored bool + const bool useOfficial = + SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationUseOfficialBracketNames(); + commanderSpellbookIntegrationBracketNamingSelector.setCurrentIndex(useOfficial ? 1 : 0); + } +} + void UserInterfaceSettingsPage::retranslateUi() { generalGroupBox->setTitle(tr("General interface settings")); @@ -216,7 +348,6 @@ void UserInterfaceSettingsPage::retranslateUi() annotateTokensCheckBox.setText(tr("Annotate card text on tokens")); showDragSelectionCountCheckBox.setText(tr("Show selection count during drag selection")); showTotalSelectionCountCheckBox.setText(tr("Show total selection count")); - showSubtypeSelectionTallyCheckBox.setText(tr("Show subtype breakdown in selection tally")); useTearOffMenusCheckBox.setText(tr("Use tear-off menus, allowing right click menus to persist on screen")); keepGameChatFocusCheckBox.setText( tr("Keep game chat focused when clicking in game (Note: disables card view search bar)")); @@ -225,8 +356,15 @@ 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")); visualDeckStorageInGameCheckBox.setText(tr("Use visual deck storage in game lobby")); @@ -234,15 +372,37 @@ void UserInterfaceSettingsPage::retranslateUi() visualDeckStoragePromptForConversionLabel.setText( tr("When adding a tag in the visual deck storage to a .txt deck:")); visualDeckStoragePromptForConversionSelector.setItemText(visualDeckStoragePromptForConversionIndexNone, - tr("do nothing")); + tr("Do nothing")); visualDeckStoragePromptForConversionSelector.setItemText(visualDeckStoragePromptForConversionIndexPrompt, - tr("ask to convert to .cod")); + tr("Ask to convert to .cod")); visualDeckStoragePromptForConversionSelector.setItemText(visualDeckStoragePromptForConversionIndexAlways, - tr("always convert to .cod")); + tr("Always convert to .cod")); 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")); + commanderSpellbookIntegrationEnabledSelector.setItemText(commanderSpellbookIntegrationEnabledIndexDisabled, + tr("Disabled")); + commanderSpellbookIntegrationEnabledSelector.setItemText(commanderSpellbookIntegrationEnabledIndexEnabled, + tr("Enabled")); + commanderSpellbookIntegrationEnabledSelector.setItemText(commanderSpellbookIntegrationEnabledIndexAutomatic, + tr("Automatic")); + commanderSpellbookIntegrationUseOfficialBracketNamesLabel.setText(tr("Bracket naming")); + commanderSpellbookIntegrationBracketNamingSelector.setItemText( + 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"); -} \ No newline at end of file +} 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 06f0e6b83..e8a30fb1f 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 @@ -7,7 +7,10 @@ #include #include #include +#include #include +#include +#include #include class UserInterfaceSettingsPage : public AbstractSettingsPage @@ -15,11 +18,11 @@ 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; @@ -29,10 +32,20 @@ private: QCheckBox annotateTokensCheckBox; QCheckBox showDragSelectionCountCheckBox; QCheckBox showTotalSelectionCountCheckBox; - QCheckBox showSubtypeSelectionTallyCheckBox; 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; @@ -40,8 +53,17 @@ private: QCheckBox visualDeckStorageSelectionAnimationCheckBox; 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 4bb8ffca4..f80649eba 100644 --- a/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp +++ b/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.cpp @@ -10,6 +10,7 @@ #include "abstract_tab_deck_editor.h" #include "../../../client/settings/cache_settings.h" +#include "../../../client/settings/shortcuts_settings.h" #include "../client/network/interfaces/deck_stats_interface.h" #include "../client/network/interfaces/tapped_out_interface.h" #include "../deck_editor/deck_state_manager.h" @@ -43,6 +44,9 @@ #include #include #include +#include +#include +#include #include /** @@ -199,7 +203,7 @@ void AbstractTabDeckEditor::cleanDeckAndResetModified() */ AbstractTabDeckEditor::DeckOpenLocation AbstractTabDeckEditor::confirmOpen(const bool openInSameTabIfBlank) { - if (SettingsCache::instance().getOpenDeckInNewTab()) { + if (SettingsCache::instance().deckEditor().getOpenDeckInNewTab()) { if (openInSameTabIfBlank && deckStateManager->isBlankNewDeck()) { return SAME_TAB; } else { @@ -350,7 +354,7 @@ bool AbstractTabDeckEditor::actSaveDeckAs() DeckList deckList = deckStateManager->getDeckList(); QFileDialog dialog(this, tr("Save deck")); - dialog.setDirectory(SettingsCache::instance().getDeckPath()); + dialog.setDirectory(SettingsCache::instance().paths().getDeckPath()); dialog.setAcceptMode(QFileDialog::AcceptSave); dialog.setDefaultSuffix("cod"); dialog.setNameFilters(DeckLoader::FILE_NAME_FILTERS); 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..a3cda2bfc 100644 --- a/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.h +++ b/cockatrice/src/interface/widgets/tabs/abstract_tab_deck_editor.h @@ -8,6 +8,7 @@ #ifndef TAB_GENERIC_DECK_EDITOR_H #define TAB_GENERIC_DECK_EDITOR_H +#include "../../deck_loader/deck_loader.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" @@ -263,12 +264,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/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..657ef3dbe 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,5 +1,6 @@ #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" @@ -10,6 +11,7 @@ #include #include +#include ArchidektApiResponseDeckDisplayWidget::ArchidektApiResponseDeckDisplayWidget(QWidget *parent, ArchidektApiResponseDeck _response, @@ -120,6 +122,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_entry_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/display/archidekt_api_response_deck_entry_display_widget.cpp index 70156df79..c35c2d0b8 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 @@ -137,11 +137,7 @@ void ArchidektApiResponseDeckEntryDisplayWidget::mousePressEvent(QMouseEvent *ev actRequestNavigationToDeck(); } -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) void ArchidektApiResponseDeckEntryDisplayWidget::enterEvent(QEnterEvent *event) -#else -void ArchidektApiResponseDeckEntryDisplayWidget::enterEvent(QEvent *event) -#endif { QWidget::enterEvent(event); backgroundPlateWidget->setFocused(true); 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 365a99c9c..575a81b5f 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 @@ -98,11 +98,7 @@ public slots: protected: void mousePressEvent(QMouseEvent *event) override; -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) - void enterEvent(QEnterEvent *event) override; ///< Qt6 hover enter -#else - void enterEvent(QEvent *event) override; ///< Qt5 hover enter -#endif + void enterEvent(QEnterEvent *event) override; ///< Hover enter void leaveEvent(QEvent *event) override; private: 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 6b30fe00c..888197f53 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp @@ -1,13 +1,14 @@ #include "tab_archidekt.h" #include "../../../../../client/settings/cache_settings.h" +#include "../../../../pixel_map_generator.h" #include "../../../cards/additional_info/mana_symbol_widget.h" +#include "../../../utility/completer_utils.h" #include "../../tab_supervisor.h" #include "api_response/archidekt_deck_listing_api_response.h" #include "display/archidekt_api_response_deck_display_widget.h" #include "display/archidekt_api_response_deck_listings_display_widget.h" -#include #include #include #include @@ -19,7 +20,6 @@ #include #include #include -#include #include #include #include @@ -27,6 +27,7 @@ #include #include #include +#include #include TabArchidekt::TabArchidekt(TabSupervisor *_tabSupervisor) @@ -131,7 +132,8 @@ void TabArchidekt::initializeUi() // Settings settingsButton = new SettingsButtonWidget(primaryToolbar); - cardSizeSlider = new CardSizeWidget(primaryToolbar, nullptr, SettingsCache::instance().getArchidektPreviewSize()); + cardSizeSlider = + new CardSizeWidget(primaryToolbar, nullptr, SettingsCache::instance().cardsDisplay().getArchidektPreviewSize()); settingsButton->addSettingsWidget(cardSizeSlider); // Assemble primary toolbar @@ -212,7 +214,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); @@ -277,41 +279,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); @@ -337,8 +312,8 @@ void TabArchidekt::connectSignals() doSearch(); }); - connect(cardSizeSlider, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance(), - &SettingsCache::setArchidektPreviewCardSize); + connect(cardSizeSlider, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setArchidektPreviewCardSize); // Search button triggers immediate search connect(searchButton, &QPushButton::clicked, this, &TabArchidekt::doSearchImmediate); 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 new file mode 100644 index 000000000..3c95a4553 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/card_in_deck_request.cpp @@ -0,0 +1,17 @@ +#include "card_in_deck_request.h" + +CardInDeckRequest CardInDeckRequest::fromJson(const QJsonObject &json) +{ + CardInDeckRequest request; + request.card = json.value("card").toString(); + request.quantity = json.value("quantity").toInt(); + return request; +} + +QJsonObject CardInDeckRequest::toJson() const +{ + QJsonObject json; + json.insert("card", card); + json.insert("quantity", quantity); + return json; +} 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 new file mode 100644 index 000000000..b7eb96211 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/card_in_deck_request.h @@ -0,0 +1,14 @@ +#ifndef COCKATRICE_CARD_IN_DECK_REQUEST_H +#define COCKATRICE_CARD_IN_DECK_REQUEST_H +#include + +struct CardInDeckRequest +{ + static CardInDeckRequest fromJson(const QJsonObject &json); + QJsonObject toJson() const; + + QString card; + int quantity; +}; + +#endif // COCKATRICE_CARD_IN_DECK_REQUEST_H 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 new file mode 100644 index 000000000..14558d234 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_card_result.cpp @@ -0,0 +1,26 @@ +#include "commander_spellbook_card_result.h" + +CommanderSpellbookCardResult CommanderSpellbookCardResult::fromJson(const QJsonObject &json) +{ + CommanderSpellbookCardResult result; + + result.id = json.value("id").toString(); + result.name = json.value("name").toString(); + result.oracleId = json.value("oracleId").toString(); + result.spoiler = json.value("spoiler").toBool(); + result.typeLine = json.value("typeLine").toString(); + + result.imageUriFrontPng = json.value("imageUriFrontPng").toString(); + result.imageUriFrontLarge = json.value("imageUriFrontLarge").toString(); + result.imageUriFrontNormal = json.value("imageUriFrontNormal").toString(); + result.imageUriFrontSmall = json.value("imageUriFrontSmall").toString(); + result.imageUriFrontArtCrop = json.value("imageUriFrontArtCrop").toString(); + + result.imageUriBackPng = json.value("imageUriBackPng").toString(); + result.imageUriBackLarge = json.value("imageUriBackLarge").toString(); + result.imageUriBackNormal = json.value("imageUriBackNormal").toString(); + result.imageUriBackSmall = json.value("imageUriBackSmall").toString(); + result.imageUriBackArtCrop = json.value("imageUriBackArtCrop").toString(); + + return result; +} diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_card_result.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_card_result.h new file mode 100644 index 000000000..eaa474097 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_card_result.h @@ -0,0 +1,29 @@ +#ifndef COCKATRICE_COMMANDER_SPELLBOOK_CARD_RESULT_H +#define COCKATRICE_COMMANDER_SPELLBOOK_CARD_RESULT_H +#include +#include + +struct CommanderSpellbookCardResult +{ + static CommanderSpellbookCardResult fromJson(const QJsonObject &json); + + QString id; + QString name; + QString oracleId; + bool spoiler = false; + QString typeLine; + + QString imageUriFrontPng; + QString imageUriFrontLarge; + QString imageUriFrontNormal; + QString imageUriFrontSmall; + QString imageUriFrontArtCrop; + + QString imageUriBackPng; + QString imageUriBackLarge; + QString imageUriBackNormal; + QString imageUriBackSmall; + QString imageUriBackArtCrop; +}; + +#endif // COCKATRICE_COMMANDER_SPELLBOOK_CARD_RESULT_H 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 new file mode 100644 index 000000000..166469353 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_deck_request.cpp @@ -0,0 +1,95 @@ +#include "commander_spellbook_deck_request.h" + +#include +#include + +CommanderSpellbookDeckRequest CommanderSpellbookDeckRequest::fromJson(const QJsonObject &json) +{ + CommanderSpellbookDeckRequest request; + + // Main deck + const QJsonArray mainArray = json.value("main").toArray(); + for (const QJsonValue &value : mainArray) { + if (!value.isObject()) { + continue; + } + + request.mainDeck.append(CardInDeckRequest::fromJson(value.toObject())); + + // Max size allowed by commanderspellbook + if (request.mainDeck.size() >= 600) { + break; + } + } + + // Commanders + const QJsonArray commanderArray = json.value("commanders").toArray(); + for (const QJsonValue &value : commanderArray) { + if (!value.isObject()) { + continue; + } + + request.commanderDeck.append(CardInDeckRequest::fromJson(value.toObject())); + + // Max size allowed by commanderspellbook + if (request.commanderDeck.size() >= 12) { + break; + } + } + + return request; +} + +QJsonObject CommanderSpellbookDeckRequest::toJson() const +{ + QJsonObject json; + + QJsonArray mainArray; + for (const CardInDeckRequest &card : mainDeck) { + mainArray.append(card.toJson()); + } + + QJsonArray commanderArray; + for (const CardInDeckRequest &card : commanderDeck) { + commanderArray.append(card.toJson()); + } + + json.insert("main", mainArray); + json.insert("commanders", commanderArray); + + return json; +} + +CommanderSpellbookDeckRequest CommanderSpellbookDeckRequest::fromDeckList(const DeckList &deck) +{ + CommanderSpellbookDeckRequest request; + + // --- Mainboard --- + const auto mainCards = deck.getCardNodes({DECK_ZONE_MAIN}); + for (const DecklistCardNode *node : mainCards) { + if (!node) { + continue; + } + + QJsonObject json; + json.insert("card", node->getName()); + json.insert("quantity", node->getNumber()); + request.mainDeck.append(CardInDeckRequest::fromJson(json)); + + // Max size allowed by commanderspellbook + if (request.mainDeck.size() >= 600) { + break; + } + } + + // --- Commander (bannerCard) --- + const auto &metadata = deck.getMetadata(); + if (!metadata.bannerCard.name.isEmpty()) { + QJsonObject json; + json.insert("card", metadata.bannerCard.name); + json.insert("quantity", 1); + request.commanderDeck.append(CardInDeckRequest::fromJson(json)); + } + + return request; +} 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 new file mode 100644 index 000000000..46d8e053b --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_deck_request.h @@ -0,0 +1,19 @@ +#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 + +struct CommanderSpellbookDeckRequest +{ + static CommanderSpellbookDeckRequest fromJson(const QJsonObject &json); + static CommanderSpellbookDeckRequest fromDeckList(const DeckList &deck); + QJsonObject toJson() const; + + QList mainDeck; // maxItems: 600 + QList commanderDeck; // maxItems: 12 +}; + +#endif // COCKATRICE_COMMANDER_SPELLBOOK_DECK_REQUEST_H 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 new file mode 100644 index 000000000..c3640ce79 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_estimate_bracket_result.cpp @@ -0,0 +1,93 @@ +#include "commander_spellbook_estimate_bracket_result.h" + +EstimateBracketResult EstimateBracketResult::fromJson(const QJsonObject &json) +{ + EstimateBracketResult result; + + result.bracketTag = json.value("bracketTag").toString(); + + // + // Cards + // + for (const auto &value : json.value("cards").toArray()) { + if (!value.isObject()) { + continue; + } + + const QJsonObject obj = value.toObject(); + + CommanderSpellbookCardResult card = CommanderSpellbookCardResult::fromJson(obj.value("card").toObject()); + + if (obj.value("gameChanger").toBool()) { + result.gameChangerCards.append(card); + } + + if (obj.value("massLandDenial").toBool()) { + result.massLandDenialCards.append(card); + } + + if (obj.value("extraTurn").toBool()) { + result.extraTurnCards.append(card); + } + } + + // + // Templates + // + for (const auto &value : json.value("templates").toArray()) { + if (!value.isObject()) { + continue; + } + + const QJsonObject obj = value.toObject(); + + CommanderSpellbookVariantResult variant = CommanderSpellbookVariantResult::fromJson(obj); + + if (obj.value("massLandDenial").toBool()) { + result.massLandDenialTemplates.append(variant); + } + + if (obj.value("extraTurn").toBool()) { + result.extraTurnTemplates.append(variant); + } + } + + // + // Combos + // + for (const auto &value : json.value("combos").toArray()) { + if (!value.isObject()) { + continue; + } + + const QJsonObject obj = value.toObject(); + + CommanderSpellbookVariantResult combo = CommanderSpellbookVariantResult::fromJson(obj); + + if (obj.value("massLandDenial").toBool()) { + result.massLandDenialCombos.append(combo); + } + + if (obj.value("extraTurn").toBool()) { + result.extraTurnCombos.append(combo); + } + + if (obj.value("lock").toBool()) { + result.lockCombos.append(combo); + } + + if (obj.value("skipTurns").toBool()) { + result.skipTurnsCombos.append(combo); + } + + if (obj.value("definitelyTwoCard").toBool()) { + result.definitelyTwoCardCombos.append(combo); + } + + if (obj.value("arguablyTwoCard").toBool()) { + result.arguablyTwoCardCombos.append(combo); + } + } + + return 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 new file mode 100644 index 000000000..bb589a901 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_estimate_bracket_result.h @@ -0,0 +1,32 @@ +#ifndef COCKATRICE_COMMANDER_SPELLBOOK_ESTIMATE_BRACKET_RESULT_H +#define COCKATRICE_COMMANDER_SPELLBOOK_ESTIMATE_BRACKET_RESULT_H + +#include "commander_spellbook_card_result.h" +#include "commander_spellbook_variant_result.h" + +#include +#include + +struct EstimateBracketResult +{ + static EstimateBracketResult fromJson(const QJsonObject &json); + + QString bracketTag; + + QList gameChangerCards; + QList massLandDenialCards; + QList extraTurnCards; + + QList massLandDenialTemplates; + QList extraTurnTemplates; + + QList massLandDenialCombos; + QList extraTurnCombos; + QList lockCombos; + QList skipTurnsCombos; + + QList definitelyTwoCardCombos; + QList arguablyTwoCardCombos; +}; + +#endif // COCKATRICE_COMMANDER_SPELLBOOK_ESTIMATE_BRACKET_RESULT_H 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 new file mode 100644 index 000000000..bbc23e15e --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_variant_result.cpp @@ -0,0 +1,35 @@ +#include "commander_spellbook_variant_result.h" + +CommanderSpellbookVariantResult CommanderSpellbookVariantResult::fromJson(const QJsonObject &json) +{ + CommanderSpellbookVariantResult result; + + result.id = json.value("id").toString(); + result.status = json.value("status").toString(); + + result.uses = json.value("uses").toArray(); + result.cardRequires = json.value("requires").toArray(); + result.produces = json.value("produces").toArray(); + result.of = json.value("of").toArray(); + result.includes = json.value("includes").toArray(); + + result.manaNeeded = json.value("manaNeeded").toArray(); + result.manaValueNeeded = json.value("manaValueNeeded").toArray(); + + result.easyPrerequisites = json.value("easyPrerequisites").toArray(); + result.notablePrerequisites = json.value("notablePrerequisites").toArray(); + + result.description = json.value("description").toString(); + result.notes = json.value("notes").toString(); + result.popularity = json.value("popularity").toDouble(); + + result.spoiler = json.value("spoiler").toBool(); + result.bracketTag = json.value("bracketTag").toString(); + + result.legalities = json.value("legalities").toObject(); + result.prices = json.value("prices").toObject(); + + result.variantCount = json.value("variantCount").toInt(); + + return 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 new file mode 100644 index 000000000..d908f1299 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/api_response/commander_spellbook_variant_result.h @@ -0,0 +1,39 @@ +#ifndef COCKATRICE_COMMANDER_SPELLBOOK_VARIANT_RESULT_H +#define COCKATRICE_COMMANDER_SPELLBOOK_VARIANT_RESULT_H + +#include +#include + +struct CommanderSpellbookVariantResult +{ + static CommanderSpellbookVariantResult fromJson(const QJsonObject &json); + + QString id; + QString status; + + QJsonArray uses; + QJsonArray cardRequires; + QJsonArray produces; + QJsonArray of; + QJsonArray includes; + + QJsonArray manaNeeded; + QJsonArray manaValueNeeded; + + QJsonArray easyPrerequisites; + QJsonArray notablePrerequisites; + + QString description; + QString notes; + double popularity = 0.0; + + bool spoiler = false; + QString bracketTag; + + QJsonObject legalities; + QJsonObject prices; + + int variantCount = 0; +}; + +#endif // COCKATRICE_COMMANDER_SPELLBOOK_VARIANT_RESULT_H 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 new file mode 100644 index 000000000..b85129a3f --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.cpp @@ -0,0 +1,53 @@ +#include "commander_bracket_service.h" + +#include "../../../../../client/settings/cache_settings.h" + +#include + +CommanderBracketService &CommanderBracketService::instance() +{ + static CommanderBracketService service; + return service; +} + +CommanderBracketService::CommanderBracketService(QObject *parent) : QObject(parent) +{ + connect(&CommanderSpellbookApiAccessor::instance(), &CommanderSpellbookApiAccessor::estimateBracketFinished, this, + &CommanderBracketService::onEstimateBracketFinished); + + connect(&CommanderSpellbookApiAccessor::instance(), &CommanderSpellbookApiAccessor::estimateBracketError, this, + &CommanderBracketService::onEstimateBracketError); +} + +quint64 CommanderBracketService::estimateBracket(const DeckList &deck, QObject *requester) +{ + return CommanderSpellbookApiAccessor::instance().estimateBracket(deck, requester); +} + +void CommanderBracketService::onEstimateBracketFinished(CommanderSpellbookApiAccessor::RequestId id, + QObject *requester, + const EstimateBracketResult &result) +{ + CommanderBracketEstimate estimate; + + estimate.bracketTag = result.bracketTag; + + estimate.rawResult = result; + + auto &brackets = SettingsCache::instance().commanderBrackets(); + + estimate.officialName = brackets.officialName(result.bracketTag); + + estimate.displayName = brackets.displayName(result.bracketTag); + + estimate.explanation = brackets.explanation(result.bracketTag); + + emit estimateFinished(id, requester, estimate); +} + +void CommanderBracketService::onEstimateBracketError(CommanderSpellbookApiAccessor::RequestId id, + QObject *requester, + const QString &error) +{ + emit estimateError(id, requester, error); +} 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 new file mode 100644 index 000000000..e0c2a9d55 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.h @@ -0,0 +1,45 @@ +#ifndef COCKATRICE_COMMANDER_BRACKET_SERVICE_H +#define COCKATRICE_COMMANDER_BRACKET_SERVICE_H + +#include "commander_spellbook_api_accessor.h" +#include "libcockatrice/deck_list/deck_list.h" + +#include + +struct CommanderBracketEstimate +{ + QString bracketTag; + + QString officialName; + QString displayName; + QString explanation; + + EstimateBracketResult rawResult; +}; + +class CommanderBracketService : public QObject +{ + Q_OBJECT + +public: + static CommanderBracketService &instance(); + + quint64 estimateBracket(const DeckList &deck, QObject *requester); + +signals: + void estimateFinished(quint64 requestId, QObject *requester, const CommanderBracketEstimate &estimate); + + void estimateError(quint64 requestId, QObject *requester, const QString &error); + +private slots: + void onEstimateBracketFinished(CommanderSpellbookApiAccessor::RequestId id, + QObject *requester, + const EstimateBracketResult &result); + + void onEstimateBracketError(CommanderSpellbookApiAccessor::RequestId id, QObject *requester, const QString &error); + +private: + explicit CommanderBracketService(QObject *parent = nullptr); +}; + +#endif 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 new file mode 100644 index 000000000..2cbbedfa4 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp @@ -0,0 +1,270 @@ +#include "commander_bracket_widget.h" + +#include "../../../../../client/settings/cache_settings.h" +#include "../../../../pixel_map_generator.h" +#include "commander_bracket_service.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +CommanderBracketWidget::CommanderBracketWidget(QWidget *parent) : QWidget(parent) +{ + bracketLabel = new QLabel(tr("Bracket:"), this); + + bracketValueLabel = new QLabel(this); + bracketValueLabel->setText("-"); + bracketValueLabel->setObjectName("bracketValueLabel"); + + bracketInfoButton = new QToolButton(this); + bracketInfoButton->setText("?"); + bracketInfoButton->setAutoRaise(true); + bracketInfoButton->setEnabled(false); + + bracketRefreshButton = new QToolButton(this); + bracketRefreshButton->setIcon(themePixmap(QStringLiteral("icons/reload"))); + bracketRefreshButton->setAutoRaise(true); + + connect(bracketRefreshButton, &QToolButton::clicked, this, &CommanderBracketWidget::requestBracketEstimate); + + auto *layout = new QGridLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + layout->addWidget(bracketLabel, 0, 0); + + auto *bracketRow = new QHBoxLayout; + bracketRow->addWidget(bracketValueLabel); + bracketRow->addWidget(bracketInfoButton); + bracketRow->addWidget(bracketRefreshButton); + bracketRow->addStretch(); + + layout->addLayout(bracketRow, 0, 1); + + connect(&CommanderBracketService::instance(), &CommanderBracketService::estimateFinished, this, + &CommanderBracketWidget::onEstimateBracketFinished); + connect(&CommanderBracketService::instance(), &CommanderBracketService::estimateError, this, + &CommanderBracketWidget::onEstimateBracketError); + + connect(&SettingsCache::instance().deckEditor(), &DeckEditorSettings::commanderSpellbookIntegrationEnabledChanged, + this, &CommanderBracketWidget::maybeAutoEstimateBracket); + connect(&SettingsCache::instance().deckEditor(), + &DeckEditorSettings::commanderSpellbookIntegrationUseOfficialBracketNamesChanged, this, + &CommanderBracketWidget::maybeAutoEstimateBracket); + + setVisible(false); +} + +void CommanderBracketWidget::setDeck(const QSharedPointer &_deck) +{ + deck = _deck; + requestId = 0; // invalidate any in-flight estimate for the previous deck + + // Reset the displayed bracket + bracketValueLabel->setText("-"); + bracketInfoButton->setToolTip({}); + bracketInfoButton->setEnabled(false); + bracketRefreshButton->setEnabled(true); + + maybeAutoEstimateBracket(); +} + +bool CommanderBracketWidget::promptCommanderSpellbookIntegration() +{ + QDialog dialog(this); + dialog.setWindowTitle(tr("CommanderSpellbook integration")); + + auto *mainLayout = new QVBoxLayout(&dialog); + + // Main text + auto *label = new QLabel(tr("CommanderSpellbook can analyze your deck and estimate its Commander bracket.\n\n" + "This sends your deck list to an external service.\n\n" + "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.")); + label->setWordWrap(true); + mainLayout->addWidget(label); + + // Naming selector + auto *formLayout = new QFormLayout; + auto *namingCombo = new QComboBox(&dialog); + namingCombo->addItem(tr("CommanderSpellbook bracket names")); + namingCombo->addItem(tr("Official Commander bracket names (approximate)")); + namingCombo->setCurrentIndex( + SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationUseOfficialBracketNames() ? 1 : 0); + + // Create label + explainer button + auto *labelWidget = new QWidget(&dialog); + auto *labelLayout = new QHBoxLayout(labelWidget); + labelLayout->setContentsMargins(0, 0, 0, 0); + + auto *namingLabel = new QLabel(tr("Bracket naming:"), labelWidget); + auto *explainerButton = new QToolButton(labelWidget); + explainerButton->setText("?"); + explainerButton->setAutoRaise(true); + explainerButton->setEnabled(false); + explainerButton->setToolTip(CommanderBracketNames::Explainer); + + labelLayout->addWidget(namingLabel); + labelLayout->addWidget(explainerButton); + labelLayout->addStretch(); // push the button next to label, combo stays aligned + + // Add row with the custom label widget + formLayout->addRow(labelWidget, namingCombo); + mainLayout->addLayout(formLayout); + + // Buttons + auto *buttonBox = new QDialogButtonBox(&dialog); + auto *enableBtn = buttonBox->addButton(tr("Enable"), QDialogButtonBox::AcceptRole); + auto *automaticBtn = buttonBox->addButton(tr("Automatic"), QDialogButtonBox::ApplyRole); + auto *disableBtn = buttonBox->addButton(tr("Disable"), QDialogButtonBox::RejectRole); + mainLayout->addWidget(buttonBox); + + // Track which button was clicked + QAbstractButton *clickedButton = nullptr; + QObject::connect(buttonBox, &QDialogButtonBox::clicked, &dialog, [&](QAbstractButton *btn) { + clickedButton = btn; + dialog.accept(); + }); + + dialog.exec(); + + // Persist naming choice (if not disabled) + if (clickedButton != disableBtn) { + bool useOfficial = namingCombo->currentIndex() == 1; + SettingsCache::instance().deckEditor().setCommanderSpellbookIntegrationUseOfficialBracketNames(useOfficial); + } + + // Persist integration mode + if (clickedButton == disableBtn) { + SettingsCache::instance().deckEditor().setCommanderSpellbookIntegrationEnabled( + commanderSpellbookIntegrationEnabledIndexDisabled); + return false; + } + if (clickedButton == enableBtn) { + SettingsCache::instance().deckEditor().setCommanderSpellbookIntegrationEnabled( + commanderSpellbookIntegrationEnabledIndexEnabled); + return true; + } + if (clickedButton == automaticBtn) { + SettingsCache::instance().deckEditor().setCommanderSpellbookIntegrationEnabled( + commanderSpellbookIntegrationEnabledIndexAutomatic); + return true; + } + + return false; +} + +void CommanderBracketWidget::updateBracketVisibility(bool visible) +{ + setVisible(visible); +} + +void CommanderBracketWidget::requestBracketEstimate() +{ + bracketRefreshButton->setEnabled(false); + bracketInfoButton->setEnabled(false); + bracketValueLabel->setText(tr("Calculating…")); + + requestId = CommanderBracketService::instance().estimateBracket(*deck, this); +} + +void CommanderBracketWidget::onEstimateBracketFinished(quint64 id, + QObject *requester, + const CommanderBracketEstimate &result) +{ + if (requester != this || id != requestId) { + return; + } + + BracketExplainer explainer; + lastBracketExplanation = explainer.explain(result.rawResult); + + // Display bracket + bracketValueLabel->setText( + SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationUseOfficialBracketNames() + ? result.officialName + : result.displayName); + bracketRefreshButton->setEnabled(true); + + // Build tooltip + QString tooltip; + for (const auto §ion : lastBracketExplanation.sections) { + tooltip += "" + section.title + "
"; + for (const auto &line : section.bulletPoints) { + tooltip += "• " + line + "
"; + } + tooltip += "
"; + } + + bracketInfoButton->setToolTip(tooltip); + bracketInfoButton->setEnabled(!tooltip.isEmpty()); +} + +void CommanderBracketWidget::onEstimateBracketError(quint64 id, QObject *requester, const QString & /*error*/) +{ + if (requester != this || id != requestId) { + return; + } + + bracketValueLabel->setText("-"); + bracketRefreshButton->setEnabled(true); + bracketInfoButton->setToolTip({}); + bracketInfoButton->setEnabled(false); +} + +void CommanderBracketWidget::maybeAutoEstimateBracket() +{ + const QString formatKey = deck->getGameFormat(); + + const bool isCommander = (formatKey.compare("commander", Qt::CaseInsensitive) == 0); + + int mode = SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationEnabled(); + + if (!isCommander || mode == commanderSpellbookIntegrationEnabledIndexDisabled) { + updateBracketVisibility(false); + return; + } + + if (mode == commanderSpellbookIntegrationEnabledIndexUnprompted) { + if (prompting) { + return; + } + prompting = true; + const bool accepted = promptCommanderSpellbookIntegration(); + prompting = false; + if (!accepted) { + updateBracketVisibility(false); + return; + } + } + + updateBracketVisibility(true); + + mode = SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationEnabled(); + if (mode != commanderSpellbookIntegrationEnabledIndexAutomatic) { + return; + } + + // Avoid firing if we already have a result or a request in flight + if (!bracketRefreshButton->isEnabled()) { + return; + } + + // Defer to avoid races during init / model rebuild + QTimer::singleShot(0, this, &CommanderBracketWidget::requestBracketEstimate); +} + +void CommanderBracketWidget::retranslateUi() +{ + bracketLabel->setText(tr("Bracket:")); + bracketInfoButton->setToolTip(tr("Why this bracket?")); + bracketRefreshButton->setToolTip(tr("Recalculate bracket")); +} diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.h new file mode 100644 index 000000000..a45610fcf --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.h @@ -0,0 +1,46 @@ +#ifndef COCKATRICE_COMMANDER_BRACKET_WIDGET_H +#define COCKATRICE_COMMANDER_BRACKET_WIDGET_H + +#include "commander_spellbook_bracket_explainer.h" + +#include +#include +#include + +class QLabel; +class QToolButton; +struct CommanderBracketEstimate; + +class CommanderBracketWidget : public QWidget +{ + Q_OBJECT + +public: + explicit CommanderBracketWidget(QWidget *parent = nullptr); + + void setDeck(const QSharedPointer &_deck); + void retranslateUi(); + +private slots: + void requestBracketEstimate(); + void onEstimateBracketFinished(quint64 id, QObject *requester, const CommanderBracketEstimate &result); + void onEstimateBracketError(quint64 id, QObject *requester, const QString &error); + void maybeAutoEstimateBracket(); + +private: + bool promptCommanderSpellbookIntegration(); + void updateBracketVisibility(bool visible); + + QSharedPointer deck; + bool prompting = false; + quint64 requestId = 0; + + QLabel *bracketLabel; + QLabel *bracketValueLabel; + QToolButton *bracketInfoButton; + QToolButton *bracketRefreshButton; + + BracketExplanation lastBracketExplanation; +}; + +#endif // COCKATRICE_COMMANDER_BRACKET_WIDGET_H 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 new file mode 100644 index 000000000..590692d98 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.cpp @@ -0,0 +1,75 @@ +#include "commander_spellbook_api_accessor.h" + +#include "api_response/commander_spellbook_deck_request.h" + +#include +#include +#include +#include +#include + +static const QUrl ESTIMATE_BRACKET_URL(QStringLiteral("https://backend.commanderspellbook.com/estimate-bracket")); + +CommanderSpellbookApiAccessor &CommanderSpellbookApiAccessor::instance() +{ + static CommanderSpellbookApiAccessor instance; + return instance; +} + +CommanderSpellbookApiAccessor::CommanderSpellbookApiAccessor(QObject *parent) : QObject(parent) +{ +} + +CommanderSpellbookApiAccessor::RequestId CommanderSpellbookApiAccessor::estimateBracket(const DeckList &deck, + QObject *requester) +{ + CommanderSpellbookDeckRequest deckRequest = CommanderSpellbookDeckRequest::fromDeckList(deck); + + QJsonDocument doc(deckRequest.toJson()); + QByteArray body = doc.toJson(QJsonDocument::Compact); + + QNetworkRequest req(ESTIMATE_BRACKET_URL); + req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); + req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING)); + + QNetworkReply *reply = network.post(req, body); + + const RequestId id = nextRequestId++; + + reply->setProperty("requestId", QVariant::fromValue(id)); + reply->setProperty("requester", QVariant::fromValue(requester)); + + connect(reply, &QNetworkReply::finished, this, [this, reply]() { onEstimateReplyFinished(reply); }); + + return id; +} + +void CommanderSpellbookApiAccessor::onEstimateReplyFinished(QNetworkReply *reply) +{ + reply->deleteLater(); + + const RequestId id = reply->property("requestId").toULongLong(); + QObject *requester = reply->property("requester").value(); + + if (!requester) { + // Requester died — silently drop + return; + } + + if (reply->error() != QNetworkReply::NoError) { + emit estimateBracketError(id, requester, reply->errorString()); + return; + } + + QJsonParseError err; + QJsonDocument doc = QJsonDocument::fromJson(reply->readAll(), &err); + + if (err.error != QJsonParseError::NoError || !doc.isObject()) { + emit estimateBracketError(id, requester, QStringLiteral("Invalid JSON response")); + return; + } + + EstimateBracketResult result = EstimateBracketResult::fromJson(doc.object()); + + emit estimateBracketFinished(id, requester, result); +} diff --git a/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.h b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.h new file mode 100644 index 000000000..682a48524 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_api_accessor.h @@ -0,0 +1,37 @@ +#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 + +class CommanderSpellbookApiAccessor final : public QObject +{ + Q_OBJECT + +public: + static CommanderSpellbookApiAccessor &instance(); + + using RequestId = quint64; + + RequestId estimateBracket(const DeckList &deck, QObject *requester); + +signals: + void estimateBracketFinished(RequestId id, QObject *requester, const EstimateBracketResult &result); + + void estimateBracketError(RequestId id, QObject *requester, const QString &errorMessage); + +private: + explicit CommanderSpellbookApiAccessor(QObject *parent = nullptr); + Q_DISABLE_COPY_MOVE(CommanderSpellbookApiAccessor) + + void onEstimateReplyFinished(QNetworkReply *reply); + + QNetworkAccessManager network; + RequestId nextRequestId = 1; +}; + +#endif // COCKATRICE_COMMANDER_SPELLBOOK_API_ACCESSOR_H 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 new file mode 100644 index 000000000..fc308ed60 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_bracket_explainer.cpp @@ -0,0 +1,120 @@ +#include "commander_spellbook_bracket_explainer.h" + +static QString cardList(const QList &cards, int max = 5) +{ + QStringList names; + for (int i = 0; i < cards.size() && i < max; ++i) { + names << cards[i].name; + } + + if (cards.size() > max) { + names << QString("and %1 more").arg(cards.size() - max); + } + + return names.join(", "); +} + +static QString comboCount(const QList &variants) +{ + return QString::number(variants.size()); +} + +BracketExplanation BracketExplainer::explain(const EstimateBracketResult &r) +{ + BracketExplanation out; + out.bracket = r.bracketTag; + + if (!r.gameChangerCards.isEmpty()) { + BracketExplanationSection s; + s.title = "Game-changing cards"; + + s.bulletPoints << QString("Your deck contains %1 game-changing cards, such as %2.") + .arg(r.gameChangerCards.size()) + .arg(cardList(r.gameChangerCards)); + + out.sections << s; + } + + if (!r.extraTurnCards.isEmpty() || !r.extraTurnTemplates.isEmpty() || !r.extraTurnCombos.isEmpty()) { + + BracketExplanationSection s; + s.title = "Extra turns"; + + if (!r.extraTurnCards.isEmpty()) { + s.bulletPoints << QString("The deck contains %1 extra-turn cards (%2).") + .arg(r.extraTurnCards.size()) + .arg(cardList(r.extraTurnCards)); + } + + if (!r.extraTurnTemplates.isEmpty()) { + s.bulletPoints << QString("%1 extra-turn templates were identified.").arg(comboCount(r.extraTurnTemplates)); + } + + if (!r.extraTurnCombos.isEmpty()) { + s.bulletPoints + << QString("%1 extra-turn combo variants were identified.").arg(comboCount(r.extraTurnCombos)); + } + + out.sections << s; + } + + if (!r.massLandDenialCards.isEmpty() || !r.massLandDenialTemplates.isEmpty() || !r.massLandDenialCombos.isEmpty()) { + + BracketExplanationSection s; + s.title = "Mass land denial"; + + if (!r.massLandDenialCards.isEmpty()) { + s.bulletPoints << QString("The deck contains %1 mass land denial cards (%2).") + .arg(r.massLandDenialCards.size()) + .arg(cardList(r.massLandDenialCards)); + } + + if (!r.massLandDenialTemplates.isEmpty()) { + s.bulletPoints + << QString("%1 mass land denial templates were identified.").arg(comboCount(r.massLandDenialTemplates)); + } + + if (!r.massLandDenialCombos.isEmpty()) { + s.bulletPoints << QString("%1 mass land denial combo variants were identified.") + .arg(comboCount(r.massLandDenialCombos)); + } + + out.sections << s; + } + + if (!r.lockCombos.isEmpty() || !r.skipTurnsCombos.isEmpty()) { + + BracketExplanationSection s; + s.title = "Lock pieces"; + + if (!r.lockCombos.isEmpty()) { + s.bulletPoints << QString("%1 lock combo variants were detected.").arg(comboCount(r.lockCombos)); + } + + if (!r.skipTurnsCombos.isEmpty()) { + s.bulletPoints << QString("%1 skip-turn combo variants were detected.").arg(comboCount(r.skipTurnsCombos)); + } + + out.sections << s; + } + + if (!r.definitelyTwoCardCombos.isEmpty() || !r.arguablyTwoCardCombos.isEmpty()) { + + BracketExplanationSection s; + s.title = "Two-card combos"; + + if (!r.definitelyTwoCardCombos.isEmpty()) { + s.bulletPoints << QString("%1 definite two-card combo variants were identified.") + .arg(comboCount(r.definitelyTwoCardCombos)); + } + + if (!r.arguablyTwoCardCombos.isEmpty()) { + s.bulletPoints << QString("%1 arguable two-card combo variants were identified.") + .arg(comboCount(r.arguablyTwoCardCombos)); + } + + out.sections << s; + } + + return out; +} 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 new file mode 100644 index 000000000..9ed499742 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/commander_spellbook_bracket_explainer.h @@ -0,0 +1,43 @@ +#ifndef COCKATRICE_COMMANDER_SPELLBOOK_BRACKET_EXPLAINER_H +#define COCKATRICE_COMMANDER_SPELLBOOK_BRACKET_EXPLAINER_H +#include "api_response/commander_spellbook_estimate_bracket_result.h" + +namespace CommanderBracketNames +{ +inline const char *CommanderSpellbookBracketNames = QT_TR_NOOP("CommanderSpellbook"); +inline const char *OfficialCommanderBracketNames = QT_TR_NOOP("Official (approximate)"); +inline const char *Explainer = QT_TR_NOOP( + "The bracket system combines both objective data, as well as subjective play experience to estimate a " + "bracket for a deck.\nCommanderSpellbook's estimation is algorithmical, which means that it can only operate " + "on the objective data, not the subjective intent. \nThey have chosen to represent this by defining their " + "own bracket system which matches their algorithm.\n" + "This custom bracket system maps loosely to the standard system. \nYou 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.\n\nAlways consider the subjective factors of the bracket system when determing a deck's " + "final bracket!"); +} // namespace CommanderBracketNames + +struct BracketExplanationSection +{ + QString title; + QStringList bulletPoints; +}; + +struct BracketExplanation +{ + QString bracket; + QList sections; + + bool isEmpty() const + { + return sections.isEmpty(); + } +}; + +class BracketExplainer +{ +public: + static BracketExplanation explain(const EstimateBracketResult &result); +}; + +#endif // COCKATRICE_COMMANDER_SPELLBOOK_BRACKET_EXPLAINER_H 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 new file mode 100644 index 000000000..90b490efe --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp @@ -0,0 +1,62 @@ +#include "handle_commander_brackets.h" + +#include "../../../../../client/settings/cache_settings.h" + +#include +#include +#include +#include + +static const QUrl COMMANDER_BRACKET_JSON_URL(QStringLiteral("https://cockatrice.github.io/commander-brackets.json")); + +HandleCommanderBrackets::HandleCommanderBrackets(QObject *parent) + : QObject(parent), nam(new QNetworkAccessManager(this)), reply(nullptr) +{ +} + +void HandleCommanderBrackets::downloadBracketDefinitions() +{ + if (reply) { + return; + } + + reply = nam->get(QNetworkRequest(COMMANDER_BRACKET_JSON_URL)); + + connect(reply, &QNetworkReply::finished, this, &HandleCommanderBrackets::actFinishParsingDownloadedData); +} + +void HandleCommanderBrackets::actFinishParsingDownloadedData() +{ + reply = qobject_cast(sender()); + + if (reply->error() != QNetworkReply::NoError) { + emit sigBracketDefinitionsDownloadFailed(reply->error()); + + reply->deleteLater(); + return; + } + + QJsonParseError parseError; + + auto document = QJsonDocument::fromJson(reply->readAll(), &parseError); + + if (parseError.error != QJsonParseError::NoError) { + emit sigBracketDefinitionsDownloadFailed(QNetworkReply::UnknownContentError); + + reply->deleteLater(); + return; + } + + updateBracketDefinitions(document.toVariant().toMap()); + + emit sigBracketDefinitionsDownloaded(); + + reply->deleteLater(); +} + +void HandleCommanderBrackets::updateBracketDefinitions(const QVariantMap &jsonMap) +{ + const auto bracketList = jsonMap.value("brackets").toList(); + SettingsCache::instance().commanderBrackets().saveDefinitions(bracketList); + SettingsCache::instance().commanderBrackets().reloadDefinitions(bracketList); +} 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 new file mode 100644 index 000000000..b8348b13c --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.h @@ -0,0 +1,31 @@ +#ifndef COCKATRICE_HANDLE_COMMANDER_BRACKETS_H +#define COCKATRICE_HANDLE_COMMANDER_BRACKETS_H + +#include +#include +#include + +class HandleCommanderBrackets : public QObject +{ + Q_OBJECT + +public: + explicit HandleCommanderBrackets(QObject *parent = nullptr); + + void downloadBracketDefinitions(); + +signals: + void sigBracketDefinitionsDownloaded(); + void sigBracketDefinitionsDownloadFailed(QNetworkReply::NetworkError error); + +private slots: + void actFinishParsingDownloadedData(); + +private: + void updateBracketDefinitions(const QVariantMap &jsonMap); + + QNetworkAccessManager *nam; + QNetworkReply *reply; +}; + +#endif // COCKATRICE_HANDLE_COMMANDER_BRACKETS_H diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/display/cards/edhrec_api_response_card_details_display_widget.cpp index 9d45f254b..9d294f10c 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 @@ -65,11 +65,7 @@ void EdhrecApiResponseCardDetailsDisplayWidget::mousePressEvent(QMouseEvent *eve } } -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) void EdhrecApiResponseCardDetailsDisplayWidget::enterEvent(QEnterEvent *event) -#else -void EdhrecApiResponseCardDetailsDisplayWidget::enterEvent(QEvent *event) -#endif { QWidget::enterEvent(event); backgroundPlateWidget->setFocused(true); 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 5daf32412..b09cc4e04 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 @@ -39,11 +39,7 @@ private: protected slots: void mousePressEvent(QMouseEvent *event) override; -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) - void enterEvent(QEnterEvent *event) override; ///< Qt6 hover enter -#else - void enterEvent(QEvent *event) override; ///< Qt5 hover enter -#endif + void enterEvent(QEnterEvent *event) override; ///< Hover enter void leaveEvent(QEvent *event) override; }; 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 f861d8afd..500eab13a 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,7 @@ #include "tab_edhrec_main.h" #include "../../../../../client/settings/cache_settings.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,7 +13,6 @@ #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 @@ -25,6 +25,7 @@ #include #include #include +#include #include static bool canBeCommander(const CardInfoPtr &cardInfo) @@ -62,41 +63,21 @@ 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(); }); settingsButton = new SettingsButtonWidget(this); - cardSizeSlider = new CardSizeWidget(this, nullptr, SettingsCache::instance().getEDHRecCardSize()); - connect(cardSizeSlider, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance(), - &SettingsCache::setEDHRecCardSize); + cardSizeSlider = new CardSizeWidget(this, nullptr, SettingsCache::instance().cardsDisplay().getEDHRecCardSize()); + connect(cardSizeSlider, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setEDHRecCardSize); settingsButton->addSettingsWidget(cardSizeSlider); diff --git a/cockatrice/src/interface/widgets/tabs/tab.h b/cockatrice/src/interface/widgets/tabs/tab.h index 6ea1f5077..bddf325e2 100644 --- a/cockatrice/src/interface/widgets/tabs/tab.h +++ b/cockatrice/src/interface/widgets/tabs/tab.h @@ -20,6 +20,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..dbcf50966 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_account.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_account.cpp @@ -1,6 +1,7 @@ #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" @@ -49,6 +50,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 +132,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 +155,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 +201,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 +252,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..68054c7a1 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_account.h +++ b/cockatrice/src/interface/widgets/tabs/tab_account.h @@ -18,6 +18,7 @@ class Event_RemoveFromList; class Event_UserJoined; class Event_UserLeft; class LineEditUnfocusable; +class QPushButton; class Response; class ServerInfo_User; class UserInfoBox; @@ -41,6 +42,7 @@ private slots: void processRemoveFromListEvent(const Event_RemoveFromList &event); void addToIgnoreList(); void addToBuddyList(); + void openMyReports(); private: AbstractClient *client; @@ -50,6 +52,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_card_art_rules.cpp b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp index 3dc4de15f..999bc3db0 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_card_art_rules.cpp @@ -1,5 +1,6 @@ #include "tab_card_art_rules.h" +#include "../utility/completer_utils.h" #include "libcockatrice/card/database/card_database_manager.h" #include @@ -194,29 +195,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_deck_editor.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_editor.cpp index 3878e12e0..8439aef70 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_editor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_editor.cpp @@ -1,6 +1,7 @@ #include "tab_deck_editor.h" #include "../../../client/settings/cache_settings.h" +#include "../../../client/settings/shortcuts_settings.h" #include "../deck_editor/deck_state_manager.h" #include "../filters/filter_builder.h" #include "../interface/pixel_map_generator.h" @@ -21,6 +22,8 @@ #include #include #include +#include +#include /** * @brief Constructs a new TabDeckEditor object. @@ -59,7 +62,8 @@ void TabDeckEditor::createMenus() registerDockWidget(viewMenu, filterDockWidget, {250, 250}); registerDockWidget(viewMenu, printingSelectorDockWidget, {525, 250}); - connect(&SettingsCache::instance(), &SettingsCache::overrideAllCardArtWithPersonalPreferenceChanged, this, + connect(&SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::overrideAllCardArtWithPersonalPreferenceChanged, this, [this](bool enabled) { dockToActions[printingSelectorDockWidget].menu->setEnabled(!enabled); }); viewMenu->addSeparator(); diff --git a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp index 8e2b114d9..f3535d850 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_deck_storage.cpp @@ -2,6 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../deck_loader/deck_loader.h" +#include "../../pixel_map_generator.h" #include "../interface/widgets/server/remote/remote_decklist_tree_widget.h" #include "../interface/widgets/utility/get_text_with_max.h" @@ -28,6 +29,7 @@ #include #include #include +#include #include TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, @@ -36,7 +38,7 @@ TabDeckStorage::TabDeckStorage(TabSupervisor *_tabSupervisor, : Tab(_tabSupervisor), client(_client) { localDirModel = new QFileSystemModel(this); - localDirModel->setRootPath(SettingsCache::instance().getDeckPath()); + localDirModel->setRootPath(SettingsCache::instance().paths().getDeckPath()); localDirModel->sort(0, Qt::AscendingOrder); localDirView = new QTreeView; @@ -104,19 +106,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); @@ -125,16 +127,16 @@ 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); // Add actions to toolbars 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..39652ef7d --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_developer.cpp @@ -0,0 +1,261 @@ +/** + * @file tab_developer.cpp + * @ingroup ServerTabs + */ +//! \todo Document this file. + +#include "tab_developer.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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..727a56bcb --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_developer.h @@ -0,0 +1,55 @@ +/** + * @file tab_developer.h + * @ingroup ServerTabs + */ +//! \todo Document this file. + +#ifndef TAB_DEVELOPER_H +#define TAB_DEVELOPER_H + +#include "tab.h" + +class AbstractClient; +class QCheckBox; +class QLabel; +class QPushButton; +class QSpinBox; +class QTableWidget; +class QTimer; +class Response; + +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 600647171..035ab1004 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -1,6 +1,7 @@ #include "tab_game.h" #include "../../../client/settings/cache_settings.h" +#include "../../../client/settings/shortcuts_settings.h" #include "../game/game.h" #include "../game/player/player_logic.h" #include "../game/replay.h" @@ -19,14 +20,23 @@ #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 "../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 "tab_supervisor.h" #include +#include +#include #include #include #include @@ -34,8 +44,12 @@ #include #include #include +#include +#include #include +#include #include +#include #include #include #include @@ -44,6 +58,10 @@ #include #include #include +#include +#include +#include +#include #include TabGame::TabGame(TabSupervisor *_tabSupervisor, GameReplay *_replay) @@ -248,21 +266,22 @@ void TabGame::resetChatAndPhase() // reset phase markers game->getGameState()->setCurrentPhase(-1); + + // reset spectator state so the replay can rebuild it from the start + game->getPlayerManager()->clearSpectators(); + playerListWidget->clearSpectators(); } void TabGame::emitUserEvent() { - bool globalEvent = - !game->getPlayerManager()->isSpectator() || SettingsCache::instance().getSpectatorNotificationsEnabled(); + bool globalEvent = !game->getPlayerManager()->isSpectator() || + SettingsCache::instance().userInterface().getSpectatorNotificationsEnabled(); emit userEvent(globalEvent); updatePlayerListDockTitle(); } TabGame::~TabGame() { - if (replayManager) { - delete replayManager->replay; - } for (auto &player : game->getPlayerManager()->getPlayers()) { player->clear(); } @@ -284,6 +303,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) { @@ -322,6 +344,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")); @@ -489,6 +517,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()); @@ -536,7 +614,8 @@ bool TabGame::leaveGame() void TabGame::actSay() { - if (completer->popup()->isVisible()) { + if (sayEdit->hasVisibleCompleterPopup()) { + sayEdit->hideCompleterPopups(); return; } @@ -556,14 +635,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); } } @@ -626,8 +705,8 @@ void TabGame::actRotateViewCCW() void TabGame::actCompleterChanged() { - SettingsCache::instance().getChatMentionCompleter() ? completer->setCompletionRole(2) - : completer->setCompletionRole(1); + SettingsCache::instance().chat().getChatMentionCompleter() ? mentionCompleter->setCompletionRole(2) + : mentionCompleter->setCompletionRole(1); } void TabGame::notifyPlayerJoin(QString playerName) @@ -836,6 +915,7 @@ void TabGame::stopGame() QMapIterator i(deckViewContainers); while (i.hasNext()) { i.next(); + i.value()->playerDeckView->advancePlaymatRotation(); i.value()->show(); } @@ -976,6 +1056,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()) { @@ -1014,6 +1099,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); @@ -1022,6 +1109,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); } @@ -1036,6 +1126,8 @@ void TabGame::createReplayMenuItems() aRotateViewCCW = nullptr; aResetLayout = nullptr; aGameInfo = nullptr; + aCopyGameLink = nullptr; + aInviteToGame = nullptr; aConcede = nullptr; aFocusChat = nullptr; aLeaveGame = new QAction(this); @@ -1169,16 +1261,17 @@ void TabGame::createPlayAreaWidget(bool bReplay) void TabGame::createReplayDock(GameReplay *replay) { - replayManager = new ReplayManager(this, replay); + replayWidget = new ReplayWidget(this, replay); replayDock = new QDockWidget(this); replayDock->setObjectName("replayDock"); replayDock->setFeatures(QDockWidget::DockWidgetClosable | QDockWidget::DockWidgetFloatable | QDockWidget::DockWidgetMovable); - replayDock->setWidget(replayManager); + replayDock->setWidget(replayWidget); replayDock->setFloating(false); - connect(replayManager, &ReplayManager::eventReplayed, game->getGameEventHandler(), + connect(replayWidget, &ReplayWidget::rewound, this, &TabGame::resetChatAndPhase); + connect(replayWidget, &ReplayWidget::eventReplayed, game->getGameEventHandler(), [this](const auto &event, auto options) { game->getGameEventHandler()->processGameEventContainer(event, nullptr, options); }); @@ -1233,11 +1326,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); } @@ -1261,11 +1372,12 @@ 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); connect(messageLog, &MessageLogWidget::addMentionTag, this, &TabGame::addMentionTag); - connect(&SettingsCache::instance(), &SettingsCache::chatMentionCompleterChanged, this, + connect(&SettingsCache::instance().chat(), &ChatSettings::chatMentionCompleterChanged, this, &TabGame::actCompleterChanged); } @@ -1278,12 +1390,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 b9289432d..a05b49a9f 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.h +++ b/cockatrice/src/interface/widgets/tabs/tab_game.h @@ -13,12 +13,13 @@ #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_manager.h" +#include "../interface/widgets/replay/replay_widget.h" #include "tab.h" #include #include #include +#include class CardMenu; class ServerInfo_PlayerProperties; @@ -31,11 +32,12 @@ class AbstractClient; class CardDatabase; class GameView; class GameScene; -class ReplayManager; +class ReplayWidget; class CardInfoFrameWidget; class QTimer; class QSplitter; class QLabel; +class QPushButton; class QToolButton; class QMenu; class ZoneViewLayout; @@ -59,14 +61,16 @@ class TabGame : public Tab private: AbstractGame *game; const UserListProxy *userListProxy; - ReplayManager *replayManager = nullptr; + 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; @@ -81,9 +85,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; @@ -126,6 +131,7 @@ private: void createPlayAreaWidget(bool bReplay = false); void createDeckViewContainerWidget(bool bReplay = false); void createReplayDock(GameReplay *replay); + void updateInviteButtonState(); signals: void gameClosing(TabGame *tab); void containerProcessingStarted(const GameEventContext &context); @@ -145,7 +151,9 @@ private slots: void setCardMenu(CardMenu *menu); void actGameInfo(); + void actInviteToGame(); void actConcede(); + void actCopyGameLink(); void actRemoveLocalArrows(); void actRotateViewCW(); void actRotateViewCCW(); diff --git a/cockatrice/src/interface/widgets/tabs/tab_logs.cpp b/cockatrice/src/interface/widgets/tabs/tab_logs.cpp index e3678a903..f73d06b57 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_logs.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_logs.cpp @@ -19,7 +19,8 @@ #include #include -TabLog::TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client) : Tab(_tabSupervisor), client(_client) +TabLog::TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client, bool _canUseDeveloperCommands) + : Tab(_tabSupervisor), client(_client), canUseDeveloperCommands(_canUseDeveloperCommands) { roomTable = new QTableWidget(); roomTable->setColumnCount(6); @@ -80,7 +81,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 +120,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 +182,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..8e914ea64 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_logs.h +++ b/cockatrice/src/interface/widgets/tabs/tab_logs.h @@ -33,6 +33,7 @@ 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 2cacab731..418843178 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_message.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_message.cpp @@ -17,19 +17,22 @@ #include #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); @@ -93,28 +96,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(); } } @@ -126,7 +165,7 @@ void TabMessage::processUserMessageEvent(const Event_UserMessage &event) if (tabSupervisor->currentIndex() != tabSupervisor->indexOf(this)) { soundEngine->playSound("private_message"); } - if (SettingsCache::instance().getShowMessagePopup() && shouldShowSystemPopup(event)) { + if (SettingsCache::instance().chat().getShowMessagePopup() && shouldShowSystemPopup(event)) { showSystemPopup(event); } if (QString::fromStdString(event.sender_name()).toLower().simplified() == "servatrice") { @@ -174,3 +213,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..0e6d66d4c 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_message.h +++ b/cockatrice/src/interface/widgets/tabs/tab_message.h @@ -19,6 +19,7 @@ class LineEditUnfocusable; class Event_UserMessage; class Response; class ServerInfo_User; +class CommandContainer; class TabMessage : public Tab { @@ -39,7 +40,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 +51,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 +64,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..b9c37f1af --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp @@ -0,0 +1,465 @@ +#include "tab_moderation.h" + +#include "abstract_client.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 + +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..0a534992b --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.h @@ -0,0 +1,83 @@ +#ifndef TAB_MODERATION_H +#define TAB_MODERATION_H + +#include "tab.h" + +#include + +class AbstractClient; +class QGroupBox; +class QLabel; +class QLineEdit; +class QPushButton; +class QTableWidget; +class QTextEdit; + +/** + * 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_replays.cpp b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp index af52c038f..ac4b2cbe9 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_replays.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_replays.cpp @@ -1,6 +1,7 @@ #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" @@ -29,6 +30,7 @@ #include #include #include +#include inline Q_LOGGING_CATEGORY(TabReplaysLog, "replays_tab"); @@ -59,7 +61,7 @@ TabReplays::TabReplays(TabSupervisor *_tabSupervisor, AbstractClient *_client, c QGroupBox *TabReplays::createLeftLayout() { localDirModel = new QFileSystemModel(this); - localDirModel->setRootPath(SettingsCache::instance().getReplaysPath()); + localDirModel->setRootPath(SettingsCache::instance().paths().getReplaysPath()); localDirModel->sort(0, Qt::AscendingOrder); localDirView = new QTreeView; @@ -101,17 +103,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); @@ -163,24 +165,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 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..0b9108f9b --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_report.cpp @@ -0,0 +1,927 @@ +#include "tab_report.h" + +#include "../utility/report_utils.h" +#include "abstract_client.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 + +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..b62a99ce2 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_report.h @@ -0,0 +1,124 @@ +#ifndef TAB_REPORT_H +#define TAB_REPORT_H + +#include "tab.h" + +#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 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 ec05e9ff6..866324367 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_room.cpp @@ -1,12 +1,21 @@ #include "tab_room.h" #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 "../utility/completer_utils.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 "tab_account.h" #include "tab_supervisor.h" @@ -16,11 +25,15 @@ #include #include #include +#include #include +#include #include #include #include #include +#include +#include #include #include #include @@ -31,6 +44,7 @@ #include #include #include +#include #include TabRoom::TabRoom(TabSupervisor *_tabSupervisor, @@ -50,32 +64,23 @@ 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); - connect(&SettingsCache::instance(), &SettingsCache::chatMentionCompleterChanged, this, + connect(&SettingsCache::instance().chat(), &ChatSettings::chatMentionCompleterChanged, this, &TabRoom::actCompleterChanged); sayLabel = new QLabel; sayEdit = new LineEditCompleter; @@ -94,7 +99,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); @@ -116,7 +121,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); @@ -135,13 +140,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(); @@ -157,7 +176,7 @@ void TabRoom::retranslateUi() { gameSelector->retranslateUi(); chatView->retranslateUi(); - userList->retranslateUi(); + userListPanel->retranslateUi(); sayLabel->setText(tr("&Say:")); chatGroupBox->setTitle(tr("Chat")); roomMenu->setTitle(tr("&Room")); @@ -211,8 +230,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; @@ -240,14 +259,14 @@ void TabRoom::actClearChat() void TabRoom::actOpenChatSettings() { DlgSettings settings(this); - settings.setTab(4); + settings.setTab(DlgSettings::MessagesPage); settings.exec(); } void TabRoom::actCompleterChanged() { - SettingsCache::instance().getChatMentionCompleter() ? completer->setCompletionRole(2) - : completer->setCompletionRole(1); + SettingsCache::instance().chat().getChatMentionCompleter() ? mentionCompleter->setCompletionRole(2) + : mentionCompleter->setCompletionRole(1); } void TabRoom::processRoomEvent(const RoomEvent &event) @@ -278,20 +297,23 @@ void TabRoom::processListGamesEvent(const Event_ListGames &event) for (int i = 0; i < gameListSize; ++i) { gameSelector->processGameInfo(event.game_list(i)); } + emit gameListUpdated(); } 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) @@ -307,13 +329,13 @@ void TabRoom::processRoomSayEvent(const Event_RoomSay &event) ServerInfo_User userInfo = {}; if (twi) { userInfo = twi->getUserInfo(); - if (SettingsCache::instance().getIgnoreUnregisteredUsers() && + if (SettingsCache::instance().chat().getIgnoreUnregisteredUsers() && !UserLevelFlags(userInfo.user_level()).testFlag(ServerInfo_User::IsRegistered)) { return; } } - if (event.message_type() == Event_RoomSay::ChatHistory && !SettingsCache::instance().getRoomHistory()) { + if (event.message_type() == Event_RoomSay::ChatHistory && !SettingsCache::instance().chat().getRoomHistory()) { return; } diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.h b/cockatrice/src/interface/widgets/tabs/tab_room.h index d669b6107..7d01d5cf6 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.h +++ b/cockatrice/src/interface/widgets/tabs/tab_room.h @@ -14,6 +14,7 @@ #include #include #include +#include class UserListProxy; class UserListManager; @@ -26,6 +27,7 @@ class Message; } // namespace google class AbstractClient; class UserListWidget; +class UserListPanelWidget; class QLabel; class ChatView; class QPushButton; @@ -56,13 +58,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,12 +74,13 @@ 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); void maximizeClient(); void notIdle(); + void gameListUpdated(); private slots: void sendMessage(); void sayFinished(const Response &response); @@ -111,6 +114,10 @@ public: { return roomId; } + [[nodiscard]] QString getRoomName() const + { + return roomName; + } [[nodiscard]] const QMap &getGameTypes() const { return gameTypes; @@ -127,6 +134,10 @@ public: { return ownUser; } + [[nodiscard]] GameSelector *getGameSelector() const + { + return gameSelector; + } PendingCommand *prepareRoomCommand(const ::google::protobuf::Message &cmd); void sendRoomCommand(PendingCommand *pend); diff --git a/cockatrice/src/interface/widgets/tabs/tab_server.cpp b/cockatrice/src/interface/widgets/tabs/tab_server.cpp index 2fce5c1fa..fca32094c 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_server.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_server.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include @@ -185,52 +186,110 @@ 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, &TabServer::joinRoomFinished); - - 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, const CommandContainer & /*commandContainer*/, - const QVariant &extraData) + 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.")); + 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 137823592..121ff814d 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_server.h +++ b/cockatrice/src/interface/widgets/tabs/tab_server.h @@ -10,6 +10,8 @@ #include "tab.h" #include +#include +#include #include #include @@ -49,19 +51,30 @@ class TabServer : public Tab Q_OBJECT signals: void roomJoined(const ServerInfo_Room &info, bool setCurrent); + void roomJoinFailed(int roomId); private slots: void processServerMessageEvent(const Event_ServerMessage &event); - void joinRoom(int id, bool setCurrent); - void joinRoomFinished(const Response &resp, const CommandContainer &commandContainer, const QVariant &extraData); + void joinRoomFinished(const Response &resp, + const CommandContainer &commandContainer, + const QVariant &extraData, + 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); + void joinRoom(int id, bool setCurrent); void retranslateUi() override; [[nodiscard]] QString getTabText() const override { diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 52309d94b..ccb687ff3 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -1,7 +1,10 @@ #include "tab_supervisor.h" #include "../../../client/settings/cache_settings.h" +#include "../../../client/settings/shortcuts_settings.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" @@ -12,11 +15,14 @@ #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_replays.h" +#include "tab_report.h" #include "tab_room.h" #include "tab_server.h" #include "tab_visual_database_display.h" @@ -29,6 +35,7 @@ #include #include #include +#include #include #include #include @@ -37,6 +44,11 @@ #include #include #include +#include +#include +#include +#include +#include QRect MacOSTabFixStyle::subElementRect(SubElement element, const QStyleOption *option, const QWidget *widget) const { @@ -45,7 +57,7 @@ QRect MacOSTabFixStyle::subElementRect(SubElement element, const QStyleOption *o } // Skip over QProxyStyle handling subElementRect, - // This fixes an issue with Qt 5.10 on OSX where the labels for tabs with a button and an icon + // This fixes an issue on OSX where the labels for tabs with a button and an icon // get cut-off too early return QCommonStyle::subElementRect(element, option, widget); } @@ -65,11 +77,7 @@ QSize CloseButton::sizeHint() const return {width, height}; } -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) void CloseButton::enterEvent(QEnterEvent *event) -#else -void CloseButton::enterEvent(QEvent *event) -#endif { update(); QAbstractButton::enterEvent(event); @@ -109,16 +117,17 @@ 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); setIconSize(QSize(15, 15)); #if defined(Q_OS_MAC) - // This is necessary to fix an issue on macOS with qt5.10, + // This is necessary to fix an issue on macOS, // where tabs with icons and buttons get drawn incorrectly tabBar()->setStyle(new MacOSTabFixStyle); #endif @@ -134,6 +143,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 @@ -188,6 +198,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(); @@ -227,6 +249,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; @@ -236,6 +262,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()); @@ -282,6 +312,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) @@ -334,22 +366,63 @@ 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() { openTabHome(); setCurrentWidget(tabHome); - if (SettingsCache::instance().getTabVisualDeckStorageOpen()) { + if (SettingsCache::instance().tabs().getTabVisualDeckStorageOpen()) { openTabVisualDeckStorage(); } - if (SettingsCache::instance().getTabDeckStorageOpen()) { + if (SettingsCache::instance().tabs().getTabDeckStorageOpen()) { openTabDeckStorage(); } - if (SettingsCache::instance().getTabReplaysOpen()) { + 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; + } } /** @@ -364,6 +437,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)); @@ -427,10 +501,10 @@ void TabSupervisor::start(const ServerInfo_User &_userInfo) tabsMenu->addAction(aTabServer); tabsMenu->addAction(aTabAccount); - if (SettingsCache::instance().getTabServerOpen()) { + if (SettingsCache::instance().tabs().getTabServerOpen()) { openTabServer(); } - if (SettingsCache::instance().getTabAccountOpen()) { + if (SettingsCache::instance().tabs().getTabAccountOpen()) { openTabAccount(); } @@ -441,14 +515,37 @@ 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().getTabAdminOpen()) { + if (SettingsCache::instance().tabs().getTabAdminOpen()) { openTabAdmin(); } - if (SettingsCache::instance().getTabLogOpen()) { + 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(); @@ -461,6 +558,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; @@ -502,6 +602,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; @@ -547,7 +659,7 @@ void TabSupervisor::openTabHome() void TabSupervisor::actTabVisualDeckStorage(bool checked) { - SettingsCache::instance().setTabVisualDeckStorageOpen(checked); + SettingsCache::instance().tabs().setTabVisualDeckStorageOpen(checked); if (checked && !tabVisualDeckStorage) { openTabVisualDeckStorage(); setCurrentWidget(tabVisualDeckStorage); @@ -571,7 +683,7 @@ void TabSupervisor::openTabVisualDeckStorage() void TabSupervisor::actTabServer(bool checked) { - SettingsCache::instance().setTabServerOpen(checked); + SettingsCache::instance().tabs().setTabServerOpen(checked); if (checked && !tabServer) { openTabServer(); setCurrentWidget(tabServer); @@ -582,6 +694,11 @@ void TabSupervisor::actTabServer(bool checked) void TabSupervisor::openTabServer() { + SettingsCache::instance().tabs().setTabServerOpen(true); + if (tabServer) { + return; + } + tabServer = new TabServer(this, client); connect(tabServer, &TabServer::roomJoined, this, &TabSupervisor::addRoomTab); myAddTab(tabServer, aTabServer); @@ -594,7 +711,7 @@ void TabSupervisor::openTabServer() void TabSupervisor::actTabAccount(bool checked) { - SettingsCache::instance().setTabAccountOpen(checked); + SettingsCache::instance().tabs().setTabAccountOpen(checked); if (checked && !tabAccount) { openTabAccount(); setCurrentWidget(tabAccount); @@ -619,7 +736,7 @@ void TabSupervisor::openTabAccount() void TabSupervisor::actTabDeckStorage(bool checked) { - SettingsCache::instance().setTabDeckStorageOpen(checked); + SettingsCache::instance().tabs().setTabDeckStorageOpen(checked); if (checked && !tabDeckStorage) { openTabDeckStorage(); setCurrentWidget(tabDeckStorage); @@ -642,7 +759,7 @@ void TabSupervisor::openTabDeckStorage() void TabSupervisor::actTabReplays(bool checked) { - SettingsCache::instance().setTabReplaysOpen(checked); + SettingsCache::instance().tabs().setTabReplaysOpen(checked); if (checked && !tabReplays) { openTabReplays(); setCurrentWidget(tabReplays); @@ -667,7 +784,7 @@ void TabSupervisor::openTabReplays() void TabSupervisor::actTabAdmin(bool checked) { - SettingsCache::instance().setTabAdminOpen(checked); + SettingsCache::instance().tabs().setTabAdminOpen(checked); if (checked && !tabAdmin) { openTabAdmin(); setCurrentWidget(tabAdmin); @@ -690,6 +807,7 @@ void TabSupervisor::openTabAdmin() void TabSupervisor::actTabCardArtRules(bool checked) { + SettingsCache::instance().tabs().setTabCardArtRulesOpen(checked); if (checked && !tabCardArtRules) { openTabCardArtRules(); setCurrentWidget(tabCardArtRules); @@ -714,7 +832,7 @@ void TabSupervisor::openTabCardArtRules() void TabSupervisor::actTabLog(bool checked) { - SettingsCache::instance().setTabLogOpen(checked); + SettingsCache::instance().tabs().setTabLogOpen(checked); if (checked && !tabLog) { openTabLog(); setCurrentWidget(tabLog); @@ -725,7 +843,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; @@ -734,6 +858,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) { @@ -746,6 +944,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; @@ -804,6 +1019,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) { @@ -850,6 +1066,30 @@ void TabSupervisor::replayLeft(TabGame *tab) replayTabs.removeOne(tab); } +void TabSupervisor::joinReportGame(const int gameId, const int roomId) +{ + auto *remoteClient = qobject_cast(client); + if (!remoteClient) { + actShowPopup(tr("Report 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 = true; + + 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())) { @@ -857,8 +1097,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()); } @@ -872,9 +1114,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) { @@ -898,6 +1148,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 @@ -905,7 +1203,7 @@ void TabSupervisor::talkLeft(TabMessage *tab) */ void TabSupervisor::openDeckInNewTab(const LoadedDeck &deckToOpen) { - int type = SettingsCache::instance().getDefaultDeckEditorType(); + int type = SettingsCache::instance().deckEditor().getDefaultDeckEditorType(); switch (type) { case ClassicDeckEditor: addDeckEditorTab(deckToOpen); @@ -1002,9 +1300,9 @@ 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().getNotificationsEnabled()) { + if (globalEvent && SettingsCache::instance().userInterface().getNotificationsEnabled()) { QApplication::alert(this); } } @@ -1037,7 +1335,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())); @@ -1046,11 +1358,11 @@ void TabSupervisor::processUserMessageEvent(const Event_UserMessage &event) const ServerInfo_User *onlineUserInfo = userListManager->getOnlineUser(senderName); if (onlineUserInfo) { auto userLevel = UserLevelFlags(onlineUserInfo->user_level()); - if (SettingsCache::instance().getIgnoreUnregisteredUserMessages() && + if (SettingsCache::instance().chat().getIgnoreUnregisteredUserMessages() && !userLevel.testFlag(ServerInfo_User::IsRegistered)) { // Flags are additive, so reg/mod/admin are all IsRegistered return; - } else if (SettingsCache::instance().getIgnoreNonBuddyUserMessages() && + } else if (SettingsCache::instance().chat().getIgnoreNonBuddyUserMessages() && !userListManager->isUserBuddy(senderName) && !userLevel.testFlag(ServerInfo_User::IsModerator) && !userLevel.testFlag(ServerInfo_User::IsAdmin)) { // Ignore private messages from non-buddies @@ -1099,7 +1411,7 @@ void TabSupervisor::processUserJoined(const ServerInfo_User &userInfoJoined) } } - if (SettingsCache::instance().getBuddyConnectNotificationsEnabled()) { + if (SettingsCache::instance().userInterface().getBuddyConnectNotificationsEnabled()) { QApplication::alert(this); this->actShowPopup(tr("Your buddy %1 has signed on!").arg(userName)); } @@ -1139,6 +1451,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) { @@ -1184,6 +1501,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 3eac144b7..adde7f971 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.h +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.h @@ -9,6 +9,7 @@ #define TAB_SUPERVISOR_H #include "../../deck_loader/deck_loader.h" +#include "../interface/widgets/server/game_link.h" #include "../interface/widgets/server/user/user_list_proxy.h" #include "abstract_tab_deck_editor.h" #include "api/archidekt/tab_archidekt.h" @@ -23,6 +24,7 @@ #include #include #include +#include class TabCardArtRules; inline Q_LOGGING_CATEGORY(TabSupervisorLog, "tab_supervisor"); @@ -39,8 +41,11 @@ class TabDeckStorage; class TabReplays; class TabAdmin; class TabMessage; +class TabReport; +class TabModeration; class TabAccount; class TabDeckEditor; +class TabDeveloper; class TabLog; class RoomEvent; class GameEventContainer; @@ -71,11 +76,7 @@ public: } protected: -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) void enterEvent(QEnterEvent *event) override; -#else - void enterEvent(QEvent *event) override; -#endif void leaveEvent(QEvent *event) override; void paintEvent(QPaintEvent *event) override; }; @@ -106,6 +107,9 @@ private: TabAdmin *tabAdmin; TabCardArtRules *tabCardArtRules; TabLog *tabLog; + TabReport *tabReport; + TabModeration *tabModeration; + TabDeveloper *tabDeveloper; QMap roomTabs; QMap gameTabs; QList replayTabs; @@ -115,7 +119,7 @@ private: 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); @@ -152,6 +156,10 @@ public: { return userListManager; } + [[nodiscard]] TabServer *getTabServer() const + { + return tabServer; + } [[nodiscard]] const QMap &getRoomTabs() const { return roomTabs; @@ -160,7 +168,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); @@ -169,6 +180,7 @@ signals: void localGameEnded(); void adminLockChanged(bool lock); void showWindowIfHidden(); + void cockatriceLinkActivated(const QString &url); public slots: void openDeckInNewTab(const LoadedDeck &deckToOpen); @@ -179,10 +191,14 @@ public slots: TabArchidekt *addArchidektTab(); TabEdhRec *addEdhrecTab(const CardInfoPtr &cardToQuery, bool isCommander = false); void openReplay(GameReplay *replay); + void joinReportGame(int gameId, int roomId); + 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); private slots: void refreshShortcuts(); @@ -192,10 +208,12 @@ 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(); - void openTabServer(); void openTabAccount(); void openTabDeckStorage(); void openTabReplays(); @@ -203,13 +221,15 @@ 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); TabMessage *addMessageTab(const QString &userName, bool focus); void replayLeft(TabGame *tab); 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 fd465ec21..0f43893d3 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 @@ -1,8 +1,10 @@ #include "tab_deck_editor_visual.h" #include "../../../../client/settings/cache_settings.h" +#include "../../../../client/settings/shortcuts_settings.h" #include "../../cards/card_info_display_widget.h" #include "../../deck_editor/deck_state_manager.h" +#include "../../deck_editor/deck_zone_dialog.h" #include "../../filters/filter_builder.h" #include "../../interface/pixel_map_generator.h" #include "../../interface/widgets/cards/card_info_frame_widget.h" @@ -26,10 +28,11 @@ #include #include #include -#include #include #include #include +#include +#include /** * @brief Constructs the TabDeckEditorVisual instance. @@ -65,9 +68,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, @@ -85,6 +85,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); @@ -100,6 +101,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() { @@ -243,6 +271,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 7d7a3f3a2..fb09578c4 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 @@ -164,6 +164,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..5ccfcc28f 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 @@ -25,7 +25,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..4f04b51f6 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 @@ -49,6 +49,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/utility/card_completer_delegate.cpp b/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp new file mode 100644 index 000000000..29bf2263e --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/card_completer_delegate.cpp @@ -0,0 +1,419 @@ +#include "card_completer_delegate.h" + +#include "../../pixel_map_generator.h" +#include "../cards/additional_info/mana_cost_widget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// 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..78667f56b --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/card_completer_delegate.h @@ -0,0 +1,57 @@ +/** + * @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 + +class CardInfo; + +/** + * @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..6794ccbb4 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/card_completer_styler.cpp @@ -0,0 +1,340 @@ +#include "card_completer_styler.h" + +#include "../cards/card_info_picture_enlarged_widget.h" +#include "card_completer_delegate.h" +#include "reversed_completer_model.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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) { + popup->setCurrentIndex(completer->completionModel()->index(newRow, completer->completionColumn())); + } + + 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->setCurrentIndex(completionModel->index(closestRow, completer->completionColumn())); + } +} + +// --------------------------------------------------------------------------- + +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..616e3fe61 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/card_completer_styler.h @@ -0,0 +1,67 @@ +/** + * @file card_completer_styler.h + * @ingroup UtilityWidgets + */ +//! \todo Document this file. + +#ifndef CARD_COMPLETER_STYLER_H +#define CARD_COMPLETER_STYLER_H + +#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/completer_utils.cpp b/cockatrice/src/interface/widgets/utility/completer_utils.cpp new file mode 100644 index 000000000..16d5cfd13 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/completer_utils.cpp @@ -0,0 +1,52 @@ +#include "completer_utils.h" + +#include "card_completer_styler.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +CardCompleterSetup createCardCompleter(CardDatabaseDisplayModel *displayModel, QObject *parent, int maxVisibleItems) +{ + auto *searchModel = new CardSearchModel(displayModel, parent); + + 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); + + 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); + 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/line_edit_completer.cpp b/cockatrice/src/interface/widgets/utility/line_edit_completer.cpp index 13f475a61..3a678029d 100644 --- a/cockatrice/src/interface/widgets/utility/line_edit_completer.cpp +++ b/cockatrice/src/interface/widgets/utility/line_edit_completer.cpp @@ -1,135 +1,199 @@ #include "line_edit_completer.h" #include -#include #include -#include -#include -#include -#include +#include -LineEditCompleter::LineEditCompleter(QWidget *parent) : LineEditUnfocusable(parent), c(nullptr) +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(); + + // Only commit the highlighted completion when focus moves away via Tab. + // Other focus losses (e.g. the unfocus shortcut / Escape) must simply close + // the popup without inserting anything. + if (e->reason() != Qt::TabFocusReason) { + hideCompleterPopups(); + return; } + + for (auto &info : completers) { + if (!info.completer->popup()->isVisible()) { + continue; + } + + const QModelIndex currentIndex = info.completer->popup()->currentIndex(); + if (currentIndex.isValid()) { + insertCompletion(info.completer, currentIndex.data().toString()); + } + } + + hideCompleterPopups(); } void LineEditCompleter::keyPressEvent(QKeyEvent *event) { - switch (event->key()) { - case Qt::Key_Return: - case Qt::Key_Enter: - case Qt::Key_Escape: - if (c->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 += " "; - } - setText(finalString); - 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; - } - 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(); + + if (event->key() == Qt::Key_Escape) { + hideCompleterPopups(); return; } - // Set new completion prefix - c->setCompletionPrefix(cursorWord(text())); - if (c->completionPrefix().length() < 1) { - 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; } - // Draw completion box - QRect cr = cursorRect(); - cr.setWidth(c->popup()->sizeHintForColumn(0) + c->popup()->verticalScrollBar()->sizeHint().width()); - c->complete(cr); + active->completer->setCompletionPrefix(prefix); - // 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; + switch (active->trigger) { + case CompleterTrigger::Card: + emit cardPartialChanged(prefix); + return; + case CompleterTrigger::Mention: + break; } - QStringListModel *model; - model = (QStringListModel *)(c->model()); - if (model == NULL) { - model = new QStringListModel(); - } - model->setStringList(completionList); + active->completer->complete(); +} + +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; + } + } + } } diff --git a/cockatrice/src/interface/widgets/utility/line_edit_completer.h b/cockatrice/src/interface/widgets/utility/line_edit_completer.h index 65fa382ac..369b7b553 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 +#include +#include + +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..aa4558e5c --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/report_utils.cpp @@ -0,0 +1,119 @@ +#include "report_utils.h" + +#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..c6cc96c18 --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/report_utils.h @@ -0,0 +1,36 @@ +#ifndef REPORT_UTILS_H +#define REPORT_UTILS_H + +#include +#include + +class QTableWidget; +class QTextEdit; + +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..1bc9ba14b --- /dev/null +++ b/cockatrice/src/interface/widgets/utility/reversed_completer_model.cpp @@ -0,0 +1,102 @@ +#include "reversed_completer_model.h" + +#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..64233a1b0 --- /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 + +class QAbstractItemModel; + +/** + * @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 a34f39042..06561a41d 100644 --- a/cockatrice/src/interface/widgets/utility/sequence_edit.cpp +++ b/cockatrice/src/interface/widgets/utility/sequence_edit.cpp @@ -1,6 +1,8 @@ #include "sequence_edit.h" #include "../../../client/settings/cache_settings.h" +#include "../../../client/settings/shortcuts_settings.h" +#include "../../pixel_map_generator.h" #include #include @@ -13,8 +15,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/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 7ee57b1e9..76a611d52 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 @@ -7,6 +7,7 @@ #include #include #include +#include VisualDatabaseDisplayFilterSaveLoadWidget::VisualDatabaseDisplayFilterSaveLoadWidget(QWidget *parent, FilterTreeModel *_filterModel) @@ -61,7 +62,7 @@ void VisualDatabaseDisplayFilterSaveLoadWidget::saveFilter() return; } - QString filePath = SettingsCache::instance().getFiltersPath() + QDir::separator() + filename + ".json"; + QString filePath = SettingsCache::instance().paths().getFiltersPath() + QDir::separator() + filename + ".json"; // Serialize the filter model to JSON QJsonArray filtersArray; @@ -86,7 +87,7 @@ void VisualDatabaseDisplayFilterSaveLoadWidget::saveFilter() void VisualDatabaseDisplayFilterSaveLoadWidget::loadFilter(const QString &filename) { - QString filePath = SettingsCache::instance().getFiltersPath() + QDir::separator() + filename; + QString filePath = SettingsCache::instance().paths().getFiltersPath() + QDir::separator() + filename; QFile file(filePath); if (!file.open(QIODevice::ReadOnly)) { @@ -156,7 +157,7 @@ void VisualDatabaseDisplayFilterSaveLoadWidget::refreshFilterList() fileListWidget->clearLayout(); fileButtons.clear(); - QDir dir(SettingsCache::instance().getFiltersPath()); + QDir dir(SettingsCache::instance().paths().getFiltersPath()); allFilterFiles = dir.entryList({"*.json"}, QDir::Files, QDir::Name); applySearchFilter(searchInput->text()); 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..a59a068dd 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,5 +1,6 @@ #include "visual_database_display_filter_toolbar_widget.h" +#include "../../pixel_map_generator.h" #include "../deck_editor/card_database_view.h" #include "visual_database_display_widget.h" @@ -60,39 +61,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_set_filter_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_set_filter_widget.cpp index b72116461..358c3ed88 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 @@ -10,6 +10,7 @@ #include #include #include +#include VisualDatabaseDisplayRecentSetFilterSettingsWidget::VisualDatabaseDisplayRecentSetFilterSettingsWidget(QWidget *parent) : QWidget(parent) @@ -21,17 +22,19 @@ VisualDatabaseDisplayRecentSetFilterSettingsWidget::VisualDatabaseDisplayRecentS filterToMostRecentSetsCheckBox = new QCheckBox(this); filterToMostRecentSetsCheckBox->setChecked( - SettingsCache::instance().getVisualDatabaseDisplayFilterToMostRecentSetsEnabled()); - connect(filterToMostRecentSetsCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setVisualDatabaseDisplayFilterToMostRecentSetsEnabled); + SettingsCache::instance().visualDeckStorage().getVisualDatabaseDisplayFilterToMostRecentSetsEnabled()); + connect(filterToMostRecentSetsCheckBox, &QCheckBox::QT_STATE_CHANGED, + &SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::setVisualDatabaseDisplayFilterToMostRecentSetsEnabled); filterToMostRecentSetsAmount = new QSpinBox(this); filterToMostRecentSetsAmount->setMinimum(1); filterToMostRecentSetsAmount->setMaximum(100); filterToMostRecentSetsAmount->setValue( - SettingsCache::instance().getVisualDatabaseDisplayFilterToMostRecentSetsAmount()); - connect(filterToMostRecentSetsAmount, qOverload(&QSpinBox::valueChanged), &SettingsCache::instance(), - &SettingsCache::setVisualDatabaseDisplayFilterToMostRecentSetsAmount); + SettingsCache::instance().visualDeckStorage().getVisualDatabaseDisplayFilterToMostRecentSetsAmount()); + connect(filterToMostRecentSetsAmount, qOverload(&QSpinBox::valueChanged), + &SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::setVisualDatabaseDisplayFilterToMostRecentSetsAmount); layout->addWidget(filterToMostRecentSetsCheckBox); layout->addWidget(filterToMostRecentSetsAmount); @@ -66,9 +69,11 @@ VisualDatabaseDisplaySetFilterWidget::VisualDatabaseDisplaySetFilterWidget(QWidg recentSetsSettingsWidget = new VisualDatabaseDisplayRecentSetFilterSettingsWidget(this); layout->addWidget(recentSetsSettingsWidget); - connect(&SettingsCache::instance(), &SettingsCache::visualDatabaseDisplayFilterToMostRecentSetsEnabledChanged, this, + connect(&SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::visualDatabaseDisplayFilterToMostRecentSetsEnabledChanged, this, &VisualDatabaseDisplaySetFilterWidget::filterToRecentSets); - connect(&SettingsCache::instance(), &SettingsCache::visualDatabaseDisplayFilterToMostRecentSetsAmountChanged, this, + connect(&SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::visualDatabaseDisplayFilterToMostRecentSetsAmountChanged, this, &VisualDatabaseDisplaySetFilterWidget::filterToRecentSets); // Create the toggle button for Exact Match/Includes mode @@ -118,7 +123,7 @@ void VisualDatabaseDisplaySetFilterWidget::createSetButtons() void VisualDatabaseDisplaySetFilterWidget::filterToRecentSets() { - if (SettingsCache::instance().getVisualDatabaseDisplayFilterToMostRecentSetsEnabled()) { + if (SettingsCache::instance().visualDeckStorage().getVisualDatabaseDisplayFilterToMostRecentSetsEnabled()) { for (auto set : activeSets.keys()) { activeSets[set] = false; } @@ -129,7 +134,8 @@ void VisualDatabaseDisplaySetFilterWidget::filterToRecentSets() std::sort(allSets.begin(), allSets.end(), [](const auto &a, const auto &b) { return a->getReleaseDate() > b->getReleaseDate(); }); - int setsToPreactivate = SettingsCache::instance().getVisualDatabaseDisplayFilterToMostRecentSetsAmount(); + int setsToPreactivate = + SettingsCache::instance().visualDeckStorage().getVisualDatabaseDisplayFilterToMostRecentSetsAmount(); int setsActivated = 0; for (const auto &set : allSets) { 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 cc4cce496..f4530a12b 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 @@ -16,10 +16,13 @@ #include #include +#include #include #include #include #include +#include +#include #include VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, @@ -51,9 +54,10 @@ VisualDatabaseDisplayWidget::VisualDatabaseDisplayWidget(QWidget *parent, mainLayout->setContentsMargins(0, 0, 0, 0); flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarPolicy::ScrollBarAsNeeded); - cardSizeWidget = new CardSizeWidget(this, flowWidget, SettingsCache::instance().getVisualDatabaseDisplayCardSize()); - connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance(), - &SettingsCache::setVisualDatabaseDisplayCardSize); + cardSizeWidget = new CardSizeWidget(this, flowWidget, + SettingsCache::instance().cardsDisplay().getVisualDatabaseDisplayCardSize()); + connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setVisualDatabaseDisplayCardSize); searchContainer = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAlwaysOff); @@ -62,7 +66,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); @@ -86,6 +90,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()); @@ -104,7 +121,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); @@ -138,9 +155,6 @@ void VisualDatabaseDisplayWidget::initialize() { databaseLoadIndicator->setVisible(false); - filterContainer->initialize(); - filterContainer->setVisible(true); - searchContainer->addWidget(colorFilterWidget); searchContainer->addWidget(clearFilterWidget); searchContainer->addWidget(searchEdit); @@ -156,17 +170,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() @@ -290,9 +335,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 a383e8ead..d161ce362 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 @@ -22,6 +22,7 @@ #include #include #include +#include #include #include #include @@ -46,6 +47,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; @@ -106,6 +113,7 @@ private: VisualDatabaseDisplayFilterToolbarWidget *filterContainer; CardDatabaseDisplayModel *databaseDisplayModel; CardDatabaseView *databaseView; + std::function newZoneCreator; QList *cards; QVBoxLayout *mainLayout; QScrollArea *scrollArea; @@ -115,17 +123,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 5364fa813..bb5b333d9 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 @@ -8,6 +8,7 @@ #include #include #include +#include FilterDisplayWidget::FilterDisplayWidget(QWidget *parent, const QString &filename, FilterTreeModel *_filterModel) : QWidget(parent), filterFilename(filename), filterModel(_filterModel) @@ -68,7 +69,7 @@ void FilterDisplayWidget::deleteFilter() QMessageBox::Yes | QMessageBox::No); if (reply == QMessageBox::Yes) { // If confirmed, delete the filter - QString filePath = SettingsCache::instance().getFiltersPath() + QDir::separator() + filterFilename; + QString filePath = SettingsCache::instance().paths().getFiltersPath() + QDir::separator() + filterFilename; QFile file(filePath); if (file.remove()) { emit filterDeleted(filterFilename); // Emit signal for deletion 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 f44c9c3ef..28abe480e 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,5 +1,6 @@ #include "visual_deck_display_options_widget.h" +#include "../../pixel_map_generator.h" #include "../tabs/visual_deck_editor/tab_deck_editor_visual.h" #include @@ -47,7 +48,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 +93,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 +118,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_editor_sample_hand_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_sample_hand_widget.cpp index 8417ffa34..a4268563a 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 @@ -8,6 +8,7 @@ #include #include +#include #include VisualDeckEditorSampleHandWidget::VisualDeckEditorSampleHandWidget(QWidget *parent, @@ -33,10 +34,10 @@ VisualDeckEditorSampleHandWidget::VisualDeckEditorSampleHandWidget(QWidget *pare resetAndHandSizeLayout->addWidget(resetButton); handSizeSpinBox = new QSpinBox(this); - handSizeSpinBox->setValue(SettingsCache::instance().getVisualDeckEditorSampleHandSize()); + handSizeSpinBox->setValue(SettingsCache::instance().cardsDisplay().getSampleHandSize()); handSizeSpinBox->setMinimum(1); - connect(handSizeSpinBox, qOverload(&QSpinBox::valueChanged), &SettingsCache::instance(), - &SettingsCache::setVisualDeckEditorSampleHandSize); + connect(handSizeSpinBox, qOverload(&QSpinBox::valueChanged), &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setSampleHandSize); connect(handSizeSpinBox, qOverload(&QSpinBox::valueChanged), this, &VisualDeckEditorSampleHandWidget::updateDisplay); resetAndHandSizeLayout->addWidget(handSizeSpinBox); 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 815892f4c..064fbed5d 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 @@ -4,12 +4,14 @@ #include "../../../main.h" #include "../../deck_loader/deck_loader.h" #include "../../layouts/overlap_layout.h" +#include "../../pixel_map_generator.h" #include "../cards/card_info_picture_with_text_overlay_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 "visual_deck_display_options_widget.h" #include @@ -21,16 +23,17 @@ #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); @@ -43,9 +46,10 @@ VisualDeckEditorWidget::VisualDeckEditorWidget(QWidget *parent, initializeScrollAreaAndZoneContainer(); - cardSizeWidget = new CardSizeWidget(this, nullptr, SettingsCache::instance().getVisualDeckEditorCardSize()); - connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance(), - &SettingsCache::setVisualDeckEditorCardSize); + cardSizeWidget = + new CardSizeWidget(this, nullptr, SettingsCache::instance().cardsDisplay().getVisualDeckEditorCardSize()); + connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setVisualDeckEditorCardSize); mainLayout->addWidget(displayOptionsAndSearch); mainLayout->addWidget(scrollArea); @@ -93,35 +97,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) { @@ -144,7 +132,7 @@ void VisualDeckEditorWidget::initializeSearchBarAndCompleter() // Search button functionality searchPushButton = new CompactPushButton(searchContainer); - searchPushButton->setButtonIcon(QPixmap("theme:icons/search")); + searchPushButton->setButtonIcon(themePixmap(QStringLiteral("icons/search"))); connect(searchPushButton, &QPushButton::clicked, this, [=, this]() { ExactCard card = CardDatabaseManager::query()->getCard({searchBar->text()}); if (card) { 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 da02b5c1f..ac0d07efd 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 @@ -38,7 +38,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(); 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 575632724..fd529ff69 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,9 +1,9 @@ #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_widget.h" -#include +#include DeckPreviewColorIdentityFilterWidget::DeckPreviewColorIdentityFilterWidget(VisualDeckStorageWidget *parent) : QWidget(parent), layout(new QHBoxLayout(this)) @@ -28,15 +28,10 @@ DeckPreviewColorIdentityFilterWidget::DeckPreviewColorIdentityFilterWidget(Visua } toggleButton = new QPushButton(this); - toggleButton->setCheckable(true); layout->addWidget(toggleButton); - // Connect the button's toggled signal - connect(toggleButton, &QPushButton::toggled, this, &DeckPreviewColorIdentityFilterWidget::updateFilterMode); - connect(this, &DeckPreviewColorIdentityFilterWidget::activeColorsChanged, parent, - &VisualDeckStorageWidget::updateColorFilter); - connect(this, &DeckPreviewColorIdentityFilterWidget::filterModeChanged, parent, - &VisualDeckStorageWidget::updateColorFilter); + // Connect the button's clicked signal + connect(toggleButton, &QPushButton::clicked, this, &DeckPreviewColorIdentityFilterWidget::updateFilterMode); // Call retranslateUi to set the initial text retranslateUi(); @@ -45,76 +40,55 @@ DeckPreviewColorIdentityFilterWidget::DeckPreviewColorIdentityFilterWidget(Visua void DeckPreviewColorIdentityFilterWidget::retranslateUi() { // Set the toggle button text based on the current mode - toggleButton->setText(exactMatchMode ? tr("Mode: Exact Match") : tr("Mode: Includes")); + switch (filterMode) { + case VisualDeckStorageSortFilterProxyModel::FilterMode::ExactMatch: + toggleButton->setText(tr("Mode: Exact Match")); + break; + case VisualDeckStorageSortFilterProxyModel::FilterMode::Includes: + toggleButton->setText(tr("Mode: Includes")); + break; + 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; emit activeColorsChanged(); } -void DeckPreviewColorIdentityFilterWidget::updateFilterMode(bool checked) +void DeckPreviewColorIdentityFilterWidget::updateFilterMode() { - exactMatchMode = checked; // Toggle between modes - retranslateUi(); // Update the button text - emit filterModeChanged(exactMatchMode); -} - -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; + // Cycle through the modes + switch (filterMode) { + case VisualDeckStorageSortFilterProxyModel::FilterMode::ExactMatch: + filterMode = VisualDeckStorageSortFilterProxyModel::FilterMode::Includes; + break; + case VisualDeckStorageSortFilterProxyModel::FilterMode::Includes: + filterMode = VisualDeckStorageSortFilterProxyModel::FilterMode::Excludes; + break; + case VisualDeckStorageSortFilterProxyModel::FilterMode::Excludes: + filterMode = VisualDeckStorageSortFilterProxyModel::FilterMode::ExactMatch; break; - } } - // If no colors are active, return the unfiltered list of widgets - if (noColorsActive) { - for (DeckPreviewWidget *previewWidget : widgets) { - previewWidget->filteredByColor = false; - } - } - - for (const auto &widget : widgets) { - QString colorIdentity = widget->getColorIdentity(); - - bool matchesFilter = true; - if (exactMatchMode) { - // 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; - } - } else { - // 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; - } - } - } - - widget->filteredByColor = !matchesFilter; - } + retranslateUi(); // Update the button text + emit filterModeChanged(filterMode); } 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 0207e2ee2..def45de66 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,18 +2,18 @@ * @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 -class DeckPreviewWidget; class VisualDeckStorageWidget; class DeckPreviewColorIdentityFilterWidget : public QWidget @@ -23,21 +23,41 @@ class DeckPreviewColorIdentityFilterWidget : public QWidget public: explicit DeckPreviewColorIdentityFilterWidget(VisualDeckStorageWidget *parent); 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(bool exactMatchMode); + /** + * 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(bool checked); + void updateFilterMode(); private: QHBoxLayout *layout; QPushButton *toggleButton; QMap activeColors; - bool exactMatchMode = false; // 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 8cd1004de..41d87ccb1 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,17 +1,15 @@ #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_loader.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 #include -#include +#include DeckPreviewDeckTagsDisplayWidget::DeckPreviewDeckTagsDisplayWidget(QWidget *_parent, const QStringList &_tags) : QWidget(_parent), currentTags(_tags) @@ -52,6 +50,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 */ @@ -74,7 +82,7 @@ static QStringList getAllFiles(const QString &filePath) */ static QStringList findAllKnownTags() { - QStringList allFiles = getAllFiles(SettingsCache::instance().getDeckPath()); + QStringList allFiles = getAllFiles(SettingsCache::instance().paths().getDeckPath()); QStringList knownTags; for (const QString &file : allFiles) { @@ -90,92 +98,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().getVisualDeckStoragePromptForConversion()) { - if (!SettingsCache::instance().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().setVisualDeckStoragePromptForConversion(!conversionDialog.dontAskAgain()); - SettingsCache::instance().setVisualDeckStorageAlwaysConvert(false); - - return false; - } - - // Try to convert file - if (!confirmOverwriteIfExists(this, deckPreviewWidget->filePath)) { - return false; - } - - convertFileToCockatriceFormat(deckPreviewWidget); - - if (conversionDialog.dontAskAgain()) { - SettingsCache::instance().setVisualDeckStoragePromptForConversion(false); - SettingsCache::instance().setVisualDeckStorageAlwaysConvert(true); - } - - return true; + const QStringList knownTags = knownTagsProvider ? knownTagsProvider() : findAllKnownTags(); + execTagDialog(knownTags); } void DeckPreviewDeckTagsDisplayWidget::execTagDialog(const QStringList &knownTags) @@ -188,4 +117,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..64bd5aa1a 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,53 @@ * @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 + +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_dialog.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_tag_dialog.cpp index e8a399fe5..f27d11d36 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 @@ -11,6 +11,7 @@ #include #include #include +#include DeckPreviewTagDialog::DeckPreviewTagDialog(const QStringList &knownTags, const QStringList &_activeTags, @@ -19,7 +20,7 @@ DeckPreviewTagDialog::DeckPreviewTagDialog(const QStringList &knownTags, { resize(400, 500); - QStringList defaultTags = SettingsCache::instance().getVisualDeckStorageDefaultTagsList(); + QStringList defaultTags = SettingsCache::instance().visualDeckStorage().getVisualDeckStorageDefaultTagsList(); // Merge knownTags with defaultTags, ensuring no duplicates QStringList combinedTags = defaultTags + knownTags + activeTags; @@ -90,7 +91,8 @@ DeckPreviewTagDialog::DeckPreviewTagDialog(const QStringList &knownTags, connect(okButton, &QPushButton::clicked, this, &DeckPreviewTagDialog::accept); connect(cancelButton, &QPushButton::clicked, this, &DeckPreviewTagDialog::reject); - connect(&SettingsCache::instance(), &SettingsCache::visualDeckStorageDefaultTagsListChanged, this, + connect(&SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::visualDeckStorageDefaultTagsListChanged, this, &DeckPreviewTagDialog::refreshTagList); retranslateUi(); @@ -114,7 +116,7 @@ void DeckPreviewTagDialog::refreshTagList() tagListView->clear(); // Get the updated list of tags from SettingsCache - QStringList defaultTags = SettingsCache::instance().getVisualDeckStorageDefaultTagsList(); + QStringList defaultTags = SettingsCache::instance().visualDeckStorage().getVisualDeckStorageDefaultTagsList(); QStringList combinedTags = defaultTags + knownTags_ + activeTags; combinedTags.removeDuplicates(); 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 06dc9dd84..876fbf6ad 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,57 +1,104 @@ #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 "../../cards/additional_info/color_identity_widget.h" #include "../../cards/deck_preview_card_picture_widget.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 -#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), visualDeckStorageWidget(_visualDeckStorageWidget), model(_model), filePath(_filePath) { 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::imageDoubleClicked, this, &DeckPreviewWidget::imageDoubleClickedEvent); + bannerCardDisplayWidget = pictureWidget; - connect(&SettingsCache::instance(), &SettingsCache::visualDeckStorageShowColorIdentityChanged, this, + 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, &DeckPreviewWidget::updateColorIdentityVisibility); - connect(&SettingsCache::instance(), &SettingsCache::visualDeckStorageShowTagsOnDeckPreviewsChanged, this, + connect(&SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::visualDeckStorageShowTagsOnDeckPreviewsChanged, this, &DeckPreviewWidget::updateTagsVisibility); - connect(&SettingsCache::instance(), &SettingsCache::visualDeckStorageShowBannerCardComboBoxChanged, this, + connect(&SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::visualDeckStorageShowBannerCardComboBoxChanged, this, &DeckPreviewWidget::updateBannerCardComboBoxVisibility); connect(visualDeckStorageWidget->settings(), &VisualDeckStorageQuickSettingsWidget::deckPreviewTooltipChanged, this, &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}; } void DeckPreviewWidget::retranslateUi() @@ -65,95 +112,44 @@ 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); } } -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) void DeckPreviewWidget::enterEvent(QEnterEvent *event) -#else -void DeckPreviewWidget::enterEvent(QEvent *event) -#endif { 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().getVisualDeckStorageShowColorIdentity()); - updateBannerCardComboBoxVisibility(SettingsCache::instance().getVisualDeckStorageShowBannerCardComboBox()); - updateTagsVisibility(SettingsCache::instance().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; } /** @@ -161,33 +157,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) @@ -227,51 +226,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. @@ -308,11 +262,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())); + } } } @@ -325,16 +283,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); @@ -342,7 +300,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 { @@ -360,8 +320,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)); } @@ -383,17 +346,24 @@ void DeckPreviewWidget::imageDoubleClickedEvent(QMouseEvent *event, DeckPreviewC 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("Edit Tags")), &QAction::triggered, deckTagsDisplayWidget, &DeckPreviewDeckTagsDisplayWidget::openTagEditDlg); @@ -406,14 +376,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(); @@ -448,57 +430,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); @@ -507,11 +490,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 0ed64e9e2..7bb69f9b9 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,16 +2,12 @@ * @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 "../visual_deck_storage_model.h" #include #include @@ -20,76 +16,82 @@ #include #include +class QEnterEvent; +class QLabel; class QMenu; -class VisualDeckStorageWidget; +class QMouseEvent; +class ColorIdentityWidget; +class DeckPreviewCardPictureWidget; class DeckPreviewDeckTagsDisplayWidget; +class VisualDeckStorageModel; +class VisualDeckStorageWidget; 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; - 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; + /** + * @brief The banner card picture; the parent widget wires its size to the card size setting. + */ + DeckPreviewCardPictureWidget *bannerCardDisplayWidget; signals: void deckLoadRequested(const QString &filePath); void openDeckEditor(const LoadedDeck &deck); 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: -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) - void enterEvent(QEnterEvent *event) override; // Qt6 signature -#else - void enterEvent(QEvent *event) override; // Qt5 signature -#endif + void enterEvent(QEnterEvent *event) override; + void resizeEvent(QResizeEvent *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 imageDoubleClickedEvent(QMouseEvent *event, DeckPreviewCardPictureWidget *instance); void actRenameDeck(); void actRenameFile(); void actDeleteFile(); + + VisualDeckStorageWidget *visualDeckStorageWidget; + VisualDeckStorageModel *model; + QString filePath; + 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. }; class NoScrollFilter : public QObject 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 fdb44dff4..22d73b604 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,19 +1,27 @@ #include "visual_deck_storage_folder_display_widget.h" -#include "../../../client/settings/cache_settings.h" +#include "../cards/card_info_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 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); @@ -21,6 +29,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); @@ -34,192 +45,283 @@ VisualDeckStorageFolderDisplayWidget::VisualDeckStorageFolderDisplayWidget( flowWidget = new FlowWidget(this, Qt::Horizontal, Qt::ScrollBarAlwaysOff, Qt::ScrollBarAlwaysOff); containerLayout->addWidget(flowWidget); - createWidgetsForFiles(); - createWidgetsForFolders(); + 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); - refreshUi(); + 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().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) { + ++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(); } /** - * Updates the visibility of this folder and all its DeckPreviewWidgets + * @brief Creates a deck preview widget and wires it up to the storage widget. * - * @param recursive Also update the visibility of all subfolders and their DeckPreviewWidgets. + * @param filePath The absolute path of the deck file to preview. */ -void VisualDeckStorageFolderDisplayWidget::updateVisibility(bool recursive) +DeckPreviewWidget *VisualDeckStorageFolderDisplayWidget::createDeckPreviewWidget(const QString &filePath) { - 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); - } + auto *deckPreviewWidget = + new DeckPreviewWidget(flowWidget, visualDeckStorageWidget, visualDeckStorageWidget->model(), filePath); + connect(deckPreviewWidget, &DeckPreviewWidget::deckLoadRequested, visualDeckStorageWidget, + &VisualDeckStorageWidget::deckLoadRequested); + connect(deckPreviewWidget, &DeckPreviewWidget::openDeckEditor, visualDeckStorageWidget, + &VisualDeckStorageWidget::openDeckEditor); + connect(visualDeckStorageWidget->settings(), &VisualDeckStorageQuickSettingsWidget::cardSizeChanged, + deckPreviewWidget->bannerCardDisplayWidget, &CardInfoPictureWidget::setScaleFactor); + deckPreviewWidget->bannerCardDisplayWidget->setScaleFactor(visualDeckStorageWidget->settings()->getCardSize()); + deckWidgets.insert(filePath, deckPreviewWidget); + return deckPreviewWidget; } -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() +/** + * @brief Creates, removes and keeps in sync the subfolder widgets of this folder. + * + * Only direct child folders are created here; each child manages its own + * children, mirroring the folder tree on disk. + */ +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..257ce1778 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,105 @@ /** * @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 +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); +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..5d0006539 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.cpp @@ -0,0 +1,595 @@ +#include "visual_deck_storage_model.h" + +#include "../../deck_loader/deck_loader.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 + +static QString computeColorIdentity(const LoadedDeck &deck); + +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 = computeColorIdentity(*deck); + 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 Computes the color identity of a deck in WUBRG order. + */ +static QString computeColorIdentity(const LoadedDeck &deck) +{ + QStringList cardList = deck.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) { + const QString colors = currentCard->getColors(); // 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; +} + +/** + * @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 = computeColorIdentity(data.deck); + } +} + +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..330205356 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_model.h @@ -0,0 +1,188 @@ +/** + * @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 +#include +#include +#include +#include +#include +#include + +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 a0e8512ac..478431703 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,63 +1,74 @@ #include "visual_deck_storage_quick_settings_widget.h" #include "../../../client/settings/cache_settings.h" +#include "../cards/card_size_widget.h" #include "visual_deck_storage_widget.h" #include #include #include +#include +#include +#include VisualDeckStorageQuickSettingsWidget::VisualDeckStorageQuickSettingsWidget(QWidget *parent) : SettingsButtonWidget(parent) { // show folders checkbox showFoldersCheckBox = new QCheckBox(this); - showFoldersCheckBox->setChecked(SettingsCache::instance().getVisualDeckStorageShowFolders()); + showFoldersCheckBox->setChecked(SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowFolders()); connect(showFoldersCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &VisualDeckStorageQuickSettingsWidget::showFoldersChanged); - connect(showFoldersCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setVisualDeckStorageShowFolders); + connect(showFoldersCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::setVisualDeckStorageShowFolders); // show tag filter widget checkbox showTagFilterCheckBox = new QCheckBox(this); - showTagFilterCheckBox->setChecked(SettingsCache::instance().getVisualDeckStorageShowTagFilter()); + showTagFilterCheckBox->setChecked( + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowTagFilter()); connect(showTagFilterCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &VisualDeckStorageQuickSettingsWidget::showTagFilterChanged); - connect(showTagFilterCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setVisualDeckStorageShowTagFilter); + connect(showTagFilterCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::setVisualDeckStorageShowTagFilter); // show color identity on DeckPreviewWidget checkbox showColorIdentityCheckBox = new QCheckBox(this); - showColorIdentityCheckBox->setChecked(SettingsCache::instance().getVisualDeckStorageShowColorIdentity()); + showColorIdentityCheckBox->setChecked( + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowColorIdentity()); connect(showColorIdentityCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &VisualDeckStorageQuickSettingsWidget::showColorIdentityChanged); - connect(showColorIdentityCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setVisualDeckStorageShowColorIdentity); + connect(showColorIdentityCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::setVisualDeckStorageShowColorIdentity); // show tags on DeckPreviewWidget checkbox showTagsOnDeckPreviewsCheckBox = new QCheckBox(this); - showTagsOnDeckPreviewsCheckBox->setChecked(SettingsCache::instance().getVisualDeckStorageShowTagsOnDeckPreviews()); + showTagsOnDeckPreviewsCheckBox->setChecked( + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowTagsOnDeckPreviews()); connect(showTagsOnDeckPreviewsCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &VisualDeckStorageQuickSettingsWidget::showTagsOnDeckPreviewsChanged); - connect(showTagsOnDeckPreviewsCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setVisualDeckStorageShowTagsOnDeckPreviews); + connect(showTagsOnDeckPreviewsCheckBox, &QCheckBox::QT_STATE_CHANGED, + &SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::setVisualDeckStorageShowTagsOnDeckPreviews); // show banner card selector checkbox showBannerCardComboBoxCheckBox = new QCheckBox(this); - showBannerCardComboBoxCheckBox->setChecked(SettingsCache::instance().getVisualDeckStorageShowBannerCardComboBox()); + showBannerCardComboBoxCheckBox->setChecked( + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowBannerCardComboBox()); connect(showBannerCardComboBoxCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &VisualDeckStorageQuickSettingsWidget::showBannerCardComboBoxChanged); - connect(showBannerCardComboBoxCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setVisualDeckStorageShowBannerCardComboBox); + connect(showBannerCardComboBoxCheckBox, &QCheckBox::QT_STATE_CHANGED, + &SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::setVisualDeckStorageShowBannerCardComboBox); // draw unused color identities checkbox drawUnusedColorIdentitiesCheckBox = new QCheckBox(this); drawUnusedColorIdentitiesCheckBox->setChecked( - SettingsCache::instance().getVisualDeckStorageDrawUnusedColorIdentities()); + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageDrawUnusedColorIdentities()); connect(drawUnusedColorIdentitiesCheckBox, &QCheckBox::QT_STATE_CHANGED, this, &VisualDeckStorageQuickSettingsWidget::drawUnusedColorIdentitiesChanged); - connect(drawUnusedColorIdentitiesCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance(), - &SettingsCache::setVisualDeckStorageDrawUnusedColorIdentities); + connect(drawUnusedColorIdentitiesCheckBox, &QCheckBox::QT_STATE_CHANGED, + &SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::setVisualDeckStorageDrawUnusedColorIdentities); // color identity opacity selector auto unusedColorIdentityOpacityWidget = new QWidget(this); @@ -68,11 +79,12 @@ VisualDeckStorageQuickSettingsWidget::VisualDeckStorageQuickSettingsWidget(QWidg unusedColorIdentitiesOpacitySpinBox->setMinimum(0); unusedColorIdentitiesOpacitySpinBox->setMaximum(100); unusedColorIdentitiesOpacitySpinBox->setValue( - SettingsCache::instance().getVisualDeckStorageUnusedColorIdentitiesOpacity()); + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageUnusedColorIdentitiesOpacity()); connect(unusedColorIdentitiesOpacitySpinBox, qOverload(&QSpinBox::valueChanged), this, &VisualDeckStorageQuickSettingsWidget::unusedColorIdentitiesOpacityChanged); - connect(unusedColorIdentitiesOpacitySpinBox, qOverload(&QSpinBox::valueChanged), &SettingsCache::instance(), - &SettingsCache::setVisualDeckStorageUnusedColorIdentitiesOpacity); + connect(unusedColorIdentitiesOpacitySpinBox, qOverload(&QSpinBox::valueChanged), + &SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::setVisualDeckStorageUnusedColorIdentitiesOpacity); unusedColorIdentitiesOpacityLabel->setBuddy(unusedColorIdentitiesOpacitySpinBox); @@ -90,11 +102,13 @@ VisualDeckStorageQuickSettingsWidget::VisualDeckStorageQuickSettingsWidget(QWidg deckPreviewTooltipComboBox->addItem("", TooltipType::None); deckPreviewTooltipComboBox->addItem("", TooltipType::Filepath); - deckPreviewTooltipComboBox->setCurrentIndex(SettingsCache::instance().getVisualDeckStorageTooltipType()); + deckPreviewTooltipComboBox->setCurrentIndex( + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageTooltipType()); connect(deckPreviewTooltipComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, [this] { emit deckPreviewTooltipChanged(getDeckPreviewTooltip()); }); - connect(deckPreviewTooltipComboBox, QOverload::of(&QComboBox::currentIndexChanged), &SettingsCache::instance(), - &SettingsCache::setVisualDeckStorageTooltipType); + connect(deckPreviewTooltipComboBox, QOverload::of(&QComboBox::currentIndexChanged), + &SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::setVisualDeckStorageTooltipType); auto deckPreviewTooltipLayout = new QHBoxLayout(deckPreviewTooltipWidget); deckPreviewTooltipLayout->setContentsMargins(11, 0, 11, 0); @@ -102,11 +116,12 @@ VisualDeckStorageQuickSettingsWidget::VisualDeckStorageQuickSettingsWidget(QWidg deckPreviewTooltipLayout->addWidget(deckPreviewTooltipComboBox); // card size slider - cardSizeWidget = new CardSizeWidget(this, nullptr, SettingsCache::instance().getVisualDeckStorageCardSize()); + cardSizeWidget = + new CardSizeWidget(this, nullptr, SettingsCache::instance().cardsDisplay().getVisualDeckStorageCardSize()); connect(cardSizeWidget->getSlider(), &QSlider::valueChanged, this, &VisualDeckStorageQuickSettingsWidget::cardSizeChanged); - connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance(), - &SettingsCache::setVisualDeckStorageCardSize); + connect(cardSizeWidget, &CardSizeWidget::cardSizeSettingUpdated, &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setVisualDeckStorageCardSize); // putting everything together this->addSettingsWidget(showFoldersCheckBox); @@ -119,7 +134,7 @@ VisualDeckStorageQuickSettingsWidget::VisualDeckStorageQuickSettingsWidget(QWidg this->addSettingsWidget(deckPreviewTooltipWidget); this->addSettingsWidget(cardSizeWidget); - connect(&SettingsCache::instance(), &SettingsCache::langChanged, this, + connect(&SettingsCache::instance().personal(), &PersonalSettings::langChanged, this, &VisualDeckStorageQuickSettingsWidget::retranslateUi); retranslateUi(); } 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 70b438232..406527893 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,22 +1,20 @@ #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 /** - * @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); @@ -27,7 +25,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); @@ -39,43 +37,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().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..7769ea911 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,32 @@ * @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 -class VisualDeckStorageWidget; +class QTimer; + 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..c05da1cb3 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.cpp @@ -0,0 +1,292 @@ +#include "visual_deck_storage_sort_filter_proxy_model.h" + +#include "../../filters/deck_filter_string.h" + +#include +#include + +VisualDeckStorageSortFilterProxyModel::VisualDeckStorageSortFilterProxyModel(QObject *parent) + : QSortFilterProxyModel(parent) +{ + setDynamicSortFilter(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; + } + + DeckFilterString filterString(searchText); + 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; + + bool matches = true; + switch (colorFilterMode) { + case ExactMatch: { + QSet activeColorSet; + for (const QChar &color : activeColors) { + activeColorSet.insert(color.toUpper()); + } + + QSet colorIdentitySet; + for (const QChar &color : colorIdentity) { + colorIdentitySet.insert(color.toUpper()); + } + + matches = activeColorSet == colorIdentitySet; + break; + } + case Includes: + matches = std::all_of(activeColors.begin(), activeColors.end(), + [&colorIdentity](const QChar &color) { return colorIdentity.contains(color); }); + break; + case Excludes: + matches = std::none_of(activeColors.begin(), activeColors.end(), + [&colorIdentity](const QChar &color) { return colorIdentity.contains(color); }); + break; + } + + colorMatches[row] = matches; + } +} + +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..7e771f6a9 --- /dev/null +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_sort_filter_proxy_model.h @@ -0,0 +1,105 @@ +/** + * @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 "visual_deck_storage_model.h" + +#include +#include +#include + +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. +}; + +#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 0eb45cdcc..6289a4941 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,19 +1,11 @@ #include "visual_deck_storage_sort_widget.h" #include "../../../client/settings/cache_settings.h" +#include "visual_deck_storage_widget.h" -#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); @@ -28,13 +20,11 @@ VisualDeckStorageSortWidget::VisualDeckStorageSortWidget(VisualDeckStorageWidget retranslateUi(); // Set the current sort order - sortComboBox->setCurrentIndex(SettingsCache::instance().getVisualDeckStorageSortingOrder()); - sortOrder = static_cast(sortComboBox->currentIndex()); + sortComboBox->setCurrentIndex(SettingsCache::instance().visualDeckStorage().getVisualDeckStorageSortingOrder()); - // 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() @@ -46,10 +36,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); @@ -58,60 +51,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().setVisualDeckStorageSortingOrder(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..633924d84 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,17 @@ * @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 class VisualDeckStorageWidget; -class VisualDeckStorageFolderDisplayWidget; class VisualDeckStorageSortWidget : public QWidget { Q_OBJECT @@ -22,25 +20,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..ba52cf8e9 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,6 +2,8 @@ #include "../general/layout_containers/flow_widget.h" #include "deck_preview/deck_preview_tag_display_widget.h" +#include "visual_deck_storage_model.h" +#include "visual_deck_storage_sort_filter_proxy_model.h" #include "visual_deck_storage_widget.h" #include @@ -18,7 +20,7 @@ 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); } @@ -29,45 +31,26 @@ void VisualDeckStorageTagFilterWidget::showEvent(QShowEvent *event) refreshTags(); } -void VisualDeckStorageTagFilterWidget::filterDecksBySelectedTags(const QList &deckPreviews) const +/** + * @brief The tags of all decks currently accepted by the proxy model. + */ +QSet VisualDeckStorageTagFilterWidget::gatherAllTags() const { - QStringList selectedTags; - QStringList excludedTags; + QSet allTags; + auto *proxy = parent->proxyModel(); - // 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; + for (int proxyRow = 0; proxyRow < proxy->rowCount(); ++proxyRow) { + const QModelIndex index = proxy->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); } } - // 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); - } + return allTags; } void VisualDeckStorageTagFilterWidget::refreshTags() @@ -80,8 +63,6 @@ void VisualDeckStorageTagFilterWidget::refreshTags() void VisualDeckStorageTagFilterWidget::removeTagsNotInList(const QSet &tags) { - auto *flowWidget = findChild(); - for (DeckPreviewTagDisplayWidget *tagWidget : findChildren()) { const QString &tagName = tagWidget->getTagName(); @@ -116,20 +97,12 @@ void VisualDeckStorageTagFilterWidget::addTagIfNotPresent(const QString &tag) 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) { - return; - } - // Get all tag widgets QList tagWidgets = findChildren(); @@ -147,19 +120,26 @@ void VisualDeckStorageTagFilterWidget::sortTags() } } -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..337c053c7 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,21 +2,22 @@ * @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 +class FlowWidget; class VisualDeckStorageWidget; class VisualDeckStorageTagFilterWidget : public QWidget { Q_OBJECT VisualDeckStorageWidget *parent; + FlowWidget *flowWidget; [[nodiscard]] QSet gatherAllTags() const; void removeTagsNotInList(const QSet &tags); @@ -27,9 +28,21 @@ class VisualDeckStorageTagFilterWidget : public QWidget public: explicit VisualDeckStorageTagFilterWidget(VisualDeckStorageWidget *_parent); [[nodiscard]] QStringList getAllKnownTags() const; - void filterDecksBySelectedTags(const QList &deckPreviews) const; + + /** + * @brief The tags currently in "selected" state. + */ + [[nodiscard]] QStringList selectedTags() const; + + /** + * @brief The tags currently in "excluded" state. + */ + [[nodiscard]] QStringList excludedTags() const; public slots: + /** + * @brief Rebuilds the tag chips from the tags of the currently visible decks. + */ 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 bfc425993..da7ddc368 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,23 +1,31 @@ #include "visual_deck_storage_widget.h" #include "../../../client/settings/cache_settings.h" +#include "../../pixel_map_generator.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 "visual_deck_storage_folder_display_widget.h" +#include "visual_deck_storage_quick_settings_widget.h" #include "visual_deck_storage_search_widget.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 -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); @@ -36,7 +44,7 @@ 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); @@ -54,10 +62,12 @@ VisualDeckStorageWidget::VisualDeckStorageWidget(QWidget *parent) : QWidget(pare // tag filter box tagFilterWidget = new VisualDeckStorageTagFilterWidget(this); - updateTagsVisibility(SettingsCache::instance().getVisualDeckStorageShowTagFilter()); + updateTagsVisibility(SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowTagFilter()); - deckPreviewSelectionAnimationEnabled = SettingsCache::instance().getVisualDeckStorageSelectionAnimation(); - connect(&SettingsCache::instance(), &SettingsCache::visualDeckStorageSelectionAnimationChanged, this, + deckPreviewSelectionAnimationEnabled = + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageSelectionAnimation(); + connect(&SettingsCache::instance().visualDeckStorage(), + &VisualDeckStorageSettings::visualDeckStorageSelectionAnimationChanged, this, &VisualDeckStorageWidget::updateSelectionAnimationEnabled); // deck area @@ -71,6 +81,33 @@ 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); + connect(CardDatabaseManager::getInstance(), &CardDatabase::cardDatabaseLoadingFinished, this, &VisualDeckStorageWidget::createRootFolderWidget); @@ -119,6 +156,8 @@ void VisualDeckStorageWidget::retranslateUi() refreshButton->setToolTip(tr("Refresh loaded files")); quickSettingsWidget->setToolTip(tr("Visual Deck Storage Settings")); + + sortWidget->retranslateUi(); } /** @@ -130,72 +169,69 @@ 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().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(); } +/** + * 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) @@ -211,4 +247,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 c3c0ae91b..fe6389414 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,29 +2,30 @@ * @file visual_deck_storage_widget.h * @ingroup VisualDeckStorageWidgets */ -//! \todo Document this file. #ifndef VISUAL_DECK_STORAGE_WIDGET_H #define VISUAL_DECK_STORAGE_WIDGET_H -#include "../../deck_loader/deck_loader.h" -#include "../cards/card_size_widget.h" -#include "../quick_settings/settings_button_widget.h" -#include "deck_preview/deck_preview_color_identity_filter_widget.h" -#include "visual_deck_storage_folder_display_widget.h" -#include "visual_deck_storage_quick_settings_widget.h" -#include "visual_deck_storage_search_widget.h" -#include "visual_deck_storage_sort_widget.h" -#include "visual_deck_storage_tag_filter_widget.h" +#include "visual_deck_storage_model.h" +#include "visual_deck_storage_sort_filter_proxy_model.h" -#include +#include +#include +#include +#include +#include -class QSpinBox; +class QLabel; +class QResizeEvent; +class QShowEvent; +class QTimer; +class DeckPreviewColorIdentityFilterWidget; +class VisualDeckStorageFolderDisplayWidget; +class VisualDeckStorageQuickSettingsWidget; class VisualDeckStorageSearchWidget; class VisualDeckStorageSortWidget; class VisualDeckStorageTagFilterWidget; -class VisualDeckStorageFolderDisplayWidget; -class DeckPreviewColorIdentityFilterWidget; + class VisualDeckStorageWidget final : public QWidget { Q_OBJECT @@ -37,29 +38,43 @@ public: 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); + +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(); private: QVBoxLayout *layout; QWidget *searchAndSortContainer; QHBoxLayout *searchAndSortLayout; - DeckListModel *deckListModel; QLabel *databaseLoadIndicator; VisualDeckStorageSortWidget *sortWidget; VisualDeckStorageSearchWidget *searchWidget; @@ -67,9 +82,10 @@ private: QToolButton *refreshButton; 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. }; #endif // VISUAL_DECK_STORAGE_WIDGET_H diff --git a/cockatrice/src/interface/window_main.cpp b/cockatrice/src/interface/window_main.cpp index 69d3260bc..722df90ef 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -19,9 +19,11 @@ ***************************************************************************/ #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" #include "../interface/widgets/dialogs/dlg_local_game_options.h" #include "../interface/widgets/dialogs/dlg_manage_sets.h" @@ -30,13 +32,24 @@ #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/onboarding/first_run_wizard.h" +#include "../interface/widgets/settings_page/general_settings_page.h" #include "../interface/widgets/tabs/tab_game.h" +#include "../interface/widgets/tabs/tab_server.h" #include "../interface/widgets/tabs/tab_supervisor.h" #include "../main.h" +#include "intents/contexts/context_connect_to_server.h" +#include "intents/contexts/context_join_room.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 "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/tabs/api/commander_spellbook/handle_commander_brackets.h" #include "widgets/utility/get_text_with_max.h" #include @@ -47,6 +60,7 @@ #include #include #include +#include #include #include #include @@ -66,10 +80,21 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #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" @@ -177,7 +202,7 @@ void MainWindow::startLocalGame(const LocalGameOptions &options) void MainWindow::actWatchReplay() { QFileDialog dlg(this, tr("Load replay")); - dlg.setDirectory(SettingsCache::instance().getReplaysPath()); + dlg.setDirectory(SettingsCache::instance().paths().getReplaysPath()); dlg.setNameFilters(QStringList() << QObject::tr("Cockatrice replays (*.cor)")); if (!dlg.exec()) { return; @@ -222,6 +247,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(); } @@ -248,7 +275,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(); } @@ -300,7 +328,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 */ @@ -328,6 +356,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")); @@ -382,12 +411,15 @@ void MainWindow::createActions() connect(aCheckCardUpdatesBackground, &QAction::triggered, this, &MainWindow::actCheckCardUpdatesBackground); aStatusBar = new QAction(this); aStatusBar->setCheckable(true); - aStatusBar->setChecked(SettingsCache::instance().getShowStatusBar()); - connect(aStatusBar, &QAction::triggered, &SettingsCache::instance(), &SettingsCache::setShowStatusBar); + aStatusBar->setChecked(SettingsCache::instance().userInterface().getShowStatusBar()); + connect(aStatusBar, &QAction::triggered, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setShowStatusBar); aViewLog = new QAction(this); 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); @@ -466,17 +498,20 @@ void MainWindow::createMenus() helpMenu->addAction(aStatusBar); helpMenu->addAction(aViewLog); helpMenu->addAction(aOpenSettingsFolder); + helpMenu->addSeparator(); + helpMenu->addAction(aFirstRunWizard); } MainWindow::MainWindow(QWidget *parent) : QMainWindow(parent), localServer(nullptr), bHasActivated(false), askedForDbUpdater(false), cardUpdateProcess(nullptr), logviewDialog(nullptr) { - connect(&SettingsCache::instance(), &SettingsCache::pixmapCacheSizeChanged, this, + connect(&SettingsCache::instance().cacheStorage(), &CacheStorageSettings::pixmapCacheSizeChanged, this, &MainWindow::pixmapCacheSizeChanged); - pixmapCacheSizeChanged(SettingsCache::instance().getPixmapCacheSize()); + pixmapCacheSizeChanged(SettingsCache::instance().cacheStorage().getPixmapCacheSize()); connectionController = new ConnectionController(this, this); + urlParser = new IntentUrlParser(this, this); createActions(); createMenus(); @@ -488,6 +523,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, @@ -508,9 +544,15 @@ MainWindow::MainWindow(QWidget *parent) } // status bar - connect(&SettingsCache::instance(), &SettingsCache::showStatusBarChanged, this, + connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::showStatusBarChanged, this, [this](bool show) { statusBar()->setVisible(show); }); - statusBar()->setVisible(SettingsCache::instance().getShowStatusBar()); + statusBar()->setVisible(SettingsCache::instance().userInterface().getShowStatusBar()); + + latencyStatus = new LatencyStatusWidget(this); + statusBar()->addPermanentWidget(latencyStatus); + + connect(connectionController, &ConnectionController::pingStatsUpdated, latencyStatus, + &LatencyStatusWidget::updateData); connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this, &MainWindow::refreshShortcuts); @@ -527,45 +569,59 @@ MainWindow::MainWindow(QWidget *parent) // run startup check async QTimer::singleShot(0, this, &MainWindow::startupConfigCheck); + QTimer::singleShot(0, this, &MainWindow::applyStartupDestination); } void MainWindow::startupConfigCheck() { + // 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(); + if (SettingsCache::instance().debug().getLocalGameOnStartup()) { LocalGameOptions options; options.numberPlayers = SettingsCache::instance().debug().getLocalGamePlayerCount(); startLocalGame(options); } - if (SettingsCache::instance().getCheckUpdatesOnStartup()) { + if (SettingsCache::instance().updates().getCheckUpdatesOnStartup()) { actCheckClientUpdates(); } - if (SettingsCache::instance().getClientVersion() == CLIENT_INFO_NOT_SET) { + actCheckCommanderBracketDefinitionUpdates(); + + if (SettingsCache::instance().network().getClientVersion() == CLIENT_INFO_NOT_SET) { // 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().setClientVersion(VERSION_STRING); - } else if (SettingsCache::instance().getClientVersion() != VERSION_STRING) { + SettingsCache::instance().network().setClientVersion(VERSION_STRING); + actCheckServerUpdates(); + runFirstRunWizard(); + + if (QString(VERSION_STRING).contains("custom", Qt::CaseInsensitive)) { + SettingsCache::instance().updates().setCheckUpdatesOnStartup(false); + } else if (QString(VERSION_STRING).contains("beta", Qt::CaseInsensitive)) { + SettingsCache::instance().updates().setUpdateReleaseChannelIndex(1); + } + } else if (SettingsCache::instance().network().getClientVersion() != VERSION_STRING) { // config found, from another (presumably older) version qCInfo(WindowMainStartupVersionLog) - << "Startup: old client version" << SettingsCache::instance().getClientVersion() + << "Startup: old client version" << SettingsCache::instance().network().getClientVersion() << "differs, assuming first start after update"; - if (SettingsCache::instance().getNotifyAboutNewVersion()) { + if (SettingsCache::instance().updates().getNotifyAboutNewVersion()) { alertForcedOracleRun(VERSION_STRING, true); - } else { - const auto reloadOk0 = QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); }); } qCInfo(WindowMainStartupShortcutsLog) << "Migrating shortcuts after update detected."; SettingsCache::instance().shortcuts().migrateShortcuts(); - if (SettingsCache::instance().getCheckUpdatesOnStartup()) { + if (SettingsCache::instance().updates().getCheckUpdatesOnStartup()) { if (QString(VERSION_STRING).contains("custom", Qt::CaseInsensitive)) { qCInfo(WindowMainStartupShortcutsLog) << "Update has changed to custom version, disabling auto update"; - SettingsCache::instance().setCheckUpdatesOnStartup(Qt::Unchecked); + SettingsCache::instance().updates().setCheckUpdatesOnStartup(false); } else { int channel = 0; if (QString(VERSION_STRING).contains("beta", Qt::CaseInsensitive)) { @@ -573,18 +629,18 @@ void MainWindow::startupConfigCheck() } if (SettingsCache::instance().getUpdateReleaseChannelIndex() != channel) { qCInfo(WindowMainStartupShortcutsLog) << "Update has changed beta state, updating release channel."; - SettingsCache::instance().setUpdateReleaseChannelIndex(channel); + SettingsCache::instance().updates().setUpdateReleaseChannelIndex(channel); } } } - SettingsCache::instance().setClientVersion(VERSION_STRING); + SettingsCache::instance().network().setClientVersion(VERSION_STRING); } else { // previous config from this version found qCInfo(WindowMainStartupVersionLog) << "Startup: found config with current version"; - if (SettingsCache::instance().getCardUpdateCheckRequired()) { - if (SettingsCache::instance().getStartupCardUpdateCheckPromptForUpdate()) { + if (SettingsCache::instance().updates().getCardUpdateCheckRequired()) { + if (SettingsCache::instance().updates().getStartupCardUpdateCheckPromptForUpdate()) { auto startupCardCheckDialog = new DlgStartupCardCheck(this); if (startupCardCheckDialog->exec() == QDialog::Accepted) { @@ -596,34 +652,125 @@ void MainWindow::startupConfigCheck() actCheckCardUpdatesBackground(); break; case 2: // background + always - SettingsCache::instance().setStartupCardUpdateCheckPromptForUpdate(false); - SettingsCache::instance().setStartupCardUpdateCheckAlwaysUpdate(true); + SettingsCache::instance().updates().setStartupCardUpdateCheckPromptForUpdate(false); + SettingsCache::instance().updates().setStartupCardUpdateCheckAlwaysUpdate(true); actCheckCardUpdatesBackground(); break; case 3: // don't prompt again + don't run - SettingsCache::instance().setStartupCardUpdateCheckPromptForUpdate(false); - SettingsCache::instance().setStartupCardUpdateCheckAlwaysUpdate(false); + SettingsCache::instance().updates().setStartupCardUpdateCheckPromptForUpdate(false); + SettingsCache::instance().updates().setStartupCardUpdateCheckAlwaysUpdate(false); break; default: break; } } - } else if (SettingsCache::instance().getStartupCardUpdateCheckAlwaysUpdate()) { + } else if (SettingsCache::instance().updates().getStartupCardUpdateCheckAlwaysUpdate()) { actCheckCardUpdatesBackground(); } } - const auto reloadOk1 = QtConcurrent::run([] { CardDatabaseManager::getInstance()->loadCardDatabases(); }); - // Run the tips dialog only on subsequent startups. // On the first run after an install/update the startup is already crowded enough - if (tip->successfulInit && SettingsCache::instance().getShowTipsOnStartup() && tip->newTipsAvailable) { + if (tip->successfulInit && SettingsCache::instance().personal().getShowTipsOnStartup() && + tip->newTipsAvailable) { tip->raise(); tip->show(); } } } +void MainWindow::runFirstRunWizard() +{ + 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); + + 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; + } + + 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(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) { @@ -640,6 +787,7 @@ void MainWindow::alertForcedOracleRun(const QString &version, bool isUpdate) actCheckCardUpdates(); actCheckServerUpdates(); + actCheckCommanderBracketDefinitionUpdates(); } MainWindow::~MainWindow() @@ -674,7 +822,7 @@ void MainWindow::createTrayIcon() trayIcon = new QSystemTrayIcon(this); trayIcon->setContextMenu(trayIconMenu); - trayIcon->setIcon(QPixmap("theme:cockatrice")); + trayIcon->setIcon(themePixmap(QStringLiteral("cockatrice"))); trayIcon->show(); } @@ -701,6 +849,17 @@ void MainWindow::closeEvent(QCloseEvent *event) } bClosingDown = true; + if (cardUpdateProcess && cardUpdateProcess->state() != QProcess::NotRunning) { + if (QMessageBox::question(this, tr("Are you sure?"), + tr("A card database update is still running. Quitting now will cancel it.\n" + "Are you sure you want to quit?"), + QMessageBox::Yes | QMessageBox::No, QMessageBox::No) == QMessageBox::No) { + event->ignore(); + bClosingDown = false; + return; + } + } + if (!tabSupervisor->close()) { event->ignore(); bClosingDown = false; @@ -725,7 +884,8 @@ void MainWindow::changeEvent(QEvent *event) connectionController->connectToServerDirect(connectTo.host(), connectTo.port(), connectTo.userName(), connectTo.password()); } else if (SettingsCache::instance().servers().getAutoConnect() && - !SettingsCache::instance().debug().getLocalGameOnStartup()) { + !SettingsCache::instance().debug().getLocalGameOnStartup() && + !startupDestinationConnectsToServer()) { qCInfo(WindowMainStartupAutoconnectLog) << "Attempting auto-connect..."; DlgConnect dlg(this); connectionController->connectToServerDirect(dlg.getHost(), static_cast(dlg.getPort()), @@ -751,6 +911,11 @@ void MainWindow::showWindowIfHidden() show(); } +void MainWindow::handleCockatriceLink(const QString &url) +{ + urlParser->handle(url); +} + void MainWindow::cardDatabaseLoadingFailed() { if (askedForDbUpdater) { @@ -780,7 +945,7 @@ void MainWindow::cardDatabaseLoadingFailed() void MainWindow::cardDatabaseNewSetsFound(int numUnknownSets, QStringList unknownSetsNames) { - if (SettingsCache::instance().getAlwaysEnableNewSets()) { + if (SettingsCache::instance().updates().getAlwaysEnableNewSets()) { CardDatabaseManager::getInstance()->enableAllUnknownSets(); const auto reloadOk1 = QtConcurrent::run([] { CardDatabaseManager::getInstance()->reloadCardDatabasesAndNotify(); }); @@ -812,7 +977,7 @@ void MainWindow::cardDatabaseNewSetsFound(int numUnknownSets, QStringList unknow CardDatabaseManager::getInstance()->enableAllUnknownSets(); const auto reloadOk1 = QtConcurrent::run([] { CardDatabaseManager::getInstance()->reloadCardDatabasesAndNotify(); }); - SettingsCache::instance().setAlwaysEnableNewSets(true); + SettingsCache::instance().updates().setAlwaysEnableNewSets(true); } else if (msgBox.clickedButton() == noButton) { CardDatabaseManager::getInstance()->markAllSetsAsKnown(); } else if (msgBox.clickedButton() == settingsButton) { @@ -888,7 +1053,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()); @@ -902,19 +1067,57 @@ void MainWindow::createCardUpdateProcess(bool background) QMessageBox::warning(this, tr("Error"), tr("Unable to run the card database updater: ") + dir.absoluteFilePath(binaryName)); exitCardDatabaseUpdate(); + emit cardDatabaseUpdateFinished(false); return; } if (!background) { cardUpdateProcess->start(updaterCmd, QStringList()); } else { + cardUpdateOutputBuffer.clear(); + connect(cardUpdateProcess, &QProcess::readyReadStandardOutput, this, &MainWindow::cardUpdateProgressOutput); cardUpdateProcess->start(updaterCmd, QStringList("-b")); statusBar()->showMessage(tr("Card database update running.")); } } +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).trimmed(); + cardUpdateOutputBuffer.remove(0, newline + 1); + // Protocol emitted by `oracle -b`: "PROGRESS " + if (!line.startsWith("PROGRESS ")) { + continue; + } + const QList parts = line.split(' '); + if (parts.size() != 4) { + continue; + } + 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) { + continue; + } + emit cardDatabaseUpdateProgress(QString::fromLatin1(parts.at(1)), done, total); + } +} + void MainWindow::exitCardDatabaseUpdate() { + if (!cardUpdateProcess) { + return; + } cardUpdateProcess->deleteLater(); cardUpdateProcess = nullptr; statusBar()->clearMessage(); @@ -952,14 +1155,19 @@ void MainWindow::cardUpdateError(QProcess::ProcessError err) 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) { + cardUpdateProgressOutput(); // drain any progress lines not yet parsed + + const bool success = (exitStatus == QProcess::NormalExit) && (exitCode == 0); if (exitStatus == QProcess::NormalExit) { - SettingsCache::instance().setLastCardUpdateCheck(QDateTime::currentDateTime().date()); + SettingsCache::instance().updates().setLastCardUpdateCheck(QDateTime::currentDateTime().date()); } exitCardDatabaseUpdate(); + emit cardDatabaseUpdateFinished(success); } void MainWindow::actCheckServerUpdates() @@ -984,6 +1192,16 @@ void MainWindow::checkClientUpdatesFinished(bool needToUpdate, bool /* isCompati } } +void MainWindow::actCheckCommanderBracketDefinitionUpdates() +{ + auto *handler = new HandleCommanderBrackets(this); + + connect(handler, &HandleCommanderBrackets::sigBracketDefinitionsDownloaded, this, + []() { qDebug() << "Bracket definitions loaded"; }); + + handler->downloadBracketDefinitions(); +} + void MainWindow::refreshShortcuts() { ShortcutsSettings &shortcuts = SettingsCache::instance().shortcuts(); @@ -1004,7 +1222,7 @@ void MainWindow::refreshShortcuts() void MainWindow::actOpenCustomFolder() { - QString dir = SettingsCache::instance().getCustomPicsPath(); + QString dir = SettingsCache::instance().paths().getCustomPicsPath(); QDesktopServices::openUrl(QUrl::fromLocalFile(dir)); } diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index 5f631ddc3..08481fd36 100644 --- a/cockatrice/src/interface/window_main.h +++ b/cockatrice/src/interface/window_main.h @@ -25,6 +25,7 @@ #ifndef WINDOW_H #define WINDOW_H +#include "../client/lag_monitor.h" #include "connection_controller/remote_connection_controller.h" #include "widgets/dialogs/dlg_local_game_options.h" @@ -49,16 +50,29 @@ class GameReplay; class HandlePublicServers; class LocalClient; class LocalServer; +class QLabel; +class LatencyStatusWidget; class QThread; 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 background card-database update subprocess exits. */ + void cardDatabaseUpdateFinished(bool success); + + /** @brief Emitted while the background card-database update subprocess runs. + * @p stage is one of "download", "scan" or "import"; @p done/@p total + * are byte counts for the first two stages and set indices for "import". */ + void cardDatabaseUpdateProgress(const QString &stage, qint64 done, qint64 total); + public slots: void actCheckCardUpdates(); void actCheckCardUpdatesBackground(); @@ -83,15 +97,18 @@ private slots: void actOpenSettingsFolder(); void actShow(); void showWindowIfHidden(); + void handleCockatriceLink(const QString &url); void cardUpdateError(QProcess::ProcessError err); void cardUpdateFinished(int exitCode, QProcess::ExitStatus exitStatus); + void cardUpdateProgressOutput(); void refreshShortcuts(); void cardDatabaseLoadingFailed(); void cardDatabaseNewSetsFound(int numUnknownSets, QStringList unknownSetsNames); void cardDatabaseAllNewSetsEnabled(); void checkClientUpdatesFinished(bool needToUpdate, bool isCompatible, Release *release); + void actCheckCommanderBracketDefinitionUpdates(); void actOpenCustomFolder(); void actOpenCustomsetsFolder(); @@ -104,6 +121,11 @@ 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; + private: static const QString appName; static const QStringList fileNameFilters; @@ -113,6 +135,9 @@ private: void createTrayIcon(); int getNextCustomSetPrefix(QDir dataDir); + + void runFirstRunWizard(); + inline QString getCardUpdaterBinaryName() { return "oracle"; @@ -128,15 +153,19 @@ 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; QProcess *cardUpdateProcess; + QByteArray cardUpdateOutputBuffer; DlgViewLog *logviewDialog; GameReplay *replay; DlgTipOfTheDay *tip; @@ -150,6 +179,11 @@ public: } ~MainWindow() override; + RemoteClient *getRemoteClient() const + { + return connectionController->client(); + } + TabSupervisor *getTabSupervisor() const { return tabSupervisor; diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp index ad68d4be9..829e08742 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -23,12 +23,17 @@ #include "client/network/update/card_spoiler/spoiler_background_updater.h" #include "client/settings/cache_settings.h" #include "client/sound_engine.h" +#include "client/url_scheme_event_filter.h" #include "database/interface/settings_card_preference_provider.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 "single_instance_manager.h" #include "version_string.h" #include @@ -37,10 +42,18 @@ #include #include #include +#include #include #include #include #include +#include +#include +#include +#include +#include +#include +#include QTranslator *translator, *qtTranslator; RNG_Abstract *rng; @@ -125,14 +138,10 @@ LONG WINAPI CockatriceUnhandledExceptionFilter(EXCEPTION_POINTERS *exceptionPoin void installNewTranslator() { - QString lang = SettingsCache::instance().getLang(); + QString lang = SettingsCache::instance().personal().getLang(); QString qtNameHint = "qt_" + lang; -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) QString qtTranslationPath = QLibraryInfo::path(QLibraryInfo::TranslationsPath); -#else - QString qtTranslationPath = QLibraryInfo::location(QLibraryInfo::TranslationsPath); -#endif bool qtTranslationLoaded = qtTranslator->load(qtNameHint, qtTranslationPath); if (!qtTranslationLoaded) { @@ -174,6 +183,7 @@ int main(int argc, char *argv[]) SetUnhandledExceptionFilter(CockatriceUnhandledExceptionFilter); #endif + // Logging setup #ifdef Q_OS_APPLE // /cockatrice/cockatrice.app/Contents/MacOS/cockatrice const QByteArray configPath = "../../../qtlogging.ini"; @@ -191,15 +201,29 @@ int main(int argc, char *argv[]) // Set the QT_LOGGING_CONF environment variable qputenv("QT_LOGGING_CONF", configPath); } + qSetMessagePattern( "\033[0m[%{time yyyy-MM-dd h:mm:ss.zzz} " "%{if-debug}\033[36mD%{endif}%{if-info}\033[32mI%{endif}%{if-warning}\033[33mW%{endif}%{if-critical}\033[31mC%{" "endif}%{if-fatal}\033[1;31mF%{endif}\033[0m] [%{function}] - %{message} [%{file}:%{line}]"); QApplication app(argc, argv); +#ifdef Q_OS_MAC + UrlSchemeEventFilter cockatriceFilter(QStringList{QStringLiteral("cockatrice")}); + + QStringList pendingMacUrls; + + const auto cocoaBufferConn = + QObject::connect(&cockatriceFilter, &UrlSchemeEventFilter::urlReceived, + [&pendingMacUrls](const QString &url) { pendingMacUrls.append(url); }); + + app.installEventFilter(&cockatriceFilter); +#endif + QObject::connect(&app, &QApplication::lastWindowClosed, &app, &QApplication::quit); qInstallMessageHandler(CockatriceLogger); + #ifdef Q_OS_WIN app.addLibraryPath(app.applicationDirPath() + "/plugins"); #endif @@ -207,7 +231,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); @@ -215,6 +239,7 @@ int main(int argc, char *argv[]) qApp->setAttribute(Qt::AA_DontShowIconsInMenus, true); #endif + // Translations #ifdef Q_OS_MAC translationPath = qApp->applicationDirPath() + "/../Resources/translations"; #elif defined(Q_OS_WIN) @@ -223,8 +248,9 @@ int main(int argc, char *argv[]) translationPath = qApp->applicationDirPath() + "/../share/cockatrice/translations"; #endif + // Command-line parser QCommandLineParser parser; - parser.setApplicationDescription("Cockatrice"); + parser.setApplicationDescription("Cockatrice Client"); parser.addHelpOption(); parser.addVersionOption(); @@ -238,7 +264,36 @@ int main(int argc, char *argv[]) Logger::getInstance().logToFile(true); } - rng = new RNG_SFMT; + // --- Handle files or URLs passed at startup --- + // Only positional arguments are treated as files/URLs, so options like + // --connect are never handed off to another instance. + const QStringList startupFiles = parser.positionalArguments(); + const bool hasActivationFiles = !startupFiles.isEmpty(); + + SingleInstanceManager instance; + + if (hasActivationFiles) { + // 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)) { + // Sent successfully → exit + return 0; + } + // No primary instance → become server + qInfo() << "No existing instance found, becoming primary instance"; + } else { + // Plain launch: if another instance is running, run independently + // instead of handing off and exiting. + if (!instance.tryRun(QStringList())) { + // Another instance is already running → just run independently + qInfo() << "Another instance exists, running independently"; + } else { + qInfo() << "No existing instance found, starting server"; + } + } + + rng = new RNG_SFMT(CryptoUtil::randomUInt64()); themeManager = new ThemeManager; soundEngine = new SoundEngine; @@ -251,21 +306,54 @@ int main(int argc, char *argv[]) // Dependency Injections CardDatabaseManager::setCardPreferenceProvider(new SettingsCardPreferenceProvider()); CardDatabaseManager::setCardDatabasePathProvider(&SettingsCache::instance()); - CardDatabaseManager::setCardSetPriorityController(SettingsCache::instance().cardDatabase()); + CardDatabaseManager::setCardSetPriorityController(&SettingsCache::instance().cardDatabase()); qCInfo(MainLog) << "Starting main program"; + // Front-load the card database before constructing the main window. The + // binary-cache read is cheap when uncontended (~1s); doing it here -- while no + // GUI work yet competes for CPU -- avoids the multi-second, GUI-freezing + // contention that happens when the load runs alongside window construction. + // The CardDatabaseModel populates from the already-loaded data in its + // constructor, so the window appears fully populated with no startup lag. + // Note: checkUnknownSets() is deferred from the initial load to + // MainWindow::startupConfigCheck() so that + // cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled have live + // receivers when emitted. Subsequent reloads (e.g. path changes) call + // checkUnknownSets() directly from the loader after the first load. + CardDatabaseManager::getInstance()->loadCardDatabases(); + MainWindow ui; + + auto handleActivation = [&ui](const QString &file) { + if (file.startsWith("cockatrice://")) { + auto urlParser = new IntentUrlParser(&ui, &ui); + urlParser->handle(file); + } else if (QFileInfo(file).exists()) { + auto openDeckIntent = new IntentOpenLocalDeck(ui.getTabSupervisor(), file); + QObject::connect(openDeckIntent, &Intent::failed, &ui, [&ui](const QString &reason) { + QMessageBox::warning(&ui, QObject::tr("Open deck"), reason); + }); + openDeckIntent->execute(); + } + }; + +#ifdef Q_OS_MAC + QObject::disconnect(cocoaBufferConn); + + QObject::connect(&cockatriceFilter, &UrlSchemeEventFilter::urlReceived, + [&handleActivation](const QString &url) { handleActivation(url); }); +#endif if (parser.isSet("connect")) { ui.setConnectTo(parser.value("connect")); } 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 - QGuiApplication::setDesktopFileName("cockatrice"); + ui.setWindowIcon(themePixmap(QStringLiteral("cockatrice"))); + // Set name of the app desktop file; used by wayland to load the window icon + QGuiApplication::setDesktopFileName("Cockatrice"); - SettingsCache::instance().setClientID(generateClientID()); + SettingsCache::instance().network().setClientID(generateClientID()); // If spoiler mode is enabled, we will download the spoilers // then reload the DB. otherwise just reload the DB @@ -275,18 +363,35 @@ int main(int argc, char *argv[]) qCInfo(MainLog) << "ui.show() finished"; // force shortcuts to be shown/hidden in right-click menus, regardless of system defaults - qApp->setAttribute(Qt::AA_DontShowShortcutsInContextMenus, !SettingsCache::instance().getShowShortcuts()); + qApp->setAttribute(Qt::AA_DontShowShortcutsInContextMenus, + !SettingsCache::instance().userInterface().getShowShortcuts()); -#if (QT_VERSION < QT_VERSION_CHECK(6, 0, 0)) - app.setAttribute(Qt::AA_UseHighDpiPixmaps); +#ifdef Q_OS_MAC + for (const QString &url : pendingMacUrls) { + handleActivation(url); + } + pendingMacUrls.clear(); #endif - app.exec(); + + for (const QString &file : startupFiles) { + handleActivation(file); + } + + // Connect to future file/URL events from other instances + QObject::connect(&instance, &SingleInstanceManager::filesReceived, [&handleActivation](const QStringList &files) { + for (const QString &file : files) { + handleActivation(file); + } + }); + + int ret = app.exec(); qCInfo(MainLog) << "Event loop finished, terminating..."; delete rng; PingPixmapGenerator::clear(); CountryPixmapGenerator::clear(); UserLevelPixmapGenerator::clear(); + ManaSymbolPixmapGenerator::clear(); - return 0; + return ret; } diff --git a/cockatrice/src/single_instance_manager.cpp b/cockatrice/src/single_instance_manager.cpp new file mode 100644 index 000000000..aca23160c --- /dev/null +++ b/cockatrice/src/single_instance_manager.cpp @@ -0,0 +1,126 @@ +#include "single_instance_manager.h" + +#include + +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 + // 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); + + // Hand off to an already-running primary instance if one exists. + if (forwardToPrimary(filesToSend)) { + return false; + } + + // No primary instance is currently reachable, so become the primary. + server = new QLocalServer(this); + connect(server, &QLocalServer::newConnection, this, &SingleInstanceManager::handleNewConnection); + + if (server->listen(serverName)) { + return true; + } + + // Another instance may have started while we were probing; hand off to it + // instead of stealing its socket. + if (forwardToPrimary(filesToSend)) { + return false; + } + + // The socket is stale (left over by a crashed instance): remove it and + // retry. If that still fails, another instance just took the name. + QLocalServer::removeServer(serverName); + if (server->listen(serverName)) { + return true; + } + + forwardToPrimary(filesToSend); + return false; +} + +bool SingleInstanceManager::forwardToPrimary(const QStringList &filesToSend) +{ + QLocalSocket socket; + socket.connectToServer(serverName); + if (!socket.waitForConnected(200)) { + return false; + } + + // Serialize payload with length prefix + QByteArray payload; + QDataStream out(&payload, QIODevice::WriteOnly); + out << filesToSend; + + QByteArray message; + QDataStream msgStream(&message, QIODevice::WriteOnly); + msgStream << quint32(payload.size()); + message.append(payload); + + socket.write(message); + socket.flush(); + socket.waitForBytesWritten(1000); + + return true; +} + +void SingleInstanceManager::handleNewConnection() +{ + QLocalSocket *socket = server->nextPendingConnection(); + + // Per-connection state. QSharedPointer keeps the buffers alive for as long + // as the connection handler is attached to the socket. + auto buffer = QSharedPointer::create(); + auto expectedSize = QSharedPointer::create(0); + + connect(socket, &QLocalSocket::readyRead, this, [this, socket, buffer, expectedSize]() { + buffer->append(socket->readAll()); + + QDataStream stream(buffer.data(), QIODevice::ReadOnly); + + while (true) { + // Step 1: read size + if (*expectedSize == 0) { + if (buffer->size() < static_cast(sizeof(quint32))) { + return; + } + + stream >> *expectedSize; + } + + // Step 2: wait for full payload + if (buffer->size() < static_cast(sizeof(quint32) + *expectedSize)) { + return; + } + + // Step 3: extract payload + QByteArray payload = buffer->mid(sizeof(quint32), *expectedSize); + + QDataStream payloadStream(&payload, QIODevice::ReadOnly); + QStringList files; + payloadStream >> files; + + emit filesReceived(files); + + // Reset buffer (single message use-case) + buffer->clear(); + *expectedSize = 0; + + socket->disconnectFromServer(); + return; + } + }); + + connect(socket, &QLocalSocket::disconnected, socket, &QLocalSocket::deleteLater); +} diff --git a/cockatrice/src/single_instance_manager.h b/cockatrice/src/single_instance_manager.h new file mode 100644 index 000000000..55bff0e80 --- /dev/null +++ b/cockatrice/src/single_instance_manager.h @@ -0,0 +1,32 @@ +#ifndef COCKATRICE_SINGLE_INSTANCE_MANAGER_H +#define COCKATRICE_SINGLE_INSTANCE_MANAGER_H + +#include +#include +#include +#include + +class SingleInstanceManager : public QObject +{ + Q_OBJECT +public: + explicit SingleInstanceManager(QObject *parent = nullptr); + + // Returns true if this process became the primary instance, false if + // another instance is already running (and received our files). + bool tryRun(const QStringList &initialFiles); + +signals: + void filesReceived(const QStringList &files); + +private slots: + void handleNewConnection(); + +private: + bool forwardToPrimary(const QStringList &filesToSend); + + QString serverName; + QLocalServer *server = nullptr; +}; + +#endif // COCKATRICE_SINGLE_INSTANCE_MANAGER_H 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/Fabric/backgrounds/home-dark.png b/cockatrice/themes/Fabric/backgrounds/home-dark.png new file mode 100644 index 000000000..8d9514626 Binary files /dev/null and b/cockatrice/themes/Fabric/backgrounds/home-dark.png differ diff --git a/cockatrice/themes/Fabric/backgrounds/home-light.png b/cockatrice/themes/Fabric/backgrounds/home-light.png new file mode 100644 index 000000000..78f51a686 Binary files /dev/null and b/cockatrice/themes/Fabric/backgrounds/home-light.png 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.png b/cockatrice/themes/Leather/backgrounds/home-dark.png new file mode 100644 index 000000000..20d16a27f Binary files /dev/null and b/cockatrice/themes/Leather/backgrounds/home-dark.png differ diff --git a/cockatrice/themes/Leather/backgrounds/home-light.png b/cockatrice/themes/Leather/backgrounds/home-light.png new file mode 100644 index 000000000..ee1da361d Binary files /dev/null and b/cockatrice/themes/Leather/backgrounds/home-light.png 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.png b/cockatrice/themes/Plasma/backgrounds/home-dark.png new file mode 100644 index 000000000..ce1469e2b Binary files /dev/null and b/cockatrice/themes/Plasma/backgrounds/home-dark.png differ diff --git a/cockatrice/themes/Plasma/backgrounds/home-light.png b/cockatrice/themes/Plasma/backgrounds/home-light.png new file mode 100644 index 000000000..2e9000bd6 Binary files /dev/null and b/cockatrice/themes/Plasma/backgrounds/home-light.png 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/Default/palette-default-dark.toml b/cockatrice/themes/System/palette-default-dark.toml similarity index 96% rename from cockatrice/themes/Default/palette-default-dark.toml rename to cockatrice/themes/System/palette-default-dark.toml index 3ee174a2f..e101935b4 100644 --- a/cockatrice/themes/Default/palette-default-dark.toml +++ b/cockatrice/themes/System/palette-default-dark.toml @@ -61,3 +61,7 @@ ToolTipBase = #ffffffdc ToolTipText = #ff000000 PlaceholderText = #6effffff + +[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..14c215cdf --- /dev/null +++ b/cockatrice/themes/System/palette-default-light.toml @@ -0,0 +1,67 @@ +[Palette] +WindowText = #ff000000 +Button = #fff0f0f0 +Light = #ffffffff +Midlight = #ffe3e3e3 +Dark = #ffa0a0a0 +Mid = #ffa0a0a0 +Text = #ff000000 +BrightText = #ffffffff +ButtonText = #ff000000 +Base = #ffffffff +Window = #fff0f0f0 +Shadow = #ff696969 +HighlightedText = #ffffffff +Link = #ff0d5f28 +LinkVisited = #ff08401b +AlternateBase = #ffe9e7e3 +ToolTipBase = #ffffffdc +ToolTipText = #ff000000 +PlaceholderText = #80000000 + +[Palette.Disabled] +WindowText = #ff787878 +Button = #fff0f0f0 +Light = #ffffffff +Midlight = #fff7f7f7 +Dark = #ffa0a0a0 +Mid = #ffa0a0a0 +Text = #ff787878 +BrightText = #ffffffff +ButtonText = #ff787878 +Base = #fff0f0f0 +Window = #fff0f0f0 +Shadow = #ff000000 +HighlightedText = #ffffffff +Link = #ff0000ff +LinkVisited = #ffff00ff +AlternateBase = #fff7f7f7 +ToolTipBase = #ffffffdc +ToolTipText = #ff000000 +PlaceholderText = #80000000 + +[Palette.Inactive] +WindowText = #ff000000 +Button = #fff0f0f0 +Light = #ffffffff +Midlight = #ffe3e3e3 +Dark = #ffa0a0a0 +Mid = #ffa0a0a0 +Text = #ff000000 +BrightText = #ffffffff +ButtonText = #ff000000 +Base = #ffffffff +Window = #fff0f0f0 +Shadow = #ff696969 +HighlightedText = #ff000000 +Link = #ff0d5f28 +LinkVisited = #ff08401b +AlternateBase = #ffe9e7e3 +ToolTipBase = #ffffffdc +ToolTipText = #ff000000 +PlaceholderText = #80000000 + + +[AppColors] +AccentStrong = #ff148c3c +AccentSoft = #ff78c850 \ No newline at end of file diff --git a/cockatrice/themes/Default/theme.cfg b/cockatrice/themes/System/theme.cfg similarity index 73% rename from cockatrice/themes/Default/theme.cfg rename to cockatrice/themes/System/theme.cfg index d2016a238..4b756a5e8 100644 --- a/cockatrice/themes/Default/theme.cfg +++ b/cockatrice/themes/System/theme.cfg @@ -2,4 +2,4 @@ ColorScheme = Light [Style] -Name = Default +Name = System diff --git a/cockatrice/themes/VelvetMarble/backgrounds/home-dark.png b/cockatrice/themes/VelvetMarble/backgrounds/home-dark.png new file mode 100644 index 000000000..34738090f Binary files /dev/null and b/cockatrice/themes/VelvetMarble/backgrounds/home-dark.png differ diff --git a/cockatrice/themes/VelvetMarble/backgrounds/home-light.png b/cockatrice/themes/VelvetMarble/backgrounds/home-light.png new file mode 100644 index 000000000..de783e086 Binary files /dev/null and b/cockatrice/themes/VelvetMarble/backgrounds/home-light.png 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..82aed32ab 100644 --- a/doc/carddatabase_v4/cards.xsd +++ b/doc/carddatabase_v4/cards.xsd @@ -35,6 +35,13 @@ + + + + + + + @@ -49,6 +56,13 @@ + + + + + + + diff --git a/doc/doxygen/extra-pages/developer_documentation/index.md b/doc/doxygen/extra-pages/developer_documentation/index.md index da2d444d8..80f4eb492 100644 --- a/doc/doxygen/extra-pages/developer_documentation/index.md +++ b/doc/doxygen/extra-pages/developer_documentation/index.md @@ -8,4 +8,6 @@ - @subpage querying_the_card_database - @subpage loading_card_pictures -- @subpage displaying_cards \ No newline at end of file +- @subpage displaying_cards + +- @subpage developer_reference_network_overview \ No newline at end of file diff --git a/doc/doxygen/extra-pages/developer_documentation/network/client/game_event_handler.md b/doc/doxygen/extra-pages/developer_documentation/network/client/game_event_handler.md new file mode 100644 index 000000000..fbb298d3a --- /dev/null +++ b/doc/doxygen/extra-pages/developer_documentation/network/client/game_event_handler.md @@ -0,0 +1,170 @@ +@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/client/index.md b/doc/doxygen/extra-pages/developer_documentation/network/client/index.md new file mode 100644 index 000000000..f22640448 --- /dev/null +++ b/doc/doxygen/extra-pages/developer_documentation/network/client/index.md @@ -0,0 +1,11 @@ +@page developer_reference_network_client Client Networking (Overview) + +The clients response to various network protocol events and associated handling are described here. + +For information about game scoped events, see: + +- @subpage game_event_handler + +Certain game scoped events may be forwarded to player based handlers. For more information, see: + +- @subpage player_event_handler \ No newline at end of file diff --git a/doc/doxygen/extra-pages/developer_documentation/network/client/player_event_handler.md b/doc/doxygen/extra-pages/developer_documentation/network/client/player_event_handler.md new file mode 100644 index 000000000..6878758da --- /dev/null +++ b/doc/doxygen/extra-pages/developer_documentation/network/client/player_event_handler.md @@ -0,0 +1,199 @@ +@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/extra-pages/developer_documentation/network/game_event_handler.md b/doc/doxygen/extra-pages/developer_documentation/network/game_event_handler.md new file mode 100644 index 000000000..fbb298d3a --- /dev/null +++ b/doc/doxygen/extra-pages/developer_documentation/network/game_event_handler.md @@ -0,0 +1,170 @@ +@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/index.md b/doc/doxygen/extra-pages/developer_documentation/network/index.md new file mode 100644 index 000000000..713291d84 --- /dev/null +++ b/doc/doxygen/extra-pages/developer_documentation/network/index.md @@ -0,0 +1,7 @@ +@page developer_reference_network_overview Network (Overview) + +This page provides information about the networking performed by the client and server. + +For information about the protocol, see @subpage developer_reference_protocol + +For information about the clients response to and handling of protocol messages, see @subpage developer_reference_network_client \ No newline at end of file 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 new file mode 100644 index 000000000..6878758da --- /dev/null +++ b/doc/doxygen/extra-pages/developer_documentation/network/player_event_handler.md @@ -0,0 +1,199 @@ +@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/extra-pages/developer_documentation/network/protocol/index.md b/doc/doxygen/extra-pages/developer_documentation/network/protocol/index.md new file mode 100644 index 000000000..724b6b994 --- /dev/null +++ b/doc/doxygen/extra-pages/developer_documentation/network/protocol/index.md @@ -0,0 +1,15 @@ +@page developer_reference_protocol Protocol (Overview) + +For an overview of the protocol used by the Cockatrice client and server, see: + +- @subpage developer_reference_protocol_overview +- @subpage protocol_game_command + +For client → server communication, see: + +- @subpage protocol_command_container + +For server → client communication, see: + +- @subpage protocol_server_message +- @subpage protocol_response \ No newline at end of file diff --git a/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_command_container.md b/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_command_container.md new file mode 100644 index 000000000..0fb634358 --- /dev/null +++ b/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_command_container.md @@ -0,0 +1,114 @@ +@page protocol_command_container CommandContainer (Protocol Concept) + +@section cc_overview Overview + +CommandContainer is the client-to-server message envelope used for all +client requests in the Cockatrice protocol. + +Every client-initiated action is transmitted as a CommandContainer. +The server never sends CommandContainer messages. + +This is a protocol-level abstraction and should not be confused with the +generated protobuf accessors or wire-level encoding. + +@section cc_lifecycle Lifetime and Ownership + +- CommandContainers are created by clients and consumed by the server. +- Each container represents one logical request. +- Containers are processed atomically and in order of arrival. + +A container may generate: +- Exactly one RESPONSE message, and +- Zero or more EVENT messages. + +@section cc_cmd_id Command Identification + +The @c cmd_id field is a client-assigned identifier used to correlate +responses with requests. + +Rules: +- @c cmd_id is optional but strongly recommended +- The server echoes @c cmd_id only in RESPONSE messages +- EVENT messages are never associated with a @c cmd_id + +The server does not enforce uniqueness of @c cmd_id values. + +@section cc_context Command Context + +Certain command domains require additional context: + +- Room commands require @c room_id +- Game commands require @c game_id + +Context fields are ignored for command domains that do not require them. + +If a required context field is missing or invalid, the server responds with +@c RespContextError. + +@section cc_invariants Invariants + +The following rules are enforced by the server: + +- Exactly one command domain must be populated +- Commands may be batched only within the same domain +- Context fields must match the active command domain + +Violations of these rules result in @c RespInvalidCommand. + +@section cc_domains Command Domains + +CommandContainer supports the following mutually exclusive command domains: + +- Session commands + - Authentication + - User discovery + - Private messaging + +- Room commands + - Chat + - Game creation + - Room membership management + +- Game commands + - In-game actions + - State mutations + +- Moderator commands + - User moderation + - Room moderation + +- Admin commands + - Server administration + +@section cc_batching Command Batching + +A CommandContainer may contain multiple commands of the same domain. + +Batching guarantees: +- Commands are processed in the order they appear in the container +- All commands in the batch share the same context + +Partial failure behavior is implementation-defined and may vary by domain. + +@section cc_dispatch Dispatch + +CommandContainers are dispatched by +Server_ProtocolHandler::processCommandContainer(). + +Dispatch is performed by inspecting which command domain is populated. +Only the first populated domain is considered. + +@section cc_errors Error Handling + +Common error responses include: +- @c RespLoginNeeded – client is not authenticated +- @c RespInvalidCommand – malformed container or invalid domain usage +- @c RespContextError – missing or invalid room/game context + +@section cc_related Related Concepts + +- @ref protocol_server_message "ServerMessage" +- @ref protocol_client_states "Client State Machine" + +@see Server_ProtocolHandler::processCommandContainer + diff --git a/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_game_command.md b/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_game_command.md new file mode 100644 index 000000000..18e31064a --- /dev/null +++ b/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_game_command.md @@ -0,0 +1,529 @@ +@page protocol_game_command GameCommand (Protocol Concept) + +# Game Commands Reference + +This document describes game-level commands (`GameCommandType`) and how they are +handled across the server and client. + +Flow overview: + +Client +→ GameCommand +→ Server command handler (`Server_Player`, `Server_AbstractParticipant`, or `Server_Game`) +→ GameEvent(s) +→ PlayerEventHandler::event* + +## Command Mapping + +### `GAME_SAY` (1002) + +**Purpose:** Send a chat message during a game. + +**Server:** +- `Server_AbstractParticipant::cmdGameSay` +- Validates spectator chat permissions +- Applies chat flood protection +- Emits `Event_GameSay` +- Logs the message to the server database + +**Client:** +- `PlayerEventHandler::eventGameSay` +- Adds the chat message to the game log + +--- + +### `SHUFFLE` (1003) + +**Purpose:** Shuffle a card zone (usually library). + +**Server:** +- `Server_Player::cmdShuffle` +- Only allows shuffling the deck zone +- Rejects if the game has not started or the player has conceded +- Supports partial-range shuffles (`start` / `end`) +- Emits `Event_Shuffle` +- Re-reveals the top card if the deck is being tracked + +**Client:** +- `PlayerEventHandler::eventShuffle` +- Closes affected library views +- Clears any revealed top card if necessary +- Updates deck graphics +- Logs the shuffle + +--- + +### `ROLL_DIE` (1005) + +**Purpose:** Roll one or more dice. + +**Server:** +- Computes random die results +- Emits `Event_RollDie` + +**Client:** +- `PlayerEventHandler::eventRollDie` +- Displays the sorted roll results in the game log + +--- + +### `DRAW_CARDS` (1006) + +**Purpose:** Draw cards from deck. + +**Server:** +- `Server_Player::cmdDrawCards` +- Rejects if the game has not started or the player has conceded +- Moves cards from deck to hand +- Sends full card information privately to the drawing player +- Sends only the draw count to all other players +- Tracks drawn cards for undo +- Updates revealed-top-card state when necessary +- Emits `Event_DrawCards` + +**Client:** +- `PlayerEventHandler::eventDrawCards` +- Moves cards from library to hand +- Reveals card identities only when included in the event +- Updates both zones +- Logs the draw + +--- + +### `UNDO_DRAW` (1007) + +**Purpose:** Undo a previous draw. + +**Server:** +- `Server_Player::cmdUndoDraw` +- Rejects if the game has not started or the player has conceded +- Uses the tracked draw history (`lastDrawList`) +- Moves the most recently drawable card from hand back to the top of the deck +- Emits `Event_GameLogNotice` if undo is no longer possible + +**Client:** +- Processed as a normal `MOVE_CARD` +- Detects `Context_UndoDraw` +- Logs the undo draw instead of a normal move + +--- + +### `FLIP_CARD` (1008) + +**Purpose:** Flip a card face up or face down. + +**Server:** +- Updates the card's face-down state +- Reveals identity when turning face up +- Emits `Event_FlipCard` + +**Client:** +- `PlayerEventHandler::eventFlipCard` +- Updates the card identity when revealed +- Changes face-up / face-down state +- Updates card menus +- Logs the flip + +--- + +### `ATTACH_CARD` (1009) + +**Purpose:** Attach one card to another. + +**Server:** +- Updates attachment relationships +- Emits `Event_AttachCard` + +**Client:** +- `PlayerEventHandler::eventAttachCard` +- Updates parent/child attachment links +- Reorganizes affected zones +- Logs attach or detach operations +- Refreshes card actions + +--- + +### `CREATE_TOKEN` (1010) + +**Purpose:** Create a token card. + +**Server:** +- Creates a new token card +- Emits `Event_CreateToken` + +**Client:** +- `PlayerEventHandler::eventCreateToken` +- Creates the token object +- Applies token properties (PT, color, annotation, face-down state) +- Adds it to the requested zone +- Logs token creation + +--- + +### `CREATE_ARROW` (1011) + +**Purpose:** Create a visual arrow. + +**Server:** +- Creates a visual arrow +- Emits `Event_CreateArrow` + +**Client:** +- `PlayerEventHandler::eventCreateArrow` +- Creates the arrow graphics +- Resolves endpoint card names +- Logs arrow creation + +--- + +### `DELETE_ARROW` (1012) + +**Purpose:** Remove a visual arrow. + +**Server:** +- Removes an existing arrow +- Emits `Event_DeleteArrow` + +**Client:** +- `PlayerEventHandler::eventDeleteArrow` +- Removes the arrow graphics + +--- + +### `SET_CARD_ATTR` (1013) + +**Purpose:** Set a card attribute. + +**Server:** +- Updates one or more card attributes +- Emits `Event_SetCardAttr` + +**Client:** +- `PlayerEventHandler::eventSetCardAttr` +- Updates tapped state, color, annotation, power/toughness, face-down state, attacking state, and related visuals +- Logs attribute changes where appropriate + +--- + +### `SET_CARD_COUNTER` (1014) + +**Purpose:** Set a card counter. + +**Server:** +- Updates the specified card counter +- Emits `Event_SetCardCounter` + +**Client:** +- `PlayerEventHandler::eventSetCardCounter` +- Updates the displayed counter value +- Refreshes card actions +- Logs the counter change + +--- + +### `INC_CARD_COUNTER` (1015) + +**Purpose:** Increment a card counter. + +**Server:** +- Normalizes to a set-counter operation +- Emits `Event_SetCardCounter` + +**Client:** +- Processed identically to `SET_CARD_COUNTER` + +--- + +### `READY_START` (1016) + +**Purpose:** Mark player as ready. + +**Server:** +- Marks the player as ready +- Starts the game once all required players are ready + +**Client:** +- Reflected through updated game state events + +--- + +### `CONCEDE` (1017) + +**Purpose:** Concede the game. + +**Server:** +- Marks the player as conceded +- Returns borrowed cards where appropriate +- Ends the game if only one player remains + +**Client:** +- Reflected through updated game state events + +--- + +### `INC_COUNTER` (1018) + +**Purpose:** Increment a player counter. + +**Server:** +- `Server_Player::cmdIncCounter` +- Rejects if the game has not started or the player has conceded +- Updates the counter value +- Emits `Event_SetCounter` only if the value changed + +**Client:** +- `PlayerEventHandler::eventSetCounter` +- Updates the displayed player counter +- Logs the change + +--- + +### `CREATE_COUNTER` (1019) + +**Purpose:** Create a player counter. + +**Server:** +- `Server_Player::cmdCreateCounter` +- Rejects if the game has not started or the player has conceded +- Allocates a new counter ID +- Creates the counter +- Emits `Event_CreateCounter` + +**Client:** +- `PlayerEventHandler::eventCreateCounter` +- Creates the local player counter + +--- + +### `SET_COUNTER` (1020) + +**Purpose:** Set a player counter value. + +**Server:** +- `Server_Player::cmdSetCounter` +- Rejects if the game has not started or the player has conceded +- Updates the counter value +- Emits `Event_SetCounter` only if the value changed + +**Client:** +- `PlayerEventHandler::eventSetCounter` +- Updates the counter value +- Logs the change + +--- + +### `DEL_COUNTER` (1021) + +**Purpose:** Delete a player counter. + +**Server:** +- `Server_Player::cmdDelCounter` +- Rejects if the game has not started or the player has conceded +- Deletes the counter +- Emits `Event_DelCounter` + +**Client:** +- `PlayerEventHandler::eventDelCounter` +- Removes the counter + +--- + +### `NEXT_TURN` (1022) + +**Purpose:** Advance to the next turn. + +**Server:** +- `Server_Player::cmdNextTurn` +- Rejects if the game has not started +- Conceded players cannot advance turns unless acting as a judge +- Calls `Server_Game::nextTurn()` + +**Client:** +- Reflected through subsequent active-player and active-phase events + +--- + +### `SET_ACTIVE_PHASE` (1023) + +**Purpose:** Set active phase. + +**Server:** +- `Server_Player::cmdSetActivePhase` +- Rejects if the game has not started +- Judges may change the phase at any time +- Normal players may only change the phase during their own active turn +- Calls `Server_Game::setActivePhase()` + +**Client:** +- Reflected through updated phase events + +--- + +### `DUMP_ZONE` (1024) + +**Purpose:** Dump zone contents. + +**Server:** +- Generates a zone summary +- Emits `Event_DumpZone` + +**Client:** +- `PlayerEventHandler::eventDumpZone` +- Logs the zone contents summary + +--- + +### `REVEAL_CARDS` (1026) + +**Purpose:** Reveal specific cards. + +**Server:** +- Reveals cards to one or more players +- Emits `Event_RevealCards` + +**Client:** +- `PlayerEventHandler::eventRevealCards` +- Reveals cards in-place or in a reveal window +- Supports temporary peeks +- Updates revealed top cards +- Logs the reveal + +--- + +### `MOVE_CARD` (1027) + +**Purpose:** Move a card between zones. + +**Server:** +- Moves cards between zones +- Updates ownership and attachments as needed +- Emits `Event_MoveCard` + +**Client:** +- `PlayerEventHandler::eventMoveCard` +- Moves the card between zones +- Updates ownership, attachments, IDs and revealed information +- Handles undo-draw context +- Refreshes menus and zone layouts +- Logs the move + +--- + +### `SET_SIDEBOARD_PLAN` (1028) + +**Purpose:** Set sideboard configuration. + +**Server:** +- `Server_Player::cmdSetSideboardPlan` +- Allowed only before readying for the game +- Requires a loaded deck +- Requires sideboarding to be unlocked +- Stores the current sideboard plan + +**Client:** +- No game event is generated + +--- + +### `DECK_SELECT` (1029) + +**Purpose:** Select a deck. + +**Server:** +- `Server_Player::cmdDeckSelect` +- Allowed only before the game starts +- Loads a deck from either the database or serialized deck data +- Locks sideboarding +- Updates player properties +- Emits `Event_PlayerPropertiesChanged` +- Attaches `Context_DeckSelect` +- Returns the selected deck in the command response + +**Client:** +- Reflected through updated player properties and command response + +--- + +### `SET_SIDEBOARD_LOCK` (1030) + +**Purpose:** Lock or unlock sideboarding. + +**Server:** +- `Server_Player::cmdSetSideboardLock` +- Allowed only before readying +- Toggles sideboard locking +- Clears pending sideboard plans when locking +- Emits `Event_PlayerPropertiesChanged` +- Attaches `Context_SetSideboardLock` + +**Client:** +- Reflected through updated player properties + +--- + +### `CHANGE_ZONE_PROPERTIES` (1031) + +**Purpose:** Change zone properties. + +**Server:** +- `Server_Player::cmdChangeZoneProperties` +- Delegates to the abstract implementation +- Updates top-card revelation if required +- Emits `Event_ChangeZoneProperties` + +**Client:** +- `PlayerEventHandler::eventChangeZoneProperties` +- Updates always-reveal-top-card and always-look-at-top-card settings +- Logs property changes + +--- + +### `UNCONCEDE` (1032) + +**Purpose:** Undo a concede. + +**Server:** +- Restores the player's active status + +**Client:** +- Reflected through updated game state events + +--- + +### `JUDGE` (1033) + +**Purpose:** Execute commands on behalf of another player. + +**Server:** +- `Server_AbstractParticipant::cmdJudge` +- Requires judge privileges +- Executes embedded commands as the selected player +- Marks resulting events as judge-forced + +**Client:** +- Transparent; resulting events are processed normally + +--- + +### `REVERSE_TURN` (1034) + +**Purpose:** Reverse turn order. + +**Server:** +- `Server_Player::cmdReverseTurn` +- Conceded non-judge players cannot use the command +- Delegates to `Server_AbstractParticipant::cmdReverseTurn` +- Toggles turn order +- Emits `Event_ReverseTurn` + +**Client:** +- Reflected by subsequent active-player progression + +--- + +## Notes + +- Game commands are handled by `Server_Player`, `Server_AbstractParticipant`, or `Server_Game`, depending on the command. +- Only commands that emit `GameEvent`s produce corresponding `PlayerEventHandler` callbacks. +- Some commands modify server state without generating a dedicated client event. +- Judge commands are transport wrappers that execute other game commands on behalf of another player. \ No newline at end of file diff --git a/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_overview.md b/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_overview.md new file mode 100644 index 000000000..f91b94c5f --- /dev/null +++ b/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_overview.md @@ -0,0 +1,76 @@ +@page developer_reference_protocol_overview Protocol (Overview) + +# Cockatrice Server Protocol – Overview + +## Purpose + +This document describes the Cockatrice client/server protocol as implemented +by the Cockatrice server. It is intended for developers implementing clients +or modifying the server. + +The protocol is **stateful**, **event-driven**, and **asynchronous**. + +--- + +## High-Level Properties + +- Encoding: Google Protocol Buffers +- Transport: TCP or WebSocket (transport-agnostic at protocol level) +- Directionality: + - Clients send `CommandContainer` + - Server sends `ServerMessage` +- Responses and events are interleaved +- Clients must handle unsolicited events at any time + +--- + +## Message Envelopes + +### Client → Server: CommandContainer + +A `CommandContainer` represents one logical client request. + +Protocol invariants: +- Exactly one command domain may be used per container +- Multiple commands of the same domain may be batched +- Context is provided via `room_id` or `game_id` when required + +Command domains: +- Session commands +- Room commands +- Game commands +- Moderator commands +- Admin commands + +--- + +### Server → Client: ServerMessage + +A `ServerMessage` represents either: +- A response to a command (`RESPONSE`) +- An unsolicited event (`*_EVENT`) + +Protocol invariants: +- Events may be delivered before or after responses +- Responses reference the original command via `cmd_id` +- Events are never correlated to a command + +--- + +## Asynchronous Model + +Clients MUST NOT assume request/response ordering. + +Example valid sequence: + +1. Client sends JOIN_ROOM +2. Server sends room chat history events +3. Server sends room join notifications +4. Server sends JOIN_ROOM response + +--- + +## Versioning + +The server reports a protocol version during connection initialization. +Clients MUST verify compatibility before sending commands. diff --git a/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_response.md b/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_response.md new file mode 100644 index 000000000..ce588114b --- /dev/null +++ b/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_response.md @@ -0,0 +1,27 @@ +@page protocol_response Response (Protocol Concept) + +## Overview + +`Response` is the server-to-client message sent immediately after a command, using the same `cmd_id` to link to the originating command. +It provides a `Response::ResponseCode` and optionally a `Response::ResponseType` to guide client handling. + +## Fields + +- **cmd_id** (`uint64`, required) — Command ID this response corresponds to +- **response_code** (`Response::ResponseCode`, optional) — Outcome of the command + +## Response Codes + +All possible outcome codes are defined in Response::ResponseCode + +These describe the result of the command, e.g., success, failure, or special conditions like bans or registration requirements. + +## Response Types + +Responses are routed according to Response::ResponseType + +Each `ResponseType` corresponds to a high-level category, like `JOIN_ROOM` or `DECK_UPLOAD`. + +## Extensions + +- Protobuf extensions from 100 to max are reserved for future use diff --git a/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_server_message.md b/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_server_message.md new file mode 100644 index 000000000..b8dac9c43 --- /dev/null +++ b/doc/doxygen/extra-pages/developer_documentation/network/protocol/protocol_server_message.md @@ -0,0 +1,171 @@ +@page protocol_server_message ServerMessage (Protocol Concept) + +## Overview + +`ServerMessage` is the server-to-client message envelope used for all +communication originating from the Cockatrice server. + +The server never sends `CommandContainer` messages, and clients never send +`ServerMessage` messages. + +A `ServerMessage` represents either: + +- A direct response to a client command, or +- An unsolicited event emitted by the server + +This document describes `ServerMessage` as a protocol-level concept. +It should not be confused with generated protobuf accessors or wire-level +encoding details. + +--- + +## Lifetime and Delivery + +`ServerMessage` instances are emitted by the server and delivered +asynchronously to clients. + +Delivery guarantees: + +- Messages are delivered in the order sent by the server +- Messages may be delivered at any time +- Events may be delivered before or after responses + +Clients **must** be prepared to handle `ServerMessage` objects at all times, +regardless of pending requests. + +--- + +## Message Type + +Each `ServerMessage` contains **exactly one payload**, identified by the +`message_type` field. + +The following message types are supported: + +- `RESPONSE` +- `SESSION_EVENT` +- `ROOM_EVENT` +- `GAME_EVENT_CONTAINER` + +A message containing multiple payloads or an unknown type is considered +malformed. + +--- + +## RESPONSE + +`RESPONSE` messages are direct replies to client-issued `CommandContainer` +messages. + +Characteristics: + +- Contain a `Response` payload +- Echo the client-assigned `cmd_id` +- Indicate success or failure of the request + +A single `CommandContainer` results in **at most one** `RESPONSE`. + +`RESPONSE` messages may be preceded or followed by event messages. + +--- + +## Events + +Event messages are unsolicited notifications emitted by the server. + +Event message types include: + +- `SESSION_EVENT` +- `ROOM_EVENT` +- `GAME_EVENT_CONTAINER` + +Event messages: + +- Are not correlated to a specific client command +- Do not include a `cmd_id` +- May be delivered at any time + +Clients **must** process events independently of responses. + +--- + +## Event Scopes + +### Session Events + +Session events are global to the client session and may include: + +- Server notifications +- Private messages +- User status updates + +--- + +### Room Events + +Room events are scoped to a specific room and are delivered only to clients +currently present in that room. + +--- + +### Game Events + +Game events are scoped to a specific game and are delivered only to +participating clients. + +Game events are grouped within a `GameEventContainer` to allow batching. + +--- + +## Event Batching + +Some `ServerMessage` types may contain multiple logical events. + +- `GameEventContainer` may batch multiple game events +- Other event types represent a single logical event + +Clients must process all contained events **in order**. + +--- + +## Error Semantics + +Errors are communicated **exclusively** via `RESPONSE` messages. + +Event messages are never used to signal command failure. + +Common error responses include: + +- `RespInvalidCommand` +- `RespLoginNeeded` +- `RespContextError` +- `RespChatFlood` + +--- + +## Related Concepts + +- CommandContainer +- Client State Machine +- Response + +--- + +## Reference Proto Definition + +```proto +message ServerMessage { + enum MessageType { + RESPONSE = 0; + SESSION_EVENT = 1; + GAME_EVENT_CONTAINER = 2; + ROOM_EVENT = 3; + } + + optional MessageType message_type = 1; + optional Response response = 2; + optional SessionEvent session_event = 3; + optional GameEventContainer game_event_container = 4; + optional RoomEvent room_event = 5; +} +``` \ No newline at end of file diff --git a/doc/doxygen/extra-pages/user_documentation/index.md b/doc/doxygen/extra-pages/user_documentation/index.md index d7e9d529d..468a28f8d 100644 --- a/doc/doxygen/extra-pages/user_documentation/index.md +++ b/doc/doxygen/extra-pages/user_documentation/index.md @@ -11,6 +11,11 @@ - @subpage beta_release +## Troubleshooting + +- @subpage fixing_card_pictures +- @subpage enabling_debug_logs + ## Syntax Help - @subpage search_syntax_help diff --git a/doc/doxygen/extra-pages/user_documentation/troubleshooting/enabling_debug_logs.md b/doc/doxygen/extra-pages/user_documentation/troubleshooting/enabling_debug_logs.md new file mode 100644 index 000000000..7e4196a50 --- /dev/null +++ b/doc/doxygen/extra-pages/user_documentation/troubleshooting/enabling_debug_logs.md @@ -0,0 +1,140 @@ +@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, 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 +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/extra-pages/user_documentation/troubleshooting/fixing_card_pictures.md b/doc/doxygen/extra-pages/user_documentation/troubleshooting/fixing_card_pictures.md new file mode 100644 index 000000000..78ba5586b --- /dev/null +++ b/doc/doxygen/extra-pages/user_documentation/troubleshooting/fixing_card_pictures.md @@ -0,0 +1,109 @@ +@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. + +# 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//` + +The following import naming schemes are recognized (using both `_` and `-` as separators): + +| 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. + +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/doc/doxygen/filters/proto2cpp.py b/doc/doxygen/filters/proto2cpp.py new file mode 100644 index 000000000..360935a48 --- /dev/null +++ b/doc/doxygen/filters/proto2cpp.py @@ -0,0 +1,285 @@ +#!/usr/bin/env python +## +# Doxygen filter for Google Protocol Buffers .proto files. +# This script converts .proto files into C++ style ones +# and prints the output to standard output. +# +# version 0.8-beta OSI +# +# How to enable this filter in Doxygen: +# 1. Generate Doxygen configuration file with command 'doxygen -g ' +# e.g. doxygen -g doxyfile +# 2. In the Doxygen configuration file, find FILE_PATTERNS and add *.proto +# FILE_PATTERNS = *.proto +# 3. In the Doxygen configuration file, find EXTENSION_MAPPING and add proto=C++ +# EXTENSION_MAPPING = proto=C++ +# 4. In the Doxygen configuration file, find INPUT_FILTER and add this script +# INPUT_FILTER = "python proto2cpp.py" +# 5. Run Doxygen with the modified configuration +# doxygen doxyfile +# +# +# Version 0.8 2018 Bugfix regarding long comments, remove typo +# Version 0.7 2018 Bugfix and extensions have been made by Open Simulation Interface (OSI) Carsten Kuebler https://github.com/OpenSimulationInterface, +# Copyright (C) 2016 Regents of the University of California https://github.com/vgteam/vg +# Copyright (C) 2012-2015 Timo Marjoniemi https://sourceforge.net/p/proto2cpp/wiki/Home/ +# All rights reserved. +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +# +## + +import fnmatch +import inspect +import os +import re +import sys + + +## Class for converting Google Protocol Buffers .proto files into C++ style output to enable Doxygen usage. +## +## The C++ style output is printed into standard output.
+## There are three different logging levels for the class: +##
  • #logNone: do not log anything
  • +##
  • #logErrors: log errors only
  • +##
  • #logAll: log everything
+## Logging level is determined by \c #logLevel.
+## Error logs are written to file determined by \c #errorLogFile.
+## Debug logs are written to file determined by \c #logFile. +# +class proto2cpp: + + ## Logging level: do not log anything. + logNone = 0 + ## Logging level: log errors only. + logErrors = 1 + ## Logging level: log everything. + logAll = 2 + + ## Constructor + # + def __init__(self): + ## Debug log file name. + self.logFile = "proto2cpp.log" + ## Error log file name. + self.errorLogFile = "proto2cpp.error.log" + ## Logging level. + self.logLevel = self.logNone + + ## Handles a file. + ## + ## If @p fileName has .proto suffix, it is processed through parseFile(). + ## Otherwise it is printed to stdout as is except for file \c proto2cpp.py without + ## path since it's the script given to python for processing. + ## + ## @param fileName Name of the file to be handled. + # + def handleFile(self, fileName): + if fnmatch.fnmatch(filename, '*.proto'): + self.log('\nXXXXXXXXXX\nXX ' + filename + '\nXXXXXXXXXX\n\n') + # Open the file. Use try to detect whether or not we have an actual file. + if (sys.version_info >= (3, 0)): + try: + with open(filename, 'r', encoding='utf8') as inputFile: + self.parseFile(inputFile) + pass + except IOError as e: + self.logError('the file ' + filename + ' could not be opened for reading') + else: + # Python 2 code in this block + try: + with open(filename, 'r') as inputFile: + self.parseFile(inputFile) + pass + except IOError as e: + self.logError('the file ' + filename + ' could not be opened for reading') + + elif not fnmatch.fnmatch(filename, os.path.basename(inspect.getfile(inspect.currentframe()))): + self.log('\nXXXXXXXXXX\nXX ' + filename + '\nXXXXXXXXXX\n\n') + if (sys.version_info > (3, 0)): + try: + with open(filename, 'r', encoding='utf8') as theFile: + output = '' + for theLine in theFile: + output += theLine + print(output) + self.log(output) + pass + except IOError as e: + self.logError('the file ' + filename + ' could not be opened for reading') + else: + # Python 2 code in this block + try: + with open(filename, 'r') as theFile: + output = '' + for theLine in theFile: + output += theLine + print(output) + self.log(output) + pass + except IOError as e: + self.logError('the file ' + filename + ' could not be opened for reading') + + else: + self.log('\nXXXXXXXXXX\nXX ' + filename + ' --skipped--\nXXXXXXXXXX\n\n') + + ## Parser function. + ## + ## The function takes a .proto file object as input + ## parameter and modifies the contents into C++ style. + ## The modified data is printed into standard output. + ## + ## @param inputFile Input file object + # + def parseFile(self, inputFile): + # Go through the input file line by line. + isEnum = False + isPackage = False + isMultilineComment = False + # This variable is here as a workaround for not getting extra line breaks (each line + # ends with a line separator and print() method will add another one). + # We will be adding lines into this var and then print the var out at the end. + theOutput = '' + for line in inputFile: + # Search for comment ("//") and add one more slash character ("/") to the comment + # block to make Doxygen detect it. + matchComment = re.search("//", line) + # Search for semicolon and if one is found before comment, add a third slash character + # ("/") and a smaller than ("<") character to the comment to make Doxygen detect it. + matchSemicolon = re.search(";", line) + if matchSemicolon is not None and (matchComment is not None and matchSemicolon.start() < matchComment.start()): + comment = "///<" + line[matchComment.end():] + # Replace '.' in nested message references with '::' + # don't work for multi-nested references and generates problems with URLs and acronyms + #comment = re.sub(r'\s(\w+)\.(\w+)\s', r' \1::\2 ', comment) + line = line[:matchComment.start()] + elif matchComment is not None: + if isMultilineComment: + comment = " * " + line[matchComment.end():] + else: + comment = "/** " + line[matchComment.end():] + isMultilineComment = True + # replace '.' in nested message references with '::' + # don't work for multi-nested references and generates problems with URLs and acronyms + #comment = re.sub(r'\s(\w+)\.(\w+)\s', r' \1::\2 ', comment) + line = line[:matchComment.start()] + else: + comment = "" + + # End multiline comment, if there is no comment or if there are some chars before the comment. + if (matchComment is None or len(line.strip())>0) and isMultilineComment: + theOutput += " */\n" + isMultilineComment = False + + # line = line.replace(".", "::") but not in quoted strings (Necessary for import statement) + line = re.sub(r'\.(?=(?:[^"]*"[^"]*")*[^"]*$)',r'::',line) + + # Search for " option ...;", remove it + line = re.sub(r'\boption\b[^;]+;', r'', line) + + # Search for " package ", make a namespace + matchPackage = re.search(r"\bpackage\b", line) + if matchPackage is not None: + isPackage = True + # Convert to C++-style separator and block instead of statement + line = "namespace" + line[:matchPackage.start()] + line[matchPackage.end():].replace(";", " {") + + # Search for " repeated " fields and make them ... + #matchRepeated = re.search(r"\brepeated\b", line) + #if matchRepeated is not None: + # # Convert + # line = re.sub(r'\brepeated\s+(\S+)', r' repeated \1', line) + + # Search for "enum", start changing all semicolons (";") to commas (","). + matchEnum = re.search(r"\benum\b", line) + if matchEnum is not None: + isEnum = True + + # Search semicolon if we have detected an enum, and replace semicolon with comma. + if isEnum is True and matchSemicolon is not None: + line = line.replace(";", ",") + + # Search for a closing brace. + matchClosingBrace = re.search("}", line) + if isEnum is True and matchClosingBrace is not None: + line = line[:matchClosingBrace.start()] + "};" + line[matchClosingBrace.end():] + isEnum = False + elif isEnum is False and matchClosingBrace is not None: + # Message (to be struct) ends => add semicolon so that it'll + # be a proper C(++) struct and Doxygen will handle it correctly. + line = line[:matchClosingBrace.start()] + "};" + line[matchClosingBrace.end():] + + # Replacements change start of comment... + matchMsg = re.search(r"\bmessage\b", line) + if matchMsg is not None: + line = line[:matchMsg.start()] + "struct" + line[matchMsg.end():] + + # Replacements change start of comment... + matchExt = re.search(r"\bextend\b", line) + if matchExt is not None: + a_extend = line[matchExt.end():] + matchName = re.search(r"\b\w[\S:]*\b", a_extend) + if matchName is not None: + name = a_extend[matchName.start():matchName.end()] + name = re.sub(r'\w+::',r'',name) + a_extend = a_extend[:matchName.start()] + name + ": public " + a_extend[matchName.start():] + else: + a_extend = "_Dummy: public " + a_extend; + line = line[:matchExt.start()] + "struct " + a_extend + + theOutput += (line.strip() + ' ' + comment.strip()).strip() + '\n' + + if isPackage: + # Close the package namespace + theOutput += "}" + isPackage = False + + # Now that we've got all lines in the string let's split the lines and print out + # one by one. + # This is a workaround to get rid of extra empty line at the end which print() method adds. + lines = theOutput.splitlines() + for line in lines: + if len(line) > 0: + print(line) # Add linebreak to generate documentation correct. + self.log(line + '\n') + + ## Writes @p string to log file. + ## + ## #logLevel must be #logAll or otherwise the logging is skipped. + ## + ## @param string String to be written to log file. + # + def log(self, string): + if self.logLevel >= self.logAll: + with open(self.logFile, 'a') as theFile: + theFile.write(string) + + ## Writes @p string to error log file. + ## + ## #logLevel must be #logError or #logAll or otherwise the logging is skipped. + ## + ## @param string String to be written to error log file. + # + def logError(self, string): + if self.logLevel >= self.logError: + with open(self.errorLogFile, 'a') as theFile: + theFile.write(string) + +converter = proto2cpp() +# Doxygen will give us the file names +for filename in sys.argv[1:]: + converter.handleFile(filename) + +# end of file \ No newline at end of file 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/libcockatrice_card/CMakeLists.txt b/libcockatrice_card/CMakeLists.txt index dd3799e33..15388ecfc 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 @@ -19,18 +21,17 @@ set(HEADERS libcockatrice/card/relation/card_relation.h ) -if(Qt6_FOUND) - qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) -elseif(Qt5_FOUND) - qt5_wrap_cpp(MOC_SOURCES ${HEADERS}) -endif() +qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) add_library( libcockatrice_card STATIC ${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 libcockatrice/card/database/card_database_manager.cpp libcockatrice/card/database/card_database_querier.cpp diff --git a/libcockatrice_card/libcockatrice/card/card_info.cpp b/libcockatrice_card/libcockatrice/card/card_info.cpp index 1aaec85b8..56c737793 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.cpp +++ b/libcockatrice_card/libcockatrice/card/card_info.cpp @@ -9,7 +9,6 @@ #include #include #include -#include #include #include @@ -19,22 +18,65 @@ class CardInfo; using CardInfoPtr = QSharedPointer; +const QHash &CardInfo::getPropertiesHash() const +{ + return properties.getProperties(); +} + +void CardInfo::setProperty(const QString &_name, const QString &_value) +{ + bool changed = properties.insert(_name, _value); + if (!changed) { + return; + } + + emit cardInfoChanged(smartThis); +} + CardInfo::CardInfo(const QString &_name, const QString &_text, bool _isToken, - QVariantHash _properties, + const QHash &_properties, const QList &_relatedCards, const QList &_reverseRelatedCards, SetToPrintingsMap _sets, - const UiAttributes _uiAttributes) - : name(_name), text(_text), isToken(_isToken), properties(std::move(_properties)), 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) { simpleName = CardInfo::simplifyName(name); refreshCachedSets(); } +CardInfo::CardInfo(const QString &_name, + const QString &_text, + bool _isToken, + const QByteArray &_propertiesBlob, + const QList &_relatedCards, + const QList &_reverseRelatedCards, + SetToPrintingsMap _sets, + const UiAttributes _uiAttributes, + QString _simpleName, + QSet _altNames, + QMap _localizedNames, + QMap _localizedTexts) + : name(_name), simpleName(std::move(_simpleName)), text(_text), isToken(_isToken), + 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)) +{ + // propertiesBlob is materialized lazily on first query; simpleName and + // altNames are supplied by the caller (binary cache). Only the set-name + // display list still depends on the current enabled-set state. + refreshCachedSetNames(); +} + CardInfoPtr CardInfo::newInstance(const QString &_name) { return newInstance(_name, "", false, {}, {}, {}, {}, {}); @@ -43,14 +85,16 @@ CardInfoPtr CardInfo::newInstance(const QString &_name) CardInfoPtr CardInfo::newInstance(const QString &_name, const QString &_text, bool _isToken, - QVariantHash _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) { @@ -63,6 +107,37 @@ CardInfoPtr CardInfo::newInstance(const QString &_name, return ptr; } +CardInfoPtr CardInfo::newInstance(const QString &_name, + const QString &_text, + bool _isToken, + QByteArray _propertiesBlob, + const QList &_relatedCards, + const QList &_reverseRelatedCards, + SetToPrintingsMap _sets, + const UiAttributes _uiAttributes, + QString _simpleName, + QSet _altNames, + 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(_localizedNames), std::move(_localizedTexts))); + ptr->setSmartPointer(ptr); + + if (_appendToSets) { + for (const auto &printings : _sets) { + for (const PrintingInfo &printing : printings) { + printing.getSet()->append(ptr); + break; + } + } + } + + return ptr; +} + QString CardInfo::getCorrectedName() const { // remove all the characters reserved in windows file paths, @@ -102,15 +177,16 @@ void CardInfo::addToSet(const CardSetPtr &_set, const PrintingInfo &_info) refreshCachedSets(); } -void CardInfo::combineLegalities(const QVariantHash &props) +void CardInfo::combineLegalities(const QHash &props) { - QHashIterator it(props); + QHashIterator it(props); while (it.hasNext()) { it.next(); if (it.key().startsWith("format-")) { - smartThis->setProperty(it.key(), it.value().toString()); + properties.insert(it.key(), it.value()); } } + emit cardInfoChanged(smartThis); } void CardInfo::refreshCachedSets() diff --git a/libcockatrice_card/libcockatrice/card/card_info.h b/libcockatrice_card/libcockatrice/card/card_info.h index 654ac1f63..17894cec5 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.h +++ b/libcockatrice_card/libcockatrice/card/card_info.h @@ -2,6 +2,7 @@ #define CARD_INFO_H #include "format/format_legality_rules.h" +#include "lazy_properties_hash.h" #include "printing/printing_info.h" #include @@ -10,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -69,12 +71,17 @@ private: * @anchor PrivateCardProperties */ ///@{ - CardInfoPtr smartThis; ///< Smart pointer to self for safe cross-references. - QString name; ///< Full name of the card. - 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. - QVariantHash properties; ///< Key-value store of dynamic card properties. + CardInfoPtr smartThis; ///< Smart pointer to self for safe cross-references. + QString name; ///< Full name of the card. + 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. + + 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. QList reverseRelatedCardsToMe; ///< Cards that consider this card as related. @@ -96,15 +103,55 @@ 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, - QVariantHash _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 + * state. + * + * Used by the binary cache reader to skip recomputing @p _simpleName and + * @p _altNames (which otherwise require a Unicode normalization and a full + * printing scan). Properties are supplied as a pre-serialized @p _propertiesBlob + * so the QHash is not built at load time (it is materialized on first + * query). + * + * @param _name The card name. + * @param _text Rules text or description of the card. + * @param _isToken Token flag. + * @param _propertiesBlob Pre-serialized properties blob (as written by the cache). + * @param _relatedCards Forward relationships. + * @param _reverseRelatedCards Reverse relationships. + * @param _sets Printing information per set. + * @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, + const QByteArray &_propertiesBlob, + const QList &_relatedCards, + const QList &_reverseRelatedCards, + SetToPrintingsMap _sets, + UiAttributes _uiAttributes, + QString _simpleName, + QSet _altNames, + QMap _localizedNames = {}, + QMap _localizedTexts = {}); /** * @brief Copy constructor for CardInfo. @@ -115,7 +162,8 @@ public: */ CardInfo(const CardInfo &other) : QObject(other.parent()), name(other.name), simpleName(other.simpleName), text(other.text), - isToken(other.isToken), properties(other.properties), 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) @@ -143,16 +191,56 @@ 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, - QVariantHash _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 + * derived state. + * + * @param _name Name of the card. + * @param _text Rules text or description. + * @param _isToken Token flag. + * @param _propertiesBlob Pre-serialized properties blob (as written by the cache). + * @param _relatedCards Forward relationships. + * @param _reverseRelatedCards Reverse relationships. + * @param _sets Printing information per set. + * @param _uiAttributes Attributes that affect display and game logic. + * @param _simpleName Precomputed simplified name. + * @param _altNames Precomputed alternate names. + * @param _appendToSets When true (default), the card is appended to each of + * 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, + const QString &_text, + bool _isToken, + QByteArray _propertiesBlob, + const QList &_relatedCards, + const QList &_reverseRelatedCards, + SetToPrintingsMap _sets, + UiAttributes _uiAttributes, + QString _simpleName, + QSet _altNames, + bool _appendToSets = true, + QMap _localizedNames = {}, + QMap _localizedTexts = {}); /** * @brief Clones the current CardInfo instance. @@ -202,26 +290,114 @@ 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; } [[nodiscard]] QStringList getProperties() const { - return properties.keys(); + return getPropertiesHash().keys(); } + [[nodiscard]] const QHash &getPropertiesHash() const; + [[nodiscard]] QString getProperty(const QString &propertyName) const { - return properties.value(propertyName).toString(); - } - void setProperty(const QString &_name, const QString &_value) - { - properties.insert(_name, _value); - emit cardInfoChanged(smartThis); + return getPropertiesHash().value(propertyName); } + void setProperty(const QString &_name, const QString &_value); [[nodiscard]] bool hasProperty(const QString &propertyName) const { - return properties.contains(propertyName); + return getPropertiesHash().contains(propertyName); } [[nodiscard]] const SetToPrintingsMap &getSets() const { @@ -325,7 +501,7 @@ public: * * @param props Key-value mapping of format legalities. */ - void combineLegalities(const QVariantHash &props); + void combineLegalities(const QHash &props); /** * @brief Refreshes all cached fields that are calculated from the contained sets and printings. diff --git a/libcockatrice_card/libcockatrice/card/card_info_comparator.cpp b/libcockatrice_card/libcockatrice/card/card_info_comparator.cpp index 821cc8675..cc0314e21 100644 --- a/libcockatrice_card/libcockatrice/card/card_info_comparator.cpp +++ b/libcockatrice_card/libcockatrice/card/card_info_comparator.cpp @@ -25,22 +25,13 @@ bool CardInfoComparator::operator()(const CardInfoPtr &a, const CardInfoPtr &b) bool CardInfoComparator::compareVariants(const QVariant &a, const QVariant &b) const { - // Determine the type of QVariant based on Qt version -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) if (a.typeId() != b.typeId()) { -#else - if (a.type() != b.type()) { -#endif // If they are not the same type, compare as strings return a.toString() < b.toString(); } // Perform type-specific comparison -#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) switch (static_cast(a.typeId())) { -#else - switch (static_cast(a.type())) { -#endif case static_cast(QMetaType::Int): return a.toInt() < b.toInt(); case static_cast(QMetaType::Double): @@ -66,7 +57,7 @@ QVariant CardInfoComparator::getProperty(const CardInfoPtr &card, const QString return card->getIsToken(); } - // Otherwise, check if it's a custom property in the QVariantHash + // Otherwise, check if it's a custom property in the properties hash if (card->hasProperty(property)) { return card->getProperty(property); } diff --git a/libcockatrice_card/libcockatrice/card/card_localization.cpp b/libcockatrice_card/libcockatrice/card/card_localization.cpp new file mode 100644 index 000000000..03c6c659b --- /dev/null +++ b/libcockatrice_card/libcockatrice/card/card_localization.cpp @@ -0,0 +1,38 @@ +#include "card_localization.h" + +#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..a9c8d28ea --- /dev/null +++ b/libcockatrice_card/libcockatrice/card/card_localization.h @@ -0,0 +1,43 @@ +#ifndef CARD_LOCALIZATION_H +#define CARD_LOCALIZATION_H + +#include +#include + +/** + * @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 261b36690..209b9d2c2 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database.cpp @@ -20,6 +20,7 @@ CardDatabase::CardDatabase(QObject *parent, { qRegisterMetaType("CardInfoPtr"); qRegisterMetaType("CardSetPtr"); + qRegisterMetaType("CardDatabaseData"); // create loader and wire it up loader = new CardDatabaseLoader(this, this, pathProvider, prefs, setPriorityController); @@ -28,6 +29,10 @@ CardDatabase::CardDatabase(QObject *parent, connect(loader, &CardDatabaseLoader::loadingFailed, this, &CardDatabase::cardDatabaseLoadingFailed); connect(loader, &CardDatabaseLoader::newSetsFound, this, &CardDatabase::cardDatabaseNewSetsFound); connect(loader, &CardDatabaseLoader::allNewSetsEnabled, this, &CardDatabase::cardDatabaseAllNewSetsEnabled); + // swap the finished snapshot into the live database. Uses AutoConnection so + // that cross-thread loads are delivered via the event loop while same-thread/ + // synchronous callers get the swap immediately. + connect(loader, &CardDatabaseLoader::databaseDataReady, this, &CardDatabase::swapInDatabaseData); querier = new CardDatabaseQuerier(this, this, prefs); } @@ -66,7 +71,12 @@ void CardDatabase::reloadCardDatabasesAndNotify() loadCardDatabases(); if (loadStatus == Ok) { - notifyEnabledSetsChanged(); + checkUnknownSets(); + // A reload reconstructs the exact same enabled-set state, so the cached + // set-name / alt-name data computed during construction is still valid. + // Skipping the per-card refresh avoids a full 36k-card recompute that + // would otherwise duplicate the work already done while building cards. + notifyEnabledSetsChanged(false); } } @@ -77,13 +87,18 @@ bool CardDatabase::saveCustomTokensToFile() void CardDatabase::refreshCachedReverseRelatedCards() { - for (const auto &card : cards) { + refreshCachedReverseRelatedCards(cards); +} + +void CardDatabase::refreshCachedReverseRelatedCards(CardNameMap &cardMap) +{ + for (const auto &card : cardMap) { card->resetReverseRelatedCards2Me(); } - for (const auto &card : cards) { + for (const auto &card : cardMap) { for (auto *rel : card->getReverseRelatedCards()) { - if (auto target = cards.value(rel->getName())) { + if (auto target = cardMap.value(rel->getName())) { auto *newRel = new CardRelation(card->getName(), rel->getAttachType(), rel->getIsCreateAllExclusion(), rel->getIsVariable(), rel->getDefaultCount(), rel->getIsPersistent(), rel->getIsFaceDown()); @@ -205,11 +220,15 @@ void CardDatabase::markAllSetsAsKnown() _sets.markAllAsKnown(); } -void CardDatabase::notifyEnabledSetsChanged() +void CardDatabase::notifyEnabledSetsChanged(bool recomputeCachedSets) { - // refresh the list of cached set names - for (const CardInfoPtr &card : cards) { - card->refreshCachedSets(); + if (recomputeCachedSets) { + // refresh the list of cached set names / alt names for every card; this is + // only needed when the enabled-set state actually changed (e.g. the user + // toggled a set in the settings UI). + for (const CardInfoPtr &card : cards) { + card->refreshCachedSets(); + } } // inform the carddatabasemodels that they need to re-check their list of cards @@ -220,3 +239,30 @@ void CardDatabase::addFormat(const FormatRulesPtr &format) { formats.insert(format->formatName.toLower(), format); } + +void CardDatabase::swapInDatabaseData(CardDatabaseData data) +{ + for (const auto &card : cards) { + for (auto *rel : card->getRelatedCards()) { + rel->deleteLater(); + } + for (auto *rel : card->getReverseRelatedCards()) { + rel->deleteLater(); + } + for (auto *rel : card->getReverseRelatedCards2Me()) { + rel->deleteLater(); + } + } + + cards = std::move(data.cards); + simpleNameCards = std::move(data.simpleNameCards); + sets = std::move(data.sets); + formats = std::move(data.formats); + + loadStatus = cards.isEmpty() ? NotLoaded : Ok; + + // inform listeners that the whole database was replaced; they should + // rebuild from the live containers in a single batch instead of reacting + // to individual card additions. + emit cardDatabaseReset(); +} diff --git a/libcockatrice_card/libcockatrice/card/database/card_database.h b/libcockatrice_card/libcockatrice/card/database/card_database.h index 44838962d..b10e3c726 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database.h +++ b/libcockatrice_card/libcockatrice/card/database/card_database.h @@ -2,6 +2,7 @@ #define CARDDATABASE_H #include "../set/card_set_list.h" +#include "card_database_data.h" #include "card_database_loader.h" #include "card_database_querier.h" @@ -54,16 +55,17 @@ protected: CardDatabaseQuerier *querier; private: - /** - * @brief Check for sets that are unknown and emit signals if needed. - */ - void checkUnknownSets(); - /** * @brief Refreshes the cached reverse-related cards for all cards. */ void refreshCachedReverseRelatedCards(); + /** + * @brief Refreshes the cached reverse-related cards for the given card map. + * @param cardMap Map of cards to process. + */ + void refreshCachedReverseRelatedCards(CardNameMap &cardMap); + /** @brief Mutexes for thread safety. */ QBasicMutex *clearDatabaseMutex = new QBasicMutex(), *addCardMutex = new QBasicMutex(), *removeCardMutex = new QBasicMutex(); @@ -127,8 +129,25 @@ public: /** @brief Marks all sets as known. */ void markAllSetsAsKnown(); - /** @brief Notifies listeners that enabled sets changed. */ - void notifyEnabledSetsChanged(); + /** + * @brief Notifies listeners that enabled sets changed. + * @param recomputeCachedSets When true, every card's cached set-name / alt-name + * data is recomputed (needed only when enabled-set state actually + * changed). Pass false after a plain reload, where enabled-set state is + * unchanged and the cached data is still valid. + */ + void notifyEnabledSetsChanged(bool recomputeCachedSets = true); + + /** + * @brief Check for sets that are unknown and emit signals if needed. + * + * Called from MainWindow::startupConfigCheck() after signal connections are + * live (the front-loaded parse in main() runs before MainWindow exists, so + * signals emitted there would have no receivers). May also be called by + * the loader via the friend declaration. Has side effects: enables all + * sets when none are enabled (first-run), marks all sets as known. + */ + void checkUnknownSets(); ICardSetPriorityController *getPriorityController() { @@ -159,10 +178,26 @@ public slots: */ bool saveCustomTokensToFile(); + /** + * @brief Replaces the entire contents of the database with the provided + * snapshot in a single operation. Intended to be called once a + * background load has finished building a CardDatabaseData, so that + * observers never see a partially-populated database. + * @param data The fully-built snapshot to adopt. + */ + void swapInDatabaseData(CardDatabaseData data); + signals: /** @brief Emitted when the card database has finished loading successfully. */ void cardDatabaseLoadingFinished(); + /** + * @brief Emitted once after the live database contents have been replaced + * by a freshly loaded snapshot. Models should rebuild from the + * database in a single batch rather than reacting to per-card adds. + */ + void cardDatabaseReset(); + /** @brief Emitted when the card database fails to load. */ void cardDatabaseLoadingFailed(); diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp new file mode 100644 index 000000000..3165ff871 --- /dev/null +++ b/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp @@ -0,0 +1,525 @@ +#include "card_database_cache.h" + +#include "../card_info.h" +#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 +#include +#include +#include +#include + +namespace +{ +constexpr quint32 CACHE_MAGIC = 0x43445243; // "CDRC" +constexpr quint32 CACHE_VERSION = 3; + +// ---- Primitives ----------------------------------------------------------- + +void writeString(QDataStream &out, const QString &s) +{ + out << s; +} + +QString readString(QDataStream &in) +{ + QString s; + in >> s; + return s; +} + +// Stores a QHash as a single pre-serialized blob. The reader keeps the +// blob as-is and materializes the QHash lazily on first query, which is +// what removes the allocation storm from database load (see card_info.cpp / +// printing_info.cpp). +void writeHashBlob(QDataStream &out, const QHash &h) +{ + QByteArray blob; + QDataStream blobOut(&blob, QIODevice::WriteOnly); + blobOut.setVersion(QDataStream::Qt_6_4); + blobOut << h; + out << static_cast(blob.size()); + out.writeRawData(blob.constData(), blob.size()); +} + +QByteArray readHashBlob(QDataStream &in) +{ + quint32 len = 0; + in >> len; + if (in.status() != QDataStream::Ok || static_cast(len) > in.device()->bytesAvailable()) { + return {}; + } + QByteArray blob(len, Qt::Uninitialized); + in.readRawData(blob.data(), static_cast(len)); + return blob; +} + +void writeDate(QDataStream &out, const QDate &d) +{ + out << d; +} + +QDate readDate(QDataStream &in) +{ + QDate d; + in >> d; + 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) +{ + writeString(out, rel->getName()); + out << static_cast(rel->getAttachType()); + out << rel->getIsCreateAllExclusion(); + out << rel->getIsVariable(); + out << rel->getDefaultCount(); + out << rel->getIsPersistent(); + out << rel->getIsFaceDown(); +} + +CardRelation *readRelation(QDataStream &in) +{ + QString name = readString(in); + quint32 attachType = 0; + bool isExclusion = false; + bool isVariable = false; + int defaultCount = 1; + bool isPersistent = false; + bool isFaceDown = false; + in >> attachType; + in >> isExclusion; + in >> isVariable; + in >> defaultCount; + in >> isPersistent; + in >> isFaceDown; + return new CardRelation(name, static_cast(attachType), isExclusion, isVariable, defaultCount, + isPersistent, isFaceDown); +} + +// ---- PrintingInfo ---------------------------------------------------------- + +// A printing references its set by short name; the CardSetPtr is resolved after +// all sets have been reconstructed on read. +void writePrinting(QDataStream &out, const PrintingInfo &p) +{ + writeString(out, p.getSet() ? p.getSet()->getShortName() : QString()); + writeHashBlob(out, p.getPropertiesHash()); +} + +PrintingInfo readPrinting(QDataStream &in, const SetNameMap &sets) +{ + QString setName = readString(in); + QByteArray propsBlob = readHashBlob(in); + auto set = sets.value(setName); + + return PrintingInfo(set, LazyPropertiesHash(propsBlob)); +} + +// ---- CardSet --------------------------------------------------------------- + +void writeSet(QDataStream &out, const CardSetPtr &set) +{ + writeString(out, set->getShortName()); + writeString(out, set->getLongName()); + writeString(out, set->getSetType()); + writeDate(out, set->getReleaseDate()); + out << static_cast(set->getPriority()); +} + +CardSetPtr readSet(QDataStream &in, ICardSetPriorityController *priorityController) +{ + QString shortName = readString(in); + QString longName = readString(in); + QString setType = readString(in); + QDate releaseDate = readDate(in); + quint32 priority = 0; + in >> priority; + return CardSet::newInstance(priorityController, shortName, longName, setType, releaseDate, + static_cast(priority)); +} + +// ---- CardInfo -------------------------------------------------------------- + +void writeCard(QDataStream &out, const CardInfoPtr &card) +{ + writeString(out, card->getName()); + writeString(out, card->getText()); + out << card->getIsToken(); + writeHashBlob(out, card->getPropertiesHash()); + + CardInfo::UiAttributes ui = card->getUiAttributes(); + out << ui.cipt; + out << ui.landscapeOrientation; + out << ui.tableRow; + out << ui.upsideDownArt; + + // Precomputed derived state, so the reader can skip simplifyName() and the + // per-printing alt-name scan entirely. + writeString(out, card->getSimpleName()); + { + const QSet altNames = card->getAltNames(); + out << static_cast(altNames.size()); + for (const QString &alt : altNames) { + writeString(out, alt); + } + } + + // setsToPrintings + const SetToPrintingsMap sets = card->getSets(); + out << static_cast(sets.size()); + for (auto it = sets.constBegin(); it != sets.constEnd(); ++it) { + writeString(out, it.key()); + out << static_cast(it.value().size()); + for (const PrintingInfo &p : it.value()) { + writePrinting(out, p); + } + } + + // related cards + const QList related = card->getRelatedCards(); + out << static_cast(related.size()); + for (const CardRelation *rel : related) { + writeRelation(out, rel); + } + + // reverse-related cards (the reverseRelatedCards list, not the computed 2Me) + const QList reverse = card->getReverseRelatedCards(); + out << static_cast(reverse.size()); + 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) +{ + QString name = readString(in); + QString text = readString(in); + bool isToken = false; + in >> isToken; + QByteArray propertiesBlob = readHashBlob(in); + + CardInfo::UiAttributes ui; + in >> ui.cipt; + in >> ui.landscapeOrientation; + in >> ui.tableRow; + in >> ui.upsideDownArt; + + // Precomputed derived state, restored directly to skip simplifyName() and the + // per-printing alt-name scan. + QString simpleName = readString(in); + QSet altNames; + quint32 altNameCount = 0; + in >> altNameCount; + if (in.status() != QDataStream::Ok) { + return nullptr; + } + altNames.reserve(altNameCount); + for (quint32 i = 0; i < altNameCount; ++i) { + altNames.insert(readString(in)); + } + + SetToPrintingsMap cardSets; + quint32 setCount = 0; + in >> setCount; + if (in.status() != QDataStream::Ok) { + return nullptr; + } + for (quint32 i = 0; i < setCount; ++i) { + QString setName = readString(in); + quint32 printingCount = 0; + in >> printingCount; + if (in.status() != QDataStream::Ok) { + return nullptr; + } + QList printings; + printings.reserve(printingCount); + for (quint32 j = 0; j < printingCount; ++j) { + printings.append(readPrinting(in, sets)); + } + cardSets.insert(setName, printings); + } + + QList related; + quint32 relatedCount = 0; + in >> relatedCount; + if (in.status() != QDataStream::Ok) { + return nullptr; + } + related.reserve(relatedCount); + for (quint32 i = 0; i < relatedCount; ++i) { + related.append(readRelation(in)); + } + + QList reverse; + quint32 reverseCount = 0; + in >> reverseCount; + if (in.status() != QDataStream::Ok) { + return nullptr; + } + reverse.reserve(reverseCount); + for (quint32 i = 0; i < reverseCount; ++i) { + reverse.append(readRelation(in)); + } + + 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 ----------------------------------------------------------- + +void writeFormat(QDataStream &out, const FormatRulesPtr &format) +{ + writeString(out, format->formatName); + out << format->minDeckSize; + out << format->maxDeckSize; + out << format->maxSideboardSize; + + out << static_cast(format->allowedCounts.size()); + for (const AllowedCount &ac : format->allowedCounts) { + out << ac.max; + writeString(out, ac.label); + } + + out << static_cast(format->exceptions.size()); + for (const ExceptionRule &ex : format->exceptions) { + out << static_cast(ex.conditions.size()); + for (const CardCondition &cond : ex.conditions) { + writeString(out, cond.field); + writeString(out, cond.matchType); + writeString(out, cond.value); + } + out << ex.maxCopies; + } +} + +FormatRulesPtr readFormat(QDataStream &in) +{ + FormatRulesPtr format(new FormatRules()); + format->formatName = readString(in); + in >> format->minDeckSize; + in >> format->maxDeckSize; + in >> format->maxSideboardSize; + + quint32 allowedCount = 0; + in >> allowedCount; + if (in.status() != QDataStream::Ok) { + return nullptr; + } + for (quint32 i = 0; i < allowedCount; ++i) { + AllowedCount ac; + in >> ac.max; + ac.label = readString(in); + format->allowedCounts.append(ac); + } + + quint32 exceptionCount = 0; + in >> exceptionCount; + if (in.status() != QDataStream::Ok) { + return nullptr; + } + for (quint32 i = 0; i < exceptionCount; ++i) { + ExceptionRule ex; + quint32 condCount = 0; + in >> condCount; + if (in.status() != QDataStream::Ok) { + return nullptr; + } + for (quint32 j = 0; j < condCount; ++j) { + CardCondition cond; + cond.field = readString(in); + cond.matchType = readString(in); + cond.value = readString(in); + ex.conditions.append(cond); + } + in >> ex.maxCopies; + format->exceptions.append(ex); + } + return format; +} + +} // namespace + +bool CardDatabaseCache::write(const QString &cachePath, const CardDatabaseData &data, const QByteArray &sourceHash) +{ + QSaveFile file(cachePath); + if (!file.open(QIODevice::WriteOnly)) { + return false; + } + + QDataStream out(&file); + out.setVersion(QDataStream::Qt_6_4); + + // Header + out << CACHE_MAGIC; + out << CACHE_VERSION; + out << sourceHash; + + // Sets + out << static_cast(data.sets.size()); + for (const CardSetPtr &set : data.sets) { + writeSet(out, set); + } + + // Cards + const quint32 cardCount = static_cast(data.cards.size()); + out << cardCount; + for (const CardInfoPtr &card : data.cards) { + writeCard(out, card); + } + + // Formats + out << static_cast(data.formats.size()); + for (const FormatRulesPtr &format : data.formats) { + writeFormat(out, format); + } + + return file.commit(); +} + +bool CardDatabaseCache::read(const QString &cachePath, + CardDatabaseData &data, + const QByteArray &sourceHash, + ICardSetPriorityController *priorityController) +{ + QFile file(cachePath); + if (!file.open(QIODevice::ReadOnly)) { + return false; + } + + // Read the whole cache into memory up front; deserialization then works on an + // in-memory buffer with no further disk I/O. + QByteArray raw = file.readAll(); + if (raw.isEmpty()) { + return false; + } + + QBuffer buffer(&raw); + buffer.open(QIODevice::ReadOnly); + QDataStream in(&buffer); + in.setVersion(QDataStream::Qt_6_4); + + quint32 magic = 0; + quint32 version = 0; + QByteArray storedHash; + in >> magic >> version >> storedHash; + if (magic != CACHE_MAGIC || version != CACHE_VERSION || storedHash != sourceHash) { + return false; + } + + QElapsedTimer deserializeTimer; + deserializeTimer.start(); + + // Sets + quint32 setCount = 0; + in >> setCount; + if (in.status() != QDataStream::Ok) { + return false; + } + for (quint32 i = 0; i < setCount; ++i) { + CardSetPtr set = readSet(in, priorityController); + data.sets.insert(set->getShortName(), set); + } + + // Cards + quint32 cardCount = 0; + in >> cardCount; + if (in.status() != QDataStream::Ok) { + return false; + } + if (cardCount > 0) { + data.cards.reserve(static_cast(cardCount)); + for (quint32 i = 0; i < cardCount; ++i) { + CardInfoPtr card = readCard(in, data.sets); + if (card == nullptr) { + return false; + } + data.cards.insert(card->getName(), card); + data.simpleNameCards.insert(card->getSimpleName(), card); + } + + for (const CardInfoPtr &card : data.cards) { + for (const auto &printings : card->getSets()) { + for (const PrintingInfo &printing : printings) { + if (auto set = printing.getSet()) { + set->append(card); + break; + } + } + } + } + } + + // Formats + quint32 formatCount = 0; + in >> formatCount; + if (in.status() != QDataStream::Ok) { + return false; + } + for (quint32 i = 0; i < formatCount; ++i) { + FormatRulesPtr format = readFormat(in); + if (format == nullptr) { + return false; + } + data.formats.insert(format->formatName.toLower(), format); + } + + qCInfo(CardDatabaseLoadingLog) << "[cache] read + deserialize" << deserializeTimer.elapsed() << "ms for" + << cardCount << "cards"; + + if (in.status() != QDataStream::Ok) { + return false; + } + + return file.error() == QFile::NoError; +} diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_cache.h b/libcockatrice_card/libcockatrice/card/database/card_database_cache.h new file mode 100644 index 000000000..40ff96541 --- /dev/null +++ b/libcockatrice_card/libcockatrice/card/database/card_database_cache.h @@ -0,0 +1,34 @@ +#ifndef CARDDATABASE_CACHE_H +#define CARDDATABASE_CACHE_H + +#include "card_database_data.h" + +#include +#include + +namespace CardDatabaseCache +{ +/** + * @brief Writes a fully-built database snapshot to a binary cache file. + * @param cachePath Path of the cache file to write. + * @param data The snapshot to serialize. + * @param sourceHash Hash of the source XML used to validate the cache later. + * @return True if the cache was written successfully. + */ +bool write(const QString &cachePath, const CardDatabaseData &data, const QByteArray &sourceHash); + +/** + * @brief Reads a database snapshot from a binary cache file. + * @param cachePath Path of the cache file to read. + * @param data Snapshot to populate. + * @param sourceHash Expected hash of the source XML; read fails if it differs. + * @param priorityController Controller used to reconstruct CardSet state. + * @return True if a valid, up-to-date cache was read. + */ +bool read(const QString &cachePath, + CardDatabaseData &data, + const QByteArray &sourceHash, + ICardSetPriorityController *priorityController); +} // namespace CardDatabaseCache + +#endif // CARDDATABASE_CACHE_H diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_data.h b/libcockatrice_card/libcockatrice/card/database/card_database_data.h new file mode 100644 index 000000000..2ca087b7e --- /dev/null +++ b/libcockatrice_card/libcockatrice/card/database/card_database_data.h @@ -0,0 +1,24 @@ +#ifndef CARDDATABASE_DATA_H +#define CARDDATABASE_DATA_H + +#include + +/** + * @struct CardDatabaseData + * @ingroup CardDatabase + * @brief Value type holding the full parsed state of a card database. + * + * Parsers fill a CardDatabaseData off the UI thread; the finished snapshot is + * then swapped into the live CardDatabase atomically. Keeping this separate from + * the live container avoids emitting per-card change notifications and prevents + * readers from observing a half-loaded database. + */ +struct CardDatabaseData +{ + CardNameMap cards; + CardNameMap simpleNameCards; + SetNameMap sets; + FormatRulesNameMap formats; +}; + +#endif // CARDDATABASE_DATA_H diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp index 76abf87ce..47f7eee6d 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database_loader.cpp @@ -1,12 +1,18 @@ #include "card_database_loader.h" #include "card_database.h" +#include "card_database_cache.h" +#include "parser/card_database_parser.h" #include "parser/cockatrice_xml_3.h" #include "parser/cockatrice_xml_4.h" +#include +#include +#include #include #include #include +#include #include CardDatabaseLoader::CardDatabaseLoader(QObject *parent, @@ -14,18 +20,14 @@ CardDatabaseLoader::CardDatabaseLoader(QObject *parent, ICardDatabasePathProvider *_pathProvider, ICardPreferenceProvider *_preferenceProvider, ICardSetPriorityController *_priorityController) - : QObject(parent), database(db), pathProvider(_pathProvider) + : QObject(parent), database(db), pathProvider(_pathProvider), priorityController(_priorityController) { - // instantiate available parsers here and connect them to the database + // instantiate available parsers here availableParsers << new CockatriceXml4Parser(_preferenceProvider, _priorityController); availableParsers << new CockatriceXml3Parser(_priorityController); - for (auto *p : availableParsers) { - // connect parser outputs to the database adders - connect(p, &ICardDatabaseParser::addCard, database, &CardDatabase::addCard, Qt::DirectConnection); - connect(p, &ICardDatabaseParser::addSet, database, &CardDatabase::addSet, Qt::DirectConnection); - connect(p, &ICardDatabaseParser::addFormat, database, &CardDatabase::addFormat, Qt::DirectConnection); - } + // 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, @@ -38,7 +40,7 @@ CardDatabaseLoader::~CardDatabaseLoader() availableParsers.clear(); } -LoadStatus CardDatabaseLoader::loadFromFile(const QString &fileName) +LoadStatus CardDatabaseLoader::loadFromFile(const QString &fileName, CardDatabaseData &data) { QFile file(fileName); if (!file.open(QIODevice::ReadOnly)) { @@ -49,7 +51,7 @@ LoadStatus CardDatabaseLoader::loadFromFile(const QString &fileName) file.reset(); if (parser->getCanParseFile(fileName, file)) { file.reset(); - parser->parseFile(file); + parser->parseFileInto(file, data); return Ok; } } @@ -57,24 +59,29 @@ LoadStatus CardDatabaseLoader::loadFromFile(const QString &fileName) return Invalid; } -LoadStatus CardDatabaseLoader::loadCardDatabase(const QString &path) +LoadStatus CardDatabaseLoader::loadCardDatabase(const QString &path, CardDatabaseData &data) { auto startTime = QTime::currentTime(); LoadStatus tempLoadStatus = NotLoaded; if (!path.isEmpty()) { QMutexLocker locker(loadFromFileMutex); - tempLoadStatus = loadFromFile(path); + tempLoadStatus = loadFromFile(path, data); } int msecs = startTime.msecsTo(QTime::currentTime()); qCInfo(CardDatabaseLoadingLog) << "Loaded card database: Path =" << path << "Status =" << tempLoadStatus - << "Cards =" << (database ? database->cards.size() : 0) - << "Sets =" << (database ? database->sets.size() : 0) << QString("%1ms").arg(msecs); + << "Cards =" << data.cards.size() << "Sets =" << data.sets.size() + << QString("%1ms").arg(msecs); return tempLoadStatus; } LoadStatus CardDatabaseLoader::loadCardDatabases() +{ + return doLoadCardDatabases(); +} + +LoadStatus CardDatabaseLoader::doLoadCardDatabases() { QMutexLocker locker(reloadDatabaseMutex); @@ -86,31 +93,55 @@ LoadStatus CardDatabaseLoader::loadCardDatabases() emit loadingStarted(); qCInfo(CardDatabaseLoadingLog) << "Card Database Loading Started"; - database->clear(); // remove old db + ICardDatabaseParser::clearSetlist(); - LoadStatus loadStatus = loadCardDatabase(pathProvider->getCardDatabasePath()); // load main card database - loadCardDatabase(pathProvider->getTokenDatabasePath()); // load tokens database - loadCardDatabase(pathProvider->getSpoilerCardDatabasePath()); // load spoilers database + CardDatabaseData data; + LoadStatus loadStatus = NotLoaded; - // find all custom card databases, recursively & following symlinks - // then load them alphabetically + // Try the binary cache first: a cache hit avoids re-parsing the (large) XML. const QStringList customPaths = collectCustomDatabasePaths(); - for (int i = 0; i < customPaths.size(); ++i) { - const auto &p = customPaths.at(i); - qCInfo(CardDatabaseLoadingLog) << "Loading Custom Set" << i << "(" << p << ")"; - loadCardDatabase(p); + const QByteArray sourceHash = computeSourceHash(customPaths); + if (loadFromCache(data, sourceHash)) { + qCInfo(CardDatabaseLoadingLog) << "Loaded card database from binary cache"; + loadStatus = Ok; + } else { + loadStatus = loadCardDatabase(pathProvider->getCardDatabasePath(), data); // main card database + if (loadStatus == Ok) { + loadCardDatabase(pathProvider->getTokenDatabasePath(), data); // tokens database + loadCardDatabase(pathProvider->getSpoilerCardDatabasePath(), data); // spoilers database + + // find all custom card databases, recursively & following symlinks + // then load them alphabetically + for (int i = 0; i < customPaths.size(); ++i) { + const auto &p = customPaths.at(i); + qCInfo(CardDatabaseLoadingLog) << "Loading Custom Set" << i << "(" << p << ")"; + loadCardDatabase(p, data); + } + + if (!saveToCache(data, sourceHash)) { + qCWarning(CardDatabaseLoadingLog) << "Failed to write binary cache to" << cachePath(); + } + } } - // AFTER all the cards have been loaded - - // resolve the reverse-related tags - - database->refreshCachedReverseRelatedCards(); - + // AFTER all the cards have been loaded: resolve the reverse-related tags + // against the fully-built snapshot. if (loadStatus == Ok) { - database->checkUnknownSets(); // update deck editors, etc + database->refreshCachedReverseRelatedCards(data.cards); + qCInfo(CardDatabaseLoadingSuccessOrFailureLog) << "Card Database Loading Success"; + emit databaseDataReady(std::move(data)); emit loadingFinished(); + // During the front-loaded parse in main() this runs before MainWindow + // exists, so cardDatabaseNewSetsFound / cardDatabaseAllNewSetsEnabled + // would be emitted with no receivers. Skip the check on the first load; + // MainWindow::startupConfigCheck() calls checkUnknownSets() once its + // signal connections are live. On subsequent reloads (e.g. path change) + // MainWindow exists and signals have live receivers. + if (initialLoadComplete) { + database->checkUnknownSets(); + } + initialLoadComplete = true; } else { qCInfo(CardDatabaseLoadingSuccessOrFailureLog) << "Card Database Loading Failed"; emit loadingFailed(); // bring up the settings dialog @@ -119,6 +150,55 @@ LoadStatus CardDatabaseLoader::loadCardDatabases() return loadStatus; } +QString CardDatabaseLoader::cachePath() const +{ + return pathProvider->getCardDatabasePath() + ".cache"; +} + +QByteArray CardDatabaseLoader::computeSourceHash(const QStringList &customPaths) const +{ + // Hash over the paths, sizes and modification times of every input file so + // the cache invalidates when any source changes. Cheap (no content read). + QCryptographicHash hash(QCryptographicHash::Sha256); + + // Include the application version so parser changes automatically invalidate + // old caches even when the XML files are byte-identical. + hash.addData(QCoreApplication::applicationVersion().toUtf8()); + hash.addData(QByteArray(1, '\0')); + + const QStringList inputs = QStringList() + << pathProvider->getCardDatabasePath() << pathProvider->getTokenDatabasePath() + << pathProvider->getSpoilerCardDatabasePath() << customPaths; + for (const QString &path : inputs) { + QFileInfo info(path); + if (info.exists()) { + hash.addData(path.toUtf8()); + hash.addData(QByteArray(1, '\0')); + hash.addData(QByteArray::number(info.size())); + hash.addData(QByteArray(1, '\0')); + hash.addData(QByteArray::number(info.lastModified().toSecsSinceEpoch())); + hash.addData(QByteArray(1, '\0')); + } + } + return hash.result(); +} + +bool CardDatabaseLoader::loadFromCache(CardDatabaseData &data, const QByteArray &sourceHash) +{ + if (sourceHash.isEmpty()) { + return false; + } + return CardDatabaseCache::read(cachePath(), data, sourceHash, priorityController); +} + +bool CardDatabaseLoader::saveToCache(const CardDatabaseData &data, const QByteArray &sourceHash) +{ + if (sourceHash.isEmpty()) { + return false; + } + return CardDatabaseCache::write(cachePath(), data, sourceHash); +} + QStringList CardDatabaseLoader::collectCustomDatabasePaths() const { QDirIterator it(pathProvider->getCustomCardDatabasePath(), {"*.xml"}, QDir::Files, diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_loader.h b/libcockatrice_card/libcockatrice/card/database/card_database_loader.h index 861cc95b0..6dcb06bf2 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_loader.h +++ b/libcockatrice_card/libcockatrice/card/database/card_database_loader.h @@ -4,6 +4,7 @@ #include #include #include +#include #include #include #include @@ -62,16 +63,20 @@ public: public slots: /** * @brief Loads all configured card databases. + * + * Runs synchronously on the calling thread. The caller should ensure + * that any signal receivers are already connected before invoking this. * @return Status of the main database load. */ LoadStatus loadCardDatabases(); /** - * @brief Loads a single card database file. + * @brief Loads a single card database file into the given snapshot. * @param path Path to the database file. + * @param data Snapshot to populate. * @return LoadStatus indicating success or failure. */ - LoadStatus loadCardDatabase(const QString &path); + LoadStatus loadCardDatabase(const QString &path, CardDatabaseData &data); /** * @brief Saves custom tokens to the user-defined custom database path. @@ -89,6 +94,12 @@ signals: /** @brief Emitted when loading fails. */ void loadingFailed(); + /** + * @brief Emitted with the fully-built snapshot once background loading + * completes. The receiver swaps it into the live database. + */ + void databaseDataReady(CardDatabaseData data); + /** * @brief Emitted when new sets are discovered during loading. * @param numSets Number of new sets. @@ -101,11 +112,18 @@ signals: private: /** - * @brief Loads a database from a single file using the available parsers. + * @brief Parses a single file into the given snapshot using the available parsers. * @param fileName Path to the database file. + * @param data Snapshot to populate. * @return LoadStatus indicating success or failure. */ - LoadStatus loadFromFile(const QString &fileName); + LoadStatus loadFromFile(const QString &fileName, CardDatabaseData &data); + + /** + * @brief Performs the actual load work synchronously on the calling thread. + * @return Status of the main database load. + */ + LoadStatus doLoadCardDatabases(); /** * @brief Collects custom card database paths recursively. @@ -113,13 +131,43 @@ private: */ [[nodiscard]] QStringList collectCustomDatabasePaths() const; + /** + * @brief Computes a hash identifying the current set of input database files. + * @return Hash over the paths, sizes and modification times of all inputs. + */ + [[nodiscard]] QByteArray computeSourceHash(const QStringList &customPaths) const; + + /** + * @brief Path of the binary cache file derived from the main card database path. + * @return Cache file path. + */ + [[nodiscard]] QString cachePath() const; + + /** + * @brief Attempts to populate the snapshot from the binary cache. + * @param data Snapshot to populate. + * @param sourceHash Pre-computed hash identifying the current input files. + * @return True if a valid, up-to-date cache was read. + */ + bool loadFromCache(CardDatabaseData &data, const QByteArray &sourceHash); + + /** + * @brief Writes the snapshot to the binary cache for the current inputs. + * @param data Snapshot to serialize. + * @param sourceHash Pre-computed hash identifying the current input files. + * @return True if the cache was written successfully. + */ + bool saveToCache(const CardDatabaseData &data, const QByteArray &sourceHash); + private: - CardDatabase *database; /**< Non-owning pointer to the target CardDatabase. */ - ICardDatabasePathProvider *pathProvider; /**< Pointer to the path provider. */ - QList availableParsers; /**< List of available parsers for different formats. */ + CardDatabase *database; /**< Non-owning pointer to the target CardDatabase. */ + ICardDatabasePathProvider *pathProvider; /**< Pointer to the path provider. */ + ICardSetPriorityController *priorityController; /**< Controller for reconstructing set state. */ + QList availableParsers; /**< List of available parsers for different formats. */ QBasicMutex *loadFromFileMutex = new QBasicMutex(); /**< Mutex for single-file loading. */ QBasicMutex *reloadDatabaseMutex = new QBasicMutex(); /**< Mutex for reloading entire database. */ + bool initialLoadComplete = false; /**< Set after the first successful load. */ }; #endif // COCKATRICE_CARD_DATABASE_LOADER_H diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp index 174943333..5b9c5a4b5 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database_querier.cpp @@ -12,6 +12,17 @@ CardDatabaseQuerier::CardDatabaseQuerier(QObject *_parent, 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 +315,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 +361,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..f195a8170 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_querier.h +++ b/libcockatrice_card/libcockatrice/card/database/card_database_querier.h @@ -220,6 +220,16 @@ public: 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 f7e6bbfcf..00ed9377d 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/card_database_parser.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/card_database_parser.cpp @@ -30,6 +30,10 @@ CardSetPtr ICardDatabaseParser::internalAddSet(const QString &setName, newSet->setPriority(priority); sets.insert(setName, newSet); - emit addSet(newSet); + if (targetData) { + targetData->sets.insert(setName, newSet); + } else { + emit addSet(newSet); + } return newSet; } 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 a8eceab5a..6d9c91260 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/card_database_parser.h +++ b/libcockatrice_card/libcockatrice/card/database/parser/card_database_parser.h @@ -2,6 +2,7 @@ #define CARDDATABASE_PARSER_H #include "../../card_info.h" +#include "../card_database_data.h" #include #include @@ -37,6 +38,15 @@ public: */ virtual void parseFile(QIODevice &device) = 0; + /** + * @brief Parses a database file into the given snapshot, without emitting + * any signals. Used for background loads that swap the snapshot into + * the live database once complete. + * @param device QIODevice representing the file content. + * @param data Target snapshot to populate. + */ + virtual void parseFileInto(QIODevice &device, CardDatabaseData &data) = 0; + /** * @brief Saves card and set data to a file. * @param _formats @@ -62,6 +72,21 @@ protected: static SetNameMap sets; ICardSetPriorityController *cardSetPriorityController; + /** + * @brief Snapshot the current parse is filling, or nullptr when emitting signals. + * + * This is an implicit-inheritance-via-member-variable pattern: parseFileInto() + * sets this before delegating to parseFile(), and loadCardsFromXml() / + * loadFormats() check it to decide between direct insertion and signal + * emission. Safe under the current model because loadFromFileMutex serialises + * all parser access and parseFile() is never re-entered. If a future change + * adds parallel parsing or a second parseFileInto() call within parseFile(), + * this pointer would race -- at that point refactor to pass CardDatabaseData* + * through the call chain instead. However, we are mostly RAM, not CPU bound on + * DB startup at this point so there's not much point to parallel parsing. + */ + CardDatabaseData *targetData = nullptr; + /** * @brief Internal helper to add a set to the global set cache. * @param setName Short set name. 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 b6c3afc57..64202ab21 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp @@ -79,6 +79,13 @@ void CockatriceXml3Parser::parseFile(QIODevice &device) } } +void CockatriceXml3Parser::parseFileInto(QIODevice &device, CardDatabaseData &data) +{ + targetData = &data; + parseFile(device); + targetData = nullptr; +} + void CockatriceXml3Parser::loadSetsFromXml(QXmlStreamReader &xml) { while (!xml.atEnd()) { @@ -165,7 +172,7 @@ void CockatriceXml3Parser::loadCardsFromXml(QXmlStreamReader &xml) if (xmlName == "card") { QString name = QString(""); QString text = QString(""); - QVariantHash properties = QVariantHash(); + QHash properties; QString colors = QString(""); QList relatedCards, reverseRelatedCards; auto _sets = SetToPrintingsMap(); @@ -221,26 +228,27 @@ void CockatriceXml3Parser::loadCardsFromXml(QXmlStreamReader &xml) // Only load printings from sets the user has enabled, matching the v4 loader's // behaviour. Without this check, disabling a set has no effect on v3 databases. if (set->getEnabled()) { - PrintingInfo setInfo(set); + QHash printingProps; if (attrs.hasAttribute("muId")) { - setInfo.setProperty("muid", attrs.value("muId").toString()); + printingProps.insert("muid", attrs.value("muId").toString()); } if (attrs.hasAttribute("uuId")) { - setInfo.setProperty("uuid", attrs.value("uuId").toString()); + printingProps.insert("uuid", attrs.value("uuId").toString()); } if (attrs.hasAttribute("picURL")) { - setInfo.setProperty("picurl", attrs.value("picURL").toString()); + printingProps.insert("picurl", attrs.value("picURL").toString()); } if (attrs.hasAttribute("num")) { - setInfo.setProperty("num", attrs.value("num").toString()); + printingProps.insert("num", attrs.value("num").toString()); } if (attrs.hasAttribute("rarity")) { - setInfo.setProperty("rarity", attrs.value("rarity").toString()); + printingProps.insert("rarity", attrs.value("rarity").toString()); } + PrintingInfo setInfo(set, LazyPropertiesHash(printingProps)); _sets[setName].append(setInfo); } // related cards @@ -299,7 +307,20 @@ void CockatriceXml3Parser::loadCardsFromXml(QXmlStreamReader &xml) .upsideDownArt = upsideDown}; CardInfoPtr newCard = CardInfo::newInstance(name, text, isToken, properties, relatedCards, reverseRelatedCards, _sets, attributes); - emit addCard(newCard); + if (targetData) { + if (auto existing = targetData->cards.value(name)) { + for (const auto &printings : newCard->getSets()) { + for (const auto &printing : printings) { + existing->addToSet(printing.getSet(), printing); + } + } + } else { + targetData->cards.insert(name, newCard); + targetData->simpleNameCards.insert(newCard->getSimpleName(), newCard); + } + } else { + emit addCard(newCard); + } } } } 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 a01b705aa..83b736257 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.h +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.h @@ -43,6 +43,13 @@ public: */ void parseFile(QIODevice &device) override; + /** + * @brief Parse the XML database into the given snapshot. + * @param device Open QIODevice positioned at start of file. + * @param data Target snapshot to populate. + */ + void parseFileInto(QIODevice &device, CardDatabaseData &data) override; + /** * @brief Save sets and cards back to an XML3 file. */ 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 c242425ab..19e972a7a 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp @@ -82,6 +82,13 @@ void CockatriceXml4Parser::parseFile(QIODevice &device) } } +void CockatriceXml4Parser::parseFileInto(QIODevice &device, CardDatabaseData &data) +{ + targetData = &data; + parseFile(device); + targetData = nullptr; +} + static QSharedPointer parseFormat(QXmlStreamReader &xml) { auto rulesPtr = FormatRulesPtr(new FormatRules()); @@ -187,7 +194,11 @@ void CockatriceXml4Parser::loadFormats(QXmlStreamReader &xml) if (xml.name().toString() == "format") { auto rulesPtr = parseFormat(xml); - emit addFormat(rulesPtr); + if (targetData) { + targetData->formats.insert(rulesPtr->formatName.toLower(), rulesPtr); + } else { + emit addFormat(rulesPtr); + } } } } @@ -232,9 +243,9 @@ void CockatriceXml4Parser::loadSetsFromXml(QXmlStreamReader &xml) } } -QVariantHash CockatriceXml4Parser::loadCardPropertiesFromXml(QXmlStreamReader &xml) +QHash CockatriceXml4Parser::loadCardPropertiesFromXml(QXmlStreamReader &xml) { - QVariantHash properties = QVariantHash(); + QHash properties; while (!xml.atEnd()) { if (xml.readNext() == QXmlStreamReader::EndElement) { break; @@ -261,7 +272,9 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) if (xmlName == "card") { QString name = QString(""); QString text = QString(""); - QVariantHash properties = QVariantHash(); + QHash properties; + QMap localizedNames; + QMap localizedTexts; QList relatedCards, reverseRelatedCards; auto _sets = SetToPrintingsMap(); int tableRow = 0; @@ -287,6 +300,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(); @@ -303,14 +354,15 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) QString setName = xml.readElementText(QXmlStreamReader::IncludeChildElements); auto set = internalAddSet(setName); if (set->getEnabled()) { - PrintingInfo printingInfo(set); + QHash printingProps; for (QXmlStreamAttribute attr : attrs) { QString attrName = attr.name().toString(); if (attrName == "picURL") { attrName = "picurl"; } - printingInfo.setProperty(attrName, attr.value().toString()); + printingProps.insert(attrName, attr.value().toString()); } + PrintingInfo printingInfo(set, LazyPropertiesHash(printingProps)); // This is very much a hack and not the right place to // put this check, as it requires a reload of Cockatrice @@ -387,9 +439,25 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) .landscapeOrientation = landscapeOrientation, .tableRow = tableRow, .upsideDownArt = upsideDown}; - CardInfoPtr newCard = CardInfo::newInstance(name, text, isToken, properties, relatedCards, - reverseRelatedCards, _sets, attributes); - emit addCard(newCard); + 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. + if (auto existing = targetData->cards.value(name)) { + for (const auto &printings : newCard->getSets()) { + for (const auto &printing : printings) { + existing->addToSet(printing.getSet(), printing); + } + } + } else { + targetData->cards.insert(name, newCard); + targetData->simpleNameCards.insert(newCard->getSimpleName(), newCard); + } + } else { + emit addCard(newCard); + } } } } @@ -490,6 +558,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 189e79535..55f7c5a3b 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.h +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.h @@ -19,7 +19,7 @@ inline Q_LOGGING_CATEGORY(CockatriceXml4Log, "cockatrice_xml.xml_4_parser"); * making the parser more extensible and schema-compliant. * * @note Differences from v3: - * - Card properties are stored in blocks as a QVariantHash. + * - 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). @@ -47,6 +47,13 @@ public: */ void parseFile(QIODevice &device) override; + /** + * @brief Parse the XML database into the given snapshot. + * @param device Open QIODevice positioned at start of file. + * @param data Target snapshot to populate. + */ + void parseFileInto(QIODevice &device, CardDatabaseData &data) override; + /** * @brief Save sets and cards back to an XML4 file. */ @@ -63,9 +70,9 @@ private: /** * @brief Loads a generic block from a element. * @param xml The open QXmlStreamReader positioned at a element. - * @return A QVariantHash mapping property names to values. + * @return A QHash mapping property names to values. */ - QVariantHash loadCardPropertiesFromXml(QXmlStreamReader &xml); + QHash loadCardPropertiesFromXml(QXmlStreamReader &xml); /** * @brief Load all elements from the XML stream. 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..4d903a3bd --- /dev/null +++ b/libcockatrice_card/libcockatrice/card/lazy_properties_hash.cpp @@ -0,0 +1,95 @@ +#include "lazy_properties_hash.h" + +#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..0ed8c215b --- /dev/null +++ b/libcockatrice_card/libcockatrice/card/lazy_properties_hash.h @@ -0,0 +1,74 @@ +#ifndef COCKATRICE_LAZY_PROPERTIES_HASH_H +#define COCKATRICE_LAZY_PROPERTIES_HASH_H + +#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/printing_info.cpp b/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp index 340998565..3d185583a 100644 --- a/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp +++ b/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp @@ -2,19 +2,28 @@ #include "../set/card_set.h" -PrintingInfo::PrintingInfo(const CardSetPtr &_set) : set(_set) +#include +#include + +PrintingInfo::PrintingInfo(const CardSetPtr &_set, const LazyPropertiesHash &_properties) + : set(_set), properties(_properties) { } +void PrintingInfo::setProperty(const QString &_name, const QString &_value) +{ + properties.insert(_name, _value); +} + /** * Gets the uuid property of the printing, or an empty string if the property isn't present */ QString PrintingInfo::getUuid() const { - return properties.value("uuid").toString(); + return getPropertiesHash().value("uuid"); } QString PrintingInfo::getFlavorName() const { - return properties.value("flavorName").toString(); -} \ No newline at end of file + return getPropertiesHash().value("flavorName"); +} diff --git a/libcockatrice_card/libcockatrice/card/printing/printing_info.h b/libcockatrice_card/libcockatrice/card/printing/printing_info.h index ad7b33654..974c7bb95 100644 --- a/libcockatrice_card/libcockatrice/card/printing/printing_info.h +++ b/libcockatrice_card/libcockatrice/card/printing/printing_info.h @@ -2,10 +2,11 @@ #define COCKATRICE_PRINTING_INFO_H #include "../set/card_set.h" +#include "libcockatrice/card/lazy_properties_hash.h" #include #include -#include +#include class PrintingInfo; @@ -30,8 +31,9 @@ public: * @brief Constructs a PrintingInfo associated with a specific set. * * @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); + explicit PrintingInfo(const CardSetPtr &_set = nullptr, const LazyPropertiesHash &_properties = {}); /** * @brief Destroys the PrintingInfo. @@ -51,7 +53,7 @@ public: */ bool operator==(const PrintingInfo &other) const { - return this->set == other.set && this->properties == other.properties; + return this->set == other.set && this->getPropertiesHash() == other.getPropertiesHash(); } /** @@ -61,12 +63,12 @@ public: */ bool isEmpty() const { - return set == nullptr && properties.isEmpty(); + return set == nullptr && getPropertiesHash().isEmpty(); } private: - CardSetPtr set; ///< The set this variation belongs to. - QVariantHash properties; ///< Key-value store for variation-specific attributes. + CardSetPtr set; ///< The set this variation belongs to. + LazyPropertiesHash properties; ///< Key-value store for variation-specific attributes. public: /** @@ -86,7 +88,12 @@ public: */ [[nodiscard]] QStringList getProperties() const { - return properties.keys(); + return getPropertiesHash().keys(); + } + + [[nodiscard]] const QHash &getPropertiesHash() const + { + return properties.getProperties(); } /** @@ -97,7 +104,7 @@ public: */ [[nodiscard]] QString getProperty(const QString &propertyName) const { - return properties.value(propertyName).toString(); + return properties.value(propertyName); } /** @@ -108,10 +115,7 @@ public: * @param _name The name of the property. * @param _value The string value to assign. */ - void setProperty(const QString &_name, const QString &_value) - { - properties.insert(_name, _value); - } + void setProperty(const QString &_name, const QString &_value); /** * @brief Returns the providerID for this printing. @@ -126,6 +130,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/set/card_set.cpp b/libcockatrice_card/libcockatrice/card/set/card_set.cpp index 6eea220bb..92060f6c4 100644 --- a/libcockatrice_card/libcockatrice/card/set/card_set.cpp +++ b/libcockatrice_card/libcockatrice/card/set/card_set.cpp @@ -42,9 +42,10 @@ QString CardSet::getCorrectedShortName() const void CardSet::loadSetOptions() { - sortKey = priorityController->getSortKey(shortName); - enabled = priorityController->isEnabled(shortName); - isknown = priorityController->isKnown(shortName); + const ICardSetPriorityController::SetOptions options = priorityController->getSetOptions(shortName); + sortKey = options.sortKey; + enabled = options.enabled; + isknown = options.isKnown; } void CardSet::setSortKey(unsigned int _sortKey) diff --git a/libcockatrice_deck_list/CMakeLists.txt b/libcockatrice_deck_list/CMakeLists.txt index 5ccdb5f66..0c487466c 100644 --- a/libcockatrice_deck_list/CMakeLists.txt +++ b/libcockatrice_deck_list/CMakeLists.txt @@ -11,14 +11,12 @@ 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 ) -if(Qt6_FOUND) - qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) -elseif(Qt5_FOUND) - qt5_wrap_cpp(MOC_SOURCES ${HEADERS}) -endif() +qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) add_library( libcockatrice_deck_list STATIC @@ -30,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 ) @@ -37,4 +37,7 @@ 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} +) diff --git a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp index d41713302..90f63c09d 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp @@ -1,6 +1,7 @@ #include "deck_list.h" #include "deck_list_memento.h" +#include "deck_list_plain_text_parser.h" #include "tree/abstract_deck_list_node.h" #include "tree/deck_list_card_node.h" #include "tree/inner_deck_list_node.h" @@ -8,24 +9,136 @@ #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 - 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 +175,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 +192,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 +210,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 +239,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 +299,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 +316,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..199d3a9a8 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.h @@ -18,7 +18,7 @@ #include #include #include -#include +#include class AbstractDecklistNode; class DecklistCardNode; @@ -70,11 +70,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 +106,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 +149,10 @@ public: { metadata.bannerCard = _bannerCard; } + void setPlaymat(const PlaymatInfo &_playmat = {}) + { + metadata.playmat = _playmat; + } void setLastLoadedTimestamp(const QString &_lastLoadedTimestamp = QString()) { metadata.lastLoadedTimestamp = _lastLoadedTimestamp; @@ -170,6 +208,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..b6d0687b4 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 @@ -14,42 +14,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..6acdb199e 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 @@ -47,6 +47,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..66f228d19 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 @@ -5,6 +5,86 @@ #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 +122,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 +150,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 +168,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 +180,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 +201,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 +217,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..2c66b34ff 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 @@ -10,6 +10,7 @@ class DecklistNodeTree { InnerDecklistNode *root; ///< Root of the deck tree (zones + cards). + int totalCards = 0; public: /** @brief Constructs an empty DecklistNodeTree. */ @@ -78,15 +79,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..de50d743b --- /dev/null +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.cpp @@ -0,0 +1,172 @@ +#include "deck_list_plain_text_parser.h" + +#include "deck_list_node_tree.h" +#include "tree/inner_deck_list_node.h" + +#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..e0f456a18 --- /dev/null +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_plain_text_parser.h @@ -0,0 +1,33 @@ +#ifndef COCKATRICE_DECK_LIST_PLAIN_TEXT_PARSER_H +#define COCKATRICE_DECK_LIST_PLAIN_TEXT_PARSER_H + +#include "deck_list.h" + +#include +#include + +class QTextStream; + +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..fd72fea0b --- /dev/null +++ b/libcockatrice_deck_list/libcockatrice/deck_list/playmat_resolver.cpp @@ -0,0 +1,54 @@ +#include "playmat_resolver.h" + +#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..59bc449c8 --- /dev/null +++ b/libcockatrice_deck_list/libcockatrice/deck_list/playmat_resolver.h @@ -0,0 +1,47 @@ +#ifndef COCKATRICE_PLAYMAT_RESOLVER_H +#define COCKATRICE_PLAYMAT_RESOLVER_H + +#include "deck_list.h" + +#include + +/** + * @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..855062c18 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/sideboard_plan.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/sideboard_plan.cpp @@ -2,6 +2,32 @@ #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 +47,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/tree/abstract_deck_list_card_node.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_card_node.cpp index 705dfae4c..685238d53 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 @@ -34,17 +34,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 +49,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..0942a4601 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 @@ -134,15 +134,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..38d1d44b8 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 @@ -142,6 +142,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 +179,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/inner_deck_list_node.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/tree/inner_deck_list_node.cpp index 602ea6aec..86f7f5363 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 @@ -28,6 +28,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 +43,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 +94,9 @@ AbstractDecklistNode *InnerDecklistNode::findCardChildByNameProviderIdAndNumber( int InnerDecklistNode::height() const { + if (isEmpty()) { + return 1; + } return at(0)->height() + 1; } @@ -139,27 +151,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 +192,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..8404d7116 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,17 @@ #include "abstract_deck_list_node.h" +#include +#include + /** @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 +121,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 +223,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 74566ca05..905533df8 100644 --- a/libcockatrice_filters/CMakeLists.txt +++ b/libcockatrice_filters/CMakeLists.txt @@ -6,11 +6,7 @@ set(HEADERS libcockatrice/filters/filter_card.h libcockatrice/filters/filter_str libcockatrice/filters/filter_tree.h ) -if(Qt6_FOUND) - qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) -elseif(Qt5_FOUND) - qt5_wrap_cpp(MOC_SOURCES ${HEADERS}) -endif() +qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) add_library( libcockatrice_filters STATIC ${MOC_SOURCES} libcockatrice/filters/filter_card.cpp diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp index 25e8e97db..aaf391c03 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.cpp +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include static peg::parser search(R"( @@ -19,7 +20,7 @@ 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') SetExpression / ([:] FlexStringValue) OracleQuery <- 'o' [:] MatcherString @@ -64,6 +65,8 @@ RegexMatcherString <- ('\\/' / !'/' .)+ FlexStringValue <- CompactStringSet / String / [(] StringList [)] CompactStringSet <- StringListString ([,+] StringListString)+ +SetExpression <- NumericOperator ws? String + NumericExpression <- NumericOperator ws? NumericValue NumericOperator <- [=:] / <[> NumericValue <- [0-9]+ @@ -101,12 +104,25 @@ static void setupParserRules() 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(); + 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::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); + }; + } + + 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 +263,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 { diff --git a/libcockatrice_filters/libcockatrice/filters/filter_string.h b/libcockatrice_filters/libcockatrice/filters/filter_string.h index 71a99f7b5..a058f7d07 100644 --- a/libcockatrice_filters/libcockatrice/filters/filter_string.h +++ b/libcockatrice_filters/libcockatrice/filters/filter_string.h @@ -22,6 +22,7 @@ typedef CardInfoPtr CardData; typedef std::function Filter; typedef std::function StringMatcher; typedef std::function NumberMatcher; +typedef std::function NumberComparer; namespace peg { diff --git a/libcockatrice_interfaces/CMakeLists.txt b/libcockatrice_interfaces/CMakeLists.txt index 4f34f7985..f53c41807 100644 --- a/libcockatrice_interfaces/CMakeLists.txt +++ b/libcockatrice_interfaces/CMakeLists.txt @@ -3,23 +3,31 @@ set(CMAKE_AUTOUIC ON) set(CMAKE_AUTORCC ON) set(HEADERS + libcockatrice/interfaces/interface_cache_storage_settings_provider.h libcockatrice/interfaces/interface_card_database_path_provider.h libcockatrice/interfaces/interface_card_preference_provider.h libcockatrice/interfaces/interface_card_set_priority_controller.h + libcockatrice/interfaces/interface_cards_display_settings_provider.h + libcockatrice/interfaces/interface_chat_settings_provider.h + libcockatrice/interfaces/interface_deck_editor_settings_provider.h + libcockatrice/interfaces/interface_game_settings_provider.h + libcockatrice/interfaces/interface_interface_settings_provider.h libcockatrice/interfaces/interface_network_settings_provider.h + libcockatrice/interfaces/interface_paths_settings_provider.h + libcockatrice/interfaces/interface_personal_settings_provider.h + libcockatrice/interfaces/interface_sound_settings_provider.h + libcockatrice/interfaces/interface_tabs_settings_provider.h + libcockatrice/interfaces/interface_updates_settings_provider.h + libcockatrice/interfaces/interface_visual_deck_storage_settings_provider.h libcockatrice/interfaces/noop_card_database_path_provider.h libcockatrice/interfaces/noop_card_preference_provider.h libcockatrice/interfaces/noop_card_set_priority_controller.h ) -if(Qt6_FOUND) - qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) -elseif(Qt5_FOUND) - qt5_wrap_cpp(MOC_SOURCES ${HEADERS}) -endif() +qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) 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}) diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_cache_storage_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_cache_storage_settings_provider.h new file mode 100644 index 000000000..2881164d6 --- /dev/null +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_cache_storage_settings_provider.h @@ -0,0 +1,14 @@ +#ifndef COCKATRICE_INTERFACE_CACHE_STORAGE_SETTINGS_PROVIDER_H +#define COCKATRICE_INTERFACE_CACHE_STORAGE_SETTINGS_PROVIDER_H + +class ICacheStorageSettingsProvider +{ +public: + virtual ~ICacheStorageSettingsProvider() = default; + + [[nodiscard]] virtual int getPixmapCacheSize() const = 0; + [[nodiscard]] virtual int getNetworkCacheSizeInMB() const = 0; + [[nodiscard]] virtual int getRedirectCacheTtl() const = 0; +}; + +#endif // COCKATRICE_INTERFACE_CACHE_STORAGE_SETTINGS_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 9559967af..36e748006 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_card_set_priority_controller.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_card_set_priority_controller.h @@ -13,6 +13,22 @@ public: bool enabled; }; + /** + * @brief Bundled per-set priority options, returned in a single call. + * + * Reading these individually (getSortKey/isEnabled/isKnown) is cheap for the + * noop controller but extremely expensive when backed by QSettings, which + * re-opens and re-parses the whole .ini on every access. Callers that need + * more than one option for the same set (notably CardSet construction during + * database load) should use getSetOptions() to avoid that cost. + */ + struct SetOptions + { + unsigned int sortKey = 0; + bool enabled = false; + bool isKnown = false; + }; + virtual ~ICardSetPriorityController() = default; virtual void setSortKey(QString shortName, unsigned int sortKey) = 0; @@ -23,6 +39,13 @@ public: virtual bool isEnabled(QString shortName) const = 0; virtual bool isKnown(QString shortName) const = 0; + /** + * @brief Returns the bundled priority options for a set in a single call. + * @param shortName The set's short name. + * @return The sort key, enabled and known flags for the set. + */ + virtual SetOptions getSetOptions(QString shortName) 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 new file mode 100644 index 000000000..304ab3cb7 --- /dev/null +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h @@ -0,0 +1,34 @@ +#ifndef COCKATRICE_INTERFACE_CARDS_DISPLAY_SETTINGS_PROVIDER_H +#define COCKATRICE_INTERFACE_CARDS_DISPLAY_SETTINGS_PROVIDER_H + +#include + +class ICardsDisplaySettingsProvider +{ +public: + virtual ~ICardsDisplaySettingsProvider() = default; + + [[nodiscard]] virtual bool getDisplayCardNames() const = 0; + [[nodiscard]] virtual bool getRoundCardCorners() const = 0; + [[nodiscard]] virtual bool getOverrideAllCardArtWithPersonalPreference() const = 0; + [[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; + [[nodiscard]] virtual int getCardInfoViewMode() const = 0; + [[nodiscard]] virtual int getVisualDeckStorageCardSize() const = 0; + [[nodiscard]] virtual int getVisualDatabaseDisplayCardSize() const = 0; + [[nodiscard]] virtual int getVisualDeckEditorCardSize() 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; +}; + +#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 new file mode 100644 index 000000000..cdf2da5eb --- /dev/null +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_chat_settings_provider.h @@ -0,0 +1,27 @@ +#ifndef COCKATRICE_INTERFACE_CHAT_SETTINGS_PROVIDER_H +#define COCKATRICE_INTERFACE_CHAT_SETTINGS_PROVIDER_H + +#include + +class IChatSettingsProvider +{ +public: + virtual ~IChatSettingsProvider() = default; + + [[nodiscard]] virtual bool getChatMention() const = 0; + [[nodiscard]] virtual bool getChatMentionCompleter() const = 0; + [[nodiscard]] virtual QString getChatMentionColor() const = 0; + [[nodiscard]] virtual QString getChatHighlightColor() const = 0; + [[nodiscard]] virtual bool getChatMentionForeground() const = 0; + [[nodiscard]] virtual bool getChatHighlightForeground() const = 0; + [[nodiscard]] virtual bool getIgnoreUnregisteredUsers() const = 0; + [[nodiscard]] virtual bool getIgnoreUnregisteredUserMessages() const = 0; + [[nodiscard]] virtual bool getIgnoreNonBuddyUserMessages() const = 0; + [[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 QString getHighlightWords() const = 0; +}; + +#endif // COCKATRICE_INTERFACE_CHAT_SETTINGS_PROVIDER_H diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_deck_editor_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_deck_editor_settings_provider.h new file mode 100644 index 000000000..0ef39d044 --- /dev/null +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_deck_editor_settings_provider.h @@ -0,0 +1,15 @@ +#ifndef COCKATRICE_INTERFACE_DECK_EDITOR_SETTINGS_PROVIDER_H +#define COCKATRICE_INTERFACE_DECK_EDITOR_SETTINGS_PROVIDER_H + +class IDeckEditorSettingsProvider +{ +public: + virtual ~IDeckEditorSettingsProvider() = default; + + [[nodiscard]] virtual bool getOpenDeckInNewTab() const = 0; + [[nodiscard]] virtual bool getBannerCardComboBoxVisible() const = 0; + [[nodiscard]] virtual bool getTagsWidgetVisible() const = 0; + [[nodiscard]] virtual int getDefaultDeckEditorType() const = 0; +}; + +#endif // COCKATRICE_INTERFACE_DECK_EDITOR_SETTINGS_PROVIDER_H diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_game_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_game_settings_provider.h new file mode 100644 index 000000000..2b1ddf8ec --- /dev/null +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_game_settings_provider.h @@ -0,0 +1,29 @@ +#ifndef COCKATRICE_INTERFACE_GAME_SETTINGS_PROVIDER_H +#define COCKATRICE_INTERFACE_GAME_SETTINGS_PROVIDER_H + +#include + +class IGameSettingsProvider +{ +public: + virtual ~IGameSettingsProvider() = default; + + [[nodiscard]] virtual QString getGameDescription() const = 0; + [[nodiscard]] virtual int getMaxPlayers() const = 0; + [[nodiscard]] virtual QString getGameTypes() const = 0; + [[nodiscard]] virtual bool getOnlyBuddies() const = 0; + [[nodiscard]] virtual bool getOnlyRegistered() const = 0; + [[nodiscard]] virtual bool getSpectatorsAllowed() const = 0; + [[nodiscard]] virtual bool getSpectatorsNeedPassword() const = 0; + [[nodiscard]] virtual bool getSpectatorsCanTalk() const = 0; + [[nodiscard]] virtual bool getSpectatorsCanSeeEverything() const = 0; + [[nodiscard]] virtual bool getCreateGameAsSpectator() const = 0; + [[nodiscard]] virtual int getDefaultStartingLifeTotal() const = 0; + [[nodiscard]] virtual bool getShareDecklistsOnLoad() const = 0; + [[nodiscard]] virtual bool getRememberGameSettings() const = 0; + [[nodiscard]] virtual bool getLocalGameRememberSettings() const = 0; + [[nodiscard]] virtual int getLocalGameMaxPlayers() const = 0; + [[nodiscard]] virtual int getLocalGameStartingLifeTotal() const = 0; +}; + +#endif // COCKATRICE_INTERFACE_GAME_SETTINGS_PROVIDER_H diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_interface_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_interface_settings_provider.h new file mode 100644 index 000000000..bc2118cb3 --- /dev/null +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_interface_settings_provider.h @@ -0,0 +1,95 @@ +#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 +{ +public: + virtual ~IInterfaceSettingsProvider() = default; + + [[nodiscard]] virtual bool getUseTearOffMenus() const = 0; + [[nodiscard]] virtual int getCardViewInitialRowsMax() const = 0; + [[nodiscard]] virtual int getCardViewExpandedRowsMax() const = 0; + [[nodiscard]] virtual bool getCloseEmptyCardView() const = 0; + [[nodiscard]] virtual bool getFocusCardViewSearchBar() const = 0; + [[nodiscard]] virtual bool getKeepGameChatFocus() const = 0; + [[nodiscard]] virtual bool getNotificationsEnabled() const = 0; + [[nodiscard]] virtual bool getSpectatorNotificationsEnabled() const = 0; + [[nodiscard]] virtual bool getBuddyConnectNotificationsEnabled() const = 0; + [[nodiscard]] virtual bool getDoubleClickToPlay() const = 0; + [[nodiscard]] virtual bool getClickPlaysAllSelected() const = 0; + [[nodiscard]] virtual bool getPlayToStack() const = 0; + [[nodiscard]] virtual bool getDoNotDeleteArrowsInSubPhases() const = 0; + [[nodiscard]] virtual int getStartingHandSize() const = 0; + [[nodiscard]] virtual bool getAnnotateTokens() const = 0; + [[nodiscard]] virtual bool getShowDragSelectionCount() const = 0; + [[nodiscard]] virtual bool getShowTotalSelectionCount() const = 0; + [[nodiscard]] virtual int getTallyType() const = 0; + [[nodiscard]] virtual bool getHorizontalHand() const = 0; + [[nodiscard]] virtual bool getInvertVerticalCoordinate() const = 0; + [[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; + [[nodiscard]] virtual bool getZoneViewPileView() const = 0; + [[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_paths_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_paths_settings_provider.h new file mode 100644 index 000000000..d4edc8b81 --- /dev/null +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_paths_settings_provider.h @@ -0,0 +1,24 @@ +#ifndef COCKATRICE_INTERFACE_PATHS_SETTINGS_PROVIDER_H +#define COCKATRICE_INTERFACE_PATHS_SETTINGS_PROVIDER_H + +#include + +class IPathsSettingsProvider +{ +public: + virtual ~IPathsSettingsProvider() = default; + + [[nodiscard]] virtual QString getDeckPath() const = 0; + [[nodiscard]] virtual QString getFiltersPath() const = 0; + [[nodiscard]] virtual QString getReplaysPath() const = 0; + [[nodiscard]] virtual QString getPicsPath() const = 0; + [[nodiscard]] virtual QString getCustomPicsPath() const = 0; + [[nodiscard]] virtual QString getThemesPath() const = 0; + [[nodiscard]] virtual QString getCardDatabasePath() const = 0; + [[nodiscard]] virtual QString getCustomCardDatabasePath() const = 0; + [[nodiscard]] virtual QString getTokenDatabasePath() const = 0; + [[nodiscard]] virtual QString getSpoilerCardDatabasePath() const = 0; + [[nodiscard]] virtual QString getRedirectCachePath() const = 0; +}; + +#endif // COCKATRICE_INTERFACE_PATHS_SETTINGS_PROVIDER_H diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_personal_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_personal_settings_provider.h new file mode 100644 index 000000000..f3d3b1220 --- /dev/null +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_personal_settings_provider.h @@ -0,0 +1,18 @@ +#ifndef COCKATRICE_INTERFACE_PERSONAL_SETTINGS_PROVIDER_H +#define COCKATRICE_INTERFACE_PERSONAL_SETTINGS_PROVIDER_H + +#include +#include +#include + +class IPersonalSettingsProvider +{ +public: + virtual ~IPersonalSettingsProvider() = default; + + [[nodiscard]] virtual QString getLang() const = 0; + [[nodiscard]] virtual bool getShowTipsOnStartup() const = 0; + [[nodiscard]] virtual QList getSeenTips() const = 0; +}; + +#endif // COCKATRICE_INTERFACE_PERSONAL_SETTINGS_PROVIDER_H diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_sound_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_sound_settings_provider.h new file mode 100644 index 000000000..7bc170d0d --- /dev/null +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_sound_settings_provider.h @@ -0,0 +1,16 @@ +#ifndef COCKATRICE_INTERFACE_SOUND_SETTINGS_PROVIDER_H +#define COCKATRICE_INTERFACE_SOUND_SETTINGS_PROVIDER_H + +#include + +class ISoundSettingsProvider +{ +public: + virtual ~ISoundSettingsProvider() = default; + + [[nodiscard]] virtual bool getSoundEnabled() const = 0; + [[nodiscard]] virtual QString getSoundThemeName() const = 0; + [[nodiscard]] virtual int getMasterVolume() const = 0; +}; + +#endif // COCKATRICE_INTERFACE_SOUND_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 new file mode 100644 index 000000000..054c4cd72 --- /dev/null +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_tabs_settings_provider.h @@ -0,0 +1,27 @@ +#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; + [[nodiscard]] virtual bool getTabDeckStorageOpen() const = 0; + [[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/interface_updates_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_updates_settings_provider.h new file mode 100644 index 000000000..687484c2c --- /dev/null +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_updates_settings_provider.h @@ -0,0 +1,23 @@ +#ifndef COCKATRICE_INTERFACE_UPDATES_SETTINGS_PROVIDER_H +#define COCKATRICE_INTERFACE_UPDATES_SETTINGS_PROVIDER_H + +#include + +class IUpdatesSettingsProvider +{ +public: + virtual ~IUpdatesSettingsProvider() = default; + + [[nodiscard]] virtual bool getCheckUpdatesOnStartup() const = 0; + [[nodiscard]] virtual bool getStartupCardUpdateCheckPromptForUpdate() = 0; + [[nodiscard]] virtual bool getStartupCardUpdateCheckAlwaysUpdate() = 0; + [[nodiscard]] virtual int getCardUpdateCheckInterval() const = 0; + [[nodiscard]] virtual QDate getLastCardUpdateCheck() const = 0; + [[nodiscard]] virtual bool getCardUpdateCheckRequired() const = 0; + [[nodiscard]] virtual bool getAlwaysEnableNewSets() const = 0; + [[nodiscard]] virtual bool getNotifyAboutUpdates() const = 0; + [[nodiscard]] virtual bool getNotifyAboutNewVersion() const = 0; + [[nodiscard]] virtual int getUpdateReleaseChannelIndex() const = 0; +}; + +#endif // COCKATRICE_INTERFACE_UPDATES_SETTINGS_PROVIDER_H diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_visual_deck_storage_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_visual_deck_storage_settings_provider.h new file mode 100644 index 000000000..114b053c6 --- /dev/null +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_visual_deck_storage_settings_provider.h @@ -0,0 +1,30 @@ +#ifndef COCKATRICE_INTERFACE_VISUAL_DECK_STORAGE_SETTINGS_PROVIDER_H +#define COCKATRICE_INTERFACE_VISUAL_DECK_STORAGE_SETTINGS_PROVIDER_H + +#include + +class IVisualDeckStorageSettingsProvider +{ +public: + virtual ~IVisualDeckStorageSettingsProvider() = default; + + [[nodiscard]] virtual int getVisualDeckStorageSortingOrder() const = 0; + [[nodiscard]] virtual bool getVisualDeckStorageShowFolders() const = 0; + [[nodiscard]] virtual bool getVisualDeckStorageShowTagFilter() const = 0; + [[nodiscard]] virtual QStringList getVisualDeckStorageDefaultTagsList() const = 0; + [[nodiscard]] virtual bool getVisualDeckStorageSearchFolderNames() const = 0; + [[nodiscard]] virtual bool getVisualDeckStorageShowColorIdentity() const = 0; + [[nodiscard]] virtual bool getVisualDeckStorageShowBannerCardComboBox() const = 0; + [[nodiscard]] virtual bool getVisualDeckStorageShowTagsOnDeckPreviews() const = 0; + [[nodiscard]] virtual bool getVisualDeckStorageDrawUnusedColorIdentities() const = 0; + [[nodiscard]] virtual int getVisualDeckStorageUnusedColorIdentitiesOpacity() const = 0; + [[nodiscard]] virtual int getVisualDeckStorageTooltipType() const = 0; + [[nodiscard]] virtual bool getVisualDeckStoragePromptForConversion() const = 0; + [[nodiscard]] virtual bool getVisualDeckStorageAlwaysConvert() const = 0; + [[nodiscard]] virtual bool getVisualDeckStorageInGame() const = 0; + [[nodiscard]] virtual bool getVisualDeckStorageSelectionAnimation() const = 0; + [[nodiscard]] virtual bool getVisualDatabaseDisplayFilterToMostRecentSetsEnabled() const = 0; + [[nodiscard]] virtual int getVisualDatabaseDisplayFilterToMostRecentSetsAmount() const = 0; +}; + +#endif // COCKATRICE_INTERFACE_VISUAL_DECK_STORAGE_SETTINGS_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 188e2ced9..8cc2011bd 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/noop_card_set_priority_controller.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/noop_card_set_priority_controller.h @@ -29,6 +29,11 @@ public: return true; } + SetOptions getSetOptions(QString /* shortName */) const override + { + return {0, true, true}; + } + 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 950d6d79f..840f14b08 100644 --- a/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt +++ b/libcockatrice_models/libcockatrice/models/database/CMakeLists.txt @@ -12,11 +12,7 @@ set(HEADERS token/token_edit_model.h ) -if(Qt6_FOUND) - qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) -elseif(Qt5_FOUND) - qt5_wrap_cpp(MOC_SOURCES ${HEADERS}) -endif() +qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) add_library( libcockatrice_models_database STATIC 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..f31463b50 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 @@ -6,6 +6,10 @@ CardCompleterProxyModel::CardCompleterProxyModel(QObject *parent) : QSortFilterP bool CardCompleterProxyModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const { + if (!sourceModel()) { + return false; + } + if (filterRegularExpression().pattern().isEmpty()) { return true; } @@ -13,6 +17,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_search_model.cpp b/libcockatrice_models/libcockatrice/models/database/card/card_search_model.cpp index d1fbbac2f..621f28983 100644 --- a/libcockatrice_models/libcockatrice/models/database/card/card_search_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card/card_search_model.cpp @@ -23,8 +23,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 +42,62 @@ 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}); + const QString lowerName = card->getName().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}); + } } - // 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 = [](const SearchResult &a, const SearchResult &b) { + if (a.distance != b.distance) { + return a.distance < b.distance; + } + return a.card->getName().size() < b.card->getName().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(); } 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..646bf7e61 100644 --- a/libcockatrice_models/libcockatrice/models/database/card/card_search_model.h +++ b/libcockatrice_models/libcockatrice/models/database/card/card_search_model.h @@ -15,6 +15,11 @@ 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; diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_model.cpp b/libcockatrice_models/libcockatrice/models/database/card_database_model.cpp index 253dcd134..ecd26a9f8 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card_database_model.cpp @@ -12,6 +12,7 @@ CardDatabaseModel::CardDatabaseModel(CardDatabase *_db, bool _showOnlyCardsFromE connect(db, &CardDatabase::cardRemoved, this, &CardDatabaseModel::cardRemoved); connect(db, &CardDatabase::cardDatabaseEnabledSetsChanged, this, &CardDatabaseModel::cardDatabaseEnabledSetsChanged); + connect(db, &CardDatabase::cardDatabaseReset, this, &CardDatabaseModel::resetCardList); cardDatabaseEnabledSetsChanged(); } @@ -110,19 +111,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) @@ -151,3 +183,18 @@ void CardDatabaseModel::cardRemoved(CardInfoPtr card) cardList.removeAt(row); endRemoveRows(); } + +void CardDatabaseModel::resetCardList() +{ + beginResetModel(); + cardList.clear(); + cardListSet.clear(); + for (const CardInfoPtr &card : db->getCardList()) { + if (checkCardHasAtLeastOneEnabledSet(card)) { + cardList.append(card); + cardListSet.insert(card); + connect(card.data(), &CardInfo::cardInfoChanged, this, &CardDatabaseModel::cardInfoChanged); + } + } + endResetModel(); +} diff --git a/libcockatrice_models/libcockatrice/models/database/card_database_model.h b/libcockatrice_models/libcockatrice/models/database/card_database_model.h index 8655389d7..ad84de6f4 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_model.h +++ b/libcockatrice_models/libcockatrice/models/database/card_database_model.h @@ -57,6 +57,7 @@ private slots: void cardRemoved(CardInfoPtr card); void cardInfoChanged(const CardInfoPtr &card); void cardDatabaseEnabledSetsChanged(); + void resetCardList(); }; #endif diff --git a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt index 851636a35..a6ab2a204 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt +++ b/libcockatrice_models/libcockatrice/models/deck_list/CMakeLists.txt @@ -4,14 +4,11 @@ set(CMAKE_AUTORCC ON) set(HEADERS deck_list_model.h deck_list_sort_filter_proxy_model.h) -if(Qt6_FOUND) - qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) -elseif(Qt5_FOUND) - qt5_wrap_cpp(MOC_SOURCES ${HEADERS}) -endif() +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}) 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..a5628a844 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.cpp @@ -28,6 +28,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 +76,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 +93,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 +176,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 +191,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 +222,10 @@ QVariant DeckListModel::data(const QModelIndex &index, int role) const return card->getFormatLegality(); } + case DeckRoles::IsCustomZoneRole: { + return false; + } + default: { return {}; } @@ -327,6 +363,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 +380,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 +395,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 +410,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 +488,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 +707,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 +855,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..ce6d7f8cb 100644 --- a/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model.h @@ -1,6 +1,8 @@ #ifndef DECKLISTMODEL_H #define DECKLISTMODEL_H +#include "deck_list_model_custom_zones.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 @@ -30,7 +32,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 +287,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 +404,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 +449,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..1dc745e63 --- /dev/null +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.cpp @@ -0,0 +1,152 @@ +#include "deck_list_model_custom_zones.h" + +#include "deck_list_model.h" + +#include <../../../../libcockatrice_deck_list/libcockatrice/deck_list/tree/deck_list_card_node.h> +#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..518a9e1d2 --- /dev/null +++ b/libcockatrice_models/libcockatrice/models/deck_list/deck_list_model_custom_zones.h @@ -0,0 +1,98 @@ +#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 + +/** + * @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_network/libcockatrice/network/client/abstract/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt index 2753246de..6fba8d629 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt @@ -2,15 +2,11 @@ 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) -if(Qt6_FOUND) - qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) -elseif(Qt5_FOUND) - qt5_wrap_cpp(MOC_SOURCES ${HEADERS}) -endif() +qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) add_library(libcockatrice_network_client_abstract STATIC ${MOC_SOURCES} ${SOURCES}) diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp index 916f4351b..687d93666 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp @@ -1,6 +1,7 @@ #include "abstract_client.h" #include +#include #include #include #include @@ -28,6 +29,7 @@ AbstractClient::AbstractClient(QObject *parent) qRegisterMetaType("Response"); qRegisterMetaType("Response::ResponseCode"); qRegisterMetaType("ClientStatus"); + qRegisterMetaType("LatencyTracker::Stats"); qRegisterMetaType("RoomEvent"); qRegisterMetaType("GameEventContainer"); qRegisterMetaType("Event_ServerIdentification"); @@ -71,6 +73,8 @@ void AbstractClient::processProtocolItem(const ServerMessage &item) } pendingCommands.remove(cmdId); + recordLatency(*pend); + pend->processResponse(response); pend->deleteLater(); break; @@ -161,9 +165,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 +253,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 2eb7e3356..af22a5c9d 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h +++ b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h @@ -7,11 +7,17 @@ #ifndef ABSTRACTCLIENT_H #define ABSTRACTCLIENT_H +#include "latency_tracker.h" + +#include +#include #include #include #include #include +inline Q_LOGGING_CATEGORY(AbstractClientLog, "abstract_client"); + class PendingCommand; class CommandContainer; class RoomEvent; @@ -54,6 +60,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 +103,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 +136,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; @@ -122,10 +155,25 @@ public: return userName; } + /** + * @brief Returns the server address configured for the current connection. + * + * May be empty for clients that have no server counterpart (e.g. local test clients). + */ + virtual QString serverName() const + { + return {}; + } + virtual quint16 serverPort() const + { + return 0; + } + static PendingCommand *prepareSessionCommand(const ::google::protobuf::Message &cmd); 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..98b353fdf --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/client/abstract/latency_tracker.cpp @@ -0,0 +1,60 @@ +#include "latency_tracker.h" + +#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..75f18ac0c --- /dev/null +++ b/libcockatrice_network/libcockatrice/network/client/abstract/latency_tracker.h @@ -0,0 +1,51 @@ +/** + * @file latency_tracker.h + * @ingroup Client + */ + +#ifndef LATENCY_TRACKER_H +#define LATENCY_TRACKER_H + +#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 d12a324dc..2ac12e1fe 100644 --- a/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/local/CMakeLists.txt @@ -6,11 +6,7 @@ set(HEADERS local_client.h) set(SOURCES local_client.cpp) -if(Qt6_FOUND) - qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) -elseif(Qt5_FOUND) - qt5_wrap_cpp(MOC_SOURCES ${HEADERS}) -endif() +qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) add_library(libcockatrice_network_client_local STATIC ${MOC_SOURCES} ${SOURCES}) diff --git a/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt index 0548700e4..cb68d0c37 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/remote/CMakeLists.txt @@ -6,11 +6,7 @@ set(HEADERS remote_client.h) set(SOURCES remote_client.cpp) -if(Qt6_FOUND) - qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) -elseif(Qt5_FOUND) - qt5_wrap_cpp(MOC_SOURCES ${HEADERS}) -endif() +qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) add_library(libcockatrice_network_client_remote STATIC ${MOC_SOURCES} ${SOURCES}) diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp index 7e20f2722..53608db65 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.cpp @@ -543,6 +543,7 @@ void RemoteClient::doDisconnectFromServer() delete i; } pendingCommands.clear(); + clearLatencyStats(); setStatus(StatusDisconnected); if (websocket->isValid()) { diff --git a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h index 289fdc5d0..862dac06e 100644 --- a/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h +++ b/libcockatrice_network/libcockatrice/network/client/remote/remote_client.h @@ -131,6 +131,22 @@ public: return socket->peerName(); } } + quint16 peerPort() const + { + if (usingWebSocket) { + return websocket->peerPort(); + } else { + return socket->peerPort(); + } + } + QString serverName() const override + { + return lastHostname; + } + quint16 serverPort() const override + { + return static_cast(lastPort); + } void connectToServer(const QString &hostname, unsigned int port, const QString &_userName, const QString &_password); void registerToServer(const QString &hostname, diff --git a/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt index 80fb379a4..494ae1294 100644 --- a/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/server/local/CMakeLists.txt @@ -6,11 +6,7 @@ set(HEADERS local_server.h local_server_interface.h) set(SOURCES local_server.cpp local_server_interface.cpp) -if(Qt6_FOUND) - qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) -elseif(Qt5_FOUND) - qt5_wrap_cpp(MOC_SOURCES ${HEADERS}) -endif() +qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) add_library(libcockatrice_network_server_local STATIC ${MOC_SOURCES} ${SOURCES}) diff --git a/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt index e883baa0d..e11a962d1 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 @@ -23,11 +28,7 @@ set(HEADERS serverinfo_user_container.h ) -if(Qt6_FOUND) - qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) -elseif(Qt5_FOUND) - qt5_wrap_cpp(MOC_SOURCES ${HEADERS}) -endif() +qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) add_library( libcockatrice_network_server_remote STATIC 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..a6280ffa1 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 @@ -37,6 +37,7 @@ #include #include #include +#include #include #include #include @@ -144,6 +145,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 +533,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..c78ae78c1 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 @@ -52,6 +52,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 +125,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 44e317bf7..6b4101a99 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 @@ -66,6 +66,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; @@ -1630,10 +1639,11 @@ void Server_AbstractPlayer::getInfo(ServerInfo_Player *info, bool withUserInfo) { getProperties(*info->mutable_properties(), withUserInfo); - if (recipient == this) { - if (deck) { - info->set_deck_list(deck->writeToString_Native().toStdString()); - } + + // 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()); } for (Server_Arrow *arrow : arrows) { @@ -1652,5 +1662,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..4cc79c5fe 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 @@ -43,6 +43,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_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..131ff1077 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp @@ -32,6 +32,7 @@ #include "server_spectator.h" #include +#include #include #include #include @@ -53,34 +54,22 @@ #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 +239,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 +342,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 +388,7 @@ void Server_Game::doStartGameIfReady(bool forceStartGame) activePlayer = -1; nextTurn(); + room->getServer()->observeGameStartDurationMs(startupTimer.nsecsElapsed() / 1000000); locker.unlock(); @@ -400,10 +409,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 +430,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 +465,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 +547,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 +606,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 +862,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 +922,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..1ed4fe4ca 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.h @@ -21,11 +21,16 @@ #define SERVERGAME_H #include "../server_response_containers.h" +#include "game_config.h" +#include "server_deck_validation_strategy.h" +#include "server_game_lifecycle_strategy.h" +#include "server_match_result_strategy.h" #include #include #include #include +#include #include #include #include @@ -78,6 +83,12 @@ private: QList replayList; GameReplay *currentReplay; + QScopedPointer deckValidationStrategy; + + QScopedPointer lifecycleStrategy; + + QScopedPointer matchResultStrategy; + void createGameStateChangedEvent(Event_GameStateChanged *event, Server_AbstractParticipant *recipient, bool omniscient, @@ -92,21 +103,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 +123,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 +220,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..cafa33c07 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.cpp @@ -27,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -250,6 +251,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 +603,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..c35428480 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_player.h @@ -62,6 +62,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/server.h b/libcockatrice_network/libcockatrice/network/server/remote/server.h index 2fca46593..3d27f4210 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server.h @@ -38,7 +38,8 @@ enum AuthenticationResult UsernameInvalid, RegistrationRequired, UserIsInactive, - ClientIdRequired + ClientIdRequired, + PasswordChangeRequired }; class Server : public QObject @@ -91,6 +92,10 @@ public: { return QString(); } + virtual SessionEvent *getLoginSessionEvent() const + { + return nullptr; + } virtual QString getRequiredFeatures() const { return QString(); @@ -175,6 +180,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..31fa13d81 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.cpp @@ -69,6 +69,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..2da72c01d 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_abstractuserinterface.h @@ -42,6 +42,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..1e4fc990b 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_database_interface.h @@ -172,6 +172,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 c441da781..8422d703d 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.cpp @@ -1,5 +1,6 @@ #include "server_protocolhandler.h" +#include "game/game_config.h" #include "game/server_game.h" #include "game/server_player.h" #include "server_database_interface.h" @@ -133,6 +134,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 +158,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 +388,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 +437,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 +483,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 +594,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 +616,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 +646,7 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd joinPersistentGames(rc); databaseInterface->removeForgotPassword(userName); + onLogin(rc); rc.setResponseExtension(re); return Response::RespOk; } @@ -685,6 +745,15 @@ Response::ResponseCode Server_ProtocolHandler::cmdGetUserInfo(const Command_GetU ServerInfo_User_Container *infoSource = server->findUser(userName); if (!infoSource) { re->mutable_user_info()->CopyFrom(databaseInterface->getUserData(userName, true)); + // The user is not currently online. Mirror the redaction that + // copyUserInfo() applies to online users: the id and email address + // are only ever visible to the account owner, and the client id + // only to moderators. + re->mutable_user_info()->clear_id(); + re->mutable_user_info()->clear_email(); + if (!(userInfo->user_level() & ServerInfo_User::IsModerator)) { + re->mutable_user_info()->clear_clientid(); + } } else { re->mutable_user_info()->CopyFrom( infoSource->copyUserInfo(true, false, userInfo->user_level() & ServerInfo_User::IsModerator)); @@ -885,10 +954,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..2c8efe50e 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h +++ b/libcockatrice_network/libcockatrice/network/server/remote/server_protocolhandler.h @@ -27,6 +27,7 @@ class CommandContainer; class SessionCommand; class ModeratorCommand; class AdminCommand; +class DeveloperCommand; class Command_Ping; class Command_Login; @@ -98,6 +99,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 +136,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_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index 20a4cb08d..0791f8c14 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -25,6 +25,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 +36,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 +57,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 +72,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 +92,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 @@ -130,6 +142,7 @@ set(PROTO_FILES 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 +150,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 @@ -154,13 +178,17 @@ set(PROTO_FILES 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_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 796f4fc68..2e5b88978 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto @@ -1,56 +1,202 @@ syntax = "proto2"; -// Commands that are sent during a game to change the game state +/// Commands that are sent during a game to change the game state message GameCommand { + + /// Identifies the concrete game command to execute enum GameCommandType { + + /// Kick a player from the game. + /// Server: Server_AbstractParticipant::cmdKickFromGame + /// Client: reflected via game state events KICK_FROM_GAME = 1000; + + /// Leave the current game voluntarily. + /// Server: Server_AbstractParticipant::cmdLeaveGame + /// Client: reflected via game state events LEAVE_GAME = 1001; + + /// Send an in-game chat message. + /// Server: Server_AbstractParticipant::cmdGameSay + /// Client: PlayerEventHandler::eventGameSay GAME_SAY = 1002; + + /// Shuffle a zone (typically the library). + /// Server: Server_Player::cmdShuffle + /// Client: PlayerEventHandler::eventShuffle SHUFFLE = 1003; + + /// Take a mulligan at game start. + /// Server: Server_Player::cmdMulligan + /// Client: reflected via game state events MULLIGAN = 1004; + + /// Roll one or more dice. + /// Server: Server_AbstractPlayer::cmdRollDie + /// Client: PlayerEventHandler::eventRollDie ROLL_DIE = 1005; + + /// Draw cards from the deck. + /// Server: Server_Player::cmdDrawCards + /// Client: PlayerEventHandler::eventDrawCards DRAW_CARDS = 1006; + + /// Undo a previous draw action. + /// Server: Server_Player::cmdUndoDraw + /// Client: PlayerEventHandler::eventMoveCard (Context_UndoDraw) UNDO_DRAW = 1007; + + /// Flip a card face up or face down. + /// Server: Server_AbstractPlayer::cmdFlipCard + /// Client: PlayerEventHandler::eventFlipCard FLIP_CARD = 1008; + + /// Attach one card to another. + /// Server: Server_AbstractPlayer::cmdAttachCard + /// Client: PlayerEventHandler::eventAttachCard ATTACH_CARD = 1009; + + /// Create a token card. + /// Server: Server_AbstractPlayer::cmdCreateToken + /// Client: PlayerEventHandler::eventCreateToken CREATE_TOKEN = 1010; + + /// Create a visual arrow between objects. + /// Server: Server_AbstractPlayer::cmdCreateArrow + /// Client: PlayerEventHandler::eventCreateArrow CREATE_ARROW = 1011; + + /// Delete a visual arrow. + /// Server: Server_AbstractPlayer::cmdDeleteArrow + /// Client: PlayerEventHandler::eventDeleteArrow DELETE_ARROW = 1012; + + /// Set a card attribute (e.g. tapped, flipped). + /// Server: Server_AbstractPlayer::cmdSetCardAttr + /// Client: PlayerEventHandler::eventSetCardAttr SET_CARD_ATTR = 1013; + + /// Set a counter value on a card. + /// Server: Server_AbstractPlayer::cmdSetCardCounter + /// Client: PlayerEventHandler::eventSetCardCounter SET_CARD_COUNTER = 1014; + + /// Increment a counter on a card. + /// Server: Server_AbstractPlayer::cmdIncCardCounter + /// Client: PlayerEventHandler::eventSetCardCounter INC_CARD_COUNTER = 1015; + + /// Mark the player as ready to start the game. + /// Server: Server_AbstractPlayer::cmdReadyStart + /// Client: reflected via game state events READY_START = 1016; + + /// Concede the game. + /// Server: Server_AbstractPlayer::cmdConcede + /// Client: reflected via game state events CONCEDE = 1017; + + /// Increment a global (non-card) counter. + /// Server: Server_Player::cmdIncCounter + /// Client: PlayerEventHandler::eventSetCounter INC_COUNTER = 1018; + + /// Create a new global counter. + /// Server: Server_Player::cmdCreateCounter + /// Client: PlayerEventHandler::eventCreateCounter CREATE_COUNTER = 1019; + + /// Set a global counter value. + /// Server: Server_Player::cmdSetCounter + /// Client: PlayerEventHandler::eventSetCounter SET_COUNTER = 1020; + + /// Delete a global counter. + /// Server: Server_Player::cmdDelCounter + /// Client: PlayerEventHandler::eventDelCounter DEL_COUNTER = 1021; + + /// Advance to the next turn. + /// Server: Server_Player::cmdNextTurn + /// Client: reflected via active player / phase events NEXT_TURN = 1022; + + /// Set the active phase of the turn. + /// Server: Server_Player::cmdSetActivePhase + /// Client: reflected via game state events SET_ACTIVE_PHASE = 1023; + + /// Dump the contents of a zone. + /// Server: Server_AbstractPlayer::cmdDumpZone + /// Client: PlayerEventHandler::eventDumpZone DUMP_ZONE = 1024; - // STOP_DUMP_ZONE = 1025; // obsolete + + /// Reveal specific cards to players. + /// Server: Server_AbstractPlayer::cmdRevealCards + /// Client: PlayerEventHandler::eventRevealCards REVEAL_CARDS = 1026; + + /// Move a card between zones. + /// Server: Server_AbstractPlayer::cmdMoveCard + /// Client: PlayerEventHandler::eventMoveCard MOVE_CARD = 1027; + + /// Set the sideboard plan for the game. + /// Server: Server_Player::cmdSetSideboardPlan + /// Client: not forwarded as a game event SET_SIDEBOARD_PLAN = 1028; + + /// Select a deck for the game. + /// Server: Server_Player::cmdDeckSelect + /// Client: reflected via lobby / game state DECK_SELECT = 1029; + + /// Lock or unlock sideboarding. + /// Server: Server_Player::cmdSetSideboardLock + /// Client: reflected via game state SET_SIDEBOARD_LOCK = 1030; + + /// Change zone properties (visibility, reveal-top, etc.). + /// Server: Server_Player::cmdChangeZoneProperties + /// Client: PlayerEventHandler::eventChangeZoneProperties CHANGE_ZONE_PROPERTIES = 1031; + + /// Undo a previous concede. + /// Server: Server_AbstractPlayer::cmdUnconcede + /// Client: reflected via game state UNCONCEDE = 1032; + + /// Execute a command on behalf of another player. + /// Server: Server_AbstractParticipant::cmdJudge + /// Client: transparent (wrapped commands handled normally) JUDGE = 1033; + + /// Reverse the current turn order. + /// 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; } -// A wrapper around a normal game command that allows a privileged user to send a command on behalf of another player +/// A wrapper around a normal game command that allows a privileged user +/// to send a command on behalf of another player. message Command_Judge { + extend GameCommand { + /// Judge command extension payload optional Command_Judge ext = 1033; } - // The player on whose behalf this command is sent + /// The player on whose behalf this command is sent. optional sint32 target_id = 1 [default = -1]; - // The wrapped game command + /// One or more wrapped game commands to execute. repeated GameCommand game_command = 2; } 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 e719f3e92..42a42fcc0 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto @@ -1,35 +1,44 @@ syntax = "proto2"; -// Sent immediately after a command with the same cmd_id, connecting it to the command sent to the server +/// Server response envelope linking to a command +/** + * This message is sent immediately after a client command, using the same `cmd_id` + * to connect the response to the command. + * + * Responses may contain standard outcome codes (`ResponseCode`) and indicate + * what type of response the server is sending (`ResponseType`). + */ message Response { + + // Outcome of the command sent by the client enum ResponseCode { - RespNotConnected = -1; - RespNothing = 0; - RespOk = 1; - RespNotInRoom = 2; - RespInternalError = 3; - RespInvalidCommand = 4; - RespInvalidData = 5; - RespNameNotFound = 6; - RespLoginNeeded = 7; - RespFunctionNotAllowed = 8; - RespGameNotStarted = 9; - RespGameFull = 10; - RespContextError = 11; - RespWrongPassword = 12; - RespSpectatorsNotAllowed = 13; - RespOnlyBuddies = 14; - RespUserLevelTooLow = 15; - RespInIgnoreList = 16; - RespWouldOverwriteOldSession = 17; - RespChatFlood = 18; - RespUserIsBanned = 19; - RespAccessDenied = 20; - RespUsernameInvalid = 21; - RespRegistrationRequired = 22; - RespRegistrationAccepted = 23; // Server agrees to process client's registration request - RespUserAlreadyExists = 24; // Client attempted to register a name which is already registered - RespEmailRequiredToRegister = 25; // Server requires email to register accounts but client did not provide one + RespNotConnected = -1; // Client is not connected or session expired + RespNothing = 0; // No response required + RespOk = 1; // Command succeeded + RespNotInRoom = 2; // Client is not in the room for this command + RespInternalError = 3; // Server encountered an unexpected error + RespInvalidCommand = 4; // Command was invalid or unrecognized + RespInvalidData = 5; // Command data was invalid or malformed + RespNameNotFound = 6; // Target user not found + RespLoginNeeded = 7; // Client must log in first + RespFunctionNotAllowed = 8; // Client tried to perform a restricted action + RespGameNotStarted = 9; // Game has not started yet + RespGameFull = 10; // Game is full + RespContextError = 11; // Context (room/game) mismatch + RespWrongPassword = 12; // Password for this room or game is incorrect + RespSpectatorsNotAllowed = 13; // Spectators are not allowed in this room/game + RespOnlyBuddies = 14; // Only buddies can perform this action + RespUserLevelTooLow = 15; // User’s permission level is insufficient + RespInIgnoreList = 16; // Target user has ignored the client + RespWouldOverwriteOldSession = 17; // Would overwrite an existing session + RespChatFlood = 18; // Too many messages sent in short time + RespUserIsBanned = 19; // Client is banned + RespAccessDenied = 20; // Access to this action is denied + RespUsernameInvalid = 21; // Username is invalid + RespRegistrationRequired = 22; // Registration is required + RespRegistrationAccepted = 23; // Server agrees to process client's registration request + RespUserAlreadyExists = 24; // Client attempted to register a name which is already registered + RespEmailRequiredToRegister = 25; // Server requires email to register accounts but client did not provide one RespTooManyRequests = 26; // Server refused to complete command because client has sent too many too quickly RespPasswordTooShort = 27; // Server requires a decent password RespAccountNotActivated = @@ -44,34 +53,39 @@ 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 } - enum ResponseType { - JOIN_ROOM = 1000; - LIST_USERS = 1001; - GET_GAMES_OF_USER = 1002; - GET_USER_INFO = 1003; - DUMP_ZONE = 1004; - LOGIN = 1005; - DECK_LIST = 1006; - DECK_DOWNLOAD = 1007; - DECK_UPLOAD = 1008; - REGISTER = 1009; - ACTIVATE = 1010; - ADJUST_MOD = 1011; - BAN_HISTORY = 1012; - WARN_HISTORY = 1013; - WARN_LIST = 1014; - VIEW_LOG = 1015; - FORGOT_PASSWORD_REQUEST = 1016; - PASSWORD_SALT = 1017; - GET_ADMIN_NOTES = 1018; - REPLAY_LIST = 1100; - REPLAY_DOWNLOAD = 1101; - REPLAY_GET_CODE = 1102; - CARD_ART_RULE_LIST = 1200; - } - required uint64 cmd_id = 1; - optional ResponseCode response_code = 2; - extensions 100 to max; + // Type of response, used to route handling on the client + enum ResponseType { + JOIN_ROOM = 1000; // Response for joining a room + LIST_USERS = 1001; // Response listing users + GET_GAMES_OF_USER = 1002; // Response with user's games + GET_USER_INFO = 1003; // Response with user info + DUMP_ZONE = 1004; // Response with zone dump + LOGIN = 1005; // Response to login attempt + DECK_LIST = 1006; // Response with deck list + DECK_DOWNLOAD = 1007; // Response for deck download + DECK_UPLOAD = 1008; // Response for deck upload + REGISTER = 1009; // Response to registration + ACTIVATE = 1010; // Response to activation + ADJUST_MOD = 1011; // Response to mod adjustments + BAN_HISTORY = 1012; // Response with ban history + WARN_HISTORY = 1013; // Response with warn history + WARN_LIST = 1014; // Response with current warnings + VIEW_LOG = 1015; // Response with logs + 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 + CARD_ART_RULE_LIST = 1200; // Response containing a list of card art rules + } + + required uint64 cmd_id = 1; // Command ID that this response corresponds to + optional ResponseCode response_code = 2; // Outcome code of the command + + extensions 100 to max; // Protobuf extensions reserved for future use } 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_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..fee8c36a8 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/session_commands.proto @@ -34,6 +34,11 @@ message SessionCommand { 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..62d35313e 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pending_command.cpp +++ b/libcockatrice_protocol/libcockatrice/protocol/pending_command.cpp @@ -29,3 +29,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..b8f861d08 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pending_command.h +++ b/libcockatrice_protocol/libcockatrice/protocol/pending_command.h @@ -7,6 +7,7 @@ #ifndef PENDING_COMMAND_H #define PENDING_COMMAND_H +#include #include #include #include @@ -21,6 +22,7 @@ private: CommandContainer commandContainer; QVariant extraData; int ticks; + QElapsedTimer startTime; public: explicit PendingCommand(const CommandContainer &_commandContainer, QVariant _extraData = QVariant()); @@ -29,6 +31,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 6ff2a4537..b988c9d2f 100644 --- a/libcockatrice_rng/CMakeLists.txt +++ b/libcockatrice_rng/CMakeLists.txt @@ -4,11 +4,7 @@ set(CMAKE_AUTORCC ON) set(HEADERS libcockatrice/rng/rng_abstract.h libcockatrice/rng/rng_sfmt.h libcockatrice/rng/sfmt/SFMT.h) -if(Qt6_FOUND) - qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) -elseif(Qt5_FOUND) - qt5_wrap_cpp(MOC_SOURCES ${HEADERS}) -endif() +qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) add_library( libcockatrice_rng STATIC ${MOC_SOURCES} libcockatrice/rng/rng_abstract.cpp libcockatrice/rng/rng_sfmt.cpp diff --git a/libcockatrice_rng/libcockatrice/rng/rng_sfmt.cpp b/libcockatrice_rng/libcockatrice/rng/rng_sfmt.cpp index 5b38deb3f..4c578b4e4 100644 --- a/libcockatrice_rng/libcockatrice/rng/rng_sfmt.cpp +++ b/libcockatrice_rng/libcockatrice/rng/rng_sfmt.cpp @@ -1,6 +1,5 @@ #include "rng_sfmt.h" -#include #include #include #include @@ -11,10 +10,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..a180dad99 100644 --- a/libcockatrice_rng/libcockatrice/rng/rng_sfmt.h +++ b/libcockatrice_rng/libcockatrice/rng/rng_sfmt.h @@ -36,7 +36,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 3afe6e00a..80a465b6d 100644 --- a/libcockatrice_settings/CMakeLists.txt +++ b/libcockatrice_settings/CMakeLists.txt @@ -3,37 +3,65 @@ set(CMAKE_AUTOUIC ON) set(CMAKE_AUTORCC ON) set(HEADERS + libcockatrice/settings/appearance_settings.h + libcockatrice/settings/cache_storage_settings.h libcockatrice/settings/card_database_settings.h libcockatrice/settings/card_override_settings.h + libcockatrice/settings/cards_display_settings.h + libcockatrice/settings/chat_settings.h + libcockatrice/settings/commander_bracket_settings.h libcockatrice/settings/debug_settings.h + libcockatrice/settings/deck_editor_settings.h libcockatrice/settings/download_settings.h libcockatrice/settings/game_filters_settings.h + libcockatrice/settings/game_settings.h + libcockatrice/settings/interface_settings.h libcockatrice/settings/layouts_settings.h libcockatrice/settings/message_settings.h + libcockatrice/settings/network_settings.h + libcockatrice/settings/paths_settings.h + libcockatrice/settings/personal_settings.h libcockatrice/settings/recents_settings.h libcockatrice/settings/servers_settings.h libcockatrice/settings/settings_manager.h + libcockatrice/settings/settings_migration.h + libcockatrice/settings/sound_settings.h + libcockatrice/settings/tabs_settings.h + libcockatrice/settings/updates_settings.h + libcockatrice/settings/visual_deck_storage_settings.h ) -if(Qt6_FOUND) - qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) -elseif(Qt5_FOUND) - qt5_wrap_cpp(MOC_SOURCES ${HEADERS}) -endif() +qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) add_library( libcockatrice_settings STATIC ${MOC_SOURCES} + libcockatrice/settings/appearance_settings.cpp + libcockatrice/settings/cache_storage_settings.cpp libcockatrice/settings/card_database_settings.cpp libcockatrice/settings/card_override_settings.cpp + libcockatrice/settings/commander_bracket_settings.cpp + libcockatrice/settings/cards_display_settings.cpp + libcockatrice/settings/chat_settings.cpp libcockatrice/settings/debug_settings.cpp + libcockatrice/settings/deck_editor_settings.cpp libcockatrice/settings/download_settings.cpp libcockatrice/settings/game_filters_settings.cpp + libcockatrice/settings/game_settings.cpp + libcockatrice/settings/interface_settings.cpp libcockatrice/settings/layouts_settings.cpp libcockatrice/settings/message_settings.cpp + libcockatrice/settings/network_settings.cpp + libcockatrice/settings/paths_settings.cpp + libcockatrice/settings/personal_settings.cpp libcockatrice/settings/recents_settings.cpp libcockatrice/settings/servers_settings.cpp libcockatrice/settings/settings_manager.cpp + libcockatrice/settings/settings_migration.cpp + libcockatrice/settings/sound_settings.cpp + libcockatrice/settings/tabs_settings.cpp + libcockatrice/settings/updates_settings.cpp + libcockatrice/settings/visual_deck_storage_settings.cpp ) target_include_directories( diff --git a/libcockatrice_settings/libcockatrice/settings/appearance_settings.cpp b/libcockatrice_settings/libcockatrice/settings/appearance_settings.cpp new file mode 100644 index 000000000..839e33be4 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/appearance_settings.cpp @@ -0,0 +1,93 @@ +#include "appearance_settings.h" + +AppearanceSettings::AppearanceSettings(const QString &settingPath, QObject *parent) + : SettingsManager(settingPath + "appearance.ini", "appearance", QString(), parent) +{ +} + +QString AppearanceSettings::getThemeName() const +{ + return getValue("themeName", QString(), QString()).toString(); +} + +void AppearanceSettings::setThemeName(const QString &_themeName) +{ + setValue(_themeName, "themeName"); + emit themeNameChanged(); +} + +bool AppearanceSettings::getStyleUserList() const +{ + return getValue("styleUserList", QString(), QString(), true).toBool(); +} + +void AppearanceSettings::setStyleUserList(bool _styleUserList) +{ + setValue(_styleUserList, "styleUserList"); + emit styleUserListChanged(); +} + +int AppearanceSettings::getMaxFontSize() const +{ + return getValue("maxFontSize", QString(), QString(), 12).toInt(); +} + +void AppearanceSettings::setMaxFontSize(int _max) +{ + setValue(_max, "maxFontSize"); +} + +QString AppearanceSettings::getHomeTabBackgroundSource() const +{ + return getValue("homeTabBackgroundSource", QString(), QString(), "themed").toString(); +} + +void AppearanceSettings::setHomeTabBackgroundSource(const QString &_backgroundSource) +{ + setValue(_backgroundSource, "homeTabBackgroundSource"); + emit homeTabBackgroundSourceChanged(); +} + +int AppearanceSettings::getHomeTabBackgroundShuffleFrequency() const +{ + return getValue("homeTabBackgroundShuffleFrequency", QString(), QString(), 0).toInt(); +} + +void AppearanceSettings::setHomeTabBackgroundShuffleFrequency(int _frequency) +{ + setValue(_frequency, "homeTabBackgroundShuffleFrequency"); + emit homeTabBackgroundShuffleFrequencyChanged(); +} + +bool AppearanceSettings::getHomeTabDisplayCardName() const +{ + return getValue("homeTabDisplayCardName", QString(), QString(), true).toBool(); +} + +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 new file mode 100644 index 000000000..a4504798e --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/appearance_settings.h @@ -0,0 +1,51 @@ +/** + * @file appearance_settings.h + * @ingroup CoreSettings + */ +//! \todo Document this file. + +#ifndef APPEARANCE_SETTINGS_H +#define APPEARANCE_SETTINGS_H + +#include "settings_manager.h" + +class AppearanceSettings : public SettingsManager +{ + Q_OBJECT + friend class SettingsCache; + +public: + [[nodiscard]] QString getThemeName() const; + void setThemeName(const QString &_themeName); + [[nodiscard]] bool getStyleUserList() const; + void setStyleUserList(bool _styleUserList); + [[nodiscard]] int getMaxFontSize() const; + void setMaxFontSize(int _max); + [[nodiscard]] QString getHomeTabBackgroundSource() const; + void setHomeTabBackgroundSource(const QString &_backgroundSource); + [[nodiscard]] int getHomeTabBackgroundShuffleFrequency() const; + 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(); + void styleUserListChanged(); + void homeTabBackgroundSourceChanged(); + void homeTabBackgroundShuffleFrequencyChanged(); + void homeTabDisplayCardNameChanged(); + void homeTabBackgroundDimChanged(); + void homeTabButtonColorChanged(); + +public: + explicit AppearanceSettings(const QString &settingPath, QObject *parent = nullptr); + +private: + AppearanceSettings(const AppearanceSettings & /*other*/); +}; + +#endif // APPEARANCE_SETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/cache_storage_settings.cpp b/libcockatrice_settings/libcockatrice/settings/cache_storage_settings.cpp new file mode 100644 index 000000000..c800cc024 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/cache_storage_settings.cpp @@ -0,0 +1,62 @@ +#include "cache_storage_settings.h" + +CacheStorageSettings::CacheStorageSettings(const QString &settingPath, QObject *parent) + : SettingsManager(settingPath + "cache_storage.ini", "cache_storage", QString(), parent) +{ +} + +int CacheStorageSettings::getPixmapCacheSize() const +{ + return getValue("pixmapCacheSize", QString(), QString(), PIXMAPCACHE_SIZE_DEFAULT).toInt(); +} + +int CacheStorageSettings::getNetworkCacheSizeInMB() const +{ + return getValue("networkCacheSize", QString(), QString(), NETWORK_CACHE_SIZE_DEFAULT).toInt(); +} + +int CacheStorageSettings::getRedirectCacheTtl() const +{ + return getValue("redirectCacheTtl", QString(), QString(), NETWORK_REDIRECT_CACHE_TTL_DEFAULT).toInt(); +} + +int CacheStorageSettings::getCardPictureLoaderCacheMethod() const +{ + return getValue("cardPictureLoaderCacheMethod", QString(), QString(), 0).toInt(); +} + +int CacheStorageSettings::getLocalCardImageStorageNamingScheme() const +{ + return getValue("localCardImageStorageNamingScheme", QString(), QString(), LOCAL_CARD_IMAGE_NAMING_SCHEME_DEFAULT) + .toInt(); +} + +void CacheStorageSettings::setPixmapCacheSize(int _pixmapCacheSize) +{ + setValue(_pixmapCacheSize, "pixmapCacheSize"); + emit pixmapCacheSizeChanged(_pixmapCacheSize); +} + +void CacheStorageSettings::setNetworkCacheSizeInMB(int _networkCacheSize) +{ + setValue(_networkCacheSize, "networkCacheSize"); + emit networkCacheSizeChanged(_networkCacheSize); +} + +void CacheStorageSettings::setNetworkRedirectCacheTtl(int _redirectCacheTtl) +{ + setValue(_redirectCacheTtl, "redirectCacheTtl"); + emit redirectCacheTtlChanged(_redirectCacheTtl); +} + +void CacheStorageSettings::setCardImageCacheMethod(int _cardImageCachingMethod) +{ + setValue(_cardImageCachingMethod, "cardPictureLoaderCacheMethod"); + emit cardPictureLoaderCacheMethodChanged(_cardImageCachingMethod); +} + +void CacheStorageSettings::setLocalCardImageStorageNamingScheme(int _localCardImageStorageNamingScheme) +{ + setValue(_localCardImageStorageNamingScheme, "localCardImageStorageNamingScheme"); + emit localCardImageStorageNamingSchemeChanged(_localCardImageStorageNamingScheme); +} diff --git a/libcockatrice_settings/libcockatrice/settings/cache_storage_settings.h b/libcockatrice_settings/libcockatrice/settings/cache_storage_settings.h new file mode 100644 index 000000000..4a4e73461 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/cache_storage_settings.h @@ -0,0 +1,57 @@ +#ifndef CACHE_STORAGE_SETTINGS_H +#define CACHE_STORAGE_SETTINGS_H + +#include "settings_manager.h" + +#include + +// In MB (Increments of 64) +#define PIXMAPCACHE_SIZE_DEFAULT 2048 +#define PIXMAPCACHE_SIZE_MIN 64 +#define PIXMAPCACHE_SIZE_MAX 4096 + +// In MB +constexpr int NETWORK_CACHE_SIZE_DEFAULT = 1024 * 4; // 4 GB +constexpr int NETWORK_CACHE_SIZE_MIN = 1; // 1 MB +constexpr int NETWORK_CACHE_SIZE_MAX = 1024 * 1024; // 1 TB + +#define LOCAL_CARD_IMAGE_NAMING_SCHEME_DEFAULT 6 + +// In Days +#define NETWORK_REDIRECT_CACHE_TTL_DEFAULT 30 +#define NETWORK_REDIRECT_CACHE_TTL_MIN 1 +#define NETWORK_REDIRECT_CACHE_TTL_MAX 90 + +class CacheStorageSettings : public SettingsManager, public ICacheStorageSettingsProvider +{ + Q_OBJECT + friend class SettingsCache; + +public: + [[nodiscard]] int getPixmapCacheSize() const override; + [[nodiscard]] int getNetworkCacheSizeInMB() const override; + [[nodiscard]] int getRedirectCacheTtl() const override; + [[nodiscard]] int getCardPictureLoaderCacheMethod() const; + [[nodiscard]] int getLocalCardImageStorageNamingScheme() const; + + void setPixmapCacheSize(int _pixmapCacheSize); + void setNetworkCacheSizeInMB(int _networkCacheSize); + void setNetworkRedirectCacheTtl(int _redirectCacheTtl); + void setCardImageCacheMethod(int _cardImageCachingMethod); + void setLocalCardImageStorageNamingScheme(int _scheme); + +signals: + void pixmapCacheSizeChanged(int newSizeInMBs); + void networkCacheSizeChanged(int newSizeInMBs); + void redirectCacheTtlChanged(int newTtl); + void cardPictureLoaderCacheMethodChanged(int cardPictureLoaderCacheMethod); + void localCardImageStorageNamingSchemeChanged(int localCardImageStorageNamingScheme); + +public: + explicit CacheStorageSettings(const QString &settingPath, QObject *parent = nullptr); + +private: + CacheStorageSettings(const CacheStorageSettings & /*other*/); +}; + +#endif // CACHE_STORAGE_SETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/card_database_settings.cpp b/libcockatrice_settings/libcockatrice/settings/card_database_settings.cpp index 4887afd2f..b55c97909 100644 --- a/libcockatrice_settings/libcockatrice/settings/card_database_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/card_database_settings.cpp @@ -1,5 +1,7 @@ #include "card_database_settings.h" +#include + CardDatabaseSettings::CardDatabaseSettings(const QString &settingPath, QObject *parent) : SettingsManager(settingPath + "cardDatabase.ini", QString(), QString(), parent) { @@ -7,32 +9,73 @@ CardDatabaseSettings::CardDatabaseSettings(const QString &settingPath, QObject * void CardDatabaseSettings::setSortKey(QString shortName, unsigned int sortKey) { - setValue(sortKey, "sortkey", "sets", std::move(shortName)); + setValue(sortKey, "sortKey", "sets", shortName); + QMutexLocker lock(&setOptionsMutex); + ensureSetOptionsLoaded(); + setOptionsCache[shortName].sortKey = sortKey; } void CardDatabaseSettings::setEnabled(QString shortName, bool enabled) { - setValue(enabled, "enabled", "sets", std::move(shortName)); + setValue(enabled, "enabled", "sets", shortName); + QMutexLocker lock(&setOptionsMutex); + ensureSetOptionsLoaded(); + setOptionsCache[shortName].enabled = enabled; } void CardDatabaseSettings::setIsKnown(QString shortName, bool isknown) { - setValue(isknown, "isknown", "sets", std::move(shortName)); + setValue(isknown, "isKnown", "sets", shortName); + QMutexLocker lock(&setOptionsMutex); + ensureSetOptionsLoaded(); + setOptionsCache[shortName].isKnown = isknown; +} + +void CardDatabaseSettings::ensureSetOptionsLoaded() const +{ + if (setOptionsLoaded) { + return; + } + QSettings settings(getSettings()); + settings.beginGroup("sets"); + const QStringList groups = settings.childGroups(); + for (const QString &group : groups) { + settings.beginGroup(group); + SetOptions &o = setOptionsCache[group]; + o.sortKey = settings.value("sortKey", 0).toUInt(); + o.enabled = settings.value("enabled", true).toBool(); + o.isKnown = settings.value("isKnown", true).toBool(); + settings.endGroup(); + } + setOptionsLoaded = true; } unsigned int CardDatabaseSettings::getSortKey(QString shortName) const { - return getValue("sortkey", "sets", std::move(shortName)).toUInt(); + QMutexLocker lock(&setOptionsMutex); + ensureSetOptionsLoaded(); + return setOptionsCache.value(shortName).sortKey; } bool CardDatabaseSettings::isEnabled(QString shortName) const { - return getValue("enabled", "sets", std::move(shortName)).toBool(); + QMutexLocker lock(&setOptionsMutex); + ensureSetOptionsLoaded(); + return setOptionsCache.value(shortName).enabled; } bool CardDatabaseSettings::isKnown(QString shortName) const { - return getValue("isknown", "sets", std::move(shortName)).toBool(); + QMutexLocker lock(&setOptionsMutex); + ensureSetOptionsLoaded(); + return setOptionsCache.value(shortName).isKnown; +} + +ICardSetPriorityController::SetOptions CardDatabaseSettings::getSetOptions(QString shortName) const +{ + QMutexLocker lock(&setOptionsMutex); + ensureSetOptionsLoaded(); + return setOptionsCache.value(shortName); } void CardDatabaseSettings::saveSets(const QVector &data) @@ -41,10 +84,18 @@ void CardDatabaseSettings::saveSets(const QVector +#include #include class CardDatabaseSettings : public SettingsManager, public ICardSetPriorityController @@ -26,10 +28,23 @@ public: bool isEnabled(QString shortName) const override; bool isKnown(QString shortName) const override; + SetOptions getSetOptions(QString shortName) const override; + void saveSets(const QVector &data) override; private: explicit CardDatabaseSettings(const QString &settingPath, QObject *parent = nullptr); + + // In-memory cache of set priority options. QSettings re-opens and re-parses + // the whole .ini on every access, which is catastrophic when CardSet + // construction calls getSortKey/isEnabled/isKnown once per set during the + // database load (thousands of file parses). The cache is populated from a + // single QSettings session and kept consistent with the writes below. + mutable QMutex setOptionsMutex; + mutable QHash setOptionsCache; + mutable bool setOptionsLoaded = false; + + void ensureSetOptionsLoaded() const; }; #endif // CARDDATABASESETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp new file mode 100644 index 000000000..b8eafca6c --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp @@ -0,0 +1,244 @@ +#include "cards_display_settings.h" + +CardsDisplaySettings::CardsDisplaySettings(const QString &settingPath, QObject *parent) + : SettingsManager(settingPath + "cards_display.ini", "cards", QString(), parent) +{ +} + +bool CardsDisplaySettings::getDisplayCardNames() const +{ + return getValue("displayCardNames", QString(), QString(), true).toBool(); +} + +bool CardsDisplaySettings::getRoundCardCorners() const +{ + return getValue("roundCardCorners", QString(), QString(), true).toBool(); +} + +bool CardsDisplaySettings::getOverrideAllCardArtWithPersonalPreference() const +{ + return getValue("overrideAllCardArtWithPersonalPreference", QString(), QString(), false).toBool(); +} + +bool CardsDisplaySettings::getBumpSetsWithCardsInDeckToTop() const +{ + return getValue("bumpSetsWithCardsInDeckToTop", QString(), QString(), true).toBool(); +} + +int CardsDisplaySettings::getPrintingSelectorSortOrder() const +{ + return getValue("sortOrder", "cards", "printingSelector", 1).toInt(); +} + +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(); +} + +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(); +} + +bool CardsDisplaySettings::getScaleCards() const +{ + return getValue("scaleCards", QString(), QString(), true).toBool(); +} + +int CardsDisplaySettings::getStackCardOverlapPercent() const +{ + return getValue("verticalCardOverlapPercent", QString(), QString(), 33).toInt(); +} + +int CardsDisplaySettings::getCardInfoViewMode() const +{ + return getValue("cardInfoViewMode", QString(), QString(), 0).toInt(); +} + +int CardsDisplaySettings::getVisualDeckStorageCardSize() const +{ + return getValue("visualDeckStorage", "cards", "cardSize", 100).toInt(); +} + +int CardsDisplaySettings::getVisualDatabaseDisplayCardSize() const +{ + return getValue("visualDatabaseDisplay", "cards", "cardSize", 100).toInt(); +} + +int CardsDisplaySettings::getVisualDeckEditorCardSize() const +{ + return getValue("visualDeckEditor", "cards", "cardSize", 100).toInt(); +} + +int CardsDisplaySettings::getEDHRecCardSize() const +{ + return getValue("edhrec", "cards", "cardSize", 100).toInt(); +} + +int CardsDisplaySettings::getArchidektPreviewSize() const +{ + return getValue("archidektPreview", "cards", "cardSize", 100).toInt(); +} + +int CardsDisplaySettings::getSampleHandSize() const +{ + return getValue("sampleHandSize", "cards", "cardSize", 7).toInt(); +} + +QString CardsDisplaySettings::getCardLang() const +{ + return getValue("cardLang", QString(), QString(), "en").toString(); +} + +void CardsDisplaySettings::setDisplayCardNames(bool _displayCardNames) +{ + setValue(_displayCardNames, "displayCardNames"); + emit displayCardNamesChanged(); +} + +void CardsDisplaySettings::setRoundCardCorners(bool _roundCardCorners) +{ + if (_roundCardCorners == getRoundCardCorners()) { + return; + } + setValue(_roundCardCorners, "roundCardCorners"); + emit roundCardCornersChanged(_roundCardCorners); +} + +void CardsDisplaySettings::setOverrideAllCardArtWithPersonalPreference(bool _overrideAllCardArt) +{ + setValue(_overrideAllCardArt, "overrideAllCardArtWithPersonalPreference"); + emit overrideAllCardArtWithPersonalPreferenceChanged(_overrideAllCardArt); +} + +void CardsDisplaySettings::setBumpSetsWithCardsInDeckToTop(bool _bumpSetsWithCardsInDeckToTop) +{ + setValue(_bumpSetsWithCardsInDeckToTop, "bumpSetsWithCardsInDeckToTop"); + emit bumpSetsWithCardsInDeckToTopChanged(); +} + +void CardsDisplaySettings::setPrintingSelectorSortOrder(int _printingSelectorSortOrder) +{ + setValue(_printingSelectorSortOrder, "sortOrder", "cards", "printingSelector"); + emit printingSelectorSortOrderChanged(); +} + +void CardsDisplaySettings::setPrintingSelectorCardSize(int _printingSelectorCardSize) +{ + setValue(_printingSelectorCardSize, "printingSelector", "cards", "cardSize"); + 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"); + emit printingSelectorNavigationButtonsVisibleChanged(); +} + +void CardsDisplaySettings::setTapAnimation(bool _tapAnimation) +{ + setValue(_tapAnimation, "tapAnimation"); +} + +void CardsDisplaySettings::setArrowDrawAnimation(bool _arrowDrawAnimation) +{ + setValue(_arrowDrawAnimation, "arrowDrawAnimation"); +} + +void CardsDisplaySettings::setAutoRotateSidewaysLayoutCards(bool _autoRotateSidewaysLayoutCards) +{ + setValue(_autoRotateSidewaysLayoutCards, "autoRotateSidewaysLayoutCards"); +} + +void CardsDisplaySettings::setCardScaling(bool _scaleCards) +{ + setValue(_scaleCards, "scaleCards"); +} + +void CardsDisplaySettings::setStackCardOverlapPercent(int _verticalCardOverlapPercent) +{ + setValue(_verticalCardOverlapPercent, "verticalCardOverlapPercent"); +} + +void CardsDisplaySettings::setCardInfoViewMode(int _viewMode) +{ + setValue(_viewMode, "cardInfoViewMode"); +} + +void CardsDisplaySettings::setVisualDeckStorageCardSize(int _cardSize) +{ + setValue(_cardSize, "visualDeckStorage", "cards", "cardSize"); + emit visualDeckStorageCardSizeChanged(); +} + +void CardsDisplaySettings::setVisualDatabaseDisplayCardSize(int _cardSize) +{ + setValue(_cardSize, "visualDatabaseDisplay", "cards", "cardSize"); + emit visualDatabaseDisplayCardSizeChanged(); +} + +void CardsDisplaySettings::setVisualDeckEditorCardSize(int _cardSize) +{ + setValue(_cardSize, "visualDeckEditor", "cards", "cardSize"); + emit visualDeckEditorCardSizeChanged(); +} + +void CardsDisplaySettings::setEDHRecCardSize(int _edhrecCardSize) +{ + setValue(_edhrecCardSize, "edhrec", "cards", "cardSize"); + emit edhRecCardSizeChanged(); +} + +void CardsDisplaySettings::setArchidektPreviewCardSize(int _archidektPreviewCardSize) +{ + setValue(_archidektPreviewCardSize, "archidektPreview", "cards", "cardSize"); + emit archidektPreviewSizeChanged(); +} + +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); +} diff --git a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h new file mode 100644 index 000000000..0b47ce490 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h @@ -0,0 +1,82 @@ +#ifndef CARDS_DISPLAY_SETTINGS_H +#define CARDS_DISPLAY_SETTINGS_H + +#include "settings_manager.h" + +#include + +class CardsDisplaySettings : public SettingsManager, public ICardsDisplaySettingsProvider +{ + Q_OBJECT + friend class SettingsCache; + +public: + [[nodiscard]] bool getDisplayCardNames() const override; + [[nodiscard]] bool getRoundCardCorners() const override; + [[nodiscard]] bool getOverrideAllCardArtWithPersonalPreference() const override; + [[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; + [[nodiscard]] int getCardInfoViewMode() const override; + [[nodiscard]] int getVisualDeckStorageCardSize() const override; + [[nodiscard]] int getVisualDatabaseDisplayCardSize() const override; + [[nodiscard]] int getVisualDeckEditorCardSize() const override; + [[nodiscard]] int getEDHRecCardSize() const override; + [[nodiscard]] int getArchidektPreviewSize() const override; + [[nodiscard]] int getSampleHandSize() const override; + [[nodiscard]] QString getCardLang() const override; + + void setDisplayCardNames(bool _displayCardNames); + void setRoundCardCorners(bool _roundCardCorners); + void setOverrideAllCardArtWithPersonalPreference(bool _overrideAllCardArt); + 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); + void setCardInfoViewMode(int _viewMode); + void setVisualDeckStorageCardSize(int _cardSize); + void setVisualDatabaseDisplayCardSize(int _cardSize); + void setVisualDeckEditorCardSize(int _cardSize); + void setEDHRecCardSize(int _edhrecCardSize); + void setArchidektPreviewCardSize(int _archidektPreviewCardSize); + void setSampleHandSize(int _sampleHandSize); + void setCardLang(const QString &_cardLang); + +signals: + void displayCardNamesChanged(); + void roundCardCornersChanged(bool roundCardCorners); + void overrideAllCardArtWithPersonalPreferenceChanged(bool _overrideAllCardArtWithPersonalPreference); + void bumpSetsWithCardsInDeckToTopChanged(); + void printingSelectorSortOrderChanged(); + void printingSelectorCardSizeChanged(); + void includeRebalancedCardsChanged(bool _includeRebalancedCards); + void printingSelectorNavigationButtonsVisibleChanged(); + void visualDeckStorageCardSizeChanged(); + void visualDatabaseDisplayCardSizeChanged(); + void visualDeckEditorCardSizeChanged(); + void edhRecCardSizeChanged(); + void archidektPreviewSizeChanged(); + void sampleHandSizeChanged(int amount); + void cardLangChanged(const QString &lang); + +public: + explicit CardsDisplaySettings(const QString &settingPath, QObject *parent = nullptr); + +private: + CardsDisplaySettings(const CardsDisplaySettings & /*other*/); +}; + +#endif // CARDS_DISPLAY_SETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/chat_settings.cpp b/libcockatrice_settings/libcockatrice/settings/chat_settings.cpp new file mode 100644 index 000000000..974173371 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/chat_settings.cpp @@ -0,0 +1,147 @@ +#include "chat_settings.h" + +ChatSettings::ChatSettings(const QString &settingPath, QObject *parent) + : SettingsManager(settingPath + "chat.ini", "chat", QString(), parent) +{ +} + +bool ChatSettings::getChatMention() const +{ + return getValue("mention", QString(), QString(), true).toBool(); +} + +bool ChatSettings::getChatMentionCompleter() const +{ + return getValue("mentionCompleter", QString(), QString(), true).toBool(); +} + +QString ChatSettings::getChatMentionColor() const +{ + return getValue("mentionColor", QString(), QString(), "A6120D").toString(); +} + +QString ChatSettings::getChatHighlightColor() const +{ + return getValue("highlightColor", QString(), QString(), "A6120D").toString(); +} + +bool ChatSettings::getChatMentionForeground() const +{ + return getValue("mentionForeground", QString(), QString(), true).toBool(); +} + +bool ChatSettings::getChatHighlightForeground() const +{ + return getValue("highlightForeground", QString(), QString(), true).toBool(); +} + +bool ChatSettings::getIgnoreUnregisteredUsers() const +{ + return getValue("ignoreUnregistered").toBool(); +} + +bool ChatSettings::getIgnoreUnregisteredUserMessages() const +{ + return getValue("ignoreUnregisteredMessages").toBool(); +} + +bool ChatSettings::getIgnoreNonBuddyUserMessages() const +{ + return getValue("ignoreNonBuddyMessages").toBool(); +} + +bool ChatSettings::getShowMessagePopup() const +{ + return getValue("showMessagePopups", QString(), QString(), true).toBool(); +} + +bool ChatSettings::getShowMentionPopup() const +{ + return getValue("showMentionPopups", QString(), QString(), true).toBool(); +} + +bool ChatSettings::getRoomHistory() const +{ + return getValue("roomHistory", QString(), QString(), true).toBool(); +} + +bool ChatSettings::getIgnoreAllPrivateMessages() const +{ + return getValue("ignoreAllPrivateMessages", QString(), QString(), false).toBool(); +} + +QString ChatSettings::getHighlightWords() const +{ + return getValue("highlightWords").toString(); +} + +void ChatSettings::setChatMention(bool _chatMention) +{ + setValue(_chatMention, "mention"); +} + +void ChatSettings::setChatMentionCompleter(bool _chatMentionCompleter) +{ + setValue(_chatMentionCompleter, "mentionCompleter"); + emit chatMentionCompleterChanged(); +} + +void ChatSettings::setChatMentionColor(const QString &_chatMentionColor) +{ + setValue(_chatMentionColor, "mentionColor"); +} + +void ChatSettings::setChatHighlightColor(const QString &_chatHighlightColor) +{ + setValue(_chatHighlightColor, "highlightColor"); +} + +void ChatSettings::setChatMentionForeground(bool _chatMentionForeground) +{ + setValue(_chatMentionForeground, "mentionForeground"); +} + +void ChatSettings::setChatHighlightForeground(bool _chatHighlightForeground) +{ + setValue(_chatHighlightForeground, "highlightForeground"); +} + +void ChatSettings::setIgnoreUnregisteredUsers(bool _ignoreUnregisteredUsers) +{ + setValue(_ignoreUnregisteredUsers, "ignoreUnregistered"); +} + +void ChatSettings::setIgnoreUnregisteredUserMessages(bool _ignoreUnregisteredUserMessages) +{ + setValue(_ignoreUnregisteredUserMessages, "ignoreUnregisteredMessages"); +} + +void ChatSettings::setIgnoreNonBuddyUserMessages(bool _ignoreNonBuddyUserMessages) +{ + setValue(_ignoreNonBuddyUserMessages, "ignoreNonBuddyMessages"); +} + +void ChatSettings::setShowMessagePopups(bool _showMessagePopups) +{ + setValue(_showMessagePopups, "showMessagePopups"); +} + +void ChatSettings::setShowMentionPopups(bool _showMentionPopups) +{ + setValue(_showMentionPopups, "showMentionPopups"); +} + +void ChatSettings::setRoomHistory(bool _roomHistory) +{ + setValue(_roomHistory, "roomHistory"); +} + +void ChatSettings::setIgnoreAllPrivateMessages(bool _ignoreAllPrivateMessages) +{ + setValue(_ignoreAllPrivateMessages, "ignoreAllPrivateMessages"); +} + +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 new file mode 100644 index 000000000..9566671e2 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/chat_settings.h @@ -0,0 +1,54 @@ +#ifndef CHAT_SETTINGS_H +#define CHAT_SETTINGS_H + +#include "settings_manager.h" + +#include + +class ChatSettings : public SettingsManager, public IChatSettingsProvider +{ + Q_OBJECT + friend class SettingsCache; + +public: + [[nodiscard]] bool getChatMention() const override; + [[nodiscard]] bool getChatMentionCompleter() const override; + [[nodiscard]] QString getChatMentionColor() const override; + [[nodiscard]] QString getChatHighlightColor() const override; + [[nodiscard]] bool getChatMentionForeground() const override; + [[nodiscard]] bool getChatHighlightForeground() const override; + [[nodiscard]] bool getIgnoreUnregisteredUsers() const override; + [[nodiscard]] bool getIgnoreUnregisteredUserMessages() const override; + [[nodiscard]] bool getIgnoreNonBuddyUserMessages() const override; + [[nodiscard]] bool getShowMessagePopup() const override; + [[nodiscard]] bool getShowMentionPopup() const override; + [[nodiscard]] bool getRoomHistory() const override; + [[nodiscard]] bool getIgnoreAllPrivateMessages() const override; + [[nodiscard]] QString getHighlightWords() const override; + + void setChatMention(bool _chatMention); + void setChatMentionCompleter(bool _chatMentionCompleter); + void setChatMentionColor(const QString &_chatMentionColor); + void setChatHighlightColor(const QString &_chatHighlightColor); + void setChatMentionForeground(bool _chatMentionForeground); + void setChatHighlightForeground(bool _chatHighlightForeground); + void setIgnoreUnregisteredUsers(bool _ignoreUnregisteredUsers); + void setIgnoreUnregisteredUserMessages(bool _ignoreUnregisteredUserMessages); + void setIgnoreNonBuddyUserMessages(bool _ignoreNonBuddyUserMessages); + void setShowMessagePopups(bool _showMessagePopups); + void setShowMentionPopups(bool _showMentionPopups); + void setRoomHistory(bool _roomHistory); + void setIgnoreAllPrivateMessages(bool _ignoreAllPrivateMessages); + void setHighlightWords(const QString &_highlightWords); + +signals: + void chatMentionCompleterChanged(); + +public: + explicit ChatSettings(const QString &settingPath, QObject *parent = nullptr); + +private: + ChatSettings(const ChatSettings & /*other*/); +}; + +#endif // CHAT_SETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/commander_bracket_settings.cpp b/libcockatrice_settings/libcockatrice/settings/commander_bracket_settings.cpp new file mode 100644 index 000000000..0c5f5521e --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/commander_bracket_settings.cpp @@ -0,0 +1,194 @@ +#include "commander_bracket_settings.h" + +#include + +QVariantList CommanderBracketSettings::defaultDefinitions() +{ + return { + QVariantMap{{"tag", "R"}, + {"officialName", "[5] cEDH"}, + {"displayName", "Ruthless"}, + {"explanation", + "Top-tier competitive decks with maximum optimization, fast combos, and minimal variance."}}, + QVariantMap{{"tag", "S"}, + {"officialName", "[4] Optimized"}, + {"displayName", "Spicy"}, + {"explanation", "Highly tuned decks with strong synergy and occasional combo finishes."}}, + QVariantMap{{"tag", "P"}, + {"officialName", "[3] Upgraded"}, + {"displayName", "Powerful"}, + {"explanation", "Focused decks with clear win conditions and solid consistency."}}, + QVariantMap{{"tag", "O"}, + {"officialName", "[2] Core"}, + {"displayName", "Oddball"}, + {"explanation", "Unconventional or thematic decks with some structure but non-standard choices."}}, + QVariantMap{{"tag", "C"}, + {"officialName", "[2] Core"}, + {"displayName", "Core"}, + {"explanation", "Preconstructed or precon-level decks with straightforward strategies."}}, + QVariantMap{{"tag", "E"}, + {"officialName", "[1] Exhibition"}, + {"displayName", "Exhibition"}, + {"explanation", "Ultra-casual, theme-focused decks with minimal optimization."}}, + QVariantMap{{"tag", "B"}, + {"officialName", "Banned"}, + {"displayName", "Banned"}, + {"explanation", "The deck contains one or more cards banned in Commander."}}, + }; +} + +CommanderBracketSettings::CommanderBracketSettings(const QString &settingPath, QObject *parent) + : SettingsManager(settingPath + "commander_brackets.ini", "commander_brackets", QString(), parent) +{ +} + +void CommanderBracketSettings::setSchemaVersion(int version) +{ + setValue(version, "schemaVersion"); +} +int CommanderBracketSettings::getSchemaVersion() const +{ + QVariant value = getValue("schemaVersion"); + return value.isValid() ? value.toInt() : 0; +} + +void CommanderBracketSettings::clearDefinitions() +{ + auto settings = getSettings(); + + settings.beginGroup("commander_brackets"); + settings.remove(""); + settings.endGroup(); + + settings.sync(); +} + +void CommanderBracketSettings::saveDefinitions(const QVariantList &definitions) +{ + auto settings = getSettings(); + + settings.beginGroup("commander_brackets"); + + settings.remove(""); + + settings.setValue("schemaVersion", CurrentSchemaVersion); + + for (const auto &entry : definitions) { + QVariantMap map = entry.toMap(); + + QString tag = map.value("tag").toString(); + + if (tag.isEmpty()) { + continue; + } + + settings.beginGroup(tag); + + settings.setValue("officialName", map.value("officialName")); + + settings.setValue("displayName", map.value("displayName")); + + settings.setValue("explanation", map.value("explanation")); + + settings.endGroup(); + } + + settings.endGroup(); + + settings.sync(); +} + +QVariantList CommanderBracketSettings::loadDefinitions() const +{ + QVariantList result; + + auto settings = getSettings(); + + settings.beginGroup("commander_brackets"); + + int version = settings.value("schemaVersion", 0).toInt(); + + if (version != CurrentSchemaVersion) { + settings.endGroup(); + return result; + } + + QStringList groups = settings.childGroups(); + + for (const QString &tag : groups) { + settings.beginGroup(tag); + + QVariantMap map; + + map["tag"] = tag; + map["officialName"] = settings.value("officialName"); + map["displayName"] = settings.value("displayName"); + map["explanation"] = settings.value("explanation"); + + result.append(map); + + settings.endGroup(); + } + + settings.endGroup(); + + return result; +} + +void CommanderBracketSettings::reloadDefinitions(const QVariantList &definitionsList) +{ + definitions.clear(); + + for (const auto &entry : definitionsList) { + const auto map = entry.toMap(); + + CommanderBracketDefinition definition; + + definition.tag = map.value("tag").toString(); + definition.officialName = map.value("officialName").toString(); + definition.displayName = map.value("displayName").toString(); + definition.explanation = map.value("explanation").toString(); + + if (!definition.tag.isEmpty()) { + definitions.insert(definition.tag, definition); + } + } +} + +QString CommanderBracketSettings::officialName(const QString &tag) const +{ + auto it = definitions.find(tag); + + if (it == definitions.end()) { + return tag; + } + + return it->officialName; +} + +QString CommanderBracketSettings::displayName(const QString &tag) const +{ + auto it = definitions.find(tag); + + if (it == definitions.end()) { + return tag; + } + + return it->displayName; +} + +QString CommanderBracketSettings::explanation(const QString &tag) const +{ + auto it = definitions.find(tag); + + if (it == definitions.end()) { + return {}; + } + + return it->explanation; +} + +bool CommanderBracketSettings::contains(const QString &tag) const +{ + return definitions.contains(tag); +} diff --git a/libcockatrice_settings/libcockatrice/settings/commander_bracket_settings.h b/libcockatrice_settings/libcockatrice/settings/commander_bracket_settings.h new file mode 100644 index 000000000..5a29f5dc7 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/commander_bracket_settings.h @@ -0,0 +1,56 @@ +#ifndef COMMANDER_BRACKET_SETTINGS_H +#define COMMANDER_BRACKET_SETTINGS_H + +#include "settings_manager.h" + +#include +#include +#include +#include + +struct CommanderBracketDefinition +{ + QString tag; + + QString officialName; + QString displayName; + + QString explanation; +}; + +class CommanderBracketSettings : public SettingsManager +{ + Q_OBJECT + friend class SettingsCache; + +public: + static constexpr int CurrentSchemaVersion = 1; + + static QVariantList defaultDefinitions(); + + void clearDefinitions(); + + void saveDefinitions(const QVariantList &definitions); + + QVariantList loadDefinitions() const; + + void reloadDefinitions(const QVariantList &definitions); + + QString officialName(const QString &tag) const; + QString displayName(const QString &tag) const; + QString explanation(const QString &tag) const; + bool contains(const QString &tag) const; + + void setSchemaVersion(int version); + int getSchemaVersion() const; + +private: + explicit CommanderBracketSettings(const QString &settingPath, QObject *parent = nullptr); + + CommanderBracketSettings(const CommanderBracketSettings &) = delete; + CommanderBracketSettings &operator=(const CommanderBracketSettings &) = delete; + + QHash definitions; +}; + +#endif // COMMANDER_BRACKET_SETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/deck_editor_settings.cpp b/libcockatrice_settings/libcockatrice/settings/deck_editor_settings.cpp new file mode 100644 index 000000000..44cdcd86f --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/deck_editor_settings.cpp @@ -0,0 +1,83 @@ +#include "deck_editor_settings.h" + +DeckEditorSettings::DeckEditorSettings(const QString &settingPath, QObject *parent) + : SettingsManager(settingPath + "deck_editor.ini", "deckeditor", QString(), parent) +{ +} + +bool DeckEditorSettings::getOpenDeckInNewTab() const +{ + return getValue("openDeckInNewTab", QString(), QString(), false).toBool(); +} + +bool DeckEditorSettings::getBannerCardComboBoxVisible() const +{ + return getValue("bannerCardComboBoxVisible", QString(), QString(), true).toBool(); +} + +bool DeckEditorSettings::getTagsWidgetVisible() const +{ + return getValue("tagsWidgetVisible", QString(), QString(), true).toBool(); +} + +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"); +} + +void DeckEditorSettings::setBannerCardComboBoxVisible(bool _bannerCardComboBoxVisible) +{ + setValue(_bannerCardComboBoxVisible, "bannerCardComboBoxVisible"); + emit bannerCardComboBoxVisibleChanged(_bannerCardComboBoxVisible); +} + +void DeckEditorSettings::setTagsWidgetVisible(bool _tagsWidgetVisible) +{ + setValue(_tagsWidgetVisible, "tagsWidgetVisible"); + emit tagsWidgetVisibleChanged(_tagsWidgetVisible); +} + +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(), + commanderSpellbookIntegrationEnabledIndexUnprompted) + .toInt(); +} + +bool DeckEditorSettings::getCommanderSpellbookIntegrationUseOfficialBracketNames() const +{ + return getValue("commanderspellbookintegrationuseofficialbracketnames", QString(), QString(), false).toBool(); +} + +void DeckEditorSettings::setCommanderSpellbookIntegrationEnabled(int _commanderSpellbookIntegrationEnabled) +{ + setValue(_commanderSpellbookIntegrationEnabled, "commanderspellbookintegrationenabled"); + emit commanderSpellbookIntegrationEnabledChanged(_commanderSpellbookIntegrationEnabled); +} + +void DeckEditorSettings::setCommanderSpellbookIntegrationUseOfficialBracketNames(bool _useOfficialBracketNames) +{ + setValue(_useOfficialBracketNames, "commanderspellbookintegrationuseofficialbracketnames"); + emit commanderSpellbookIntegrationUseOfficialBracketNamesChanged(_useOfficialBracketNames); +} diff --git a/libcockatrice_settings/libcockatrice/settings/deck_editor_settings.h b/libcockatrice_settings/libcockatrice/settings/deck_editor_settings.h new file mode 100644 index 000000000..0c87a270a --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/deck_editor_settings.h @@ -0,0 +1,59 @@ +#ifndef DECK_EDITOR_SETTINGS_H +#define DECK_EDITOR_SETTINGS_H + +#include "settings_manager.h" + +#include + +enum commanderSpellbookIntegrationEnabledIndex +{ + commanderSpellbookIntegrationEnabledIndexDisabled, + commanderSpellbookIntegrationEnabledIndexEnabled, + commanderSpellbookIntegrationEnabledIndexAutomatic, + 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 + friend class SettingsCache; + +public: + [[nodiscard]] bool getOpenDeckInNewTab() const override; + [[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; + + void setOpenDeckInNewTab(bool _openDeckInNewTab); + void setBannerCardComboBoxVisible(bool _bannerCardComboBoxVisible); + void setTagsWidgetVisible(bool _tagsWidgetVisible); + void setDefaultDeckEditorType(int _defaultDeckEditorType); + void setVdeStartupTab(int _vdeStartupTab); + void setCommanderSpellbookIntegrationEnabled(int _commanderSpellbookIntegrationEnabled); + void setCommanderSpellbookIntegrationUseOfficialBracketNames(bool _useOfficialBracketNames); + +signals: + void bannerCardComboBoxVisibleChanged(bool visible); + 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); + +private: + DeckEditorSettings(const DeckEditorSettings & /*other*/); +}; + +#endif // DECK_EDITOR_SETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/download_settings.cpp b/libcockatrice_settings/libcockatrice/settings/download_settings.cpp index 66525a598..eb73e58ee 100644 --- a/libcockatrice_settings/libcockatrice/settings/download_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/download_settings.cpp @@ -3,11 +3,15 @@ #include "settings_manager.h" const QStringList DownloadSettings::DEFAULT_DOWNLOAD_URLS = { - "https://api.scryfall.com/cards/!set:uuid!?format=image&face=!prop:side!", - "https://api.scryfall.com/cards/multiverse/!set:muid!?format=image", + "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!&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!"; + DownloadSettings::DownloadSettings(const QString &settingPath, QObject *parent = nullptr) : SettingsManager(settingPath + "downloads.ini", "downloads", QString(), parent) { @@ -27,3 +31,37 @@ 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(); +} + +void DownloadSettings::setPicDownload(bool _picDownload) +{ + setValue(_picDownload, "pictureDownload"); + emit picDownloadChanged(); +} + +bool DownloadSettings::getDownloadSpoilersStatus() const +{ + return getValue("downloadSpoilers", QString(), QString(), false).toBool(); +} + +void DownloadSettings::setDownloadSpoilerStatus(bool _spoilerStatus) +{ + setValue(_spoilerStatus, "downloadSpoilers"); + emit downloadSpoilerStatusChanged(); +} diff --git a/libcockatrice_settings/libcockatrice/settings/download_settings.h b/libcockatrice_settings/libcockatrice/settings/download_settings.h index 60e59220b..ae49884f0 100644 --- a/libcockatrice_settings/libcockatrice/settings/download_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/download_settings.h @@ -15,6 +15,7 @@ class DownloadSettings : public SettingsManager friend class SettingsCache; static const QStringList DEFAULT_DOWNLOAD_URLS; + static const QString SCRYFALL_NAMED_LOCALIZED_URL; public: explicit DownloadSettings(const QString &, QObject *); @@ -22,6 +23,15 @@ public: 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); + +signals: + void picDownloadChanged(); + void downloadSpoilerStatusChanged(); }; #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 4f5bf52ee..0edba44dd 100644 --- a/libcockatrice_settings/libcockatrice/settings/game_filters_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/game_filters_settings.cpp @@ -19,137 +19,137 @@ static QString hashGameType(const QString &gameType) void GameFiltersSettings::setHideBuddiesOnlyGames(bool hide) { - setValue(hide, "hide_buddies_only_games"); + setValue(hide, "hideBuddiesOnlyGames"); } bool GameFiltersSettings::isHideBuddiesOnlyGames() const { - QVariant previous = getValue("hide_buddies_only_games"); + QVariant previous = getValue("hideBuddiesOnlyGames"); return previous == QVariant() ? false : previous.toBool(); } void GameFiltersSettings::setHideFullGames(bool hide) { - setValue(hide, "hide_full_games"); + setValue(hide, "hideFullGames"); } bool GameFiltersSettings::isHideFullGames() const { - QVariant previous = getValue("hide_full_games"); + QVariant previous = getValue("hideFullGames"); return previous == QVariant() ? false : previous.toBool(); } void GameFiltersSettings::setHideGamesThatStarted(bool hide) { - setValue(hide, "hide_games_that_started"); + setValue(hide, "hideGamesThatStarted"); } bool GameFiltersSettings::isHideGamesThatStarted() const { - QVariant previous = getValue("hide_games_that_started"); + QVariant previous = getValue("hideGamesThatStarted"); return previous == QVariant() ? false : previous.toBool(); } void GameFiltersSettings::setHidePasswordProtectedGames(bool hide) { - setValue(hide, "hide_password_protected_games"); + setValue(hide, "hidePasswordProtectedGames"); } bool GameFiltersSettings::isHidePasswordProtectedGames() const { - QVariant previous = getValue("hide_password_protected_games"); + QVariant previous = getValue("hidePasswordProtectedGames"); return previous == QVariant() ? false : previous.toBool(); } void GameFiltersSettings::setHideIgnoredUserGames(bool hide) { - setValue(hide, "hide_ignored_user_games"); + setValue(hide, "hideIgnoredUserGames"); } bool GameFiltersSettings::isHideIgnoredUserGames() const { - QVariant previous = getValue("hide_ignored_user_games"); + QVariant previous = getValue("hideIgnoredUserGames"); return previous == QVariant() ? true : previous.toBool(); } void GameFiltersSettings::setHideNotBuddyCreatedGames(bool hide) { - setValue(hide, "hide_not_buddy_created_games"); + setValue(hide, "hideNotBuddyCreatedGames"); } bool GameFiltersSettings::isHideNotBuddyCreatedGames() const { - QVariant previous = getValue("hide_not_buddy_created_games"); + QVariant previous = getValue("hideNotBuddyCreatedGames"); return previous == QVariant() ? false : previous.toBool(); } void GameFiltersSettings::setHideOpenDecklistGames(bool hide) { - setValue(hide, "hide_open_decklist_games"); + setValue(hide, "hideOpenDecklistGames"); } bool GameFiltersSettings::isHideOpenDecklistGames() const { - QVariant previous = getValue("hide_open_decklist_games"); + QVariant previous = getValue("hideOpenDecklistGames"); return previous == QVariant() ? false : previous.toBool(); } void GameFiltersSettings::setGameNameFilter(QString gameName) { - setValue(gameName, "game_name_filter"); + setValue(gameName, "gameNameFilter"); } QString GameFiltersSettings::getGameNameFilter() const { - return getValue("game_name_filter").toString(); + return getValue("gameNameFilter").toString(); } -void GameFiltersSettings::setCreatorNameFilters(QStringList creatorName) +void GameFiltersSettings::setHostNameFilters(QStringList hostName) { - setValue(creatorName, "creator_name_filter"); + setValue(hostName, "hostNameFilter"); } -QStringList GameFiltersSettings::getCreatorNameFilters() const +QStringList GameFiltersSettings::getHostNameFilters() const { - return getValue("creator_name_filter").toStringList(); + return getValue("hostNameFilter").toStringList(); } void GameFiltersSettings::setMinPlayers(int min) { - setValue(min, "min_players"); + setValue(min, "minPlayers"); } int GameFiltersSettings::getMinPlayers() const { - QVariant previous = getValue("min_players"); + QVariant previous = getValue("minPlayers"); return previous == QVariant() ? 1 : previous.toInt(); } void GameFiltersSettings::setMaxPlayers(int max) { - setValue(max, "max_players"); + setValue(max, "maxPlayers"); } int GameFiltersSettings::getMaxPlayers() const { - QVariant previous = getValue("max_players"); + QVariant previous = getValue("maxPlayers"); return previous == QVariant() ? 99 : previous.toInt(); } void GameFiltersSettings::setMaxGameAge(const QTime &maxGameAge) { - setValue(maxGameAge, "max_game_age_time"); + setValue(maxGameAge, "maxGameAgeTime"); } QTime GameFiltersSettings::getMaxGameAge() const { - QVariant previous = getValue("max_game_age_time"); + QVariant previous = getValue("maxGameAgeTime"); return previous.toTime(); } void GameFiltersSettings::setGameTypeEnabled(QString gametype, bool enabled) { - setValue(enabled, "game_type/" + hashGameType(gametype)); + setValue(enabled, "gameType/" + hashGameType(gametype)); } void GameFiltersSettings::setGameHashedTypeEnabled(QString gametypeHASHED, bool enabled) @@ -159,50 +159,50 @@ void GameFiltersSettings::setGameHashedTypeEnabled(QString gametypeHASHED, bool bool GameFiltersSettings::isGameTypeEnabled(QString gametype) const { - QVariant previous = getValue("game_type/" + hashGameType(gametype)); + QVariant previous = getValue("gameType/" + hashGameType(gametype)); return previous == QVariant() ? false : previous.toBool(); } void GameFiltersSettings::setShowOnlyIfSpectatorsCanWatch(bool show) { - setValue(show, "show_only_if_spectators_can_watch"); + setValue(show, "showOnlyIfSpectatorsCanWatch"); } bool GameFiltersSettings::isShowOnlyIfSpectatorsCanWatch() const { - QVariant previous = getValue("show_only_if_spectators_can_watch"); + QVariant previous = getValue("showOnlyIfSpectatorsCanWatch"); return previous == QVariant() ? false : previous.toBool(); } void GameFiltersSettings::setShowSpectatorPasswordProtected(bool show) { - setValue(show, "show_spectator_password_protected"); + setValue(show, "showSpectatorPasswordProtected"); } bool GameFiltersSettings::isShowSpectatorPasswordProtected() const { - QVariant previous = getValue("show_spectator_password_protected"); + QVariant previous = getValue("showSpectatorPasswordProtected"); return previous == QVariant() ? false : previous.toBool(); } void GameFiltersSettings::setShowOnlyIfSpectatorsCanChat(bool show) { - setValue(show, "show_only_if_spectators_can_chat"); + setValue(show, "showOnlyIfSpectatorsCanChat"); } bool GameFiltersSettings::isShowOnlyIfSpectatorsCanChat() const { - QVariant previous = getValue("show_only_if_spectators_can_chat"); + QVariant previous = getValue("showOnlyIfSpectatorsCanChat"); return previous == QVariant() ? false : previous.toBool(); } void GameFiltersSettings::setShowOnlyIfSpectatorsCanSeeHands(bool show) { - setValue(show, "show_only_if_spectators_can_see_hands"); + setValue(show, "showOnlyIfSpectatorsCanSeeHands"); } bool GameFiltersSettings::isShowOnlyIfSpectatorsCanSeeHands() const { - QVariant previous = getValue("show_only_if_spectators_can_see_hands"); + QVariant previous = getValue("showOnlyIfSpectatorsCanSeeHands"); return previous == QVariant() ? false : previous.toBool(); } \ No newline at end of file diff --git a/libcockatrice_settings/libcockatrice/settings/game_filters_settings.h b/libcockatrice_settings/libcockatrice/settings/game_filters_settings.h index 24f582007..11480d483 100644 --- a/libcockatrice_settings/libcockatrice/settings/game_filters_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/game_filters_settings.h @@ -24,7 +24,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 +42,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 new file mode 100644 index 000000000..dfd67c98a --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/game_settings.cpp @@ -0,0 +1,166 @@ +#include "game_settings.h" + +GameSettings::GameSettings(const QString &settingPath, QObject *parent) + : SettingsManager(settingPath + "game.ini", QString(), QString(), parent) +{ +} + +QString GameSettings::getGameDescription() const +{ + return getValue("gameDescription", "game").toString(); +} + +int GameSettings::getMaxPlayers() const +{ + return getValue("maxPlayers", "game", QString(), 2).toInt(); +} + +QString GameSettings::getGameTypes() const +{ + return getValue("gameTypes", "game").toString(); +} + +bool GameSettings::getOnlyBuddies() const +{ + return getValue("onlyBuddies", "game").toBool(); +} + +bool GameSettings::getOnlyRegistered() const +{ + return getValue("onlyRegistered", "game").toBool(); +} + +bool GameSettings::getSpectatorsAllowed() const +{ + return getValue("spectatorsAllowed", "game").toBool(); +} + +bool GameSettings::getSpectatorsNeedPassword() const +{ + return getValue("spectatorsNeedPassword", "game").toBool(); +} + +bool GameSettings::getSpectatorsCanTalk() const +{ + return getValue("spectatorsCanTalk", "game").toBool(); +} + +bool GameSettings::getSpectatorsCanSeeEverything() const +{ + return getValue("spectatorsCanSeeEverything", "game").toBool(); +} + +bool GameSettings::getCreateGameAsSpectator() const +{ + return getValue("createGameAsSpectator", "game").toBool(); +} + +int GameSettings::getDefaultStartingLifeTotal() const +{ + return getValue("defaultStartingLifeTotal", "game", QString(), 20).toInt(); +} + +bool GameSettings::getShareDecklistsOnLoad() const +{ + return getValue("shareDecklistsOnLoad", "game").toBool(); +} + +bool GameSettings::getRememberGameSettings() const +{ + return getValue("rememberGameSettings", "game", QString(), true).toBool(); +} + +bool GameSettings::getLocalGameRememberSettings() const +{ + return getValue("rememberSettings", "localgameoptions").toBool(); +} + +int GameSettings::getLocalGameMaxPlayers() const +{ + return getValue("maxPlayers", "localgameoptions", QString(), 1).toInt(); +} + +int GameSettings::getLocalGameStartingLifeTotal() const +{ + return getValue("startingLifeTotal", "localgameoptions", QString(), 20).toInt(); +} + +void GameSettings::setGameDescription(const QString &_gameDescription) +{ + setValue(_gameDescription, "gameDescription", "game"); +} + +void GameSettings::setMaxPlayers(int _maxPlayers) +{ + setValue(_maxPlayers, "maxPlayers", "game"); +} + +void GameSettings::setGameTypes(const QString &_gameTypes) +{ + setValue(_gameTypes, "gameTypes", "game"); +} + +void GameSettings::setOnlyBuddies(bool _onlyBuddies) +{ + setValue(_onlyBuddies, "onlyBuddies", "game"); +} + +void GameSettings::setOnlyRegistered(bool _onlyRegistered) +{ + setValue(_onlyRegistered, "onlyRegistered", "game"); +} + +void GameSettings::setSpectatorsAllowed(bool _spectatorsAllowed) +{ + setValue(_spectatorsAllowed, "spectatorsAllowed", "game"); +} + +void GameSettings::setSpectatorsNeedPassword(bool _spectatorsNeedPassword) +{ + setValue(_spectatorsNeedPassword, "spectatorsNeedPassword", "game"); +} + +void GameSettings::setSpectatorsCanTalk(bool _spectatorsCanTalk) +{ + setValue(_spectatorsCanTalk, "spectatorsCanTalk", "game"); +} + +void GameSettings::setSpectatorsCanSeeEverything(bool _spectatorsCanSeeEverything) +{ + setValue(_spectatorsCanSeeEverything, "spectatorsCanSeeEverything", "game"); +} + +void GameSettings::setCreateGameAsSpectator(bool _createGameAsSpectator) +{ + setValue(_createGameAsSpectator, "createGameAsSpectator", "game"); +} + +void GameSettings::setDefaultStartingLifeTotal(int _defaultStartingLifeTotal) +{ + setValue(_defaultStartingLifeTotal, "defaultStartingLifeTotal", "game"); +} + +void GameSettings::setShareDecklistsOnLoad(bool _shareDecklistsOnLoad) +{ + setValue(_shareDecklistsOnLoad, "shareDecklistsOnLoad", "game"); +} + +void GameSettings::setRememberGameSettings(bool _rememberGameSettings) +{ + setValue(_rememberGameSettings, "rememberGameSettings", "game"); +} + +void GameSettings::setLocalGameRememberSettings(bool value) +{ + setValue(value, "rememberSettings", "localgameoptions"); +} + +void GameSettings::setLocalGameMaxPlayers(int value) +{ + setValue(value, "maxPlayers", "localgameoptions"); +} + +void GameSettings::setLocalGameStartingLifeTotal(int value) +{ + setValue(value, "startingLifeTotal", "localgameoptions"); +} diff --git a/libcockatrice_settings/libcockatrice/settings/game_settings.h b/libcockatrice_settings/libcockatrice/settings/game_settings.h new file mode 100644 index 000000000..e0a484bac --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/game_settings.h @@ -0,0 +1,55 @@ +#ifndef GAME_SETTINGS_H +#define GAME_SETTINGS_H + +#include "settings_manager.h" + +#include + +class GameSettings : public SettingsManager, public IGameSettingsProvider +{ + Q_OBJECT + friend class SettingsCache; + +public: + [[nodiscard]] QString getGameDescription() const override; + [[nodiscard]] int getMaxPlayers() const override; + [[nodiscard]] QString getGameTypes() const override; + [[nodiscard]] bool getOnlyBuddies() const override; + [[nodiscard]] bool getOnlyRegistered() const override; + [[nodiscard]] bool getSpectatorsAllowed() const override; + [[nodiscard]] bool getSpectatorsNeedPassword() const override; + [[nodiscard]] bool getSpectatorsCanTalk() const override; + [[nodiscard]] bool getSpectatorsCanSeeEverything() const override; + [[nodiscard]] bool getCreateGameAsSpectator() const override; + [[nodiscard]] int getDefaultStartingLifeTotal() const override; + [[nodiscard]] bool getShareDecklistsOnLoad() const override; + [[nodiscard]] bool getRememberGameSettings() const override; + [[nodiscard]] bool getLocalGameRememberSettings() const override; + [[nodiscard]] int getLocalGameMaxPlayers() const override; + [[nodiscard]] int getLocalGameStartingLifeTotal() const override; + + void setGameDescription(const QString &_gameDescription); + void setMaxPlayers(int _maxPlayers); + void setGameTypes(const QString &_gameTypes); + void setOnlyBuddies(bool _onlyBuddies); + void setOnlyRegistered(bool _onlyRegistered); + void setSpectatorsAllowed(bool _spectatorsAllowed); + void setSpectatorsNeedPassword(bool _spectatorsNeedPassword); + void setSpectatorsCanTalk(bool _spectatorsCanTalk); + void setSpectatorsCanSeeEverything(bool _spectatorsCanSeeEverything); + void setCreateGameAsSpectator(bool _createGameAsSpectator); + void setDefaultStartingLifeTotal(int _defaultStartingLifeTotal); + void setShareDecklistsOnLoad(bool _shareDecklistsOnLoad); + void setRememberGameSettings(bool _rememberGameSettings); + void setLocalGameRememberSettings(bool value); + void setLocalGameMaxPlayers(int value); + void setLocalGameStartingLifeTotal(int value); + +public: + explicit GameSettings(const QString &settingPath, QObject *parent = nullptr); + +private: + GameSettings(const GameSettings & /*other*/); +}; + +#endif // GAME_SETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp b/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp new file mode 100644 index 000000000..b92a5fdec --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp @@ -0,0 +1,460 @@ +#include "interface_settings.h" + +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) +{ +} + +bool InterfaceSettings::getUseTearOffMenus() const +{ + return getValue("useTearOffMenus", QString(), QString(), true).toBool(); +} + +int InterfaceSettings::getCardViewInitialRowsMax() const +{ + return getValue("cardViewInitialRowsMax", QString(), QString(), 14).toInt(); +} + +int InterfaceSettings::getCardViewExpandedRowsMax() const +{ + return getValue("cardViewExpandedRowsMax", QString(), QString(), 20).toInt(); +} + +bool InterfaceSettings::getCloseEmptyCardView() const +{ + return getValue("closeEmptyCardView", QString(), QString(), true).toBool(); +} + +bool InterfaceSettings::getFocusCardViewSearchBar() const +{ + return getValue("focusCardViewSearchBar", QString(), QString(), true).toBool(); +} + +bool InterfaceSettings::getKeepGameChatFocus() const +{ + return getValue("keepGameChatFocus", QString(), QString(), false).toBool(); +} + +bool InterfaceSettings::getNotificationsEnabled() const +{ + return getValue("enabled", "interface", "notifications", true).toBool(); +} + +bool InterfaceSettings::getSpectatorNotificationsEnabled() const +{ + return getValue("spectatorsEnabled", "interface", "notifications", false).toBool(); +} + +bool InterfaceSettings::getBuddyConnectNotificationsEnabled() const +{ + return getValue("buddyConnectEnabled", "interface", "notifications", true).toBool(); +} + +bool InterfaceSettings::getDoubleClickToPlay() const +{ + return getValue("doubleClickToPlay", QString(), QString(), true).toBool(); +} + +bool InterfaceSettings::getClickPlaysAllSelected() const +{ + return getValue("clickPlaysAllSelected", QString(), QString(), true).toBool(); +} + +bool InterfaceSettings::getPlayToStack() const +{ + return getValue("playToStack", QString(), QString(), true).toBool(); +} + +bool InterfaceSettings::getDoNotDeleteArrowsInSubPhases() const +{ + return getValue("doNotDeleteArrowsInSubPhases", QString(), QString(), true).toBool(); +} + +int InterfaceSettings::getStartingHandSize() const +{ + return getValue("startingHandSize", QString(), QString(), 7).toInt(); +} + +bool InterfaceSettings::getAnnotateTokens() const +{ + return getValue("annotateTokens", QString(), QString(), false).toBool(); +} + +bool InterfaceSettings::getShowDragSelectionCount() const +{ + return getValue("showLassoSelectionCount", QString(), QString(), true).toBool(); +} + +bool InterfaceSettings::getShowTotalSelectionCount() const +{ + return getValue("showPersistentSelectionCount", QString(), QString(), true).toBool(); +} + +int InterfaceSettings::getTallyType() const +{ + return getValue("tallyType", QString(), QString(), 0).toInt(); +} + +bool InterfaceSettings::getHorizontalHand() const +{ + return getValue("horizontal", "hand", QString(), true).toBool(); +} + +bool InterfaceSettings::getInvertVerticalCoordinate() const +{ + return getValue("invertVertical", "table", QString(), false).toBool(); +} + +int InterfaceSettings::getMinPlayersForMultiColumnLayout() const +{ + return getValue("minPlayersMulticolumn", QString(), QString(), 4).toInt(); +} + +int InterfaceSettings::getRewindBufferingMs() const +{ + return getValue("rewindBufferingMs", "replay", QString(), 200).toInt(); +} + +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(); +} + +int InterfaceSettings::getZoneViewGroupByIndex() const +{ + return getValue("groupBy", "zoneview", QString(), 1).toInt(); +} + +int InterfaceSettings::getZoneViewSortByIndex() const +{ + return getValue("sortBy", "zoneview", QString(), 1).toInt(); +} + +bool InterfaceSettings::getZoneViewPileView() const +{ + return getValue("pileView", "zoneview", QString(), true).toBool(); +} + +bool InterfaceSettings::getShowStatusBar() const +{ + return getValue("showStatusBar", QString(), QString(), false).toBool(); +} + +bool InterfaceSettings::getShowShortcuts() const +{ + return getValue("showShortcuts", QString(), QString(), true).toBool(); +} + +bool InterfaceSettings::getShowGameSelectorFilterToolbar() const +{ + return getValue("showGameSelectorFilterToolbar", QString(), QString(), true).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"); + emit useTearOffMenusChanged(_useTearOffMenus); +} + +void InterfaceSettings::setCardViewInitialRowsMax(int _cardViewInitialRowsMax) +{ + setValue(_cardViewInitialRowsMax, "cardViewInitialRowsMax"); +} + +void InterfaceSettings::setCardViewExpandedRowsMax(int value) +{ + setValue(value, "cardViewExpandedRowsMax"); +} + +void InterfaceSettings::setCloseEmptyCardView(bool value) +{ + setValue(value, "closeEmptyCardView"); +} + +void InterfaceSettings::setFocusCardViewSearchBar(bool value) +{ + setValue(value, "focusCardViewSearchBar"); +} + +void InterfaceSettings::setKeepGameChatFocus(bool value) +{ + setValue(value, "keepGameChatFocus"); + emit keepGameChatFocusChanged(value); +} + +void InterfaceSettings::setNotificationsEnabled(bool _notificationsEnabled) +{ + setValue(_notificationsEnabled, "enabled", "interface", "notifications"); +} + +void InterfaceSettings::setSpectatorNotificationsEnabled(bool _spectatorNotificationsEnabled) +{ + setValue(_spectatorNotificationsEnabled, "spectatorsEnabled", "interface", "notifications"); +} + +void InterfaceSettings::setBuddyConnectNotificationsEnabled(bool _buddyConnectNotificationsEnabled) +{ + setValue(_buddyConnectNotificationsEnabled, "buddyConnectEnabled", "interface", "notifications"); +} + +void InterfaceSettings::setDoubleClickToPlay(bool _doubleClickToPlay) +{ + setValue(_doubleClickToPlay, "doubleClickToPlay"); +} + +void InterfaceSettings::setClickPlaysAllSelected(bool _clickPlaysAllSelected) +{ + setValue(_clickPlaysAllSelected, "clickPlaysAllSelected"); +} + +void InterfaceSettings::setPlayToStack(bool _playToStack) +{ + setValue(_playToStack, "playToStack"); +} + +void InterfaceSettings::setDoNotDeleteArrowsInSubPhases(bool _doNotDeleteArrowsInSubPhases) +{ + setValue(_doNotDeleteArrowsInSubPhases, "doNotDeleteArrowsInSubPhases"); +} + +void InterfaceSettings::setStartingHandSize(int _startingHandSize) +{ + setValue(_startingHandSize, "startingHandSize"); +} + +void InterfaceSettings::setAnnotateTokens(bool _annotateTokens) +{ + setValue(_annotateTokens, "annotateTokens"); +} + +void InterfaceSettings::setShowDragSelectionCount(bool _showDragSelectionCount) +{ + setValue(_showDragSelectionCount, "showLassoSelectionCount"); +} + +void InterfaceSettings::setShowTotalSelectionCount(bool _showTotalSelectionCount) +{ + setValue(_showTotalSelectionCount, "showPersistentSelectionCount"); +} + +void InterfaceSettings::setTallyType(int value) +{ + if (getTallyType() == value) { + return; + } + setValue(value, "tallyType"); + emit tallyTypeChanged(value); +} + +void InterfaceSettings::setHorizontalHand(bool _horizontalHand) +{ + setValue(_horizontalHand, "horizontal", "hand"); + emit horizontalHandChanged(); +} + +void InterfaceSettings::setInvertVerticalCoordinate(bool _invertVerticalCoordinate) +{ + setValue(_invertVerticalCoordinate, "invertVertical", "table"); + emit invertVerticalCoordinateChanged(); +} + +void InterfaceSettings::setMinPlayersForMultiColumnLayout(int _minPlayersForMultiColumnLayout) +{ + setValue(_minPlayersForMultiColumnLayout, "minPlayersMulticolumn"); + emit minPlayersForMultiColumnLayoutChanged(); +} + +void InterfaceSettings::setRewindBufferingMs(int _rewindBufferingMs) +{ + setValue(_rewindBufferingMs, "rewindBufferingMs", "replay"); +} + +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"); + emit handJustificationChanged(); +} + +void InterfaceSettings::setZoneViewGroupByIndex(int _zoneViewGroupByIndex) +{ + setValue(_zoneViewGroupByIndex, "groupBy", "zoneview"); +} + +void InterfaceSettings::setZoneViewSortByIndex(int _zoneViewSortByIndex) +{ + setValue(_zoneViewSortByIndex, "sortBy", "zoneview"); +} + +void InterfaceSettings::setZoneViewPileView(bool _zoneViewPileView) +{ + setValue(_zoneViewPileView, "pileView", "zoneview"); +} + +void InterfaceSettings::setShowStatusBar(bool _showStatusBar) +{ + setValue(_showStatusBar, "showStatusBar"); + emit showStatusBarChanged(_showStatusBar); +} + +void InterfaceSettings::setShowShortcuts(bool _showShortcuts) +{ + setValue(_showShortcuts, "showShortcuts"); +} + +void InterfaceSettings::setShowGameSelectorFilterToolbar(bool _showGameSelectorFilterToolbar) +{ + setValue(_showGameSelectorFilterToolbar, "showGameSelectorFilterToolbar"); + emit showGameSelectorFilterToolbarChanged(_showGameSelectorFilterToolbar); +} + +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 new file mode 100644 index 000000000..6b226f307 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/interface_settings.h @@ -0,0 +1,114 @@ +#ifndef INTERFACE_SETTINGS_H +#define INTERFACE_SETTINGS_H + +#include "settings_manager.h" + +#include + +class InterfaceSettings : public SettingsManager, public IInterfaceSettingsProvider +{ + Q_OBJECT + friend class SettingsCache; + +public: + [[nodiscard]] bool getUseTearOffMenus() const override; + [[nodiscard]] int getCardViewInitialRowsMax() const override; + [[nodiscard]] int getCardViewExpandedRowsMax() const override; + [[nodiscard]] bool getCloseEmptyCardView() const override; + [[nodiscard]] bool getFocusCardViewSearchBar() const override; + [[nodiscard]] bool getKeepGameChatFocus() const override; + [[nodiscard]] bool getNotificationsEnabled() const override; + [[nodiscard]] bool getSpectatorNotificationsEnabled() const override; + [[nodiscard]] bool getBuddyConnectNotificationsEnabled() const override; + [[nodiscard]] bool getDoubleClickToPlay() const override; + [[nodiscard]] bool getClickPlaysAllSelected() const override; + [[nodiscard]] bool getPlayToStack() const override; + [[nodiscard]] bool getDoNotDeleteArrowsInSubPhases() const override; + [[nodiscard]] int getStartingHandSize() const override; + [[nodiscard]] bool getAnnotateTokens() const override; + [[nodiscard]] bool getShowDragSelectionCount() const override; + [[nodiscard]] bool getShowTotalSelectionCount() const override; + [[nodiscard]] int getTallyType() const override; + [[nodiscard]] bool getHorizontalHand() const override; + [[nodiscard]] bool getInvertVerticalCoordinate() const override; + [[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; + [[nodiscard]] bool getZoneViewPileView() const override; + [[nodiscard]] bool getShowStatusBar() const override; + [[nodiscard]] bool getShowShortcuts() const override; + [[nodiscard]] bool getShowGameSelectorFilterToolbar() const override; + [[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); + void setCardViewExpandedRowsMax(int value); + void setCloseEmptyCardView(bool value); + void setFocusCardViewSearchBar(bool value); + void setKeepGameChatFocus(bool value); + void setNotificationsEnabled(bool _notificationsEnabled); + void setSpectatorNotificationsEnabled(bool _spectatorNotificationsEnabled); + void setBuddyConnectNotificationsEnabled(bool _buddyConnectNotificationsEnabled); + void setDoubleClickToPlay(bool _doubleClickToPlay); + void setClickPlaysAllSelected(bool _clickPlaysAllSelected); + void setPlayToStack(bool _playToStack); + void setDoNotDeleteArrowsInSubPhases(bool _doNotDeleteArrowsInSubPhases); + void setStartingHandSize(int _startingHandSize); + void setAnnotateTokens(bool _annotateTokens); + void setShowDragSelectionCount(bool _showDragSelectionCount); + void setShowTotalSelectionCount(bool _showTotalSelectionCount); + void setTallyType(int value); + void setHorizontalHand(bool _horizontalHand); + void setInvertVerticalCoordinate(bool _invertVerticalCoordinate); + 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); + void setZoneViewPileView(bool _zoneViewPileView); + void setShowStatusBar(bool _showStatusBar); + void setShowShortcuts(bool _showShortcuts); + void setShowGameSelectorFilterToolbar(bool _showGameSelectorFilterToolbar); + 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); + void keepGameChatFocusChanged(bool value); + void horizontalHandChanged(); + void invertVerticalCoordinateChanged(); + void minPlayersForMultiColumnLayoutChanged(); + void handJustificationChanged(); + 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); + +private: + InterfaceSettings(const InterfaceSettings & /*other*/); +}; + +#endif // INTERFACE_SETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/network_settings.cpp b/libcockatrice_settings/libcockatrice/settings/network_settings.cpp new file mode 100644 index 000000000..2d34d47ad --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/network_settings.cpp @@ -0,0 +1,56 @@ +#include "network_settings.h" + +NetworkSettings::NetworkSettings(const QString &settingPath, QObject *parent) + : SettingsManager(settingPath + "network.ini", "network", QString(), parent) +{ +} + +QString NetworkSettings::getClientID() const +{ + return getValue("clientId", QString(), QString(), "notset").toString(); +} + +void NetworkSettings::setClientID(const QString &_clientID) +{ + setValue(_clientID, "clientId"); +} + +QString NetworkSettings::getClientVersion() const +{ + return getValue("clientVersion", QString(), QString(), "notset").toString(); +} + +void NetworkSettings::setClientVersion(const QString &_clientVersion) +{ + setValue(_clientVersion, "clientVersion"); +} + +int NetworkSettings::getKeepAlive() const +{ + return getValue("keepAlive", QString(), QString(), 3).toInt(); +} + +void NetworkSettings::setKeepAlive(int _keepAlive) +{ + setValue(_keepAlive, "keepAlive"); +} + +int NetworkSettings::getTimeOut() const +{ + return getValue("timeout", QString(), QString(), 5).toInt(); +} + +void NetworkSettings::setTimeOut(int _timeOut) +{ + setValue(_timeOut, "timeout"); +} + +QString NetworkSettings::getKnownMissingFeatures() const +{ + return getValue("knownMissingFeatures", QString(), QString(), "").toString(); +} + +void NetworkSettings::setKnownMissingFeatures(const QString &_knownMissingFeatures) +{ + setValue(_knownMissingFeatures, "knownMissingFeatures"); +} diff --git a/libcockatrice_settings/libcockatrice/settings/network_settings.h b/libcockatrice_settings/libcockatrice/settings/network_settings.h new file mode 100644 index 000000000..446bb51d3 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/network_settings.h @@ -0,0 +1,36 @@ +/** + * @file network_settings.h + * @ingroup NetworkSettings + */ +//! \todo Document this file. + +#ifndef NETWORK_SETTINGS_H +#define NETWORK_SETTINGS_H + +#include "settings_manager.h" + +class NetworkSettings : public SettingsManager +{ + Q_OBJECT + friend class SettingsCache; + +public: + [[nodiscard]] QString getClientID() const; + void setClientID(const QString &_clientID); + [[nodiscard]] QString getClientVersion() const; + void setClientVersion(const QString &_clientVersion); + [[nodiscard]] int getKeepAlive() const; + void setKeepAlive(int _keepAlive); + [[nodiscard]] int getTimeOut() const; + void setTimeOut(int _timeOut); + [[nodiscard]] QString getKnownMissingFeatures() const; + void setKnownMissingFeatures(const QString &_knownMissingFeatures); + +public: + explicit NetworkSettings(const QString &settingPath, QObject *parent = nullptr); + +private: + NetworkSettings(const NetworkSettings & /*other*/); +}; + +#endif // NETWORK_SETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/paths_settings.cpp b/libcockatrice_settings/libcockatrice/settings/paths_settings.cpp new file mode 100644 index 000000000..67032e38f --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/paths_settings.cpp @@ -0,0 +1,120 @@ +#include "paths_settings.h" + +#include +#include + +PathsSettings::PathsSettings(const QString &settingPath, QObject *parent) + : SettingsManager(settingPath + "paths.ini", "paths", QString(), parent) +{ +} + +QString PathsSettings::getDeckPath() const +{ + return getValue("decks").toString(); +} + +QString PathsSettings::getFiltersPath() const +{ + return getValue("filters").toString(); +} + +QString PathsSettings::getReplaysPath() const +{ + return getValue("replays").toString(); +} + +QString PathsSettings::getPicsPath() const +{ + return getValue("pics").toString(); +} + +QString PathsSettings::getCustomPicsPath() const +{ + return getValue("customPics").toString(); +} + +QString PathsSettings::getThemesPath() const +{ + return getValue("themes").toString(); +} + +QString PathsSettings::getCardDatabasePath() const +{ + return getValue("cardDatabase").toString(); +} + +QString PathsSettings::getCustomCardDatabasePath() const +{ + return getValue("customSets").toString(); +} + +QString PathsSettings::getTokenDatabasePath() const +{ + return getValue("tokenDatabase").toString(); +} + +QString PathsSettings::getSpoilerCardDatabasePath() const +{ + return getValue("spoilerDatabase").toString(); +} + +QString PathsSettings::getRedirectCachePath() const +{ + return getValue("redirects").toString(); +} + +void PathsSettings::setDeckPath(const QString &_deckPath) +{ + setValue(_deckPath, "decks"); +} + +void PathsSettings::setFiltersPath(const QString &_filtersPath) +{ + setValue(_filtersPath, "filters"); +} + +void PathsSettings::setReplaysPath(const QString &_replaysPath) +{ + setValue(_replaysPath, "replays"); +} + +void PathsSettings::setPicsPath(const QString &_picsPath) +{ + setValue(_picsPath, "pics"); + emit picsPathChanged(); +} + +void PathsSettings::setCustomPicsPath(const QString &_customPicsPath) +{ + setValue(_customPicsPath, "customPics"); +} + +void PathsSettings::setThemesPath(const QString &_themesPath) +{ + setValue(_themesPath, "themes"); + emit themeChanged(); +} + +void PathsSettings::setCardDatabasePath(const QString &_cardDatabasePath) +{ + setValue(_cardDatabasePath, "cardDatabase"); + emit cardDatabasePathChanged(); +} + +void PathsSettings::setCustomCardDatabasePath(const QString &_customCardDatabasePath) +{ + setValue(_customCardDatabasePath, "customSets"); + emit cardDatabasePathChanged(); +} + +void PathsSettings::setTokenDatabasePath(const QString &_tokenDatabasePath) +{ + setValue(_tokenDatabasePath, "tokenDatabase"); + emit cardDatabasePathChanged(); +} + +void PathsSettings::setSpoilerDatabasePath(const QString &_spoilerDatabasePath) +{ + setValue(_spoilerDatabasePath, "spoilerDatabase"); + emit cardDatabasePathChanged(); +} diff --git a/libcockatrice_settings/libcockatrice/settings/paths_settings.h b/libcockatrice_settings/libcockatrice/settings/paths_settings.h new file mode 100644 index 000000000..ab1ea05c8 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/paths_settings.h @@ -0,0 +1,47 @@ +#ifndef PATHS_SETTINGS_H +#define PATHS_SETTINGS_H + +#include "settings_manager.h" + +#include + +class PathsSettings : public SettingsManager, public IPathsSettingsProvider +{ + Q_OBJECT + friend class SettingsCache; + +public: + [[nodiscard]] QString getDeckPath() const override; + [[nodiscard]] QString getFiltersPath() const override; + [[nodiscard]] QString getReplaysPath() const override; + [[nodiscard]] QString getPicsPath() const override; + [[nodiscard]] QString getCustomPicsPath() const override; + [[nodiscard]] QString getThemesPath() const override; + [[nodiscard]] QString getCardDatabasePath() const override; + [[nodiscard]] QString getCustomCardDatabasePath() const override; + [[nodiscard]] QString getTokenDatabasePath() const override; + [[nodiscard]] QString getSpoilerCardDatabasePath() const override; + [[nodiscard]] QString getRedirectCachePath() const override; + + void setDeckPath(const QString &_deckPath); + void setFiltersPath(const QString &_filtersPath); + void setReplaysPath(const QString &_replaysPath); + void setPicsPath(const QString &_picsPath); + void setCustomPicsPath(const QString &_customPicsPath); + void setThemesPath(const QString &_themesPath); + void setCardDatabasePath(const QString &_cardDatabasePath); + void setCustomCardDatabasePath(const QString &_customCardDatabasePath); + void setTokenDatabasePath(const QString &_tokenDatabasePath); + void setSpoilerDatabasePath(const QString &_spoilerDatabasePath); + +signals: + void cardDatabasePathChanged(); + void picsPathChanged(); + void themeChanged(); + +private: + explicit PathsSettings(const QString &settingPath, QObject *parent = nullptr); + PathsSettings(const PathsSettings & /*other*/); +}; + +#endif // PATHS_SETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/personal_settings.cpp b/libcockatrice_settings/libcockatrice/settings/personal_settings.cpp new file mode 100644 index 000000000..d1cb74d09 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/personal_settings.cpp @@ -0,0 +1,46 @@ +#include "personal_settings.h" + +PersonalSettings::PersonalSettings(const QString &settingPath, QObject *parent) + : SettingsManager(settingPath + "personal.ini", "personal", QString(), parent) +{ +} + +QString PersonalSettings::getLang() const +{ + return getValue("lang", QString(), QString(), QString()).toString(); +} + +bool PersonalSettings::getShowTipsOnStartup() const +{ + return getValue("showTips", "tipOfDay", QString(), true).toBool(); +} + +QList PersonalSettings::getSeenTips() const +{ + QList tips; + auto tipValues = getValue("seenTips", "tipOfDay", QString()).toList(); + for (const auto &tipNumber : tipValues) { + tips.append(tipNumber.toInt()); + } + return tips; +} + +void PersonalSettings::setLang(const QString &_lang) +{ + setValue(_lang, "lang"); + emit langChanged(); +} + +void PersonalSettings::setShowTipsOnStartup(bool _showTipsOnStartup) +{ + setValue(_showTipsOnStartup, "showTips", "tipOfDay"); +} + +void PersonalSettings::setSeenTips(const QList &_seenTips) +{ + QList storedTipList; + for (auto tipNumber : _seenTips) { + storedTipList.append(tipNumber); + } + setValue(QVariant::fromValue(storedTipList), "seenTips", "tipOfDay"); +} diff --git a/libcockatrice_settings/libcockatrice/settings/personal_settings.h b/libcockatrice_settings/libcockatrice/settings/personal_settings.h new file mode 100644 index 000000000..04eab2a8a --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/personal_settings.h @@ -0,0 +1,34 @@ +#ifndef PERSONAL_SETTINGS_H +#define PERSONAL_SETTINGS_H + +#include "settings_manager.h" + +#include +#include +#include + +class PersonalSettings : public SettingsManager, public IPersonalSettingsProvider +{ + Q_OBJECT + friend class SettingsCache; + +public: + [[nodiscard]] QString getLang() const override; + [[nodiscard]] bool getShowTipsOnStartup() const override; + [[nodiscard]] QList getSeenTips() const override; + + void setLang(const QString &_lang); + void setShowTipsOnStartup(bool _showTipsOnStartup); + void setSeenTips(const QList &_seenTips); + +signals: + void langChanged(); + +public: + explicit PersonalSettings(const QString &settingPath, QObject *parent = nullptr); + +private: + PersonalSettings(const PersonalSettings & /*other*/); +}; + +#endif // PERSONAL_SETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/recents_settings.cpp b/libcockatrice_settings/libcockatrice/settings/recents_settings.cpp index 76bc4069e..5f16af179 100644 --- a/libcockatrice_settings/libcockatrice/settings/recents_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/recents_settings.cpp @@ -9,16 +9,16 @@ RecentsSettings::RecentsSettings(const QString &settingPath, QObject *parent) QStringList RecentsSettings::getRecentlyOpenedDeckPaths() const { - return getValue("deckpaths").toStringList(); + return getValue("deckPaths").toStringList(); } void RecentsSettings::clearRecentlyOpenedDeckPaths() { - deleteValue("deckpaths"); + deleteValue("deckPaths"); emit recentlyOpenedDeckPathsChanged(); } void RecentsSettings::updateRecentlyOpenedDeckPaths(const QString &deckPath) { - auto deckPaths = getValue("deckpaths").toStringList(); + auto deckPaths = getValue("deckPaths").toStringList(); deckPaths.removeAll(deckPath); deckPaths.prepend(deckPath); @@ -27,7 +27,7 @@ void RecentsSettings::updateRecentlyOpenedDeckPaths(const QString &deckPath) deckPaths.removeLast(); } - setValue(deckPaths, "deckpaths"); + setValue(deckPaths, "deckPaths"); emit recentlyOpenedDeckPathsChanged(); } diff --git a/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp b/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp index d9b98e036..811b0c842 100644 --- a/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/servers_settings.cpp @@ -10,28 +10,28 @@ ServersSettings::ServersSettings(const QString &settingPath, QObject *parent) void ServersSettings::setPreviousHostLogin(int previous) { - setValue(previous, "previoushostlogin"); + setValue(previous, "previousHostLogin"); } int ServersSettings::getPreviousHostLogin() const { - QVariant previous = getValue("previoushostlogin"); + QVariant previous = getValue("previousHostLogin"); return previous == QVariant() ? 1 : previous.toInt(); } void ServersSettings::setPreviousHostList(QStringList list) { - setValue(list, "previoushosts"); + setValue(list, "previousHosts"); } QStringList ServersSettings::getPreviousHostList() const { - return getValue("previoushosts").toStringList(); + return getValue("previousHosts").toStringList(); } void ServersSettings::setPrevioushostName(const QString &name) { - setValue(name, "previoushostName"); + setValue(name, "previousHostName"); } QString ServersSettings::getSaveName(QString defaultname) @@ -50,7 +50,7 @@ QString ServersSettings::getSite(QString defaultSite) QString ServersSettings::getPrevioushostName() const { - QVariant value = getValue("previoushostName"); + QVariant value = getValue("previousHostName"); return value == QVariant() ? "Rooster Ranges" : value.toString(); } @@ -110,56 +110,56 @@ bool ServersSettings::getSavePassword() const void ServersSettings::setAutoConnect(int autoconnect) { - setValue(autoconnect, "auto_connect"); + setValue(autoconnect, "autoConnect"); } int ServersSettings::getAutoConnect() const { - QVariant autoconnect = getValue("auto_connect"); + QVariant autoconnect = getValue("autoConnect"); return autoconnect == QVariant() ? 0 : autoconnect.toInt(); } void ServersSettings::setFPHostName(QString hostname) { - setValue(hostname, "fphostname"); + setValue(hostname, "fpHostName"); } QString ServersSettings::getFPHostname(QString defaultHost) const { - QVariant hostname = getValue("fphostname"); + QVariant hostname = getValue("fpHostName"); return hostname == QVariant() ? std::move(defaultHost) : hostname.toString(); } void ServersSettings::setFPPort(QString port) { - setValue(port, "fpport"); + setValue(port, "fpPort"); } QString ServersSettings::getFPPort(QString defaultPort) const { - QVariant port = getValue("fpport"); + QVariant port = getValue("fpPort"); return port == QVariant() ? std::move(defaultPort) : port.toString(); } void ServersSettings::setFPPlayerName(QString playerName) { - setValue(playerName, "fpplayername"); + setValue(playerName, "fpPlayerName"); } QString ServersSettings::getFPPlayerName(QString defaultName) const { - QVariant name = getValue("fpplayername"); + QVariant name = getValue("fpPlayerName"); return name == QVariant() ? std::move(defaultName) : name.toString(); } void ServersSettings::setClearDebugLogStatus(bool abIsChecked) { - setValue(abIsChecked, "save_debug_log"); + setValue(abIsChecked, "saveDebugLog"); } bool ServersSettings::getClearDebugLogStatus(bool abDefaultValue) const { - QVariant cbFlushLog = getValue("save_debug_log"); + QVariant cbFlushLog = getValue("saveDebugLog"); return cbFlushLog == QVariant() ? abDefaultValue : cbFlushLog.toBool(); } @@ -293,3 +293,48 @@ bool ServersSettings::updateExistingServer(QString saveName, } return false; } + +int ServersSettings::findServerIndex(const QString &host, const QString &port) 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(); + QString storedPort = getValue(QString("port%1").arg(i), "server", "server_details").toString(); + + if (storedHost == host && storedPort == port) { + return i; + } + } + + return -1; +} + +bool ServersSettings::hasUsername(const QString &host, const QString &port) const +{ + int index = findServerIndex(host, port); + if (index < 0) { + return false; + } + + QString user = getValue(QString("username%1").arg(index), "server", "server_details").toString(); + return !user.isEmpty(); +} + +bool ServersSettings::hasCredentials(const QString &host, const QString &port) const +{ + int index = findServerIndex(host, port); + if (index < 0) { + return false; + } + + bool save = getValue(QString("savePassword%1").arg(index), "server", "server_details").toBool(); + QString password = getValue(QString("password%1").arg(index), "server", "server_details").toString(); + + return save && !password.isEmpty(); +} + +bool ServersSettings::hasLoginData(const QString &host, const QString &port) const +{ + return hasUsername(host, port) && hasCredentials(host, port); +} diff --git a/libcockatrice_settings/libcockatrice/settings/servers_settings.h b/libcockatrice_settings/libcockatrice/settings/servers_settings.h index 40fa996fb..f9803a158 100644 --- a/libcockatrice_settings/libcockatrice/settings/servers_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/servers_settings.h @@ -61,6 +61,10 @@ public: QString password, bool savePassword, QString site = QString()); + int findServerIndex(const QString &host, const QString &port) 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; bool updateExistingServerWithoutLoss(QString saveName, QString serv = QString(), diff --git a/libcockatrice_settings/libcockatrice/settings/settings_manager.cpp b/libcockatrice_settings/libcockatrice/settings/settings_manager.cpp index b66edd630..f98778ca0 100644 --- a/libcockatrice_settings/libcockatrice/settings/settings_manager.cpp +++ b/libcockatrice_settings/libcockatrice/settings/settings_manager.cpp @@ -138,21 +138,55 @@ QVariant SettingsManager::getValue(const QString &name, const QString &group, co { auto settings = getSettings(); - if (!group.isEmpty()) { - settings.beginGroup(group); + QString effectiveGroup = group.isEmpty() ? defaultGroup : group; + QString effectiveSubGroup = subGroup.isEmpty() ? defaultSubGroup : subGroup; + + if (!effectiveGroup.isEmpty()) { + settings.beginGroup(effectiveGroup); } - if (!subGroup.isEmpty()) { - settings.beginGroup(subGroup); + if (!effectiveSubGroup.isEmpty()) { + settings.beginGroup(effectiveSubGroup); } QVariant value = settings.value(name); - if (!subGroup.isEmpty()) { + if (!effectiveSubGroup.isEmpty()) { settings.endGroup(); } - if (!group.isEmpty()) { + if (!effectiveGroup.isEmpty()) { + settings.endGroup(); + } + + return value; +} + +QVariant SettingsManager::getValue(const QString &name, + const QString &group, + const QString &subGroup, + const QVariant &defaultValue) const +{ + auto settings = getSettings(); + + QString effectiveGroup = group.isEmpty() ? defaultGroup : group; + QString effectiveSubGroup = subGroup.isEmpty() ? defaultSubGroup : subGroup; + + if (!effectiveGroup.isEmpty()) { + settings.beginGroup(effectiveGroup); + } + + if (!effectiveSubGroup.isEmpty()) { + settings.beginGroup(effectiveSubGroup); + } + + QVariant value = settings.value(name, defaultValue); + + if (!effectiveSubGroup.isEmpty()) { + settings.endGroup(); + } + + if (!effectiveGroup.isEmpty()) { settings.endGroup(); } diff --git a/libcockatrice_settings/libcockatrice/settings/settings_manager.h b/libcockatrice_settings/libcockatrice/settings/settings_manager.h index 4213cf4c1..3d3c95d3e 100644 --- a/libcockatrice_settings/libcockatrice/settings/settings_manager.h +++ b/libcockatrice_settings/libcockatrice/settings/settings_manager.h @@ -23,6 +23,8 @@ public: QVariant getValue(const QString &name) const; QVariant getValue(const QString &name, const QString &group, const QString &subGroup = QString()) const; + QVariant + getValue(const QString &name, const QString &group, const QString &subGroup, const QVariant &defaultValue) const; void batchWrite(std::function batchWriteFunction); void sync(); diff --git a/libcockatrice_settings/libcockatrice/settings/settings_migration.cpp b/libcockatrice_settings/libcockatrice/settings/settings_migration.cpp new file mode 100644 index 000000000..f6e107dad --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/settings_migration.cpp @@ -0,0 +1,702 @@ +#include "settings_migration.h" + +#include +#include +#include +#include + +static const QString MIGRATION_SENTINEL_KEY = QStringLiteral("migration/perfile_complete"); + +static void migrateTabsSettings(const QString &settingsPath, QSettings &globalIni) +{ + if (!globalIni.contains("tabs/visualDeckStorage") && !globalIni.contains("tabs/server") && + !globalIni.contains("tabs/account") && !globalIni.contains("tabs/deckStorage") && + !globalIni.contains("tabs/replays") && !globalIni.contains("tabs/admin") && !globalIni.contains("tabs/log")) { + return; + } + + QSettings tabsIni(settingsPath + "tabs.ini", QSettings::IniFormat); + tabsIni.setValue("tabs/visualDeckStorage", globalIni.value("tabs/visualDeckStorage", true)); + tabsIni.setValue("tabs/server", globalIni.value("tabs/server", true)); + tabsIni.setValue("tabs/account", globalIni.value("tabs/account", true)); + tabsIni.setValue("tabs/deckStorage", globalIni.value("tabs/deckStorage", true)); + tabsIni.setValue("tabs/replays", globalIni.value("tabs/replays", true)); + tabsIni.setValue("tabs/admin", globalIni.value("tabs/admin", true)); + tabsIni.setValue("tabs/log", globalIni.value("tabs/log", true)); +} + +static void migrateSoundSettings(const QString &settingsPath, QSettings &globalIni) +{ + if (!globalIni.contains("sound/enabled") && !globalIni.contains("sound/theme") && + !globalIni.contains("sound/mastervolume")) { + return; + } + + QSettings soundIni(settingsPath + "sound.ini", QSettings::IniFormat); + soundIni.setValue("sound/enabled", globalIni.value("sound/enabled", false)); + soundIni.setValue("sound/theme", globalIni.value("sound/theme")); + soundIni.setValue("sound/masterVolume", globalIni.value("sound/mastervolume", 100)); +} + +static void migrateGameSettings(const QString &settingsPath, QSettings &globalIni) +{ + const QMap gameKeyMap = { + {"game/maxplayers", "game/maxPlayers"}, + {"game/gamedescription", "game/gameDescription"}, + {"game/gametypes", "game/gameTypes"}, + {"game/onlybuddies", "game/onlyBuddies"}, + {"game/onlyregistered", "game/onlyRegistered"}, + {"game/spectatorsallowed", "game/spectatorsAllowed"}, + {"game/spectatorsneedpassword", "game/spectatorsNeedPassword"}, + {"game/spectatorscantalk", "game/spectatorsCanTalk"}, + {"game/spectatorscanseeeverything", "game/spectatorsCanSeeEverything"}, + {"game/creategameasspectator", "game/createGameAsSpectator"}, + {"game/defaultstartinglifetotal", "game/defaultStartingLifeTotal"}, + {"game/sharedecklistsonload", "game/shareDecklistsOnLoad"}, + {"game/remembergamesettings", "game/rememberGameSettings"}, + {"localgameoptions/maxplayers", "localgameoptions/maxPlayers"}, + {"localgameoptions/startinglifetotal", "localgameoptions/startingLifeTotal"}, + {"localgameoptions/remembersettings", "localgameoptions/rememberSettings"}, + }; + + bool hasAny = false; + for (auto it = gameKeyMap.constBegin(); it != gameKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + hasAny = true; + break; + } + } + if (!hasAny) { + return; + } + + QSettings gameIni(settingsPath + "game.ini", QSettings::IniFormat); + for (auto it = gameKeyMap.constBegin(); it != gameKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + gameIni.setValue(it.value(), globalIni.value(it.key())); + } + } +} + +static void migrateChatSettings(const QString &settingsPath, QSettings &globalIni) +{ + const QMap chatKeyMap = { + {"chat/mention", "chat/mention"}, + {"chat/mentioncompleter", "chat/mentionCompleter"}, + {"chat/mentioncolor", "chat/mentionColor"}, + {"chat/highlightcolor", "chat/highlightColor"}, + {"chat/mentionforeground", "chat/mentionForeground"}, + {"chat/highlightforeground", "chat/highlightForeground"}, + {"chat/ignore_unregistered", "chat/ignoreUnregistered"}, + {"chat/ignore_unregistered_messages", "chat/ignoreUnregisteredMessages"}, + {"chat/ignore_nonbuddy_messages", "chat/ignoreNonBuddyMessages"}, + {"chat/showmessagepopups", "chat/showMessagePopups"}, + {"chat/showmentionpopups", "chat/showMentionPopups"}, + {"chat/roomhistory", "chat/roomHistory"}, + {"chat/highlightwords", "chat/highlightWords"}, + // Legacy highlight words lived under [personal], but the chat settings + // class reads them from [chat] + {"personal/highlightWords", "chat/highlightWords"}, + }; + bool hasAny = false; + for (auto it = chatKeyMap.constBegin(); it != chatKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + hasAny = true; + break; + } + } + if (!hasAny) { + return; + } + + QSettings chatIni(settingsPath + "chat.ini", QSettings::IniFormat); + for (auto it = chatKeyMap.constBegin(); it != chatKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + chatIni.setValue(it.value(), globalIni.value(it.key())); + } + } +} + +static void migrateCacheStorageSettings(const QString &settingsPath, QSettings &globalIni) +{ + const QMap cacheStorageKeyMap = { + {"personal/pixmapCacheSize", "cache_storage/pixmapCacheSize"}, + {"personal/networkCacheSize", "cache_storage/networkCacheSize"}, + {"personal/redirectCacheTtl", "cache_storage/redirectCacheTtl"}, + {"personal/cardPictureLoaderCacheMethod", "cache_storage/cardPictureLoaderCacheMethod"}, + {"personal/localCardImageStorageNamingScheme", "cache_storage/localCardImageStorageNamingScheme"}, + }; + bool hasAny = false; + for (auto it = cacheStorageKeyMap.constBegin(); it != cacheStorageKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + hasAny = true; + break; + } + } + if (!hasAny) { + return; + } + + QSettings cacheStorageIni(settingsPath + "cache_storage.ini", QSettings::IniFormat); + for (auto it = cacheStorageKeyMap.constBegin(); it != cacheStorageKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + cacheStorageIni.setValue(it.value(), globalIni.value(it.key())); + } + } +} + +static void migrateUpdatesSettings(const QString &settingsPath, QSettings &globalIni) +{ + const QMap updateKeyMap = { + {"personal/startupUpdateCheck", "updates/startupUpdateCheck"}, + {"personal/startupCardUpdateCheckPromptForUpdate", "updates/startupCardUpdateCheckPromptForUpdate"}, + {"personal/startupCardUpdateCheckAlwaysUpdate", "updates/startupCardUpdateCheckAlwaysUpdate"}, + {"personal/cardUpdateCheckInterval", "updates/cardUpdateCheckInterval"}, + {"personal/lastCardUpdateCheck", "updates/lastCardUpdateCheck"}, + {"personal/alwaysEnableNewSets", "updates/alwaysEnableNewSets"}, + {"personal/updatenotification", "updates/updateNotification"}, + {"personal/newversionnotification", "updates/newVersionNotification"}, + {"personal/updatereleasechannel", "updates/updateReleaseChannel"}, + }; + bool hasAny = false; + for (auto it = updateKeyMap.constBegin(); it != updateKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + hasAny = true; + break; + } + } + if (!hasAny) { + return; + } + + QSettings updatesIni(settingsPath + "updates.ini", QSettings::IniFormat); + for (auto it = updateKeyMap.constBegin(); it != updateKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + updatesIni.setValue(it.value(), globalIni.value(it.key())); + } + } +} + +static void migratePersonalSettings(const QString &settingsPath, QSettings &globalIni) +{ + const QStringList personalRootKeys = {"personal/lang"}; + const QStringList tipKeys = {"tipOfDay/showTips", "tipOfDay/seenTips"}; + + bool hasAny = false; + for (const auto &key : personalRootKeys) { + if (globalIni.contains(key)) { + hasAny = true; + } + } + for (const auto &key : tipKeys) { + if (globalIni.contains(key)) { + hasAny = true; + } + } + if (!hasAny) { + return; + } + + QSettings personalIni(settingsPath + "personal.ini", QSettings::IniFormat); + for (const auto &key : personalRootKeys) { + if (globalIni.contains(key)) { + personalIni.setValue(key, globalIni.value(key)); + } + } + for (const auto &key : tipKeys) { + if (globalIni.contains(key)) { + personalIni.setValue(key, globalIni.value(key)); + } + } +} + +static void migrateCardsDisplaySettings(const QString &settingsPath, QSettings &globalIni) +{ + const QMap cardsKeyMap = { + {"cards/displaycardnames", "cards/displayCardNames"}, + {"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"}, + {"cards/verticalCardOverlapPercent", "cards/verticalCardOverlapPercent"}, + {"cards/cardinfoviewmode", "cards/cardInfoViewMode"}, + {"cards/printingselectorsortorder", "cards/printingSelector/sortOrder"}, + {"cards/printingselectornavigationbuttonsvisible", "cards/printingSelector/navigationButtonsVisible"}, + {"cards/printingselectorcardsize", "cards/cardSize/printingSelector"}, + {"interface/visualdeckstoragecardsize", "cards/cardSize/visualDeckStorage"}, + {"interface/visualdatabasedisplaycardsize", "cards/cardSize/visualDatabaseDisplay"}, + {"interface/visualdeckeditorcardsize", "cards/cardSize/visualDeckEditor"}, + {"interface/edhreccardsize", "cards/cardSize/edhrec"}, + {"interface/archidektpreviewsize", "cards/cardSize/archidektPreview"}, + {"interface/visualdeckeditorsamplehandsize", "cards/cardSize/sampleHandSize"}, + }; + + bool hasAny = false; + for (auto it = cardsKeyMap.constBegin(); it != cardsKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + hasAny = true; + break; + } + } + if (!hasAny) { + return; + } + + QSettings cardsIni(settingsPath + "cards_display.ini", QSettings::IniFormat); + for (auto it = cardsKeyMap.constBegin(); it != cardsKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + cardsIni.setValue(it.value(), globalIni.value(it.key())); + } + } +} + +static void migrateCardCounterSettings(const QString &settingsPath, QSettings &globalIni) +{ + QStringList counterKeys; + const QStringList allKeys = globalIni.allKeys(); + for (const auto &key : allKeys) { + if (key.startsWith("cards/counters/")) { + counterKeys.append(key); + } + } + if (counterKeys.isEmpty()) { + return; + } + + QSettings countersIni(settingsPath + "card_counters.ini", QSettings::IniFormat); + for (const auto &key : counterKeys) { + countersIni.setValue(key, globalIni.value(key)); + } +} + +static void migrateInterfaceSettings(const QString &settingsPath, QSettings &globalIni) +{ + const QMap interfaceKeyMap = { + {"interface/usetearoffmenus", "interface/useTearOffMenus"}, + {"interface/cardViewInitialRowsMax", "interface/cardViewInitialRowsMax"}, + {"interface/cardViewExpandedRowsMax", "interface/cardViewExpandedRowsMax"}, + {"interface/closeEmptyCardView", "interface/closeEmptyCardView"}, + {"interface/focusCardViewSearchBar", "interface/focusCardViewSearchBar"}, + {"interface/keepGameChatFocus", "interface/keepGameChatFocus"}, + {"interface/doubleclicktoplay", "interface/doubleClickToPlay"}, + {"interface/clickPlaysAllSelected", "interface/clickPlaysAllSelected"}, + {"interface/playtostack", "interface/playToStack"}, + {"interface/doNotDeleteArrowsInSubPhases", "interface/doNotDeleteArrowsInSubPhases"}, + {"interface/startinghandsize", "interface/startingHandSize"}, + {"interface/annotatetokens", "interface/annotateTokens"}, + {"interface/showlassoselectioncount", "interface/showLassoSelectionCount"}, + {"interface/showpersistentselectioncount", "interface/showPersistentSelectionCount"}, + {"interface/tallyType", "interface/tallyType"}, + {"interface/leftjustified", "interface/leftJustified"}, + {"interface/min_players_multicolumn", "interface/minPlayersMulticolumn"}, + {"hand/horizontal", "hand/horizontal"}, + {"table/invert_vertical", "table/invertVertical"}, + {"replay/rewindBufferingMs", "replay/rewindBufferingMs"}, + {"replay/fastForwardSpeed", "replay/fastForwardSpeed"}, + {"zoneview/groupby", "zoneview/groupBy"}, + {"zoneview/sortby", "zoneview/sortBy"}, + {"zoneview/pileview", "zoneview/pileView"}, + {"personal/showStatusBar", "interface/showStatusBar"}, + {"menu/showshortcuts", "interface/showShortcuts"}, + {"menu/showgameselectorfiltertoolbar", "interface/showGameSelectorFilterToolbar"}, + {"interface/notificationsenabled", "interface/notifications/enabled"}, + {"interface/specnotificationsenabled", "interface/notifications/spectatorsEnabled"}, + {"interface/buddyconnectnotificationsenabled", "interface/notifications/buddyConnectEnabled"}, + }; + bool hasAny = false; + for (auto it = interfaceKeyMap.constBegin(); it != interfaceKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + hasAny = true; + break; + } + } + if (!hasAny) { + return; + } + + QSettings interfaceIni(settingsPath + "interface.ini", QSettings::IniFormat); + for (auto it = interfaceKeyMap.constBegin(); it != interfaceKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + interfaceIni.setValue(it.value(), globalIni.value(it.key())); + } + } +} + +static void migrateDownloadSettings(const QString &settingsPath, QSettings &globalIni) +{ + const QMap downloadKeyMap = { + {"personal/picturedownload", "downloads/pictureDownload"}, + {"personal/downloadspoilers", "downloads/downloadSpoilers"}, + }; + bool hasAny = false; + for (auto it = downloadKeyMap.constBegin(); it != downloadKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + hasAny = true; + break; + } + } + if (!hasAny) { + return; + } + + QSettings downloadsIni(settingsPath + "downloads.ini", QSettings::IniFormat); + for (auto it = downloadKeyMap.constBegin(); it != downloadKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + downloadsIni.setValue(it.value(), globalIni.value(it.key())); + } + } +} + +static void migrateAppearanceSettings(const QString &settingsPath, QSettings &globalIni) +{ + const QMap appearanceKeyMap = { + {"theme/name", "appearance/themeName"}, + {"game/maxfontsize", "appearance/maxFontSize"}, + {"home/background", "appearance/homeTabBackgroundSource"}, + {"home/background/shuffleTimer", "appearance/homeTabBackgroundShuffleFrequency"}, + {"home/background/displayCardName", "appearance/homeTabDisplayCardName"}, + {"appearance/styleUserList", "appearance/styleUserList"}, + }; + bool hasAny = false; + for (auto it = appearanceKeyMap.constBegin(); it != appearanceKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + hasAny = true; + break; + } + } + if (!hasAny) { + return; + } + + QSettings appearanceIni(settingsPath + "appearance.ini", QSettings::IniFormat); + for (auto it = appearanceKeyMap.constBegin(); it != appearanceKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + QVariant value = globalIni.value(it.key()); + if (it.key() == "theme/name" && value.toString() == "Default") { + value = "System"; + } + appearanceIni.setValue(it.value(), value); + } + } +} + +static void migrateNetworkSettings(const QString &settingsPath, QSettings &globalIni) +{ + const QMap networkKeyMap = { + {"personal/clientid", "network/clientId"}, + {"personal/clientversion", "network/clientVersion"}, + {"personal/keepalive", "network/keepAlive"}, + {"personal/timeout", "network/timeout"}, + {"interface/knownmissingfeatures", "network/knownMissingFeatures"}, + }; + bool hasAny = false; + for (auto it = networkKeyMap.constBegin(); it != networkKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + hasAny = true; + break; + } + } + if (!hasAny) { + return; + } + + QSettings networkIni(settingsPath + "network.ini", QSettings::IniFormat); + for (auto it = networkKeyMap.constBegin(); it != networkKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + networkIni.setValue(it.value(), globalIni.value(it.key())); + } + } +} + +static void migratePathsSettings(const QString &settingsPath, QSettings &globalIni) +{ + const QMap pathsKeyMap = { + {"paths/decks", "paths/decks"}, + {"paths/filters", "paths/filters"}, + {"paths/replays", "paths/replays"}, + {"paths/pics", "paths/pics"}, + {"paths/custompics", "paths/customPics"}, + {"paths/themes", "paths/themes"}, + {"paths/carddatabase", "paths/cardDatabase"}, + {"paths/customsets", "paths/customSets"}, + {"paths/tokendatabase", "paths/tokenDatabase"}, + {"paths/spoilerdatabase", "paths/spoilerDatabase"}, + {"paths/redirects", "paths/redirects"}, + }; + bool hasAny = false; + for (auto it = pathsKeyMap.constBegin(); it != pathsKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + hasAny = true; + break; + } + } + if (!hasAny) { + return; + } + + QSettings pathsIni(settingsPath + "paths.ini", QSettings::IniFormat); + for (auto it = pathsKeyMap.constBegin(); it != pathsKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + pathsIni.setValue(it.value(), globalIni.value(it.key())); + } + } +} + +static void migrateVisualDeckStorageSettings(const QString &settingsPath, QSettings &globalIni) +{ + const QMap vdsKeyMap = { + {"interface/visualdeckstoragesortingorder", "interface/visualDeckStorage/sortingOrder"}, + {"interface/visualdeckstorageshowfolders", "interface/visualDeckStorage/showFolders"}, + {"interface/visualdeckstorageshowtagfilter", "interface/visualDeckStorage/showTagFilter"}, + {"interface/visualdeckstoragedefaulttagslist", "interface/visualDeckStorage/defaultTagsList"}, + {"interface/visualdeckstoragesearchfoldernames", "interface/visualDeckStorage/searchFolderNames"}, + {"interface/visualdeckstorageshowcoloridentity", "interface/visualDeckStorage/showColorIdentity"}, + {"interface/visualdeckstorageshowbannercardcombobox", "interface/visualDeckStorage/showBannerCardComboBox"}, + {"interface/visualdeckstorageshowtagsondeckpreviews", "interface/visualDeckStorage/showTagsOnDeckPreviews"}, + {"interface/visualdeckstoragedrawunusedcoloridentities", + "interface/visualDeckStorage/drawUnusedColorIdentities"}, + {"interface/visualdeckstorageunusedcoloridentitiesopacity", + "interface/visualDeckStorage/unusedColorIdentitiesOpacity"}, + {"interface/visualdeckstoragetooltiptype", "interface/visualDeckStorage/tooltipType"}, + {"interface/visualdeckstoragepromptforconversion", "interface/visualDeckStorage/promptForConversion"}, + {"interface/visualdeckstoragealwaysconvert", "interface/visualDeckStorage/alwaysConvert"}, + {"interface/visualdeckstorageingame", "interface/visualDeckStorage/inGame"}, + {"interface/visualdeckstorageselectionanimation", "interface/visualDeckStorage/selectionAnimation"}, + {"interface/visualdatabasedisplayfiltertomostrecentsetsenabled", + "interface/visualDatabaseDisplay/filterToMostRecentSetsEnabled"}, + {"interface/visualdatabasedisplayfiltertomostrecentsetsamount", + "interface/visualDatabaseDisplay/filterToMostRecentSetsAmount"}, + }; + bool hasAny = false; + for (auto it = vdsKeyMap.constBegin(); it != vdsKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + hasAny = true; + break; + } + } + if (!hasAny) { + return; + } + + QSettings vdsIni(settingsPath + "visual_deck_storage.ini", QSettings::IniFormat); + for (auto it = vdsKeyMap.constBegin(); it != vdsKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + vdsIni.setValue(it.value(), globalIni.value(it.key())); + } + } +} + +static void migrateDeckEditorSettings(const QString &settingsPath, QSettings &globalIni) +{ + const QMap deckEditorKeyMap = { + {"editor/openDeckInNewTab", "deckeditor/openDeckInNewTab"}, + {"interface/deckeditorbannercardcomboboxvisible", "deckeditor/bannerCardComboBoxVisible"}, + {"interface/deckeditortagswidgetvisible", "deckeditor/tagsWidgetVisible"}, + {"interface/defaultDeckEditorType", "deckeditor/defaultDeckEditorType"}, + }; + bool hasAny = false; + for (auto it = deckEditorKeyMap.constBegin(); it != deckEditorKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + hasAny = true; + break; + } + } + if (!hasAny) { + return; + } + + QSettings deckEditorIni(settingsPath + "deck_editor.ini", QSettings::IniFormat); + for (auto it = deckEditorKeyMap.constBegin(); it != deckEditorKeyMap.constEnd(); ++it) { + if (globalIni.contains(it.key())) { + deckEditorIni.setValue(it.value(), globalIni.value(it.key())); + } + } +} + +static void migrateLegacySets(const QString &settingsPath) +{ + QSettings legacySetting; + legacySetting.beginGroup("sets"); + QStringList groups = legacySetting.childGroups(); + if (groups.isEmpty()) { + legacySetting.endGroup(); + return; + } + + 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")); + legacySetting.endGroup(); + } + legacySetting.endGroup(); +} + +static void migrateLegacyServers(const QString &settingsPath) +{ + QSettings legacySetting; + legacySetting.beginGroup("server"); + QStringList keys = legacySetting.allKeys(); + if (keys.isEmpty()) { + legacySetting.endGroup(); + return; + } + + const QMap serverKeyMap = { + {"previoushostlogin", "previousHostLogin"}, + {"previoushosts", "previousHosts"}, + {"previoushostName", "previousHostName"}, + {"auto_connect", "autoConnect"}, + {"fphostname", "fpHostName"}, + {"fpport", "fpPort"}, + {"fpplayername", "fpPlayerName"}, + {"save_debug_log", "saveDebugLog"}, + }; + + QSettings serversIni(settingsPath + "servers.ini", QSettings::IniFormat); + for (auto it = serverKeyMap.constBegin(); it != serverKeyMap.constEnd(); ++it) { + if (legacySetting.contains(it.key())) { + serversIni.setValue("server/" + it.value(), legacySetting.value(it.key())); + } + } + + legacySetting.beginGroup("server_details"); + const QStringList detailsKeys = legacySetting.allKeys(); + for (const auto &key : detailsKeys) { + serversIni.setValue("server/server_details/" + key, legacySetting.value(key)); + } + legacySetting.endGroup(); + legacySetting.endGroup(); +} + +static void migrateLegacyMessages(const QString &settingsPath) +{ + QSettings legacySetting; + legacySetting.beginGroup("messages"); + QStringList keys = legacySetting.allKeys(); + if (keys.isEmpty()) { + legacySetting.endGroup(); + return; + } + + QSettings messageIni(settingsPath + "messages.ini", QSettings::IniFormat); + for (const auto &key : keys) { + if (key == "count") { + messageIni.setValue("messages/count", legacySetting.value("count")); + } else { + messageIni.setValue("messages/" + key, legacySetting.value(key)); + } + } + legacySetting.endGroup(); +} + +static void migrateLegacyGameFilters(const QString &settingsPath) +{ + QSettings legacySetting; + legacySetting.beginGroup("filter_games"); + QStringList keys = legacySetting.allKeys(); + if (keys.isEmpty()) { + legacySetting.endGroup(); + return; + } + + const QMap filterKeyMap = { + {"hide_buddies_only_games", "hideBuddiesOnlyGames"}, + {"hide_full_games", "hideFullGames"}, + {"hide_games_that_started", "hideGamesThatStarted"}, + {"hide_password_protected_games", "hidePasswordProtectedGames"}, + {"hide_ignored_user_games", "hideIgnoredUserGames"}, + {"hide_not_buddy_created_games", "hideNotBuddyCreatedGames"}, + {"hide_open_decklist_games", "hideOpenDecklistGames"}, + {"game_name_filter", "gameNameFilter"}, + {"creator_name_filter", "creatorNameFilter"}, + {"min_players", "minPlayers"}, + {"max_players", "maxPlayers"}, + {"max_game_age_time", "maxGameAgeTime"}, + {"show_only_if_spectators_can_watch", "showOnlyIfSpectatorsCanWatch"}, + {"show_spectator_password_protected", "showSpectatorPasswordProtected"}, + {"show_only_if_spectators_can_chat", "showOnlyIfSpectatorsCanChat"}, + {"show_only_if_spectators_can_see_hands", "showOnlyIfSpectatorsCanSeeHands"}, + }; + + QSettings filtersIni(settingsPath + "gamefilters.ini", QSettings::IniFormat); + for (auto it = filterKeyMap.constBegin(); it != filterKeyMap.constEnd(); ++it) { + if (legacySetting.contains(it.key())) { + filtersIni.setValue("filter_games/" + it.value(), legacySetting.value(it.key())); + } + } + const QString gameTypePrefix = "game_type/"; + for (const auto &key : keys) { + if (key.startsWith(gameTypePrefix)) { + filtersIni.setValue("filter_games/gameType/" + key.mid(gameTypePrefix.size()), legacySetting.value(key)); + } + } + legacySetting.endGroup(); +} + +bool SettingsMigration::migrateLegacySettings(const QString &settingsPath) +{ + QSettings personalIni(settingsPath + "personal.ini", QSettings::IniFormat); + if (personalIni.value("migration/legacy_complete", false).toBool()) { + return false; + } + + migrateLegacySets(settingsPath); + migrateLegacyServers(settingsPath); + migrateLegacyMessages(settingsPath); + migrateLegacyGameFilters(settingsPath); + + personalIni.setValue("migration/legacy_complete", true); + personalIni.sync(); + return true; +} + +bool SettingsMigration::migrateSettingsFromGlobalIni(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()) { + // 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); + migrateGameSettings(settingsPath, globalIni); + migrateChatSettings(settingsPath, globalIni); + migrateCacheStorageSettings(settingsPath, globalIni); + migrateUpdatesSettings(settingsPath, globalIni); + migratePersonalSettings(settingsPath, globalIni); + migrateDownloadSettings(settingsPath, globalIni); + migrateCardsDisplaySettings(settingsPath, globalIni); + migrateCardCounterSettings(settingsPath, globalIni); + migrateInterfaceSettings(settingsPath, globalIni); + migrateAppearanceSettings(settingsPath, globalIni); + migrateNetworkSettings(settingsPath, globalIni); + migratePathsSettings(settingsPath, globalIni); + migrateVisualDeckStorageSettings(settingsPath, globalIni); + migrateDeckEditorSettings(settingsPath, globalIni); + + QFile::remove(settingsPath + "global.ini.old"); + QFile::rename(settingsPath + "global.ini", settingsPath + "global.ini.old"); + + QSettings newGlobalIni(settingsPath + "global.ini", QSettings::IniFormat); + newGlobalIni.setValue(MIGRATION_SENTINEL_KEY, true); + newGlobalIni.sync(); + + return true; +} diff --git a/libcockatrice_settings/libcockatrice/settings/settings_migration.h b/libcockatrice_settings/libcockatrice/settings/settings_migration.h new file mode 100644 index 000000000..f596dba75 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/settings_migration.h @@ -0,0 +1,13 @@ +#ifndef SETTINGS_MIGRATION_H +#define SETTINGS_MIGRATION_H + +#include + +class SettingsMigration +{ +public: + static bool migrateSettingsFromGlobalIni(const QString &settingsPath); + static bool migrateLegacySettings(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 new file mode 100644 index 000000000..82b724a07 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/sound_settings.cpp @@ -0,0 +1,39 @@ +#include "sound_settings.h" + +SoundSettings::SoundSettings(const QString &settingPath, QObject *parent) + : SettingsManager(settingPath + "sound.ini", "sound", QString(), parent) +{ +} + +bool SoundSettings::getSoundEnabled() const +{ + return getValue("enabled", QString(), QString(), false).toBool(); +} + +QString SoundSettings::getSoundThemeName() const +{ + return getValue("theme", QString(), QString()).toString(); +} + +int SoundSettings::getMasterVolume() const +{ + return getValue("masterVolume", QString(), QString(), 100).toInt(); +} + +void SoundSettings::setSoundEnabled(bool _soundEnabled) +{ + setValue(_soundEnabled, "enabled"); + emit soundEnabledChanged(); +} + +void SoundSettings::setSoundThemeName(const QString &_soundThemeName) +{ + setValue(_soundThemeName, "theme"); + emit soundThemeChanged(); +} + +void SoundSettings::setMasterVolume(int _masterVolume) +{ + setValue(_masterVolume, "masterVolume"); + emit masterVolumeChanged(_masterVolume); +} diff --git a/libcockatrice_settings/libcockatrice/settings/sound_settings.h b/libcockatrice_settings/libcockatrice/settings/sound_settings.h new file mode 100644 index 000000000..ffaed2a7c --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/sound_settings.h @@ -0,0 +1,34 @@ +#ifndef SOUND_SETTINGS_H +#define SOUND_SETTINGS_H + +#include "settings_manager.h" + +#include + +class SoundSettings : public SettingsManager, public ISoundSettingsProvider +{ + Q_OBJECT + friend class SettingsCache; + +public: + [[nodiscard]] bool getSoundEnabled() const override; + [[nodiscard]] QString getSoundThemeName() const override; + [[nodiscard]] int getMasterVolume() const override; + + void setSoundEnabled(bool _soundEnabled); + void setSoundThemeName(const QString &_soundThemeName); + void setMasterVolume(int _masterVolume); + +signals: + void soundEnabledChanged(); + void soundThemeChanged(); + void masterVolumeChanged(int value); + +public: + explicit SoundSettings(const QString &settingPath, QObject *parent = nullptr); + +private: + SoundSettings(const SoundSettings & /*other*/); +}; + +#endif // SOUND_SETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/tabs_settings.cpp b/libcockatrice_settings/libcockatrice/settings/tabs_settings.cpp new file mode 100644 index 000000000..cf5bfd81a --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/tabs_settings.cpp @@ -0,0 +1,162 @@ +#include "tabs_settings.h" + +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(); +} + +bool TabsSettings::getTabServerOpen() const +{ + return getValue("server", QString(), QString(), true).toBool(); +} + +bool TabsSettings::getTabAccountOpen() const +{ + return getValue("account", QString(), QString(), true).toBool(); +} + +bool TabsSettings::getTabDeckStorageOpen() const +{ + return getValue("deckStorage", QString(), QString(), true).toBool(); +} + +bool TabsSettings::getTabReplaysOpen() const +{ + return getValue("replays", QString(), QString(), true).toBool(); +} + +bool TabsSettings::getTabAdminOpen() const +{ + return getValue("admin", QString(), QString(), true).toBool(); +} + +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"); +} + +void TabsSettings::setTabServerOpen(bool value) +{ + setValue(value, "server"); +} + +void TabsSettings::setTabAccountOpen(bool value) +{ + setValue(value, "account"); +} + +void TabsSettings::setTabDeckStorageOpen(bool value) +{ + setValue(value, "deckStorage"); +} + +void TabsSettings::setTabReplaysOpen(bool value) +{ + setValue(value, "replays"); +} + +void TabsSettings::setTabAdminOpen(bool value) +{ + setValue(value, "admin"); +} + +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 new file mode 100644 index 000000000..eb78d311b --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/tabs_settings.h @@ -0,0 +1,76 @@ +#ifndef TABS_SETTINGS_H +#define TABS_SETTINGS_H + +#include "settings_manager.h" + +#include + +/** + * @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 +{ + Q_OBJECT + 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; + [[nodiscard]] bool getTabDeckStorageOpen() const override; + [[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); + void setTabDeckStorageOpen(bool value); + 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); + +private: + TabsSettings(const TabsSettings & /*other*/); +}; + +#endif // TABS_SETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/updates_settings.cpp b/libcockatrice_settings/libcockatrice/settings/updates_settings.cpp new file mode 100644 index 000000000..166bc0aa8 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/updates_settings.cpp @@ -0,0 +1,104 @@ +#include "updates_settings.h" + +#include + +UpdatesSettings::UpdatesSettings(const QString &settingPath, QObject *parent) + : SettingsManager(settingPath + "updates.ini", "updates", QString(), parent) +{ +} + +bool UpdatesSettings::getCheckUpdatesOnStartup() const +{ + return getValue("startupUpdateCheck", QString(), QString(), true).toBool(); +} + +bool UpdatesSettings::getStartupCardUpdateCheckPromptForUpdate() +{ + return getValue("startupCardUpdateCheckPromptForUpdate", QString(), QString(), true).toBool(); +} + +bool UpdatesSettings::getStartupCardUpdateCheckAlwaysUpdate() +{ + return getValue("startupCardUpdateCheckAlwaysUpdate", QString(), QString(), false).toBool(); +} + +int UpdatesSettings::getCardUpdateCheckInterval() const +{ + return getValue("cardUpdateCheckInterval", QString(), QString(), 7).toInt(); +} + +QDate UpdatesSettings::getLastCardUpdateCheck() const +{ + return getValue("lastCardUpdateCheck", QString(), QString(), QDateTime::currentDateTime().date()).toDate(); +} + +bool UpdatesSettings::getCardUpdateCheckRequired() const +{ + return getLastCardUpdateCheck().daysTo(QDateTime::currentDateTime().date()) >= getCardUpdateCheckInterval() && + getLastCardUpdateCheck() != QDateTime::currentDateTime().date(); +} + +bool UpdatesSettings::getAlwaysEnableNewSets() const +{ + return getValue("alwaysEnableNewSets", QString(), QString(), false).toBool(); +} + +bool UpdatesSettings::getNotifyAboutUpdates() const +{ + return getValue("updateNotification", QString(), QString(), true).toBool(); +} + +bool UpdatesSettings::getNotifyAboutNewVersion() const +{ + return getValue("newVersionNotification", QString(), QString(), true).toBool(); +} + +int UpdatesSettings::getUpdateReleaseChannelIndex() const +{ + return getValue("updateReleaseChannel", QString(), QString(), 0).toInt(); +} + +void UpdatesSettings::setCheckUpdatesOnStartup(bool value) +{ + setValue(value, "startupUpdateCheck"); +} + +void UpdatesSettings::setStartupCardUpdateCheckPromptForUpdate(bool value) +{ + setValue(value, "startupCardUpdateCheckPromptForUpdate"); +} + +void UpdatesSettings::setStartupCardUpdateCheckAlwaysUpdate(bool value) +{ + setValue(value, "startupCardUpdateCheckAlwaysUpdate"); +} + +void UpdatesSettings::setCardUpdateCheckInterval(int value) +{ + setValue(value, "cardUpdateCheckInterval"); +} + +void UpdatesSettings::setLastCardUpdateCheck(QDate value) +{ + setValue(value, "lastCardUpdateCheck"); +} + +void UpdatesSettings::setAlwaysEnableNewSets(bool value) +{ + setValue(value, "alwaysEnableNewSets"); +} + +void UpdatesSettings::setNotifyAboutUpdates(bool _notifyaboutupdate) +{ + setValue(_notifyaboutupdate, "updateNotification"); +} + +void UpdatesSettings::setNotifyAboutNewVersion(bool _notifyaboutnewversion) +{ + setValue(_notifyaboutnewversion, "newVersionNotification"); +} + +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 new file mode 100644 index 000000000..6df0f8ce4 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/updates_settings.h @@ -0,0 +1,43 @@ +#ifndef UPDATES_SETTINGS_H +#define UPDATES_SETTINGS_H + +#include "settings_manager.h" + +#include +#include + +class UpdatesSettings : public SettingsManager, public IUpdatesSettingsProvider +{ + Q_OBJECT + friend class SettingsCache; + +public: + [[nodiscard]] bool getCheckUpdatesOnStartup() const override; + [[nodiscard]] bool getStartupCardUpdateCheckPromptForUpdate() override; + [[nodiscard]] bool getStartupCardUpdateCheckAlwaysUpdate() override; + [[nodiscard]] int getCardUpdateCheckInterval() const override; + [[nodiscard]] QDate getLastCardUpdateCheck() const override; + [[nodiscard]] bool getCardUpdateCheckRequired() const override; + [[nodiscard]] bool getAlwaysEnableNewSets() const override; + [[nodiscard]] bool getNotifyAboutUpdates() const override; + [[nodiscard]] bool getNotifyAboutNewVersion() const override; + [[nodiscard]] int getUpdateReleaseChannelIndex() const override; + + void setCheckUpdatesOnStartup(bool value); + void setStartupCardUpdateCheckPromptForUpdate(bool value); + void setStartupCardUpdateCheckAlwaysUpdate(bool value); + void setCardUpdateCheckInterval(int value); + void setLastCardUpdateCheck(QDate value); + void setAlwaysEnableNewSets(bool value); + void setNotifyAboutUpdates(bool _notifyaboutupdate); + void setNotifyAboutNewVersion(bool _notifyaboutnewversion); + void setUpdateReleaseChannelIndex(int value); + +public: + explicit UpdatesSettings(const QString &settingPath, QObject *parent = nullptr); + +private: + UpdatesSettings(const UpdatesSettings & /*other*/); +}; + +#endif // UPDATES_SETTINGS_H diff --git a/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.cpp b/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.cpp new file mode 100644 index 000000000..1b21af58e --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.cpp @@ -0,0 +1,272 @@ +#include "visual_deck_storage_settings.h" + +namespace +{ +QStringList defaultTags = { + // Strategies + "🏃️ Aggro", + "🧙‍️ Control", + "⚔️ Midrange", + "🌀 Combo", + "🪓 Mill", + "🔒 Stax", + "🗺️ Landfall", + "🛡️ Pillowfort", + "🌱 Ramp", + "⚡ Storm", + "💀 Aristocrats", + "☠️ Reanimator", + "👹 Sacrifice", + "🔥 Burn", + "🌟 Lifegain", + "🔮 Spellslinger", + "👥 Tokens", + "🎭 Blink", + "⏳ Time Manipulation", + "🌍 Domain", + "💫 Proliferate", + "📜 Saga", + "🎲 Chaos", + "🪄 Auras", + "🔫 Pingers", + + // Themes + "👑 Monarch", + "🚀 Vehicles", + "💉 Infect", + "🩸 Madness", + "🌀 Morph", + + // Card Types + "⚔️ Creature", + "💎 Artifact", + "🌔 Enchantment", + "📖 Sorcery", + "⚡ Instant", + "🌌 Planeswalker", + "🌏 Land", + "🪄 Aura", + + // Kindred Types + "🐉 Kindred", + "🧙 Humans", + "⚔️ Soldiers", + "🛡️ Knights", + "🎻 Bards", + "🧝 Elves", + "🌲 Dryads", + "😇 Angels", + "🎩 Wizards", + "🧛 Vampires", + "🦴 Skeletons", + "💀 Zombies", + "👹 Demons", + "👾 Eldrazi", + "🐉 Dragons", + "🐠 Merfolk", + "🦁 Cats", + "🐺 Wolves", + "🐺 Werewolves", + "🦇 Bats", + "🐀 Rats", + "🦅 Birds", + "🦗 Insects", + "🍄 Fungus", + "🐚 Sea Creatures", + "🐗 Boars", + "🦊 Foxes", + "🦄 Unicorns", + "🐘 Elephants", + "🐻 Bears", + "🦏 Rhinos", + "🦂 Scorpions", + +}; +} + +VisualDeckStorageSettings::VisualDeckStorageSettings(const QString &settingPath, QObject *parent) + : SettingsManager(settingPath + "visual_deck_storage.ini", "interface", QString(), parent) +{ +} + +int VisualDeckStorageSettings::getVisualDeckStorageSortingOrder() const +{ + return getValue("sortingOrder", "interface", "visualDeckStorage", 0).toInt(); +} + +bool VisualDeckStorageSettings::getVisualDeckStorageShowFolders() const +{ + return getValue("showFolders", "interface", "visualDeckStorage", true).toBool(); +} + +bool VisualDeckStorageSettings::getVisualDeckStorageShowTagFilter() const +{ + return getValue("showTagFilter", "interface", "visualDeckStorage", true).toBool(); +} + +QStringList VisualDeckStorageSettings::getVisualDeckStorageDefaultTagsList() const +{ + return getValue("defaultTagsList", "interface", "visualDeckStorage", QVariant::fromValue(defaultTags)) + .toStringList(); +} + +bool VisualDeckStorageSettings::getVisualDeckStorageSearchFolderNames() const +{ + return getValue("searchFolderNames", "interface", "visualDeckStorage", true).toBool(); +} + +bool VisualDeckStorageSettings::getVisualDeckStorageShowColorIdentity() const +{ + return getValue("showColorIdentity", "interface", "visualDeckStorage", true).toBool(); +} + +bool VisualDeckStorageSettings::getVisualDeckStorageShowBannerCardComboBox() const +{ + return getValue("showBannerCardComboBox", "interface", "visualDeckStorage", true).toBool(); +} + +bool VisualDeckStorageSettings::getVisualDeckStorageShowTagsOnDeckPreviews() const +{ + return getValue("showTagsOnDeckPreviews", "interface", "visualDeckStorage", true).toBool(); +} + +bool VisualDeckStorageSettings::getVisualDeckStorageDrawUnusedColorIdentities() const +{ + return getValue("drawUnusedColorIdentities", "interface", "visualDeckStorage", true).toBool(); +} + +int VisualDeckStorageSettings::getVisualDeckStorageUnusedColorIdentitiesOpacity() const +{ + return getValue("unusedColorIdentitiesOpacity", "interface", "visualDeckStorage", 15).toInt(); +} + +int VisualDeckStorageSettings::getVisualDeckStorageTooltipType() const +{ + return getValue("tooltipType", "interface", "visualDeckStorage", 0).toInt(); +} + +bool VisualDeckStorageSettings::getVisualDeckStoragePromptForConversion() const +{ + return getValue("promptForConversion", "interface", "visualDeckStorage", true).toBool(); +} + +bool VisualDeckStorageSettings::getVisualDeckStorageAlwaysConvert() const +{ + return getValue("alwaysConvert", "interface", "visualDeckStorage", false).toBool(); +} + +bool VisualDeckStorageSettings::getVisualDeckStorageInGame() const +{ + return getValue("inGame", "interface", "visualDeckStorage", true).toBool(); +} + +bool VisualDeckStorageSettings::getVisualDeckStorageSelectionAnimation() const +{ + return getValue("selectionAnimation", "interface", "visualDeckStorage", true).toBool(); +} + +bool VisualDeckStorageSettings::getVisualDatabaseDisplayFilterToMostRecentSetsEnabled() const +{ + return getValue("filterToMostRecentSetsEnabled", "interface", "visualDatabaseDisplay", false).toBool(); +} + +int VisualDeckStorageSettings::getVisualDatabaseDisplayFilterToMostRecentSetsAmount() const +{ + return getValue("filterToMostRecentSetsAmount", "interface", "visualDatabaseDisplay", 10).toInt(); +} + +void VisualDeckStorageSettings::setVisualDeckStorageSortingOrder(int _sortingOrder) +{ + setValue(_sortingOrder, "sortingOrder", "interface", "visualDeckStorage"); +} + +void VisualDeckStorageSettings::setVisualDeckStorageShowFolders(bool value) +{ + setValue(value, "showFolders", "interface", "visualDeckStorage"); +} + +void VisualDeckStorageSettings::setVisualDeckStorageShowTagFilter(bool _showTags) +{ + setValue(_showTags, "showTagFilter", "interface", "visualDeckStorage"); + emit visualDeckStorageShowTagFilterChanged(_showTags); +} + +void VisualDeckStorageSettings::setVisualDeckStorageDefaultTagsList(QStringList _defaultTagsList) +{ + setValue(QVariant::fromValue(_defaultTagsList), "defaultTagsList", "interface", "visualDeckStorage"); + emit visualDeckStorageDefaultTagsListChanged(); +} + +void VisualDeckStorageSettings::setVisualDeckStorageSearchFolderNames(bool value) +{ + setValue(value, "searchFolderNames", "interface", "visualDeckStorage"); +} + +void VisualDeckStorageSettings::setVisualDeckStorageShowColorIdentity(bool value) +{ + setValue(value, "showColorIdentity", "interface", "visualDeckStorage"); + emit visualDeckStorageShowColorIdentityChanged(value); +} + +void VisualDeckStorageSettings::setVisualDeckStorageShowBannerCardComboBox(bool _showBannerCardComboBox) +{ + setValue(_showBannerCardComboBox, "showBannerCardComboBox", "interface", "visualDeckStorage"); + emit visualDeckStorageShowBannerCardComboBoxChanged(_showBannerCardComboBox); +} + +void VisualDeckStorageSettings::setVisualDeckStorageShowTagsOnDeckPreviews(bool _showTags) +{ + setValue(_showTags, "showTagsOnDeckPreviews", "interface", "visualDeckStorage"); + emit visualDeckStorageShowTagsOnDeckPreviewsChanged(_showTags); +} + +void VisualDeckStorageSettings::setVisualDeckStorageDrawUnusedColorIdentities(bool _draw) +{ + setValue(_draw, "drawUnusedColorIdentities", "interface", "visualDeckStorage"); + emit visualDeckStorageDrawUnusedColorIdentitiesChanged(_draw); +} + +void VisualDeckStorageSettings::setVisualDeckStorageUnusedColorIdentitiesOpacity(int _opacity) +{ + setValue(_opacity, "unusedColorIdentitiesOpacity", "interface", "visualDeckStorage"); + emit visualDeckStorageUnusedColorIdentitiesOpacityChanged(_opacity); +} + +void VisualDeckStorageSettings::setVisualDeckStorageTooltipType(int value) +{ + setValue(value, "tooltipType", "interface", "visualDeckStorage"); +} + +void VisualDeckStorageSettings::setVisualDeckStoragePromptForConversion(bool _prompt) +{ + setValue(_prompt, "promptForConversion", "interface", "visualDeckStorage"); +} + +void VisualDeckStorageSettings::setVisualDeckStorageAlwaysConvert(bool _always) +{ + setValue(_always, "alwaysConvert", "interface", "visualDeckStorage"); +} + +void VisualDeckStorageSettings::setVisualDeckStorageInGame(bool enabled) +{ + setValue(enabled, "inGame", "interface", "visualDeckStorage"); + emit visualDeckStorageInGameChanged(enabled); +} + +void VisualDeckStorageSettings::setVisualDeckStorageSelectionAnimation(bool enabled) +{ + setValue(enabled, "selectionAnimation", "interface", "visualDeckStorage"); + emit visualDeckStorageSelectionAnimationChanged(enabled); +} + +void VisualDeckStorageSettings::setVisualDatabaseDisplayFilterToMostRecentSetsEnabled(bool _enabled) +{ + setValue(_enabled, "filterToMostRecentSetsEnabled", "interface", "visualDatabaseDisplay"); + emit visualDatabaseDisplayFilterToMostRecentSetsEnabledChanged(_enabled); +} + +void VisualDeckStorageSettings::setVisualDatabaseDisplayFilterToMostRecentSetsAmount(int _amount) +{ + setValue(_amount, "filterToMostRecentSetsAmount", "interface", "visualDatabaseDisplay"); + emit visualDatabaseDisplayFilterToMostRecentSetsAmountChanged(_amount); +} diff --git a/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.h b/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.h new file mode 100644 index 000000000..fd2a76663 --- /dev/null +++ b/libcockatrice_settings/libcockatrice/settings/visual_deck_storage_settings.h @@ -0,0 +1,71 @@ +#ifndef VISUAL_DECK_STORAGE_SETTINGS_H +#define VISUAL_DECK_STORAGE_SETTINGS_H + +#include "settings_manager.h" + +#include +#include + +class VisualDeckStorageSettings : public SettingsManager, public IVisualDeckStorageSettingsProvider +{ + Q_OBJECT + friend class SettingsCache; + +public: + [[nodiscard]] int getVisualDeckStorageSortingOrder() const override; + [[nodiscard]] bool getVisualDeckStorageShowFolders() const override; + [[nodiscard]] bool getVisualDeckStorageShowTagFilter() const override; + [[nodiscard]] QStringList getVisualDeckStorageDefaultTagsList() const override; + [[nodiscard]] bool getVisualDeckStorageSearchFolderNames() const override; + [[nodiscard]] bool getVisualDeckStorageShowColorIdentity() const override; + [[nodiscard]] bool getVisualDeckStorageShowBannerCardComboBox() const override; + [[nodiscard]] bool getVisualDeckStorageShowTagsOnDeckPreviews() const override; + [[nodiscard]] bool getVisualDeckStorageDrawUnusedColorIdentities() const override; + [[nodiscard]] int getVisualDeckStorageUnusedColorIdentitiesOpacity() const override; + [[nodiscard]] int getVisualDeckStorageTooltipType() const override; + [[nodiscard]] bool getVisualDeckStoragePromptForConversion() const override; + [[nodiscard]] bool getVisualDeckStorageAlwaysConvert() const override; + [[nodiscard]] bool getVisualDeckStorageInGame() const override; + [[nodiscard]] bool getVisualDeckStorageSelectionAnimation() const override; + [[nodiscard]] bool getVisualDatabaseDisplayFilterToMostRecentSetsEnabled() const override; + [[nodiscard]] int getVisualDatabaseDisplayFilterToMostRecentSetsAmount() const override; + + void setVisualDeckStorageSortingOrder(int _sortingOrder); + void setVisualDeckStorageShowFolders(bool value); + void setVisualDeckStorageShowTagFilter(bool _showTags); + void setVisualDeckStorageDefaultTagsList(QStringList _defaultTagsList); + void setVisualDeckStorageSearchFolderNames(bool value); + void setVisualDeckStorageShowColorIdentity(bool value); + void setVisualDeckStorageShowBannerCardComboBox(bool _showBannerCardComboBox); + void setVisualDeckStorageShowTagsOnDeckPreviews(bool _showTags); + void setVisualDeckStorageDrawUnusedColorIdentities(bool _draw); + void setVisualDeckStorageUnusedColorIdentitiesOpacity(int _opacity); + void setVisualDeckStorageTooltipType(int value); + void setVisualDeckStoragePromptForConversion(bool _prompt); + void setVisualDeckStorageAlwaysConvert(bool _always); + void setVisualDeckStorageInGame(bool enabled); + void setVisualDeckStorageSelectionAnimation(bool enabled); + void setVisualDatabaseDisplayFilterToMostRecentSetsEnabled(bool _enabled); + void setVisualDatabaseDisplayFilterToMostRecentSetsAmount(int _amount); + +signals: + void visualDeckStorageShowTagFilterChanged(bool _visible); + void visualDeckStorageDefaultTagsListChanged(); + void visualDeckStorageShowColorIdentityChanged(bool _visible); + void visualDeckStorageShowBannerCardComboBoxChanged(bool _visible); + void visualDeckStorageShowTagsOnDeckPreviewsChanged(bool _visible); + void visualDeckStorageDrawUnusedColorIdentitiesChanged(bool _visible); + void visualDeckStorageUnusedColorIdentitiesOpacityChanged(bool value); + void visualDeckStorageInGameChanged(bool enabled); + void visualDeckStorageSelectionAnimationChanged(bool enabled); + void visualDatabaseDisplayFilterToMostRecentSetsEnabledChanged(bool enabled); + void visualDatabaseDisplayFilterToMostRecentSetsAmountChanged(int amount); + +public: + explicit VisualDeckStorageSettings(const QString &settingPath, QObject *parent = nullptr); + +private: + VisualDeckStorageSettings(const VisualDeckStorageSettings & /*other*/); +}; + +#endif // VISUAL_DECK_STORAGE_SETTINGS_H diff --git a/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index 2d34cad31..db23f7951 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -5,29 +5,38 @@ 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 +set(UTILITY_SOURCES + libcockatrice/utility/cryptoutil.cpp libcockatrice/utility/expression.cpp libcockatrice/utility/levenshtein.cpp + libcockatrice/utility/passwordhasher.cpp libcockatrice/utility/server_rate_limiter.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/macros.h libcockatrice/utility/passwordhasher.h + libcockatrice/utility/playmat_params.h libcockatrice/utility/string_limits.h libcockatrice/utility/dice_limits.h libcockatrice/utility/counter_limits.h libcockatrice/utility/clamped_arithmetic.h libcockatrice/utility/zone_names.h libcockatrice/utility/days_years_between.h + libcockatrice/utility/server_rate_limiter.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) diff --git a/libcockatrice_utility/libcockatrice/utility/cryptoutil.cpp b/libcockatrice_utility/libcockatrice/utility/cryptoutil.cpp new file mode 100644 index 000000000..416ef261b --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/cryptoutil.cpp @@ -0,0 +1,25 @@ +#include "cryptoutil.h" + +#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/passwordhasher.cpp b/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp index c40c5f94f..1c22fdcfa 100644 --- a/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp +++ b/libcockatrice_utility/libcockatrice/utility/passwordhasher.cpp @@ -1,7 +1,7 @@ #include "passwordhasher.h" #include -#include +#include QString PasswordHasher::computeHash(const QString &password, const QString &salt) { @@ -21,12 +21,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 +50,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/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/server_rate_limiter.cpp b/libcockatrice_utility/libcockatrice/utility/server_rate_limiter.cpp new file mode 100644 index 000000000..2a1e3b283 --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/server_rate_limiter.cpp @@ -0,0 +1,85 @@ +#include "server_rate_limiter.h" + +bool ServerRateLimiter::isRateLimited(const QString &host, const QDateTime &now) const +{ + auto it = backoffUntil.constFind(host); + return it != backoffUntil.constEnd() && now < it.value(); +} + +int ServerRateLimiter::rounds(const QString &host) const +{ + return retryRounds.value(host, 0); +} + +QDateTime ServerRateLimiter::deadline(const QString &host) const +{ + return backoffUntil.value(host); +} + +QDateTime ServerRateLimiter::earliestDeadline(const QDateTime &now) const +{ + QDateTime earliest; + for (auto it = backoffUntil.cbegin(); it != backoffUntil.cend(); ++it) { + if (now < it.value() && (!earliest.isValid() || it.value() < earliest)) { + earliest = it.value(); + } + } + return earliest; +} + +QDateTime ServerRateLimiter::on429(const QString &host, const QDateTime &now, qint64 retryAfterMs) +{ + QDateTime existing = backoffUntil.value(host); + int round = retryRounds.value(host, 0); + + if (!existing.isValid() || now >= existing) { + if (last429.value(host).isValid() && now >= last429.value(host).addMSecs(RESET_GRACE_MS)) { + round = 0; + } + round = qMin(round + 1, MAX_RETRIES); + retryRounds.insert(host, round); + } + last429.insert(host, now); + + qint64 delay; + if (round >= MAX_RETRIES) { + delay = EXHAUSTED_BACKOFF_MS; + } else { + delay = qMax(MIN_429_BACKOFF_MS, retryAfterMs); + delay = qMin(delay, MAX_BACKOFF_MS); + } + + QDateTime deadline = now.addMSecs(delay); + if (existing.isValid() && existing > deadline) { + deadline = existing; // never shorten an active backoff + } + backoffUntil.insert(host, deadline); + + return deadline; +} + +void ServerRateLimiter::onSuccess(const QString &host) +{ + backoffUntil.remove(host); + retryRounds.remove(host); + last429.remove(host); +} + +void ServerRateLimiter::clearExpired(const QDateTime &now) +{ + auto it = backoffUntil.begin(); + while (it != backoffUntil.end()) { + if (now < it.value()) { + ++it; + continue; + } + + QString host = it.key(); + bool budgetStillFresh = last429.value(host).isValid() && now < last429.value(host).addMSecs(RESET_GRACE_MS); + it = backoffUntil.erase(it); + if (!budgetStillFresh) { + retryRounds.remove(host); + last429.remove(host); + } + } +} diff --git a/libcockatrice_utility/libcockatrice/utility/server_rate_limiter.h b/libcockatrice_utility/libcockatrice/utility/server_rate_limiter.h new file mode 100644 index 000000000..e071b9189 --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/server_rate_limiter.h @@ -0,0 +1,73 @@ +#ifndef SERVER_RATE_LIMITER_H +#define SERVER_RATE_LIMITER_H + +#include +#include +#include + +/** + * @class ServerRateLimiter + * @ingroup Utility + * @brief Tracks per-server backoff state triggered by HTTP 429 responses. + * + * Keeps a monotonic backoff deadline per host and a retry-round budget that + * escalates once per backoff window (a burst of concurrent 429s counts as a + * single round). The budget is refreshed after a long period without 429s so a + * server is not permanently blacklisted after a past overload. + */ +class ServerRateLimiter +{ +public: + static constexpr int MAX_RETRIES = 5; ///< Max 429 rounds per host before the budget is exhausted + static constexpr int MIN_429_BACKOFF_MS = 30000; ///< Minimum wait after a 429 (scryfall documented cool-down) + static constexpr int MAX_BACKOFF_MS = 60000; ///< Cap for a single backoff period + static constexpr int EXHAUSTED_BACKOFF_MS = 60000; ///< Cool-down once the retry budget is exhausted + static constexpr qint64 RESET_GRACE_MS = 300000; ///< Idle time after which the retry budget refreshes + + /** + * @brief Checks whether a host is currently in backoff. + * @param host The host to check + * @param now The current time + * @return True if requests to the host should be paused + */ + [[nodiscard]] bool isRateLimited(const QString &host, const QDateTime &now) const; + + /** @return The number of consecutive 429 rounds recorded for the host. */ + [[nodiscard]] int rounds(const QString &host) const; + + /** @return The current backoff deadline for the host, or an invalid QDateTime. */ + [[nodiscard]] QDateTime deadline(const QString &host) const; + + /** @return The earliest active backoff deadline across all hosts, or an invalid QDateTime. */ + [[nodiscard]] QDateTime earliestDeadline(const QDateTime &now) const; + + /** + * @brief Registers a 429 response for the given host. + * @param host The host that returned 429 + * @param now The time the response was received + * @param retryAfterMs A Retry-After hint in milliseconds, or 0 if absent + * @return The new backoff deadline for the host + * + * The round counter only escalates once the previous backoff window has + * fully passed, so concurrent 429s from a burst do not consume the whole + * budget. The deadline is extended monotonically and never shortened. + */ + QDateTime on429(const QString &host, const QDateTime &now, qint64 retryAfterMs = 0); + + /** @brief Clears all penalty state for a host after a successful request. */ + void onSuccess(const QString &host); + + /** + * @brief Removes expired backoffs, refreshing the round budget of hosts + * that have been idle past the reset grace period. + * @param now The current time + */ + void clearExpired(const QDateTime &now); + +private: + QMap backoffUntil; ///< When each host may be contacted again + QMap retryRounds; ///< Consecutive 429 rounds per host + QMap last429; ///< When each host was last rate limited +}; + +#endif // SERVER_RATE_LIMITER_H 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/warning_categories.cpp b/libcockatrice_utility/libcockatrice/utility/warning_categories.cpp new file mode 100644 index 000000000..b5ca563ff --- /dev/null +++ b/libcockatrice_utility/libcockatrice/utility/warning_categories.cpp @@ -0,0 +1,24 @@ +#include "warning_categories.h" + +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 ce7b76b4c..5b61439c3 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -23,11 +23,12 @@ 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 @@ -64,11 +65,7 @@ set(oracle_RESOURCES oracle.qrc) # ------------------------ # Qt resources # ------------------------ -if(Qt6_FOUND) - qt6_add_resources(oracle_RESOURCES_RCC ${oracle_RESOURCES}) -elseif(Qt5_FOUND) - qt5_add_resources(oracle_RESOURCES_RCC ${oracle_RESOURCES}) -endif() +qt6_add_resources(oracle_RESOURCES_RCC ${oracle_RESOURCES}) # ------------------------ # Include directories @@ -106,37 +103,18 @@ set(ORACLE_MAC_QM_INSTALL_DIR "oracle.app/Contents/Resources/translations") set(ORACLE_UNIX_QM_INSTALL_DIR "share/oracle/translations") set(ORACLE_WIN32_QM_INSTALL_DIR "translations") -if(Qt6_FOUND) - # Qt6 Translations are linked after the executable is created in manual mode - qt6_add_executable( - oracle - WIN32 - MACOSX_BUNDLE - ${oracle_SOURCES} - ${oracle_RESOURCES_RCC} - ${oracle_MOC_SRCS} - MANUAL_FINALIZATION - ) -elseif(Qt5_FOUND) - # Qt5 Translations need to be linked at executable creation time - if(Qt5LinguistTools_FOUND) - if(UPDATE_TRANSLATIONS) - qt5_create_translation(oracle_QM ${translate_SRCS} ${oracle_TS}) - else() - qt5_add_translation(oracle_QM ${oracle_TS}) - endif() - endif() - add_executable(oracle WIN32 MACOSX_BUNDLE ${oracle_MOC_SRCS} ${oracle_QM} ${oracle_RESOURCES_RCC} ${oracle_SOURCES}) - if(UNIX) - if(APPLE) - install(FILES ${oracle_QM} DESTINATION ${ORACLE_MAC_QM_INSTALL_DIR}) - else() - install(FILES ${oracle_QM} DESTINATION ${ORACLE_UNIX_QM_INSTALL_DIR}) - endif() - elseif(WIN32) - install(FILES ${oracle_QM} DESTINATION ${ORACLE_WIN32_QM_INSTALL_DIR}) - endif() -endif() +# Qt6 Translations are linked after the executable is created in manual mode +qt6_add_executable( + oracle + WIN32 + MACOSX_BUNDLE + ${oracle_SOURCES} + ${oracle_RESOURCES_RCC} + ${oracle_MOC_SRCS} + MANUAL_FINALIZATION +) + +target_precompile_headers(oracle PRIVATE "${CMAKE_SOURCE_DIR}/cmake/pch/qtwidgets_pch.h") # ------------------------ # Link libraries @@ -286,7 +264,7 @@ endif() # ------------------------ # Qt translations # ------------------------ -if(Qt6_FOUND AND Qt6LinguistTools_FOUND) +if(Qt6LinguistTools_FOUND) #Qt6 Translations happen after the executable is built up if(UPDATE_TRANSLATIONS) qt6_add_translations( @@ -313,6 +291,4 @@ if(Qt6_FOUND AND Qt6LinguistTools_FOUND) endif() endif() -if(Qt6_FOUND) - qt6_finalize_target(oracle) -endif() +qt6_finalize_target(oracle) diff --git a/oracle/src/main.cpp b/oracle/src/main.cpp index 5def0c887..0f962f9be 100644 --- a/oracle/src/main.cpp +++ b/oracle/src/main.cpp @@ -10,6 +10,7 @@ #include #include #include +#include QTranslator *translator, *qtTranslator; ThemeManager *themeManager; @@ -21,14 +22,10 @@ bool isBackgrounded; void installNewTranslator() { - QString lang = SettingsCache::instance().getLang(); + QString lang = SettingsCache::instance().personal().getLang(); QString qtNameHint = "qt_" + lang; -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) QString qtTranslationPath = QLibraryInfo::path(QLibraryInfo::TranslationsPath); -#else - QString qtTranslationPath = QLibraryInfo::location(QLibraryInfo::TranslationsPath); -#endif bool qtTranslationLoaded = qtTranslator->load(qtNameHint, qtTranslationPath); if (!qtTranslationLoaded) { @@ -53,8 +50,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 @@ -86,7 +83,7 @@ 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 - QGuiApplication::setDesktopFileName("oracle"); + QGuiApplication::setDesktopFileName("Oracle"); wizard.show(); diff --git a/oracle/src/oracleimporter.cpp b/oracle/src/oracleimporter.cpp index bace63508..88b522197 100644 --- a/oracle/src/oracleimporter.cpp +++ b/oracle/src/oracleimporter.cpp @@ -3,12 +3,16 @@ #include "libcockatrice/interfaces/noop_card_preference_provider.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 @@ -18,9 +22,10 @@ static const QList kSingletonCounts = {{1, "legal"}, {0, "banned"} SplitCardPart::SplitCardPart(const QString &_name, const QString &_text, - const QVariantHash &_properties, - const PrintingInfo &_printingInfo) - : name(_name), text(_text), properties(_properties), printingInfo(_printingInfo) + const QHash &_properties, + const PrintingInfo &_printingInfo, + const QString &_localizedText) + : name(_name), text(_text), localizedText(_localizedText), properties(_properties), printingInfo(_printingInfo) { } @@ -30,6 +35,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 +83,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 +121,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 +138,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,29 +186,26 @@ 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, const QString &text, bool isToken, - QVariantHash properties, + QHash properties, const QList &relatedCards, const PrintingInfo &printingInfo) { // 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); } @@ -152,7 +213,7 @@ CardInfoPtr OracleImporter::addCard(QString name, } // Remove {} around mana costs, except if it's split cost - QString manacost = properties.value("manacost").toString(); + QString manacost = properties.value("manacost"); if (!manacost.isEmpty()) { QStringList symbols = manacost.split("}"); QString formattedCardCost; @@ -169,12 +230,12 @@ CardInfoPtr OracleImporter::addCard(QString name, } // fix colors - QString allColors = properties.value("colors").toString(); + QString allColors = properties.value("colors"); if (allColors.size() > 1) { sortAndReduceColors(allColors); properties.insert("colors", allColors); } - QString allColorIdent = properties.value("coloridentity").toString(); + QString allColorIdent = properties.value("coloridentity"); if (allColorIdent.size() > 1) { sortAndReduceColors(allColorIdent); properties.insert("coloridentity", allColorIdent); @@ -182,16 +243,16 @@ CardInfoPtr OracleImporter::addCard(QString name, // DETECT CARD POSITIONING INFO - bool landscapeOrientation = properties.value("maintype").toString() == "Battle" || - properties.value("layout").toString() == "split" || - properties.value("layout").toString() == "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; // table row int tableRow = 1; - QString mainCardType = properties.value("maintype").toString(); + QString mainCardType = properties.value("maintype"); if (mainCardType == "Land") { tableRow = 0; } else if (mainCardType == "Sorcery" || mainCardType == "Instant") { @@ -201,11 +262,11 @@ CardInfoPtr OracleImporter::addCard(QString name, } // card side - QString side = properties.value("side").toString() == "b" ? "back" : "front"; + QString side = properties.value("side") == "b" ? "back" : "front"; properties.insert("side", side); // upsideDown (flip cards) - QString layout = properties.value("layout").toString(); + QString layout = properties.value("layout"); bool upsideDown = layout == "flip" && side == "back"; // insert the card and its properties @@ -223,12 +284,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 and booleans to text, while QJsonValue::toString() + // returns a null string for them — some MTGJSON fields (manaValue, + // convertedManaCost, isOnlineOnly, isRebalanced) 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,15 +397,18 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList }; // mtgjson name => xml name - static const QMap setInfoProperties{ - {"number", "num"}, {"rarity", "rarity"}, {"isOnlineOnly", "isOnlineOnly"}, {"isRebalanced", "isRebalanced"}}; + static const QMap setInfoProperties{{"number", "num"}, + {"rarity", "rarity"}, + {"isOnlineOnly", "isOnlineOnly"}, + {"isRebalanced", "isRebalanced"}, + {"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; @@ -254,16 +416,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") { @@ -271,52 +433,49 @@ 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; } // card properties - QVariantHash 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 - PrintingInfo printingInfo = PrintingInfo(currentSet); + 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()) { - printingInfo.setProperty(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()) { - printingInfo.setProperty("flavorName", flavorName); + 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()) { - printingInfo.setProperty(xmlPropertyName, propertyValue); + printingProps.insert(i.value(), propertyValue); } } + PrintingInfo printingInfo(currentSet, LazyPropertiesHash(printingProps)); + QString numComponent; const QString numProperty = printingInfo.getProperty("num"); const QChar lastChar = numProperty.isEmpty() ? QChar() : numProperty.back(); @@ -327,21 +486,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 { @@ -350,39 +514,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; @@ -402,20 +574,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++; } } @@ -423,12 +598,13 @@ 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; - QVariantHash properties; + QString localizedText; + bool localizedTextComplete = true; + QHash properties; PrintingInfo printingInfo; for (const SplitCardPart &tmp : splitCardParts) { @@ -437,22 +613,44 @@ 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(); } else { - const QVariantHash &tmpProps = tmp.getProperties(); + const QHash &tmpProps = tmp.getProperties(); for (auto i = tmpProps.cbegin(), end = tmpProps.cend(); i != end; ++i) { QString prop = i.key(); - QString originalPropertyValue = properties.value(prop).toString(); - QString thisCardPropertyValue = i.value().toString(); + QString originalPropertyValue = properties.value(prop); + QString thisCardPropertyValue = i.value(); 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); @@ -461,20 +659,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); @@ -487,7 +697,6 @@ FormatRulesNameMap OracleImporter::createDefaultMagicFormats() ExceptionRule mayContainAnyNumber; mayContainAnyNumber.conditions.append(anyNumberAllowed); - // Map to store default rules FormatRulesNameMap defaultFormatRulesNameMap; // ----------------- Helper lambda to create format ----------------- @@ -533,10 +742,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"); @@ -545,20 +773,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 @@ -572,9 +841,18 @@ bool OracleImporter::saveToFile(const QString &fileName, const QString &sourceUr 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 5bf352594..c8d3a19bf 100644 --- a/oracle/src/oracleimporter.h +++ b/oracle/src/oracleimporter.h @@ -1,6 +1,12 @@ #ifndef ORACLEIMPORTER_H #define ORACLEIMPORTER_H +#include "raw_json_scanner.h" + +#include +#include +#include +#include #include #include #include @@ -44,10 +50,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 +66,6 @@ public: { return longName; } - const QList &getCards() const - { - return cards; - } const QString &getSetType() const { return setType; @@ -74,16 +78,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; @@ -95,8 +106,9 @@ class SplitCardPart public: SplitCardPart(const QString &_name, const QString &_text, - const QVariantHash &_properties, - const PrintingInfo &_printingInfo); + const QHash &_properties, + const PrintingInfo &_printingInfo, + const QString &_localizedText = QString()); inline const QString &getName() const { return name; @@ -105,7 +117,14 @@ public: { return text; } - inline const QVariantHash &getProperties() const + /** + * @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 +136,18 @@ public: private: QString name; QString text; - QVariantHash properties; + 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 +166,130 @@ 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. A background run routes that signal to stdout (for the + * hosting Cockatrice client to parse); 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, - QVariantHash properties, + 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 an interactive wizard shows on its progress bar + * and a background run routes to stdout for the hosting client. 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 +298,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 2edb9e561..9ce0d7b51 100644 --- a/oracle/src/oraclewizard.cpp +++ b/oracle/src/oraclewizard.cpp @@ -15,6 +15,8 @@ #include #include #include +#include +#include OracleWizard::OracleWizard(QWidget *parent) : QWizard(parent) { @@ -33,9 +35,12 @@ OracleWizard::OracleWizard(QWidget *parent) : QWizard(parent) migrateOracleSettings(); } - connect(&SettingsCache::instance(), &SettingsCache::langChanged, this, &OracleWizard::updateLanguage); + 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); @@ -109,6 +114,24 @@ 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::enableButtons() { button(QWizard::NextButton)->setDisabled(false); diff --git a/oracle/src/oraclewizard.h b/oracle/src/oraclewizard.h index 78427175c..9a509ce5e 100644 --- a/oracle/src/oraclewizard.h +++ b/oracle/src/oraclewizard.h @@ -23,6 +23,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 +53,7 @@ public: } bool saveTokensToFile(const QString &fileName); - void runInBackground() - { - backgroundMode = true; - hide(); - currentPage()->initializePage(); - } + void runInBackground(); public: OracleImporter *importer; diff --git a/oracle/src/pages.cpp b/oracle/src/pages.cpp index 7629a291b..df0c3f51d 100644 --- a/oracle/src/pages.cpp +++ b/oracle/src/pages.cpp @@ -12,6 +12,7 @@ #include #include #include +#include #include #include #include @@ -20,14 +21,18 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include +#include +#include #ifdef HAS_LZMA #include "lzma/decompress.h" @@ -52,6 +57,115 @@ #define ALLSETS_URL "https://www.mtgjson.com/api/v5/AllPrintings.json" #endif +/** + * @brief Emits one machine-readable background-run progress line to stdout. + * + * Used only in background mode, so the hosting Cockatrice client can parse these + * lines to drive a determinate progress bar. stderr stays reserved for + * human-readable log output. + */ +static void emitBackgroundProgress(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" @@ -119,7 +233,7 @@ QString IntroPage::languageName(const QString &lang) void IntroPage::languageBoxChanged(int index) { - SettingsCache::instance().setLang(languageBox->itemData(index).toString()); + SettingsCache::instance().personal().setLang(languageBox->itemData(index).toString()); } void IntroPage::retranslateUi() @@ -181,6 +295,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()); @@ -259,7 +378,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); @@ -278,18 +397,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; @@ -338,11 +452,14 @@ void LoadSetsPage::downloadSetsFile(const QUrl &url) void LoadSetsPage::actDownloadProgressSetsFile(qint64 received, qint64 total) { + if (wizard()->backgroundMode) { + emitBackgroundProgress("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() @@ -383,106 +500,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) +{ + emitBackgroundProgress("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(); } } @@ -508,17 +616,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) @@ -526,46 +682,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.")); } } @@ -573,7 +796,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:")); @@ -587,13 +810,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); + emitBackgroundProgress("import", totalSets, totalSets); + } else { + messageLog->append("" + tr("Import finished: %1 cards.").arg(cardCount) + ""); + } } 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); + emitBackgroundProgress("import", setIndex, totalSets); + } else { + messageLog->append(tr("%1: %2 cards imported").arg(setName).arg(cardsImported)); + } } messageLog->verticalScrollBar()->setValue(messageLog->verticalScrollBar()->maximum()); @@ -739,4 +977,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..d0a9fcd43 100644 --- a/oracle/src/pages.h +++ b/oracle/src/pages.h @@ -3,8 +3,10 @@ #include "pagetemplates.h" +#include #include #include +#include #include #include #include @@ -57,19 +59,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 +95,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 +118,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..0ffeabbb3 100644 --- a/oracle/src/pagetemplates.cpp +++ b/oracle/src/pagetemplates.cpp @@ -112,7 +112,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..ccad7ec62 100644 --- a/oracle/src/pagetemplates.h +++ b/oracle/src/pagetemplates.h @@ -20,6 +20,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..ee7c4eb2e 100644 --- a/oracle/src/parsehelpers.cpp +++ b/oracle/src/parsehelpers.cpp @@ -20,7 +20,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 2fffd0f70..000000000 --- a/oracle/src/qt-json/json.cpp +++ /dev/null @@ -1,573 +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"; - } -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) - else if ((data.typeId() == QMetaType::Type::QVariantList) || - (data.typeId() == QMetaType::Type::QStringList)) // variant is a list? -#else - else if ((data.type() == QVariant::List) || (data.type() == QVariant::StringList)) // variant is a list? -#endif - { - 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, ", ") + " ]"; - } -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) - else if ((data.typeId() == QMetaType::Type::QVariantHash)) // variant is a list? -#else - else if (data.type() == QVariant::Hash) // variant is a hash? -#endif - { - 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 += " }"; - } -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) - else if ((data.typeId() == QMetaType::Type::QVariantMap)) // variant is a list? -#else - else if (data.type() == QVariant::Map) // variant is a map? -#endif - { - 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 += " }"; - } -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) - else if ((data.typeId() == QMetaType::Type::QString) || - (data.typeId() == QMetaType::Type::QByteArray)) // variant is a list? -#else - else if ((data.type() == QVariant::String) || (data.type() == QVariant::ByteArray)) // a string or a byte array? -#endif - { - str = sanitizeString(data.toString()).toUtf8(); - } -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) - else if (data.typeId() == QMetaType::Type::Double) -#else - else if (data.type() == QVariant::Double) // double? -#endif - { - str = QByteArray::number(data.toDouble(), 'g', 20); - if (!str.contains(".") && !str.contains("e")) { - str += ".0"; - } - } -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) - else if (data.typeId() == QMetaType::Type::Bool) -#else - else if (data.type() == QVariant::Bool) // boolean value? -#endif - { - str = data.toBool() ? "true" : "false"; - } -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) - else if (data.typeId() == QMetaType::Type::ULongLong) -#else - else if (data.type() == QVariant::ULongLong) // large unsigned number? -#endif - { - 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..8c4633598 --- /dev/null +++ b/oracle/src/raw_json_scanner.cpp @@ -0,0 +1,677 @@ +#include "raw_json_scanner.h" + +#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..04fcc52e4 --- /dev/null +++ b/oracle/src/raw_json_scanner.h @@ -0,0 +1,89 @@ +#ifndef RAW_JSON_SCANNER_H +#define RAW_JSON_SCANNER_H + +#include +#include +#include +#include + +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/servatrice/CMakeLists.txt b/servatrice/CMakeLists.txt index 6e4191beb..68e422d8c 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 @@ -43,11 +45,7 @@ if(APPLE) set(servatrice_SOURCES ${servatrice_SOURCES} ${CMAKE_CURRENT_SOURCE_DIR}/resources/appicon.icns) endif(APPLE) -if(Qt6_FOUND) - qt6_add_resources(servatrice_RESOURCES_RCC ${servatrice_RESOURCES}) -elseif(Qt5_FOUND) - qt5_add_resources(servatrice_RESOURCES_RCC ${servatrice_RESOURCES}) -endif() +qt6_add_resources(servatrice_RESOURCES_RCC ${servatrice_RESOURCES}) set(QT_DONT_USE_QTGUI TRUE) @@ -99,6 +97,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} 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/servatrice.ini.example b/servatrice/servatrice.ini.example index fac743c39..23eca3f1b 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -79,8 +79,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 +375,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. diff --git a/servatrice/servatrice.sql b/servatrice/servatrice.sql index 7f530063c..cfb1ef5d8 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(36); -- 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, @@ -233,6 +237,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/event_loop_watchdog.cpp b/servatrice/src/event_loop_watchdog.cpp new file mode 100644 index 000000000..e50bd4201 --- /dev/null +++ b/servatrice/src/event_loop_watchdog.cpp @@ -0,0 +1,34 @@ +/** + * @file event_loop_watchdog.cpp + * @ingroup Servatrice + */ + +#include "event_loop_watchdog.h" + +#include "servatrice.h" + +#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..b9061ff97 --- /dev/null +++ b/servatrice/src/event_loop_watchdog.h @@ -0,0 +1,50 @@ +/** + * @file event_loop_watchdog.h + * @ingroup Servatrice + */ + +#ifndef EVENT_LOOP_WATCHDOG_H +#define EVENT_LOOP_WATCHDOG_H + +#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/main.cpp b/servatrice/src/main.cpp index 9e7fe38d9..13bf95a82 100644 --- a/servatrice/src/main.cpp +++ b/servatrice/src/main.cpp @@ -33,6 +33,7 @@ #include #include #include +#include #include RNG_Abstract *rng; @@ -169,7 +170,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..99ce390f1 --- /dev/null +++ b/servatrice/src/metrics_registry.cpp @@ -0,0 +1,70 @@ +#include "metrics_registry.h" + +#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..4df1057c2 --- /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..26352ccd7 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -20,6 +20,7 @@ #include "servatrice.h" #include "email_parser.h" +#include "event_loop_watchdog.h" #include "isl_interface.h" #include "main.h" #include "servatrice_connection_pool.h" @@ -38,6 +39,7 @@ #include #include #include +#include #include #include #include @@ -63,6 +65,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 +89,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 +133,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 +159,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 +228,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; @@ -470,9 +479,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 +569,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; @@ -673,8 +745,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 +774,7 @@ void Servatrice::scheduleShutdown(const QString &reason, int minutes) connect(shutdownTimer, SIGNAL(timeout()), this, SLOT(shutdownTimeout())); shutdownTimer->start(60000); } + shutdownStateMutex.unlock(); shutdownTimeout(); } @@ -702,6 +794,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 +803,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); diff --git a/servatrice/src/servatrice.h b/servatrice/src/servatrice.h index 62fb382cb..8d964a52b 100644 --- a/servatrice/src/servatrice.h +++ b/servatrice/src/servatrice.h @@ -20,6 +20,9 @@ #ifndef SERVATRICE_H #define SERVATRICE_H +#include "metrics_registry.h" + +#include #include #include #include @@ -29,6 +32,9 @@ #include #include #include +#include +#include +#include #include #include @@ -158,6 +164,7 @@ private: Servatrice_IslServer *islServer; mutable QMutex loginMessageMutex; QString loginMessage; + mutable QMutex shutdownStateMutex; QString dbPrefix; QMap serverRequiredFeatureList; QString officialWarnings; @@ -166,12 +173,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 +234,8 @@ public: QMutexLocker locker(&loginMessageMutex); return loginMessage; } + SessionEvent *getLoginSessionEvent() const override; + SessionEvent *makeShutdownEvent() const; QString getRequiredFeatures() const override; QString getAuthenticationMethodString() const; QString getDBTypeString() const; @@ -275,11 +295,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_database_interface.cpp b/servatrice/src/servatrice_database_interface.cpp index d5e1f13ef..af6118646 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -12,8 +12,10 @@ #include #include #include +#include #include #include +#include #include inline Q_LOGGING_CATEGORY(DatabaseInterfaceLog, "database_interface"); @@ -85,9 +87,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 +100,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 +389,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 +400,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 +412,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 +690,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(); @@ -1084,11 +1143,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 +1384,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..f0e369449 100644 --- a/servatrice/src/servatrice_database_interface.h +++ b/servatrice/src/servatrice_database_interface.h @@ -6,11 +6,14 @@ #include #include #include +#include +#include +#include #include #include #include -#define DATABASE_SCHEMA_VERSION 35 +#define DATABASE_SCHEMA_VERSION 36 class Servatrice; @@ -29,6 +32,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, @@ -119,6 +123,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 +138,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/serversocketinterface.cpp b/servatrice/src/serversocketinterface.cpp index 6ceebfca9..aeffd7081 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -30,6 +30,7 @@ #include #include +#include #include #include #include @@ -39,20 +40,33 @@ #include #include #include +#include #include #include +#include #include #include #include #include #include #include +#include #include #include +#include #include #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -70,20 +84,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 #include @@ -164,6 +195,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) @@ -223,6 +325,14 @@ Response::ResponseCode AbstractServerSocketInterface::processExtendedSessionComm 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 +353,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 +377,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 +406,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; } @@ -325,6 +477,10 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAddToList(const Command Event_AddToList event; event.set_list_name(cmd.list()); event.mutable_user_info()->CopyFrom(databaseInterface->getUserData(user)); + // The buddy/ignore list entry is only used to display the user's basic + // profile: never leak the target's email address or client id. + event.mutable_user_info()->clear_email(); + event.mutable_user_info()->clear_clientid(); rc.enqueuePreResponseItem(ServerMessage::SESSION_EVENT, prepareSessionEvent(event)); return Response::RespOk; @@ -892,6 +1048,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("-"); @@ -956,12 +1116,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()); @@ -976,11 +1137,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(); @@ -990,7 +1161,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; @@ -1059,9 +1234,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()); @@ -1245,6 +1421,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) { @@ -1777,6 +3030,8 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAccountPassword(const C return Response::RespWrongPassword; } + databaseInterface->setForcePasswordChange(userName, false); + return Response::RespOk; } @@ -1913,6 +3168,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; } @@ -1981,6 +3237,100 @@ 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; + } + + static const QStringList validCategories = {"cheating", "bug_abuse", "verbal_abuse", "other"}; + if (!validCategories.contains(category.toLower())) { + 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. @@ -2049,7 +3399,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); @@ -2091,6 +3441,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; } diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index 0d66ae78f..b464e6a9b 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -24,6 +24,17 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include class Servatrice; @@ -50,6 +61,7 @@ class Command_BanFromServer; class Command_UpdateServerMessage; class Command_ShutdownServer; class Command_ReloadConfig; +class Command_ReplayDownloadByGameId; class Command_AccountEdit; class Command_AccountImage; @@ -67,8 +79,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; @@ -102,13 +115,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 +141,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 +171,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 +195,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/smtp/qxtsmtp.cpp b/servatrice/src/smtp/qxtsmtp.cpp index 6326b101d..c81e0955c 100644 --- a/servatrice/src/smtp/qxtsmtp.cpp +++ b/servatrice/src/smtp/qxtsmtp.cpp @@ -54,13 +54,8 @@ QxtSmtp::QxtSmtp(QObject *parent) : QObject(parent) // QObject::connect(socket(), SIGNAL(encrypted()), &qxt_d(), SLOT(ehlo())); QObject::connect(socket(), SIGNAL(connected()), this, SIGNAL(connected())); QObject::connect(socket(), SIGNAL(disconnected()), this, SIGNAL(disconnected())); -#if (QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)) QObject::connect(socket(), SIGNAL(errorOccurred(QAbstractSocket::SocketError)), &qxt_d(), SLOT(socketError(QAbstractSocket::SocketError))); -#else - QObject::connect(socket(), SIGNAL(error(QAbstractSocket::SocketError)), &qxt_d(), - SLOT(socketError(QAbstractSocket::SocketError))); -#endif QObject::connect(this, SIGNAL(authenticated()), &qxt_d(), SLOT(sendNext())); QObject::connect(socket(), SIGNAL(readyRead()), &qxt_d(), SLOT(socketRead())); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 94a7327d4..3ee10a151 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -7,21 +7,50 @@ 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 playmat_resolver_test COMMAND playmat_resolver_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 server_developer_role_test COMMAND server_developer_role_test) +add_test(NAME server_game_join_test COMMAND server_game_join_test) +add_test(NAME warning_categories_test COMMAND warning_categories_test) +add_test(NAME lag_monitor_test COMMAND lag_monitor_test) +add_test(NAME latency_tracker_test COMMAND latency_tracker_test) +add_test(NAME metrics_registry_test COMMAND metrics_registry_test) +add_test(NAME loader_local_matching_test COMMAND loader_local_matching_test) add_test(NAME deck_hash_performance_test COMMAND deck_hash_performance_test) set_tests_properties(dummy_test PROPERTIES TIMEOUT 5) -set_tests_properties(deck_hash_performance_test PROPERTIES TIMEOUT 5) +set_tests_properties(deck_hash_performance_test PROPERTIES TIMEOUT 15) 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(playmat_resolver_test playmat_resolver_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) +add_executable(server_developer_role_test server_developer_role_test.cpp) +add_executable(server_game_join_test server_game_join_test.cpp) +add_executable(warning_categories_test warning_categories_test.cpp) +add_executable(lag_monitor_test ${CMAKE_SOURCE_DIR}/cockatrice/src/client/lag_monitor.cpp lag_monitor_test.cpp) +target_include_directories(lag_monitor_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src) +add_executable(latency_tracker_test latency_tracker_test.cpp) +add_executable(metrics_registry_test ../servatrice/src/metrics_registry.cpp metrics_registry_test.cpp) +add_executable( + loader_local_matching_test + ${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 +) +target_include_directories(loader_local_matching_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src) # Find GTest find_package(GTest) @@ -54,9 +83,18 @@ if(NOT GTEST_FOUND) add_dependencies(clamped_arithmetic_test gtest) add_dependencies(test_age_formatting gtest) add_dependencies(password_hash_test gtest) + add_dependencies(playmat_resolver_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) + add_dependencies(server_developer_role_test gtest) + add_dependencies(server_game_join_test gtest) + add_dependencies(warning_categories_test gtest) + add_dependencies(lag_monitor_test gtest) + add_dependencies(latency_tracker_test gtest) + add_dependencies(metrics_registry_test gtest) + add_dependencies(loader_local_matching_test gtest) endif() include_directories(${GTEST_INCLUDE_DIRS}) @@ -71,6 +109,10 @@ target_link_libraries( target_link_libraries( password_hash_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} ) +target_link_libraries( + playmat_resolver_test libcockatrice_deck_list libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} + ${TEST_QT_MODULES} +) target_link_libraries( deck_hash_performance_test libcockatrice_deck_list libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} @@ -81,9 +123,35 @@ target_link_libraries( target_link_libraries( server_counter_test libcockatrice_network Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} ) +target_link_libraries( + server_rate_limiter_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} +) +target_link_libraries( + server_developer_role_test libcockatrice_network libcockatrice_rng Threads::Threads ${GTEST_BOTH_LIBRARIES} + ${TEST_QT_MODULES} +) +target_link_libraries( + server_game_join_test libcockatrice_network_server_remote libcockatrice_rng Threads::Threads ${GTEST_BOTH_LIBRARIES} + ${TEST_QT_MODULES} +) +target_link_libraries( + warning_categories_test libcockatrice_utility Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} +) +target_link_libraries(lag_monitor_test Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}) +target_link_libraries( + latency_tracker_test libcockatrice_network Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} +) +target_include_directories(metrics_registry_test PRIVATE ${CMAKE_SOURCE_DIR}/servatrice/src) +target_link_libraries(metrics_registry_test ${TEST_QT_MODULES} Threads::Threads ${GTEST_BOTH_LIBRARIES}) +target_link_libraries( + loader_local_matching_test libcockatrice_settings Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} +) 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) diff --git a/tests/card_zone_algorithms/card_zone_algorithms_test.cpp b/tests/card_zone_algorithms/card_zone_algorithms_test.cpp index cc098cae9..8fab42566 100644 --- a/tests/card_zone_algorithms/card_zone_algorithms_test.cpp +++ b/tests/card_zone_algorithms/card_zone_algorithms_test.cpp @@ -134,6 +134,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 987e23cd5..811cebc1d 100644 --- a/tests/carddatabase/CMakeLists.txt +++ b/tests/carddatabase/CMakeLists.txt @@ -21,6 +21,24 @@ target_link_libraries( 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) diff --git a/tests/carddatabase/carddatabase_test.cpp b/tests/carddatabase/carddatabase_test.cpp index 3fa0e3834..392f6395a 100644 --- a/tests/carddatabase/carddatabase_test.cpp +++ b/tests/carddatabase/carddatabase_test.cpp @@ -2,9 +2,14 @@ #include "test_card_database_path_provider.h" #include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include #include #include - namespace { @@ -33,6 +38,49 @@ 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"); + NoopCardPreferenceProvider prefProvider; + CockatriceXml4Parser writer(&prefProvider, &controller); + ASSERT_TRUE(writer.saveToFile({}, sets, cards, fileName)); + + CardDatabaseData data; + CockatriceXml4Parser parser(&prefProvider, &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."); +} } // namespace int main(int argc, char **argv) diff --git a/tests/carddatabase/load_benchmark.cpp b/tests/carddatabase/load_benchmark.cpp new file mode 100644 index 000000000..8e9e80e4d --- /dev/null +++ b/tests/carddatabase/load_benchmark.cpp @@ -0,0 +1,235 @@ +/* + * Standalone benchmark for card database loading. + * + * Measures wall-clock cost and model churn: + * - Cold load (XML -> write cache) + * - Warm load (read cache) + * + * Run: + * load_benchmark [--carddb PATH] [--tokens PATH] [--spoilers PATH] [--custom DIR] + * + * All arguments are optional; they default to the XDG Cockatrice data location + * (~/.local/share/Cockatrice/Cockatrice/...). + */ + +#include "mocks.h" + +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static QString defaultXdgPath(const QString &file) +{ + QString base = QDir::homePath() + "/.local/share/Cockatrice/Cockatrice/"; + return base + file; +} + +class CliCardDatabasePathProvider : public ICardDatabasePathProvider +{ +public: + QString cardDb; + QString tokens; + QString spoilers; + QString custom; + + QString getCardDatabasePath() const override + { + return cardDb; + } + QString getTokenDatabasePath() const override + { + return tokens; + } + QString getSpoilerCardDatabasePath() const override + { + return spoilers; + } + QString getCustomCardDatabasePath() const override + { + return custom; + } +}; + +namespace +{ +struct ChurnCounters +{ + qint64 cardAddedEmissions = 0; + qint64 cardRemovedEmissions = 0; + qint64 modelRowsInserted = 0; + qint64 modelRowsRemoved = 0; + qint64 loadingFinishedSignals = 0; + qint64 databaseResetSignals = 0; + qint64 modelResets = 0; +}; + +struct RunResult +{ + qint64 elapsedMs = 0; + qint64 cardCount = 0; + qint64 setCount = 0; + LoadStatus status = NotLoaded; + ChurnCounters churn; + qint64 propsQueried = 0; + qint64 propTotal = 0; +}; + +void deleteCache(const QString &cardDbPath) +{ + const QString cachePath = cardDbPath + ".cache"; + if (QFile::exists(cachePath)) { + QFile::remove(cachePath); + } +} + +RunResult runLoad(CardDatabase *db, ChurnCounters &counters) +{ + const ChurnCounters before = counters; + + QElapsedTimer timer; + timer.start(); + + db->loadCardDatabases(); + + RunResult result; + result.elapsedMs = timer.elapsed(); + result.cardCount = db->getCardList().size(); + result.setCount = db->getSetList().size(); + result.status = db->getLoadStatus(); + + result.churn.cardAddedEmissions = counters.cardAddedEmissions - before.cardAddedEmissions; + result.churn.cardRemovedEmissions = counters.cardRemovedEmissions - before.cardRemovedEmissions; + result.churn.modelRowsInserted = counters.modelRowsInserted - before.modelRowsInserted; + result.churn.modelRowsRemoved = counters.modelRowsRemoved - before.modelRowsRemoved; + result.churn.loadingFinishedSignals = counters.loadingFinishedSignals - before.loadingFinishedSignals; + result.churn.databaseResetSignals = counters.databaseResetSignals - before.databaseResetSignals; + result.churn.modelResets = counters.modelResets - before.modelResets; + + for (const CardInfoPtr &card : db->getCardList()) { + result.propTotal += card->getPropertiesHash().size(); + if (!card->getProperty("colors").isEmpty()) { + ++result.propsQueried; + } + } + + return result; +} + +void printResult(const QString &label, const RunResult &r) +{ + qInfo() << "----------------------------------------"; + qInfo() << label; + qInfo() << " Load wall-clock time :" << r.elapsedMs << "ms"; + qInfo() << " Cards loaded :" << r.cardCount; + qInfo() << " Sets loaded :" << r.setCount; + qInfo() << " Load status :" << r.status; + qInfo() << " Properties queried :" << r.propsQueried << "/" << r.cardCount + << " cards, total props:" << r.propTotal; + qInfo() << " CHURN during load:"; + qInfo() << " cardAdded emissions :" << r.churn.cardAddedEmissions; + qInfo() << " cardRemoved emissions :" << r.churn.cardRemovedEmissions; + qInfo() << " model rowsInserted :" << r.churn.modelRowsInserted; + qInfo() << " model rowsRemoved :" << r.churn.modelRowsRemoved; + qInfo() << " loadingFinished signals :" << r.churn.loadingFinishedSignals; + qInfo() << " databaseReset signals :" << r.churn.databaseResetSignals; + qInfo() << " model resets (batch) :" << r.churn.modelResets; +} +} // namespace + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + + QStringList args = app.arguments(); + CliCardDatabasePathProvider pathProvider; + pathProvider.cardDb = defaultXdgPath("cards.xml"); + pathProvider.tokens = defaultXdgPath("tokens.xml"); + pathProvider.spoilers = defaultXdgPath("spoiler.xml"); + pathProvider.custom = defaultXdgPath("customsets/"); + + for (int i = 1; i < args.size(); ++i) { + const QString &a = args.at(i); + if (a == "--carddb" && i + 1 < args.size()) { + pathProvider.cardDb = args.at(++i); + } else if (a == "--tokens" && i + 1 < args.size()) { + pathProvider.tokens = args.at(++i); + } else if (a == "--spoilers" && i + 1 < args.size()) { + pathProvider.spoilers = args.at(++i); + } else if (a == "--custom" && i + 1 < args.size()) { + pathProvider.custom = args.at(++i); + } else if (a == "--help" || a == "-h") { + qInfo() << "Usage: load_benchmark [--carddb PATH] [--tokens PATH] [--spoilers PATH] [--custom DIR]"; + return 0; + } + } + + QLoggingCategory::setFilterRules("card_database.loading=true\n" + "card_database.loading.success=true\n"); + + qInfo() << "=== Card Database Load Benchmark ==="; + qInfo() << "carddb :" << pathProvider.cardDb; + qInfo() << "tokens :" << pathProvider.tokens; + qInfo() << "spoilers :" << pathProvider.spoilers; + qInfo() << "custom :" << pathProvider.custom; + qInfo() << ""; + + ChurnCounters counters; + + auto runScenario = [&](bool deleteCacheBefore) -> RunResult { + if (deleteCacheBefore) { + deleteCache(pathProvider.cardDb); + } + + // Fresh database each scenario so signals don't accumulate across scenarios + // for the churn counters, but we keep the same counters object to get a + // running total if desired. + CardDatabase db(nullptr, new NoopCardPreferenceProvider(), &pathProvider, new NoopCardSetPriorityController()); + + // Re-wire churn counters for this db instance + QObject::connect(&db, &CardDatabase::cardAdded, &app, + [&counters](const CardInfoPtr &) { ++counters.cardAddedEmissions; }); + QObject::connect(&db, &CardDatabase::cardRemoved, &app, + [&counters](CardInfoPtr) { ++counters.cardRemovedEmissions; }); + QObject::connect(&db, &CardDatabase::cardDatabaseLoadingFinished, &app, + [&counters]() { ++counters.loadingFinishedSignals; }); + QObject::connect(&db, &CardDatabase::cardDatabaseReset, &app, + [&counters]() { ++counters.databaseResetSignals; }); + + CardDatabaseModel model(&db, false); + QObject::connect(&model, &QAbstractItemModel::rowsInserted, &app, + [&counters](const QModelIndex &, int, int) { ++counters.modelRowsInserted; }); + QObject::connect(&model, &QAbstractItemModel::rowsRemoved, &app, + [&counters](const QModelIndex &, int, int) { ++counters.modelRowsRemoved; }); + QObject::connect(&model, &QAbstractItemModel::modelReset, &app, [&counters]() { ++counters.modelResets; }); + + return runLoad(&db, counters); + }; + + // ---- Scenario 1: Cold load (XML -> write cache) ---- + printResult("Scenario 1: Cold load (XML -> cache)", runScenario(true)); + + // ---- Scenario 2: Warm load (read cache) ---- + printResult("Scenario 2: Warm load (read cache)", runScenario(false)); + + // Teardown + deleteCache(pathProvider.cardDb); + + qInfo() << "----------------------------------------"; + qInfo() << "Done."; + + return 0; +} diff --git a/tests/deck_list_model/CMakeLists.txt b/tests/deck_list_model/CMakeLists.txt new file mode 100644 index 000000000..e3096c559 --- /dev/null +++ b/tests/deck_list_model/CMakeLists.txt @@ -0,0 +1,33 @@ +add_executable(deck_list_model_custom_zones_test ${VERSION_STRING_CPP} deck_list_model_custom_zones_test.cpp) + +if(NOT GTEST_FOUND) + add_dependencies(deck_list_model_custom_zones_test gtest) +endif() + +target_link_libraries( + deck_list_model_custom_zones_test + libcockatrice_models + libcockatrice_card + libcockatrice_deck_list + Threads::Threads + ${GTEST_BOTH_LIBRARIES} + ${TEST_QT_MODULES} +) +add_test(NAME deck_list_model_custom_zones_test COMMAND deck_list_model_custom_zones_test) + +add_executable(deck_list_model_zone_integration_test ${VERSION_STRING_CPP} deck_list_model_zone_integration_test.cpp) + +if(NOT GTEST_FOUND) + add_dependencies(deck_list_model_zone_integration_test gtest) +endif() + +target_link_libraries( + deck_list_model_zone_integration_test + libcockatrice_models + libcockatrice_card + libcockatrice_deck_list + Threads::Threads + ${GTEST_BOTH_LIBRARIES} + ${TEST_QT_MODULES} +) +add_test(NAME deck_list_model_zone_integration_test COMMAND deck_list_model_zone_integration_test) 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..7f4ab81e1 --- /dev/null +++ b/tests/deck_list_model/deck_list_model_custom_zones_test.cpp @@ -0,0 +1,276 @@ +/** + * @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 +#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..a4562c95d --- /dev/null +++ b/tests/deck_list_model/deck_list_model_zone_integration_test.cpp @@ -0,0 +1,283 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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..0710be94d --- /dev/null +++ b/tests/deck_list_zones/CMakeLists.txt @@ -0,0 +1,10 @@ +add_executable(deck_list_zones_test deck_list_zones_test.cpp) + +if(NOT GTEST_FOUND) + add_dependencies(deck_list_zones_test gtest) +endif() + +target_link_libraries( + deck_list_zones_test libcockatrice_deck_list Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} +) +add_test(NAME deck_list_zones_test COMMAND deck_list_zones_test) 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..801f226a9 --- /dev/null +++ b/tests/deck_list_zones/deck_list_zones_test.cpp @@ -0,0 +1,392 @@ +/** + * @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 +#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/lag_monitor_test.cpp b/tests/lag_monitor_test.cpp new file mode 100644 index 000000000..9948eeabb --- /dev/null +++ b/tests/lag_monitor_test.cpp @@ -0,0 +1,103 @@ +#include "client/lag_monitor.h" + +#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..68631b7ca --- /dev/null +++ b/tests/latency_tracker_test.cpp @@ -0,0 +1,149 @@ +#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..7ffa7157d --- /dev/null +++ b/tests/loader_local_matching_test.cpp @@ -0,0 +1,187 @@ +#include "client/settings/cache_settings.h" +#include "interface/card_picture_loader/card_picture_loader_local.h" + +#include "gtest/gtest.h" +#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 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"; +} + +} // 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/metrics_registry_test.cpp b/tests/metrics_registry_test.cpp new file mode 100644 index 000000000..1abef58e1 --- /dev/null +++ b/tests/metrics_registry_test.cpp @@ -0,0 +1,83 @@ +#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/reverse_card_move_test.cpp b/tests/movecard_tests/reverse_card_move_test.cpp index 2231a7e3b..64c93078c 100644 --- a/tests/movecard_tests/reverse_card_move_test.cpp +++ b/tests/movecard_tests/reverse_card_move_test.cpp @@ -1,3 +1,4 @@ +#include "game/game_config.h" #include "game/server_abstract_player.h" #include "game/server_card.h" #include "game/server_cardzone.h" @@ -22,7 +23,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..9bc5ee5be 100644 --- a/tests/oracle/CMakeLists.txt +++ b/tests/oracle/CMakeLists.txt @@ -7,3 +7,63 @@ endif() target_link_libraries(parse_cipt_test Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES}) add_test(NAME parse_cipt_test COMMAND parse_cipt_test) + +# Oracle importer unit tests +add_executable( + oracle_importer_test ${VERSION_STRING_CPP} ../../oracle/src/oracleimporter.cpp ../../oracle/src/parsehelpers.cpp + ../../oracle/src/raw_json_scanner.cpp oracle_importer_test.cpp +) + +if(NOT GTEST_FOUND) + add_dependencies(oracle_importer_test gtest) +endif() + +target_link_libraries( + oracle_importer_test libcockatrice_card libcockatrice_interfaces Threads::Threads ${GTEST_BOTH_LIBRARIES} + ${TEST_QT_MODULES} +) + +add_test(NAME oracle_importer_test COMMAND oracle_importer_test) + +# 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. +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) + set(_ORACLE_BENCH_EXTRA_LIBRARIES ${ZLIB_LIBRARIES}) + include_directories(${ZLIB_INCLUDE_DIRS}) +else() + message(STATUS "Oracle tests: zlib not found; zip download benchmark disabled") +endif() + +find_package(LibLZMA) +if(LIBLZMA_FOUND) + add_definitions("-DHAS_LZMA") + list(APPEND _ORACLE_BENCH_EXTRA_SOURCES ../../oracle/src/lzma/decompress.cpp) + 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_executable( + oracle_importer_benchmark_test + ${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} +) + +if(NOT GTEST_FOUND) + add_dependencies(oracle_importer_benchmark_test gtest) +endif() + +target_link_libraries( + oracle_importer_benchmark_test + libcockatrice_card + libcockatrice_interfaces + Threads::Threads + ${GTEST_BOTH_LIBRARIES} + ${TEST_QT_MODULES} + ${_ORACLE_BENCH_EXTRA_LIBRARIES} +) diff --git a/tests/oracle/oracle_importer_benchmark_test.cpp b/tests/oracle/oracle_importer_benchmark_test.cpp new file mode 100644 index 000000000..633c9e40a --- /dev/null +++ b/tests/oracle/oracle_importer_benchmark_test.cpp @@ -0,0 +1,578 @@ +#include "../../oracle/src/oracleimporter.h" + +#include "gtest/gtest.h" +#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 and booleans, not strings. This + // exercises the QVariant coercion in the property reader. + card["convertedManaCost"] = 1.0; + card["manaValue"] = 1.0; + card["isOnlineOnly"] = false; + card["isRebalanced"] = false; + + 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..f66616e37 --- /dev/null +++ b/tests/oracle/oracle_importer_test.cpp @@ -0,0 +1,1128 @@ +#include "../../oracle/src/oracleimporter.h" + +#include "gtest/gtest.h" +#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/password_hash_test.cpp b/tests/password_hash_test.cpp index 38d9b6315..2b8f8bdb7 100644 --- a/tests/password_hash_test.cpp +++ b/tests/password_hash_test.cpp @@ -1,25 +1,9 @@ #include "gtest/gtest.h" -#include -#include +#include #include -RNG_Abstract *rng; - namespace { -class PasswordHashTest : public ::testing::Test -{ -protected: - void SetUp() override - { - rng = new RNG_SFMT; - } - - void TearDown() override - { - delete rng; - } -}; TEST(PasswordHashTest, RegressionTest) { @@ -29,6 +13,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/playmat_resolver_test.cpp b/tests/playmat_resolver_test.cpp new file mode 100644 index 000000000..a931c0647 --- /dev/null +++ b/tests/playmat_resolver_test.cpp @@ -0,0 +1,126 @@ +#include +#include +#include + +namespace +{ + +PlaymatInfo makePlaymatInfo(const QString &name, const QString &providerId = QString()) +{ + PlaymatInfo info; + info.card = {name, providerId}; + return info; +} + +} // namespace + +TEST(PlaymatResolverTest, EmptyChainReturnsEmpty) +{ + DeckList deck; + const PlaymatInfo resolved = resolveEffectivePlaymat(deck, {}, {}, PlaymatFallbackModeFixed, 0); + EXPECT_TRUE(resolved.card.isEmpty()); +} + +TEST(PlaymatResolverTest, OverrideWinsOverDeckAndFallback) +{ + DeckList deck; + deck.setPlaymat({{QStringLiteral("Deck Mat"), QStringLiteral("deck-provider")}, {}}); + + const PlaymatInfo force = makePlaymatInfo(QStringLiteral("Force Mat"), QStringLiteral("force-provider")); + const QList fallback = {makePlaymatInfo(QStringLiteral("Fallback Mat"))}; + + const PlaymatInfo resolved = resolveEffectivePlaymat(deck, force, fallback, PlaymatFallbackModeFixed, 0); + EXPECT_EQ(resolved.card.name, QStringLiteral("Force Mat")); + EXPECT_EQ(resolved.card.providerId, QStringLiteral("force-provider")); +} + +TEST(PlaymatResolverTest, DeckWinsOverFallback) +{ + DeckList deck; + deck.setPlaymat({{QStringLiteral("Deck Mat"), QStringLiteral("deck-provider")}, {0.1, 0.2, 0.3, 1.5}}); + + const QList fallback = {makePlaymatInfo(QStringLiteral("Fallback Mat"))}; + + const PlaymatInfo resolved = resolveEffectivePlaymat(deck, {}, fallback, PlaymatFallbackModeFixed, 0); + EXPECT_EQ(resolved.card.name, QStringLiteral("Deck Mat")); + EXPECT_EQ(resolved.card.providerId, QStringLiteral("deck-provider")); + EXPECT_DOUBLE_EQ(resolved.params.marginPctL, 0.1); + EXPECT_DOUBLE_EQ(resolved.params.verticalOffset, 0.3); +} + +TEST(PlaymatResolverTest, FallbackUsedWhenDeckHasNone) +{ + DeckList deck; + const QList fallback = {makePlaymatInfo(QStringLiteral("Fallback Mat"))}; + + const PlaymatInfo resolved = resolveEffectivePlaymat(deck, {}, fallback, PlaymatFallbackModeFixed, 0); + EXPECT_EQ(resolved.card.name, QStringLiteral("Fallback Mat")); +} + +TEST(PlaymatResolverTest, FixedAlwaysUsesFirst) +{ + DeckList deck; + const QList fallback = {makePlaymatInfo(QStringLiteral("First")), + makePlaymatInfo(QStringLiteral("Second"))}; + + for (int i = 0; i < 5; ++i) { + const PlaymatInfo resolved = resolveEffectivePlaymat(deck, {}, fallback, PlaymatFallbackModeFixed, i); + EXPECT_EQ(resolved.card.name, QStringLiteral("First")); + } +} + +TEST(PlaymatResolverTest, RoundRobinCyclesAndWraps) +{ + DeckList deck; + const QList fallback = {makePlaymatInfo(QStringLiteral("First")), + makePlaymatInfo(QStringLiteral("Second")), + makePlaymatInfo(QStringLiteral("Third"))}; + + const QStringList expected = {QStringLiteral("First"), QStringLiteral("Second"), QStringLiteral("Third"), + QStringLiteral("First"), QStringLiteral("Second"), QStringLiteral("Third")}; + for (int i = 0; i < expected.size(); ++i) { + const PlaymatInfo resolved = resolveEffectivePlaymat(deck, {}, fallback, PlaymatFallbackModeRoundRobin, i); + EXPECT_EQ(resolved.card.name, expected.at(i)); + } +} + +TEST(PlaymatResolverTest, RoundRobinRespectsCursor) +{ + DeckList deck; + const QList fallback = {makePlaymatInfo(QStringLiteral("First")), + makePlaymatInfo(QStringLiteral("Second"))}; + + const PlaymatInfo resolved = resolveEffectivePlaymat(deck, {}, fallback, PlaymatFallbackModeRoundRobin, 5); + EXPECT_EQ(resolved.card.name, QStringLiteral("Second")); // 5 % 2 == 1 +} + +TEST(PlaymatResolverTest, RandomStaysWithinList) +{ + DeckList deck; + const QList fallback = {makePlaymatInfo(QStringLiteral("First")), + makePlaymatInfo(QStringLiteral("Second")), + makePlaymatInfo(QStringLiteral("Third"))}; + + for (int i = 0; i < 50; ++i) { + const PlaymatInfo resolved = resolveEffectivePlaymat(deck, {}, fallback, PlaymatFallbackModeRandom, i); + ASSERT_FALSE(resolved.card.name.isEmpty()); + EXPECT_TRUE(fallback.contains(resolved)); + } +} + +TEST(PlaymatResolverTest, ForceWithEmptyCardIgnoresFallbackParamsButNotFallback) +{ + DeckList deck; + deck.setPlaymat({{QStringLiteral("Deck Mat")}, {}}); + + // An empty force entry must not mask the deck-configured playmat. + const PlaymatInfo emptyForce; + const PlaymatInfo resolved = resolveEffectivePlaymat(deck, emptyForce, {}, PlaymatFallbackModeFixed, 0); + EXPECT_EQ(resolved.card.name, QStringLiteral("Deck Mat")); +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/server_developer_role_test.cpp b/tests/server_developer_role_test.cpp new file mode 100644 index 000000000..127f606a8 --- /dev/null +++ b/tests/server_developer_role_test.cpp @@ -0,0 +1,137 @@ +/** @file server_developer_role_test.cpp + * @brief Tests for the developer staff role authorization and dispatch. + * @ingroup Tests + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +// The server_remote library references the global RNG, which is normally +// defined by the servatrice/client executable main(). Provide a stub so the +// unit test can link against it. +RNG_Abstract *rng = nullptr; + +namespace +{ + +class TestDeveloperHandler : public Server_ProtocolHandler +{ +public: + explicit TestDeveloperHandler(Server *_server) : Server_ProtocolHandler(_server, nullptr) + { + } + + QString getAddress() const override + { + return {}; + } + QString getConnectionType() const override + { + return {}; + } + + // Buffer the last response code sent to the client so tests can assert on + // the outcome of processCommandContainer(). + Response::ResponseCode lastResponseCode = Response::RespNothing; + int dispatchCount = 0; + +protected: + void transmitProtocolItem(const ServerMessage &item) override + { + if (item.message_type() == ServerMessage::RESPONSE) { + lastResponseCode = item.response().response_code(); + } + } + + Response::ResponseCode + processExtendedDeveloperCommand(int cmdType, const DeveloperCommand &, ResponseContainer &) override + { + ++dispatchCount; + // Fail closed for anything not explicitly handled. + if (cmdType != DeveloperCommand::GET_SERVER_STATS) { + return Response::RespFunctionNotAllowed; + } + return Response::RespOk; + } +}; + +class DeveloperRoleTest : public ::testing::Test +{ +protected: + Server server; + TestDeveloperHandler handler{&server}; + + void setUserLevel(uint32_t level) + { + ServerInfo_User user; + user.set_user_level(level); + handler.setUserInfo(user); + } +}; + +TEST_F(DeveloperRoleTest, RejectsWhenNotLoggedIn) +{ + CommandContainer cont; + cont.add_developer_command(); + handler.processCommandContainer(cont); + EXPECT_EQ(handler.lastResponseCode, Response::RespLoginNeeded); + EXPECT_EQ(handler.dispatchCount, 0); +} + +TEST_F(DeveloperRoleTest, RejectsPlainUser) +{ + setUserLevel(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered); + + CommandContainer cont; + cont.add_developer_command(); + handler.processCommandContainer(cont); + EXPECT_EQ(handler.lastResponseCode, Response::RespLoginNeeded); + EXPECT_EQ(handler.dispatchCount, 0); +} + +TEST_F(DeveloperRoleTest, RejectsModeratorThatIsNotDeveloper) +{ + setUserLevel(ServerInfo_User::IsModerator); + + CommandContainer cont; + cont.add_developer_command(); + handler.processCommandContainer(cont); + EXPECT_EQ(handler.lastResponseCode, Response::RespLoginNeeded); +} + +TEST_F(DeveloperRoleTest, DispatchesToDeveloperCommandForDeveloper) +{ + setUserLevel(ServerInfo_User::IsDeveloper); + + CommandContainer cont; + DeveloperCommand *cmd = cont.add_developer_command(); + cmd->MutableExtension(Command_GetServerStats::ext); + handler.processCommandContainer(cont); + EXPECT_EQ(handler.lastResponseCode, Response::RespOk); + EXPECT_EQ(handler.dispatchCount, 1); +} + +TEST_F(DeveloperRoleTest, FailClosedForUnknownDeveloperCommand) +{ + setUserLevel(ServerInfo_User::IsDeveloper); + + CommandContainer cont; + cont.add_developer_command(); // no extension set -> getPbExtension() returns -1 + handler.processCommandContainer(cont); + EXPECT_EQ(handler.lastResponseCode, Response::RespFunctionNotAllowed); + EXPECT_EQ(handler.dispatchCount, 1); +} + +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/server_game_join_test.cpp b/tests/server_game_join_test.cpp new file mode 100644 index 000000000..c84e4e066 --- /dev/null +++ b/tests/server_game_join_test.cpp @@ -0,0 +1,174 @@ +/** @file server_game_join_test.cpp + * @brief Tests for the moderator/judge game-entry restriction override in Server_Game::checkJoin. + * @ingroup Tests + */ + +#include "game/server_game.h" +#include "server.h" +#include "server_database_interface.h" +#include "server_room.h" + +#include +#include +#include + +RNG_Abstract *rng = nullptr; // referenced by the server_remote library + +namespace +{ + +class MockDatabaseInterface : public Server_DatabaseInterface +{ +public: + AuthenticationResult checkUserPassword(Server_ProtocolHandler *, + const QString &, + const QString &, + const QString &, + QString &, + int &, + bool) override + { + return NotLoggedIn; + } + int getNextReplayId() override + { + return 1; + } + int getNextGameId() override + { + return 1; + } + int getActiveUserCount(QString) override + { + return 0; + } + ServerInfo_User getUserData(const QString &, bool) override + { + return ServerInfo_User(); + } +}; + +class FakeServer : public Server +{ +public: + FakeServer() + { + setDatabaseInterface(new MockDatabaseInterface()); + } +}; + +class GameJoinOverrideTest : public ::testing::Test +{ +protected: + FakeServer server; + Server_Room room{0, 0, "", "", "", "", false, "", {}, &server}; + ServerInfo_User creator; + ServerInfo_User plainUser; + ServerInfo_User unregisteredJudge; + ServerInfo_User moderator; + ServerInfo_User judge; + Server_Game *game = nullptr; + + void SetUp() override + { + creator.set_name("creator"); + creator.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered); + plainUser.set_name("plain-user"); + plainUser.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered); + unregisteredJudge.set_name("unregistered-judge"); + unregisteredJudge.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsJudge); + moderator.set_name("moderator"); + moderator.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered | + ServerInfo_User::IsModerator); + judge.set_name("judge"); + judge.set_user_level(ServerInfo_User::IsUser | ServerInfo_User::IsRegistered | ServerInfo_User::IsJudge); + } + + void TearDown() override + { + delete game; + } + + Server_Game *makeGame(bool passwordProtected, bool onlyRegistered, bool onlyBuddies, bool spectatorsAllowed) + { + GameConfig config{.creatorInfo = creator, + .gameId = 1, + .description = QString(), + .password = passwordProtected ? "secret" : QString(), + .maxPlayers = 2, + .gameTypes = QList(), + .onlyBuddies = onlyBuddies, + .onlyRegistered = onlyRegistered, + .spectatorsAllowed = spectatorsAllowed, + .spectatorsNeedPassword = true, + .spectatorsCanTalk = false, + .spectatorsSeeEverything = false, + .startingLifeTotal = 20, + .shareDecklistsOnLoad = false}; + return new Server_Game(config, &room); + } +}; + +TEST_F(GameJoinOverrideTest, StaffBypassPasswordRestriction) +{ + game = makeGame(true, false, false, true); + + // A plain user cannot override the password even with the override flag set. + EXPECT_EQ(game->checkJoin(&plainUser, "wrong", false, true, false), Response::RespWrongPassword); + // Moderators and judges may enter any game regardless of the password. + EXPECT_EQ(game->checkJoin(&moderator, "wrong", false, true, false), Response::RespOk); + EXPECT_EQ(game->checkJoin(&judge, "wrong", false, true, false), Response::RespOk); + // Without the override flag judges are still subject to the password. + EXPECT_EQ(game->checkJoin(&judge, "wrong", false, false, true), Response::RespWrongPassword); + EXPECT_EQ(game->checkJoin(&judge, "secret", false, false, true), Response::RespOk); +} + +TEST_F(GameJoinOverrideTest, StaffBypassRegisteredOnlyRestriction) +{ + game = makeGame(false, true, false, true); + + // Without the override flag the only-registered restriction still applies. + EXPECT_EQ(game->checkJoin(&unregisteredJudge, QString(), false, false, false), Response::RespUserLevelTooLow); + // An unregistered judge may enter when overriding restrictions. + EXPECT_EQ(game->checkJoin(&unregisteredJudge, QString(), false, true, false), Response::RespOk); +} + +TEST_F(GameJoinOverrideTest, StaffBypassBuddiesOnlyRestriction) +{ + game = makeGame(false, false, true, true); + + // A plain user who is not on the creator's buddy list gets rejected. + EXPECT_EQ(game->checkJoin(&plainUser, QString(), false, true, false), Response::RespOnlyBuddies); + // Moderators and judges bypass the buddies-only restriction. + EXPECT_EQ(game->checkJoin(&moderator, QString(), false, true, false), Response::RespOk); + EXPECT_EQ(game->checkJoin(&judge, QString(), false, true, false), Response::RespOk); +} + +TEST_F(GameJoinOverrideTest, StaffBypassSpectatorsNotAllowedRestriction) +{ + game = makeGame(false, false, false, false); + + // A plain user cannot spectate when the game disallows spectators. + EXPECT_EQ(game->checkJoin(&plainUser, QString(), true, false, false), Response::RespSpectatorsNotAllowed); + // Moderators and judges may spectate any game regardless of the password + // and the spectator restriction. + EXPECT_EQ(game->checkJoin(&moderator, "wrong", true, true, false), Response::RespOk); + EXPECT_EQ(game->checkJoin(&judge, "wrong", true, true, false), Response::RespOk); +} + +TEST_F(GameJoinOverrideTest, JudgeOverrideDoesNotGrantJudgeJoinToPlainUser) +{ + game = makeGame(false, false, false, true); + + // joining with join_as_judge still requires the judge flag even when overriding. + EXPECT_EQ(game->checkJoin(&plainUser, QString(), false, true, true), Response::RespUserLevelTooLow); + EXPECT_EQ(game->checkJoin(&judge, QString(), false, true, true), Response::RespOk); +} + +} // 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/server_rate_limiter_test.cpp b/tests/server_rate_limiter_test.cpp new file mode 100644 index 000000000..bc15ca421 --- /dev/null +++ b/tests/server_rate_limiter_test.cpp @@ -0,0 +1,151 @@ +#include "gtest/gtest.h" +#include +#include +#include + +namespace +{ + +const QString HOST = "api.scryfall.com"; + +QDateTime timeAt(qint64 secsFromEpoch) +{ + return QDateTime::fromMSecsSinceEpoch(secsFromEpoch * 1000, QTimeZone::UTC); +} + +TEST(ServerRateLimiterTest, First429SetsThirtySecondBackoff) +{ + ServerRateLimiter limiter; + QDateTime now = timeAt(1000); + + QDateTime deadline = limiter.on429(HOST, now); + + EXPECT_TRUE(limiter.isRateLimited(HOST, now)); + EXPECT_EQ(now.addSecs(30), deadline); + EXPECT_EQ(1, limiter.rounds(HOST)); + EXPECT_FALSE(limiter.isRateLimited(HOST, deadline)); +} + +TEST(ServerRateLimiterTest, RetryAfterHeaderIsHonoredWhenLarger) +{ + ServerRateLimiter limiter; + QDateTime now = timeAt(1000); + + QDateTime deadline = limiter.on429(HOST, now, 45 * 1000); + + EXPECT_EQ(now.addSecs(45), deadline); +} + +TEST(ServerRateLimiterTest, RetryAfterSmallerThanFloorIsIgnored) +{ + ServerRateLimiter limiter; + QDateTime now = timeAt(1000); + + QDateTime deadline = limiter.on429(HOST, now, 5 * 1000); + + EXPECT_EQ(now.addSecs(30), deadline); +} + +TEST(ServerRateLimiterTest, Concurrent429sDoNotEscalateOrShorten) +{ + ServerRateLimiter limiter; + QDateTime now = timeAt(1000); + + QDateTime first = limiter.on429(HOST, now); + limiter.on429(HOST, now.addMSecs(100)); + limiter.on429(HOST, now.addMSecs(200)); + + // The burst counts as a single round, but each 429 slides the deadline forward. + EXPECT_EQ(1, limiter.rounds(HOST)); + EXPECT_EQ(now.addMSecs(30200), limiter.deadline(HOST)); + EXPECT_TRUE(limiter.deadline(HOST) >= first); +} + +TEST(ServerRateLimiterTest, EscalatesAfterWindowPasses) +{ + ServerRateLimiter limiter; + QDateTime now = timeAt(1000); + + limiter.on429(HOST, now); + limiter.on429(HOST, now.addSecs(31)); + + EXPECT_EQ(2, limiter.rounds(HOST)); +} + +TEST(ServerRateLimiterTest, BudgetExhaustionAppliesCooldown) +{ + ServerRateLimiter limiter; + QDateTime now = timeAt(1000); + + QDateTime deadline; + for (int i = 0; i < ServerRateLimiter::MAX_RETRIES; ++i) { + deadline = limiter.on429(HOST, now.addSecs(31 * i)); + } + + EXPECT_EQ(ServerRateLimiter::MAX_RETRIES, limiter.rounds(HOST)); + EXPECT_EQ(now.addSecs(31 * (ServerRateLimiter::MAX_RETRIES - 1) + 60), deadline); + + // A 429 inside the exhausted cooldown does not escalate, but keeps sliding the cooldown forward. + limiter.on429(HOST, now.addSecs(31 * (ServerRateLimiter::MAX_RETRIES - 1) + 30)); + EXPECT_EQ(ServerRateLimiter::MAX_RETRIES, limiter.rounds(HOST)); + EXPECT_EQ(now.addSecs(31 * (ServerRateLimiter::MAX_RETRIES - 1) + 30 + 60), limiter.deadline(HOST)); +} + +TEST(ServerRateLimiterTest, SuccessClearsPenalty) +{ + ServerRateLimiter limiter; + QDateTime now = timeAt(1000); + + limiter.on429(HOST, now); + limiter.onSuccess(HOST); + + EXPECT_EQ(0, limiter.rounds(HOST)); + EXPECT_FALSE(limiter.isRateLimited(HOST, now)); +} + +TEST(ServerRateLimiterTest, ClearExpiredRefreshesStaleBudget) +{ + ServerRateLimiter limiter; + QDateTime now = timeAt(1000); + + limiter.on429(HOST, now); + limiter.clearExpired(now.addSecs(400)); + + EXPECT_EQ(0, limiter.rounds(HOST)); + EXPECT_FALSE(limiter.isRateLimited(HOST, now.addSecs(400))); +} + +TEST(ServerRateLimiterTest, ClearExpiredKeepsRecentBudget) +{ + ServerRateLimiter limiter; + QDateTime now = timeAt(1000); + + for (int i = 0; i < ServerRateLimiter::MAX_RETRIES; ++i) { + limiter.on429(HOST, now.addSecs(31 * i)); + } + limiter.clearExpired(now.addSecs(200)); + + EXPECT_EQ(ServerRateLimiter::MAX_RETRIES, limiter.rounds(HOST)); + EXPECT_FALSE(limiter.isRateLimited(HOST, now.addSecs(200))); +} + +TEST(ServerRateLimiterTest, EarliestDeadlineAcrossHosts) +{ + ServerRateLimiter limiter; + QDateTime now = timeAt(1000); + + limiter.on429("api.scryfall.com", now, 40 * 1000); + limiter.on429("gatherer.wizards.com", now.addSecs(5)); + + EXPECT_EQ(now.addSecs(35), limiter.earliestDeadline(now)); + EXPECT_EQ(now.addSecs(40), limiter.deadline("api.scryfall.com")); + EXPECT_EQ(now.addSecs(40), limiter.earliestDeadline(now.addSecs(36))); +} + +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/settings/CMakeLists.txt b/tests/settings/CMakeLists.txt new file mode 100644 index 000000000..7ee32a645 --- /dev/null +++ b/tests/settings/CMakeLists.txt @@ -0,0 +1,26 @@ +add_executable(settings_manager_test settings_manager_test.cpp) +add_executable(settings_defaults_test settings_defaults_test.cpp) +add_executable(settings_migration_test settings_migration_test.cpp) + +target_link_libraries( + settings_manager_test libcockatrice_settings Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} +) + +target_link_libraries( + settings_defaults_test libcockatrice_settings Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} +) +target_compile_definitions(settings_defaults_test PRIVATE SETTINGS_UNIT_TEST) + +target_link_libraries( + settings_migration_test libcockatrice_settings Threads::Threads ${GTEST_BOTH_LIBRARIES} ${TEST_QT_MODULES} +) + +add_test(NAME settings_manager_test COMMAND settings_manager_test) +add_test(NAME settings_defaults_test COMMAND settings_defaults_test) +add_test(NAME settings_migration_test COMMAND settings_migration_test) + +if(NOT GTEST_FOUND) + add_dependencies(settings_manager_test gtest) + add_dependencies(settings_defaults_test gtest) + add_dependencies(settings_migration_test gtest) +endif() diff --git a/tests/settings/settings_defaults_test.cpp b/tests/settings/settings_defaults_test.cpp new file mode 100644 index 000000000..c8c5ad4c8 --- /dev/null +++ b/tests/settings/settings_defaults_test.cpp @@ -0,0 +1,612 @@ +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +class SettingsDefaultsTest : public ::testing::Test +{ +protected: + QTemporaryDir tempDir; + QString settingsPath; + + void SetUp() override + { + settingsPath = tempDir.path() + "/"; + } +}; + +// --- CacheStorageSettings --- + +TEST_F(SettingsDefaultsTest, CacheStorage_PixmapCacheSize_Default) +{ + CacheStorageSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getPixmapCacheSize(), PIXMAPCACHE_SIZE_DEFAULT); +} + +TEST_F(SettingsDefaultsTest, CacheStorage_PixmapCacheSize_SetAndGet) +{ + CacheStorageSettings s(settingsPath, nullptr); + s.setPixmapCacheSize(1024); + ASSERT_EQ(s.getPixmapCacheSize(), 1024); +} + +TEST_F(SettingsDefaultsTest, CacheStorage_NetworkCacheSizeInMB_Default) +{ + CacheStorageSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getNetworkCacheSizeInMB(), NETWORK_CACHE_SIZE_DEFAULT); +} + +TEST_F(SettingsDefaultsTest, CacheStorage_RedirectCacheTtl_Default) +{ + CacheStorageSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getRedirectCacheTtl(), NETWORK_REDIRECT_CACHE_TTL_DEFAULT); +} + +TEST_F(SettingsDefaultsTest, CacheStorage_CardPictureLoaderCacheMethod_Default) +{ + CacheStorageSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getCardPictureLoaderCacheMethod(), 0); +} + +TEST_F(SettingsDefaultsTest, CacheStorage_LocalCardImageStorageNamingScheme_Default) +{ + CacheStorageSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getLocalCardImageStorageNamingScheme(), LOCAL_CARD_IMAGE_NAMING_SCHEME_DEFAULT); +} + +// --- GameSettings --- + +TEST_F(SettingsDefaultsTest, Game_MaxPlayers_Default) +{ + GameSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getMaxPlayers(), 2); +} + +TEST_F(SettingsDefaultsTest, Game_MaxPlayers_SetAndGet) +{ + GameSettings s(settingsPath, nullptr); + s.setMaxPlayers(4); + ASSERT_EQ(s.getMaxPlayers(), 4); +} + +TEST_F(SettingsDefaultsTest, Game_DefaultStartingLifeTotal_Default) +{ + GameSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getDefaultStartingLifeTotal(), 20); +} + +TEST_F(SettingsDefaultsTest, Game_RememberGameSettings_Default) +{ + GameSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getRememberGameSettings(), true); +} + +TEST_F(SettingsDefaultsTest, Game_LocalGameMaxPlayers_Default) +{ + GameSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getLocalGameMaxPlayers(), 1); +} + +TEST_F(SettingsDefaultsTest, Game_LocalGameStartingLifeTotal_Default) +{ + GameSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getLocalGameStartingLifeTotal(), 20); +} + +TEST_F(SettingsDefaultsTest, Game_LocalGameRememberSettings_Default) +{ + GameSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getLocalGameRememberSettings(), false); +} + +// --- UpdatesSettings --- + +TEST_F(SettingsDefaultsTest, Updates_CheckUpdatesOnStartup_Default) +{ + UpdatesSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getCheckUpdatesOnStartup(), true); +} + +TEST_F(SettingsDefaultsTest, Updates_StartupCardUpdateCheckPromptForUpdate_Default) +{ + UpdatesSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getStartupCardUpdateCheckPromptForUpdate(), true); +} + +TEST_F(SettingsDefaultsTest, Updates_StartupCardUpdateCheckAlwaysUpdate_Default) +{ + UpdatesSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getStartupCardUpdateCheckAlwaysUpdate(), false); +} + +TEST_F(SettingsDefaultsTest, Updates_CardUpdateCheckInterval_Default) +{ + UpdatesSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getCardUpdateCheckInterval(), 7); +} + +TEST_F(SettingsDefaultsTest, Updates_AlwaysEnableNewSets_Default) +{ + UpdatesSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getAlwaysEnableNewSets(), false); +} + +TEST_F(SettingsDefaultsTest, Updates_NotifyAboutUpdates_Default) +{ + UpdatesSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getNotifyAboutUpdates(), true); +} + +TEST_F(SettingsDefaultsTest, Updates_NotifyAboutNewVersion_Default) +{ + UpdatesSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getNotifyAboutNewVersion(), true); +} + +TEST_F(SettingsDefaultsTest, Updates_UpdateReleaseChannelIndex_Default) +{ + UpdatesSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getUpdateReleaseChannelIndex(), 0); +} + +// --- SoundSettings --- + +TEST_F(SettingsDefaultsTest, Sound_SoundEnabled_Default) +{ + SoundSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getSoundEnabled(), false); +} + +TEST_F(SettingsDefaultsTest, Sound_MasterVolume_Default) +{ + SoundSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getMasterVolume(), 100); +} + +TEST_F(SettingsDefaultsTest, Sound_MasterVolume_SetAndGet) +{ + SoundSettings s(settingsPath, nullptr); + s.setMasterVolume(50); + ASSERT_EQ(s.getMasterVolume(), 50); +} + +// --- TabsSettings --- + +TEST_F(SettingsDefaultsTest, Tabs_StartupTab_Default) +{ + TabsSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getStartupTabIndex(), static_cast(StartupTab::StartupTabHome)); +} + +TEST_F(SettingsDefaultsTest, Tabs_StartupTab_SetAndGet) +{ + TabsSettings s(settingsPath, nullptr); + s.setStartupTabIndex(StartupTab::StartupTabServerRoom); + ASSERT_EQ(s.getStartupTabIndex(), static_cast(StartupTab::StartupTabServerRoom)); +} + +TEST_F(SettingsDefaultsTest, Tabs_StartupServer_Default) +{ + TabsSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getStartupServerHost(), QString()); + ASSERT_EQ(s.getStartupServerPort(), QString()); + ASSERT_EQ(s.getStartupRoomName(), QString()); +} + +TEST_F(SettingsDefaultsTest, Tabs_StartupServer_SetAndGet) +{ + TabsSettings s(settingsPath, nullptr); + s.setStartupServerHost("server.cockatrice.us"); + s.setStartupServerPort("4748"); + s.setStartupRoomName("General"); + ASSERT_EQ(s.getStartupServerHost(), QString("server.cockatrice.us")); + ASSERT_EQ(s.getStartupServerPort(), QString("4748")); + ASSERT_EQ(s.getStartupRoomName(), QString("General")); +} + +TEST_F(SettingsDefaultsTest, Tabs_AllTabsOpen_Default) +{ + TabsSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getTabVisualDeckStorageOpen(), true); + ASSERT_EQ(s.getTabServerOpen(), true); + ASSERT_EQ(s.getTabAccountOpen(), true); + ASSERT_EQ(s.getTabDeckStorageOpen(), true); + ASSERT_EQ(s.getTabReplaysOpen(), true); + ASSERT_EQ(s.getTabAdminOpen(), true); + ASSERT_EQ(s.getTabLogOpen(), true); +} + +TEST_F(SettingsDefaultsTest, Tabs_ModerationOpen_Default) +{ + TabsSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getTabModerationOpen(), false); +} + +TEST_F(SettingsDefaultsTest, Tabs_CardArtRulesOpen_Default) +{ + TabsSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getTabCardArtRulesOpen(), false); +} + +// --- ChatSettings --- + +TEST_F(SettingsDefaultsTest, Chat_Mention_Default) +{ + ChatSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getChatMention(), true); +} + +TEST_F(SettingsDefaultsTest, Chat_MentionCompleter_Default) +{ + ChatSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getChatMentionCompleter(), true); +} + +TEST_F(SettingsDefaultsTest, Chat_MentionColor_Default) +{ + ChatSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getChatMentionColor(), QString("A6120D")); +} + +TEST_F(SettingsDefaultsTest, Chat_HighlightColor_Default) +{ + ChatSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getChatHighlightColor(), QString("A6120D")); +} + +TEST_F(SettingsDefaultsTest, Chat_MentionForeground_Default) +{ + ChatSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getChatMentionForeground(), true); +} + +TEST_F(SettingsDefaultsTest, Chat_HighlightForeground_Default) +{ + ChatSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getChatHighlightForeground(), true); +} + +TEST_F(SettingsDefaultsTest, Chat_ShowMessagePopup_Default) +{ + ChatSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getShowMessagePopup(), true); +} + +TEST_F(SettingsDefaultsTest, Chat_ShowMentionPopup_Default) +{ + ChatSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getShowMentionPopup(), true); +} + +TEST_F(SettingsDefaultsTest, Chat_RoomHistory_Default) +{ + ChatSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getRoomHistory(), true); +} + +TEST_F(SettingsDefaultsTest, Chat_IgnoreAllPrivateMessages_Default) +{ + ChatSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getIgnoreAllPrivateMessages(), false); +} + +// --- PersonalSettings --- + +TEST_F(SettingsDefaultsTest, Personal_Lang_Default) +{ + PersonalSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getLang(), QString("")); +} + +TEST_F(SettingsDefaultsTest, Personal_ShowTipsOnStartup_Default) +{ + PersonalSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getShowTipsOnStartup(), true); +} + +// --- DownloadSettings --- + +TEST_F(SettingsDefaultsTest, Download_PicDownload_Default) +{ + DownloadSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getPicDownload(), true); +} + +TEST_F(SettingsDefaultsTest, Download_DownloadSpoilersStatus_Default) +{ + DownloadSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getDownloadSpoilersStatus(), false); +} + +// --- AppearanceSettings --- + +TEST_F(SettingsDefaultsTest, Appearance_ThemeName_Default) +{ + AppearanceSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getThemeName(), QString("")); +} + +TEST_F(SettingsDefaultsTest, Appearance_ThemeName_SetAndGet) +{ + AppearanceSettings s(settingsPath, nullptr); + s.setThemeName("my_theme"); + ASSERT_EQ(s.getThemeName(), QString("my_theme")); +} + +TEST_F(SettingsDefaultsTest, Appearance_StyleUserList_Default) +{ + AppearanceSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getStyleUserList(), true); +} + +TEST_F(SettingsDefaultsTest, Appearance_MaxFontSize_Default) +{ + AppearanceSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getMaxFontSize(), 12); +} + +TEST_F(SettingsDefaultsTest, Appearance_MaxFontSize_SetAndGet) +{ + AppearanceSettings s(settingsPath, nullptr); + s.setMaxFontSize(14); + ASSERT_EQ(s.getMaxFontSize(), 14); +} + +TEST_F(SettingsDefaultsTest, Appearance_HomeTabBackgroundSource_Default) +{ + AppearanceSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getHomeTabBackgroundSource(), QString("themed")); +} + +TEST_F(SettingsDefaultsTest, Appearance_HomeTabBackgroundShuffleFrequency_Default) +{ + AppearanceSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getHomeTabBackgroundShuffleFrequency(), 0); +} + +TEST_F(SettingsDefaultsTest, Appearance_HomeTabDisplayCardName_Default) +{ + AppearanceSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getHomeTabDisplayCardName(), true); +} + +TEST_F(SettingsDefaultsTest, Appearance_HomeTabBackgroundDim_Default) +{ + AppearanceSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getHomeTabBackgroundDim(), true); +} + +TEST_F(SettingsDefaultsTest, Appearance_HomeTabBackgroundDim_SetAndGet) +{ + AppearanceSettings s(settingsPath, nullptr); + s.setHomeTabBackgroundDim(false); + ASSERT_EQ(s.getHomeTabBackgroundDim(), false); +} + +// --- InterfaceSettings --- + +TEST_F(SettingsDefaultsTest, Interface_ShowStatusBar_Default) +{ + InterfaceSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getShowStatusBar(), false); +} + +TEST_F(SettingsDefaultsTest, Interface_ShowShortcuts_Default) +{ + InterfaceSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getShowShortcuts(), true); +} + +TEST_F(SettingsDefaultsTest, Interface_ShowGameSelectorFilterToolbar_Default) +{ + InterfaceSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getShowGameSelectorFilterToolbar(), true); +} + +TEST_F(SettingsDefaultsTest, Interface_NotificationsEnabled_Default) +{ + InterfaceSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getNotificationsEnabled(), true); +} + +TEST_F(SettingsDefaultsTest, Interface_SpectatorNotificationsEnabled_Default) +{ + InterfaceSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getSpectatorNotificationsEnabled(), false); +} + +TEST_F(SettingsDefaultsTest, Interface_BuddyConnectNotificationsEnabled_Default) +{ + InterfaceSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getBuddyConnectNotificationsEnabled(), true); +} + +// --- DeckEditorSettings --- + +TEST_F(SettingsDefaultsTest, DeckEditor_OpenDeckInNewTab_Default) +{ + DeckEditorSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getOpenDeckInNewTab(), false); +} + +TEST_F(SettingsDefaultsTest, DeckEditor_BannerCardComboBoxVisible_Default) +{ + DeckEditorSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getBannerCardComboBoxVisible(), true); +} + +TEST_F(SettingsDefaultsTest, DeckEditor_TagsWidgetVisible_Default) +{ + DeckEditorSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getTagsWidgetVisible(), true); +} + +TEST_F(SettingsDefaultsTest, DeckEditor_DefaultDeckEditorType_Default) +{ + DeckEditorSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getDefaultDeckEditorType(), 1); +} + +TEST_F(SettingsDefaultsTest, DeckEditor_VdeStartupTab_Default) +{ + DeckEditorSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getVdeStartupTab(), VdeStartupTabContext); +} + +TEST_F(SettingsDefaultsTest, DeckEditor_VdeStartupTab_SetAndGet) +{ + DeckEditorSettings s(settingsPath, nullptr); + s.setVdeStartupTab(VdeStartupTabDeckDisplay); + ASSERT_EQ(s.getVdeStartupTab(), VdeStartupTabDeckDisplay); + s.setVdeStartupTab(VdeStartupTabDatabaseDisplay); + ASSERT_EQ(s.getVdeStartupTab(), VdeStartupTabDatabaseDisplay); + s.setVdeStartupTab(VdeStartupTabContext); + ASSERT_EQ(s.getVdeStartupTab(), VdeStartupTabContext); +} + +// --- NetworkSettings --- + +TEST_F(SettingsDefaultsTest, Network_ClientID_Default) +{ + NetworkSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getClientID(), QString("notset")); +} + +TEST_F(SettingsDefaultsTest, Network_ClientVersion_Default) +{ + NetworkSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getClientVersion(), QString("notset")); +} + +TEST_F(SettingsDefaultsTest, Network_KeepAlive_Default) +{ + NetworkSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getKeepAlive(), 3); +} + +TEST_F(SettingsDefaultsTest, Network_TimeOut_Default) +{ + NetworkSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getTimeOut(), 5); +} + +TEST_F(SettingsDefaultsTest, Network_KnownMissingFeatures_Default) +{ + NetworkSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getKnownMissingFeatures(), QString("")); +} + +// --- CardsDisplaySettings --- + +TEST_F(SettingsDefaultsTest, CardsDisplay_PrintingSelectorCardSize_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getPrintingSelectorCardSize(), 100); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_VisualDeckStorageCardSize_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getVisualDeckStorageCardSize(), 100); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_VisualDatabaseDisplayCardSize_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getVisualDatabaseDisplayCardSize(), 100); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_VisualDeckEditorCardSize_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getVisualDeckEditorCardSize(), 100); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_EDHRecCardSize_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getEDHRecCardSize(), 100); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_ArchidektPreviewSize_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getArchidektPreviewSize(), 100); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_SampleHandSize_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getSampleHandSize(), 7); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_ArrowDrawAnimation_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getArrowDrawAnimation(), true); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_CardLang_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getCardLang(), QString("en")); +} + +TEST_F(SettingsDefaultsTest, CardsDisplay_CardLang_SetAndGet) +{ + CardsDisplaySettings s(settingsPath, nullptr); + s.setCardLang("de"); + ASSERT_EQ(s.getCardLang(), QString("de")); +} + +// --- VisualDeckStorageSettings --- + +TEST_F(SettingsDefaultsTest, VisualDeckStorage_SortingOrder_Default) +{ + VisualDeckStorageSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getVisualDeckStorageSortingOrder(), 0); +} + +TEST_F(SettingsDefaultsTest, VisualDeckStorage_ShowFolders_Default) +{ + VisualDeckStorageSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getVisualDeckStorageShowFolders(), true); +} + +TEST_F(SettingsDefaultsTest, VisualDeckStorage_DefaultTagsList_NotEmpty) +{ + VisualDeckStorageSettings s(settingsPath, nullptr); + ASSERT_GT(s.getVisualDeckStorageDefaultTagsList().size(), 0); +} + +TEST_F(SettingsDefaultsTest, VisualDeckStorage_DefaultTagsList_SetAndGet) +{ + VisualDeckStorageSettings s(settingsPath, nullptr); + QStringList custom = {"Tag1", "Tag2"}; + s.setVisualDeckStorageDefaultTagsList(custom); + ASSERT_EQ(s.getVisualDeckStorageDefaultTagsList(), custom); +} + +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/settings/settings_manager_test.cpp b/tests/settings/settings_manager_test.cpp new file mode 100644 index 000000000..51eb66dbb --- /dev/null +++ b/tests/settings/settings_manager_test.cpp @@ -0,0 +1,171 @@ +#include "gtest/gtest.h" +#include +#include +#include + +namespace +{ + +class SettingsManagerTest : public ::testing::Test +{ +protected: + QTemporaryDir tempDir; + QString settingsPath; + + void SetUp() override + { + settingsPath = tempDir.path() + "/"; + } + + void writeValue(const QString &file, const QString &key, const QVariant &value) + { + QSettings qs(file, QSettings::IniFormat); + qs.setValue(key, value); + } +}; + +TEST_F(SettingsManagerTest, GetValueNoDefaultReturnsInvalid) +{ + SettingsManager sm(settingsPath + "test.ini"); + QVariant val = sm.getValue("nonexistent"); + ASSERT_TRUE(val.isNull()); +} + +TEST_F(SettingsManagerTest, GetValueWithDefaultReturnsDefaultWhenMissing) +{ + SettingsManager sm(settingsPath + "test.ini"); + QVariant val = sm.getValue("nonexistent", QString(), QString(), 42); + ASSERT_EQ(val.toInt(), 42); +} + +TEST_F(SettingsManagerTest, GetValueWithDefaultReturnsStoredWhenPresent) +{ + writeValue(settingsPath + "test.ini", "mykey", 99); + + SettingsManager sm(settingsPath + "test.ini"); + QVariant val = sm.getValue("mykey", QString(), QString(), 42); + ASSERT_EQ(val.toInt(), 99); +} + +TEST_F(SettingsManagerTest, GetValueWithGroup) +{ + writeValue(settingsPath + "grouped.ini", "group1/greeting", "world"); + + SettingsManager sm(settingsPath + "grouped.ini"); + QVariant val = sm.getValue("greeting", "group1"); + ASSERT_EQ(val.toString(), QString("world")); +} + +TEST_F(SettingsManagerTest, GetValueWithGroupAndSubGroup) +{ + writeValue(settingsPath + "nested.ini", "outer/inner/depth", 1); + + SettingsManager sm(settingsPath + "nested.ini"); + QVariant val = sm.getValue("depth", "outer", "inner"); + ASSERT_EQ(val.toInt(), 1); +} + +TEST_F(SettingsManagerTest, DefaultGroupUsedInGetValue) +{ + writeValue(settingsPath + "defgroup.ini", "defaultGroup/key", "val"); + + SettingsManager sm(settingsPath + "defgroup.ini", "defaultGroup"); + QVariant val = sm.getValue("key"); + ASSERT_EQ(val.toString(), QString("val")); +} + +TEST_F(SettingsManagerTest, DefaultGroupScopesGetValue) +{ + writeValue(settingsPath + "scoped.ini", "key", "ungrouped"); + writeValue(settingsPath + "scoped.ini", "defaultGroup/key", "grouped"); + + SettingsManager sm(settingsPath + "scoped.ini", "defaultGroup"); + QVariant val = sm.getValue("key"); + ASSERT_EQ(val.toString(), QString("grouped")); +} + +TEST_F(SettingsManagerTest, DefaultSubGroupUsedInGetValue) +{ + writeValue(settingsPath + "sub.ini", "top/bottom/key", "nested"); + + SettingsManager sm(settingsPath + "sub.ini", "top", "bottom"); + QVariant val = sm.getValue("key"); + ASSERT_EQ(val.toString(), QString("nested")); +} + +TEST_F(SettingsManagerTest, BatchWriteWritesAllValues) +{ + SettingsManager sm(settingsPath + "batch.ini"); + sm.batchWrite([&](QSettings &qs) { + qs.setValue("a", 1); + qs.setValue("b", 2); + qs.setValue("c", 3); + }); + + SettingsManager sm2(settingsPath + "batch.ini"); + ASSERT_EQ(sm2.getValue("a", QString(), QString(), 0).toInt(), 1); + ASSERT_EQ(sm2.getValue("b", QString(), QString(), 0).toInt(), 2); + ASSERT_EQ(sm2.getValue("c", QString(), QString(), 0).toInt(), 3); +} + +TEST_F(SettingsManagerTest, BatchWriteWithGroup) +{ + SettingsManager sm(settingsPath + "batch_group.ini"); + sm.batchWrite([&](QSettings &qs) { + qs.beginGroup("section"); + qs.setValue("x", 10); + qs.endGroup(); + }); + + SettingsManager sm2(settingsPath + "batch_group.ini"); + ASSERT_EQ(sm2.getValue("x", "section", QString(), 0).toInt(), 10); +} + +TEST_F(SettingsManagerTest, SyncPersistsToDisk) +{ + QString filePath = settingsPath + "sync_test.ini"; + { + SettingsManager sm(filePath); + sm.batchWrite([&](QSettings &qs) { qs.setValue("persist", "yes"); }); + } + { + QSettings qs(filePath, QSettings::IniFormat); + ASSERT_EQ(qs.value("persist").toString(), QString("yes")); + } +} + +TEST_F(SettingsManagerTest, MultipleReadsAfterWrite) +{ + SettingsManager sm(settingsPath + "multi.ini"); + sm.batchWrite([&](QSettings &qs) { qs.setValue("k1", 1); }); + + QVariant v1 = sm.getValue("k1", QString(), QString(), 0); + QVariant v2 = sm.getValue("nonexistent", QString(), QString(), 99); + + ASSERT_EQ(v1.toInt(), 1); + ASSERT_EQ(v2.toInt(), 99); +} + +TEST_F(SettingsManagerTest, ValuesDeletedViaQSettingsAreReadAsDefault) +{ + writeValue(settingsPath + "del.ini", "deleteMe", "present"); + + SettingsManager sm(settingsPath + "del.ini"); + ASSERT_EQ(sm.getValue("deleteMe", QString(), QString(), "default").toString(), QString("present")); + + // Delete via QSettings directly (deleteValue is protected) + { + QSettings qs(settingsPath + "del.ini", QSettings::IniFormat); + qs.remove("deleteMe"); + } + + ASSERT_EQ(sm.getValue("deleteMe", QString(), QString(), "default").toString(), QString("default")); +} + +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/tests/settings/settings_migration_test.cpp b/tests/settings/settings_migration_test.cpp new file mode 100644 index 000000000..8a3d756a0 --- /dev/null +++ b/tests/settings/settings_migration_test.cpp @@ -0,0 +1,710 @@ +#include "gtest/gtest.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +class SettingsMigrationTest : public ::testing::Test +{ +protected: + QTemporaryDir tempDir; + QString settingsPath; + + void SetUp() override + { + settingsPath = tempDir.path() + "/"; + + // Isolate the settings used by the legacy migration tests inside the temporary + // directory so the tests never read or write the real user config (registry on + // Windows, plist on macOS, .conf on Linux), which would otherwise be shared + // across CI jobs and flaky. Default-format QSettings is forced to IniFormat and + // its UserScope path is redirected here; setPath wins over the XDG_CONFIG_HOME + // default on Unix, so every platform resolves to /config/... + const QString configDir = tempDir.path() + "/config"; + QDir().mkpath(configDir); + qputenv("XDG_CONFIG_HOME", configDir.toUtf8()); + QSettings::setPath(QSettings::IniFormat, QSettings::UserScope, configDir); + } + + bool fileExists(const QString &name) const + { + return QFile::exists(settingsPath + name); + } + + QVariant readFromIni(const QString &fileName, const QString &key) const + { + QSettings ini(settingsPath + fileName, QSettings::IniFormat); + return ini.value(key); + } + + // Checks for the exact legacy key in the raw INI content. On Windows, INI keys are + // case-insensitive, so reading back a lower-case legacy key would match its migrated + // camelCase counterpart and hide regressions. Checking the file bytes directly keeps + // the comparison case-sensitive on every platform. + bool iniFileHasKeyCaseSensitive(const QString &fileName, const QString &key) const + { + QFile f(settingsPath + fileName); + if (!f.open(QIODevice::ReadOnly)) { + return false; + } + const QString valueName = key.section('/', -1); + const QStringList lines = QString::fromUtf8(f.readAll()).split('\n'); + for (const auto &line : lines) { + if (line.startsWith(valueName + '=')) { + return true; + } + } + return false; + } +}; + +TEST_F(SettingsMigrationTest, NoGlobalIniDoesNothing) +{ + bool result = SettingsMigration::migrateSettingsFromGlobalIni(settingsPath); + ASSERT_FALSE(result); +} + +TEST_F(SettingsMigrationTest, EmptyGlobalIniGetsBackedUp) +{ + { + QFile f(settingsPath + "global.ini"); + ASSERT_TRUE(f.open(QIODevice::WriteOnly)); + f.close(); + } + bool result = SettingsMigration::migrateSettingsFromGlobalIni(settingsPath); + ASSERT_TRUE(result); + ASSERT_TRUE(fileExists("global.ini.old")); + ASSERT_TRUE(fileExists("global.ini")); + + // No per-file INIs should be created + ASSERT_FALSE(fileExists("tabs.ini")); + ASSERT_FALSE(fileExists("sound.ini")); + + // Sentinel should be in the new global.ini + ASSERT_EQ(readFromIni("global.ini", "migration/perfile_complete"), QVariant(true)); +} + +TEST_F(SettingsMigrationTest, MigratesAllSettingsGroups) +{ + { + QSettings g(settingsPath + "global.ini", QSettings::IniFormat); + + // tabs + g.setValue("tabs/visualDeckStorage", false); + g.setValue("tabs/server", true); + g.setValue("tabs/account", true); + g.setValue("tabs/deckStorage", true); + g.setValue("tabs/replays", false); + g.setValue("tabs/admin", false); + g.setValue("tabs/log", true); + + // sound + g.setValue("sound/enabled", true); + g.setValue("sound/theme", "custom_theme"); + g.setValue("sound/mastervolume", 75); + + // game + g.beginGroup("game"); + g.setValue("maxplayers", 4); + g.setValue("gamedescription", "test game"); + g.setValue("remembergamesettings", false); + g.setValue("gametypes", "commander"); + g.setValue("onlybuddies", true); + g.endGroup(); + + // localgameoptions + g.beginGroup("localgameoptions"); + g.setValue("maxplayers", 2); + g.setValue("startinglifetotal", 40); + g.endGroup(); + + // chat + g.beginGroup("chat"); + g.setValue("mention", false); + g.setValue("mentioncolor", "FF0000"); + g.setValue("showmessagepopups", false); + g.setValue("mentioncompleter", false); + g.setValue("roomhistory", false); + g.setValue("highlightcolor", "00FF00"); + g.endGroup(); + + // legacy highlight words (under [personal]) + g.setValue("personal/highlightWords", "alpha beta"); + + // cache storage (under [personal] group) + g.setValue("personal/pixmapCacheSize", 1024); + g.setValue("personal/networkCacheSize", 2048); + g.setValue("personal/redirectCacheTtl", 5); + g.setValue("personal/cardPictureLoaderCacheMethod", 1); + g.setValue("personal/localCardImageStorageNamingScheme", 2); + + // updates (under [personal] group) + g.setValue("personal/startupUpdateCheck", false); + g.setValue("personal/startupCardUpdateCheckPromptForUpdate", false); + g.setValue("personal/startupCardUpdateCheckAlwaysUpdate", true); + g.setValue("personal/cardUpdateCheckInterval", 14); + g.setValue("personal/lastCardUpdateCheck", QDate(2024, 1, 1)); + g.setValue("personal/alwaysEnableNewSets", true); + g.setValue("personal/updatenotification", false); + g.setValue("personal/newversionnotification", false); + + // personal + g.setValue("personal/lang", "de"); + + // downloads (previously under [personal]) + g.setValue("personal/picturedownload", true); + g.setValue("personal/downloadspoilers", true); + + // interface (previously under [personal]) + g.setValue("personal/showStatusBar", true); + + // theme + g.setValue("theme/name", "custom_theme"); + g.setValue("game/maxfontsize", 14); + + // appearance + g.setValue("appearance/styleUserList", false); + g.setValue("home/background/displayCardName", false); + g.setValue("menu/showshortcuts", false); + g.setValue("menu/showgameselectorfiltertoolbar", false); + + // deck editor + g.setValue("editor/openDeckInNewTab", false); + g.setValue("interface/deckeditortagswidgetvisible", false); + g.setValue("interface/defaultDeckEditorType", 0); + g.setValue("interface/visualdeckeditorsamplehandsize", 5); + + // personal home + g.setValue("home/background", "custom_bg"); + g.setValue("home/background/shuffleTimer", 30); + + // personal tipOfDay + g.setValue("tipOfDay/showTips", false); + g.setValue("tipOfDay/seenTips", QStringList{"1", "2", "3"}); + + // network + g.setValue("personal/keepalive", 10); + g.setValue("personal/timeout", 30); + g.setValue("personal/clientid", "test-client-id"); + g.setValue("personal/clientversion", "test-client-version"); + g.setValue("interface/knownmissingfeatures", "feature1,feature2"); + + // cards + g.setValue("cards/displaycardnames", false); + g.setValue("cards/roundcardcorners", false); + g.setValue("cards/overrideallcardartwithpersonalpreference", true); + g.setValue("cards/bumpsetswithcardsindecktotop", false); + g.setValue("cards/includerebalancedcards", false); + g.setValue("cards/autorotatesidewayslayoutcards", false); + g.setValue("cards/tapanimation", true); + g.setValue("cards/scaleCards", false); + g.setValue("cards/verticalCardOverlapPercent", 42); + g.setValue("cards/cardinfoviewmode", 1); + g.setValue("cards/printingselectorcardsize", 90); + g.setValue("cards/printingselectorsortorder", 3); + g.setValue("cards/printingselectornavigationbuttonsvisible", false); + // card counters (migrate into card_counters.ini) + g.setValue("cards/counters/0/color", QColor(Qt::red)); + + // interface + g.setValue("interface/usetearoffmenus", true); + g.setValue("interface/cardViewInitialRowsMax", 8); + g.setValue("interface/cardViewExpandedRowsMax", 12); + g.setValue("interface/closeEmptyCardView", false); + g.setValue("interface/focusCardViewSearchBar", false); + g.setValue("interface/keepGameChatFocus", true); + g.setValue("interface/notificationsenabled", false); + g.setValue("interface/specnotificationsenabled", true); + g.setValue("interface/buddyconnectnotificationsenabled", false); + g.setValue("interface/doubleclicktoplay", false); + g.setValue("interface/clickPlaysAllSelected", false); + g.setValue("interface/playtostack", false); + g.setValue("interface/doNotDeleteArrowsInSubPhases", false); + g.setValue("interface/startinghandsize", 5); + g.setValue("interface/annotatetokens", true); + g.setValue("interface/showlassoselectioncount", false); + g.setValue("interface/showpersistentselectioncount", false); + g.setValue("interface/tallyType", 2); + g.setValue("interface/leftjustified", true); + g.setValue("interface/min_players_multicolumn", 6); + g.setValue("interface/deckeditorbannercardcomboboxvisible", false); + // hand/table/replay/zoneview + g.setValue("hand/horizontal", true); + g.setValue("table/invert_vertical", true); + g.setValue("replay/rewindBufferingMs", 6000); + g.setValue("replay/fastForwardSpeed", 5); + g.setValue("zoneview/groupby", 2); + g.setValue("zoneview/sortby", 1); + g.setValue("zoneview/pileview", false); + + // paths + g.beginGroup("paths"); + g.setValue("decks", "/custom/decks"); + g.setValue("pics", "/custom/pics"); + g.endGroup(); + + // visual deck storage (under [interface] group) + g.setValue("interface/visualdeckstoragecardsize", 150); + g.setValue("interface/visualdeckstoragesortingorder", 2); + g.setValue("interface/visualdeckstorageshowfolders", false); + g.setValue("interface/visualdeckstorageshowtagfilter", false); + g.setValue("interface/visualdeckstoragedefaulttagslist", QStringList{"Alpha", "Beta"}); + g.setValue("interface/visualdeckstoragesearchfoldernames", false); + g.setValue("interface/visualdeckstorageshowcoloridentity", false); + g.setValue("interface/visualdeckstorageshowbannercardcombobox", false); + g.setValue("interface/visualdeckstorageshowtagsondeckpreviews", false); + g.setValue("interface/visualdeckstoragedrawunusedcoloridentities", false); + g.setValue("interface/visualdeckstorageunusedcoloridentitiesopacity", 35); + g.setValue("interface/visualdeckstoragetooltiptype", 1); + g.setValue("interface/visualdeckstoragepromptforconversion", false); + g.setValue("interface/visualdeckstoragealwaysconvert", true); + g.setValue("interface/visualdeckstorageingame", false); + g.setValue("interface/visualdeckstorageselectionanimation", false); + g.setValue("interface/visualdatabasedisplayfiltertomostrecentsetsenabled", true); + g.setValue("interface/visualdatabasedisplayfiltertomostrecentsetsamount", 25); + + // card sizes (migrate into cards_display.ini) + g.setValue("interface/visualdatabasedisplaycardsize", 80); + g.setValue("interface/visualdeckeditorcardsize", 70); + g.setValue("interface/edhreccardsize", 60); + g.setValue("interface/archidektpreviewsize", 50); + + g.sync(); + } + + bool result = SettingsMigration::migrateSettingsFromGlobalIni(settingsPath); + ASSERT_TRUE(result); + + // Original global.ini should be backed up + ASSERT_TRUE(fileExists("global.ini.old")); + + // Verify each per-file INI was created and has correct values + ASSERT_TRUE(fileExists("tabs.ini")); + ASSERT_EQ(readFromIni("tabs.ini", "tabs/visualDeckStorage"), QVariant(false)); + ASSERT_EQ(readFromIni("tabs.ini", "tabs/server"), QVariant(true)); + ASSERT_EQ(readFromIni("tabs.ini", "tabs/log"), QVariant(true)); + + ASSERT_TRUE(fileExists("sound.ini")); + ASSERT_EQ(readFromIni("sound.ini", "sound/enabled"), QVariant(true)); + ASSERT_EQ(readFromIni("sound.ini", "sound/theme"), QVariant("custom_theme")); + ASSERT_EQ(readFromIni("sound.ini", "sound/masterVolume"), QVariant(75)); + + ASSERT_TRUE(fileExists("game.ini")); + ASSERT_EQ(readFromIni("game.ini", "game/maxPlayers"), QVariant(4)); + ASSERT_EQ(readFromIni("game.ini", "game/gameDescription"), QVariant("test game")); + ASSERT_EQ(readFromIni("game.ini", "game/rememberGameSettings"), QVariant(false)); + ASSERT_EQ(readFromIni("game.ini", "game/gameTypes"), QVariant("commander")); + ASSERT_EQ(readFromIni("game.ini", "game/onlyBuddies"), QVariant(true)); + ASSERT_EQ(readFromIni("game.ini", "localgameoptions/maxPlayers"), QVariant(2)); + ASSERT_EQ(readFromIni("game.ini", "localgameoptions/startingLifeTotal"), QVariant(40)); + + ASSERT_TRUE(fileExists("chat.ini")); + ASSERT_EQ(readFromIni("chat.ini", "chat/mention"), QVariant(false)); + ASSERT_EQ(readFromIni("chat.ini", "chat/mentionColor"), QVariant("FF0000")); + ASSERT_EQ(readFromIni("chat.ini", "chat/showMessagePopups"), QVariant(false)); + ASSERT_EQ(readFromIni("chat.ini", "chat/mentionCompleter"), QVariant(false)); + ASSERT_EQ(readFromIni("chat.ini", "chat/roomHistory"), QVariant(false)); + ASSERT_EQ(readFromIni("chat.ini", "chat/highlightColor"), QVariant("00FF00")); + ASSERT_EQ(readFromIni("chat.ini", "chat/highlightWords"), QVariant("alpha beta")); + + ASSERT_TRUE(fileExists("cache_storage.ini")); + ASSERT_EQ(readFromIni("cache_storage.ini", "cache_storage/pixmapCacheSize"), QVariant(1024)); + ASSERT_EQ(readFromIni("cache_storage.ini", "cache_storage/networkCacheSize"), QVariant(2048)); + ASSERT_EQ(readFromIni("cache_storage.ini", "cache_storage/redirectCacheTtl"), QVariant(5)); + ASSERT_EQ(readFromIni("cache_storage.ini", "cache_storage/cardPictureLoaderCacheMethod"), QVariant(1)); + ASSERT_EQ(readFromIni("cache_storage.ini", "cache_storage/localCardImageStorageNamingScheme"), QVariant(2)); + + ASSERT_TRUE(fileExists("updates.ini")); + ASSERT_EQ(readFromIni("updates.ini", "updates/startupUpdateCheck"), QVariant(false)); + ASSERT_EQ(readFromIni("updates.ini", "updates/startupCardUpdateCheckPromptForUpdate"), QVariant(false)); + ASSERT_EQ(readFromIni("updates.ini", "updates/startupCardUpdateCheckAlwaysUpdate"), QVariant(true)); + ASSERT_EQ(readFromIni("updates.ini", "updates/cardUpdateCheckInterval"), QVariant(14)); + ASSERT_EQ(readFromIni("updates.ini", "updates/lastCardUpdateCheck"), QVariant(QDate(2024, 1, 1))); + ASSERT_EQ(readFromIni("updates.ini", "updates/alwaysEnableNewSets"), QVariant(true)); + ASSERT_EQ(readFromIni("updates.ini", "updates/updateNotification"), QVariant(false)); + ASSERT_EQ(readFromIni("updates.ini", "updates/newVersionNotification"), QVariant(false)); + + ASSERT_TRUE(fileExists("personal.ini")); + ASSERT_EQ(readFromIni("personal.ini", "personal/lang"), QVariant("de")); + ASSERT_EQ(readFromIni("personal.ini", "tipOfDay/showTips"), QVariant(false)); + ASSERT_EQ(readFromIni("personal.ini", "tipOfDay/seenTips"), QVariant(QStringList{"1", "2", "3"})); + + ASSERT_TRUE(fileExists("downloads.ini")); + ASSERT_EQ(readFromIni("downloads.ini", "downloads/pictureDownload"), QVariant(true)); + ASSERT_EQ(readFromIni("downloads.ini", "downloads/downloadSpoilers"), QVariant(true)); + + ASSERT_TRUE(fileExists("appearance.ini")); + ASSERT_EQ(readFromIni("appearance.ini", "appearance/themeName"), QVariant("custom_theme")); + ASSERT_EQ(readFromIni("appearance.ini", "appearance/maxFontSize"), QVariant(14)); + ASSERT_EQ(readFromIni("appearance.ini", "appearance/styleUserList"), QVariant(false)); + ASSERT_EQ(readFromIni("appearance.ini", "appearance/homeTabBackgroundSource"), QVariant("custom_bg")); + ASSERT_EQ(readFromIni("appearance.ini", "appearance/homeTabBackgroundShuffleFrequency"), QVariant(30)); + ASSERT_EQ(readFromIni("appearance.ini", "appearance/homeTabDisplayCardName"), QVariant(false)); + + ASSERT_TRUE(fileExists("network.ini")); + ASSERT_EQ(readFromIni("network.ini", "network/keepAlive"), QVariant(10)); + ASSERT_EQ(readFromIni("network.ini", "network/timeout"), QVariant(30)); + ASSERT_EQ(readFromIni("network.ini", "network/clientId"), QVariant("test-client-id")); + ASSERT_EQ(readFromIni("network.ini", "network/clientVersion"), QVariant("test-client-version")); + ASSERT_EQ(readFromIni("network.ini", "network/knownMissingFeatures"), QVariant("feature1,feature2")); + + ASSERT_TRUE(fileExists("cards_display.ini")); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/displayCardNames"), QVariant(false)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/roundCardCorners"), QVariant(false)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/overrideAllCardArtWithPersonalPreference"), QVariant(true)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/bumpSetsWithCardsInDeckToTop"), QVariant(false)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/includerebalancedcards"), QVariant(false)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/autoRotateSidewaysLayoutCards"), QVariant(false)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/tapAnimation"), QVariant(true)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/scaleCards"), QVariant(false)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/verticalCardOverlapPercent"), QVariant(42)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/cardInfoViewMode"), QVariant(1)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/cardSize/printingSelector"), QVariant(90)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/printingSelector/sortOrder"), QVariant(3)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/printingSelector/navigationButtonsVisible"), QVariant(false)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/cardSize/visualDeckStorage"), QVariant(150)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/cardSize/visualDatabaseDisplay"), QVariant(80)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/cardSize/visualDeckEditor"), QVariant(70)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/cardSize/edhrec"), QVariant(60)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/cardSize/archidektPreview"), QVariant(50)); + ASSERT_EQ(readFromIni("cards_display.ini", "cards/cardSize/sampleHandSize"), QVariant(5)); + + ASSERT_TRUE(fileExists("card_counters.ini")); + ASSERT_EQ(readFromIni("card_counters.ini", "cards/counters/0/color").toString(), QColor(Qt::red).name()); + ASSERT_FALSE(readFromIni("global.ini", "cards/counters/0/color").isValid()); + + ASSERT_TRUE(fileExists("interface.ini")); + ASSERT_EQ(readFromIni("interface.ini", "interface/useTearOffMenus"), QVariant(true)); + ASSERT_EQ(readFromIni("interface.ini", "interface/cardViewInitialRowsMax"), QVariant(8)); + ASSERT_EQ(readFromIni("interface.ini", "interface/cardViewExpandedRowsMax"), QVariant(12)); + ASSERT_EQ(readFromIni("interface.ini", "interface/closeEmptyCardView"), QVariant(false)); + ASSERT_EQ(readFromIni("interface.ini", "interface/focusCardViewSearchBar"), QVariant(false)); + ASSERT_EQ(readFromIni("interface.ini", "interface/keepGameChatFocus"), QVariant(true)); + ASSERT_EQ(readFromIni("interface.ini", "interface/notifications/enabled"), QVariant(false)); + ASSERT_EQ(readFromIni("interface.ini", "interface/notifications/spectatorsEnabled"), QVariant(true)); + ASSERT_EQ(readFromIni("interface.ini", "interface/notifications/buddyConnectEnabled"), QVariant(false)); + ASSERT_EQ(readFromIni("interface.ini", "interface/doubleClickToPlay"), QVariant(false)); + ASSERT_EQ(readFromIni("interface.ini", "interface/clickPlaysAllSelected"), QVariant(false)); + ASSERT_EQ(readFromIni("interface.ini", "interface/playToStack"), QVariant(false)); + ASSERT_EQ(readFromIni("interface.ini", "interface/doNotDeleteArrowsInSubPhases"), QVariant(false)); + ASSERT_EQ(readFromIni("interface.ini", "interface/startingHandSize"), QVariant(5)); + ASSERT_EQ(readFromIni("interface.ini", "interface/annotateTokens"), QVariant(true)); + ASSERT_EQ(readFromIni("interface.ini", "interface/showLassoSelectionCount"), QVariant(false)); + ASSERT_EQ(readFromIni("interface.ini", "interface/showPersistentSelectionCount"), QVariant(false)); + ASSERT_EQ(readFromIni("interface.ini", "interface/tallyType"), QVariant(2)); + ASSERT_EQ(readFromIni("interface.ini", "interface/leftJustified"), QVariant(true)); + ASSERT_EQ(readFromIni("interface.ini", "interface/minPlayersMulticolumn"), QVariant(6)); + ASSERT_EQ(readFromIni("interface.ini", "interface/showStatusBar"), QVariant(true)); + ASSERT_EQ(readFromIni("interface.ini", "interface/showShortcuts"), QVariant(false)); + ASSERT_EQ(readFromIni("interface.ini", "interface/showGameSelectorFilterToolbar"), QVariant(false)); + ASSERT_EQ(readFromIni("interface.ini", "hand/horizontal"), QVariant(true)); + ASSERT_EQ(readFromIni("interface.ini", "table/invertVertical"), QVariant(true)); + ASSERT_EQ(readFromIni("interface.ini", "replay/rewindBufferingMs"), QVariant(6000)); + ASSERT_EQ(readFromIni("interface.ini", "replay/fastForwardSpeed"), QVariant(5)); + ASSERT_EQ(readFromIni("interface.ini", "zoneview/groupBy"), QVariant(2)); + ASSERT_EQ(readFromIni("interface.ini", "zoneview/sortBy"), QVariant(1)); + ASSERT_EQ(readFromIni("interface.ini", "zoneview/pileView"), QVariant(false)); + + ASSERT_TRUE(fileExists("deck_editor.ini")); + ASSERT_EQ(readFromIni("deck_editor.ini", "deckeditor/openDeckInNewTab"), QVariant(false)); + ASSERT_EQ(readFromIni("deck_editor.ini", "deckeditor/bannerCardComboBoxVisible"), QVariant(false)); + ASSERT_EQ(readFromIni("deck_editor.ini", "deckeditor/tagsWidgetVisible"), QVariant(false)); + ASSERT_EQ(readFromIni("deck_editor.ini", "deckeditor/defaultDeckEditorType"), QVariant(0)); + + ASSERT_TRUE(fileExists("paths.ini")); + ASSERT_EQ(readFromIni("paths.ini", "paths/decks"), QVariant("/custom/decks")); + ASSERT_EQ(readFromIni("paths.ini", "paths/pics"), QVariant("/custom/pics")); + + ASSERT_TRUE(fileExists("visual_deck_storage.ini")); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDeckStorage/sortingOrder"), QVariant(2)); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDeckStorage/showFolders"), QVariant(false)); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDeckStorage/showTagFilter"), QVariant(false)); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDeckStorage/defaultTagsList"), + QVariant(QStringList{"Alpha", "Beta"})); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDeckStorage/searchFolderNames"), QVariant(false)); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDeckStorage/showColorIdentity"), QVariant(false)); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDeckStorage/showBannerCardComboBox"), + QVariant(false)); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDeckStorage/showTagsOnDeckPreviews"), + QVariant(false)); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDeckStorage/drawUnusedColorIdentities"), + QVariant(false)); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDeckStorage/unusedColorIdentitiesOpacity"), + QVariant(35)); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDeckStorage/tooltipType"), QVariant(1)); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDeckStorage/promptForConversion"), + QVariant(false)); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDeckStorage/alwaysConvert"), QVariant(true)); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDeckStorage/inGame"), QVariant(false)); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDeckStorage/selectionAnimation"), + QVariant(false)); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDatabaseDisplay/filterToMostRecentSetsEnabled"), + QVariant(true)); + ASSERT_EQ(readFromIni("visual_deck_storage.ini", "interface/visualDatabaseDisplay/filterToMostRecentSetsAmount"), + QVariant(25)); + + // No legacy flat keys should remain in the per-file INIs + ASSERT_FALSE(iniFileHasKeyCaseSensitive("visual_deck_storage.ini", "interface/visualdeckstorageshowfolders")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("visual_deck_storage.ini", "interface/visualdeckstoragecardsize")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("deck_editor.ini", "deckeditor/sampleHandSize")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("deck_editor.ini", "deckeditor/cardSize")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("interface.ini", "interface/notificationsenabled")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("cards_display.ini", "cards/printingselectorsortorder")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("cards_display.ini", "cards/visualDeckStorage/cardSize")); + + // No legacy non-camelCase keys should remain in the per-file INIs + ASSERT_FALSE(iniFileHasKeyCaseSensitive("game.ini", "game/gamedescription")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("game.ini", "localgameoptions/maxplayers")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("chat.ini", "chat/roomhistory")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("chat.ini", "chat/highlightwords")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("sound.ini", "sound/mastervolume")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("downloads.ini", "downloads/picturedownload")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("network.ini", "network/keepalive")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("network.ini", "network/knownmissingfeatures")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("updates.ini", "updates/updatenotification")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("cards_display.ini", "cards/displaycardnames")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("cards_display.ini", "cards/cardinfoviewmode")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("interface.ini", "interface/usetearoffmenus")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("interface.ini", "interface/doubleclicktoplay")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("interface.ini", "interface/min_players_multicolumn")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("interface.ini", "table/invert_vertical")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("interface.ini", "zoneview/groupby")); + ASSERT_FALSE(iniFileHasKeyCaseSensitive("interface.ini", "zoneview/pileview")); + + // Verify sentinel was written + ASSERT_EQ(readFromIni("global.ini", "migration/perfile_complete"), QVariant(true)); +} + +TEST_F(SettingsMigrationTest, SecondCallDoesNothing) +{ + { + QSettings g(settingsPath + "global.ini", QSettings::IniFormat); + g.setValue("tabs/visualDeckStorage", true); + g.setValue("sound/enabled", true); + g.sync(); + } + + ASSERT_TRUE(SettingsMigration::migrateSettingsFromGlobalIni(settingsPath)); + ASSERT_FALSE(SettingsMigration::migrateSettingsFromGlobalIni(settingsPath)); +} + +TEST_F(SettingsMigrationTest, SecondCallDoesNotOverwriteUserChanges) +{ + { + QSettings g(settingsPath + "global.ini", QSettings::IniFormat); + g.setValue("tabs/visualDeckStorage", true); + g.setValue("sound/enabled", true); + g.sync(); + } + + ASSERT_TRUE(SettingsMigration::migrateSettingsFromGlobalIni(settingsPath)); + + // User changes the value after migration + { + QSettings tabsIni(settingsPath + "tabs.ini", QSettings::IniFormat); + tabsIni.setValue("tabs/visualDeckStorage", false); + tabsIni.sync(); + } + + // Second migration should NOT overwrite the user's change + ASSERT_FALSE(SettingsMigration::migrateSettingsFromGlobalIni(settingsPath)); + + ASSERT_EQ(readFromIni("tabs.ini", "tabs/visualDeckStorage"), QVariant(false)); +} + +TEST_F(SettingsMigrationTest, PartialMigrationOnlyMigratesPresentKeys) +{ + { + QSettings g(settingsPath + "global.ini", QSettings::IniFormat); + g.setValue("tabs/visualDeckStorage", true); + g.setValue("tabs/server", false); + g.sync(); + } + + ASSERT_TRUE(SettingsMigration::migrateSettingsFromGlobalIni(settingsPath)); + + ASSERT_TRUE(fileExists("global.ini.old")); + ASSERT_TRUE(fileExists("tabs.ini")); + ASSERT_EQ(readFromIni("tabs.ini", "tabs/visualDeckStorage"), QVariant(true)); + ASSERT_EQ(readFromIni("tabs.ini", "tabs/server"), QVariant(false)); + + // Other files should NOT exist since no keys were present for them + ASSERT_FALSE(fileExists("sound.ini")); + ASSERT_FALSE(fileExists("game.ini")); + ASSERT_FALSE(fileExists("chat.ini")); +} + +TEST_F(SettingsMigrationTest, KeyMapTranslationIsCorrect) +{ + { + QSettings g(settingsPath + "global.ini", QSettings::IniFormat); + g.setValue("personal/cardUpdateCheckInterval", 30); + g.setValue("personal/updatereleasechannel", 1); + g.sync(); + } + + ASSERT_TRUE(SettingsMigration::migrateSettingsFromGlobalIni(settingsPath)); + + ASSERT_TRUE(fileExists("global.ini.old")); + ASSERT_TRUE(fileExists("updates.ini")); + // The key should be translated from "personal/cardUpdateCheckInterval" to "updates/cardUpdateCheckInterval" + ASSERT_EQ(readFromIni("updates.ini", "updates/cardUpdateCheckInterval"), QVariant(30)); + ASSERT_EQ(readFromIni("updates.ini", "updates/updateReleaseChannel"), QVariant(1)); +} + +TEST_F(SettingsMigrationTest, CardsKeysKeepGroupPrefix) +{ + { + QSettings g(settingsPath + "global.ini", QSettings::IniFormat); + g.setValue("cards/displaycardnames", false); + g.setValue("interface/deckeditorbannercardcomboboxvisible", true); + g.sync(); + } + + ASSERT_TRUE(SettingsMigration::migrateSettingsFromGlobalIni(settingsPath)); + + ASSERT_TRUE(fileExists("global.ini.old")); + ASSERT_TRUE(fileExists("cards_display.ini")); + ASSERT_TRUE(fileExists("deck_editor.ini")); + // "cards/displaycardnames" should be stored with its group prefix + ASSERT_EQ(readFromIni("cards_display.ini", "cards/displayCardNames"), QVariant(false)); + // deck editor keys belong to the deck editor settings now + ASSERT_EQ(readFromIni("deck_editor.ini", "deckeditor/bannerCardComboBoxVisible"), QVariant(true)); +} + +TEST_F(SettingsMigrationTest, LegacyMigrationIsIdempotent) +{ + { + QSettings nativeSettings; + nativeSettings.setValue("server/previoushostlogin", "test_user"); + nativeSettings.setValue("server/auto_connect", true); + nativeSettings.sync(); + } + + ASSERT_TRUE(SettingsMigration::migrateLegacySettings(settingsPath)); + + // Change the migrated value + { + QSettings serversIni(settingsPath + "servers.ini", QSettings::IniFormat); + serversIni.setValue("server/previousHostLogin", "modified_user"); + serversIni.sync(); + } + + // Second migration should NOT overwrite the change + ASSERT_FALSE(SettingsMigration::migrateLegacySettings(settingsPath)); + ASSERT_EQ(readFromIni("servers.ini", "server/previousHostLogin"), QVariant("modified_user")); +} + +TEST_F(SettingsMigrationTest, LegacyMigrationCamelCasesKeys) +{ + { + QSettings nativeSettings; + nativeSettings.setValue("sets/AAA/sortkey", 2); + nativeSettings.setValue("sets/AAA/enabled", false); + nativeSettings.setValue("sets/AAA/isknown", false); + nativeSettings.setValue("server/previoushostlogin", "legacy_user"); + nativeSettings.setValue("server/auto_connect", 1); + nativeSettings.setValue("server/fpport", "5080"); + nativeSettings.setValue("messages/count", 1); + nativeSettings.setValue("messages/msg0", "hello"); + nativeSettings.setValue("filter_games/hide_full_games", true); + nativeSettings.setValue("filter_games/min_players", 3); + nativeSettings.setValue("filter_games/max_players", 5); + nativeSettings.setValue("filter_games/game_type/deadbeef", true); + nativeSettings.sync(); + } + + ASSERT_TRUE(SettingsMigration::migrateLegacySettings(settingsPath)); + + ASSERT_TRUE(fileExists("cardDatabase.ini")); + ASSERT_EQ(readFromIni("cardDatabase.ini", "sets/AAA/sortKey"), QVariant(2)); + ASSERT_EQ(readFromIni("cardDatabase.ini", "sets/AAA/enabled"), QVariant(false)); + ASSERT_EQ(readFromIni("cardDatabase.ini", "sets/AAA/isKnown"), QVariant(false)); + + ASSERT_TRUE(fileExists("servers.ini")); + ASSERT_EQ(readFromIni("servers.ini", "server/previousHostLogin"), QVariant("legacy_user")); + ASSERT_EQ(readFromIni("servers.ini", "server/autoConnect"), QVariant(1)); + ASSERT_EQ(readFromIni("servers.ini", "server/fpPort"), QVariant("5080")); + + ASSERT_TRUE(fileExists("messages.ini")); + ASSERT_EQ(readFromIni("messages.ini", "messages/count"), QVariant(1)); + ASSERT_EQ(readFromIni("messages.ini", "messages/msg0"), QVariant("hello")); + + ASSERT_TRUE(fileExists("gamefilters.ini")); + ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/hideFullGames"), QVariant(true)); + ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/minPlayers"), QVariant(3)); + ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/maxPlayers"), QVariant(5)); + ASSERT_EQ(readFromIni("gamefilters.ini", "filter_games/gameType/deadbeef"), QVariant(true)); +} + +TEST_F(SettingsMigrationTest, LegacyMigrationEmptyNativeFormatWritesSentinel) +{ + QSettings().clear(); + QSettings().sync(); + + ASSERT_TRUE(SettingsMigration::migrateLegacySettings(settingsPath)); + ASSERT_TRUE(fileExists("personal.ini")); + ASSERT_EQ(readFromIni("personal.ini", "migration/legacy_complete"), QVariant(true)); + ASSERT_FALSE(fileExists("messages.ini")); + ASSERT_FALSE(fileExists("gamefilters.ini")); + ASSERT_FALSE(fileExists("cardDatabase.ini")); + + // Second call should be a no-op + ASSERT_FALSE(SettingsMigration::migrateLegacySettings(settingsPath)); +} + +// --- migrateSettingsFromGlobalIni rename-failure test --- + +TEST_F(SettingsMigrationTest, RenameFailureWithExistingBackup) +{ + { + QSettings g(settingsPath + "global.ini", QSettings::IniFormat); + g.setValue("tabs/visualDeckStorage", true); + g.setValue("sound/enabled", true); + g.sync(); + } + + // Create a stale backup + { + QFile oldBackup(settingsPath + "global.ini.old"); + ASSERT_TRUE(oldBackup.open(QIODevice::WriteOnly)); + oldBackup.close(); + } + + // Migration should still succeed + ASSERT_TRUE(SettingsMigration::migrateSettingsFromGlobalIni(settingsPath)); + + // Per-file INIs should be created + ASSERT_TRUE(fileExists("tabs.ini")); + ASSERT_TRUE(fileExists("sound.ini")); + + // Sentinel should be set in the new global.ini + ASSERT_EQ(readFromIni("global.ini", "migration/perfile_complete"), QVariant(true)); + + // Original data should be backed up (stale backup was replaced) + ASSERT_TRUE(fileExists("global.ini.old")); + ASSERT_EQ(readFromIni("global.ini.old", "tabs/visualDeckStorage"), QVariant(true)); + + // Second call should be a no-op + ASSERT_FALSE(SettingsMigration::migrateSettingsFromGlobalIni(settingsPath)); +} + +} // namespace + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + QCoreApplication app(argc, argv); + app.setOrganizationName("CockatriceTest"); + app.setApplicationName("SettingsMigrationTest"); + QSettings::setDefaultFormat(QSettings::IniFormat); + return RUN_ALL_TESTS(); +} diff --git a/tests/warning_categories_test.cpp b/tests/warning_categories_test.cpp new file mode 100644 index 000000000..432885dd9 --- /dev/null +++ b/tests/warning_categories_test.cpp @@ -0,0 +1,89 @@ +#include "gtest/gtest.h" +#include +#include + +TEST(WarningCategoriesTest, EmptyValueYieldsNoCategories) +{ + EXPECT_TRUE(parseWarningCategories(QString()).isEmpty()); + EXPECT_TRUE(parseWarningCategories(QString("")).isEmpty()); +} + +TEST(WarningCategoriesTest, PlainNamesDefaultToInterventionLevelOne) +{ + const QList categories = parseWarningCategories("Flaming,Spamming,Causing Drama"); + + ASSERT_EQ(3, categories.size()); + EXPECT_EQ("Flaming", categories.at(0).name); + EXPECT_EQ(1, categories.at(0).startingIl); + EXPECT_EQ("Spamming", categories.at(1).name); + EXPECT_EQ(1, categories.at(1).startingIl); + EXPECT_EQ("Causing Drama", categories.at(2).name); + EXPECT_EQ(1, categories.at(2).startingIl); +} + +TEST(WarningCategoriesTest, ExplicitInterventionLevelsAreParsed) +{ + const QList categories = parseWarningCategories("Cheating|2,Inappropriate Avatar|3"); + + ASSERT_EQ(2, categories.size()); + EXPECT_EQ("Cheating", categories.at(0).name); + EXPECT_EQ(2, categories.at(0).startingIl); + EXPECT_EQ("Inappropriate Avatar", categories.at(1).name); + EXPECT_EQ(3, categories.at(1).startingIl); +} + +TEST(WarningCategoriesTest, MixedEntriesKeepDefaultsForThoseWithoutLevels) +{ + const QList categories = parseWarningCategories("Abusive Language|1,Cheating|2,Spamming"); + + ASSERT_EQ(3, categories.size()); + EXPECT_EQ(1, categories.at(0).startingIl); + EXPECT_EQ(2, categories.at(1).startingIl); + EXPECT_EQ("Spamming", categories.at(2).name); + EXPECT_EQ(1, categories.at(2).startingIl); +} + +TEST(WarningCategoriesTest, EmptyEntriesAreSkipped) +{ + const QList categories = parseWarningCategories("Spamming,,Cheating|2,"); + + ASSERT_EQ(2, categories.size()); + EXPECT_EQ("Spamming", categories.at(0).name); + EXPECT_EQ("Cheating", categories.at(1).name); +} + +TEST(WarningCategoriesTest, WhitespaceIsTrimmed) +{ + const QList categories = parseWarningCategories(" Abusive Language , Cheating | 2 "); + + ASSERT_EQ(2, categories.size()); + EXPECT_EQ("Abusive Language", categories.at(0).name); + EXPECT_EQ(1, categories.at(0).startingIl); + EXPECT_EQ("Cheating", categories.at(1).name); + EXPECT_EQ(2, categories.at(1).startingIl); +} + +TEST(WarningCategoriesTest, InvalidInterventionLevelsFallBackToOne) +{ + const QList categories = parseWarningCategories("Spamming|abc,Cheating|0,Targeted Harassment|-3"); + + ASSERT_EQ(3, categories.size()); + EXPECT_EQ(1, categories.at(0).startingIl); + EXPECT_EQ(1, categories.at(1).startingIl); + EXPECT_EQ(1, categories.at(2).startingIl); +} + +TEST(WarningCategoriesTest, EntryWithOnlyLevelIsSkipped) +{ + const QList categories = parseWarningCategories("|2,Spamming|2"); + + ASSERT_EQ(1, categories.size()); + EXPECT_EQ("Spamming", categories.at(0).name); + EXPECT_EQ(2, categories.at(0).startingIl); +} + +int main(int argc, char **argv) +{ + ::testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/vcpkg b/vcpkg index 56bb24116..9e593bb18 160000 --- a/vcpkg +++ b/vcpkg @@ -1 +1 @@ -Subproject commit 56bb2411609227288b70117ead2c47585ba07713 +Subproject commit 9e593bb18ea69cc5095e012465dcd675a822ed0d diff --git a/vcpkg.json b/vcpkg.json index ac7b75d07..f12227024 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -1,5 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/microsoft/vcpkg-tool/main/docs/vcpkg.schema.json", + "builtin-baseline": "9e593bb18ea69cc5095e012465dcd675a822ed0d", "dependencies": [ "gtest", "liblzma",