diff --git a/.ci/Arch/Dockerfile b/.ci/Arch/Dockerfile index 36cf5c4ae..f37315262 100644 --- a/.ci/Arch/Dockerfile +++ b/.ci/Arch/Dockerfile @@ -10,8 +10,10 @@ RUN pacman --sync --refresh --sysupgrade --needed --noconfirm \ ninja \ 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..0fa227d6f 100644 --- a/.ci/Debian12/Dockerfile +++ b/.ci/Debian12/Dockerfile @@ -20,7 +20,9 @@ RUN apt-get update && \ qt6-image-formats-plugins \ qt6-l10n-tools \ qt6-multimedia-dev \ + qt6-declarative-dev \ qt6-svg-dev \ + qt6-shadertools-dev \ qt6-tools-dev \ qt6-tools-dev-tools \ qt6-websockets-dev \ diff --git a/.ci/Debian13/Dockerfile b/.ci/Debian13/Dockerfile index d7ab6ac86..13e8b35c7 100644 --- a/.ci/Debian13/Dockerfile +++ b/.ci/Debian13/Dockerfile @@ -21,7 +21,9 @@ RUN apt-get update && \ qt6-image-formats-plugins \ qt6-l10n-tools \ qt6-multimedia-dev \ + qt6-declarative-dev \ qt6-svg-dev \ + qt6-shadertools-dev \ qt6-tools-dev \ qt6-tools-dev-tools \ qt6-websockets-dev \ diff --git a/.ci/Fedora43/Dockerfile b/.ci/Fedora43/Dockerfile index 27570cf99..68e894543 100644 --- a/.ci/Fedora43/Dockerfile +++ b/.ci/Fedora43/Dockerfile @@ -8,7 +8,7 @@ RUN dnf install -y \ mariadb-devel \ ninja-build \ protobuf-devel \ - qt6-{qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \ + qt6-{qtdeclarative,qtshadertools,qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \ qt6-qtimageformats \ rpm-build \ xz-devel \ diff --git a/.ci/Fedora44/Dockerfile b/.ci/Fedora44/Dockerfile index e6c8da7f3..ffd7c1b9b 100644 --- a/.ci/Fedora44/Dockerfile +++ b/.ci/Fedora44/Dockerfile @@ -8,7 +8,7 @@ RUN dnf install -y \ mariadb-devel \ ninja-build \ protobuf-devel \ - qt6-{qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \ + qt6-{qtdeclarative,qtshadertools,qttools,qtsvg,qtmultimedia,qtwebsockets}-devel \ qt6-qtimageformats \ rpm-build \ xz-devel \ diff --git a/.ci/Ubuntu24.04/Dockerfile b/.ci/Ubuntu24.04/Dockerfile index 809b2e43a..12320c276 100644 --- a/.ci/Ubuntu24.04/Dockerfile +++ b/.ci/Ubuntu24.04/Dockerfile @@ -20,7 +20,9 @@ RUN apt-get update && \ qt6-image-formats-plugins \ qt6-l10n-tools \ qt6-multimedia-dev \ + qt6-declarative-dev \ qt6-svg-dev \ + qt6-shadertools-dev \ qt6-tools-dev \ qt6-tools-dev-tools \ qt6-websockets-dev \ diff --git a/.ci/Ubuntu26.04/Dockerfile b/.ci/Ubuntu26.04/Dockerfile index 7b0cd389f..ce3d9cd6c 100644 --- a/.ci/Ubuntu26.04/Dockerfile +++ b/.ci/Ubuntu26.04/Dockerfile @@ -21,7 +21,9 @@ RUN apt-get update && \ qt6-image-formats-plugins \ qt6-l10n-tools \ qt6-multimedia-dev \ + qt6-declarative-dev \ qt6-svg-dev \ + qt6-shadertools-dev \ qt6-tools-dev \ qt6-tools-dev-tools \ qt6-websockets-dev \ 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/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 000000000..e895e2220 --- /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@v6 + + - name: "Initialize CodeQL" + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # https://docs.github.com/en/code-security/reference/code-scanning/codeql/codeql-queries/c-cpp-built-in-queries + # https://docs.github.com/en/code-security/reference/code-scanning/codeql/codeql-queries/actions-built-in-queries + queries: security-extended + dependency-caching: true + + - name: "[C++] Install dependencies" + if: matrix.language == 'cpp' && matrix.build-mode == 'manual' + shell: bash + env: + DEBIAN_FRONTEND: noninteractive + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + cmake \ + g++ \ + libgl-dev \ + liblzma-dev \ + libmariadb-dev-compat \ + libprotobuf-dev \ + ninja-build \ + protobuf-compiler \ + qt6-declarative-dev \ + qt6-multimedia-dev \ + qt6-shadertools-dev \ + qt6-svg-dev \ + qt6-tools-dev \ + qt6-tools-dev-tools \ + qt6-websockets-dev + +# Minimize dependency install +# Add ccache usage for faster compilation, (install ccache dep, actions/cache step + append DUSE_CCACHE=1 in cmake config, CCACHE env values) + + - name: "[C++] Configure CMake" + if: matrix.language == 'cpp' && matrix.build-mode == 'manual' + shell: bash + run: cmake -S . -B build -G Ninja -DWITH_SERVER=1 -DCMAKE_BUILD_TYPE=Release + + - name: "[C++] Build application" + if: matrix.language == 'cpp' && matrix.build-mode == 'manual' + shell: bash + run: cmake --build build + + - name: "Perform CodeQL Analysis" + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{ matrix.language }}" diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index f1846ecf6..04037a74e 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,7 +146,7 @@ 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: @@ -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 @@ -203,10 +200,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 +214,7 @@ jobs: if: github.ref == 'refs/heads/master' uses: actions/cache/save@v6 with: - key: ${{ steps.ccache_restore.outputs.cache-primary-key }} + key: ${{ steps.restore_ccache.outputs.cache-primary-key }} path: ${{ env.CACHE }} - name: "Upload artifact" @@ -262,74 +259,77 @@ jobs: matrix: include: - os: macOS - target: 13 - runner: macos-15-intel + target: 13 # EOL 2025-09-15 + runner: macos-15-intel # https://github.com/actions/runner-images/blob/main/images/macos/macos-15-Readme.md + # macos-26-intel is very slow and fails in CPack during DMG config if not increasing Finder timeout ccache_eviction_age: 7d cmake_generator: Ninja make_package: 1 override_target: 13 package_suffix: "-macOS13_Intel" - qt_version: 6.11.0 - qt_modules: qtimageformats qtmultimedia qtwebsockets + qt_version: 6.11.1 + qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools 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.1 + qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools 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.1 + qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools 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.1 + qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools soc: Apple type: Debug use_ccache: 1 - xcode: "16.4" + xcode: "26.6" - os: Windows target: 10 - runner: windows-2025 + runner: windows-2025 # https://github.com/actions/runner-images/blob/main/images/windows/Windows2025-VS2026-Readme.md cmake_generator: "Visual Studio 18 2026" cmake_generator_platform: x64 make_package: 1 package_suffix: "-Win10" - qt_version: 6.11.0 - qt_modules: qtimageformats qtmultimedia qtwebsockets + qt_version: 6.11.1 + qt_modules: qtimageformats qtmultimedia qtwebsockets qtshadertools type: Release name: ${{ matrix.os }} ${{ matrix.target }}${{ matrix.soc == 'Intel' && ' Intel' || '' }}${{ matrix.type == 'Debug' && ' Debug' || '' }} @@ -348,7 +348,6 @@ jobs: - name: "[Windows] Add msbuild to PATH" if: matrix.os == 'Windows' - id: add-msbuild uses: microsoft/setup-msbuild@v3 with: msbuild-architecture: x64 @@ -359,14 +358,11 @@ 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" run: pipx install aqtinstall @@ -384,7 +380,7 @@ jobs: 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 @@ -406,7 +402,7 @@ jobs: if: matrix.os == 'macOS' && steps.restore_qt.outputs.cache-hit != 'true' 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 }}" @@ -416,6 +412,7 @@ jobs: # 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-key-prefix: Qt modules: ${{ matrix.qt_modules }} version: ${{ steps.resolve_qt_version.outputs.version }} @@ -440,6 +437,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 }} @@ -450,25 +448,26 @@ jobs: USE_CCACHE: ${{ matrix.use_ccache }} VCPKG_BINARY_SOURCES: 'clear;files,${{ steps.vcpkg-cache.outputs.path }},readwrite' 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..df4fe233c 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: type=gha,mode=max,scope=${{ 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@v7 + 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 e90dc0151..37fe5d317 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/Dockerfile b/Dockerfile index 7c5c773c9..382309d47 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,35 +3,45 @@ 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 \ + 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 \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* diff --git a/cmake/FindQtRuntime.cmake b/cmake/FindQtRuntime.cmake index 78c9aa1b0..1ee1f215f 100644 --- a/cmake/FindQtRuntime.cmake +++ b/cmake/FindQtRuntime.cmake @@ -18,10 +18,13 @@ if(WITH_CLIENT) Multimedia Network PrintSupport + ShaderTools Svg WebSockets Widgets Xml + Quick + QuickWidgets ) endif() if(WITH_ORACLE) diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index a15fda582..85a95a9bd 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 @@ -143,7 +149,11 @@ set(cockatrice_SOURCES src/interface/intents/intent_wait_for_database_load.h src/interface/layouts/flow_layout.cpp src/interface/layouts/overlap_layout.cpp + src/interface/widgets/utility/card_completer_delegate.cpp + src/interface/widgets/utility/card_completer_styler.cpp + src/interface/widgets/utility/completer_utils.cpp src/interface/widgets/utility/line_edit_completer.cpp + src/interface/widgets/utility/reversed_completer_model.cpp src/interface/pixel_map_generator.cpp src/interface/theme_config.cpp src/interface/theme_manager.cpp @@ -155,6 +165,7 @@ 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_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 @@ -219,6 +230,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 @@ -234,6 +246,10 @@ set(cockatrice_SOURCES src/interface/widgets/printing_selector/printing_selector_card_selection_widget.cpp src/interface/widgets/printing_selector/printing_selector_card_sorting_widget.cpp src/interface/widgets/printing_selector/set_name_and_collectors_number_display_widget.cpp + src/interface/widgets/playmat/playmat_collection_dialog.cpp + src/interface/widgets/playmat/playmat_collection_dialog.h + src/interface/widgets/playmat/playmat_preview_widget.cpp + src/interface/widgets/playmat/playmat_settings_dialog.cpp src/interface/widgets/quick_settings/settings_button_widget.cpp src/interface/widgets/quick_settings/settings_popup_widget.cpp src/interface/widgets/replay/replay_manager.cpp @@ -242,6 +258,7 @@ set(cockatrice_SOURCES src/interface/widgets/replay/replay_widget.cpp src/interface/widgets/server/chat_view/chat_view.cpp src/interface/widgets/server/game_filter_configs.cpp + src/interface/widgets/server/game_link.cpp src/interface/widgets/server/game_selector.cpp src/interface/widgets/server/game_selector_quick_filter_toolbar.cpp src/interface/widgets/server/games_model.cpp @@ -256,6 +273,7 @@ set(cockatrice_SOURCES src/interface/widgets/server/user/user_info_connection.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 @@ -270,6 +288,8 @@ set(cockatrice_SOURCES src/interface/widgets/settings_page/user_interface_settings_page.cpp src/interface/widgets/utility/custom_line_edit.cpp src/interface/widgets/utility/get_text_with_max.cpp + src/interface/widgets/utility/report_utils.cpp + src/interface/widgets/utility/report_utils.h src/interface/widgets/utility/sequence_edit.cpp src/interface/widgets/utility/visibility_change_listener.cpp src/interface/widgets/utility/visibility_change_listener.h @@ -295,8 +315,10 @@ 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 @@ -361,6 +383,8 @@ set(cockatrice_SOURCES 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 @@ -374,6 +398,27 @@ set(cockatrice_SOURCES src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_service.cpp src/interface/widgets/tabs/api/commander_spellbook/commander_bracket_widget.cpp src/interface/widgets/tabs/api/commander_spellbook/handle_commander_brackets.cpp + src/interface/widgets/onboarding/banner_shader_config.h + src/interface/widgets/onboarding/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 @@ -392,6 +437,8 @@ set(cockatrice_SOURCES src/interface/intents/intent_join_server_room.h src/interface/intents/intent_login.cpp src/interface/intents/intent_login.h + src/interface/intents/intent_open_server_room_by_name.cpp + src/interface/intents/intent_open_server_room_by_name.h src/interface/intents/url_parser.cpp src/interface/intents/url_parser.h src/interface/widgets/server/user/user_info_popup.cpp @@ -468,6 +515,28 @@ qt6_add_executable( MANUAL_FINALIZATION ) +qt6_add_shaders( + cockatrice + "onboarding_shaders" + PREFIX + "/onboarding/shaders" + BASE + "src/interface/widgets/onboarding/shaders" + FILES + src/interface/widgets/onboarding/shaders/brand_banner.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 diff --git a/cockatrice/cockatrice.qrc b/cockatrice/cockatrice.qrc index 9c34929b7..e21bdb0be 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 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/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 4e425fb66..890a621c8 100644 --- a/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp +++ b/cockatrice/src/client/network/connection_controller/remote_connection_controller.cpp @@ -44,6 +44,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); @@ -296,6 +298,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/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/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/game/game_event_handler.cpp b/cockatrice/src/game/game_event_handler.cpp index 4a96eebdb..bc68d4d7c 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: { diff --git a/cockatrice/src/game/player/player_logic.cpp b/cockatrice/src/game/player/player_logic.cpp index 485e2fc5c..45ba09aac 100644 --- a/cockatrice/src/game/player/player_logic.cpp +++ b/cockatrice/src/game/player/player_logic.cpp @@ -250,6 +250,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_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_item.cpp b/cockatrice/src/game_graphics/board/abstract_card_item.cpp index e0029ee2d..1410d0c80 100644 --- a/cockatrice/src/game_graphics/board/abstract_card_item.cpp +++ b/cockatrice/src/game_graphics/board/abstract_card_item.cpp @@ -305,6 +305,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 a20fb1b3c..e63117e13 100644 --- a/cockatrice/src/game_graphics/board/abstract_counter.cpp +++ b/cockatrice/src/game_graphics/board/abstract_counter.cpp @@ -29,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) { value = newValue; + onValueChanged(oldValue, newValue); update(); }); @@ -228,3 +229,9 @@ void AbstractCounterDialog::changeValue(int diff) curValue += diff; setTextValue(QString::number(curValue)); } + +void AbstractCounter::onValueChanged(int /*oldValue*/, int /*newValue*/) +{ + // 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..9ddcc6d58 100644 --- a/cockatrice/src/game_graphics/board/abstract_counter.h +++ b/cockatrice/src/game_graphics/board/abstract_counter.h @@ -35,6 +35,13 @@ 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. + */ + virtual void onValueChanged(int oldValue, int newValue); + 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 ce8967bb5..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,10 +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) { @@ -47,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); } @@ -91,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)); @@ -111,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); @@ -133,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) @@ -226,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; } @@ -349,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.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_container.cpp b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp index 69941040f..bc07ac183 100644 --- a/cockatrice/src/game_graphics/deckview/deck_view_container.cpp +++ b/cockatrice/src/game_graphics/deckview/deck_view_container.cpp @@ -9,17 +9,21 @@ #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 @@ -100,6 +104,9 @@ DeckViewContainer::DeckViewContainer(int _playerId, TabGame *parent) connect(&SettingsCache::instance().visualDeckStorage(), &VisualDeckStorageSettings::visualDeckStorageInGameChanged, this, &DeckViewContainer::setVisualDeckStorageExists); + connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::playmatSettingsChanged, this, + &DeckViewContainer::onPlaymatSettingsChanged); + switchToDeckSelectView(); } @@ -277,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) { @@ -289,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() @@ -379,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); @@ -416,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/game_scene.cpp b/cockatrice/src/game_graphics/game_scene.cpp index db2088104..87af4c73c 100644 --- a/cockatrice/src/game_graphics/game_scene.cpp +++ b/cockatrice/src/game_graphics/game_scene.cpp @@ -17,7 +17,6 @@ #include #include #include -#include #include #include #include @@ -45,7 +44,14 @@ GameScene::GameScene(PhasesToolbar *_phasesToolbar, QObject *parent) GameScene::~GameScene() { + // Sever all incoming connections (animated item destroy-tracking) 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. + QObject::disconnect(nullptr, nullptr, this, nullptr); + delete animationTimer; + animationTimer = nullptr; // Delete all ArrowItems before QGraphicsScene's base destructor runs. // QGraphicsScene::~QGraphicsScene() destroys items in arbitrary order. @@ -246,17 +252,27 @@ void GameScene::adjustPlayerRotation(int rotationAdjustment) */ void GameScene::rearrange() { - int firstPlayerIndex = 0; - auto playersPlaying = collectActivePlayers(firstPlayerIndex); - playersPlaying = rotatePlayers(playersPlaying, firstPlayerIndex); + if (rearranging) { + needsReArrange = true; + return; + } + rearranging = true; + do { + needsReArrange = false; - int columns = determineColumnCount(playersPlaying.size()); - QSizeF sceneSize = computeSceneSizeAndPlayerLayout(playersPlaying, columns); + int firstPlayerIndex = 0; + auto playersPlaying = collectActivePlayers(firstPlayerIndex); + playersPlaying = rotatePlayers(playersPlaying, firstPlayerIndex); - phasesToolbar->setHeight(sceneSize.height()); - setSceneRect(0, 0, sceneSize.width(), sceneSize.height()); + int columns = determineColumnCount(playersPlaying.size()); + QSizeF sceneSize = computeSceneSizeAndPlayerLayout(playersPlaying, columns); - processViewSizeChange(viewSize); + phasesToolbar->setHeight(sceneSize.height()); + setSceneRect(0, 0, sceneSize.width(), sceneSize.height()); + + processViewSizeChange(viewSize); + } while (needsReArrange); + rearranging = false; } // ---------- View Size ---------- @@ -453,8 +469,14 @@ void GameScene::resizeColumnsAndPlayers(const QList &minWidthByColumn, qr qreal extraWidthPerColumn = (newWidth - minWidth) / playersByColumn.size(); qreal newx = phasesToolbar->getWidth(); - for (int col = 0; col < playersByColumn.size(); ++col) { - for (PlayerGraphicsItem *player : playersByColumn[col]) { + // Snapshot the columns: resizing a player's table can synchronously trigger + // GameScene::rearrange (table width -> sizeChanged -> updateBoundingRect -> + // sizeChanged -> rearrange), and rearrange rebuilds playersByColumn. Iterating + // the live container across that re-entrant call would use invalidated iterators. + const QList> columns = playersByColumn; + + for (int col = 0; col < columns.size(); ++col) { + for (PlayerGraphicsItem *player : columns[col]) { player->processSceneSizeChange(minWidthByColumn[col] + extraWidthPerColumn); player->setPos(newx, player->y()); } @@ -496,6 +518,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); } @@ -736,30 +759,45 @@ 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; + } + if (!animatedItems.contains(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); + 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..c12696189 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,11 @@ 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 + QBasicTimer *animationTimer; ///< Timer for scene animations + QHash animatedItems; ///< Items currently animating 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 +186,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 b768c8317..ed6355157 100644 --- a/cockatrice/src/game_graphics/game_view.cpp +++ b/cockatrice/src/game_graphics/game_view.cpp @@ -114,6 +114,7 @@ void GameView::startRubberBand(const QPointF &_selectionOrigin) } selectionOrigin = _selectionOrigin; + previousBandRect = QRect(); rubberBand->setGeometry(QRect(mapFromScene(selectionOrigin), QSize(0, 0))); rubberBand->show(); } @@ -128,7 +129,17 @@ void GameView::resizeRubberBand(const QPointF &cursorPoint, int selectedCount) QPoint cursor = cursorPoint.toPoint(); QRect rect = QRect(mapFromScene(selectionOrigin), cursor).normalized(); + rubberBand->setGeometry(rect); + if (viewport()) { + // Repaint the union of the previous and current band rects: the vacated + // strip of a child widget is not reliably invalidated on all platforms + // (notably macOS), leaving stale pixels under the selection. + QRect dirty = previousBandRect.isNull() ? rect : previousBandRect.united(rect); + dirty.adjust(-1, -1, 1, 1); + viewport()->update(dirty); + previousBandRect = rect; + } if (!SettingsCache::instance().userInterface().getShowDragSelectionCount()) { dragCountLabel->hide(); @@ -171,7 +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(); } 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/player/player_graphics_item.cpp b/cockatrice/src/game_graphics/player/player_graphics_item.cpp index d443853ce..8bf2703e1 100644 --- a/cockatrice/src/game_graphics/player/player_graphics_item.cpp +++ b/cockatrice/src/game_graphics/player/player_graphics_item.cpp @@ -1,6 +1,9 @@ #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/playmat/playmat_utils.h" #include "../../interface/widgets/tabs/tab_game.h" #include "../board/abstract_card_item.h" #include "../board/counter_general.h" @@ -13,6 +16,9 @@ #include "player_dialogs.h" #include +#include +#include +#include #include PlayerGraphicsItem::PlayerGraphicsItem(PlayerLogic *_player) : player(_player) @@ -28,6 +34,10 @@ PlayerGraphicsItem::PlayerGraphicsItem(PlayerLogic *_player) : player(_player) connect(player, &PlayerLogic::counterAdded, this, &PlayerGraphicsItem::onCounterAdded); connect(player, &PlayerLogic::counterRemoved, this, &PlayerGraphicsItem::onCounterRemoved); + connect(player, &PlayerLogic::deckChanged, this, &PlayerGraphicsItem::updatePlaymat); + connect(player, &PlayerLogic::playmatChanged, this, &PlayerGraphicsItem::updatePlaymat); + connect(&SettingsCache::instance().userInterface(), &InterfaceSettings::playmatVisibilityChanged, this, + [this](int) { updatePlaymat(); }); playerMenu = new PlayerMenu(this); @@ -67,6 +77,9 @@ PlayerGraphicsItem::PlayerGraphicsItem(PlayerLogic *_player) : player(_player) connect(tableZoneGraphicsItem, &TableZone::sizeChanged, this, &PlayerGraphicsItem::updateBoundingRect); + connect(this, &PlayerGraphicsItem::playmatChanged, tableZoneGraphicsItem, &TableZone::onPlaymatChanged); + connect(this, &PlayerGraphicsItem::playmatChanged, stackZoneGraphicsItem, &StackZone::onPlaymatChanged); + updateBoundingRect(); rearrangeZones(); @@ -112,7 +125,6 @@ void PlayerGraphicsItem::initializeZones() rfgZoneGraphicsItem->setPos(base + QPointF(0, 2 * h + h2 + 10)); tableZoneGraphicsItem = new TableZone(player->getTableZone(), mirrored, this); - connect(tableZoneGraphicsItem, &TableZone::sizeChanged, this, &PlayerGraphicsItem::updateBoundingRect); connect(this, &PlayerGraphicsItem::mirroredChanged, tableZoneGraphicsItem, &TableZone::setMirrored); stackZoneGraphicsItem = @@ -155,10 +167,61 @@ qreal PlayerGraphicsItem::getMinimumWidth() const return result; } -void PlayerGraphicsItem::paint(QPainter * /*painter*/, - const QStyleOptionGraphicsItem * /*option*/, - QWidget * /*widget*/) +void PlayerGraphicsItem::paint(QPainter *painter, const QStyleOptionGraphicsItem *, QWidget *) { + if (!hasPlaymat || playmatPixmap.isNull()) { + return; + } + + // Calculate the combined bounding rect of stack + table zones + QPointF stackPos = stackZoneGraphicsItem->pos(); + QPointF tablePos = tableZoneGraphicsItem->pos(); + QSizeF stackSize = stackZoneGraphicsItem->boundingRect().size(); + QSizeF tableSize = tableZoneGraphicsItem->boundingRect().size(); + + // Combined area: from stack left edge to table right edge + double combinedLeft = qMin(stackPos.x(), tablePos.x()); + double combinedTop = qMin(stackPos.y(), tablePos.y()); + double combinedRight = qMax(stackPos.x() + stackSize.width(), tablePos.x() + tableSize.width()); + double combinedBottom = qMax(stackPos.y() + stackSize.height(), tablePos.y() + tableSize.height()); + + QRectF combinedArea(combinedLeft, combinedTop, combinedRight - combinedLeft, combinedBottom - combinedTop); + + const QRectF srcRect = PlaymatUtils::computeArtSourceRect(playmatPixmap.size(), playmatParams); + const QRectF dstRect = PlaymatUtils::coverFitRect(combinedArea, srcRect.size()); + + painter->save(); + painter->setClipRect(combinedArea); + painter->setRenderHint(QPainter::SmoothPixmapTransform, true); + + // Render from a down-scaled copy of the art so the full-resolution source + // pixmap is never re-sampled at a tiny device size (also much cheaper than + // scaling it on every frame). + const QPixmap scaledPixmap = scaledPlaymatFor(srcRect, painter->worldTransform().mapRect(dstRect).size()); + painter->drawPixmap(dstRect, scaledPixmap, QRectF(scaledPixmap.rect())); + + painter->restore(); + + if (!playmatAttribution.isEmpty()) { + paintArtAttribution(*painter, combinedArea, playmatAttribution, Qt::AlignRight | Qt::AlignBottom, 0.8); + } +} + +QPixmap PlayerGraphicsItem::scaledPlaymatFor(const QRectF &srcRect, const QSizeF &deviceDstSize) +{ + // Bucket the render size so the source pixmap is re-scaled at most once per + // zoom step instead of once per frame. + constexpr int bucketSize = 32; + const QSize target = QSize(qMax(1, qRound(deviceDstSize.width() / bucketSize) * bucketSize), + qMax(1, qRound(deviceDstSize.height() / bucketSize) * bucketSize)) + .boundedTo(srcRect.toAlignedRect().size()); + + if (scaledPlaymatKey != target) { + const QPixmap crop = playmatPixmap.copy(srcRect.toAlignedRect()); + scaledPlaymatPixmap = crop.scaled(target, Qt::KeepAspectRatio, Qt::SmoothTransformation); + scaledPlaymatKey = target; + } + return scaledPlaymatPixmap; } void PlayerGraphicsItem::processSceneSizeChange(int newPlayerWidth) @@ -188,6 +251,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) { + if (newValue < oldValue) { + tableZoneGraphicsItem->triggerDamageShimmer(); + } + }); } else { widget = new GeneralCounter(state, player, true, this); } @@ -298,3 +366,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 = fullRes; + 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_target.cpp b/cockatrice/src/game_graphics/player/player_target.cpp index 567f3d44d..910ee9c17 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,48 @@ 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) +{ + flashDelta = newValue - oldValue; + if (flashDelta == 0) { + return; + } + + if (!SettingsCache::instance().userInterface().getLifeCounterAnimationsEnabled()) { + 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) diff --git a/cockatrice/src/game_graphics/player/player_target.h b/cockatrice/src/game_graphics/player/player_target.h index 67e155660..af0e9c8b7 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) 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/zones/stack_zone.cpp b/cockatrice/src/game_graphics/zones/stack_zone.cpp index 184f96d62..e9b14f13d 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, 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 4ef01853f..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 @@ -47,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); @@ -66,20 +92,43 @@ bool TableZone::isInverted() const void TableZone::paint(QPainter *painter, const QStyleOptionGraphicsItem * /*option*/, QWidget * /*widget*/) { - QBrush brush = themeManager->getExtraBgBrush(ThemeManager::Table, getLogic()->getPlayer()->getZoneId()); - painter->fillRect(boundingRect(), brush); + if (playmatActive) { + // Subtle overlay to distinguish table zone from stack zone + painter->fillRect(boundingRect(), QColor(0, 0, 0, 60)); + } else { + QBrush brush = themeManager->getExtraBgBrush(ThemeManager::Table, getLogic()->getPlayer()->getZoneId()); + painter->fillRect(boundingRect(), brush); + } if (active) { paintZoneOutline(painter); } else { // inactive player gets a darker table zone with a semi transparent black mask - // this means if the user provides a custom background it will fade + // this means if the user provides a custom background or playmat it will fade painter->fillRect(boundingRect(), FADE_MASK); } + // Decaying crimson wash from taking damage. + if (damageShimmerAlpha > 0.0) { + QColor shimmerColor(239, 68, 68); + shimmerColor.setAlphaF(0.22 * damageShimmerAlpha); + painter->fillRect(boundingRect(), shimmerColor); + } + paintLandDivider(painter); } +void TableZone::onPlaymatChanged(bool active) +{ + playmatActive = active; + // While a playmat is shown the zone paints a translucent overlay over the + // dynamic playmat behind it. Keep it out of the device cache so the cached + // pixels are never stale relative to the playmat (and to avoid compositing + // artifacts of cached translucent content on some platforms). + setCacheMode(active ? QGraphicsItem::NoCache : QGraphicsItem::DeviceCoordinateCache); + update(); +} + /** Render a soft outline around the edge of the TableZone. diff --git a/cockatrice/src/game_graphics/zones/table_zone.h b/cockatrice/src/game_graphics/zones/table_zone.h index 0d7e58206..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/interface/intents/contexts/context_join_game.h b/cockatrice/src/interface/intents/contexts/context_join_game.h index 102e2a520..2e5a88ea2 100644 --- a/cockatrice/src/interface/intents/contexts/context_join_game.h +++ b/cockatrice/src/interface/intents/contexts/context_join_game.h @@ -6,6 +6,7 @@ struct ContextJoinGame { ContextJoinRoom roomContext; int gameId; + bool asSpectator = false; }; #endif // COCKATRICE_CONTEXT_JOIN_GAME_H diff --git a/cockatrice/src/interface/intents/intent_join_server_game.cpp b/cockatrice/src/interface/intents/intent_join_server_game.cpp index fb9c4d5ce..205c4dc70 100644 --- a/cockatrice/src/interface/intents/intent_join_server_game.cpp +++ b/cockatrice/src/interface/intents/intent_join_server_game.cpp @@ -55,7 +55,7 @@ bool IntentJoinServerGame::tryJoinGame(TabRoom *room) return false; } - if (room->getGameSelector()->joinGameById(context->gameId)) { + if (room->getGameSelector()->joinGameById(context->gameId, context->asSpectator)) { emitFinished(); return true; } diff --git a/cockatrice/src/interface/intents/intent_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/url_parser.cpp b/cockatrice/src/interface/intents/url_parser.cpp index 8b5309603..509390611 100644 --- a/cockatrice/src/interface/intents/url_parser.cpp +++ b/cockatrice/src/interface/intents/url_parser.cpp @@ -1,5 +1,7 @@ #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" @@ -9,6 +11,7 @@ #include #include #include +#include #include IntentUrlParser::IntentUrlParser(QObject *parent, MainWindow *_mainWindow) : QObject(parent), mainWindow(_mainWindow) @@ -71,6 +74,15 @@ void IntentUrlParser::handleJoinGame(const QUrlQuery &query) 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; @@ -87,3 +99,38 @@ void IntentUrlParser::handleJoinGame(const QUrlQuery &query) 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 index bac0e3d25..6d705e013 100644 --- a/cockatrice/src/interface/intents/url_parser.h +++ b/cockatrice/src/interface/intents/url_parser.h @@ -4,6 +4,7 @@ #include class MainWindow; +struct ContextJoinGame; class IntentUrlParser : public QObject { Q_OBJECT @@ -14,6 +15,8 @@ public: void handleJoinGame(const QUrlQuery &query); private: + QString generateJoinGameMessage(const ContextJoinGame &context, const QString &gameDescription); + MainWindow *mainWindow; }; diff --git a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp index adae6e152..9cde72c01 100644 --- a/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp +++ b/cockatrice/src/interface/palette_editor/palette_editor_dialog.cpp @@ -290,28 +290,42 @@ void PaletteEditorDialog::onSave() // Persist every scheme that changed, not just the one on screen. Each scheme // has its own file, so edits to the non-active scheme would otherwise be // silently discarded when the dialog closes. + // + // Save the loaded scheme last so commitPalette's global colour-scheme + // update (ThemeConfig::colorScheme) points at the active scheme. for (auto it = workingConfig.begin(); it != workingConfig.end(); ++it) { - const QString &scheme = it.key(); - if (it.value().colors == savedConfig.value(scheme).colors) { - continue; // unchanged — leave the on-disk file alone + if (it.key() == loadedScheme) { + continue; } - - if (!ThemeManager::savePaletteConfig(saveDir, scheme, it.value())) { + if (it.value().colors == savedConfig.value(it.key()).colors) { + continue; + } + if (!ThemeManager::commitPalette(saveDir, it.key(), it.value())) { QMessageBox::warning(this, tr("Save failed"), - tr("Could not write %1 to:\n%2").arg(PaletteConfig::fileName(scheme), saveDir)); + tr("Could not write %1 to:\n%2").arg(PaletteConfig::fileName(it.key()), saveDir)); return; } } + // Commit the active scheme last so the global colour scheme matches. + if (workingConfig[loadedScheme].colors != savedConfig.value(loadedScheme).colors) { + if (!ThemeManager::commitPalette(saveDir, loadedScheme, workingConfig[loadedScheme])) { + QMessageBox::warning(this, tr("Save failed"), + tr("Could not write %1 to:\n%2").arg(PaletteConfig::fileName(loadedScheme), saveDir)); + return; + } + } else { + // No palette change but scheme may have switched -- still update global config. + ThemeConfig globalCfg = ThemeConfig::fromThemeDir(saveDir); + globalCfg.colorScheme = loadedScheme; + globalCfg.save(saveDir); + } + // Keep the saved snapshot in sync so Reset behaves correctly afterwards. for (auto it = workingConfig.begin(); it != workingConfig.end(); ++it) { savedConfig[it.key()] = it.value(); } - ThemeConfig globalCfg = ThemeConfig::fromThemeDir(saveDir); - globalCfg.colorScheme = loadedScheme; - globalCfg.save(saveDir); - themeManager->reloadCurrentTheme(); accept(); } diff --git a/cockatrice/src/interface/pixel_map_generator.cpp b/cockatrice/src/interface/pixel_map_generator.cpp index d3b0252a6..9b8c4bcdc 100644 --- a/cockatrice/src/interface/pixel_map_generator.cpp +++ b/cockatrice/src/interface/pixel_map_generator.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -14,6 +15,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 +62,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); @@ -247,7 +277,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); @@ -387,6 +419,57 @@ 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) { if (qApp->palette().windowText().color().lightness() > 200) { diff --git a/cockatrice/src/interface/pixel_map_generator.h b/cockatrice/src/interface/pixel_map_generator.h index 22f44d8db..17720166a 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,34 @@ 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); #endif diff --git a/cockatrice/src/interface/theme_manager.cpp b/cockatrice/src/interface/theme_manager.cpp index 518a97bc6..e6b4b3c7f 100644 --- a/cockatrice/src/interface/theme_manager.cpp +++ b/cockatrice/src/interface/theme_manager.cpp @@ -90,13 +90,17 @@ struct PaletteColorInfo } } +static QString usableDefaultStyle(const QString &style) +{ + // The Windows 11 native style is broken: when the OS default + // ("Default" 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(); @@ -119,7 +123,7 @@ void ThemeManager::ensureThemeDirectoryExists() } } -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) { @@ -268,6 +272,19 @@ PaletteConfig ThemeManager::loadDefaultPaletteConfig(const QString &themeDirPath return cfg; } +bool ThemeManager::commitPalette(const QString &themeDirPath, const QString &colorScheme, const PaletteConfig &cfg) +{ + if (!savePaletteConfig(themeDirPath, colorScheme, cfg)) { + return false; + } + + ThemeConfig globalCfg = ThemeConfig::fromThemeDir(themeDirPath); + globalCfg.colorScheme = colorScheme; + globalCfg.save(themeDirPath); + + return true; +} + void ThemeManager::setColorScheme(const QString &scheme) { const QString dirPath = getAvailableThemes().value(SettingsCache::instance().getThemeName()); @@ -316,13 +333,13 @@ void ThemeManager::applyStyleAndPalette(const QString &themeName, 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 diff --git a/cockatrice/src/interface/theme_manager.h b/cockatrice/src/interface/theme_manager.h index 861ab838b..79a1b6470 100644 --- a/cockatrice/src/interface/theme_manager.h +++ b/cockatrice/src/interface/theme_manager.h @@ -66,7 +66,14 @@ protected: public: bool isBuiltInTheme(); - bool isDarkMode(const QString &themeDirPath); + // 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 @@ -84,6 +91,10 @@ public: // 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); diff --git a/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp b/cockatrice/src/interface/widgets/cards/additional_info/color_identity_widget.cpp index 3f0f30a27..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 @@ -41,6 +41,11 @@ void ColorIdentityWidget::populateManaSymbolWidgets() // clear old layout QtUtils::clearLayoutRec(layout); + // The freshly created symbols haven't been sized yet, so force the next resize pass + // to apply the symbol size again. + lastIconSize = -1; + lastWidth = -1; + // populate mana symbols if (SettingsCache::instance().visualDeckStorage().getVisualDeckStorageDrawUnusedColorIdentities()) { for (const QString symbol : fullColorIdentity) { @@ -73,20 +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 51247da7a..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,15 +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 @@ -64,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_info_picture_enlarged_widget.cpp b/cockatrice/src/interface/widgets/cards/card_info_picture_enlarged_widget.cpp index f442fe425..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 @@ -52,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 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 fc53b296f..14defc8e9 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_editor_deck_dock_widget.cpp @@ -2,6 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../../../client/settings/shortcuts_settings.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" @@ -11,10 +12,12 @@ #include #include #include +#include #include #include #include #include +#include #include #include @@ -228,10 +231,18 @@ void DeckEditorDeckDockWidget::createDeckDock() upperLayout->addWidget(bannerCardLabel, 4, 0); upperLayout->addWidget(bannerCardComboBox, 4, 1); - upperLayout->addWidget(deckTagsDisplayWidget, 5, 1); + playmatLabel = new QLabel(); + playmatLabel->setObjectName("playmatLabel"); + playmatLabel->setText(tr("Playmat")); + playmatSettingsButton = new QPushButton(tr("Edit Playmat...")); + connect(playmatSettingsButton, &QPushButton::clicked, this, &DeckEditorDeckDockWidget::openPlaymatSettings); + upperLayout->addWidget(playmatLabel, 5, 0); + upperLayout->addWidget(playmatSettingsButton, 5, 1); - upperLayout->addWidget(activeGroupCriteriaLabel, 6, 0); - upperLayout->addWidget(activeGroupCriteriaComboBox, 6, 1); + upperLayout->addWidget(deckTagsDisplayWidget, 6, 1); + + upperLayout->addWidget(activeGroupCriteriaLabel, 7, 0); + upperLayout->addWidget(activeGroupCriteriaComboBox, 7, 1); hashLabel1 = new QLabel(); hashLabel1->setObjectName("hashLabel1"); @@ -440,6 +451,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( @@ -497,6 +537,7 @@ void DeckEditorDeckDockWidget::syncDisplayWidgetsToModel() syncBannerCardComboBoxSelectionWithDeck(); updateBannerCardComboBox(); bannerCardComboBox->blockSignals(false); + updatePlaymatLabel(); updateHash(); formatComboBox->blockSignals(true); 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 540199f0d..9db01e2e5 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,11 +15,15 @@ #include "deck_list_history_manager_widget.h" #include "deck_list_style_proxy.h" +#include +#include #include #include +#include #include #include #include +#include class CommanderBracketWidget; class DeckListModel; @@ -33,6 +37,8 @@ public: DeckListStyleProxy *proxy; QTreeView *deckView; QComboBox *bannerCardComboBox; + QLabel *playmatLabel; + QPushButton *playmatSettingsButton; void createDeckDock(); ExactCard getCurrentCard(); void retranslateUi(); @@ -102,6 +108,8 @@ private slots: 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_state_manager.cpp b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp index f8fb450ce..eda741728 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.cpp @@ -142,6 +142,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(); diff --git a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h index 6fce6be57..b9c99903e 100644 --- a/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h +++ b/cockatrice/src/interface/widgets/deck_editor/deck_state_manager.h @@ -171,6 +171,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); ///@} diff --git a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp index 2b1e9e8c2..59f3e033d 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_create_game.cpp @@ -214,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) { 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_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_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 fce99a1a7..6ae8c9adb 100644 --- a/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp +++ b/cockatrice/src/interface/widgets/dialogs/dlg_register.cpp @@ -1,18 +1,62 @@ #include "dlg_register.h" #include "../../../client/settings/cache_settings.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(QPixmap("theme: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(QPixmap("theme:icons/sync")); + btnRefreshServers->setToolTip(tr("Refresh the server list with known public servers")); + btnRefreshServers->setFixedWidth(30); + + connect(hps, &HandlePublicServers::sigPublicServersDownloadedSuccessfully, this, [this] { rebuildComboBoxList(); }); + connect(hps, &HandlePublicServers::sigPublicServersDownloadedUnsuccessfully, this, + &DlgRegister::rebuildComboBoxList); + connect(btnRefreshServers, &QPushButton::released, this, &DlgRegister::downloadThePublicServers); + + newHostButton = new QRadioButton(tr("New Host"), this); + + auto *serverPickerRow = new QHBoxLayout; + serverPickerRow->addWidget(previousHosts); + serverPickerRow->addWidget(btnDeleteServer); + serverPickerRow->addWidget(btnRefreshServers); + + auto *serverGroupLayout = new QVBoxLayout; + serverGroupLayout->addWidget(previousHostButton); + serverGroupLayout->addLayout(serverPickerRow); + serverGroupLayout->addWidget(newHostButton); + + auto *serverGroupBox = new QGroupBox(tr("Server")); + serverGroupBox->setLayout(serverGroupLayout); + + // ── Registration fields ──────────────────────────────────────────── ServersSettings &servers = SettingsCache::instance().servers(); infoLabel = new QLabel(tr("Enter your information and the information of the server you'd like to register to.\n" "Your email will be used to verify your account.")); @@ -321,26 +365,28 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent) realnameEdit->setMaxLength(MAX_NAME_LENGTH); realnameLabel->setBuddy(realnameEdit); + // ── Layout ───────────────────────────────────────────────────────── auto *grid = new QGridLayout; - grid->addWidget(infoLabel, 0, 0, 1, 2); - grid->addWidget(hostLabel, 1, 0); - grid->addWidget(hostEdit, 1, 1); - grid->addWidget(portLabel, 2, 0); - grid->addWidget(portEdit, 2, 1); - grid->addWidget(playernameLabel, 3, 0); - grid->addWidget(playernameEdit, 3, 1); - grid->addWidget(passwordLabel, 4, 0); - grid->addWidget(passwordEdit, 4, 1); - grid->addWidget(passwordConfirmationLabel, 5, 0); - grid->addWidget(passwordConfirmationEdit, 5, 1); - grid->addWidget(emailLabel, 6, 0); - grid->addWidget(emailEdit, 6, 1); - grid->addWidget(emailConfirmationLabel, 7, 0); - grid->addWidget(emailConfirmationEdit, 7, 1); - grid->addWidget(countryLabel, 9, 0); - grid->addWidget(countryEdit, 9, 1); - grid->addWidget(realnameLabel, 10, 0); - grid->addWidget(realnameEdit, 10, 1); + grid->addWidget(serverGroupBox, 0, 0, 1, 2); + grid->addWidget(infoLabel, 1, 0, 1, 2); + grid->addWidget(hostLabel, 2, 0); + grid->addWidget(hostEdit, 2, 1); + grid->addWidget(portLabel, 3, 0); + grid->addWidget(portEdit, 3, 1); + grid->addWidget(playernameLabel, 4, 0); + grid->addWidget(playernameEdit, 4, 1); + grid->addWidget(passwordLabel, 5, 0); + grid->addWidget(passwordEdit, 5, 1); + grid->addWidget(passwordConfirmationLabel, 6, 0); + grid->addWidget(passwordConfirmationEdit, 6, 1); + grid->addWidget(emailLabel, 7, 0); + grid->addWidget(emailEdit, 7, 1); + grid->addWidget(emailConfirmationLabel, 8, 0); + grid->addWidget(emailConfirmationEdit, 8, 1); + grid->addWidget(countryLabel, 10, 0); + grid->addWidget(countryEdit, 10, 1); + grid->addWidget(realnameLabel, 11, 0); + grid->addWidget(realnameEdit, 11, 1); auto *buttonBox = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); connect(buttonBox, &QDialogButtonBox::accepted, this, &DlgRegister::actOk); @@ -352,13 +398,115 @@ DlgRegister::DlgRegister(QWidget *parent) : QDialog(parent) setLayout(mainLayout); setWindowTitle(tr("Register to server")); - setFixedHeight(sizeHint().height()); - setMinimumWidth(300); + setMinimumWidth(360); + + connect(previousHostButton, &QRadioButton::toggled, this, &DlgRegister::previousHostSelected); + connect(newHostButton, &QRadioButton::toggled, this, &DlgRegister::newHostSelected); + connect(previousHosts, &QComboBox::currentTextChanged, this, &DlgRegister::updateDisplayInfo); + + previousHostButton->setChecked(true); + + preRebuildComboBoxList(); +} + +DlgRegister::~DlgRegister() = default; + +void DlgRegister::downloadThePublicServers() +{ + btnRefreshServers->setDisabled(true); + previousHosts->clear(); + previousHosts->addItem(placeHolderText); + hps->downloadPublicServers(); +} + +void DlgRegister::preRebuildComboBoxList() +{ + UserConnection_Information uci; + savedHostList = uci.getServerInfo(); + + if (savedHostList.size() == 1) { + downloadThePublicServers(); + } else { + rebuildComboBoxList(); + } +} + +void DlgRegister::rebuildComboBoxList(int failure) +{ + Q_UNUSED(failure); + + previousHosts->clear(); + + UserConnection_Information uci; + savedHostList = uci.getServerInfo(); + + auto &servers = SettingsCache::instance().servers(); + QString previousHostName = servers.getPrevioushostName(); + + for (const auto &pair : savedHostList) { + const auto &tmp = pair.second; + QString saveName = tmp.getSaveName(); + if (saveName.size()) { + previousHosts->addItem(saveName); + if (saveName.compare(previousHostName) == 0) { + previousHosts->setCurrentIndex(previousHosts->count() - 1); + } + } + } + + btnRefreshServers->setDisabled(false); +} + +void DlgRegister::previousHostSelected(bool state) +{ + if (state) { + previousHosts->setDisabled(false); + btnRefreshServers->setDisabled(false); + hostEdit->setDisabled(true); + portEdit->setDisabled(true); + } +} + +void DlgRegister::newHostSelected(bool state) +{ + if (state) { + previousHosts->setDisabled(true); + btnRefreshServers->setDisabled(true); + hostEdit->setDisabled(false); + hostEdit->clear(); + hostEdit->setPlaceholderText(tr("Server URL")); + portEdit->setDisabled(false); + portEdit->clear(); + portEdit->setPlaceholderText(tr("Communication Port")); + playernameEdit->setDisabled(false); + playernameEdit->clear(); + } else { + // Rebuild the list so the previously selected host's details are + // repopulated (mirrors DlgConnect::newHostSelected). + preRebuildComboBoxList(); + } +} + +void DlgRegister::updateDisplayInfo(const QString &saveName) +{ + if (saveName.isEmpty() || saveName == placeHolderText) { + return; + } + + UserConnection_Information uci; + QStringList _data = uci.getServerInfo(saveName); + + if (_data.size() < 7) { + return; + } + + hostEdit->setText(_data.at(1)); + portEdit->setText(_data.at(2)); + playernameEdit->setText(_data.at(3)); } void DlgRegister::actOk() { - //! \todo This stuff should be using QValidators. if (passwordEdit->text().length() < 8) { QMessageBox::critical(this, tr("Registration Warning"), tr("Your password is too short.")); return; @@ -375,5 +523,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/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/home_tab_button_color.h b/cockatrice/src/interface/widgets/general/home_tab_button_color.h new file mode 100644 index 000000000..1550b57e7 --- /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 +{ + Automatic, ///< Extract color from background, or use theme color if no background + FromBackground, ///< Always extract color from background +}; + +struct Entry +{ + Source source; + const char *trKey; ///< key for translation +}; + +inline QList all() +{ + static QList entries = {{Automatic, QT_TR_NOOP("Automatic")}, + {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::Automatic if the value is not within range + */ +inline Source intToSource(int value) +{ + if (value > FromBackground) { + return Automatic; // 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 8589e3517..10fcdcb43 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.cpp +++ b/cockatrice/src/interface/widgets/general/home_widget.cpp @@ -4,8 +4,10 @@ #include "../../../interface/widgets/tabs/tab_supervisor.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 @@ -24,7 +26,7 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor) backgroundSourceCard = new CardInfoPictureArtCropWidget(this); - gradientColors = extractDominantColors(background); + gradientColors = determineButtonColor(); layout->addWidget(createButtons(), 1, 1, Qt::AlignVCenter | Qt::AlignHCenter); @@ -54,6 +56,8 @@ HomeWidget::HomeWidget(QWidget *parent, TabSupervisor *_tabSupervisor) &HomeWidget::initializeBackgroundFromSource); connect(&SettingsCache::instance(), &SettingsCache::themeChanged, this, &HomeWidget::updateButtonsToBackgroundColor); + connect(&SettingsCache::instance().appearance(), &AppearanceSettings::homeTabButtonColorChanged, this, + &HomeWidget::updateButtonsToBackgroundColor); } void HomeWidget::initializeBackgroundFromSource() @@ -96,6 +100,34 @@ void HomeWidget::loadBackgroundSourceDeck() backgroundSourceDeck = deckOpt.has_value() ? deckOpt.value().deckList : DeckList(); } +static bool isDefaultBackgroundAndTheme() +{ + QString sourceId = SettingsCache::instance().appearance().getHomeTabBackgroundSource(); + return themeManager->isBuiltInTheme() && BackgroundSources::fromId(sourceId) == BackgroundSources::Theme; +} + +QPair HomeWidget::determineButtonColor() const +{ + static QPair defaultColor = {QColor::fromRgb(20, 140, 60), QColor::fromRgb(120, 200, 80)}; + + auto colorSource = + HomeTabButtonColor::intToSource(SettingsCache::instance().appearance().getHomeTabButtonColorSourceIndex()); + + switch (colorSource) { + case HomeTabButtonColor::Automatic: { + if (isDefaultBackgroundAndTheme()) { + return defaultColor; + } else { + return extractDominantColors(background); + } + } + case HomeTabButtonColor::FromBackground: + return extractDominantColors(background); + } + + return defaultColor; +} + void HomeWidget::setRandomCard(ExactCard &newCard) { static constexpr int ATTEMPTS = 10; @@ -170,7 +202,7 @@ void HomeWidget::updateBackgroundProperties() void HomeWidget::updateButtonsToBackgroundColor() { - gradientColors = extractDominantColors(background); + gradientColors = determineButtonColor(); for (HomeStyledButton *button : findChildren()) { button->updateStylesheet(gradientColors); button->update(); @@ -265,11 +297,6 @@ void HomeWidget::updateConnectButton(const ClientStatus status) QPair HomeWidget::extractDominantColors(const QPixmap &pixmap) { - if (themeManager->isBuiltInTheme() && SettingsCache::instance().appearance().getHomeTabBackgroundSource() == - BackgroundSources::toId(BackgroundSources::Theme)) { - return QPair(QColor::fromRgb(20, 140, 60), QColor::fromRgb(120, 200, 80)); - } - // Step 1: Downscale image for performance QImage image = pixmap.toImage() .scaled(64, 64, Qt::KeepAspectRatio, Qt::SmoothTransformation) @@ -341,8 +368,9 @@ void HomeWidget::paintEvent(QPaintEvent *event) 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(); @@ -350,8 +378,27 @@ void HomeWidget::paintEvent(QPaintEvent *event) cardName += " (" + card.getPrinting().getSet()->getCorrectedShortName() + ") " + card.getPrinting().getProperty("num"); } + attribution = buildArtAttribution(card); } + // Scryfall requires artist attribution wherever card art is shown cropped. + // Pin it to the bottom-right corner, using the same font as the card name pill, + // and align its right edge with the card name pill's right edge. + constexpr int margin = 15; + constexpr qreal attributionMargin = 4.0; + + QFont attributionFont = painter.font(); + attributionFont.setPointSize(14); + attributionFont.setBold(true); + painter.setFont(attributionFont); + + // paintArtAttribution insets the pill 4px from the given rect's right edge, + // so nudge the rect's right edge to land exactly on the pill's right edge. + QRectF attributionArea = rect(); + attributionArea.setRight(width() - margin + attributionMargin); + const QRectF attributionRect = paintArtAttribution(painter, attributionArea, attribution); + + // Card name bubble above the attribution (when enabled). if (!cardName.isEmpty() && SettingsCache::instance().appearance().getHomeTabDisplayCardName()) { QFont font = painter.font(); font.setPointSize(14); @@ -360,23 +407,26 @@ 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); diff --git a/cockatrice/src/interface/widgets/general/home_widget.h b/cockatrice/src/interface/widgets/general/home_widget.h index 90d003aa7..9df0d7b6a 100644 --- a/cockatrice/src/interface/widgets/general/home_widget.h +++ b/cockatrice/src/interface/widgets/general/home_widget.h @@ -23,7 +23,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; @@ -47,6 +47,7 @@ private: void setRandomCard(ExactCard &newCard); void loadBackgroundSourceDeck(); + QPair determineButtonColor() const; }; #endif // HOME_WIDGET_H 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..32f3e89c0 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/banner_shader_config.h @@ -0,0 +1,250 @@ +#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(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(); + } + } + + 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 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; + + QColor m_colorA{0x1A, 0x1A, 0x20}; + QColor m_colorB{0x0E, 0x0E, 0x12}; + QColor m_accent{0x8B, 0xDD, 0x6B}; + + bool m_logoVisible = false; + qreal m_logoGlow = 1.0; +}; + +#endif // BANNER_SHADER_CONFIG_H 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..618ac6f26 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.cpp @@ -0,0 +1,218 @@ +#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::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..2c186ef95 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/first_run_wizard.h @@ -0,0 +1,71 @@ +#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); + +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..12116de7a --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.cpp @@ -0,0 +1,314 @@ +#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 + +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(); + } +} + +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..0461d11d5 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/card_database_setup_page.h @@ -0,0 +1,79 @@ +#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); + +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..3293b19ac --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.cpp @@ -0,0 +1,231 @@ +#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 + +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); + + 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 newDir = themeManager->getAvailableThemes().value(SettingsCache::instance().getThemeName()); + 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); + + 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(); + + if (PaletteConfig::fromScheme(dirPath, scheme).hasPalette() || + PaletteConfig::fromDefault(dirPath, 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..d1f84c1b9 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/pages/theme_setup_page.h @@ -0,0 +1,58 @@ +#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; +}; + +#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..f1a385cad --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/qml/BrandBanner.qml @@ -0,0 +1,62 @@ +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 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 real uLogoGlow: bannerConfig.logoGlow + fragmentShader: "qrc:/onboarding/shaders/brand_banner.frag.qsb" + } + + // The hero logo itself — breathes cleanly over a 0.5–1.0 opacity range + Image { + id: logo + anchors.centerIn: parent + visible: bannerConfig.logoVisible + source: "qrc:/resources/cockatrice-logo-white.svg" + width: root.height * 0.6 + height: width * (sourceSize.height > 0 ? sourceSize.height / Math.max(sourceSize.width, 1) : 1) + fillMode: Image.PreserveAspectFit + smooth: true + opacity: 0.5 + 0.5 * bannerConfig.logoGlow + sourceSize: Qt.size(256, 256) + + Behavior on opacity { NumberAnimation { duration: 300; easing.type: Easing.InOutSine } } + + transform: Scale { + origin.x: logo.width / 2 + origin.y: logo.height / 2 + xScale: 0.94 + 0.06 * bannerConfig.logoGlow + yScale: 0.94 + 0.06 * bannerConfig.logoGlow + } + } +} 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..fd1fb2a98 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.cpp @@ -0,0 +1,195 @@ +#include "shader_banner_widget.h" + +#include "banner_shader_config.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +// Near-black base palette -- the background is dark and quiet so the green +// accent stands out. +constexpr QRgb kColorA = 0x1A1A20; +constexpr QRgb kColorB = 0x0E0E12; +constexpr QRgb kAccent = 0x8BDD6B; +} // namespace + +class GradientFallbackWidget : public QWidget +{ +public: + using QWidget::QWidget; + +protected: + void paintEvent(QPaintEvent *) override + { + QPainter painter(this); + QLinearGradient gradient(0, 0, width(), height()); + gradient.setColorAt(0.0, QColor(kColorA)); + gradient.setColorAt(1.0, QColor(kColorB)); + painter.fillRect(rect(), gradient); + } +}; + +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 + + 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(QColor(kColorA)); + config->setColorB(QColor(kColorB)); + config->setAccent(QColor(kAccent)); + 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::tick() +{ + 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..2e230ad7f --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/shader_banner_widget.h @@ -0,0 +1,83 @@ +#ifndef SHADER_BANNER_WIDGET_H +#define SHADER_BANNER_WIDGET_H + +#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(); + +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; + + 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..508bd4bc4 --- /dev/null +++ b/cockatrice/src/interface/widgets/onboarding/shaders/brand_banner.frag @@ -0,0 +1,461 @@ +#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; + 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.10; + + 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 + float centreLight = bloom(cDist, 0.08 * asp, 0.40 * asp); + col += 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 += 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.006 + hash21(vec2(fi * 2.9, uSeed * 4.7)) * 0.012; + float bright = 0.15 + hash21(vec2(fi * 6.1, uSeed * 0.9)) * 0.30; + + // 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.55, fill * 0.50); + + // 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.20, 0.45, 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.10; + } + } + } + + // Central bloom at banner centre + float cDist = length(ac - center); + col += uAccent.rgb * bloom(cDist, 0.04, 0.25) * 0.12; + + // 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.10; + + 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); + + col *= mix(0.62, 1.0, vignette(uv)); + fragColor = vec4(col, 1.0) * qt_Opacity; +} 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..57706cf93 --- /dev/null +++ b/cockatrice/src/interface/widgets/playmat/playmat_settings_dialog.cpp @@ -0,0 +1,324 @@ +#include "playmat_settings_dialog.h" + +#include "../../card_picture_loader/card_picture_loader.h" +#include "../cards/art_crop_attribution.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 = fullRes; + 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/printing_selector_card_overlay_widget.cpp b/cockatrice/src/interface/widgets/printing_selector/printing_selector_card_overlay_widget.cpp index 2b9201e6c..0b77ca185 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,6 +1,7 @@ #include "printing_selector_card_overlay_widget.h" #include "../../../client/settings/cache_settings.h" +#include "../cards/card_info_picture_widget.h" #include "printing_selector_card_display_widget.h" #include 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 064064380..c23e22651 100644 --- a/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp +++ b/cockatrice/src/interface/widgets/server/chat_view/chat_view.cpp @@ -13,6 +13,9 @@ #include #include #include +#include +#include +#include #include #include #include @@ -48,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() @@ -151,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(); } } @@ -169,7 +175,7 @@ void ChatView::appendHtmlServerMessage(const QString &html, bool optionalIsBold, prepareBlock().insertHtml(htmlText); if (atBottom) { - verticalScrollBar()->setValue(verticalScrollBar()->maximum()); + scrollToBottom(); } } @@ -215,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, @@ -227,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; @@ -338,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]")) { @@ -474,6 +553,17 @@ void ChatView::checkWord(QTextCursor &cursor, QString &message) } } + if (fullWordUpToSpaceOrEnd.startsWith("cockatrice://", Qt::CaseInsensitive)) { + // Only links to a game (cockatrice://joingame) become invite buttons; + // any other cockatrice:// scheme falls through to plain text below. + const QUrl gameLink(fullWordUpToSpaceOrEnd); + if (gameLink.host().compare("joingame", Qt::CaseInsensitive) == 0) { + appendGameLinkTag(cursor, fullWordUpToSpaceOrEnd); + cursor.insertText(rest, defaultFormat); + return; + } + } + // check word mentions for (const QString &word : highlightedWords) { if (fullWordUpToSpaceOrEnd.compare(word, Qt::CaseInsensitive) == 0) { @@ -558,6 +648,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) @@ -685,6 +788,11 @@ void ChatView::mouseReleaseEvent(QMouseEvent *event) void ChatView::openLink(const QUrl &link) { + if (link.scheme() == "cockatrice") { + emit cockatriceLinkActivated(link.toString(QUrl::FullyEncoded)); + return; + } + if ((link.scheme() == "card") || (link.scheme() == "user")) { return; } diff --git a/cockatrice/src/interface/widgets/server/chat_view/chat_view.h b/cockatrice/src/interface/widgets/server/chat_view/chat_view.h index 8d5894613..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); @@ -88,6 +100,8 @@ private slots: void actMessageClicked(); void adjustColorsToPalette(); void refreshBlockColors(); + void onScrollBarRangeChanged(); + void onScrollBarValueChanged(int value); public: ChatView(TabSupervisor *_tabSupervisor, AbstractGame *_game, bool _showTimestamps, QWidget *parent = nullptr); @@ -102,6 +116,7 @@ public: bool playerBold = false); void clearChat(); void redactMessages(const QString &userName, int amount); + QString getRecentChatLog(int maxMessages = 50) const; protected: void enterEvent(QEnterEvent *event) override; @@ -117,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 6580f0262..f41002247 100644 --- a/cockatrice/src/interface/widgets/server/game_selector.cpp +++ b/cockatrice/src/interface/widgets/server/game_selector.cpp @@ -7,6 +7,7 @@ #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" @@ -18,8 +19,6 @@ #include #include #include -#include -#include #include #include #include @@ -320,19 +319,12 @@ void GameSelector::customContextMenu(const QPoint &point) dlg.exec(); }); - QAction copyLink(tr("Copy Game Link")); + QAction copyLink(tr("Cop&y game link")); connect(©Link, &QAction::triggered, this, [=, this]() { const ServerInfo_Game &gameInfo = gameListModel->getGame(index.data(Qt::UserRole).toInt()); - QUrl url; - url.setScheme("cockatrice"); - url.setHost("joingame"); - QUrlQuery query; - query.addQueryItem("hostname", client->serverName()); - query.addQueryItem("port", QString::number(client->serverPort())); - query.addQueryItem("roomid", QString::number(gameInfo.room_id())); - query.addQueryItem("gameid", QString::number(gameInfo.game_id())); - url.setQuery(query); - QGuiApplication::clipboard()->setText(url.toString(QUrl::FullyEncoded)); + QGuiApplication::clipboard()->setText(makeGameJoinLink(client->serverName(), client->serverPort(), + gameInfo.room_id(), gameInfo.game_id(), + QString::fromStdString(gameInfo.description()))); }); QMenu menu; @@ -367,18 +359,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->getAdminLocked(); + + // 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; + QString password; if (game.with_password() && !(spectator && !game.spectators_need_password()) && !overrideRestrictions) { bool ok; - password = getTextWithMax(this, tr("Join game"), tr("Password:"), QLineEdit::Password, QString(), &ok); + // Games without a description have no sensible label — fall back to the + // game id so the prompt still tells the user which game they're entering. + const QString gameLabel = QString::fromStdString(game.description()); + const QString prompt = gameLabel.isEmpty() ? tr("Password for game #%1:").arg(game.game_id()) + : tr("Password for \"%1\":").arg(gameLabel); + password = getTextWithMax(this, tr("Join game"), prompt, QLineEdit::Password, QString(), &ok); if (!ok) { return; } @@ -404,18 +418,16 @@ void GameSelector::joinGame(const bool asSpectator, const bool asJudge) disableButtons(); } -bool GameSelector::joinGameById(int gameId) +bool GameSelector::joinGameById(const int gameId, const bool asSpectator) { - auto *model = gameListView->model(); - - for (int row = 0; row < model->rowCount(); ++row) { - QModelIndex idx = model->index(row, 0); - const ServerInfo_Game &game = gameListModel->getGame(idx.data(Qt::UserRole).toInt()); - if (game.game_id() == gameId) { - gameListView->setCurrentIndex(idx); - joinGame(); - return true; + for (int row = 0; row < gameListModel->rowCount(); ++row) { + const ServerInfo_Game &game = gameListModel->getGame(row); + if (game.game_id() != gameId) { + continue; } + + joinGame(game, asSpectator); + return true; } qWarning() << "Game" << gameId << "not found"; diff --git a/cockatrice/src/interface/widgets/server/game_selector.h b/cockatrice/src/interface/widgets/server/game_selector.h index da34d5322..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,7 +213,16 @@ public: * @param info The ServerInfo_Game object containing information about the game to update. */ void processGameInfo(const ServerInfo_Game &info); - bool joinGameById(int gameId); + /** + * @brief Finds a game by ID and joins or spectates it. + * @param gameId The ID of the game to join. + * @param asSpectator True to join as a spectator, false to join as a player. + * @return True if the game was found and joined, false otherwise. + * + * Unlike the selection-based overload, this does not depend on the game + * being visible in the filtered game list. + */ + bool joinGameById(int gameId, bool asSpectator = false); }; #endif diff --git a/cockatrice/src/interface/widgets/server/games_model.cpp b/cockatrice/src/interface/widgets/server/games_model.cpp index 8e623d392..127badbb2 100644 --- a/cockatrice/src/interface/widgets/server/games_model.cpp +++ b/cockatrice/src/interface/widgets/server/games_model.cpp @@ -17,13 +17,27 @@ enum GameListColumn ROOM, CREATED, DESCRIPTION, - CREATOR, + HOST, GAME_TYPE, RESTRICTIONS, PLAYERS, SPECTATORS }; +namespace +{ +/** + * @brief Returns the user info of the game's current host, falling back to the creator. + * + * The server only sends host_info once a host transfer has happened, so older + * servers and freshly created games fall back to the original creator. + */ +const ServerInfo_User &getGameHost(const ServerInfo_Game &game) +{ + return game.has_host_info() ? game.host_info() : game.creator_info(); +} +} // namespace + const QString GamesModel::getGameCreatedString(const int secs) { static const QTime zeroTime{0, 0}; @@ -110,16 +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(); @@ -233,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: @@ -271,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)); } @@ -347,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()}); @@ -364,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()) { @@ -409,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()) { @@ -435,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/user/user_card_art_provider.cpp b/cockatrice/src/interface/widgets/server/user/user_card_art_provider.cpp index 67fb4f684..3a1876fa1 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 @@ -39,7 +39,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; } @@ -63,6 +66,10 @@ QPixmap UserCardArtProvider::cropCardArt(const QPixmap &fullRes) void UserCardArtProvider::insertIntoCache(const QString &key, const QPixmap &pixmap) { + if (pixmap.isNull()) { + return; + } + if (!cardArtCache.contains(key)) { cacheInsertionOrder.append(key); while (cacheInsertionOrder.size() > MaxCacheEntries) { @@ -129,8 +136,6 @@ void UserCardArtProvider::processQueue() if (!fullRes.isNull()) { self->insertIntoCache(key, self->cropCardArt(fullRes)); - } else { - self->insertIntoCache(key, QPixmap()); } 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..2592237c4 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 @@ -26,7 +26,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..532112964 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) { @@ -327,13 +563,16 @@ void UserCardArtSettingsDialog::reloadPreview() currentPixmap = UserCardArtProvider::cropCardArt(fullRes); 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..646f2ee33 100644 --- a/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_context_menu.cpp @@ -1,5 +1,6 @@ #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" @@ -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); @@ -50,6 +52,7 @@ UserContextMenu::UserContextMenu(TabSupervisor *_tabSupervisor, QWidget *parent, aPromoteToJudge = new QAction(QString(), this); aDemoteFromJudge = new QAction(QString(), this); aGetAdminNotes = new QAction(QString(), this); + aInvestigateUser = new QAction(QString(), this); retranslateUi(); } @@ -64,6 +67,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")); @@ -73,6 +77,7 @@ void UserContextMenu::retranslateUi() aPromoteToJudge->setText(tr("Promote user to &judge")); aDemoteFromJudge->setText(tr("Demote user from judge")); aGetAdminNotes->setText(tr("View admin notes")); + aInvestigateUser->setText(tr("Investigate user")); } void UserContextMenu::gamesOfUserReceived(const Response &resp, const CommandContainer &commandContainer) @@ -144,7 +149,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(); @@ -355,6 +361,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 +373,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 +401,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 +417,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) && @@ -416,10 +438,10 @@ void UserContextMenu::showContextMenu(const QPoint &pos, menu->addAction(aPromoteToJudge); } } - bool anotherUser = userName != userListProxy->getOwnUsername(); aDetails->setEnabled(true); aChat->setEnabled(anotherUser && online); aShowGames->setEnabled(online); + aReport->setEnabled(anotherUser); aAddToBuddyList->setEnabled(anotherUser); aRemoveFromBuddyList->setEnabled(anotherUser); aAddToIgnoreList->setEnabled(anotherUser); @@ -430,6 +452,7 @@ void UserContextMenu::showContextMenu(const QPoint &pos, aBan->setEnabled(anotherUser); aBanHistory->setEnabled(anotherUser); aGetAdminNotes->setEnabled(anotherUser); + aInvestigateUser->setEnabled(anotherUser); aPromoteToMod->setEnabled(anotherUser); aDemoteFromMod->setEnabled(anotherUser); @@ -451,6 +474,15 @@ 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) { @@ -465,6 +497,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 +514,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()), @@ -587,6 +675,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; 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..f1ce931f8 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,14 @@ private: QAction *aAddToBuddyList, *aRemoveFromBuddyList; QAction *aAddToIgnoreList, *aRemoveFromIgnoreList; QAction *aKick; + QAction *aReport; QAction *aBan, *aBanHistory; QAction *aPromoteToMod, *aDemoteFromMod; QAction *aPromoteToJudge, *aDemoteFromJudge; QAction *aWarnUser, *aWarnHistory; QAction *aGetAdminNotes; + std::function()> gameInviteLinkProvider; + QAction *aInvestigateUser; signals: void openMessageDialog(const QString &userName, bool focus); private slots: @@ -80,9 +86,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 +120,12 @@ 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); + +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..875bdfb05 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_box.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_info_box.cpp @@ -319,6 +319,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_popup.cpp b/cockatrice/src/interface/widgets/server/user/user_info_popup.cpp index 74e567dde..a1d58fe5c 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); } @@ -187,20 +263,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 +288,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)); @@ -243,143 +319,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,16 +511,16 @@ 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); @@ -411,20 +528,20 @@ void UserInfoPopup::rebuildActionButtons(const ServerInfo_User &userInfo, bool o // ── 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 +554,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 +570,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 +605,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 +618,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 +631,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 +645,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 +694,40 @@ void UserInfoPopup::showForUser(const QString &userName, void UserInfoPopup::fetchGames() { - if (!m_client || m_currentUser.isEmpty()) { + if (!client || currentUser.isEmpty()) { return; } Command_GetGamesOfUser cmd; - cmd.set_user_name(m_currentUser.toStdString()); + cmd.set_user_name(currentUser.toStdString()); - const QString snapshot = m_currentUser; - PendingCommand *pend = m_client->prepareSessionCommand(cmd); + const QString snapshot = currentUser; + PendingCommand *pend = client->prepareSessionCommand(cmd); connect(pend, &PendingCommand::finished, this, [this, snapshot](const Response &r) { onGamesReceived(r, snapshot); }); - m_client->sendCommand(pend); + client->sendCommand(pend); } void UserInfoPopup::onGamesReceived(const Response &r, const QString &forUser) { - if (forUser != m_currentUser) { - return; // stale response — different user showing now + if (forUser != currentUser) { + return; // stale response, different user showing now } - m_gamesModel->clear(); + gamesModel->clear(); if (r.response_code() != Response::RespOk) { - m_gamesStatus->setText(tr("Could not load games.")); - m_gamesStatus->show(); - m_gamesView->hide(); + gamesStatus->setText(tr("Could not load games.")); + gamesStatus->show(); + gamesView->hide(); return; } const auto &resp = r.GetExtension(Response_GetGamesOfUser::ext); if (resp.game_list_size() == 0) { - m_gamesStatus->setText(tr("No active games.")); - m_gamesStatus->show(); - m_gamesView->hide(); + gamesStatus->setText(tr("No active games.")); + gamesStatus->show(); + gamesView->hide(); return; } @@ -608,29 +735,29 @@ void UserInfoPopup::onGamesReceived(const Response &r, const QString &forUser) auto *item = new QStandardItem; item->setData(QVariant::fromValue(resp.game_list(i)), PopupRoles::GameData); item->setEditable(false); - m_gamesModel->appendRow(item); + gamesModel->appendRow(item); } - m_gamesStatus->hide(); - m_gamesView->show(); + gamesStatus->hide(); + gamesView->show(); // Fit exactly to the number of visible rows, scroll when more than 5 constexpr int rowH = 38; // must match PopupGameDelegate::sizeHint constexpr int maxRows = 5; - const int count = m_gamesModel->rowCount(); + const int count = gamesModel->rowCount(); const int visible = qMin(count, maxRows); - m_gamesView->setFixedHeight(visible * rowH + 2); - m_gamesView->setVerticalScrollBarPolicy(count > maxRows ? Qt::ScrollBarAlwaysOn : Qt::ScrollBarAlwaysOff); + gamesView->setFixedHeight(visible * rowH + 2); + gamesView->setVerticalScrollBarPolicy(count > maxRows ? Qt::ScrollBarAlwaysOn : Qt::ScrollBarAlwaysOff); adjustSize(); } void UserInfoPopup::refreshGames() { - m_gamesModel->clear(); - m_gamesView->hide(); - m_gamesStatus->setText(tr("Loading games…")); - m_gamesStatus->show(); + gamesModel->clear(); + gamesView->hide(); + gamesStatus->setText(tr("Loading games…")); + gamesStatus->show(); fetchGames(); } @@ -646,4 +773,4 @@ void UserInfoPopup::leaveEvent(QEvent *e) { QFrame::leaveEvent(e); emit mouseLeftPopup(); -} \ No newline at end of file +} diff --git a/cockatrice/src/interface/widgets/server/user/user_info_popup.h b/cockatrice/src/interface/widgets/server/user/user_info_popup.h index 69517093f..02cc2b44e 100644 --- a/cockatrice/src/interface/widgets/server/user/user_info_popup.h +++ b/cockatrice/src/interface/widgets/server/user/user_info_popup.h @@ -26,6 +26,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 +80,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 +123,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 +138,17 @@ 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(); + signals: void mouseEnteredPopup(); void mouseLeftPopup(); @@ -153,25 +186,30 @@ 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; + + 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_painter.cpp b/cockatrice/src/interface/widgets/server/user/user_list_painter.cpp index 8891ff268..5c65b090d 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,6 +20,29 @@ 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; @@ -59,16 +83,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 +153,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 +176,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 +217,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 +238,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 +251,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 +267,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 +276,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); } @@ -262,7 +319,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 +342,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 +365,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 +385,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::IsModerator) || + userLevel.testFlag(ServerInfo_User::IsJudge); const QRectF cardRect = QRectF(rect).adjusted(3, 2, -3, -2); const int cardRight = getCardRight(option, rect); @@ -324,19 +395,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 64cbb7b7d..e7570ab26 100644 --- a/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp +++ b/cockatrice/src/interface/widgets/server/user/user_list_widget.cpp @@ -2,7 +2,9 @@ #include "../../../../client/settings/cache_settings.h" #include "../../../card_picture_loader/card_picture_loader.h" +#include "../../cards/art_crop_attribution.h" #include "../../interface/pixel_map_generator.h" +#include "../../interface/theme_manager.h" #include "../../interface/widgets/tabs/tab_account.h" #include "../../interface/widgets/tabs/tab_supervisor.h" #include "../game_selector.h" @@ -11,11 +13,17 @@ #include #include +#include +#include +#include +#include #include #include #include +#include #include #include +#include #include #include #include @@ -23,6 +31,8 @@ #include #include #include +#include +#include #include #include #include @@ -141,7 +151,7 @@ WarningDialog::WarningDialog(const QString userName, const QString clientID, QWi warnClientID = new QLineEdit(clientID); warnClientID->setMaxLength(MAX_NAME_LENGTH); warningOption = new QComboBox(); - warningOption->addItem(""); + warningOption->addItem("", ""); deleteMessages = new QCheckBox(tr("Redact all messages from this user in all rooms")); @@ -174,7 +184,7 @@ void WarningDialog::okClicked() return; } - if (warningOption->currentText().simplified().isEmpty()) { + 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; @@ -195,7 +205,7 @@ QString WarningDialog::getWarnID() const QString WarningDialog::getReason() const { - return warningOption->currentText().simplified(); + return warningOption->currentData().toString().simplified(); } int WarningDialog::getDeleteMessages() const @@ -203,9 +213,13 @@ int WarningDialog::getDeleteMessages() const return deleteMessages->isChecked() ? -1 : 0; } -void WarningDialog::addWarningOption(const QString warning) +void WarningDialog::addWarningOption(const QString warning, int startingIl) { - warningOption->addItem(warning); + if (startingIl > 1) { + warningOption->addItem(tr("%1 (IL %2)").arg(warning).arg(startingIl), warning); + } else { + warningOption->addItem(warning, warning); + } } void BanDialog::okClicked() @@ -323,11 +337,16 @@ constexpr int Online = Qt::UserRole + 1; constexpr int UserInfo = Qt::UserRole + 2; } // namespace UserListRoles -UserListItemDelegate::UserListItemDelegate(QObject *const parent, +// Divider items (section headers) in sectioned mode are distinguished from user +// rows (UserListTWI, which uses QTreeWidgetItem::Type) by this item type. +constexpr int SectionItemType = QTreeWidgetItem::UserType + 1; + +UserListItemDelegate::UserListItemDelegate(UserListWidget *owner, + QTreeWidget *tree, const QMap *avatarCache, const QMap *cardArtCache, const QMap *cardArtParamsMap) - : QStyledItemDelegate(parent), avatarCache(avatarCache), cardArtCache(cardArtCache), + : QStyledItemDelegate(tree), tree(tree), owner(owner), avatarCache(avatarCache), cardArtCache(cardArtCache), cardArtParamsMap(cardArtParamsMap) { } @@ -340,7 +359,10 @@ bool UserListItemDelegate::editorEvent(QEvent *event, if ((event->type() == QEvent::MouseButtonPress) && index.isValid()) { QMouseEvent *const mouseEvent = static_cast(event); if (mouseEvent->button() == Qt::RightButton) { - static_cast(parent())->showContextMenu(mouseEvent->globalPosition().toPoint(), index); + // Dialog mode has no context menu: consume the press, show nothing. + if (owner->getHasUserInfoPopup()) { + owner->showContextMenu(mouseEvent->globalPosition().toPoint(), index); + } return true; } } @@ -352,25 +374,129 @@ QSize UserListItemDelegate::sizeHint(const QStyleOptionViewItem &option, const Q if (!SettingsCache::instance().appearance().getStyleUserList()) { return QStyledItemDelegate::sizeHint(option, index); } + if (!index.data(UserListRoles::UserInfo).isValid()) { + return QStyledItemDelegate::sizeHint(option, index); // section dividers stay compact + } return UserListPainter::sizeHint(); } void UserListItemDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const { - if (!SettingsCache::instance().appearance().getStyleUserList()) { - QStyledItemDelegate::paint(painter, option, index); + const bool styled = SettingsCache::instance().appearance().getStyleUserList(); + // UserInfo/Online are stored on column 0 only. The name lives on column 2, + // so resolve the user data against column 0 no matter which cell is painted. + const QModelIndex userIndex = index.siblingAtColumn(0).isValid() ? index.siblingAtColumn(0) : index; + const QVariant var = userIndex.data(UserListRoles::UserInfo); + + if (styled && var.isValid()) { + // Styled card rows: the tree's cached palette can be stale after a + // runtime theme change, so paint from the application palette (always + // current) instead of option.palette (frozen at the last style switch). + QStyleOptionViewItem opt = option; + opt.palette = qApp->palette(); + + UserListPainter::paint(painter, opt, index, var.value(), avatarCache, cardArtCache, + cardArtParamsMap, themeManager && themeManager->isDarkModeActive()); return; } - const QVariant var = index.data(UserListRoles::UserInfo); + // Unstyled rows and section dividers are painted manually so every color + // is derived from the current application palette at paint time. The widget + // palette, the view's alternation, and the stored brushes (one per item) + // all go stale after a runtime theme change. + const QPalette appPal = qApp->palette(); + const bool selected = option.state & QStyle::State_Selected; + const bool hovered = option.state & QStyle::State_MouseOver; - if (!var.isValid()) { - QStyledItemDelegate::paint(painter, option, index); - return; + // Row background: selection, hover, zebra striping, plain base. + QColor bg = appPal.color(QPalette::Base); + if (selected) { + bg = appPal.color(QPalette::Highlight); + } else if (hovered) { + bg = UserListPainter::blend(appPal.color(QPalette::Base), appPal.color(QPalette::Highlight), 0.12); + } else if (var.isValid()) { + // Zebra alternation per section. The dividers are top level rows too, + // so the view's own alternation would drift across sections. Count the + // visible user rows back to the previous divider within the same parent + // (sectioned mode stores users as children of the dividers, flat mode + // as top level rows), so the stripe restarts at every divider and at + // the tree top. Hidden filter matches are skipped the same way the view + // skips them, so adjacent visible rows always alternate. + int usersSinceDivider = 0; + const QModelIndex parent = userIndex.parent(); + for (int r = userIndex.row() - 1; r >= 0; --r) { + if (tree->isRowHidden(r, parent)) { + continue; + } + const QModelIndex above = userIndex.model()->index(r, 0, parent); + if (above.isValid() && above.data(UserListRoles::UserInfo).isValid()) { + ++usersSinceDivider; + } else { + break; + } + } + if (usersSinceDivider % 2 == 1) { + bg = appPal.color(QPalette::AlternateBase); + } + } + // Paint the row background. In the column 0 pass the fill spans the full + // viewport width so stripes, hover and selection cover the whole row (the + // name column is content sized in unstyled mode). Later column passes fill + // only their own cell, which is the same color and cannot cover the icons. + QRect bgRect = option.rect; + if (index.column() == 0) { + bgRect = QRect(0, option.rect.top(), tree->viewport()->width(), option.rect.height()); + } + painter->fillRect(bgRect, bg); + + // Text color. + QColor fg = appPal.color(QPalette::Text); + if (selected) { + fg = appPal.color(QPalette::HighlightedText); + } else if (!var.isValid()) { + // Section divider: muted application text color. + fg = appPal.color(QPalette::WindowText); + fg.setAlpha(170); + } else if (index.column() == 2) { + // Name column: online/offline color recomputed at paint time instead + // of trusting the brush stored at login time. + QTreeWidgetItem *item = tree->itemFromIndex(index); + const bool online = item && item->data(0, UserListRoles::Online).toBool(); + if (online) { + fg = appPal.color(QPalette::WindowText); + } else { + fg = (themeManager && themeManager->isDarkModeActive()) + ? QColor(Qt::gray) + : UserListPainter::blend(appPal.color(QPalette::Text), appPal.color(QPalette::Mid), 0.5); + } } - UserListPainter::paint(painter, option, index, var.value(), avatarCache, cardArtCache, - cardArtParamsMap); + // Icon (level badge in column 0, country flag in column 1). + QRect textRect = option.rect; + const QIcon icon = index.data(Qt::DecorationRole).value(); + if (!icon.isNull()) { + const QSize iconSize = icon.actualSize(QSize(18, 18)); + const QRect iconRect(option.rect.left() + 2, option.rect.center().y() - iconSize.height() / 2, iconSize.width(), + iconSize.height()); + icon.paint(painter, iconRect); + textRect.setLeft(iconRect.right() + 4); + } + + // Text (name column / divider title), elided to the row width. + painter->save(); + painter->setPen(fg); + const QFont itemFont = index.data(Qt::FontRole).value(); + painter->setFont(itemFont.isCopyOf(QFont()) ? option.font : itemFont); + const QString text = index.data(Qt::DisplayRole).toString(); + const QString elided = painter->fontMetrics().elidedText(text, Qt::ElideRight, textRect.width() - 4); + painter->drawText(textRect.adjusted(2, 0, -2, 0), Qt::AlignLeft | Qt::AlignVCenter, elided); + painter->restore(); + + // Focus indicator for the current item. + if (option.state & QStyle::State_HasFocus) { + painter->setPen(appPal.color(QPalette::Highlight)); + painter->drawRect(option.rect.adjusted(0, 0, -1, -1)); + } } UserListTWI::UserListTWI(const ServerInfo_User &_userInfo) : QTreeWidgetItem(Type) @@ -394,8 +520,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)); } /** @@ -458,15 +586,14 @@ bool UserListTWI::operator<(const QTreeWidgetItem &other) const UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, AbstractClient *_client, UserListType _type, - QWidget *parent) - : QGroupBox(parent), tabSupervisor(_tabSupervisor), client(_client), type(_type), onlineCount(0) + QWidget *parent, + bool _hasUserInfoPopup) + : QGroupBox(parent), hasUserInfoPopup(_hasUserInfoPopup), tabSupervisor(_tabSupervisor), client(_client), + type(_type), onlineCount(0) { avatarProvider = new UserAvatarProvider(client, this); cardArtProvider = new UserCardArtProvider(this); - itemDelegate = - new UserListItemDelegate(this, &avatarProvider->cache(), &cardArtProvider->cache(), &cardArtParamsMap); - userContextMenu = new UserContextMenu(tabSupervisor, this); connect(userContextMenu, &UserContextMenu::openMessageDialog, this, &UserListWidget::openMessageDialog); @@ -477,6 +604,8 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, userTree->setHeaderHidden(true); userTree->setRootIsDecorated(false); userTree->setIconSize(QSize(20, 18)); + itemDelegate = new UserListItemDelegate(this, userTree, &avatarProvider->cache(), &cardArtProvider->cache(), + &cardArtParamsMap); userTree->setItemDelegate(itemDelegate); userTree->setAlternatingRowColors(true); userTree->hideColumn(1); @@ -484,79 +613,173 @@ 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 + + userInfoPopup->hide(); + userInfoPopup->setWindowOpacity(0.0); + userInfoPopup->installEventFilter(this); + + connectPopupSignals(); + } + userTree->setMouseTracking(true); userTree->viewport()->setMouseTracking(true); userTree->viewport()->installEventFilter(this); + userTree->installEventFilter(this); // keyboard handling for section dividers - // Pin on item click - connect(userTree, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem *item, int) { - if (!SettingsCache::instance().appearance().getStyleUserList()) { - return; - } - const QString name = static_cast(item)->getUserInfo().name().c_str(); - m_popupPinned = false; // reset so showPopupForUser can update - showPopupForUser(name); - m_popupPinned = true; // pin after showing - }); + if (hasUserInfoPopup) { + // Clicking anywhere outside the list clears its selection and closes the + // popup. The filter watches all widgets because the press can land on any + // part of the window, on another list or on the popup itself. + qApp->installEventFilter(this); - connect(userTree->selectionModel(), &QItemSelectionModel::selectionChanged, this, - [this](const QItemSelection &sel, const QItemSelection &) { - // if (m_rebuildingTree) return; - if (sel.isEmpty() && m_popupPinned) { - m_popupPinned = false; - hidePopup(); - } - }); + // Pin on item click + connect(userTree, &QTreeWidget::itemClicked, this, [this](QTreeWidgetItem *item, int) { + // Clicking a section divider toggles it + if (sectioned && item->type() == SectionItemType) { + setExpandedProgrammatically(item, !item->isExpanded()); + handleSectionExpansion(item, item->isExpanded()); + return; + } + if (!SettingsCache::instance().appearance().getStyleUserList()) { + return; + } + if (item->type() != QTreeWidgetItem::Type) { + return; // divider rows have no user popup + } + popupPinned = false; // reset so showPopupForUser can update + showPopupForUser(static_cast(item)); + popupPinned = true; // pin after showing + }); - // Hide popup when list scrolls (reference row has moved) - connect(userTree->verticalScrollBar(), &QScrollBar::valueChanged, this, [this] { - m_showPopupTimer->stop(); - hidePopup(true); - }); + connect(userTree->selectionModel(), &QItemSelectionModel::selectionChanged, this, + [this](const QItemSelection &sel, const QItemSelection &) { + if (sel.isEmpty() && popupPinned) { + popupPinned = false; + hidePopup(); + } + }); - // Forward join requests from popup upward - connect(m_userInfoPopup, &UserInfoPopup::joinGameRequested, this, &UserListWidget::joinGameRequested); + // Keyboard selection: the popup is a mouse surface, so keyboard + // navigation shows no floating popup. A pinned (clicked) popup still + // follows the selection so it does not strand on a stale user while + // arrows move the cursor. + connect(userTree, &QTreeWidget::currentItemChanged, this, [this](QTreeWidgetItem *current, QTreeWidgetItem *) { + if (!popupPinned) { + return; // keyboard navigation shows no popup + } + if (!isVisible() || !SettingsCache::instance().appearance().getStyleUserList()) { + return; + } + if (current && current->type() == QTreeWidgetItem::Type) { + showPopupForUser(static_cast(current)); + } else { + popupPinned = false; + hidePopup(); + } + }); - connect(avatarProvider, &UserAvatarProvider::avatarUpdated, this, - [this](const QString &) { userTree->viewport()->update(); }); - connect(cardArtProvider, &UserCardArtProvider::cardArtUpdated, this, - [this](const QString &) { userTree->viewport()->update(); }); + // Section dividers can be collapsed/expanded by the user. Surface those + // changes only from real user interaction. Programmatic expansion is + // applied through setSectionExpanded() / setExpandedProgrammatically(). + connect(userTree, &QTreeWidget::itemExpanded, this, + [this](QTreeWidgetItem *item) { handleSectionExpansion(item, true); }); + connect(userTree, &QTreeWidget::itemCollapsed, this, + [this](QTreeWidgetItem *item) { handleSectionExpansion(item, false); }); + + // Hide popup when list scrolls (reference row has moved) + connect(userTree->verticalScrollBar(), &QScrollBar::valueChanged, this, [this] { + showPopupTimer->stop(); + hidePopup(true); + requestVisibleItemResources(); + }); + + // 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); @@ -565,6 +788,11 @@ UserListWidget::UserListWidget(TabSupervisor *_tabSupervisor, retranslateUi(); } +UserListWidget::~UserListWidget() +{ + qApp->removeEventFilter(this); +} + void UserListWidget::bind(UserListManager *mgr) { manager = mgr; @@ -572,50 +800,70 @@ void UserListWidget::bind(UserListManager *mgr) // ── Full rebuild: disconnect / reconnect / bulk initial load ────────────── connect(manager, &UserListManager::listReset, this, &UserListWidget::rebuild); - // ── Online users list (AllUsersList / RoomList) ─────────────────────────── - if (type == AllUsersList || type == RoomList) { + if (!sectioned) { + // Online users list (AllUsersList / RoomList) + if (type == AllUsersList || type == RoomList) { + connect(manager, &UserListManager::userJoinedOnline, this, + [this](const ServerInfo_User &user) { processUserInfo(user, true); }); + connect(manager, &UserListManager::userLeftOnline, this, [this](const QString &name) { deleteUser(name); }); + } + + // Buddy list + if (type == BuddyList) { + connect(manager, &UserListManager::addedToBuddyList, this, [this](const ServerInfo_User &user) { + const QString name = QString::fromStdString(user.name()); + processUserInfo(user, manager->getOnlineUser(name) != nullptr); + }); + connect(manager, &UserListManager::removedFromBuddyList, this, + [this](const QString &name) { deleteUser(name); }); + // Track online presence changes for buddies already in the tree + connect(manager, &UserListManager::userJoinedOnline, this, [this](const ServerInfo_User &user) { + const QString name = QString::fromStdString(user.name()); + if (users.contains(name)) { + users[name]->setUserInfo(user); + setUserOnline(name, true); + } + }); + connect(manager, &UserListManager::userLeftOnline, this, [this](const QString &name) { + if (users.contains(name)) { + setUserOnline(name, false); + } + }); + } + + // Ignore list + if (type == IgnoreList) { + connect(manager, &UserListManager::addedToIgnoreList, this, [this](const ServerInfo_User &user) { + const QString name = QString::fromStdString(user.name()); + processUserInfo(user, manager->getOnlineUser(name) != nullptr); + }); + connect(manager, &UserListManager::removedFromIgnoreList, this, + [this](const QString &name) { deleteUser(name); }); + } + } else { + // Sectioned mode: one tree, every source feeds its own section. + // Sections are pure membership views: the "Online" section holds every + // currently online user, the "Buddy"/"Ignore" sections hold those + // lists. A user can therefore appear in several sections at once (an + // online buddy gets one row in each). connect(manager, &UserListManager::userJoinedOnline, this, - [this](const ServerInfo_User &user) { processUserInfo(user, true); }); - connect(manager, &UserListManager::userLeftOnline, this, [this](const QString &name) { deleteUser(name); }); - } - - // ── Buddy list ──────────────────────────────────────────────────────────── - if (type == BuddyList) { - connect(manager, &UserListManager::addedToBuddyList, this, [this](const ServerInfo_User &user) { - const QString name = QString::fromStdString(user.name()); - processUserInfo(user, manager->getOnlineUser(name) != nullptr); - }); + [this](const ServerInfo_User &user) { handleOnlineChange(user); }); + connect(manager, &UserListManager::userLeftOnline, this, + [this](const QString &name) { handleOnlineChangeLeft(name); }); + connect(manager, &UserListManager::addedToBuddyList, this, + [this](const ServerInfo_User &user) { handleListAdd(Section::Buddy, user); }); connect(manager, &UserListManager::removedFromBuddyList, this, - [this](const QString &name) { deleteUser(name); }); - // Track online presence changes for buddies already in the tree - connect(manager, &UserListManager::userJoinedOnline, this, [this](const ServerInfo_User &user) { - const QString name = QString::fromStdString(user.name()); - if (users.contains(name)) { - users[name]->setUserInfo(user); - setUserOnline(name, true); - } - }); - connect(manager, &UserListManager::userLeftOnline, this, [this](const QString &name) { - if (users.contains(name)) { - setUserOnline(name, false); - } - }); - } - - // ── Ignore list ─────────────────────────────────────────────────────────── - if (type == IgnoreList) { - connect(manager, &UserListManager::addedToIgnoreList, this, [this](const ServerInfo_User &user) { - const QString name = QString::fromStdString(user.name()); - processUserInfo(user, manager->getOnlineUser(name) != nullptr); - }); + [this](const QString &name) { handleListRemove(Section::Buddy, name); }); + connect(manager, &UserListManager::addedToIgnoreList, this, + [this](const ServerInfo_User &user) { handleListAdd(Section::Ignore, user); }); connect(manager, &UserListManager::removedFromIgnoreList, this, - [this](const QString &name) { deleteUser(name); }); + [this](const QString &name) { handleListRemove(Section::Ignore, name); }); } // ── Popup button refresh ────────────────────────────────────────────────── // Any buddy/ignore mutation while the popup is open refreshes its buttons auto refreshIfPopupOpen = [this](const QString &name) { - if (m_userInfoPopup && m_userInfoPopup->isVisible() && m_userInfoPopup->currentUser() == name) { + if (userInfoPopup && userInfoPopup->isVisible() && userInfoPopup->getCurrentUser() == name) { refreshPopupButtons(name); } }; @@ -633,8 +881,22 @@ void UserListWidget::bind(UserListManager *mgr) rebuild(); } +void UserListWidget::refreshVisibleUserHeader(const QString &name) +{ + userTree->viewport()->update(); + if (!userInfoPopup) { + return; + } + if (userInfoPopup->isVisible() && userInfoPopup->getCurrentUser() == name) { + userInfoPopup->refreshHeader(); + } +} + void UserListWidget::refreshPopupButtons(const QString &userName) { + if (!userInfoPopup) { + return; + } UserListTWI *item = users.value(userName); if (!item) { return; @@ -645,29 +907,52 @@ void UserListWidget::refreshPopupButtons(const QString &userName) const bool isBuddy = proxy->isUserBuddy(userName); const bool isIgn = proxy->isUserIgnored(userName); - m_userInfoPopup->updateActionButtons(item->getUserInfo(), online, isBuddy, isIgn); - positionPopup(userName); // height may have changed — reposition + userInfoPopup->updateActionButtons(item->getUserInfo(), online, isBuddy, isIgn); + positionPopup(item); // height may have changed, reposition } void UserListWidget::hideEvent(QHideEvent *e) { QGroupBox::hideEvent(e); - m_showPopupTimer->stop(); - m_hidePopupTimer->stop(); + if (!userInfoPopup) { + return; + } + showPopupTimer->stop(); + hidePopupTimer->stop(); hidePopup(true); } +void UserListWidget::showEvent(QShowEvent *e) +{ + QGroupBox::showEvent(e); + if (!userInfoPopup) { + return; + } + requestVisibleItemResources(); +} + void UserListWidget::applyDisplayMode() { const bool styled = SettingsCache::instance().appearance().getStyleUserList(); + // Both modes must keep the header length at the viewport width: with + // ScrollBarAlwaysOff a nonzero horizontal range is invisible but still + // pans via trackpad gestures, which reads as janky random drift. if (styled) { userTree->header()->setSectionResizeMode(0, QHeaderView::Stretch); userTree->hideColumn(1); userTree->hideColumn(2); userTree->hideColumn(3); } else { - userTree->header()->setSectionResizeMode(QHeaderView::ResizeToContents); + // Bounded widths instead of ResizeToContents: content sizing measures + // the FULL text width while the delegate elides afterwards, so long + // names widened the header past the viewport. Fixed icon columns plus + // a stretched name column keep the range at zero, eliding trims. + userTree->header()->setSectionResizeMode(0, QHeaderView::Fixed); + userTree->header()->resizeSection(0, 24); + userTree->header()->setSectionResizeMode(1, QHeaderView::Fixed); + userTree->header()->resizeSection(1, 22); + userTree->header()->setSectionResizeMode(2, QHeaderView::Stretch); userTree->showColumn(1); userTree->showColumn(2); userTree->hideColumn(3); @@ -678,72 +963,124 @@ 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::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().appearance().getStyleUserList()) { return QGroupBox::eventFilter(obj, event); } auto *me = static_cast(event); - auto *twi = static_cast(userTree->itemAt(me->pos())); - const QString hovName = twi ? QString::fromStdString(twi->getUserInfo().name()) : QString{}; + QTreeWidgetItem *hoveredItem = userTree->itemAt(me->pos()); + QString hovName; + if (hoveredItem && hoveredItem->type() == QTreeWidgetItem::Type) { + hovName = QString::fromStdString(static_cast(hoveredItem)->getUserInfo().name()); + } - if (hovName != m_hoveredUser) { - m_hoveredUser = hovName; + if (hovName != hoveredUser) { + hoveredUser = hovName; if (!hovName.isEmpty()) { - m_hidePopupTimer->stop(); - if (!m_popupPinned) { - m_showPopupTimer->start(); + hidePopupTimer->stop(); + if (!popupPinned) { + showPopupTimer->start(); } } else { - m_showPopupTimer->stop(); - if (!m_popupPinned) { - m_hidePopupTimer->start(); + showPopupTimer->stop(); + if (!popupPinned) { + hidePopupTimer->start(); } } } } else if (event->type() == QEvent::Leave) { - m_hoveredUser.clear(); - m_showPopupTimer->stop(); - if (!m_popupPinned) { - m_hidePopupTimer->start(); + hoveredUser.clear(); + showPopupTimer->stop(); + if (!popupPinned) { + hidePopupTimer->start(); } } } @@ -751,41 +1088,76 @@ bool UserListWidget::eventFilter(QObject *obj, QEvent *event) return QGroupBox::eventFilter(obj, event); } -void UserListWidget::showPopupForUser(const QString &userName) +void UserListWidget::showPopupForUser(UserListTWI *item) { - UserListTWI *item = users.value(userName); + if (!userInfoPopup) { + return; + } if (!item) { return; } + const QString userName = QString::fromStdString(item->getUserInfo().name()); + avatarProvider->requestAvatar(userName); // ensure the hovered user's avatar is fetched promptly + if (cardArtParamsMap.contains(userName)) { + const CardArtParams ¶ms = cardArtParamsMap.value(userName); + cardArtProvider->requestCardArt(userName, params.cardName, params.cardProviderId); + } + const ServerInfo_User &info = item->getUserInfo(); const bool online = item->data(0, UserListRoles::Online).toBool(); const bool isBuddy = userContextMenu->getUserListProxy()->isUserBuddy(userName); const bool isIgn = userContextMenu->getUserListProxy()->isUserIgnored(userName); - m_userInfoPopup->showForUser(userName, info, online, isBuddy, isIgn); + // The popup is already showing this user (e.g. arrow key navigation between + // the online/buddy rows of the same user): just reposition it. + if (userInfoPopup->isVisible() && userInfoPopup->getCurrentUser() == userName) { + positionPopup(item); + return; + } - // Realize the native window at opacity 0 before positioning so that: - // 1) move() applies to an existing native handle (not overridden by Qt's - // default centering logic on first show) - // 2) adjustSize() inside positionPopup() can measure the final laid-out - // geometry correctly - m_userInfoPopup->setWindowOpacity(0.0); - m_userInfoPopup->show(); - m_userInfoPopup->raise(); + // Cancel any pending show/hide so a hover timer that armed before a row + // selected via the keyboard cannot override it, and a pending hide cannot + // kill the popup right after it appears. + showPopupTimer->stop(); + hidePopupTimer->stop(); - positionPopup(userName); // geometry is now accurate; move() sticks + userInfoPopup->showForUser(userName, info, online, isBuddy, isIgn); - auto *fade = new QPropertyAnimation(m_userInfoPopup, "windowOpacity", m_userInfoPopup); + const bool wasVisible = userInfoPopup->isVisible(); + if (!wasVisible) { + // Realize the native window at opacity 0 before positioning so that: + // 1) move() applies to an existing native handle (not overridden by + // Qt's default centering logic on first show) + // 2) adjustSize() inside positionPopup() can measure the final + // laid out geometry correctly + userInfoPopup->setWindowOpacity(0.0); + } + userInfoPopup->show(); + userInfoPopup->raise(); + + positionPopup(item); // geometry is accurate after show, so move() is not overridden + + if (wasVisible) { + // Content swap while already open (hover or arrow key navigation): + // keep the popup opaque instead of flashing through a fade on every + // step. + userInfoPopup->setWindowOpacity(1.0); + return; + } + + auto *fade = new QPropertyAnimation(userInfoPopup, "windowOpacity", userInfoPopup); fade->setDuration(120); fade->setStartValue(0.0); fade->setEndValue(1.0); fade->start(QAbstractAnimation::DeleteWhenStopped); } -void UserListWidget::positionPopup(const QString &userName) +void UserListWidget::positionPopup(UserListTWI *item) { - UserListTWI *item = users.value(userName); + if (!userInfoPopup) { + return; + } if (!item) { return; } @@ -796,12 +1168,17 @@ void UserListWidget::positionPopup(const QString &userName) const QPoint vpTL = vp->mapToGlobal(vp->rect().topLeft()); const QPoint vpTR = vp->mapToGlobal(vp->rect().topRight()); - m_userInfoPopup->adjustSize(); - const int popW = m_userInfoPopup->width(); - const int popH = m_userInfoPopup->height(); + userInfoPopup->adjustSize(); + const int popW = userInfoPopup->width(); + const int popH = userInfoPopup->height(); const int margin = 12; - const QRect screen = QGuiApplication::primaryScreen()->availableGeometry(); + QScreen *activeScreen = QGuiApplication::screenAt(itemTL); + if (!activeScreen) { + activeScreen = window()->screen(); + } + const QRect screen = + activeScreen ? activeScreen->availableGeometry() : QGuiApplication::primaryScreen()->availableGeometry(); // ── X: prefer the side with more space ─────────────────────────────────── const int spaceLeft = vpTL.x() - screen.left() - margin; @@ -830,31 +1207,53 @@ void UserListWidget::positionPopup(const QString &userName) } y = qBound(screen.top() + margin, y, screen.bottom() - popH - margin); - m_userInfoPopup->move(x, y); + userInfoPopup->move(x, y); } void UserListWidget::hidePopup(bool immediate) { - m_showPopupTimer->stop(); - m_hidePopupTimer->stop(); - if (!m_userInfoPopup->isVisible()) { + if (!userInfoPopup) { + return; + } + showPopupTimer->stop(); + hidePopupTimer->stop(); + if (!userInfoPopup->isVisible()) { return; } if (immediate) { - m_userInfoPopup->hide(); + userInfoPopup->hide(); return; } // Fade out - auto *fade = new QPropertyAnimation(m_userInfoPopup, "windowOpacity", m_userInfoPopup); + auto *fade = new QPropertyAnimation(userInfoPopup, "windowOpacity", userInfoPopup); fade->setDuration(100); - fade->setStartValue(m_userInfoPopup->windowOpacity()); + fade->setStartValue(userInfoPopup->windowOpacity()); fade->setEndValue(0.0); - connect(fade, &QPropertyAnimation::finished, m_userInfoPopup, &QWidget::hide); + connect(fade, &QPropertyAnimation::finished, userInfoPopup, &QWidget::hide); fade->start(QAbstractAnimation::DeleteWhenStopped); } +bool UserListWidget::isPressInsideListUi(const QWidget *widget) const +{ + const QWidget *w = widget; + while (w) { + if (w == userTree || w == userInfoPopup || qobject_cast(w)) { + return true; + } + w = w->parentWidget(); + } + return false; +} + +void UserListWidget::clearSelectionAndClosePopup() +{ + popupPinned = false; + hidePopup(true); + userTree->clearSelection(); +} + void UserListWidget::retranslateUi() { userContextMenu->retranslateUi(); @@ -875,17 +1274,100 @@ void UserListWidget::retranslateUi() updateCount(); } +void UserListWidget::beginBulkLoad() +{ + bulkLoading = true; +} + +void UserListWidget::endBulkLoad() +{ + bulkLoading = false; + sortItems(); + updateCount(); // divider counts were deferred during the bulk build + requestVisibleItemResources(); + userTree->viewport()->update(); +} + +bool UserListWidget::isItemNearViewport(const UserListTWI *item) const +{ + // Prefetch a full viewport of rows above and below so scrolling never shows + // an unloaded row. + const QRect nearView = + userTree->viewport()->rect().adjusted(0, -userTree->viewport()->height(), 0, userTree->viewport()->height()); + return userTree->visualItemRect(item).intersects(nearView); +} + +void UserListWidget::requestVisibleItemResources() +{ + const auto requestResources = [this](UserListTWI *twi) { + if (!isItemNearViewport(twi)) { + return; + } + const QString userName = QString::fromStdString(twi->getUserInfo().name()); + avatarProvider->requestAvatar(userName); + if (cardArtParamsMap.contains(userName)) { + const CardArtParams ¶ms = cardArtParamsMap.value(userName); + cardArtProvider->requestCardArt(userName, params.cardName, params.cardProviderId); + } + }; + + if (sectioned) { + // Top level items are dividers, user rows hang below them. + for (const Section section : sectionIds) { + QTreeWidgetItem *divider = sectionItems.value(section); + if (!divider) { + continue; + } + for (int i = 0; i < divider->childCount(); ++i) { + requestResources(static_cast(divider->child(i))); + } + } + return; + } + + for (int i = 0; i < userTree->topLevelItemCount(); ++i) { + requestResources(static_cast(userTree->topLevelItem(i))); + } +} + void UserListWidget::rebuild() { userTree->clear(); users.clear(); + sectionUsers.clear(); cardArtParamsMap.clear(); onlineCount = 0; + if (sectioned) { + createSectionItems(); + } + if (!manager) { return; } + if (sectioned) { + // Every source feeds its own section. Users that belong to several + // sources (an online buddy) get one row per section because + // ensureSectionMembership() creates the row when it is missing. + beginBulkLoad(); + const auto &onlineUsers = manager->getAllUsersList(); + for (auto it = onlineUsers.cbegin(); it != onlineUsers.cend(); ++it) { + processUserInfo(Section::Online, it.value(), true); + } + const auto &buddyUsers = manager->getBuddyList(); + for (auto it = buddyUsers.cbegin(); it != buddyUsers.cend(); ++it) { + processUserInfo(Section::Buddy, it.value(), manager->getOnlineUser(it.key()) != nullptr); + } + const auto &ignoreUsers = manager->getIgnoreList(); + for (auto it = ignoreUsers.cbegin(); it != ignoreUsers.cend(); ++it) { + processUserInfo(Section::Ignore, it.value(), manager->getOnlineUser(it.key()) != nullptr); + } + endBulkLoad(); + applyFilter(); + return; + } + const QMap *source = nullptr; switch (type) { @@ -901,19 +1383,18 @@ void UserListWidget::rebuild() break; } + beginBulkLoad(); for (auto it = source->cbegin(); it != source->cend(); ++it) { processUserInfo(it.value(), manager->getOnlineUser(it.key()) != nullptr); } - - sortItems(); + endBulkLoad(); + applyFilter(); } -void UserListWidget::processUserInfo(const ServerInfo_User &user, bool online) +void UserListWidget::updateCardArtParams(const ServerInfo_User &user, const QString &userName) { - const QString userName = QString::fromStdString(user.name()); - // Always update params from the latest ServerInfo_User, whether the - // item is new or existing, so a live server-push refreshes the rendering. + // item is new or existing, so a live server push refreshes the rendering. if (user.has_card_art_params()) { const auto &cap = user.card_art_params(); CardArtParams params; @@ -925,9 +1406,20 @@ void UserListWidget::processUserInfo(const ServerInfo_User &user, bool online) params.zoom = cap.zoom(); cardArtParamsMap.insert(userName, params); cardArtProvider->requestCardArt(userName, params.cardName, params.cardProviderId); - } else { - cardArtParamsMap.remove(userName); // clear stale params on removal } + // Intentionally no removal branch: buddy/ignore list copies never carry + // card_art_params (the server omits the column), so a params-less copy here + // means "this snapshot doesn't include it", not "the banner was removed". + // Removing on such copies would wipe banners that the live online list set. + // The map is rebuilt from scratch (clear() + repopulate) on every rebuild, + // which is what actually drops stale entries. +} + +void UserListWidget::processUserInfo(const ServerInfo_User &user, bool online) +{ + const QString userName = QString::fromStdString(user.name()); + + updateCardArtParams(user, userName); UserListTWI *item = users.value(userName); if (item) { @@ -940,37 +1432,92 @@ void UserListWidget::processUserInfo(const ServerInfo_User &user, bool online) ++onlineCount; } updateCount(); - avatarProvider->requestAvatar(userName); + if (!bulkLoading && isItemNearViewport(item)) { + avatarProvider->requestAvatar(userName); + } } item->setOnline(online); - sortItems(); - userTree->viewport()->update(); + if (!bulkLoading) { + sortItems(); + applyFilter(); + userTree->viewport()->update(); + } +} + +void UserListWidget::processUserInfo(Section section, const ServerInfo_User &user, bool online) +{ + ensureSectionMembership(section, user, online); + if (!bulkLoading) { + sortItems(); + applyFilter(); + userTree->viewport()->update(); + } } bool UserListWidget::deleteUser(const QString &userName) { + if (sectioned) { + // The user may own several rows (one per section). Drop them all. + bool removed = false; + const QList
sections = sectionUsers.keys(); // snapshot: maps mutate + for (const Section section : sections) { + removed = dropSectionMembership(section, userName) || removed; + } + if (removed && !bulkLoading) { + sortItems(); + applyFilter(); + userTree->viewport()->update(); + } + return removed; + } + UserListTWI *twi = users.value(userName); if (!twi) { return false; } users.remove(userName); - userTree->takeTopLevelItem(userTree->indexOfTopLevelItem(twi)); - if (twi->data(0, Qt::UserRole + 1).toBool()) { + if (twi->parent()) { + twi->parent()->removeChild(twi); // sectioned mode: rows hang off a divider + } else { + userTree->takeTopLevelItem(userTree->indexOfTopLevelItem(twi)); + } + if (twi->data(0, UserListRoles::Online).toBool()) { --onlineCount; } delete twi; updateCount(); + applyFilter(); return true; } void UserListWidget::setUserOnline(const QString &userName, bool online) { + if (sectioned) { + // The rows in the "Online" section are created/removed by the presence + // handlers. This only keeps the presence flag of the surviving rows + // (e.g. a buddy row after the user went offline) in sync. + for (auto it = sectionUsers.cbegin(); it != sectionUsers.cend(); ++it) { + UserListTWI *item = it.value().value(userName); + if (item) { + item->setOnline(online); + } + } + return; + } + UserListTWI *twi = users.value(userName); if (!twi) { return; } + // No state change: nothing to resort. This also keeps the presence + // broadcasts cheap (userJoinedOnline fires once per online user) when the + // row already carries the right flag. + if (twi->data(0, UserListRoles::Online).toBool() == online) { + return; + } + twi->setOnline(online); if (online) { ++onlineCount; @@ -978,26 +1525,158 @@ void UserListWidget::setUserOnline(const QString &userName, bool online) --onlineCount; } updateCount(); + + // Online users sort above offline users (UserListTWI::operator<), so a + // flag change moves the row. Resort to place the user by the new state. + if (!bulkLoading) { + sortItems(); + applyFilter(); + userTree->viewport()->update(); + } } void UserListWidget::updateCount() { - QString str = titleStr; - if ((type == BuddyList) || (type == IgnoreList)) { - str = str.arg(onlineCount); + if (sectioned) { + // The dividers carry the section titles + setTitle(QString()); + for (const Section section : sectionIds) { + updateSectionDivider(section); + } + return; } - setTitle(str.arg(userTree->topLevelItemCount())); + + if (showTitle) { + QString str = titleStr; + if ((type == BuddyList) || (type == IgnoreList)) { + str = str.arg(onlineCount); + } + setTitle(str.arg(userTree->topLevelItemCount())); + } else { + setTitle(QString()); + } +} + +void UserListWidget::setShowTitle(bool _showTitle) +{ + this->showTitle = _showTitle; + updateCount(); +} + +void UserListWidget::setFilterText(const QString &text) +{ + if (filterText == text) { + return; + } + filterText = text; + applyFilter(); +} + +void UserListWidget::applyFilter() +{ + if (sectioned) { + const bool searching = !filterText.isEmpty(); + const QString lower = filterText.toLower(); + for (const Section section : sectionIds) { + QTreeWidgetItem *divider = sectionItems.value(section); + if (!divider) { + continue; + } + int visible = 0; + for (int i = 0; i < divider->childCount(); ++i) { + auto *child = static_cast(divider->child(i)); + const QString name = QString::fromStdString(child->getUserInfo().name()); + const bool passesFilter = + !userFilter || userFilter(name, child->data(0, UserListRoles::Online).toBool()); + const bool match = passesFilter && (!searching || name.toLower().contains(lower)); + child->setHidden(!match); + if (match) { + ++visible; + } + } + if (searching) { + // During a search the sections with matches stay open and empty + // sections disappear entirely. The persisted expansion state is + // untouched and restored when the search is cleared. + divider->setHidden(visible == 0); + setExpandedProgrammatically(divider, visible > 0); + } else { + divider->setHidden(false); + setExpandedProgrammatically(divider, expandedSections.contains(section)); + } + updateSectionDivider(section); + } + requestVisibleItemResources(); + userTree->viewport()->update(); + emit userListChanged(); + return; + } + + if (filterText.isEmpty()) { + for (auto it = users.cbegin(); it != users.cend(); ++it) { + it.value()->setHidden(false); + } + } else { + const QString lower = filterText.toLower(); + for (auto it = users.cbegin(); it != users.cend(); ++it) { + const bool match = QString::fromStdString(it.value()->getUserInfo().name()).toLower().contains(lower); + it.value()->setHidden(!match); + } + } + + requestVisibleItemResources(); + userTree->viewport()->update(); + emit userListChanged(); } void UserListWidget::userClicked(QTreeWidgetItem *item, int /*column*/) { - emit openMessageDialog(item->data(2, Qt::UserRole).toString(), true); + if (item->type() != QTreeWidgetItem::Type) { + return; // divider rows open no chat + } + const QString userName = item->data(2, Qt::UserRole).toString(); + if (hasUserInfoPopup) { + emit openMessageDialog(userName, true); + } else { + emit userActivated(userName); + } +} + +int UserListWidget::visibleUserRowCount() const +{ + int count = 0; + if (sectioned) { + for (const Section section : sectionIds) { + QTreeWidgetItem *divider = sectionItems.value(section); + if (!divider || divider->isHidden()) { + continue; + } + for (int i = 0; i < divider->childCount(); ++i) { + if (!divider->child(i)->isHidden()) { + ++count; + } + } + } + return count; + } + for (int i = 0; i < userTree->topLevelItemCount(); ++i) { + QTreeWidgetItem *item = userTree->topLevelItem(i); + if (!item->isHidden() && item->type() == QTreeWidgetItem::Type) { + ++count; + } + } + return count; } void UserListWidget::showContextMenu(const QPoint &pos, const QModelIndex &index) { - const ServerInfo_User &userInfo = static_cast(userTree->topLevelItem(index.row()))->getUserInfo(); - bool online = index.sibling(index.row(), 0).data(Qt::UserRole + 1).toBool(); + QTreeWidgetItem *item = userTree->itemFromIndex(index); + if (!item || item->type() != QTreeWidgetItem::Type) { + return; // divider rows have no user menu + } + const auto *userItem = static_cast(item); + const ServerInfo_User &userInfo = userItem->getUserInfo(); + const bool online = userItem->data(0, UserListRoles::Online).toBool(); userContextMenu->showContextMenu(pos, QString::fromStdString(userInfo.name()), UserLevelFlags(userInfo.user_level()), online); @@ -1005,5 +1684,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..5fed54573 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" @@ -18,9 +19,11 @@ #include #include #include +#include #include #include #include +#include #include #include @@ -36,6 +39,8 @@ class QPlainTextEdit; class Response; class CommandContainer; class UserContextMenu; +class UserListWidget; +class QShowEvent; class BanDialog : public QDialog { @@ -80,7 +85,7 @@ public: [[nodiscard]] QString getWarnID() const; [[nodiscard]] QString getReason() const; [[nodiscard]] int getDeleteMessages() const; - void addWarningOption(const QString warning); + void addWarningOption(const QString warning, int startingIl = 1); }; class AdminNotesDialog : public QDialog @@ -102,12 +107,15 @@ public: 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 +154,12 @@ public: BuddyList, IgnoreList }; + enum class Section + { + Buddy, + Online, + Ignore + }; private: UserListManager *manager = nullptr; @@ -153,30 +167,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 +243,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/appearance_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp index c470e54fe..881c54167 100644 --- a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.cpp @@ -5,13 +5,17 @@ #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 @@ -128,6 +132,14 @@ AppearanceSettingsPage::AppearanceSettingsPage() connect(&homeTabDisplayCardNameCheckBox, &QCheckBox::QT_STATE_CHANGED, &settings.appearance(), &AppearanceSettings::setHomeTabDisplayCardName); + for (const auto &entry : HomeTabButtonColor::all()) { + homeTabButtonColorSourceBox.addItem(QObject::tr(entry.trKey)); + } + + homeTabButtonColorSourceBox.setCurrentIndex(settings.appearance().getHomeTabButtonColorSourceIndex()); + connect(&homeTabButtonColorSourceBox, QOverload::of(&QComboBox::currentIndexChanged), &settings.appearance(), + &AppearanceSettings::setHomeTabButtonColorSourceIndex); + updateHomeTabSettingsVisibility(); auto *homeTabGrid = new QGridLayout; @@ -136,6 +148,8 @@ 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); homeTabGroupBox = new QGroupBox; homeTabGroupBox->setLayout(homeTabGrid); @@ -325,10 +339,52 @@ AppearanceSettingsPage::AppearanceSettingsPage() tableGroupBox = new QGroupBox; tableGroupBox->setLayout(tableGrid); + // 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); + // putting it all together auto *mainLayout = new QVBoxLayout; mainLayout->addWidget(themeGroupBox); mainLayout->addWidget(homeTabGroupBox); + mainLayout->addWidget(playmatGroupBox); mainLayout->addWidget(stylingGroupBox); mainLayout->addWidget(menuGroupBox); mainLayout->addWidget(printingsGroupBox); @@ -375,8 +431,8 @@ void AppearanceSettingsPage::editPalette() void AppearanceSettingsPage::updateHomeTabSettingsVisibility() { - bool visible = SettingsCache::instance().appearance().getHomeTabBackgroundSource() != - BackgroundSources::toId(BackgroundSources::Theme); + QString sourceId = SettingsCache::instance().appearance().getHomeTabBackgroundSource(); + bool visible = BackgroundSources::fromId(sourceId) != BackgroundSources::Theme; homeTabBackgroundShuffleFrequencyLabel.setVisible(visible); homeTabBackgroundShuffleFrequencySpinBox.setVisible(visible); @@ -431,6 +487,12 @@ void AppearanceSettingsPage::cardViewExpandedRowsMaxChanged(int value) } } +void AppearanceSettingsPage::openPlaymatCollectionDialog() +{ + PlaymatCollectionDialog dialog(this); + dialog.exec(); +} + void AppearanceSettingsPage::retranslateUi() { themeGroupBox->setTitle(tr("Theme settings")); @@ -446,6 +508,9 @@ 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")); + homeTabButtonColorSourceLabel.setText(tr("Home tab button color:")); + homeTabButtonColorSourceBox.setToolTip( + tr("Automatic: extract from background if present, otherwise use theme default")); stylingGroupBox->setTitle(tr("Styling settings")); styleUserListCheckBox.setText(tr("Style user list")); @@ -489,4 +554,9 @@ 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 + 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...")); +} diff --git a/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h b/cockatrice/src/interface/widgets/settings_page/appearance_settings_page.h index 0b6b6832c..6b0369694 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; @@ -34,11 +35,15 @@ private: QLabel styleComboLabel; QComboBox styleCombo; QPushButton editPaletteButton; + QLabel homeTabBackgroundSourceLabel; QComboBox homeTabBackgroundSourceBox; QLabel homeTabBackgroundShuffleFrequencyLabel; QSpinBox homeTabBackgroundShuffleFrequencySpinBox; QCheckBox homeTabDisplayCardNameCheckBox; + QLabel homeTabButtonColorSourceLabel; + QComboBox homeTabButtonColorSourceBox; + QCheckBox styleUserListCheckBox; QCheckBox showShortcutsCheckBox; QCheckBox showGameSelectorFilterToolbarCheckBox; @@ -59,6 +64,12 @@ private: QCheckBox horizontalHandCheckBox; QCheckBox leftJustifiedHandCheckBox; QCheckBox invertVerticalCoordinateCheckBox; + QLabel playmatVisibilityLabel; + QComboBox playmatVisibilityCombo; + QLabel playmatModeLabel; + QComboBox playmatModeCombo; + QLabel playmatDefaultLabel; + QPushButton playmatDefaultEditButton; QGroupBox *themeGroupBox; QGroupBox *homeTabGroupBox; QGroupBox *stylingGroupBox; @@ -67,6 +78,7 @@ private: QGroupBox *cardsGroupBox; QGroupBox *cardLayoutGroupBox; QGroupBox *handGroupBox; + QGroupBox *playmatGroupBox; QGroupBox *tableGroupBox; QGroupBox *cardCountersGroupBox; QList cardCounterNames; diff --git a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp index 91c4943e1..a293660f9 100644 --- a/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp +++ b/cockatrice/src/interface/widgets/settings_page/general_settings_page.cpp @@ -2,6 +2,7 @@ #include "../../../client/settings/cache_settings.h" #include "../main.h" +#include "../server/user/user_info_connection.h" #include "update/client/release_channel.h" #include @@ -11,6 +12,7 @@ #include #include #include +#include #include #include @@ -121,8 +123,61 @@ GeneralSettingsPage::GeneralSettingsPage() connect(&showTipsOnStartup, &QCheckBox::clicked, &settings.personal(), &PersonalSettings::setShowTipsOnStartup); + // startup destination + for (int i = 0; i < 8; ++i) { + startupTabSelector.addItem(""); // texts set in retranslateUi + } + startupTabSelector.setCurrentIndex(settings.tabs().getStartupTabIndex()); + + connect(&startupTabSelector, qOverload(&QComboBox::currentIndexChanged), &settings.tabs(), + &TabsSettings::setStartupTabIndex); + connect(&startupTabSelector, qOverload(&QComboBox::currentIndexChanged), this, + &GeneralSettingsPage::updateStartupServerControlsVisibility); + + const QString savedHost = settings.tabs().getStartupServerHost(); + const QString savedPort = settings.tabs().getStartupServerPort(); + int startupServerIndex = -1; + UserConnection_Information uci; + for (const auto &savedServer : uci.getServerInfo()) { + const UserConnection_Information &info = savedServer.second; + const QString saveName = info.getSaveName(); + if (saveName.isEmpty()) { + continue; + } + startupServerSelector.addItem(saveName, QVariantList{info.getServer(), info.getPort()}); + if (startupServerIndex == -1 && info.getServer() == savedHost && info.getPort() == savedPort) { + startupServerIndex = startupServerSelector.count() - 1; + } + } + startupServerSelector.setCurrentIndex(startupServerIndex); + + connect(&startupServerSelector, qOverload(&QComboBox::currentIndexChanged), this, [this](int index) { + const QVariantList serverInfo = startupServerSelector.itemData(index).toList(); + if (serverInfo.size() != 2) { + return; + } + TabsSettings &tabs = SettingsCache::instance().tabs(); + tabs.setStartupServerHost(serverInfo[0].toString()); + tabs.setStartupServerPort(serverInfo[1].toString()); + }); + + startupRoomNameEdit = new QLineEdit(settings.tabs().getStartupRoomName()); + // Default (Expanding) would stretch the whole controls column when this row becomes visible, + // so size it like the combo boxes instead: fills the column, never widens it. + startupRoomNameEdit->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::Fixed); + connect(startupRoomNameEdit, &QLineEdit::editingFinished, this, + [this] { SettingsCache::instance().tabs().setStartupRoomName(startupRoomNameEdit->text().trimmed()); }); + auto *startupGrid = new QGridLayout; startupGrid->addWidget(&showTipsOnStartup, 0, 0, 1, 2); + startupGrid->addWidget(&startupTabLabel, 1, 0); + startupGrid->addWidget(&startupTabSelector, 1, 1); + startupGrid->addWidget(&startupServerLabel, 2, 0); + startupGrid->addWidget(&startupServerSelector, 2, 1); + startupGrid->addWidget(&startupRoomLabel, 3, 0); + startupGrid->addWidget(startupRoomNameEdit, 3, 1); + + updateStartupServerControlsVisibility(); startupGroupBox = new QGroupBox; startupGroupBox->setLayout(startupGrid); @@ -357,6 +412,17 @@ void GeneralSettingsPage::languageBoxChanged(int index) SettingsCache::instance().personal().setLang(languageBox.itemData(index).toString()); } +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")); @@ -393,6 +459,20 @@ void GeneralSettingsPage::retranslateUi() 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")); + 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")); resetAllPathsButton->setText(tr("Reset all paths")); const auto &settings = SettingsCache::instance(); 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..8dd7e8798 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,9 @@ public: GeneralSettingsPage(); void retranslateUi() override; + static QStringList findQmFiles(); + static QString languageName(const QString &lang); + private slots: void deckPathButtonClicked(); void filtersPathButtonClicked(); @@ -30,11 +33,9 @@ private slots: void tokenDatabasePathButtonClicked(); void resetAllPathsClicked(); void languageBoxChanged(int index); + void updateStartupServerControlsVisibility(); private: - QStringList findQmFiles(); - QString languageName(const QString &lang); - QGroupBox *languageGroupBox; QGroupBox *versionGroupBox; QGroupBox *cardDatabaseGroupBox; @@ -71,6 +72,12 @@ private: QLabel updateReleaseChannelLabel; QLabel advertiseTranslationPageLabel; QCheckBox showTipsOnStartup; + QLabel startupTabLabel; + QComboBox startupTabSelector; + QLabel startupServerLabel; + QComboBox startupServerSelector; + QLabel startupRoomLabel; + QLineEdit *startupRoomNameEdit; }; #endif // COCKATRICE_GENERAL_SETTINGS_PAGE_H diff --git a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.cpp index 634df0b15..182e75aac 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 @@ -116,8 +116,29 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() connect(&tapAnimationCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().cardsDisplay(), &CardsDisplaySettings::setTapAnimation); + arrowDrawAnimationCheckBox.setChecked(SettingsCache::instance().cardsDisplay().getArrowDrawAnimation()); + connect(&arrowDrawAnimationCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().cardsDisplay(), + &CardsDisplaySettings::setArrowDrawAnimation); + + lifeCounterAnimationsCheckBox.setChecked( + SettingsCache::instance().userInterface().getLifeCounterAnimationsEnabled()); + connect(&lifeCounterAnimationsCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setLifeCounterAnimationsEnabled); + + battlefieldFlashCheckBox.setChecked(SettingsCache::instance().userInterface().getBattlefieldFlashEnabled()); + connect(&battlefieldFlashCheckBox, &QCheckBox::QT_STATE_CHANGED, &SettingsCache::instance().userInterface(), + &InterfaceSettings::setBattlefieldFlashEnabled); + + connect(&enableAllAnimationsButton, &QPushButton::clicked, this, &UserInterfaceSettingsPage::enableAllAnimations); + connect(&disableAllAnimationsButton, &QPushButton::clicked, this, &UserInterfaceSettingsPage::disableAllAnimations); + auto *animationGrid = new QGridLayout; - animationGrid->addWidget(&tapAnimationCheckBox, 0, 0); + animationGrid->addWidget(&enableAllAnimationsButton, 0, 0); + animationGrid->addWidget(&disableAllAnimationsButton, 0, 1); + animationGrid->addWidget(&tapAnimationCheckBox, 1, 0); + animationGrid->addWidget(&arrowDrawAnimationCheckBox, 2, 0); + animationGrid->addWidget(&lifeCounterAnimationsCheckBox, 3, 0); + animationGrid->addWidget(&battlefieldFlashCheckBox, 4, 0); animationGroupBox = new QGroupBox; animationGroupBox->setLayout(animationGrid); @@ -162,6 +183,13 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() connect(&defaultDeckEditorTypeSelector, QOverload::of(&QComboBox::currentIndexChanged), &SettingsCache::instance().deckEditor(), &DeckEditorSettings::setDefaultDeckEditorType); + vdeStartupTabSelector.addItem(""); // these will be set in retranslateUI + vdeStartupTabSelector.addItem(""); + vdeStartupTabSelector.addItem(""); + vdeStartupTabSelector.setCurrentIndex(SettingsCache::instance().deckEditor().getVdeStartupTab()); + connect(&vdeStartupTabSelector, QOverload::of(&QComboBox::currentIndexChanged), + &SettingsCache::instance().deckEditor(), &DeckEditorSettings::setVdeStartupTab); + commanderSpellbookIntegrationUseOfficialBracketNamesExplainer.setText("?"); commanderSpellbookIntegrationUseOfficialBracketNamesExplainer.setAutoRaise(true); commanderSpellbookIntegrationUseOfficialBracketNamesExplainer.setEnabled(false); @@ -221,10 +249,12 @@ UserInterfaceSettingsPage::UserInterfaceSettingsPage() deckEditorGrid->addWidget(&visualDeckStoragePromptForConversionSelector, 3, 1); deckEditorGrid->addWidget(&defaultDeckEditorTypeLabel, 4, 0); deckEditorGrid->addWidget(&defaultDeckEditorTypeSelector, 4, 1); - deckEditorGrid->addWidget(&commanderSpellbookIntegrationEnabledLabel, 5, 0); - deckEditorGrid->addWidget(&commanderSpellbookIntegrationEnabledSelector, 5, 1); - deckEditorGrid->addWidget(labelWidget, 6, 0); - deckEditorGrid->addWidget(&commanderSpellbookIntegrationBracketNamingSelector, 6, 1); + deckEditorGrid->addWidget(&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); @@ -268,6 +298,22 @@ void UserInterfaceSettingsPage::setNotificationEnabled(QT_STATE_CHANGED_T i) } } +void UserInterfaceSettingsPage::enableAllAnimations() +{ + tapAnimationCheckBox.setChecked(true); + arrowDrawAnimationCheckBox.setChecked(true); + lifeCounterAnimationsCheckBox.setChecked(true); + battlefieldFlashCheckBox.setChecked(true); +} + +void UserInterfaceSettingsPage::disableAllAnimations() +{ + tapAnimationCheckBox.setChecked(false); + arrowDrawAnimationCheckBox.setChecked(false); + lifeCounterAnimationsCheckBox.setChecked(false); + battlefieldFlashCheckBox.setChecked(false); +} + void UserInterfaceSettingsPage::updateCommanderSpellbookUiState() { const int mode = SettingsCache::instance().deckEditor().getCommanderSpellbookIntegrationEnabled(); @@ -310,7 +356,12 @@ void UserInterfaceSettingsPage::retranslateUi() 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")); @@ -326,6 +377,12 @@ void UserInterfaceSettingsPage::retranslateUi() defaultDeckEditorTypeLabel.setText(tr("Default deck editor type")); defaultDeckEditorTypeSelector.setItemText(TabSupervisor::ClassicDeckEditor, tr("Classic Deck Editor")); defaultDeckEditorTypeSelector.setItemText(TabSupervisor::VisualDeckEditor, tr("Visual Deck Editor")); + vdeStartupTabLabel.setText(tr("Visual deck editor startup tab")); + vdeStartupTabSelector.setItemText(VdeStartupTabContext, tr("Context")); + vdeStartupTabSelector.setItemText(VdeStartupTabDeckDisplay, tr("Deck display")); + vdeStartupTabSelector.setItemText(VdeStartupTabDatabaseDisplay, tr("Database display")); + vdeStartupTabSelector.setToolTip( + tr("Context mode: New decks open on the database display, existing decks open on the deck view.")); commanderSpellbookIntegrationEnabledLabel.setText( tr("CommanderSpellbook integration to estimate commander bracket")); diff --git a/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h b/cockatrice/src/interface/widgets/settings_page/user_interface_settings_page.h index 9e6fada69..0dc4cf4e8 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,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -17,6 +18,8 @@ class UserInterfaceSettingsPage : public AbstractSettingsPage Q_OBJECT private slots: void setNotificationEnabled(QT_STATE_CHANGED_T); + void enableAllAnimations(); + void disableAllAnimations(); void updateCommanderSpellbookUiState(); private: @@ -34,7 +37,12 @@ private: QCheckBox showTotalSelectionCountCheckBox; QCheckBox useTearOffMenusCheckBox; QCheckBox keepGameChatFocusCheckBox; + QPushButton enableAllAnimationsButton; + QPushButton disableAllAnimationsButton; QCheckBox tapAnimationCheckBox; + QCheckBox arrowDrawAnimationCheckBox; + QCheckBox lifeCounterAnimationsCheckBox; + QCheckBox battlefieldFlashCheckBox; QCheckBox openDeckInNewTabCheckBox; QLabel visualDeckStoragePromptForConversionLabel; QComboBox visualDeckStoragePromptForConversionSelector; @@ -42,6 +50,8 @@ private: QCheckBox visualDeckStorageSelectionAnimationCheckBox; QLabel defaultDeckEditorTypeLabel; QComboBox defaultDeckEditorTypeSelector; + QLabel vdeStartupTabLabel; + QComboBox vdeStartupTabSelector; QLabel commanderSpellbookIntegrationEnabledLabel; QComboBox commanderSpellbookIntegrationEnabledSelector; QLabel commanderSpellbookIntegrationUseOfficialBracketNamesLabel; 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/tab_archidekt.cpp b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp index 374d35cdf..98b21d0f1 100644 --- a/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp +++ b/cockatrice/src/interface/widgets/tabs/api/archidekt/tab_archidekt.cpp @@ -2,12 +2,12 @@ #include "../../../../../client/settings/cache_settings.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 +19,6 @@ #include #include #include -#include #include #include #include @@ -279,41 +278,14 @@ void TabArchidekt::setupFilterWidgets() auto cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this); auto displayModel = new CardDatabaseDisplayModel(this); displayModel->setSourceModel(cardDatabaseModel); - auto *searchModel = new CardSearchModel(displayModel, this); - auto *proxyModel = new CardCompleterProxyModel(this); - proxyModel->setSourceModel(searchModel); - proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive); - proxyModel->setFilterRole(Qt::DisplayRole); - - auto *completer = new QCompleter(proxyModel, this); - completer->setCompletionRole(Qt::DisplayRole); - completer->setCompletionMode(QCompleter::PopupCompletion); - completer->setCaseSensitivity(Qt::CaseInsensitive); - completer->setFilterMode(Qt::MatchContains); - completer->setMaxVisibleItems(10); - - cardsField->setCompleter(completer); - commandersField->setCompleter(completer); + const CardCompleterSetup cardSetup = createCardCompleter(displayModel, this); + cardsField->setCompleter(cardSetup.completer); + commandersField->setCompleter(cardSetup.completer); // Keep autocomplete working for both fields - connect(cardsField, &QLineEdit::textChanged, this, [=](const QString &text) { - searchModel->updateSearchResults(text); - QString pattern = ".*" + QRegularExpression::escape(text) + ".*"; - proxyModel->setFilterRegularExpression(QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption)); - if (!text.isEmpty()) { - completer->complete(); - } - }); - - connect(commandersField, &QLineEdit::textChanged, this, [=](const QString &text) { - searchModel->updateSearchResults(text); - QString pattern = ".*" + QRegularExpression::escape(text) + ".*"; - proxyModel->setFilterRegularExpression(QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption)); - if (!text.isEmpty()) { - completer->complete(); - } - }); + connectCardCompleterSearch(cardsField, cardSetup); + connectCardCompleterSearch(commandersField, cardSetup); // Assemble secondary toolbar secondaryToolbarLayout->addWidget(bracketLabel); diff --git a/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp b/cockatrice/src/interface/widgets/tabs/api/edhrec/tab_edhrec_main.cpp index 42a689898..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 @@ -63,32 +63,12 @@ TabEdhRecMain::TabEdhRecMain(TabSupervisor *_tabSupervisor) : Tab(_tabSupervisor auto cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, this); auto displayModel = new CardDatabaseDisplayModel(this); displayModel->setSourceModel(cardDatabaseModel); - auto *searchModel = new CardSearchModel(displayModel, this); - auto *proxyModel = new CardCompleterProxyModel(this); - proxyModel->setSourceModel(searchModel); - proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive); - proxyModel->setFilterRole(Qt::DisplayRole); - - auto *completer = new QCompleter(proxyModel, this); - completer->setCompletionRole(Qt::DisplayRole); - completer->setCompletionMode(QCompleter::PopupCompletion); - completer->setCaseSensitivity(Qt::CaseInsensitive); - completer->setFilterMode(Qt::MatchContains); - completer->setMaxVisibleItems(10); - searchBar->setCompleter(completer); + const CardCompleterSetup cardSetup = createCardCompleter(displayModel, this); + searchBar->setCompleter(cardSetup.completer); // Update suggestions dynamically - connect(searchBar, &QLineEdit::textChanged, searchModel, &CardSearchModel::updateSearchResults); - connect(searchBar, &QLineEdit::textChanged, this, [=](const QString &text) { - // Ensure substring matching - QString pattern = ".*" + QRegularExpression::escape(text) + ".*"; - proxyModel->setFilterRegularExpression(QRegularExpression(pattern, QRegularExpression::CaseInsensitiveOption)); - - if (!text.isEmpty()) { - completer->complete(); // Force the dropdown to appear - } - }); + connectCardCompleterSearch(searchBar, cardSetup); searchPushButton = new QPushButton(navigationContainer); connect(searchPushButton, &QPushButton::clicked, this, [=, this]() { doSearch(); }); 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..410a48d40 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,6 +132,7 @@ void TabAccount::addToList(const std::string &listName, const QString &userName) void TabAccount::retranslateUi() { + myReportsButton->setText(tr("My Reports")); allUsersList->retranslateUi(); buddyList->retranslateUi(); ignoreList->retranslateUi(); @@ -135,6 +142,7 @@ void TabAccount::retranslateUi() 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 +150,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 +196,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 +247,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_game.cpp b/cockatrice/src/interface/widgets/tabs/tab_game.cpp index 7ffcd8a9b..196ea4526 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_game.cpp @@ -20,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 @@ -35,8 +44,12 @@ #include #include #include +#include +#include #include +#include #include +#include #include #include #include @@ -286,6 +299,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) { @@ -324,6 +340,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")); @@ -491,6 +513,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()); @@ -538,7 +610,8 @@ bool TabGame::leaveGame() void TabGame::actSay() { - if (completer->popup()->isVisible()) { + if (sayEdit->hasVisibleCompleterPopup()) { + sayEdit->hideCompleterPopups(); return; } @@ -558,14 +631,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); } } @@ -628,8 +701,8 @@ void TabGame::actRotateViewCCW() void TabGame::actCompleterChanged() { - SettingsCache::instance().chat().getChatMentionCompleter() ? completer->setCompletionRole(2) - : completer->setCompletionRole(1); + SettingsCache::instance().chat().getChatMentionCompleter() ? mentionCompleter->setCompletionRole(2) + : mentionCompleter->setCompletionRole(1); } void TabGame::notifyPlayerJoin(QString playerName) @@ -838,6 +911,7 @@ void TabGame::stopGame() QMapIterator i(deckViewContainers); while (i.hasNext()) { i.next(); + i.value()->playerDeckView->advancePlaymatRotation(); i.value()->show(); } @@ -978,6 +1052,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()) { @@ -1016,6 +1095,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); @@ -1024,6 +1105,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); } @@ -1038,6 +1122,8 @@ void TabGame::createReplayMenuItems() aRotateViewCCW = nullptr; aResetLayout = nullptr; aGameInfo = nullptr; + aCopyGameLink = nullptr; + aInviteToGame = nullptr; aConcede = nullptr; aFocusChat = nullptr; aLeaveGame = new QAction(this); @@ -1236,11 +1322,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); } @@ -1264,6 +1368,7 @@ void TabGame::createMessageDock(bool bReplay) qOverload(&CardInfoFrameWidget::setCard)); connect(messageLog, &MessageLogWidget::showCardInfoPopup, this, &TabGame::showCardInfoPopup); connect(messageLog, &MessageLogWidget::deleteCardInfoPopup, this, &TabGame::deleteCardInfoPopup); + connect(messageLog, &MessageLogWidget::cockatriceLinkActivated, this, &TabGame::cockatriceLinkActivated); if (!bReplay) { connect(messageLog, &MessageLogWidget::openMessageDialog, this, &TabGame::openMessageDialog); @@ -1281,12 +1386,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 9c1406b56..a05b49a9f 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_game.h +++ b/cockatrice/src/interface/widgets/tabs/tab_game.h @@ -19,6 +19,7 @@ #include #include #include +#include class CardMenu; class ServerInfo_PlayerProperties; @@ -36,6 +37,7 @@ class CardInfoFrameWidget; class QTimer; class QSplitter; class QLabel; +class QPushButton; class QToolButton; class QMenu; class ZoneViewLayout; @@ -61,12 +63,14 @@ private: const UserListProxy *userListProxy; ReplayWidget *replayWidget = nullptr; QStringList gameTypes; - QCompleter *completer; + QCompleter *mentionCompleter; + QStringListModel *mentionModel; QStringList autocompleteUserList; QStackedWidget *mainWidget; CardInfoFrameWidget *cardInfoFrameWidget; PlayerListWidget *playerListWidget; + QPushButton *inviteButton = nullptr; QLabel *timeElapsedLabel; MessageLogWidget *messageLog; QLabel *sayLabel; @@ -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_message.cpp b/cockatrice/src/interface/widgets/tabs/tab_message.cpp index 9e9dbce1c..9506d96f3 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_message.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_message.cpp @@ -23,14 +23,16 @@ TabMessage::TabMessage(TabSupervisor *_tabSupervisor, AbstractClient *_client, const ServerInfo_User &_ownUserInfo, - const ServerInfo_User &_otherUserInfo) + const ServerInfo_User &_otherUserInfo, + bool _userOnline) : Tab(_tabSupervisor), client(_client), ownUserInfo(new ServerInfo_User(_ownUserInfo)), - otherUserInfo(new ServerInfo_User(_otherUserInfo)), userOnline(true) + otherUserInfo(new ServerInfo_User(_otherUserInfo)), userOnline(_userOnline) { chatView = new ChatView(tabSupervisor, 0, true); connect(chatView, &ChatView::showCardInfoPopup, this, &TabMessage::showCardInfoPopup); connect(chatView, &ChatView::deleteCardInfoPopup, this, &TabMessage::deleteCardInfoPopup); connect(chatView, &ChatView::addMentionTag, this, &TabMessage::addMentionTag); + connect(chatView, &ChatView::cockatriceLinkActivated, this, &TabMessage::cockatriceLinkActivated); sayEdit = new LineEditUnfocusable; sayEdit->setMaxLength(MAX_TEXT_LENGTH); connect(sayEdit, &LineEditUnfocusable::returnPressed, this, &TabMessage::sendMessage); @@ -94,28 +96,58 @@ void TabMessage::closeEvent(QCloseEvent *event) event->accept(); } +void TabMessage::sendPrivateMessage(const QString &text) +{ + Command_Message cmd; + cmd.set_user_name(otherUserInfo->name()); + 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() || !userOnline) { + if (sayEdit->text().isEmpty()) { return; } - Command_Message cmd; - cmd.set_user_name(otherUserInfo->name()); - cmd.set_message(sayEdit->text().toStdString()); - - PendingCommand *pend = client->prepareSessionCommand(cmd); - connect(pend, &PendingCommand::finished, this, &TabMessage::messageSent); - client->sendCommand(pend); + if (!userOnline) { + notifyUserOffline(); + return; + } + sendPrivateMessage(sayEdit->text()); sayEdit->clear(); } -void TabMessage::messageSent(const Response &response) +bool TabMessage::isUserOnline() const +{ + return userOnline; +} + +void TabMessage::sendInviteMessage(const QString &text) +{ + sayEdit->setText(text); + sendMessage(); +} + +void TabMessage::messageSent(const Response &response, + const CommandContainer & /*commandContainer*/, + const QVariant &extraData) { if (response.response_code() == Response::RespInIgnoreList) { chatView->appendMessage(tr( "This user is ignoring you, they cannot see your messages in main chat and you cannot join their games.")); + } else if (response.response_code() == Response::RespNameNotFound) { + // The recipient went offline before the command reached the server: restore the draft. + userOnline = false; + if (sayEdit->text().isEmpty()) { + sayEdit->setText(extraData.toString()); + } + notifyUserOffline(); } } @@ -175,3 +207,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..077b876d2 --- /dev/null +++ b/cockatrice/src/interface/widgets/tabs/tab_moderation.cpp @@ -0,0 +1,462 @@ +#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::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_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 5705c184e..6245b5301 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_room.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_room.cpp @@ -4,10 +4,17 @@ #include "../../../client/settings/shortcuts_settings.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" @@ -17,11 +24,15 @@ #include #include #include +#include #include +#include #include #include #include #include +#include +#include #include #include #include @@ -52,28 +63,19 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, tempMap.insert(info.room_id(), gameTypes); gameSelector = new GameSelector(client, tabSupervisor, this, QMap(), tempMap, true, true); - auto *tabs = new QTabWidget(this); + userListPanel = new UserListPanelWidget(tabSupervisor, client, this); + userListPanel->bind(tabSupervisor->getUserListManager()); + userList = userListPanel->getUserList(); + connect(userListPanel, &UserListPanelWidget::openMessageDialog, this, &TabRoom::openMessageDialog); - friendsList = new UserListWidget(tabSupervisor, client, UserListWidget::BuddyList); - friendsList->bind(tabSupervisor->getUserListManager()); - userList = new UserListWidget(tabSupervisor, client, UserListWidget::RoomList); - userList->bind(tabSupervisor->getUserListManager()); - ignoreList = new UserListWidget(tabSupervisor, client, UserListWidget::IgnoreList); - ignoreList->bind(tabSupervisor->getUserListManager()); - - connect(friendsList, SIGNAL(openMessageDialog(const QString &, bool)), this, - SIGNAL(openMessageDialog(const QString &, bool))); - connect(userList, SIGNAL(openMessageDialog(const QString &, bool)), this, - SIGNAL(openMessageDialog(const QString &, bool))); - - tabs->addTab(friendsList, tr("Friends")); - tabs->addTab(userList, tr("Online")); - tabs->addTab(ignoreList, tr("Ignored")); + const auto gameInviteLinkProvider = [this]() { return tabSupervisor->getGameInviteLinksForRoom(roomId); }; + userList->setGameInviteLinkProvider(gameInviteLinkProvider); chatView = new ChatView(tabSupervisor, nullptr, true, this); connect(chatView, &ChatView::showMentionPopup, this, &TabRoom::actShowMentionPopup); connect(chatView, &ChatView::messageClickedSignal, this, &TabRoom::focusTab); connect(chatView, &ChatView::openMessageDialog, this, &TabRoom::openMessageDialog); + connect(chatView, &ChatView::cockatriceLinkActivated, this, &TabRoom::cockatriceLinkActivated); connect(chatView, &ChatView::showCardInfoPopup, this, &TabRoom::showCardInfoPopup); connect(chatView, &ChatView::deleteCardInfoPopup, this, &TabRoom::deleteCardInfoPopup); connect(chatView, &ChatView::addMentionTag, this, &TabRoom::addMentionTag); @@ -118,7 +120,7 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, auto *hbox = new QHBoxLayout; hbox->addWidget(splitter, 3); - hbox->addWidget(tabs, 1); + hbox->addWidget(userListPanel, 1); aLeaveRoom = new QAction(this); connect(aLeaveRoom, &QAction::triggered, this, &TabRoom::closeRequest); @@ -137,13 +139,27 @@ TabRoom::TabRoom(TabSupervisor *_tabSupervisor, gameSelector->processGameInfo(info.game_list(i)); } - completer = new QCompleter(autocompleteUserList, sayEdit); - completer->setCaseSensitivity(Qt::CaseInsensitive); - completer->setMaxVisibleItems(5); - completer->setFilterMode(Qt::MatchStartsWith); + mentionModel = new QStringListModel(autocompleteUserList, sayEdit); + mentionCompleter = createMentionCompleter(mentionModel, sayEdit); + sayEdit->addCompleter(mentionCompleter, CompleterTrigger::Mention); + + auto *cardDatabaseModel = new CardDatabaseModel(CardDatabaseManager::getInstance(), false, sayEdit); + auto *displayModel = new CardDatabaseDisplayModel(sayEdit); + displayModel->setSourceModel(cardDatabaseModel); + const CardCompleterSetup cardSetup = createCardCompleter(displayModel, sayEdit); + sayEdit->addCompleter(cardSetup.completer, CompleterTrigger::Card); + + connect(sayEdit, &LineEditCompleter::cardPartialChanged, this, [this, cardSetup](const QString &text) { + cardSetup.searchModel->updateSearchResults(text); + cardSetup.proxyModel->setFilterRegularExpression( + QRegularExpression(QRegularExpression::escape(text), QRegularExpression::CaseInsensitiveOption)); + if (sayEdit->hasFocus()) { + cardSetup.completer->complete(); + } + }); - sayEdit->setCompleter(completer); actCompleterChanged(); + connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this, &TabRoom::refreshShortcuts); refreshShortcuts(); @@ -159,7 +175,7 @@ void TabRoom::retranslateUi() { gameSelector->retranslateUi(); chatView->retranslateUi(); - userList->retranslateUi(); + userListPanel->retranslateUi(); sayLabel->setText(tr("&Say:")); chatGroupBox->setTitle(tr("Chat")); roomMenu->setTitle(tr("&Room")); @@ -213,8 +229,8 @@ void TabRoom::sendMessage() { if (sayEdit->text().isEmpty()) { return; - } else if (completer->popup()->isVisible()) { - completer->popup()->hide(); + } else if (sayEdit->hasVisibleCompleterPopup()) { + sayEdit->hideCompleterPopups(); return; } else { Command_RoomSay cmd; @@ -248,8 +264,8 @@ void TabRoom::actOpenChatSettings() void TabRoom::actCompleterChanged() { - SettingsCache::instance().chat().getChatMentionCompleter() ? completer->setCompletionRole(2) - : completer->setCompletionRole(1); + SettingsCache::instance().chat().getChatMentionCompleter() ? mentionCompleter->setCompletionRole(2) + : mentionCompleter->setCompletionRole(1); } void TabRoom::processRoomEvent(const RoomEvent &event) @@ -285,16 +301,18 @@ void TabRoom::processListGamesEvent(const Event_ListGames &event) void TabRoom::processJoinRoomEvent(const Event_JoinRoom &event) { - if (!autocompleteUserList.contains("@" + QString::fromStdString(event.user_info().name()))) { - autocompleteUserList << "@" + QString::fromStdString(event.user_info().name()); - sayEdit->setCompletionList(autocompleteUserList); + QString mention = "@" + QString::fromStdString(event.user_info().name()); + if (!autocompleteUserList.contains(mention)) { + autocompleteUserList << mention; + mentionModel->setStringList(autocompleteUserList); } } void TabRoom::processLeaveRoomEvent(const Event_LeaveRoom &event) { - autocompleteUserList.removeOne("@" + QString::fromStdString(event.name())); - sayEdit->setCompletionList(autocompleteUserList); + QString mention = "@" + QString::fromStdString(event.name()); + autocompleteUserList.removeOne(mention); + mentionModel->setStringList(autocompleteUserList); } void TabRoom::processRoomSayEvent(const Event_RoomSay &event) diff --git a/cockatrice/src/interface/widgets/tabs/tab_room.h b/cockatrice/src/interface/widgets/tabs/tab_room.h index 2881c25f4..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,7 +74,7 @@ private: [[nodiscard]] QString sanitizeHtml(QString dirty) const; QStringList autocompleteUserList; - QCompleter *completer; + QCompleter *mentionCompleter; signals: void roomClosing(TabRoom *tab); void openMessageDialog(const QString &userName, bool focus); @@ -112,6 +114,10 @@ public: { return roomId; } + [[nodiscard]] QString getRoomName() const + { + return roomName; + } [[nodiscard]] const QMap &getGameTypes() const { return gameTypes; diff --git a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp index 39fd44438..a59e83637 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_supervisor.cpp @@ -2,7 +2,9 @@ #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" @@ -17,7 +19,9 @@ #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" @@ -30,6 +34,7 @@ #include #include #include +#include #include #include #include @@ -38,6 +43,7 @@ #include #include #include +#include #include #include #include @@ -100,7 +106,7 @@ 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) + tabLog(nullptr), tabReport(nullptr), tabModeration(nullptr), isLocalGame(false) { setElideMode(Qt::ElideRight); setMovable(true); @@ -117,6 +123,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 @@ -171,6 +178,14 @@ 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); + connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this, &TabSupervisor::refreshShortcuts); refreshShortcuts(); @@ -210,6 +225,8 @@ 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")); // tabs QList tabs; @@ -219,6 +236,8 @@ void TabSupervisor::retranslateUi() tabs.append(tabAdmin); tabs.append(tabAccount); tabs.append(tabLog); + tabs.append(tabReport); + tabs.append(tabModeration); QMapIterator roomIterator(roomTabs); while (roomIterator.hasNext()) { tabs.append(roomIterator.next().value()); @@ -265,6 +284,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) @@ -317,7 +338,12 @@ static void checkAndTrigger(QAction *checkableAction, bool checked) } /** - * Opens the always-available tabs, depending on settings. + * Opens the always-available tabs, depending on settings, and lands on the configured startup tab. + * + * The startup destination is a request: tabs that were not open before (deck editors, storage + * tabs disabled in the Tabs menu) are opened as part of the startup flow. Destinations that + * require a server connection (Server, Server Room) are handled asynchronously by MainWindow + * through the intent system, since this class has no RemoteClient. */ void TabSupervisor::initStartupTabs() { @@ -333,6 +359,42 @@ void TabSupervisor::initStartupTabs() if (SettingsCache::instance().tabs().getTabReplaysOpen()) { openTabReplays(); } + + switch (SettingsCache::instance().tabs().getStartupTabIndex()) { + case StartupTab::StartupTabVisualDeckStorage: + if (!tabVisualDeckStorage) { + openTabVisualDeckStorage(); + } + setCurrentWidget(tabVisualDeckStorage); + break; + case StartupTab::StartupTabDeckStorage: + if (!tabDeckStorage) { + openTabDeckStorage(); + } + setCurrentWidget(tabDeckStorage); + break; + case StartupTab::StartupTabReplays: + if (!tabReplays) { + openTabReplays(); + } + setCurrentWidget(tabReplays); + break; + case StartupTab::StartupTabDeckEditor: + addDeckEditorTab(LoadedDeck()); + break; + case StartupTab::StartupTabVisualDeckEditor: + addVisualDeckEditorTab(LoadedDeck()); + break; + case StartupTab::StartupTabServer: + case StartupTab::StartupTabServerRoom: + // Handled asynchronously by MainWindow::applyStartupDestination(); Home stays selected + // until the server connection succeeds. + break; + case StartupTab::StartupTabHome: + default: + setCurrentWidget(tabHome); + break; + } } /** @@ -347,6 +409,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)); @@ -424,6 +487,8 @@ void TabSupervisor::start(const ServerInfo_User &_userInfo) tabsMenu->addAction(aTabAdmin); tabsMenu->addAction(aTabLog); tabsMenu->addAction(aTabCardArtRules); + tabsMenu->addAction(aTabReport); + tabsMenu->addAction(aTabModeration); if (SettingsCache::instance().tabs().getTabAdminOpen()) { openTabAdmin(); @@ -431,6 +496,12 @@ void TabSupervisor::start(const ServerInfo_User &_userInfo) if (SettingsCache::instance().tabs().getTabLogOpen()) { openTabLog(); } + if (SettingsCache::instance().tabs().getTabReportOpen()) { + openTabReport(); + } + if (SettingsCache::instance().tabs().getTabModerationOpen()) { + openTabModeration(); + } openTabCardArtRules(); } @@ -444,6 +515,8 @@ void TabSupervisor::startLocal(const QList &_clients) tabAccount = nullptr; tabAdmin = nullptr; tabLog = nullptr; + tabReport = nullptr; + tabModeration = nullptr; isLocalGame = true; userInfo = new ServerInfo_User; localClients = _clients; @@ -485,6 +558,12 @@ void TabSupervisor::stop() if (tabLog) { tabLog->close(); } + if (tabReport) { + tabReport->close(); + } + if (tabModeration) { + tabModeration->close(); + } } QList tabsToDelete; @@ -722,6 +801,59 @@ 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::updatePingTime(int value, int max) { if (!tabServer) { @@ -734,6 +866,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; @@ -792,6 +941,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) { @@ -838,6 +988,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())) { @@ -845,8 +1019,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()); } @@ -860,9 +1036,10 @@ TabMessage *TabSupervisor::addMessageTab(const QString &receiverName, bool focus return tab; } - tab = new TabMessage(this, client, *userInfo, otherUser); + 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) { @@ -886,6 +1063,53 @@ 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 (TabGame *tab : gameTabs) { + 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 @@ -1172,6 +1396,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 5d349e18c..852ebe645 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" @@ -22,6 +23,7 @@ #include #include #include +#include class TabCardArtRules; inline Q_LOGGING_CATEGORY(TabSupervisorLog, "tab_supervisor"); @@ -38,6 +40,8 @@ class TabDeckStorage; class TabReplays; class TabAdmin; class TabMessage; +class TabReport; +class TabModeration; class TabAccount; class TabDeckEditor; class TabLog; @@ -94,6 +98,8 @@ private: TabAdmin *tabAdmin; TabCardArtRules *tabCardArtRules; TabLog *tabLog; + TabReport *tabReport; + TabModeration *tabModeration; QMap roomTabs; QMap gameTabs; QList replayTabs; @@ -103,7 +109,7 @@ private: QAction *aTabHome, *aTabDeckEditor, *aTabVisualDeckEditor, *aTabEdhRec, *aTabArchidekt, *aTabVisualDeckStorage, *aTabVisualDatabaseDisplay, *aTabServer, *aTabAccount, *aTabDeckStorage, *aTabReplays, *aTabAdmin, - *aTabCardArtRules, *aTabLog; + *aTabCardArtRules, *aTabLog, *aTabReport, *aTabModeration; int myAddTab(Tab *tab, QAction *manager = nullptr); void addCloseButtonToTab(Tab *tab, int tabIndex, QAction *manager); @@ -152,6 +158,8 @@ public: { return deckEditorTabs; } + [[nodiscard]] QList getGameInviteLinksForRoom(int roomId) const; + void sendInviteToUser(const QString &userName, const QString &inviteText); [[nodiscard]] bool getAdminLocked() const; void closeEvent(QCloseEvent *event) override; bool switchToGameTabIfAlreadyExists(const int gameId); @@ -161,6 +169,7 @@ signals: void localGameEnded(); void adminLockChanged(bool lock); void showWindowIfHidden(); + void cockatriceLinkActivated(const QString &url); public slots: void openDeckInNewTab(const LoadedDeck &deckToOpen); @@ -171,11 +180,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(); @@ -185,6 +197,8 @@ private slots: void actTabDeckStorage(bool checked); void actTabAdmin(bool checked); void actTabLog(bool checked); + void actTabReport(bool checked); + void actTabModeration(bool checked); void openTabVisualDeckStorage(); void openTabHome(); @@ -195,13 +209,14 @@ private slots: void actTabCardArtRules(bool checked); void openTabCardArtRules(); void openTabLog(); + void openTabReport(); 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 0bc927eeb..209a30642 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 @@ -27,10 +27,10 @@ #include #include #include -#include #include #include #include +#include #include /** @@ -67,9 +67,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, @@ -102,6 +99,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() { 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..21335d2d0 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,15 @@ public slots: * @return true if successful, false otherwise. */ bool actSaveDeckAs() override; + +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/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..4a558a5e0 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 @@ -82,17 +82,14 @@ VisualDatabaseDisplayFilterToolbarWidget::VisualDatabaseDisplayFilterToolbarWidg 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_widget.cpp b/cockatrice/src/interface/widgets/visual_database_display/visual_database_display_widget.cpp index dc98e6940..0cdf60d5d 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,6 +16,7 @@ #include #include +#include #include #include #include @@ -140,9 +141,6 @@ void VisualDatabaseDisplayWidget::initialize() { databaseLoadIndicator->setVisible(false); - filterContainer->initialize(); - filterContainer->setVisible(true); - searchContainer->addWidget(colorFilterWidget); searchContainer->addWidget(clearFilterWidget); searchContainer->addWidget(searchEdit); @@ -158,17 +156,43 @@ 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::retranslateUi() @@ -292,9 +316,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..6e4d87876 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 @@ -115,17 +115,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_deck_editor/visual_deck_editor_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_editor/visual_deck_editor_widget.cpp index e5bcb2fd3..e3261b346 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 @@ -10,6 +10,7 @@ #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,8 +22,6 @@ #include #include #include -#include -#include #include #include #include @@ -30,8 +29,10 @@ 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); @@ -95,35 +96,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) { 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 f1dcf113f..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)) @@ -32,10 +32,6 @@ DeckPreviewColorIdentityFilterWidget::DeckPreviewColorIdentityFilterWidget(Visua // Connect the button's clicked signal connect(toggleButton, &QPushButton::clicked, this, &DeckPreviewColorIdentityFilterWidget::updateFilterMode); - connect(this, &DeckPreviewColorIdentityFilterWidget::activeColorsChanged, parent, - &VisualDeckStorageWidget::updateColorFilter); - connect(this, &DeckPreviewColorIdentityFilterWidget::filterModeChanged, parent, - &VisualDeckStorageWidget::updateColorFilter); // Call retranslateUi to set the initial text retranslateUi(); @@ -45,19 +41,33 @@ void DeckPreviewColorIdentityFilterWidget::retranslateUi() { // Set the toggle button text based on the current mode switch (filterMode) { - case ExactMatch: + case VisualDeckStorageSortFilterProxyModel::FilterMode::ExactMatch: toggleButton->setText(tr("Mode: Exact Match")); break; - case Includes: + case VisualDeckStorageSortFilterProxyModel::FilterMode::Includes: toggleButton->setText(tr("Mode: Includes")); break; - case Excludes: + case VisualDeckStorageSortFilterProxyModel::FilterMode::Excludes: toggleButton->setText(tr("Mode: Excludes")); break; } toggleButton->setToolTip(tr("Color identity filter mode (AND/OR/NOT conjunctions of filters)")); } +/** + * @brief The colors that are currently toggled on. + */ +QSet DeckPreviewColorIdentityFilterWidget::getActiveColors() const +{ + QSet activeColorSet; + for (auto it = activeColors.constBegin(); it != activeColors.constEnd(); ++it) { + if (it.value()) { + activeColorSet.insert(it.key()); + } + } + return activeColorSet; +} + void DeckPreviewColorIdentityFilterWidget::handleColorToggled(QChar color, bool active) { activeColors[color] = active; @@ -68,88 +78,17 @@ void DeckPreviewColorIdentityFilterWidget::updateFilterMode() { // Cycle through the modes switch (filterMode) { - case ExactMatch: - filterMode = Includes; + case VisualDeckStorageSortFilterProxyModel::FilterMode::ExactMatch: + filterMode = VisualDeckStorageSortFilterProxyModel::FilterMode::Includes; break; - case Includes: - filterMode = Excludes; + case VisualDeckStorageSortFilterProxyModel::FilterMode::Includes: + filterMode = VisualDeckStorageSortFilterProxyModel::FilterMode::Excludes; break; - case Excludes: - filterMode = ExactMatch; + case VisualDeckStorageSortFilterProxyModel::FilterMode::Excludes: + filterMode = VisualDeckStorageSortFilterProxyModel::FilterMode::ExactMatch; break; } retranslateUi(); // Update the button text emit filterModeChanged(filterMode); } - -void DeckPreviewColorIdentityFilterWidget::filterWidgets(QList widgets) -{ - // Check if no colors are active - bool noColorsActive = true; - for (auto it = activeColors.constBegin(); it != activeColors.constEnd(); ++it) { - if (it.value()) { - noColorsActive = false; - break; - } - } - - // If no colors are active, return the unfiltered list of widgets - if (noColorsActive) { - for (DeckPreviewWidget *previewWidget : widgets) { - previewWidget->filteredByColor = false; - } - return; - } - - for (const auto &widget : widgets) { - QString colorIdentity = widget->getColorIdentity(); - - bool matchesFilter = true; - switch (filterMode) { - case ExactMatch: { - // Exact match mode: active colors must exactly match colorIdentity - - // Create a set of active colors - QSet activeColorSet; - for (auto it = activeColors.constBegin(); it != activeColors.constEnd(); ++it) { - if (it.value()) { - activeColorSet.insert(it.key().toUpper()); // Use uppercase for uniformity - } - } - - // Create a set of colors from the color identity string - QSet colorIdentitySet; - for (const QChar &color : colorIdentity) { - colorIdentitySet.insert(color.toUpper()); // Ensure case uniformity - } - - // Compare the sets: the sets must match exactly - if (activeColorSet != colorIdentitySet) { - matchesFilter = false; - } - break; - } - case Includes: - // Includes mode: colorIdentity must contain all active colors - for (auto it = activeColors.constBegin(); it != activeColors.constEnd(); ++it) { - if (it.value() && !colorIdentity.contains(it.key())) { - matchesFilter = false; - break; - } - } - break; - case Excludes: - // Excludes mode: colorIdentity must contain none of the active colors - for (auto it = activeColors.constBegin(); it != activeColors.constEnd(); ++it) { - if (it.value() && colorIdentity.contains(it.key())) { - matchesFilter = false; - break; - } - } - break; - } - - widget->filteredByColor = !matchesFilter; - } -} diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_color_identity_filter_widget.h index 8e60b16fb..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 @@ -21,25 +21,34 @@ class DeckPreviewColorIdentityFilterWidget : public QWidget Q_OBJECT public: - /** - * How the active colors are matched against a deck's color identity. - */ - enum FilterMode - { - ExactMatch, ///< The color identity consists of exactly the active colors. - Includes, ///< The color identity contains all of the active colors. - Excludes ///< The color identity contains none of the active colors. - }; - Q_ENUM(FilterMode) - explicit DeckPreviewColorIdentityFilterWidget(VisualDeckStorageWidget *parent); void retranslateUi(); - void filterWidgets(QList widgets); + + /** + * @brief The currently active color identity filter mode. + */ + [[nodiscard]] VisualDeckStorageSortFilterProxyModel::FilterMode getFilterMode() const + { + return filterMode; + } + + /** + * @brief The colors that are currently toggled on. + */ + [[nodiscard]] QSet getActiveColors() const; signals: - void filterModeChanged(FilterMode mode); + /** + * Emitted when the set of active colors changed due to user interaction. + */ void activeColorsChanged(); + /** + * Emitted when the user cycles the color identity filter mode. + * @param mode The new filter mode. + */ + void filterModeChanged(VisualDeckStorageSortFilterProxyModel::FilterMode mode); + private slots: void handleColorToggled(QChar color, bool active); void updateFilterMode(); @@ -48,7 +57,7 @@ private: QHBoxLayout *layout; QPushButton *toggleButton; QMap activeColors; - FilterMode filterMode = Includes; // Default to "includes" mode + VisualDeckStorageSortFilterProxyModel::FilterMode filterMode = VisualDeckStorageSortFilterProxyModel::Includes; }; #endif // DECK_PREVIEW_COLOR_IDENTITY_FILTER_WIDGET_H diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_deck_tags_display_widget.cpp index b45f61be7..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,19 +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 -#include DeckPreviewDeckTagsDisplayWidget::DeckPreviewDeckTagsDisplayWidget(QWidget *_parent, const QStringList &_tags) : QWidget(_parent), currentTags(_tags) @@ -54,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 */ @@ -92,93 +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().visualDeckStorage().getVisualDeckStoragePromptForConversion()) { - if (!SettingsCache::instance().visualDeckStorage().getVisualDeckStorageAlwaysConvert()) { - return false; - } - - if (!confirmOverwriteIfExists(this, deckPreviewWidget->filePath)) { - return false; - } - - convertFileToCockatriceFormat(deckPreviewWidget); - return true; - } - - // Show the dialog to the user - DialogConvertDeckToCodFormat conversionDialog(parentWidget()); - if (conversionDialog.exec() != QDialog::Accepted) { - SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion( - !conversionDialog.dontAskAgain()); - SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(false); - - return false; - } - - // Try to convert file - if (!confirmOverwriteIfExists(this, deckPreviewWidget->filePath)) { - return false; - } - - convertFileToCockatriceFormat(deckPreviewWidget); - - if (conversionDialog.dontAskAgain()) { - SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion(false); - SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(true); - } - - return true; + const QStringList knownTags = knownTagsProvider ? knownTagsProvider() : findAllKnownTags(); + execTagDialog(knownTags); } void DeckPreviewDeckTagsDisplayWidget::execTagDialog(const QStringList &knownTags) @@ -191,4 +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_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp index ffe954308..04dcdf7f2 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.cpp @@ -1,47 +1,69 @@ #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 #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; + + colorIdentityWidget = new ColorIdentityWidget(this); + deckTagsDisplayWidget = new DeckPreviewDeckTagsDisplayWidget(this); + connect(deckTagsDisplayWidget, &DeckPreviewDeckTagsDisplayWidget::tagsChanged, this, &DeckPreviewWidget::setTags); + deckTagsDisplayWidget->setKnownTagsProvider( + [this] { return visualDeckStorageWidget->tagFilterWidget->getAllKnownTags(); }); + deckTagsDisplayWidget->setConversionPromptHandler([this] { return promptFileConversionIfRequired(); }); + + bannerCardLabel = new QLabel(this); + bannerCardLabel->setObjectName("bannerCardLabel"); + bannerCardComboBox = new QComboBox(this); + bannerCardComboBox->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); + bannerCardComboBox->setObjectName("bannerCardComboBox"); + bannerCardComboBox->installEventFilter(new NoScrollFilter(bannerCardComboBox)); + connect(bannerCardComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, + &DeckPreviewWidget::setBannerCard); + + // Apply the initial visibility settings and keep them in sync while they change. + updateColorIdentityVisibility( + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowColorIdentity()); + updateBannerCardComboBoxVisibility( + SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowBannerCardComboBox()); + updateTagsVisibility(SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowTagsOnDeckPreviews()); connect(&SettingsCache::instance().visualDeckStorage(), &VisualDeckStorageSettings::visualDeckStorageShowColorIdentityChanged, this, @@ -56,6 +78,29 @@ DeckPreviewWidget::DeckPreviewWidget(QWidget *_parent, &DeckPreviewWidget::refreshBannerCardToolTip); layout->addWidget(bannerCardDisplayWidget); + layout->addWidget(colorIdentityWidget); + layout->addWidget(deckTagsDisplayWidget); + layout->addWidget(bannerCardLabel); + layout->addWidget(bannerCardComboBox); + + // Only re-sync when this widget's own row changed. Without the row check, every + // finished deck load would trigger a full resync (card db lookup + combo rebuild) + // in every preview widget. + connect(model, &QAbstractItemModel::dataChanged, this, + [this](const QModelIndex &topLeft, const QModelIndex &bottomRight) { + const int r = row(); + if (r >= topLeft.row() && r <= bottomRight.row()) { + syncFromModel(); + } + }); + + retranslateUi(); + syncFromModel(); + + // resizeEvent clamps every child to the picture's width, so collect them once here + // to keep the resize handler from searching the widget tree on every layout pass. + fixedWidthChildren = {bannerCardDisplayWidget, colorIdentityWidget, deckTagsDisplayWidget, bannerCardLabel, + bannerCardComboBox}; } void DeckPreviewWidget::retranslateUi() @@ -69,9 +114,15 @@ 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); } } @@ -79,83 +130,28 @@ void DeckPreviewWidget::enterEvent(QEnterEvent *event) { QWidget::enterEvent(event); - // don't do reloads until widgets have been created - if (bannerCardComboBox != nullptr) { - reloadIfModified(); + // Don't do reloads until the deck has actually been loaded once. + reloadIfModified(); +} + +/** + * @brief The row of this deck in the source model, or -1 if it no longer exists. + */ +int DeckPreviewWidget::row() const +{ + return model->rowForFilePath(filePath); +} + +/** + * @brief The display name is given by the deck name, or the filename if the deck name is not set. + */ +QString DeckPreviewWidget::getDisplayName() const +{ + const int r = row(); + if (r == -1) { + return {}; } -} - -/** - * @brief Sets the lastModifiedTime to the value given by the file. - */ -void DeckPreviewWidget::updateLastModifiedTime() -{ - QFileInfo fileInfo(filePath); - lastModifiedTime = fileInfo.lastModified(); -} - -/** - * @brief Writes the current contents of the deck to file. Updates the lastModifiedTime afterward. - */ -void DeckPreviewWidget::writeDeckToFile() -{ - DeckLoader::saveToFile(deckLoader->getDeck()); - updateLastModifiedTime(); -} - -void DeckPreviewWidget::initializeUi(const bool deckLoadSuccess) -{ - if (!deckLoadSuccess) { - return; - } - - QFileInfo fileInfo(filePath); - lastModifiedTime = fileInfo.lastModified(); - - bannerCardDisplayWidget->setFontSize(24); - setFilePath(deckLoader->getDeck().lastLoadInfo.fileName); - - colorIdentityWidget = new ColorIdentityWidget(this); - deckTagsDisplayWidget = new DeckPreviewDeckTagsDisplayWidget(this); - connect(deckTagsDisplayWidget, &DeckPreviewDeckTagsDisplayWidget::tagsChanged, this, &DeckPreviewWidget::setTags); - - bannerCardLabel = new QLabel(this); - bannerCardLabel->setObjectName("bannerCardLabel"); - bannerCardComboBox = new QComboBox(this); - bannerCardComboBox->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Minimum); - bannerCardComboBox->setObjectName("bannerCardComboBox"); - bannerCardComboBox->installEventFilter(new NoScrollFilter(bannerCardComboBox)); - connect(bannerCardComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, - &DeckPreviewWidget::setBannerCard); - - updateColorIdentityVisibility( - SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowColorIdentity()); - updateBannerCardComboBoxVisibility( - SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowBannerCardComboBox()); - updateTagsVisibility(SettingsCache::instance().visualDeckStorage().getVisualDeckStorageShowTagsOnDeckPreviews()); - - layout->addWidget(colorIdentityWidget); - layout->addWidget(deckTagsDisplayWidget); - layout->addWidget(bannerCardLabel); - layout->addWidget(bannerCardComboBox); - - retranslateUi(); - resyncWidgets(); -} - -/** - * @brief Syncs the contents of the child widgets with the current deck. - */ -void DeckPreviewWidget::resyncWidgets() -{ - auto bannerCardRef = deckLoader->getDeck().deckList.getBannerCard(); - auto bannerCard = bannerCardRef.name.isEmpty() ? ExactCard() : CardDatabaseManager::query()->getCard(bannerCardRef); - - bannerCardDisplayWidget->setCard(bannerCard); - refreshBannerCardText(); - updateBannerCardComboBox(bannerCardRef.name); - colorIdentityWidget->setColorIdentity(getColorIdentity()); - deckTagsDisplayWidget->setTags(deckLoader->getDeck().deckList.getTags()); + return model->dataForRow(r).displayName; } /** @@ -163,33 +159,36 @@ void DeckPreviewWidget::resyncWidgets() */ void DeckPreviewWidget::reloadIfModified() { - QFileInfo fileInfo(filePath); - QDateTime newLastModifiedTime = fileInfo.lastModified(); - - if (!newLastModifiedTime.isValid() || newLastModifiedTime <= lastModifiedTime) { + const int r = row(); + if (r == -1 || !model->dataForRow(r).loadSucceeded) { return; } - bool success = deckLoader->reload(); - - if (success) { - fileInfo.refresh(); - lastModifiedTime = fileInfo.lastModified(); - resyncWidgets(); - } + model->reloadIfModified(r); } -void DeckPreviewWidget::updateVisibility() +/** + * @brief Syncs the contents of the child widgets with the current row's data. + */ +void DeckPreviewWidget::syncFromModel() { - setHidden(!checkVisibility()); -} - -bool DeckPreviewWidget::checkVisibility() const -{ - if (filteredBySearch || filteredByColor || filteredByTags) { - return false; + const int r = row(); + if (r == -1) { + return; } - return true; + + const DeckPreviewData &data = model->dataForRow(r); + filePath = data.filePath; + + const CardRef bannerCardRef = data.deck.deckList.getBannerCard(); + const ExactCard bannerCard = + bannerCardRef.name.isEmpty() ? ExactCard() : CardDatabaseManager::query()->getCard(bannerCardRef); + + bannerCardDisplayWidget->setCard(bannerCard); + refreshBannerCardText(); + updateBannerCardComboBox(bannerCardRef.name); + colorIdentityWidget->setColorIdentity(data.colorIdentity); + deckTagsDisplayWidget->setTags(data.tags); } void DeckPreviewWidget::updateColorIdentityVisibility(bool visible) @@ -229,51 +228,6 @@ void DeckPreviewWidget::updateTagsVisibility(bool visible) } } -QString DeckPreviewWidget::getColorIdentity() -{ - QStringList cardList = deckLoader->getDeck().deckList.getCardList({DECK_ZONE_MAIN, DECK_ZONE_SIDE}); - if (cardList.isEmpty()) { - return {}; - } - - QSet colorSet; // A set to collect unique color symbols (e.g., W, U, B, R, G) - - for (const QString &cardName : cardList) { - CardInfoPtr currentCard = CardDatabaseManager::query()->getCardInfo(cardName); - if (currentCard) { - QString colors = currentCard->getColors(); // Assuming this returns something like "WUB" - for (const QChar &color : colors) { - colorSet.insert(color); - } - } - } - - // Ensure the color identity is in WUBRG order - QString colorIdentity; - const QString wubrgOrder = "WUBRG"; - for (const QChar &color : wubrgOrder) { - if (colorSet.contains(color)) { - colorIdentity.append(color); - } - } - - return colorIdentity; -} - -/** - * The display name is given by the deck name, or the filename if the deck name is not set. - */ -QString DeckPreviewWidget::getDisplayName() const -{ - QString deckName = deckLoader->getDeck().deckList.getName(); - return !deckName.isEmpty() ? deckName : QFileInfo(deckLoader->getDeck().lastLoadInfo.fileName).fileName(); -} - -void DeckPreviewWidget::setFilePath(const QString &_filePath) -{ - filePath = _filePath; -} - /** * Refreshes the banner card text. * This also calls `refreshBannerCardToolTip`, since those two often need to be updated together. @@ -310,11 +264,15 @@ void DeckPreviewWidget::updateBannerCardComboBox(const QString ¤tText) // Prepare the new items with deduplication QSet> bannerCardSet; - QList cardsInDeck = deckLoader->getDeck().deckList.getCardNodes(); + const int r = row(); + if (r != -1) { + const DeckList &deckList = model->dataForRow(r).deck.deckList; + const QList cardsInDeck = deckList.getCardNodes(); - for (auto currentCard : cardsInDeck) { - for (int k = 0; k < currentCard->getNumber(); ++k) { - bannerCardSet.insert(QPair(currentCard->getName(), currentCard->getCardProviderId())); + for (auto currentCard : cardsInDeck) { + for (int k = 0; k < currentCard->getNumber(); ++k) { + bannerCardSet.insert(QPair(currentCard->getName(), currentCard->getCardProviderId())); + } } } @@ -327,16 +285,16 @@ void DeckPreviewWidget::updateBannerCardComboBox(const QString ¤tText) // This is *slightly* more performant than using addItem in a loop. - QStandardItemModel *model = new QStandardItemModel(pairList.size(), 1, bannerCardComboBox); + QStandardItemModel *comboModel = new QStandardItemModel(pairList.size(), 1, bannerCardComboBox); int row = 0; for (const auto &pair : pairList) { QStandardItem *item = new QStandardItem(pair.first); item->setData(QVariant::fromValue(pair), Qt::UserRole); - model->setItem(row++, 0, item); + comboModel->setItem(row++, 0, item); } - bannerCardComboBox->setModel(model); + bannerCardComboBox->setModel(comboModel); // Try to restore the previous selection by finding the currentText int restoredIndex = bannerCardComboBox->findText(currentText); @@ -344,7 +302,9 @@ void DeckPreviewWidget::updateBannerCardComboBox(const QString ¤tText) bannerCardComboBox->setCurrentIndex(restoredIndex); } else { // Add a placeholder "-" and set it as the current selection - int bannerIndex = bannerCardComboBox->findText(deckLoader->getDeck().deckList.getBannerCard().name); + const QString currentBannerCardName = + r == -1 ? QString() : model->dataForRow(r).deck.deckList.getBannerCard().name; + int bannerIndex = bannerCardComboBox->findText(currentBannerCardName); if (bannerIndex != -1) { bannerCardComboBox->setCurrentIndex(bannerIndex); } else { @@ -362,8 +322,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)); } @@ -385,17 +348,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); @@ -408,14 +378,26 @@ QMenu *DeckPreviewWidget::createRightClickMenu() auto saveToClipboardMenu = menu->addMenu(tr("Save Deck to Clipboard")); - connect(saveToClipboardMenu->addAction(tr("Annotated")), &QAction::triggered, this, - [this] { DeckLoader::saveToClipboard(deckLoader->getDeck().deckList, true, true); }); - connect(saveToClipboardMenu->addAction(tr("Annotated (No set info)")), &QAction::triggered, this, - [this] { DeckLoader::saveToClipboard(deckLoader->getDeck().deckList, true, false); }); - connect(saveToClipboardMenu->addAction(tr("Not Annotated")), &QAction::triggered, this, - [this] { DeckLoader::saveToClipboard(deckLoader->getDeck().deckList, false, true); }); - connect(saveToClipboardMenu->addAction(tr("Not Annotated (No set info)")), &QAction::triggered, this, - [this] { DeckLoader::saveToClipboard(deckLoader->getDeck().deckList, false, false); }); + connect(saveToClipboardMenu->addAction(tr("Annotated")), &QAction::triggered, this, [this, r] { + if (r != -1) { + DeckLoader::saveToClipboard(model->dataForRow(r).deck.deckList, true, true); + } + }); + connect(saveToClipboardMenu->addAction(tr("Annotated (No set info)")), &QAction::triggered, this, [this, r] { + if (r != -1) { + DeckLoader::saveToClipboard(model->dataForRow(r).deck.deckList, true, false); + } + }); + connect(saveToClipboardMenu->addAction(tr("Not Annotated")), &QAction::triggered, this, [this, r] { + if (r != -1) { + DeckLoader::saveToClipboard(model->dataForRow(r).deck.deckList, false, true); + } + }); + connect(saveToClipboardMenu->addAction(tr("Not Annotated (No set info)")), &QAction::triggered, this, [this, r] { + if (r != -1) { + DeckLoader::saveToClipboard(model->dataForRow(r).deck.deckList, false, false); + } + }); menu->addSeparator(); @@ -450,57 +432,58 @@ void DeckPreviewWidget::addSetBannerCardMenu(QMenu *menu) void DeckPreviewWidget::actRenameDeck() { + const int r = row(); + if (r == -1) { + return; + } + // read input - const QString oldName = deckLoader->getDeck().deckList.getName(); + const QString oldName = model->dataForRow(r).deckName; bool ok; - QString newName = QInputDialog::getText(this, "Rename deck", tr("New name:"), QLineEdit::Normal, oldName, &ok); + QString newName = QInputDialog::getText(this, tr("Rename deck"), tr("New name:"), QLineEdit::Normal, oldName, &ok); if (!ok || oldName == newName) { return; } // write change - deckLoader->getDeck().deckList.setName(newName); - writeDeckToFile(); + model->renameDeck(r, newName); - // update VDS - refreshBannerCardText(); + // The banner card text updates via the model's dataChanged signal. } void DeckPreviewWidget::actRenameFile() { + const int r = row(); + if (r == -1) { + return; + } + // read input const auto info = QFileInfo(filePath); const QString oldName = info.baseName(); bool ok; - QString newName = QInputDialog::getText(this, "Rename file", tr("New name:"), QLineEdit::Normal, oldName, &ok); + QString newName = QInputDialog::getText(this, tr("Rename file"), tr("New name:"), QLineEdit::Normal, oldName, &ok); if (!ok || newName.isEmpty() || oldName == newName) { return; } - QString newFileName = newName; - if (!info.suffix().isEmpty()) { - newFileName += "." + info.suffix(); - } - // write change - const QString newFilePath = QFileInfo(info.dir(), newFileName).filePath(); - if (!QFile::rename(info.filePath(), newFilePath)) { + if (!model->renameFile(r, newName)) { QMessageBox::critical(this, tr("Error"), tr("Rename failed")); - return; } - deckLoader->getDeck().lastLoadInfo.fileName = newFilePath; - setFilePath(newFilePath); - - // update VDS - updateLastModifiedTime(); - refreshBannerCardText(); + // The file path and banner card text update via the model's signals. } void DeckPreviewWidget::actDeleteFile() { + const int r = row(); + if (r == -1) { + return; + } + // read input auto res = QMessageBox::warning(this, tr("Delete file"), tr("Are you sure you want to delete the selected file?"), QMessageBox::Yes | QMessageBox::No); @@ -509,11 +492,74 @@ 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. +} + +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 +} + +/** + * 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() +{ + 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(this, filePath)) { + return false; + } + + model->convertToCockatriceFormat(row()); + return true; + } + + // Show the dialog to the user + DialogConvertDeckToCodFormat conversionDialog(this); + if (conversionDialog.exec() != QDialog::Accepted) { + SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion( + !conversionDialog.dontAskAgain()); + SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(false); + + return false; + } + + // Try to convert file + if (!confirmOverwriteIfExists(this, filePath)) { + return false; + } + + model->convertToCockatriceFormat(row()); + + if (conversionDialog.dontAskAgain()) { + SettingsCache::instance().visualDeckStorage().setVisualDeckStoragePromptForConversion(false); + SettingsCache::instance().visualDeckStorage().setVisualDeckStorageAlwaysConvert(true); + } + + return true; } diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/deck_preview/deck_preview_widget.h index de66c194b..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,72 +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: 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 cc7f07871..fbaabf90f 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_folder_display_widget.cpp @@ -1,20 +1,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 +#include VisualDeckStorageFolderDisplayWidget::VisualDeckStorageFolderDisplayWidget( QWidget *parent, VisualDeckStorageWidget *_visualDeckStorageWidget, - QString _filePath, + const QString &_folderPath, bool canBeHidden, bool _showFolders) - : QWidget(parent), showFolders(_showFolders), visualDeckStorageWidget(_visualDeckStorageWidget), filePath(_filePath) + : QWidget(parent), showFolders(_showFolders), folderPath(_folderPath), + visualDeckStorageWidget(_visualDeckStorageWidget) { layout = new QVBoxLayout(this); setLayout(layout); @@ -22,6 +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); @@ -35,192 +45,285 @@ 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().paths().getDeckPath(); - if (filePath != deckPath) { - QString relativePath = filePath; - - if (filePath.startsWith(deckPath)) { - relativePath = filePath.mid(deckPath.length()); // Remove the deckPath prefix - if (relativePath.startsWith('/')) { - relativePath.remove(0, 1); // Remove leading '/' if it exists - } - } - - bannerText = relativePath; + if (deckPassActive) { + // The active pass may be scanning stale model state, so restart it from a clean + // slate once the current chunk yields. + deckPassRestartRequested = true; + return; } - header->setText(bannerText); + reconcileTimer->start(); } /** - * Gets all files in the directory that have an accepted decklist file extension - * - * @param filePath The directory to search through - * @param recursive Whether to search through subdirectories + * @brief Starts a new chunked scan of the source model, yielding to the event loop between chunks. */ -static QStringList getAllFiles(const QString &filePath, bool recursive) +void VisualDeckStorageFolderDisplayWidget::reconcile() { - QStringList allFiles; - - // QDirIterator with QDir::Files ensures only files are listed (no directories) - auto flags = - recursive ? QDirIterator::Subdirectories | QDirIterator::FollowSymlinks : QDirIterator::NoIteratorFlags; - QDirIterator it(filePath, DeckLoader::ACCEPTED_FILE_EXTENSIONS, QDir::Files, flags); - - while (it.hasNext()) { - allFiles << it.next(); // Add each file path to the list - } - - return allFiles; + beginDeckPass(); } -void VisualDeckStorageFolderDisplayWidget::createWidgetsForFiles() +void VisualDeckStorageFolderDisplayWidget::beginDeckPass() { - QList allDecks; - for (const QString &file : getAllFiles(filePath, !showFolders)) { - auto *display = new DeckPreviewWidget(flowWidget, visualDeckStorageWidget, file); + deckPassActive = true; + deckPassRestartRequested = false; + deckPassRow = 0; + visibleDeckCount = 0; + deckPassPresentPaths.clear(); - connect(display, &DeckPreviewWidget::deckLoadRequested, visualDeckStorageWidget, - &VisualDeckStorageWidget::deckLoadRequested); - connect(display, &DeckPreviewWidget::openDeckEditor, visualDeckStorageWidget, - &VisualDeckStorageWidget::openDeckEditor); - connect(visualDeckStorageWidget->settings(), &VisualDeckStorageQuickSettingsWidget::cardSizeChanged, - display->bannerCardDisplayWidget, &CardInfoPictureWidget::setScaleFactor); - display->bannerCardDisplayWidget->setScaleFactor(visualDeckStorageWidget->settings()->getCardSize()); - allDecks.append(display); + continueDeckPass(); +} + +void VisualDeckStorageFolderDisplayWidget::continueDeckPass() +{ + if (!deckPassActive) { + return; } - flowWidget->clearLayout(); // Clear existing widgets in the flow layout + QElapsedTimer passTimer; + passTimer.start(); - for (DeckPreviewWidget *deck : allDecks) { - flowWidget->addWidget(deck); + auto *proxy = visualDeckStorageWidget->proxyModel(); + const int proxyRowCount = proxy->rowCount(); + + // Scan rows of this folder, creating missing previews, until the time budget for this + // event loop turn runs out. The rest continues on the next turn. + while (deckPassRow < proxyRowCount) { + const int row = deckPassRow++; + const QModelIndex index = proxy->index(row, 0); + if (showFolders && index.data(VisualDeckStorageRoles::FolderPathRole).toString() != folderPath) { + continue; + } + const QString filePath = index.data(VisualDeckStorageRoles::FilePathRole).toString(); + deckPassPresentPaths.insert(filePath); + + DeckPreviewWidget *deckPreviewWidget = deckWidgets.value(filePath, nullptr); + if (!deckPreviewWidget) { + deckPreviewWidget = createDeckPreviewWidget(filePath); + flowWidget->addWidget(deckPreviewWidget); + } + + const bool matches = index.data(VisualDeckStorageRoles::FilterMatchRole).toBool(); + if (matches == deckPreviewWidget->isHidden()) { + 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 cce3ff6ce..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,6 +1,7 @@ #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 diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp index 07ee55105..baa5e5792 100644 --- a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp +++ b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.cpp @@ -1,23 +1,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 +#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); @@ -40,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().paths().getDeckPath(); - if (filePath.startsWith(deckPath)) { - return filePath.mid(deckPath.length()); - } - - QFileInfo fileInfo(filePath); - QString fileName = fileInfo.fileName(); - return fileName; -} - -void VisualDeckStorageSearchWidget::filterWidgets(QList widgets, const QString &searchText) -{ - auto filterString = DeckFilterString(searchText); - - for (auto widget : widgets) { - QString relativeFilePath = toRelativeFilepath(widget->filePath); - widget->filteredBySearch = !filterString.check(widget, {relativeFilePath}); - } + connect(searchDebounceTimer, &QTimer::timeout, this, [this] { emit searchTextChanged(searchBar->text()); }); } diff --git a/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.h b/cockatrice/src/interface/widgets/visual_deck_storage/visual_deck_storage_search_widget.h index 2f3d81aeb..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 e4eedb078..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,20 +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); @@ -30,12 +21,10 @@ VisualDeckStorageSortWidget::VisualDeckStorageSortWidget(VisualDeckStorageWidget // Set the current sort order sortComboBox->setCurrentIndex(SettingsCache::instance().visualDeckStorage().getVisualDeckStorageSortingOrder()); - sortOrder = static_cast(sortComboBox->currentIndex()); - // Connect sorting change signal to refresh the file list + // Connect sorting change signal to persist the order and refresh the file list connect(sortComboBox, QOverload::of(&QComboBox::currentIndexChanged), this, &VisualDeckStorageSortWidget::updateSortOrder); - connect(this, &VisualDeckStorageSortWidget::sortOrderChanged, parent, &VisualDeckStorageWidget::updateSortOrder); } void VisualDeckStorageSortWidget::retranslateUi() @@ -47,10 +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); @@ -59,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().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 4b4dee55b..acb0dcab2 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 @@ -2,24 +2,29 @@ #include "../../../client/settings/cache_settings.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); @@ -75,6 +80,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); @@ -123,6 +155,8 @@ void VisualDeckStorageWidget::retranslateUi() refreshButton->setToolTip(tr("Refresh loaded files")); quickSettingsWidget->setToolTip(tr("Visual Deck Storage Settings")); + + sortWidget->retranslateUi(); } /** @@ -134,72 +168,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().paths().getDeckPath(), - false, quickSettingsWidget->getShowFolders()); + storageModel->setDeckPath(SettingsCache::instance().paths().getDeckPath()); + + folderWidget = + new VisualDeckStorageFolderDisplayWidget(this, this, QString(), false, quickSettingsWidget->getShowFolders()); scrollArea->setWidget(folderWidget); // this automatically destroys the old folderWidget scrollArea->widget()->setMaximumWidth(scrollArea->viewport()->width()); scrollArea->widget()->adjustSize(); - /* We have to schedule a QTimer here so that the sorting logic doesn't try to access widgets that haven't been - * processed by the event loop yet. Otherwise, deck sorting will intermittently segfault on some systems. - */ - QTimer::singleShot(0, this, &VisualDeckStorageWidget::reapplySortAndFilters); + // Sort and filter runs against the model data, so it is safe to apply immediately. + reapplySortAndFilters(); } void VisualDeckStorageWidget::updateShowFolders(bool enabled) { if (folderWidget) { folderWidget->updateShowFolders(enabled); - QTimer::singleShot(0, this, &VisualDeckStorageWidget::reapplySortAndFilters); } } void VisualDeckStorageWidget::updateSortOrder() { - if (folderWidget) { - sortWidget->sortFolder(folderWidget); - for (VisualDeckStorageFolderDisplayWidget *subFolderWidget : - folderWidget->findChildren()) { - sortWidget->sortFolder(subFolderWidget); - } - } + storageProxyModel->setSortOrder(sortWidget->currentSortOrder()); } void VisualDeckStorageWidget::updateTagFilter() { - if (folderWidget) { - tagFilterWidget->filterDecksBySelectedTags(folderWidget->findChildren()); - folderWidget->updateVisibility(); - } + const QStringList selected = tagFilterWidget->selectedTags(); + const QStringList excluded = tagFilterWidget->excludedTags(); + storageProxyModel->setTagFilter(QSet(selected.cbegin(), selected.cend()), + QSet(excluded.cbegin(), excluded.cend())); + // The visible deck set changed, so the chips are re-gathered from it. + tagFilterWidget->refreshTags(); } +/** + * 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) @@ -215,4 +246,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 44e188760..4567991c8 100644 --- a/cockatrice/src/interface/window_main.cpp +++ b/cockatrice/src/interface/window_main.cpp @@ -19,6 +19,7 @@ ***************************************************************************/ #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" @@ -31,9 +32,17 @@ #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/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 "version_string.h" #include "widgets/dialogs/dlg_connect.h" @@ -49,6 +58,7 @@ #include #include #include +#include #include #include #include @@ -77,6 +87,7 @@ #include #include #include +#include #include #define GITHUB_PAGES_URL "https://cockatrice.github.io" @@ -340,6 +351,7 @@ void MainWindow::retranslateUi() aStatusBar->setText(tr("Show Status Bar")); aViewLog->setText(tr("View &Debug Log")); aOpenSettingsFolder->setText(tr("Open Settings Folder")); + aFirstRunWizard->setText(tr("Re-run Onboarding Wizard...")); aShow->setText(tr("Show/Hide")); @@ -401,6 +413,8 @@ void MainWindow::createActions() connect(aViewLog, &QAction::triggered, this, &MainWindow::actViewLog); aOpenSettingsFolder = new QAction(this); connect(aOpenSettingsFolder, &QAction::triggered, this, &MainWindow::actOpenSettingsFolder); + aFirstRunWizard = new QAction(this); + connect(aFirstRunWizard, &QAction::triggered, this, [this] { runFirstRunWizard(); }); aShow = new QAction(this); connect(aShow, &QAction::triggered, this, &MainWindow::actShow); @@ -479,6 +493,8 @@ void MainWindow::createMenus() helpMenu->addAction(aStatusBar); helpMenu->addAction(aViewLog); helpMenu->addAction(aOpenSettingsFolder); + helpMenu->addSeparator(); + helpMenu->addAction(aFirstRunWizard); } MainWindow::MainWindow(QWidget *parent) @@ -490,6 +506,7 @@ MainWindow::MainWindow(QWidget *parent) pixmapCacheSizeChanged(SettingsCache::instance().cacheStorage().getPixmapCacheSize()); connectionController = new ConnectionController(this, this); + urlParser = new IntentUrlParser(this, this); createActions(); createMenus(); @@ -501,6 +518,7 @@ MainWindow::MainWindow(QWidget *parent) connect(tabSupervisor, &TabSupervisor::setMenu, this, &MainWindow::updateTabMenu); connect(tabSupervisor, &TabSupervisor::localGameEnded, this, &MainWindow::localGameEnded); connect(tabSupervisor, &TabSupervisor::showWindowIfHidden, this, &MainWindow::showWindowIfHidden); + connect(tabSupervisor, &TabSupervisor::cockatriceLinkActivated, this, &MainWindow::handleCockatriceLink); connect(connectionController, &ConnectionController::tabSupervisorStartRequested, tabSupervisor, &TabSupervisor::start); connect(connectionController, &ConnectionController::tabSupervisorStopRequested, tabSupervisor, @@ -525,6 +543,12 @@ MainWindow::MainWindow(QWidget *parent) [this](bool show) { statusBar()->setVisible(show); }); statusBar()->setVisible(SettingsCache::instance().userInterface().getShowStatusBar()); + latencyStatus = new LatencyStatusWidget(this); + statusBar()->addPermanentWidget(latencyStatus); + + connect(connectionController, &ConnectionController::pingStatsUpdated, latencyStatus, + &LatencyStatusWidget::updateData); + connect(&SettingsCache::instance().shortcuts(), &ShortcutsSettings::shortCutChanged, this, &MainWindow::refreshShortcuts); refreshShortcuts(); @@ -540,6 +564,7 @@ MainWindow::MainWindow(QWidget *parent) // run startup check async QTimer::singleShot(0, this, &MainWindow::startupConfigCheck); + QTimer::singleShot(0, this, &MainWindow::applyStartupDestination); } void MainWindow::startupConfigCheck() @@ -566,9 +591,10 @@ void MainWindow::startupConfigCheck() // no config found, 99% new clean install qCInfo(WindowMainStartupVersionLog) << "Startup: old client version empty, assuming first start after clean install"; - alertForcedOracleRun(VERSION_STRING, false); SettingsCache::instance().downloads().resetToDefaultURLs(); // populate the download urls SettingsCache::instance().network().setClientVersion(VERSION_STRING); + actCheckServerUpdates(); + runFirstRunWizard(); if (QString(VERSION_STRING).contains("custom", Qt::CaseInsensitive)) { SettingsCache::instance().updates().setCheckUpdatesOnStartup(false); @@ -648,6 +674,97 @@ void MainWindow::startupConfigCheck() } } +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(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) { @@ -750,7 +867,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()), @@ -776,6 +894,11 @@ void MainWindow::showWindowIfHidden() show(); } +void MainWindow::handleCockatriceLink(const QString &url) +{ + urlParser->handle(url); +} + void MainWindow::cardDatabaseLoadingFailed() { if (askedForDbUpdater) { @@ -927,6 +1050,7 @@ 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; } @@ -940,6 +1064,9 @@ void MainWindow::createCardUpdateProcess(bool background) void MainWindow::exitCardDatabaseUpdate() { + if (!cardUpdateProcess) { + return; + } cardUpdateProcess->deleteLater(); cardUpdateProcess = nullptr; statusBar()->clearMessage(); @@ -977,14 +1104,17 @@ 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) { + const bool success = (exitStatus == QProcess::NormalExit) && (exitCode == 0); if (exitStatus == QProcess::NormalExit) { SettingsCache::instance().updates().setLastCardUpdateCheck(QDateTime::currentDateTime().date()); } exitCardDatabaseUpdate(); + emit cardDatabaseUpdateFinished(success); } void MainWindow::actCheckServerUpdates() diff --git a/cockatrice/src/interface/window_main.h b/cockatrice/src/interface/window_main.h index fa6c79915..920145552 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,24 @@ 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); + public slots: void actCheckCardUpdates(); void actCheckCardUpdatesBackground(); @@ -83,6 +92,7 @@ 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); @@ -105,6 +115,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; @@ -114,6 +129,9 @@ private: void createTrayIcon(); int getNextCustomSetPrefix(QDir dataDir); + + void runFirstRunWizard(); + inline QString getCardUpdaterBinaryName() { return "oracle"; @@ -129,13 +147,16 @@ 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; DlgViewLog *logviewDialog; diff --git a/cockatrice/src/main.cpp b/cockatrice/src/main.cpp index 814da9808..84d5d175f 100644 --- a/cockatrice/src/main.cpp +++ b/cockatrice/src/main.cpp @@ -390,6 +390,7 @@ int main(int argc, char *argv[]) PingPixmapGenerator::clear(); CountryPixmapGenerator::clear(); UserLevelPixmapGenerator::clear(); + ManaSymbolPixmapGenerator::clear(); return ret; } 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/format.sh b/format.sh index 83dee9e28..3fa435be1 100755 --- a/format.sh +++ b/format.sh @@ -11,6 +11,7 @@ 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" \ @@ -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 7d3d47eea..081e6fd05 100644 --- a/libcockatrice_card/CMakeLists.txt +++ b/libcockatrice_card/CMakeLists.txt @@ -5,6 +5,7 @@ set(CMAKE_AUTORCC ON) set(HEADERS libcockatrice/card/card_info.h libcockatrice/card/card_info_comparator.h + libcockatrice/card/lazy_properties_hash.h libcockatrice/card/database/card_database.h libcockatrice/card/database/card_database_loader.h libcockatrice/card/database/card_database_manager.h @@ -26,6 +27,7 @@ add_library( ${MOC_SOURCES} libcockatrice/card/card_info.cpp libcockatrice/card/card_info_comparator.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 diff --git a/libcockatrice_card/libcockatrice/card/card_info.cpp b/libcockatrice_card/libcockatrice/card/card_info.cpp index f03503550..786e17950 100644 --- a/libcockatrice_card/libcockatrice/card/card_info.cpp +++ b/libcockatrice_card/libcockatrice/card/card_info.cpp @@ -5,10 +5,7 @@ #include "relation/card_relation.h" #include "set/card_set.h" -#include #include -#include -#include #include #include #include @@ -21,64 +18,33 @@ class CardInfo; using CardInfoPtr = QSharedPointer; -namespace -{ -QByteArray serializeProperties(const QHash &props) -{ - QByteArray blob; - QDataStream out(&blob, QIODevice::WriteOnly); - out.setVersion(QDataStream::Qt_6_4); - out << props; - return blob; -} -} // namespace - -void CardInfo::ensurePropertiesLoaded() const -{ - QMutexLocker lock(&propertiesMutex); - if (propertiesLoaded) { - return; - } - if (!propertiesBlob.isEmpty()) { - QDataStream in(propertiesBlob); - in.setVersion(QDataStream::Qt_6_4); - in >> propertiesCache; - } - propertiesLoaded = true; -} - const QHash &CardInfo::getPropertiesHash() const { - ensurePropertiesLoaded(); - return propertiesCache; + return properties.getProperties(); } void CardInfo::setProperty(const QString &_name, const QString &_value) { - ensurePropertiesLoaded(); - if (propertiesCache.value(_name) == _value) { + bool changed = properties.insert(_name, _value); + if (!changed) { return; } - propertiesCache.insert(_name, _value); - propertiesBlob = serializeProperties(propertiesCache); + emit cardInfoChanged(smartThis); } CardInfo::CardInfo(const QString &_name, const QString &_text, bool _isToken, - QHash _properties, + const QHash &_properties, const QList &_relatedCards, const QList &_reverseRelatedCards, SetToPrintingsMap _sets, const UiAttributes _uiAttributes) - : name(_name), text(_text), isToken(_isToken), relatedCards(_relatedCards), - reverseRelatedCards(_reverseRelatedCards), setsToPrintings(std::move(_sets)), uiAttributes(_uiAttributes) + : name(_name), text(_text), isToken(_isToken), properties(LazyPropertiesHash(_properties)), + relatedCards(_relatedCards), reverseRelatedCards(_reverseRelatedCards), setsToPrintings(std::move(_sets)), + uiAttributes(_uiAttributes) { - propertiesCache = std::move(_properties); - propertiesBlob = serializeProperties(propertiesCache); - propertiesLoaded = true; - simpleName = CardInfo::simplifyName(name); refreshCachedSets(); @@ -87,7 +53,7 @@ CardInfo::CardInfo(const QString &_name, CardInfo::CardInfo(const QString &_name, const QString &_text, bool _isToken, - QByteArray _propertiesBlob, + const QByteArray &_propertiesBlob, const QList &_relatedCards, const QList &_reverseRelatedCards, SetToPrintingsMap _sets, @@ -95,7 +61,7 @@ CardInfo::CardInfo(const QString &_name, QString _simpleName, QSet _altNames) : name(_name), simpleName(std::move(_simpleName)), text(_text), isToken(_isToken), - propertiesBlob(std::move(_propertiesBlob)), relatedCards(_relatedCards), + properties(LazyPropertiesHash(_propertiesBlob)), relatedCards(_relatedCards), reverseRelatedCards(_reverseRelatedCards), setsToPrintings(std::move(_sets)), uiAttributes(_uiAttributes), altNames(std::move(_altNames)) { @@ -113,14 +79,14 @@ CardInfoPtr CardInfo::newInstance(const QString &_name) CardInfoPtr CardInfo::newInstance(const QString &_name, const QString &_text, bool _isToken, - QHash _properties, + const QHash &_properties, const QList &_relatedCards, const QList &_reverseRelatedCards, SetToPrintingsMap _sets, const UiAttributes _uiAttributes) { - 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)); ptr->setSmartPointer(ptr); for (const auto &printings : _sets) { @@ -203,15 +169,13 @@ void CardInfo::addToSet(const CardSetPtr &_set, const PrintingInfo &_info) void CardInfo::combineLegalities(const QHash &props) { - ensurePropertiesLoaded(); QHashIterator it(props); while (it.hasNext()) { it.next(); if (it.key().startsWith("format-")) { - propertiesCache.insert(it.key(), it.value()); + properties.insert(it.key(), it.value()); } } - propertiesBlob = serializeProperties(propertiesCache); emit cardInfoChanged(smartThis); } diff --git a/libcockatrice_card/libcockatrice/card/card_info.h b/libcockatrice_card/libcockatrice/card/card_info.h index a5c208893..392dc3849 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 @@ -75,19 +76,8 @@ private: QString simpleName; ///< Simplified name for fuzzy matching. QString text; ///< Text description or rules text of the card. bool isToken; ///< Whether this card is a token or not. - // Properties are stored as a pre-serialized blob (cheap to load) and the - // QHash is materialized on first query, so database load avoids - // constructing thousands of QStrings per card. - mutable QByteArray propertiesBlob; ///< Serialized properties (load form). - mutable QHash propertiesCache; ///< Materialized properties (query form). - mutable bool propertiesLoaded = false; ///< Whether propertiesCache is valid. - mutable QMutex propertiesMutex; ///< Guards lazy materialization. - /** - * @brief Materializes propertiesCache from propertiesBlob if not already done. - * Safe to call from const getters (members are mutable). - */ - void ensurePropertiesLoaded() const; + 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. @@ -114,7 +104,7 @@ public: explicit CardInfo(const QString &_name, const QString &_text, bool _isToken, - QHash _properties, + const QHash &_properties, const QList &_relatedCards, const QList &_reverseRelatedCards, SetToPrintingsMap _sets, @@ -144,7 +134,7 @@ public: explicit CardInfo(const QString &_name, const QString &_text, bool _isToken, - QByteArray _propertiesBlob, + const QByteArray &_propertiesBlob, const QList &_relatedCards, const QList &_reverseRelatedCards, SetToPrintingsMap _sets, @@ -161,7 +151,7 @@ public: */ CardInfo(const CardInfo &other) : QObject(other.parent()), name(other.name), simpleName(other.simpleName), text(other.text), - isToken(other.isToken), propertiesBlob(other.propertiesBlob), relatedCards(other.relatedCards), + isToken(other.isToken), properties(other.properties), relatedCards(other.relatedCards), reverseRelatedCards(other.reverseRelatedCards), reverseRelatedCardsToMe(other.reverseRelatedCardsToMe), setsToPrintings(other.setsToPrintings), uiAttributes(other.uiAttributes), setsNames(other.setsNames), altNames(other.altNames) @@ -194,7 +184,7 @@ public: static CardInfoPtr newInstance(const QString &_name, const QString &_text, bool _isToken, - QHash _properties, + const QHash &_properties, const QList &_relatedCards, const QList &_reverseRelatedCards, SetToPrintingsMap _sets, diff --git a/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp b/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp index 3985889b7..2b27f50f8 100644 --- a/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp +++ b/libcockatrice_card/libcockatrice/card/database/card_database_cache.cpp @@ -119,7 +119,7 @@ PrintingInfo readPrinting(QDataStream &in, const SetNameMap &sets) QByteArray propsBlob = readHashBlob(in); auto set = sets.value(setName); - return PrintingInfo(set, propsBlob); + return PrintingInfo(set, LazyPropertiesHash(propsBlob)); } // ---- CardSet --------------------------------------------------------------- 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/cockatrice_xml_3.cpp b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp index f3aac7809..64202ab21 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_3.cpp @@ -248,7 +248,7 @@ void CockatriceXml3Parser::loadCardsFromXml(QXmlStreamReader &xml) if (attrs.hasAttribute("rarity")) { printingProps.insert("rarity", attrs.value("rarity").toString()); } - PrintingInfo setInfo(set, printingProps); + PrintingInfo setInfo(set, LazyPropertiesHash(printingProps)); _sets[setName].append(setInfo); } // related cards diff --git a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp index 8649bbfaf..ec460d685 100644 --- a/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp +++ b/libcockatrice_card/libcockatrice/card/database/parser/cockatrice_xml_4.cpp @@ -322,7 +322,7 @@ void CockatriceXml4Parser::loadCardsFromXml(QXmlStreamReader &xml) } printingProps.insert(attrName, attr.value().toString()); } - PrintingInfo printingInfo(set, printingProps); + 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 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 49086f8b7..3d185583a 100644 --- a/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp +++ b/libcockatrice_card/libcockatrice/card/printing/printing_info.cpp @@ -5,37 +5,14 @@ #include #include -PrintingInfo::PrintingInfo(const CardSetPtr &_set, const QHash &_properties) - : set(_set), propertiesCache(_properties), propertiesLoaded(true) +PrintingInfo::PrintingInfo(const CardSetPtr &_set, const LazyPropertiesHash &_properties) + : set(_set), properties(_properties) { } -PrintingInfo::PrintingInfo(const CardSetPtr &_set, const QByteArray &_blob) : set(_set), propertiesBlob(_blob) -{ -} - -void PrintingInfo::ensurePropertiesLoaded() const -{ - QMutexLocker lock(propertiesMutex.data()); - if (propertiesLoaded) { - return; - } - propertiesCache.clear(); - if (!propertiesBlob.isEmpty()) { - QDataStream in(propertiesBlob); - in.setVersion(QDataStream::Qt_6_4); - in >> propertiesCache; - } - propertiesLoaded = true; -} - void PrintingInfo::setProperty(const QString &_name, const QString &_value) { - ensurePropertiesLoaded(); - if (propertiesCache.value(_name) == _value) { - return; - } - propertiesCache.insert(_name, _value); + properties.insert(_name, _value); } /** diff --git a/libcockatrice_card/libcockatrice/card/printing/printing_info.h b/libcockatrice_card/libcockatrice/card/printing/printing_info.h index 70093b686..974c7bb95 100644 --- a/libcockatrice_card/libcockatrice/card/printing/printing_info.h +++ b/libcockatrice_card/libcockatrice/card/printing/printing_info.h @@ -2,12 +2,11 @@ #define COCKATRICE_PRINTING_INFO_H #include "../set/card_set.h" +#include "libcockatrice/card/lazy_properties_hash.h" #include #include -#include #include -#include class PrintingInfo; @@ -34,15 +33,7 @@ public: * @param _set The set this printing belongs to (defaults to null). * @param _properties The printing properties (defaults to empty) */ - explicit PrintingInfo(const CardSetPtr &_set = nullptr, const QHash &_properties = {}); - - /** - * @brief Constructs a PrintingInfo associated with a specific set. - * - * @param _set The set this printing belongs to (defaults to null). - * @param _blob The serialized properties (as written by the cache writer). - */ - explicit PrintingInfo(const CardSetPtr &_set, const QByteArray &_blob); + explicit PrintingInfo(const CardSetPtr &_set = nullptr, const LazyPropertiesHash &_properties = {}); /** * @brief Destroys the PrintingInfo. @@ -76,18 +67,8 @@ public: } private: - CardSetPtr set; ///< The set this variation belongs to. - - // Properties are stored as a pre-serialized blob (cheap to load) and the - // QHash is materialized on first query. This avoids constructing - // thousands of QStrings per card at database-load time. - mutable QByteArray propertiesBlob; ///< Serialized properties (load form). - mutable QHash propertiesCache; ///< Materialized properties (query form). - mutable bool propertiesLoaded = false; ///< Whether propertiesCache is valid. - mutable QSharedPointer propertiesMutex = - QSharedPointer::create(); ///< Guards lazy materialization. - - void ensurePropertiesLoaded() const; + CardSetPtr set; ///< The set this variation belongs to. + LazyPropertiesHash properties; ///< Key-value store for variation-specific attributes. public: /** @@ -112,8 +93,7 @@ public: [[nodiscard]] const QHash &getPropertiesHash() const { - ensurePropertiesLoaded(); - return propertiesCache; + return properties.getProperties(); } /** @@ -124,7 +104,7 @@ public: */ [[nodiscard]] QString getProperty(const QString &propertyName) const { - return getPropertiesHash().value(propertyName); + return properties.value(propertyName); } /** @@ -150,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_deck_list/CMakeLists.txt b/libcockatrice_deck_list/CMakeLists.txt index 33494b9a9..c7a54a390 100644 --- a/libcockatrice_deck_list/CMakeLists.txt +++ b/libcockatrice_deck_list/CMakeLists.txt @@ -11,6 +11,7 @@ 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/playmat_resolver.h libcockatrice/deck_list/sideboard_plan.h ) @@ -26,6 +27,7 @@ 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/playmat_resolver.cpp libcockatrice/deck_list/sideboard_plan.cpp ) @@ -33,4 +35,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 d9d2450de..af8628d21 100644 --- a/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp +++ b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list.cpp @@ -17,7 +17,7 @@ static const QString CURRENT_SIDEBOARD_PLAN_KEY = ""; 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(); } DeckList::DeckList() @@ -66,6 +66,36 @@ bool DeckList::readElement(QXmlStreamReader *xml) QString providerId = xml->attributes().value("providerId").toString(); QString cardName = xml->readElementText(); metadata.bannerCard = {cardName, providerId}; + } else if (childName == "playmatCard") { + QString providerId = xml->attributes().value("providerId").toString(); + bool ok; + 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(); + PlaymatInfo playmat; + playmat.card = {cardName, providerId}; + // Clamp 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). + playmat.params.marginPctL = qBound(0.0, marginLStr.toDouble(&ok), 0.95); + if (!ok) { + playmat.params.marginPctL = 0.07; + } + playmat.params.marginPctR = qBound(0.0, marginRStr.toDouble(&ok), 0.95); + if (!ok) { + playmat.params.marginPctR = 0.07; + } + playmat.params.verticalOffset = qBound(0.0, vOffStr.toDouble(&ok), 1.0); + if (!ok) { + playmat.params.verticalOffset = 0.33; + } + playmat.params.zoom = qBound(0.1, zoomStr.toDouble(&ok), 4.0); + if (!ok) { + playmat.params.zoom = 1.0; + } + metadata.playmat = playmat; } else if (childName == "tags") { metadata.tags.clear(); // Clear existing tags while (xml->readNextStartElement()) { @@ -96,6 +126,16 @@ static void writeMetadata(QXmlStreamWriter *xml, const DeckList::Metadata &metad xml->writeAttribute("providerId", metadata.bannerCard.providerId); xml->writeCharacters(metadata.bannerCard.name); xml->writeEndElement(); + if (!metadata.playmat.card.isEmpty()) { + xml->writeStartElement("playmatCard"); + xml->writeAttribute("providerId", metadata.playmat.card.providerId); + xml->writeAttribute("marginPctL", QString::number(metadata.playmat.params.marginPctL, 'f', 4)); + xml->writeAttribute("marginPctR", QString::number(metadata.playmat.params.marginPctR, 'f', 4)); + xml->writeAttribute("verticalOffset", QString::number(metadata.playmat.params.verticalOffset, 'f', 4)); + xml->writeAttribute("zoom", QString::number(metadata.playmat.params.zoom, 'f', 4)); + xml->writeCharacters(metadata.playmat.card.name); + xml->writeEndElement(); + } xml->writeTextElement("comments", metadata.comments); // Write tags @@ -365,6 +405,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..475d99560 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,6 +70,7 @@ 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. @@ -115,6 +116,10 @@ public: { metadata.bannerCard = _bannerCard; } + void setPlaymat(const PlaymatInfo &_playmat = {}) + { + metadata.playmat = _playmat; + } void setLastLoadedTimestamp(const QString &_lastLoadedTimestamp = QString()) { metadata.lastLoadedTimestamp = _lastLoadedTimestamp; @@ -170,6 +175,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_node_tree.cpp b/libcockatrice_deck_list/libcockatrice/deck_list/deck_list_node_tree.cpp index 196416cde..21f628f9d 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 @@ -41,13 +41,19 @@ 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); + std::function collectCards = [&collectCards, + &result](const InnerDecklistNode *node) { + 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))) { + collectCards(inner); } } + }; + + for (auto *zoneNode : getZoneNodes(restrictToZones)) { + collectCards(zoneNode); } return result; @@ -139,7 +145,8 @@ bool DecklistNodeTree::deleteNode(AbstractDecklistNode *node, InnerDecklistNode if (index != -1) { delete rootNode->takeAt(index); - if (rootNode->empty()) { + // Empty custom zones are kept while empty board zones get pruned. + if (rootNode->empty() && rootNode->getParent() == root) { deleteNode(rootNode, rootNode->getParent()); } @@ -160,28 +167,179 @@ bool DecklistNodeTree::deleteNode(AbstractDecklistNode *node, InnerDecklistNode void DecklistNodeTree::forEachCard(const std::function &func) const { - // Support for this is only possible if the internal structure - // doesn't get more complicated. + // Cards nested in custom zones are reported with their top-level board zone + // so that callers can classify cards by board (main/side/maybeboard/tokens). + std::function walk = [&func, &walk](InnerDecklistNode *boardZone, + InnerDecklistNode *node) { + 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))) { + walk(boardZone, inner); + } + } + }; + 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))) { + walk(zone, zone); } } } /** * 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 { 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..1012d5919 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 @@ -78,15 +78,57 @@ 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 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 *findBoardZone(const QString &boardZoneName) const; + InnerDecklistNode *findOrCreateBoardZone(const QString &boardZoneName); + InnerDecklistNode *findCustomZoneByName(const QString &zoneName) const; + bool hasZoneName(const QString &zoneName) const; }; #endif // COCKATRICE_DECKLIST_NODE_TREE_H 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/tree/abstract_deck_list_node.h b/libcockatrice_deck_list/libcockatrice/deck_list/tree/abstract_deck_list_node.h index a39f0e7b2..c5cb25d8f 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.). 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..1f470695d 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; } 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..f8fdedf30 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 @@ -24,6 +24,8 @@ #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 diff --git a/libcockatrice_interfaces/CMakeLists.txt b/libcockatrice_interfaces/CMakeLists.txt index f606f6207..f53c41807 100644 --- a/libcockatrice_interfaces/CMakeLists.txt +++ b/libcockatrice_interfaces/CMakeLists.txt @@ -30,4 +30,4 @@ 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_cards_display_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h index 3ee3d2aef..3f2cbbe8e 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_cards_display_settings_provider.h @@ -15,6 +15,7 @@ public: [[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; diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_interface_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_interface_settings_provider.h index ab2caa0d7..bc2118cb3 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_interface_settings_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_interface_settings_provider.h @@ -1,7 +1,40 @@ #ifndef COCKATRICE_INTERFACE_INTERFACE_SETTINGS_PROVIDER_H #define COCKATRICE_INTERFACE_INTERFACE_SETTINGS_PROVIDER_H +#include #include +#include +#include + +/** + * @brief Whether playmats are rendered in-game, and for whom. + */ +enum PlaymatVisibility +{ + PlaymatVisibilityNone = 0, ///< Don't use playmats. + PlaymatVisibilityOwnOnly = 1, ///< Show the local player's playmat only. + PlaymatVisibilityAll = 2 ///< Show playmats for all players. +}; + +/** + * @brief How the user-level playmat collection interacts with the deck-configured playmat. + */ +enum PlaymatMode +{ + PlaymatModeOverrideDeck = 0, ///< Always use the collection, ignoring any deck-configured playmat. + PlaymatModeFallback = 1, ///< Prefer the deck-configured playmat; fall back to the collection when absent. + PlaymatModeDeckOnly = 2 ///< Use only the deck-configured playmat, ignoring the collection. +}; + +/** + * @brief How the user-level fallback playmat list is consulted when a deck has no playmat configured. + */ +enum PlaymatFallbackMode +{ + PlaymatFallbackModeFixed = 0, ///< Always use the first entry of the fallback list. + PlaymatFallbackModeRoundRobin = 1, ///< Cycle through the list, advancing one entry per resolution. + PlaymatFallbackModeRandom = 2 ///< Pick a random entry per resolution. +}; class IInterfaceSettingsProvider { @@ -39,6 +72,24 @@ public: [[nodiscard]] virtual bool getShowStatusBar() const = 0; [[nodiscard]] virtual bool getShowShortcuts() const = 0; [[nodiscard]] virtual bool getShowGameSelectorFilterToolbar() const = 0; + [[nodiscard]] virtual bool getLifeCounterAnimationsEnabled() const = 0; + [[nodiscard]] virtual bool getBattlefieldFlashEnabled() const = 0; + [[nodiscard]] virtual QStringList getUserListExpandedSections() const = 0; + + /** @brief Who gets playmats rendered: @ref PlaymatVisibility. */ + [[nodiscard]] virtual int getPlaymatVisibility() const = 0; + + /** @brief User-level playmat collection. Used either as a forced playmat + * (mode == @ref PlaymatModeOverrideDeck) or as a fallback when a deck has none + * (mode == @ref PlaymatModeFallback). */ + [[nodiscard]] virtual QList getPlaymatFallbackList() const = 0; + + /** @brief How the fallback list is applied: @ref PlaymatMode. */ + [[nodiscard]] virtual int getPlaymatMode() const = 0; + + /** @brief How the fallback list is picked from when mode is @ref PlaymatModeFallback: + * @ref PlaymatFallbackMode. */ + [[nodiscard]] virtual int getPlaymatFallbackBehavior() const = 0; }; #endif // COCKATRICE_INTERFACE_INTERFACE_SETTINGS_PROVIDER_H diff --git a/libcockatrice_interfaces/libcockatrice/interfaces/interface_tabs_settings_provider.h b/libcockatrice_interfaces/libcockatrice/interfaces/interface_tabs_settings_provider.h index 4403de569..a81616cb0 100644 --- a/libcockatrice_interfaces/libcockatrice/interfaces/interface_tabs_settings_provider.h +++ b/libcockatrice_interfaces/libcockatrice/interfaces/interface_tabs_settings_provider.h @@ -1,11 +1,17 @@ #ifndef COCKATRICE_INTERFACE_TABS_SETTINGS_PROVIDER_H #define COCKATRICE_INTERFACE_TABS_SETTINGS_PROVIDER_H +#include + class ITabsSettingsProvider { public: virtual ~ITabsSettingsProvider() = default; + [[nodiscard]] virtual int getStartupTabIndex() const = 0; + [[nodiscard]] virtual QString getStartupServerHost() const = 0; + [[nodiscard]] virtual QString getStartupServerPort() const = 0; + [[nodiscard]] virtual QString getStartupRoomName() const = 0; [[nodiscard]] virtual bool getTabVisualDeckStorageOpen() const = 0; [[nodiscard]] virtual bool getTabServerOpen() const = 0; [[nodiscard]] virtual bool getTabAccountOpen() const = 0; @@ -13,6 +19,8 @@ public: [[nodiscard]] virtual bool getTabReplaysOpen() const = 0; [[nodiscard]] virtual bool getTabAdminOpen() const = 0; [[nodiscard]] virtual bool getTabLogOpen() const = 0; + [[nodiscard]] virtual bool getTabReportOpen() const = 0; + [[nodiscard]] virtual bool getTabModerationOpen() const = 0; }; #endif // COCKATRICE_INTERFACE_TABS_SETTINGS_PROVIDER_H diff --git a/libcockatrice_models/libcockatrice/models/database/card/card_completer_proxy_model.cpp b/libcockatrice_models/libcockatrice/models/database/card/card_completer_proxy_model.cpp index 387eb454f..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 7b248a982..ecd26a9f8 100644 --- a/libcockatrice_models/libcockatrice/models/database/card_database_model.cpp +++ b/libcockatrice_models/libcockatrice/models/database/card_database_model.cpp @@ -111,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) diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt index c4a8e4648..6fba8d629 100644 --- a/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt +++ b/libcockatrice_network/libcockatrice/network/client/abstract/CMakeLists.txt @@ -2,9 +2,9 @@ set(CMAKE_AUTOMOC ON) set(CMAKE_AUTOUIC ON) set(CMAKE_AUTORCC ON) -set(HEADERS abstract_client.h) +set(HEADERS abstract_client.h latency_tracker.h) -set(SOURCES abstract_client.cpp) +set(SOURCES abstract_client.cpp latency_tracker.cpp) qt6_wrap_cpp(MOC_SOURCES ${HEADERS}) diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.cpp index 916f4351b..d6316deb3 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; diff --git a/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h b/libcockatrice_network/libcockatrice/network/client/abstract/abstract_client.h index 982aa6bf3..1ef9a31e4 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; 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/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/server/remote/CMakeLists.txt b/libcockatrice_network/libcockatrice/network/server/remote/CMakeLists.txt index 9fb63c221..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 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 5bf27eebb..957a89792 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 @@ -1631,7 +1631,9 @@ void Server_AbstractPlayer::getInfo(ServerInfo_Player *info, { getProperties(*info->mutable_properties(), withUserInfo); - if (deck) { + // Deck lists are only shared with other players when the game is in Open Decklists mode, + // so a player joining an open lobby can see every deck that was loaded before they joined. + if (deck && (recipient == this || game->getShareDecklistsOnLoad())) { info->set_deck_list(deck->writeToString_Native().toStdString()); } @@ -1651,5 +1653,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_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..43209e994 100644 --- a/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp +++ b/libcockatrice_network/libcockatrice/network/server/remote/game/server_game.cpp @@ -53,34 +53,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); @@ -343,6 +331,11 @@ void Server_Game::doStartGameIfReady(bool forceStartGame) } 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(); } @@ -400,10 +393,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 +414,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; @@ -526,10 +531,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 +590,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 +846,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 +906,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..1b9f651bd 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 { @@ -221,6 +218,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..0ded27afa 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(); 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 c3686ddfa..899df6529 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; @@ -542,6 +564,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 +586,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 +616,7 @@ Response::ResponseCode Server_ProtocolHandler::cmdLogin(const Command_Login &cmd joinPersistentGames(rc); databaseInterface->removeForgotPassword(userName); + onLogin(rc); rc.setResponseExtension(re); return Response::RespOk; } @@ -894,10 +924,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_protocol/libcockatrice/protocol/pb/CMakeLists.txt b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt index 20a4cb08d..3a193ae3c 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/CMakeLists.txt @@ -35,10 +35,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 +56,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 @@ -137,8 +148,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 +176,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..f8b34b3f8 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; } @@ -37,3 +38,10 @@ message Command_AdjustMod { optional bool should_be_mod = 2; optional bool should_be_judge = 3; } + +message Command_ResetUserPassword { + extend AdminCommand { + optional Command_ResetUserPassword ext = 1016; + } + optional string user_name = 1; +} 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/event_notify_user.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/event_notify_user.proto index 3a90d278b..b722dfc71 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/event_notify_user.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/event_notify_user.proto @@ -9,6 +9,8 @@ message Event_NotifyUser { WARNING = 2; IDLEWARNING = 3; CUSTOM = 4; + REPORT_RESOLVED = 5; + REPORT_COMMENT = 6; } extend SessionEvent { diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto index 8ecf7ec9c..2e5b88978 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/game_commands.proto @@ -175,6 +175,11 @@ message GameCommand { /// Server: Server_Player::cmdReverseTurn /// Client: reflected via subsequent turn events REVERSE_TURN = 1034; + + /// Set the player's playmat independently of the deck. + /// Server: Server_Player::cmdSetPlaymat + /// Client: reflected via player properties changed event + SET_PLAYMAT = 1035; } extensions 100 to max; diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto index ca46e4dd7..685408830 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/moderator_commands.proto @@ -14,6 +14,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; } @@ -135,3 +146,31 @@ message Command_ListCardArtRules { optional Command_ListCardArtRules ext = 1012; } } + +message Command_GetUserSessions { + extend ModeratorCommand { + optional Command_GetUserSessions ext = 1013; + } + optional string user_name = 1; + optional uint32 limit = 2 [default = 110]; +} + +message Command_GetUserAlts { + extend ModeratorCommand { + optional Command_GetUserAlts ext = 1014; + } + optional string user_name = 1; +} + +message Command_GetModeratorLastLogins { + extend ModeratorCommand { + optional Command_GetModeratorLastLogins ext = 1015; + } +} + +message Command_RemoveUserAvatar { + extend ModeratorCommand { + optional Command_RemoveUserAvatar ext = 1017; + } + optional string user_name = 1; +} diff --git a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto index e1f415ce6..14ba737b5 100644 --- a/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto +++ b/libcockatrice_protocol/libcockatrice/protocol/pb/response.proto @@ -53,6 +53,7 @@ message Response { RespClientUpdateRequired = 35; // Client is missing features that the server is requiring RespServerFull = 36; // Server user limit reached RespEmailBlackListed = 37; // Server has blocked the email address provided for registration for some reason + RespPasswordChangeRequired = 38; // Server requires the user to change their password before proceeding } // Type of response, used to route handling on the client 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_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_settings/libcockatrice/settings/appearance_settings.cpp b/libcockatrice_settings/libcockatrice/settings/appearance_settings.cpp index 45a02299e..2f19d6224 100644 --- a/libcockatrice_settings/libcockatrice/settings/appearance_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/appearance_settings.cpp @@ -69,3 +69,14 @@ void AppearanceSettings::setHomeTabDisplayCardName(bool _displayCardName) setValue(_displayCardName, "homeTabDisplayCardName"); emit homeTabDisplayCardNameChanged(); } + +int AppearanceSettings::getHomeTabButtonColorSourceIndex() const +{ + return getValue("homeTabButtonColorSource", "", "", 0).toInt(); +} + +void AppearanceSettings::setHomeTabButtonColorSourceIndex(int index) +{ + setValue(index, "homeTabButtonColorSource"); + emit homeTabButtonColorChanged(); +} diff --git a/libcockatrice_settings/libcockatrice/settings/appearance_settings.h b/libcockatrice_settings/libcockatrice/settings/appearance_settings.h index d9b326bee..3a63f0df0 100644 --- a/libcockatrice_settings/libcockatrice/settings/appearance_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/appearance_settings.h @@ -27,6 +27,8 @@ public: void setHomeTabBackgroundShuffleFrequency(int _frequency); [[nodiscard]] bool getHomeTabDisplayCardName() const; void setHomeTabDisplayCardName(bool _displayCardName); + [[nodiscard]] int getHomeTabButtonColorSourceIndex() const; + void setHomeTabButtonColorSourceIndex(int index); signals: void themeNameChanged(); @@ -34,6 +36,7 @@ signals: void homeTabBackgroundSourceChanged(); void homeTabBackgroundShuffleFrequencyChanged(); void homeTabDisplayCardNameChanged(); + void homeTabButtonColorChanged(); public: explicit AppearanceSettings(const QString &settingPath, QObject *parent = nullptr); diff --git a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp index 6ec1af962..f528a7c4b 100644 --- a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.cpp @@ -50,6 +50,11 @@ bool CardsDisplaySettings::getTapAnimation() const return getValue("tapAnimation", QString(), QString(), true).toBool(); } +bool CardsDisplaySettings::getArrowDrawAnimation() const +{ + return getValue("arrowDrawAnimation", QString(), QString(), true).toBool(); +} + bool CardsDisplaySettings::getAutoRotateSidewaysLayoutCards() const { return getValue("autoRotateSidewaysLayoutCards", QString(), QString(), true).toBool(); @@ -159,6 +164,11 @@ void CardsDisplaySettings::setTapAnimation(bool _tapAnimation) setValue(_tapAnimation, "tapAnimation"); } +void CardsDisplaySettings::setArrowDrawAnimation(bool _arrowDrawAnimation) +{ + setValue(_arrowDrawAnimation, "arrowDrawAnimation"); +} + void CardsDisplaySettings::setAutoRotateSidewaysLayoutCards(bool _autoRotateSidewaysLayoutCards) { setValue(_autoRotateSidewaysLayoutCards, "autoRotateSidewaysLayoutCards"); diff --git a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h index 15a3e3ff4..dbafa32ae 100644 --- a/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/cards_display_settings.h @@ -20,6 +20,7 @@ public: [[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; @@ -40,6 +41,7 @@ public: 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); diff --git a/libcockatrice_settings/libcockatrice/settings/deck_editor_settings.cpp b/libcockatrice_settings/libcockatrice/settings/deck_editor_settings.cpp index 65296a450..44cdcd86f 100644 --- a/libcockatrice_settings/libcockatrice/settings/deck_editor_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/deck_editor_settings.cpp @@ -25,6 +25,11 @@ int DeckEditorSettings::getDefaultDeckEditorType() const return getValue("defaultDeckEditorType", QString(), QString(), 1).toInt(); } +int DeckEditorSettings::getVdeStartupTab() const +{ + return getValue("vdeStartupTab", QString(), QString(), VdeStartupTabContext).toInt(); +} + void DeckEditorSettings::setOpenDeckInNewTab(bool _openDeckInNewTab) { setValue(_openDeckInNewTab, "openDeckInNewTab"); @@ -47,6 +52,12 @@ void DeckEditorSettings::setDefaultDeckEditorType(int _defaultDeckEditorType) setValue(_defaultDeckEditorType, "defaultDeckEditorType"); } +void DeckEditorSettings::setVdeStartupTab(int _vdeStartupTab) +{ + setValue(_vdeStartupTab, "vdeStartupTab"); + emit vdeStartupTabChanged(_vdeStartupTab); +} + int DeckEditorSettings::getCommanderSpellbookIntegrationEnabled() const { return getValue("commanderspellbookintegrationenabled", QString(), QString(), diff --git a/libcockatrice_settings/libcockatrice/settings/deck_editor_settings.h b/libcockatrice_settings/libcockatrice/settings/deck_editor_settings.h index 70f91be9b..0c87a270a 100644 --- a/libcockatrice_settings/libcockatrice/settings/deck_editor_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/deck_editor_settings.h @@ -13,6 +13,13 @@ enum commanderSpellbookIntegrationEnabledIndex commanderSpellbookIntegrationEnabledIndexUnprompted, }; +enum VdeStartupTab +{ + VdeStartupTabContext, ///< Match the opened deck: new decks show the database display, existing decks the deck view + VdeStartupTabDeckDisplay, ///< Always open the Visual Deck View + VdeStartupTabDatabaseDisplay, ///< Always open the Visual Database Display +}; + class DeckEditorSettings : public SettingsManager, public IDeckEditorSettingsProvider { Q_OBJECT @@ -23,6 +30,7 @@ public: [[nodiscard]] bool getBannerCardComboBoxVisible() const override; [[nodiscard]] bool getTagsWidgetVisible() const override; [[nodiscard]] int getDefaultDeckEditorType() const override; + [[nodiscard]] int getVdeStartupTab() const; [[nodiscard]] int getCommanderSpellbookIntegrationEnabled() const; [[nodiscard]] bool getCommanderSpellbookIntegrationUseOfficialBracketNames() const; @@ -30,6 +38,7 @@ public: void setBannerCardComboBoxVisible(bool _bannerCardComboBoxVisible); void setTagsWidgetVisible(bool _tagsWidgetVisible); void setDefaultDeckEditorType(int _defaultDeckEditorType); + void setVdeStartupTab(int _vdeStartupTab); void setCommanderSpellbookIntegrationEnabled(int _commanderSpellbookIntegrationEnabled); void setCommanderSpellbookIntegrationUseOfficialBracketNames(bool _useOfficialBracketNames); @@ -38,6 +47,7 @@ signals: void tagsWidgetVisibleChanged(bool visible); void commanderSpellbookIntegrationEnabledChanged(int enabled); void commanderSpellbookIntegrationUseOfficialBracketNamesChanged(bool useOfficialBracketNames); + void vdeStartupTabChanged(int vdeStartupTab); public: explicit DeckEditorSettings(const QString &settingPath, QObject *parent = nullptr); diff --git a/libcockatrice_settings/libcockatrice/settings/game_filters_settings.cpp b/libcockatrice_settings/libcockatrice/settings/game_filters_settings.cpp index ad972b433..0edba44dd 100644 --- a/libcockatrice_settings/libcockatrice/settings/game_filters_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/game_filters_settings.cpp @@ -104,14 +104,14 @@ QString GameFiltersSettings::getGameNameFilter() const return getValue("gameNameFilter").toString(); } -void GameFiltersSettings::setCreatorNameFilters(QStringList creatorName) +void GameFiltersSettings::setHostNameFilters(QStringList hostName) { - setValue(creatorName, "creatorNameFilter"); + setValue(hostName, "hostNameFilter"); } -QStringList GameFiltersSettings::getCreatorNameFilters() const +QStringList GameFiltersSettings::getHostNameFilters() const { - return getValue("creatorNameFilter").toStringList(); + return getValue("hostNameFilter").toStringList(); } void GameFiltersSettings::setMinPlayers(int min) diff --git a/libcockatrice_settings/libcockatrice/settings/game_filters_settings.h b/libcockatrice_settings/libcockatrice/settings/game_filters_settings.h index 24f582007..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/interface_settings.cpp b/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp index 29c57c57e..b92a5fdec 100644 --- a/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/interface_settings.cpp @@ -1,5 +1,35 @@ #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) { @@ -160,6 +190,51 @@ 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"); @@ -326,3 +401,60 @@ void InterfaceSettings::setShowGameSelectorFilterToolbar(bool _showGameSelectorF 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 index 982976310..6b226f307 100644 --- a/libcockatrice_settings/libcockatrice/settings/interface_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/interface_settings.h @@ -42,6 +42,13 @@ public: [[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); @@ -74,6 +81,13 @@ public: 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); @@ -85,6 +99,10 @@ signals: void tallyTypeChanged(int type); void showStatusBarChanged(bool state); void showGameSelectorFilterToolbarChanged(bool state); + void playmatVisibilityChanged(int visibility); + void playmatSettingsChanged(); + void lifeCounterAnimationsEnabledChanged(bool state); + void battlefieldFlashEnabledChanged(bool state); public: explicit InterfaceSettings(const QString &settingPath, QObject *parent = nullptr); diff --git a/libcockatrice_settings/libcockatrice/settings/tabs_settings.cpp b/libcockatrice_settings/libcockatrice/settings/tabs_settings.cpp index 1838f667e..85a1424a6 100644 --- a/libcockatrice_settings/libcockatrice/settings/tabs_settings.cpp +++ b/libcockatrice_settings/libcockatrice/settings/tabs_settings.cpp @@ -5,6 +5,26 @@ TabsSettings::TabsSettings(const QString &settingPath, QObject *parent) { } +int TabsSettings::getStartupTabIndex() const +{ + return getValue("startupTab", QString(), QString(), StartupTab::StartupTabHome).toInt(); +} + +QString TabsSettings::getStartupServerHost() const +{ + return getValue("startupServerHost", QString(), QString(), QString()).toString(); +} + +QString TabsSettings::getStartupServerPort() const +{ + return getValue("startupServerPort", QString(), QString(), QString()).toString(); +} + +QString TabsSettings::getStartupRoomName() const +{ + return getValue("startupRoomName", QString(), QString(), QString()).toString(); +} + bool TabsSettings::getTabVisualDeckStorageOpen() const { return getValue("visualDeckStorage", QString(), QString(), true).toBool(); @@ -40,6 +60,52 @@ 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(); +} + void TabsSettings::setTabVisualDeckStorageOpen(bool value) { setValue(value, "visualDeckStorage"); @@ -74,3 +140,13 @@ void TabsSettings::setTabLogOpen(bool value) { setValue(value, "log"); } + +void TabsSettings::setTabReportOpen(bool value) +{ + setValue(value, "report"); +} + +void TabsSettings::setTabModerationOpen(bool value) +{ + setValue(value, "moderation"); +} diff --git a/libcockatrice_settings/libcockatrice/settings/tabs_settings.h b/libcockatrice_settings/libcockatrice/settings/tabs_settings.h index c8e952b87..365d91af7 100644 --- a/libcockatrice_settings/libcockatrice/settings/tabs_settings.h +++ b/libcockatrice_settings/libcockatrice/settings/tabs_settings.h @@ -5,12 +5,35 @@ #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; @@ -18,7 +41,13 @@ public: [[nodiscard]] bool getTabReplaysOpen() const override; [[nodiscard]] bool getTabAdminOpen() const override; [[nodiscard]] bool getTabLogOpen() const override; + [[nodiscard]] bool getTabReportOpen() const override; + [[nodiscard]] bool getTabModerationOpen() const override; + void setStartupTabIndex(int value); + void setStartupServerHost(const QString &host); + void setStartupServerPort(const QString &port); + void setStartupRoomName(const QString &roomName); void setTabVisualDeckStorageOpen(bool value); void setTabServerOpen(bool value); void setTabAccountOpen(bool value); @@ -26,6 +55,14 @@ public: void setTabReplaysOpen(bool value); void setTabAdminOpen(bool value); void setTabLogOpen(bool value); + void setTabReportOpen(bool value); + void setTabModerationOpen(bool value); + +signals: + void startupTabIndexChanged(int index); + void startupServerHostChanged(const QString &host); + void startupServerPortChanged(const QString &port); + void startupRoomNameChanged(const QString &roomName); public: explicit TabsSettings(const QString &settingPath, QObject *parent = nullptr); diff --git a/libcockatrice_utility/CMakeLists.txt b/libcockatrice_utility/CMakeLists.txt index 79f5a11e4..c6411ea76 100644 --- a/libcockatrice_utility/CMakeLists.txt +++ b/libcockatrice_utility/CMakeLists.txt @@ -5,16 +5,19 @@ set(CMAKE_AUTOMOC ON) set(CMAKE_AUTOUIC ON) set(CMAKE_AUTORCC ON) -set(UTILITY_SOURCES libcockatrice/utility/expression.cpp libcockatrice/utility/levenshtein.cpp - libcockatrice/utility/passwordhasher.cpp libcockatrice/utility/server_rate_limiter.cpp +set(UTILITY_SOURCES + libcockatrice/utility/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/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 @@ -22,6 +25,7 @@ set(UTILITY_HEADERS 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}) 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/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/src/oracleimporter.cpp b/oracle/src/oracleimporter.cpp index a9008c4da..fdb32bb8d 100644 --- a/oracle/src/oracleimporter.cpp +++ b/oracle/src/oracleimporter.cpp @@ -237,8 +237,11 @@ 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"}}; @@ -316,7 +319,7 @@ int OracleImporter::importCardsFromSet(const CardSetPtr ¤tSet, const QList } } - PrintingInfo printingInfo(currentSet, printingProps); + PrintingInfo printingInfo(currentSet, LazyPropertiesHash(printingProps)); QString numComponent; const QString numProperty = printingInfo.getProperty("num"); 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..c1940c22f 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,13 @@ 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 + [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..5dbf69cbc 100644 --- a/servatrice/servatrice.sql +++ b/servatrice/servatrice.sql @@ -20,7 +20,7 @@ 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` ( @@ -41,6 +41,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 +234,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/servatrice.cpp b/servatrice/src/servatrice.cpp index aa50e068a..db8751658 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -514,6 +514,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 +690,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 +719,7 @@ void Servatrice::scheduleShutdown(const QString &reason, int minutes) connect(shutdownTimer, SIGNAL(timeout()), this, SLOT(shutdownTimeout())); shutdownTimer->start(60000); } + shutdownStateMutex.unlock(); shutdownTimeout(); } @@ -702,6 +739,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 +748,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..8b0f5ad60 100644 --- a/servatrice/src/servatrice.h +++ b/servatrice/src/servatrice.h @@ -20,6 +20,7 @@ #ifndef SERVATRICE_H #define SERVATRICE_H +#include #include #include #include @@ -29,6 +30,8 @@ #include #include #include +#include +#include #include #include @@ -158,6 +161,7 @@ private: Servatrice_IslServer *islServer; mutable QMutex loginMessageMutex; QString loginMessage; + mutable QMutex shutdownStateMutex; QString dbPrefix; QMap serverRequiredFeatureList; QString officialWarnings; @@ -172,6 +176,11 @@ private: 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 +225,8 @@ public: QMutexLocker locker(&loginMessageMutex); return loginMessage; } + SessionEvent *getLoginSessionEvent() const override; + SessionEvent *makeShutdownEvent() const; QString getRequiredFeatures() const override; QString getAuthenticationMethodString() const; QString getDBTypeString() const; @@ -280,6 +291,11 @@ public: 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..847be61da 100644 --- a/servatrice/src/servatrice_database_interface.cpp +++ b/servatrice/src/servatrice_database_interface.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include inline Q_LOGGING_CATEGORY(DatabaseInterfaceLog, "database_interface"); @@ -339,8 +340,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 +351,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 +363,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 { @@ -1084,11 +1090,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 +1331,169 @@ 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 & 7) <> 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; + } + loginDetails.set_user_level(userLevel); + + if (!query->value(2).isNull()) { + loginDetails.set_last_login(query->value(2).toLongLong()); + } + results << loginDetails; + } + + return results; +} + +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..cd76ae288 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; @@ -119,6 +122,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 +137,10 @@ public: bool &room, int &range, int &maxresults); + QList getUserSessions(const QString &userName, int limit); + QList getUserAlts(const QString &userName); + QList getModeratorLastLogins(); + 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 842ddb4c8..2a8b5f0a4 100644 --- a/servatrice/src/serversocketinterface.cpp +++ b/servatrice/src/serversocketinterface.cpp @@ -49,10 +49,20 @@ #include #include #include +#include #include #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include @@ -70,20 +80,36 @@ #include #include #include +#include #include #include +#include #include +#include #include #include +#include +#include +#include +#include +#include +#include +#include +#include #include #include #include #include #include #include +#include #include #include +#include +#include +#include #include +#include #include #include #include @@ -223,6 +249,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 +277,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); 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 +301,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 +330,8 @@ 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; } @@ -896,6 +952,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("-"); @@ -1063,9 +1123,10 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetWarnList(const Comma Response_WarnList *re = new Response_WarnList; QString officialWarnings = settingsCache->value("server/officialwarnings").toString(); - QStringList warningsList = officialWarnings.split(",", Qt::SkipEmptyParts); - for (const QString &warning : warningsList) { - re->add_warning(warning.toStdString()); + const QList categories = parseWarningCategories(officialWarnings); + for (const WarningCategory &category : categories) { + re->add_warning(category.name.toStdString()); + re->add_warning_il(category.startingIl); } re->set_user_name(nameFromStdString(cmd.user_name()).toStdString()); re->set_user_clientid(nameFromStdString(cmd.user_clientid()).toStdString()); @@ -1249,6 +1310,1010 @@ 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::cmdReportStats(const Command_ReportStats & /*cmd */, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + if (const auto cached = servatrice->getCachedReportStats()) { + rc.setResponseExtension(new Response_ReportStats(*cached)); + return Response::RespOk; + } + + Response_ReportStats *re = new Response_ReportStats; + + QSqlQuery *overviewQuery = sqlInterface->prepareQuery( + "SELECT COUNT(*) AS total, " + "SUM(status IN ('open', 'assigned')) AS pending, " + "SUM(status = 'assigned') AS assigned, " + "SUM(status IN ('resolved', 'dismissed')) AS resolved, " + "SUM(created_at >= DATE_SUB(NOW(), INTERVAL 1 DAY)) AS last24h, " + "SUM(created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)) AS last7d, " + "SUM(created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)) AS last30d, " + "SUM(created_at >= DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY)) AS this_week " + "FROM {prefix}_reports"); + if (!sqlInterface->execSqlQuery(overviewQuery) || !overviewQuery->next()) { + delete re; + return Response::RespInternalError; + } + + re->set_total_reports(overviewQuery->value(0).toInt()); + re->set_total_pending(overviewQuery->value(1).toInt()); + re->set_total_assigned(overviewQuery->value(2).toInt()); + re->set_total_resolved(overviewQuery->value(3).toInt()); + re->set_reports_last_24h(overviewQuery->value(4).toInt()); + re->set_reports_last_7d(overviewQuery->value(5).toInt()); + re->set_reports_last_30d(overviewQuery->value(6).toInt()); + re->set_reports_this_week(overviewQuery->value(7).toInt()); + + bool allQueriesOk = true; + + QSqlQuery *lastWeekQuery = + sqlInterface->prepareQuery("SELECT COUNT(*) FROM {prefix}_reports WHERE created_at >= DATE_SUB(CURDATE(), " + "INTERVAL WEEKDAY(CURDATE()) + 7 DAY) " + "AND created_at < DATE_SUB(CURDATE(), INTERVAL WEEKDAY(CURDATE()) DAY)"); + if (sqlInterface->execSqlQuery(lastWeekQuery) && lastWeekQuery->next()) { + re->set_reports_last_week(lastWeekQuery->value(0).toInt()); + } else { + allQueriesOk = false; + } + + QSqlQuery *avgResQuery = sqlInterface->prepareQuery( + "SELECT AVG(TIMESTAMPDIFF(HOUR, created_at, resolution_time)) " + "FROM {prefix}_reports WHERE status IN ('resolved','dismissed') AND resolution_time IS NOT NULL"); + if (sqlInterface->execSqlQuery(avgResQuery) && avgResQuery->next()) { + if (!avgResQuery->value(0).isNull()) { + re->set_avg_resolution_hours(avgResQuery->value(0).toDouble()); + } + } else { + allQueriesOk = false; + } + + QSqlQuery *catQuery = sqlInterface->prepareQuery( + "SELECT category, COUNT(*) AS cnt FROM {prefix}_reports GROUP BY category ORDER BY cnt DESC LIMIT 10"); + if (sqlInterface->execSqlQuery(catQuery)) { + while (catQuery->next()) { + ReportCategoryCount *cc = re->add_category_counts(); + cc->set_category(catQuery->value(0).toString().toStdString()); + cc->set_count(catQuery->value(1).toInt()); + } + } else { + allQueriesOk = false; + } + + QSqlQuery *topReportedQuery = + sqlInterface->prepareQuery("SELECT reported_user_name, COUNT(*) AS cnt FROM {prefix}_reports " + "GROUP BY reported_user_name ORDER BY cnt DESC LIMIT 10"); + if (sqlInterface->execSqlQuery(topReportedQuery)) { + while (topReportedQuery->next()) { + ReportTopUser *tu = re->add_top_reported_users(); + tu->set_user_name(topReportedQuery->value(0).toString().toStdString()); + tu->set_count(topReportedQuery->value(1).toInt()); + } + } else { + allQueriesOk = false; + } + + QSqlQuery *topReporterQuery = + sqlInterface->prepareQuery("SELECT reporter_name, COUNT(*) AS cnt FROM {prefix}_reports " + "GROUP BY reporter_name ORDER BY cnt DESC LIMIT 10"); + if (sqlInterface->execSqlQuery(topReporterQuery)) { + while (topReporterQuery->next()) { + ReportTopUser *tu = re->add_top_reporters(); + tu->set_user_name(topReporterQuery->value(0).toString().toStdString()); + tu->set_count(topReporterQuery->value(1).toInt()); + } + } else { + allQueriesOk = false; + } + + if (allQueriesOk) { + servatrice->cacheReportStats(*re); + } + + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdGetUserSessions(const Command_GetUserSessions &cmd, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const QString userName = nameFromStdString(cmd.user_name()); + if (userName.isEmpty()) { + return Response::RespContextError; + } + + Response_UserSessions *re = new Response_UserSessions; + const int limit = qMin(static_cast(cmd.limit()), 500); + const QList sessions = sqlInterface->getUserSessions(userName, limit); + for (const ServerInfo_UserSession &session : sessions) { + re->add_sessions()->CopyFrom(session); + } + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdGetUserAlts(const Command_GetUserAlts &cmd, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const QString userName = nameFromStdString(cmd.user_name()); + if (userName.isEmpty()) { + return Response::RespContextError; + } + + Response_UserAlts *re = new Response_UserAlts; + const QList alts = sqlInterface->getUserAlts(userName); + for (const ServerInfo_UserAlt &alt : alts) { + re->add_alts()->CopyFrom(alt); + } + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdGetModeratorLastLogins(const Command_GetModeratorLastLogins &, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + Response_ModeratorLastLogins *re = new Response_ModeratorLastLogins; + const QList logins = sqlInterface->getModeratorLastLogins(); + for (const ServerInfo_ModeratorLogin &login : logins) { + re->add_logins()->CopyFrom(login); + } + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdResetUserPassword(const Command_ResetUserPassword &cmd, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const QString userName = nameFromStdString(cmd.user_name()).simplified(); + if (userName.isEmpty()) { + return Response::RespContextError; + } + + // Look up the target user's privilege level to prevent escalation. + QSqlQuery *privQuery = sqlInterface->prepareQuery("SELECT admin FROM {prefix}_users WHERE name = :name"); + privQuery->bindValue(":name", userName); + if (!sqlInterface->execSqlQuery(privQuery) || !privQuery->next()) { + return Response::RespNameNotFound; + } + const int targetAdmin = privQuery->value(0).toInt(); + const bool targetIsAdmin = targetAdmin & 1; + const bool targetIsMod = targetAdmin & 2; + + const bool callerIsAdmin = userInfo->user_level() & ServerInfo_User::IsAdmin; + + // A moderator must not reset the password of an admin or another moderator. + if (!callerIsAdmin && (targetIsAdmin || targetIsMod)) { + return Response::RespAccessDenied; + } + + const QString tempPassword = PasswordHasher::generateRandomSalt(); + if (!sqlInterface->changeUserPassword(userName, tempPassword, true)) { + return Response::RespInternalError; + } + sqlInterface->setForcePasswordChange(userName, true); + + sqlInterface->addAuditRecord(userName, this->getAddress(), QString::fromStdString(userInfo->clientid()), + "PASSWORD_RESET", "Admin password reset", true); + + // Notify the affected user if they are currently online. + QReadLocker clientsLocker(&servatrice->clientsLock); + AbstractServerSocketInterface *targetSession = + static_cast(server->getUsers().value(userName)); + if (targetSession) { + Event_NotifyUser event; + event.set_type(Event_NotifyUser::CUSTOM); + event.set_custom_title(tr("Password Reset").toStdString()); + event.set_custom_content(tr("An administrator has reset your password. Please log in with the new " + "password provided to you and change it immediately.") + .toStdString()); + SessionEvent *se = targetSession->prepareSessionEvent(event); + targetSession->sendProtocolItem(*se); + delete se; + } + + Response_ResetUserPassword *re = new Response_ResetUserPassword; + re->set_user_name(userName.toStdString()); + re->set_temporary_password(tempPassword.toStdString()); + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdRemoveUserAvatar(const Command_RemoveUserAvatar &cmd, + ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const QString userName = nameFromStdString(cmd.user_name()).simplified(); + if (userName.isEmpty()) { + return Response::RespContextError; + } + + if (!sqlInterface->removeUserAvatar(userName)) { + return Response::RespInternalError; + } + + sqlInterface->addAuditRecord(userName, this->getAddress(), QString::fromStdString(userInfo->clientid()), + "REMOVE_USER_AVATAR", "Moderator removed user avatar", true); + + Response_RemoveUserAvatar *re = new Response_RemoveUserAvatar; + re->set_user_name(userName.toStdString()); + rc.setResponseExtension(re); + return Response::RespOk; +} + +void AbstractServerSocketInterface::sendPendingReportNotifications(ResponseContainer &rc) +{ + if (!sqlInterface->checkSql()) { + return; + } + + QSqlQuery *query = sqlInterface->prepareQuery("SELECT id, reported_user_name, status, resolution_note " + "FROM {prefix}_reports " + "WHERE reporter_id = :reporter_id AND notified = 0 " + "AND status IN ('resolved', 'dismissed')"); + query->bindValue(":reporter_id", userInfo->id()); + + if (!sqlInterface->execSqlQuery(query)) { + return; + } + + while (query->next()) { + const int reportId = query->value(0).toInt(); + const QString reportedUser = query->value(1).toString(); + const bool dismissed = query->value(2).toString() == "dismissed"; + const QString note = query->value(3).toString(); + + Event_NotifyUser event; + event.set_type(Event_NotifyUser::REPORT_RESOLVED); + event.set_custom_title(dismissed ? tr("Report Dismissed").toStdString() : tr("Report Resolved").toStdString()); + + QString content = dismissed ? tr("Your report about %1 has been dismissed.").arg(reportedUser) + : tr("Your report about %1 has been resolved.").arg(reportedUser); + if (!note.isEmpty()) { + content += "\n" + tr("Note: %1").arg(note); + } + event.set_custom_content(content.toStdString()); + + rc.enqueuePreResponseItem(ServerMessage::SESSION_EVENT, prepareSessionEvent(event)); + + QSqlQuery *notifiedQuery = + sqlInterface->prepareQuery("UPDATE {prefix}_reports SET notified = 1 WHERE id = :id"); + notifiedQuery->bindValue(":id", reportId); + sqlInterface->execSqlQuery(notifiedQuery); + } + + QSqlQuery *commentQuery = sqlInterface->prepareQuery("SELECT c.id, c.report_id, c.author_name, c.comment_text " + "FROM {prefix}_report_comments c " + "JOIN {prefix}_reports r ON r.id = c.report_id " + "WHERE c.notified = 0 " + "AND c.author_id != :user_id " + "AND ((c.is_moderator = 1 AND r.reporter_id = :user_id) " + "OR (c.is_moderator = 0 AND r.assigned_to = :user_id) " + "OR (c.is_moderator = 1 AND r.assigned_to = :user_id)) " + "ORDER BY c.created_at ASC"); + commentQuery->bindValue(":user_id", userInfo->id()); + + if (!sqlInterface->execSqlQuery(commentQuery)) { + return; + } + + while (commentQuery->next()) { + const qlonglong commentId = commentQuery->value(0).toLongLong(); + const int reportId = commentQuery->value(1).toInt(); + const QString authorName = commentQuery->value(2).toString(); + const QString commentText = commentQuery->value(3).toString(); + + Event_NotifyUser event; + event.set_type(Event_NotifyUser::REPORT_COMMENT); + event.set_custom_title(tr("New Comment on Report #%1").arg(reportId).toStdString()); + event.set_custom_content(tr("%1 commented:\n%2").arg(authorName, commentText).toStdString()); + + rc.enqueuePreResponseItem(ServerMessage::SESSION_EVENT, prepareSessionEvent(event)); + + QSqlQuery *notifiedQuery = + sqlInterface->prepareQuery("UPDATE {prefix}_report_comments SET notified = 1 WHERE id = :id"); + notifiedQuery->bindValue(":id", commentId); + sqlInterface->execSqlQuery(notifiedQuery); + } +} + +void AbstractServerSocketInterface::onLogin(ResponseContainer &rc) +{ + sendPendingReportNotifications(rc); +} + +Response::ResponseCode AbstractServerSocketInterface::cmdReportMyList(const Command_ReportMyList & /*cmd */, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + sendPendingReportNotifications(rc); + + QString queryStr = "SELECT r.id, r.reporter_name, r.reported_user_name, r.game_id, " + "r.category, r.description, r.created_at, r.status, " + "r.resolution_note, u.name AS assigned_mod_name, r.room_id, " + "(SELECT id FROM {prefix}_replays WHERE id_game = r.game_id ORDER BY id DESC LIMIT 1) " + "AS replay_id " + "FROM {prefix}_reports r " + "LEFT JOIN {prefix}_users u ON r.assigned_to = u.id " + "WHERE r.reporter_id = :reporter_id " + "ORDER BY r.created_at DESC LIMIT 200"; + + QSqlQuery *query = sqlInterface->prepareQuery(queryStr); + query->bindValue(":reporter_id", userInfo->id()); + + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespInternalError; + } + + Response_ReportMyList *re = new Response_ReportMyList; + + while (query->next()) { + ServerInfo_Report *info = re->add_reports(); + + info->set_report_id(query->value(0).toInt()); + info->set_reporter_name(query->value(1).toString().toStdString()); + info->set_reported_user_name(query->value(2).toString().toStdString()); + + if (!query->value(3).isNull()) { + info->set_game_id(query->value(3).toInt()); + } + + info->set_category(query->value(4).toString().toStdString()); + info->set_description(query->value(5).toString().toStdString()); + info->set_report_time(query->value(6).toDateTime().toSecsSinceEpoch()); + info->set_status(query->value(7).toString().toStdString()); + + if (!query->value(8).isNull()) { + info->set_resolution_note(query->value(8).toString().toStdString()); + } + + if (!query->value(9).isNull()) { + info->set_assigned_mod_name(query->value(9).toString().toStdString()); + } + + if (!query->value(10).isNull()) { + info->set_room_id(query->value(10).toInt()); + } + + if (!query->value(11).isNull()) { + info->set_replay_id(query->value(11).toInt()); + } + } + + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdReportDetails(const Command_ReportDetails &cmd, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + const int reportId = cmd.report_id(); + + // Columns: 0=id, 1=reporter_id, 2=reporter_name, 3=reported_user_name, 4=game_id, + // 5=category, 6=description, 7=created_at, 8=status, + // 9=resolution_note, 10=assigned_mod_name, 11=chat_log, + // 12=room_id, 13=resolution_time, 14=replay_id + QString queryStr = "SELECT r.id, r.reporter_id, r.reporter_name, r.reported_user_name, r.game_id, " + "r.category, r.description, r.created_at, r.status, " + "r.resolution_note, u.name AS assigned_mod_name, r.chat_log, r.room_id, " + "r.resolution_time, " + "(SELECT id FROM {prefix}_replays WHERE id_game = r.game_id ORDER BY id DESC LIMIT 1) " + "AS replay_id " + "FROM {prefix}_reports r " + "LEFT JOIN {prefix}_users u ON r.assigned_to = u.id " + "WHERE r.id = :id"; + QSqlQuery *query = sqlInterface->prepareQuery(queryStr); + query->bindValue(":id", reportId); + + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespInternalError; + } + + if (!query->next()) { + return Response::RespNameNotFound; + } + + const bool isMod = userInfo->user_level() & ServerInfo_User::IsModerator; + const int reporterId = query->value(1).toInt(); + if (reporterId != userInfo->id() && !isMod) { + return Response::RespAccessDenied; + } + + ServerInfo_Report *info = new ServerInfo_Report; + info->set_report_id(query->value(0).toInt()); + info->set_reporter_name(query->value(2).toString().toStdString()); + info->set_reported_user_name(query->value(3).toString().toStdString()); + + if (!query->value(4).isNull()) { + info->set_game_id(query->value(4).toInt()); + } + + info->set_category(query->value(5).toString().toStdString()); + info->set_description(query->value(6).toString().toStdString()); + info->set_report_time(query->value(7).toDateTime().toSecsSinceEpoch()); + info->set_status(query->value(8).toString().toStdString()); + + if (!query->value(9).isNull()) { + info->set_resolution_note(query->value(9).toString().toStdString()); + } + + if (!query->value(10).isNull()) { + info->set_assigned_mod_name(query->value(10).toString().toStdString()); + } + + if (!query->value(11).isNull()) { + info->set_chat_log(query->value(11).toString().toStdString()); + } + + if (!query->value(12).isNull()) { + info->set_room_id(query->value(12).toInt()); + } + + if (!query->value(13).isNull()) { + info->set_resolution_time(query->value(13).toDateTime().toSecsSinceEpoch()); + } + + if (!query->value(14).isNull()) { + info->set_replay_id(query->value(14).toInt()); + } + + QSqlQuery *commentQuery = + sqlInterface->prepareQuery("SELECT c.author_name, c.comment_text, c.created_at, c.is_moderator " + "FROM {prefix}_report_comments c " + "WHERE c.report_id = :report_id ORDER BY c.created_at ASC"); + commentQuery->bindValue(":report_id", reportId); + + if (sqlInterface->execSqlQuery(commentQuery)) { + while (commentQuery->next()) { + ServerInfo_ReportComment *comment = info->add_comments(); + comment->set_author_name(commentQuery->value(0).toString().toStdString()); + comment->set_comment_text(commentQuery->value(1).toString().toStdString()); + comment->set_comment_time(commentQuery->value(2).toDateTime().toSecsSinceEpoch()); + comment->set_is_moderator(commentQuery->value(3).toBool()); + } + } + + Response_ReportDetails *re = new Response_ReportDetails; + re->set_allocated_report(info); + rc.setResponseExtension(re); + return Response::RespOk; +} + +Response::ResponseCode AbstractServerSocketInterface::cmdReportAddComment(const Command_ReportAddComment &cmd, + ResponseContainer & /*rc*/) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + int reportId = cmd.report_id(); + QString commentText = textFromStdString(cmd.comment()).trimmed(); + + if (commentText.isEmpty()) { + return Response::RespInvalidData; + } + + const int maxCommentsPerHour = settingsCache->value("reporting/max_comments_per_hour", 30).toInt(); + if (maxCommentsPerHour > 0) { + QSqlQuery *countQuery = + sqlInterface->prepareQuery("SELECT COUNT(*) FROM {prefix}_report_comments WHERE author_id = :id " + "AND created_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR)"); + countQuery->bindValue(":id", userInfo->id()); + if (sqlInterface->execSqlQuery(countQuery) && countQuery->next() && + countQuery->value(0).toInt() >= maxCommentsPerHour) { + return Response::RespTooManyRequests; + } + } + + QSqlQuery *checkQuery = sqlInterface->prepareQuery( + "SELECT reporter_id, reporter_name, assigned_to, status FROM {prefix}_reports WHERE id = :id"); + checkQuery->bindValue(":id", reportId); + + if (!sqlInterface->execSqlQuery(checkQuery)) { + return Response::RespInternalError; + } + + if (!checkQuery->next()) { + return Response::RespNameNotFound; + } + + int reporterId = checkQuery->value(0).toInt(); + QString reporterName = checkQuery->value(1).toString(); + int assignedToId = checkQuery->value(2).toInt(); + + bool isMod = userInfo->user_level() & ServerInfo_User::IsModerator; + + // Only the reporter (by id) or a moderator may comment on a report. + if (reporterId != userInfo->id() && !isMod) { + return Response::RespAccessDenied; + } + + QString reportStatus = checkQuery->value(3).toString(); + if (reportStatus == "resolved" || reportStatus == "dismissed") { + return Response::RespInvalidData; + } + + QSqlQuery *insertQuery = sqlInterface->prepareQuery( + "INSERT INTO {prefix}_report_comments (report_id, author_name, author_id, comment_text, created_at, " + "is_moderator) " + "VALUES (:report_id, :author_name, :author_id, :comment_text, NOW(), :is_moderator)"); + insertQuery->bindValue(":report_id", reportId); + insertQuery->bindValue(":author_name", QString::fromStdString(userInfo->name())); + insertQuery->bindValue(":author_id", userInfo->id()); + insertQuery->bindValue(":comment_text", commentText); + insertQuery->bindValue(":is_moderator", isMod); + + if (!sqlInterface->execSqlQuery(insertQuery)) { + return Response::RespInternalError; + } + + const qlonglong commentId = insertQuery->lastInsertId().toLongLong(); + + QStringList recipients; + if (isMod) { + // A moderator comment reaches both the reporter and (if assigned) the assigned moderator. + recipients.append(reporterName); + if (assignedToId > 0) { + QSqlQuery *modNameQuery = sqlInterface->prepareQuery("SELECT name FROM {prefix}_users WHERE id = :id"); + modNameQuery->bindValue(":id", assignedToId); + if (sqlInterface->execSqlQuery(modNameQuery) && modNameQuery->next()) { + recipients.append(modNameQuery->value(0).toString()); + } + } + } else if (assignedToId > 0) { + QSqlQuery *modNameQuery = sqlInterface->prepareQuery("SELECT name FROM {prefix}_users WHERE id = :id"); + modNameQuery->bindValue(":id", assignedToId); + if (sqlInterface->execSqlQuery(modNameQuery) && modNameQuery->next()) { + recipients.append(modNameQuery->value(0).toString()); + } + } + + const QString ownName = QString::fromStdString(userInfo->name()); + bool allNotified = !recipients.isEmpty(); + QReadLocker clientsLocker(&servatrice->clientsLock); + for (const QString ¬ifyName : recipients) { + if (notifyName == ownName) { + continue; + } + + AbstractServerSocketInterface *notify = + static_cast(server->getUsers().value(notifyName)); + if (!notify) { + // The recipient is offline; leave `notified` = 0 so the notification is + // delivered on their next login via sendPendingReportNotifications. + allNotified = false; + continue; + } + + Event_NotifyUser event; + event.set_type(Event_NotifyUser::REPORT_COMMENT); + event.set_custom_title(tr("New Comment on Report #%1").arg(reportId).toStdString()); + event.set_custom_content( + tr("%1 commented:\n%2").arg(QString::fromStdString(userInfo->name()), commentText).toStdString()); + + SessionEvent *se = notify->prepareSessionEvent(event); + notify->sendProtocolItem(*se); + delete se; + } + + if (allNotified) { + QSqlQuery *notifiedQuery = + sqlInterface->prepareQuery("UPDATE {prefix}_report_comments SET notified = 1 WHERE id = :id"); + notifiedQuery->bindValue(":id", commentId); + sqlInterface->execSqlQuery(notifiedQuery); + } + + return Response::RespOk; +} + +Response::ResponseCode +AbstractServerSocketInterface::cmdReplayDownloadByGameId(const Command_ReplayDownloadByGameId &cmd, + ResponseContainer &rc) +{ + if (authState != PasswordRight) { + return Response::RespFunctionNotAllowed; + } + + if (!sqlInterface->checkSql()) { + return Response::RespInternalError; + } + + QSqlQuery *query = sqlInterface->prepareQuery("SELECT r.id, r.replay FROM {prefix}_replays r " + "WHERE r.id_game = :game_id ORDER BY r.id DESC LIMIT 1"); + query->bindValue(":game_id", cmd.game_id()); + + if (!sqlInterface->execSqlQuery(query)) { + return Response::RespInternalError; + } + + if (!query->next()) { + return Response::RespNameNotFound; + } + + int replayId = query->value(0).toInt(); + QByteArray data = query->value(1).toByteArray(); + + Response_ReplayDownloadByGameId *re = new Response_ReplayDownloadByGameId; + re->set_replay_data(data.data(), data.size()); + re->set_replay_id(replayId); + rc.setResponseExtension(re); + + return Response::RespOk; +} + Response::ResponseCode AbstractServerSocketInterface::cmdRegisterAccount(const Command_Register &cmd, ResponseContainer &rc) { @@ -1781,6 +2846,8 @@ Response::ResponseCode AbstractServerSocketInterface::cmdAccountPassword(const C return Response::RespWrongPassword; } + databaseInterface->setForcePasswordChange(userName, false); + return Response::RespOk; } @@ -1917,6 +2984,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdForgotPasswordReset(con "PASSWORD_RESET", "", true); } + sqlInterface->setForcePasswordChange(nameFromStdString(cmd.user_name()), false); sqlInterface->removeForgotPassword(nameFromStdString(cmd.user_name())); return Response::RespOk; } @@ -1985,6 +3053,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. diff --git a/servatrice/src/serversocketinterface.h b/servatrice/src/serversocketinterface.h index 0d66ae78f..600796b5f 100644 --- a/servatrice/src/serversocketinterface.h +++ b/servatrice/src/serversocketinterface.h @@ -24,6 +24,16 @@ #include #include #include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include #include class Servatrice; @@ -50,6 +60,7 @@ class Command_BanFromServer; class Command_UpdateServerMessage; class Command_ShutdownServer; class Command_ReloadConfig; +class Command_ReplayDownloadByGameId; class Command_AccountEdit; class Command_AccountImage; @@ -67,7 +78,7 @@ signals: void incTxBytes(qint64 amount); protected: - void logDebugMessage(const QString &message); + void logDebugMessage(const QString &message) override; bool tooManyRegistrationAttempts(const QString &ipAddress); virtual void writeToSocket(QByteArray &data) = 0; @@ -102,6 +113,7 @@ 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 cmdGetBanHistory(const Command_GetBanHistory &cmd, ResponseContainer &rc); @@ -109,6 +121,10 @@ private: 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 +138,19 @@ 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 cmdAccountEdit(const Command_AccountEdit &cmd, ResponseContainer &rc); Response::ResponseCode cmdAccountImage(const Command_AccountImage &cmd, ResponseContainer &rc); @@ -141,6 +166,12 @@ 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); + bool addAdminFlagToUser(const QString &user, int flag); bool removeAdminFlagFromUser(const QString &user, int flag); @@ -157,9 +188,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/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1c3f4c2c6..a28f671c9 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -7,9 +7,13 @@ 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 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 deck_hash_performance_test COMMAND deck_hash_performance_test) set_tests_properties(deck_hash_performance_test PROPERTIES TIMEOUT 5) @@ -21,10 +25,15 @@ 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(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) find_package(GTest) @@ -56,10 +65,14 @@ 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(warning_categories_test gtest) + add_dependencies(lag_monitor_test gtest) + add_dependencies(latency_tracker_test gtest) endif() include_directories(${GTEST_INCLUDE_DIRS}) @@ -74,6 +87,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} @@ -87,9 +104,17 @@ target_link_libraries( target_link_libraries( server_rate_limiter_test libcockatrice_utility 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} +) add_subdirectory(card_zone_algorithms) add_subdirectory(carddatabase) +add_subdirectory(deck_list_zones) add_subdirectory(loading_from_clipboard) add_subdirectory(movecard_tests) add_subdirectory(oracle) 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..a5148621d --- /dev/null +++ b/tests/deck_list_zones/deck_list_zones_test.cpp @@ -0,0 +1,356 @@ +/** + * @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, 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/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/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/settings/settings_defaults_test.cpp b/tests/settings/settings_defaults_test.cpp index dfdad4780..6c79d5227 100644 --- a/tests/settings/settings_defaults_test.cpp +++ b/tests/settings/settings_defaults_test.cpp @@ -188,6 +188,38 @@ TEST_F(SettingsDefaultsTest, Sound_MasterVolume_SetAndGet) // --- 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); @@ -200,6 +232,12 @@ TEST_F(SettingsDefaultsTest, Tabs_AllTabsOpen_Default) ASSERT_EQ(s.getTabLogOpen(), true); } +TEST_F(SettingsDefaultsTest, Tabs_ModerationOpen_Default) +{ + TabsSettings s(settingsPath, nullptr); + ASSERT_EQ(s.getTabModerationOpen(), false); +} + // --- ChatSettings --- TEST_F(SettingsDefaultsTest, Chat_Mention_Default) @@ -400,6 +438,23 @@ TEST_F(SettingsDefaultsTest, DeckEditor_DefaultDeckEditorType_Default) 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) @@ -476,6 +531,12 @@ TEST_F(SettingsDefaultsTest, CardsDisplay_SampleHandSize_Default) ASSERT_EQ(s.getSampleHandSize(), 7); } +TEST_F(SettingsDefaultsTest, CardsDisplay_ArrowDrawAnimation_Default) +{ + CardsDisplaySettings s(settingsPath, nullptr); + ASSERT_EQ(s.getArrowDrawAnimation(), true); +} + // --- VisualDeckStorageSettings --- TEST_F(SettingsDefaultsTest, VisualDeckStorage_SortingOrder_Default) 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(); +}