Merge branch 'master' into tooomm-qt5

This commit is contained in:
tooomm 2026-08-27 06:33:34 +02:00
commit 3bc08ef94c
357 changed files with 23069 additions and 2916 deletions

View file

@ -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 \

View file

@ -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 \

View file

@ -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 \

View file

@ -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 \

View file

@ -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 \

View file

@ -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 \

View file

@ -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 \

View file

@ -12,9 +12,9 @@ Available pre-compiled binaries for installation:
<kbd>Windows 10+</kbd>
<b>macOS</b>
<kbd>macOS 15+</kbd> <sub><i>Sequoia</i></sub> <sub>Apple M</sub>
<kbd>macOS 14+</kbd> <sub><i>Sonoma</i></sub> <sub>Apple M</sub>
<kbd>macOS 13+</kbd> <sub><i>Ventura</i></sub> <sub>Intel</sub>
<kbd>macOS 15+</kbd> <sub><i>Sequoia</i></sub>
<kbd>macOS 14+</kbd> <sub><i>Sonoma</i></sub>
<kbd>macOS 13+</kbd> <sub><i>Ventura (x86)</i></sub>
<b>Linux</b>
<kbd>Ubuntu 26.04 LTS</kbd> <sub><i>Resolute Racoon</i></sub>
@ -24,10 +24,10 @@ Available pre-compiled binaries for installation:
<kbd>Fedora 44</kbd>
<kbd>Fedora 43</kbd>
<sub>We are also packaged in <kbd>Arch Linux</kbd>'s <a href="https://archlinux.org/packages/extra/x86_64/cockatrice">official extra repository</a>, courtesy of @FFY00.</sub>
<sub>General Linux support is available via a <kbd>flatpak</kbd> package at <a href="https://flathub.org/apps/io.github.Cockatrice.cockatrice">Flathub</a>!</sub>
<sub>General Linux support is available via a <kbd>flatpak</kbd> package hosted at <a href="https://flathub.org/apps/io.github.Cockatrice.cockatrice">Flathub</a> (x86 & ARM)!</sub>
<sub>Thanks to courtesy of @FFY00, the app is also available in <kbd>Arch Linux</kbd>'s <a href="https://archlinux.org/packages/extra/x86_64/cockatrice">official extra repository</a>.</sub>
<sub>We provide a <kbd>Docker</kbd> image for "Servatrice" in <a href="https://github.com/Cockatrice/Cockatrice/pkgs/container/servatrice">GHCR</a>. You can docker pull it or use our Docker Compose files!</sub>
<sub>We maintain a <kbd>Docker</kbd> image for "Servatrice" in <a href="https://github.com/Cockatrice/Cockatrice/pkgs/container/servatrice">GHCR</a> (x86 & ARM). You can docker pull it or use our Docker Compose files!</sub>
</pre>

95
.github/workflows/codeql.yml vendored Normal file
View file

@ -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 }}"

View file

@ -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"

View file

@ -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:

View file

@ -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"

View file

@ -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

View file

@ -1,5 +1,9 @@
name: Update Translations
permissions:
contents: read
pull-requests: write
on:
pull_request:
paths:

View file

@ -1,5 +1,9 @@
name: Update Translation Source
permissions:
contents: read
pull-requests: write
on:
pull_request:
paths:

View file

@ -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/*

View file

@ -18,10 +18,13 @@ if(WITH_CLIENT)
Multimedia
Network
PrintSupport
ShaderTools
Svg
WebSockets
Widgets
Xml
Quick
QuickWidgets
)
endif()
if(WITH_ORACLE)

View file

@ -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

View file

@ -2,6 +2,7 @@
<qresource prefix="/">
<file>resources/cardback.svg</file>
<file>resources/cockatrice.svg</file>
<file>resources/cockatrice-logo-white.svg</file>
<file>resources/hand.svg</file>
<file>resources/hr.jpg</file>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.8 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Before After
Before After

View file

@ -52,8 +52,7 @@
id="defs8">
<polygon
id="s"
points="-301930,415571 0,-513674 301930,415571 -488533,-158734 488533,-158734 "
transform="scale(1.94676e-6,1.94676e-6)"
points="-0.587785,0.809017 0,-1 0.587785,0.809017 -0.951057,-0.309017 0.951057,-0.309017 "
style="fill:#ffde00" />
<clipPath
clipPathUnits="userSpaceOnUse"

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Before After
Before After

View file

@ -16,6 +16,6 @@
<rect id="rect10" height="2.88" width="12.8" y="4.278e-9" x="0"/>
<rect id="rect12" height="2.88" width="12.8" y="3.36" x="0" fill="#da121a"/>
<polygon id="polygon14" points="0 0 8.6603 5 0 10" fill="#0f47af" transform="scale(.96)"/>
<polygon id="polygon16" points="4.1557e5 -3.0193e5 -5.1367e5 0 4.1557e5 3.0193e5 -1.5873e5 -4.8853e5 -1.5873e5 4.8853e5" fill="#fcdd09" transform="matrix(.0000029902 0 0 .0000029902 2.7713 4.8)"/>
<polygon id="polygon16" points="4.01394 3.897169 1.23532 4.8 4.01394 5.702831 2.296666 3.3392 2.296666 6.2608" fill="#fcdd09"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Before After
Before After

View file

@ -0,0 +1,63 @@
#include "lag_monitor.h"
#include <QCoreApplication>
#include <QEvent>
#include <QTimer>
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::StallRecord> 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<long long>(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<long long>(gapMs),
STALL_THRESHOLD_MS);
}

View file

@ -0,0 +1,87 @@
/**
* @file lag_monitor.h
* @ingroup Client
*/
#ifndef LAG_MONITOR_H
#define LAG_MONITOR_H
#include <QDateTime>
#include <QElapsedTimer>
#include <QList>
#include <QLoggingCategory>
#include <QObject>
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<StallRecord> 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<StallRecord> stalls;
};
#endif

View file

@ -0,0 +1,51 @@
/**
* @file latency_graph_widget.cpp
* @ingroup Client
*/
#include "latency_graph_widget.h"
#include <QPainter>
LatencyGraphWidget::LatencyGraphWidget(QWidget *parent) : QWidget(parent)
{
}
void LatencyGraphWidget::setSamples(const QList<int> &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<qint64>(sample));
}
const qreal widthPerBar = static_cast<qreal>(width()) / samples.size();
for (int i = 0; i < samples.size(); ++i) {
const qreal heightRatio = qBound(0.0, static_cast<double>(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<double>(samples.at(i)) / ColorScaleMs, 1.0);
QColor color;
color.setHsv(qRound(120.0 * (1.0 - colorRatio)), 255, 255);
const QRectF bar(static_cast<qreal>(i) * widthPerBar + 1.0, static_cast<qreal>(height()) - barHeight,
qMax(1.0, widthPerBar - 2.0), barHeight);
painter.fillRect(bar, color);
}
}

View file

@ -0,0 +1,43 @@
/**
* @file latency_graph_widget.h
* @ingroup Client
*/
#ifndef LATENCY_GRAPH_WIDGET_H
#define LATENCY_GRAPH_WIDGET_H
#include <QList>
#include <QWidget>
/**
* @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<int> &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<int> samples;
};
#endif

View file

@ -0,0 +1,111 @@
/**
* @file latency_status_widget.cpp
* @ingroup Client
*/
#include "latency_status_widget.h"
#include "latency_graph_widget.h"
#include <QEvent>
#include <QHBoxLayout>
#include <QLabel>
#include <QVBoxLayout>
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<QObject *>{pingLabel, latencyGraph}) {
child->installEventFilter(this);
}
setCursor(Qt::PointingHandCursor);
hide();
}
void LatencyStatusWidget::updateData(const LatencyTracker::Stats &stats, const QList<int> &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);
}

View file

@ -0,0 +1,50 @@
/**
* @file latency_status_widget.h
* @ingroup Client
*/
#ifndef LATENCY_STATUS_WIDGET_H
#define LATENCY_STATUS_WIDGET_H
#include <QList>
#include <QWidget>
#include <libcockatrice/network/client/abstract/latency_tracker.h>
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<int> &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<int> latestSamples;
};
#endif

View file

@ -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."));

View file

@ -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<int> &samplesMs);
private slots:
// Slots wired directly to RemoteClient signals
void onStatusChanged(ClientStatus status);

View file

@ -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());
}

View file

@ -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;
}

View file

@ -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)},
};
};

View file

@ -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<DeckFilter>(query)(deck, info);
};
return [=](const DeckSearchData &data) {
auto matchesFilter = [&data](const std::any &query) { return std::any_cast<DeckFilter>(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<DeckFilter>(query)(deck, info);
};
return [=](const DeckSearchData &data) {
auto matchesFilter = [&data](const std::any &query) { return std::any_cast<DeckFilter>(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<DeckFilter>(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<QString>(sv[0]));
auto numberMatcher = sv.size() > 1 ? std::any_cast<NumberMatcher>(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<QString>(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<QString>(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<QString>(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<QString>(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<QString>(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<QString>(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; };
}
}
}

View file

@ -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 <QLoggingCategory>
#include <QString>
@ -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<bool(const DeckPreviewWidget *, const ExtraDeckSearchInfo &)> DeckFilter;
typedef std::function<bool(const DeckSearchData &data)> 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

View file

@ -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<GameEventContext::ContextType>(getPbExtension(context));
switch (contextType) {
case GameEventContext::READY_START: {

View file

@ -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()),

View file

@ -17,6 +17,7 @@
#include "../zones/table_zone_logic.h"
#include "player_event_handler.h"
#include "player_info.h"
#include "player_manager.h"
#include <QInputDialog>
#include <QLoggingCategory>
@ -72,6 +73,8 @@ signals:
const QList<const ServerInfo_Card *> &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<CardItem *> cardsToDelete;
// Playmat from player properties (for opponent display)
CardRef remotePlaymatCard;
PlaymatParams remotePlaymatParams;
bool hasRemotePlaymat = false;
};
class AnnotationDialog : public QInputDialog

View file

@ -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

View file

@ -305,6 +305,11 @@ void AbstractCardItem::setTapped(bool _tapped, bool canAnimate)
}
}
bool AbstractCardItem::animationEvent()
{
return false;
}
void AbstractCardItem::setFaceDown(bool _facedown)
{
facedown = _facedown;

View file

@ -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;

View file

@ -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).
}

View file

@ -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;

View file

@ -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 <QDebug>
#include <QElapsedTimer>
#include <QGraphicsScene>
#include <QGraphicsSceneMouseEvent>
#include <QPainter>
@ -18,10 +20,27 @@
#include <libcockatrice/protocol/pb/command_attach_card.pb.h>
#include <libcockatrice/protocol/pb/command_create_arrow.pb.h>
#include <libcockatrice/protocol/pb/command_delete_arrow.pb.h>
#include <libcockatrice/settings/cards_display_settings.h>
#include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/utility/color.h>
#include <libcockatrice/utility/zone_names.h>
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<const ArrowData> _data, ArrowTarget *_startItem, ArrowTarget *_targetItem)
: data(std::move(_data)), startItem(_startItem), targetItem(_targetItem)
{
@ -47,8 +66,23 @@ ArrowItem::ArrowItem(QSharedPointer<const ArrowData> _data, ArrowTarget *_startI
}
}
ArrowItem::~ArrowItem()
{
if (auto *scene = qobject_cast<GameScene *>(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<GameScene *>(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<qreal>(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<qreal>(1.0, drawProgress / 0.08);
const QPointF sweep = centerLine.pointAtPercent(qMin<qreal>(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;
}

View file

@ -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 <QElapsedTimer>
#include <QGraphicsItem>
#include <QPainterPath>
#include <QPointer>
#include <QSharedPointer>
@ -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<const ArrowData> data;
@ -32,17 +49,28 @@ protected:
void mousePressEvent(QGraphicsSceneMouseEvent *event) override;
public:
enum
{
Type = typeArrow
};
[[nodiscard]] int type() const override
{
return Type;
}
ArrowItem(QSharedPointer<const ArrowData> _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
#endif

View file

@ -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);

View file

@ -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

View file

@ -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 <QMessageBox>
#include <libcockatrice/card/database/card_database.h>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/playmat_resolver.h>
#include <libcockatrice/protocol/pb/command_deck_select.pb.h>
#include <libcockatrice/protocol/pb/command_ready_start.pb.h>
#include <libcockatrice/protocol/pb/command_set_playmat.pb.h>
#include <libcockatrice/protocol/pb/command_set_sideboard_lock.pb.h>
#include <libcockatrice/protocol/pb/command_set_sideboard_plan.pb.h>
#include <libcockatrice/protocol/pb/response_deck_download.pb.h>
#include <libcockatrice/protocol/pending_command.h>
#include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/settings/visual_deck_storage_settings.h>
#include <libcockatrice/utility/string_limits.h>
@ -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<PlaymatFallbackMode>(settings.getPlaymatFallbackBehavior());
QList<PlaymatInfo> 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<PlaymatMode>(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();
}

View file

@ -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);

View file

@ -17,7 +17,6 @@
#include <QDebug>
#include <QGraphicsSceneMouseEvent>
#include <QGraphicsView>
#include <QSet>
#include <QtMath>
#include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/utility/zone_names.h>
@ -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<qreal> &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<QList<PlayerGraphicsItem *>> 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<ArrowData> 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<CardItem *> i(cardsToAnimate);
QMutableHashIterator<QObject *, IAnimatedItem *> 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<CardItem *>(card));
if (!animationTimer->isActive()) {
auto *object = dynamic_cast<QObject *>(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<CardItem *>(card));
if (cardsToAnimate.isEmpty()) {
animatedItems.remove(dynamic_cast<QObject *>(item));
if (animationTimer && animatedItems.isEmpty()) {
animationTimer->stop();
}
}
void GameScene::removeAnimatedItem(QObject *item)
{
animatedItems.remove(item);
if (animationTimer && animatedItems.isEmpty()) {
animationTimer->stop();
}
}

View file

@ -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 <QGraphicsScene>
#include <QHash>
#include <QList>
#include <QLoggingCategory>
#include <QPointer>
#include <QSet>
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<ZoneViewWidget *> zoneViews; ///< Active zone view widgets
QSize viewSize; ///< Current view size
QPointer<CardItem> hoveredCard; ///< Currently hovered card
QBasicTimer *animationTimer; ///< Timer for card animations
QSet<CardItem *> cardsToAnimate; ///< Cards currently animating
QBasicTimer *animationTimer; ///< Timer for scene animations
QHash<QObject *, IAnimatedItem *> 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);

View file

@ -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();
}

View file

@ -27,6 +27,7 @@ private:
QWidget *tallyContainer;
QGridLayout *tallyLayout;
QPointF selectionOrigin;
QRect previousBandRect; ///< Last rubber-band rect for targeted repaint
QList<TallyRow> cachedTallyRows; ///< Cached entries to avoid redundant rebuilds
QSize rebuildTallyLabels(const QList<TallyRow> &entries);

View file

@ -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 <QGraphicsView>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/deck_list/deck_list.h>
#include <libcockatrice/deck_list/playmat_resolver.h>
#include <libcockatrice/settings/interface_settings.h>
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<PlaymatMode>(settings.getPlaymatMode()),
static_cast<PlaymatFallbackMode>(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();
}

View file

@ -11,6 +11,7 @@
#include "../game_scene.h"
#include <QGraphicsObject>
#include <libcockatrice/deck_list/deck_list.h>
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

View file

@ -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 <QApplication>
#include <QDebug>
#include <QPainter>
#include <QPixmapCache>
@ -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<GameScene *>(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)

View file

@ -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 <QElapsedTimer>
#include <QPixmap>
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

View file

@ -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<CardDragItem *> &dragItems,

View file

@ -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. */

View file

@ -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 <QGraphicsScene>
@ -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<GameScene *>(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.

View file

@ -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 <QElapsedTimer>
/**
* @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<int>(_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);

View file

@ -6,6 +6,7 @@ struct ContextJoinGame
{
ContextJoinRoom roomContext;
int gameId;
bool asSpectator = false;
};
#endif // COCKATRICE_CONTEXT_JOIN_GAME_H

View file

@ -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;
}

View file

@ -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 <libcockatrice/protocol/pb/event_list_rooms.pb.h>
#include <libcockatrice/protocol/pb/response_join_room.pb.h>
#include <libcockatrice/protocol/pb/session_commands.pb.h>
#include <libcockatrice/protocol/pending_command.h>
IntentOpenServerRoomByName::IntentOpenServerRoomByName(TabSupervisor *_tabSupervisor,
RemoteClient *_remoteClient,
std::unique_ptr<ContextJoinRoom> _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;
}
}

View file

@ -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 <QScopedPointer>
#include <QString>
#include <QTimer>
#include <memory>
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<ContextJoinRoom> _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<ContextJoinRoom> context;
QString roomName;
bool listening = false;
bool joinPending = false;
QTimer checkTimer;
QTimer refreshTimer;
};
#endif // COCKATRICE_INTENT_OPEN_SERVER_ROOM_BY_NAME_H

View file

@ -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 <QMessageBox>
#include <QUrl>
#include <QUrlQuery>
#include <libcockatrice/network/client/abstract/abstract_client.h>
#include <memory>
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);
}

View file

@ -4,6 +4,7 @@
#include <QUrlQuery>
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;
};

View file

@ -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();
}

View file

@ -3,6 +3,7 @@
#include <QApplication>
#include <QDomDocument>
#include <QFile>
#include <QImageReader>
#include <QPainter>
#include <QPalette>
#include <QSvgRenderer>
@ -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<qreal>(1.0, static_cast<qreal>(longestRequestedSide * 4) / longestRenderSide);
return QSize(qMax(1, static_cast<int>(renderSize.width() * scale)),
qMax(1, static_cast<int>(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<QString, QPixmap> 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<QString, QPixmap> ManaSymbolPixmapGenerator::masterCache;
QHash<QString, QPixmap> ManaSymbolPixmapGenerator::scaledCache;
QPixmap loadColorAdjustedPixmap(const QString &name)
{
if (qApp->palette().windowText().color().lightness() > 200) {

View file

@ -7,6 +7,7 @@
#ifndef PIXMAPGENERATOR_H
#define PIXMAPGENERATOR_H
#include <QHash>
#include <QIcon>
#include <QLoggingCategory>
#include <QMap>
@ -125,6 +126,34 @@ public:
}
};
class ManaSymbolPixmapGenerator
{
private:
static QHash<QString, QPixmap> masterCache;
static QHash<QString, QPixmap> 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

View file

@ -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

View file

@ -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-<scheme>.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);

View file

@ -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<ManaSymbolWidget *> manaSymbols = findChildren<ManaSymbolWidget *>();
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<ManaSymbolWidget *>(layout->itemAt(i)->widget())) {
w->setFixedSize(iconSize, iconSize);
}
}
}

View file

@ -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

View file

@ -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;

View file

@ -1,15 +1,15 @@
#include "mana_symbol_widget.h"
#include "../../../../client/settings/cache_settings.h"
#include "../../../pixel_map_generator.h"
#include <QResizeEvent>
#include <libcockatrice/settings/visual_deck_storage_settings.h>
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));
}

View file

@ -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;

View file

@ -0,0 +1,61 @@
#include "art_crop_attribution.h"
#include <QFontMetrics>
#include <QObject>
#include <QPainter>
#include <libcockatrice/card/printing/exact_card.h>
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;
}

View file

@ -0,0 +1,41 @@
#ifndef COCKATRICE_ART_CROP_ATTRIBUTION_H
#define COCKATRICE_ART_CROP_ATTRIBUTION_H
#include <QStringView>
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

View file

@ -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

View file

@ -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 <QDockWidget>
#include <QHeaderView>
#include <QLabel>
#include <QPushButton>
#include <QSplitter>
#include <QTextEdit>
#include <libcockatrice/card/database/card_database_manager.h>
#include <libcockatrice/settings/deck_editor_settings.h>
#include <libcockatrice/settings/interface_settings.h>
#include <libcockatrice/utility/macros.h>
#include <libcockatrice/utility/string_limits.h>
@ -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);

View file

@ -15,11 +15,15 @@
#include "deck_list_history_manager_widget.h"
#include "deck_list_style_proxy.h"
#include <QCheckBox>
#include <QComboBox>
#include <QDockWidget>
#include <QLabel>
#include <QPushButton>
#include <QTextEdit>
#include <QTreeView>
#include <libcockatrice/card/card_info.h>
#include <libcockatrice/deck_list/deck_list.h>
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();

View file

@ -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();

View file

@ -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);
///@}

View file

@ -214,6 +214,7 @@ DlgCreateGame::DlgCreateGame(const ServerInfo_Game &gameInfo, const QMap<int, QS
spectatorsNeedPasswordCheckBox->setChecked(gameInfo.spectators_need_password());
spectatorsCanTalkCheckBox->setChecked(gameInfo.spectators_can_chat());
spectatorsSeeEverythingCheckBox->setChecked(gameInfo.spectators_omniscient());
shareDecklistsOnLoadCheckBox->setChecked(gameInfo.share_decklists_on_load());
QSet<int> types;
for (int i = 0; i < gameInfo.game_types_size(); ++i) {

View file

@ -30,7 +30,7 @@ DlgFilterGames::DlgFilterGames(const QMap<int, QString> &_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<int, QString> &_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<int> DlgFilterGames::getGameTypeFilter() const

View file

@ -35,7 +35,7 @@ private:
QCheckBox *hideNotBuddyCreatedGames;
QCheckBox *hideOpenDecklistGames;
QLineEdit *gameNameFilterEdit;
QLineEdit *creatorNameFilterEdit;
QLineEdit *hostNameFilterEdit;
QMap<int, QCheckBox *> gameTypeFilterCheckBoxes;
QSpinBox *maxPlayersFilterMinSpinBox;
QSpinBox *maxPlayersFilterMaxSpinBox;
@ -50,7 +50,7 @@ private:
const GamesProxyModel *gamesProxyModel;
const QMap<QTime, QString> gameAgeMap;
[[nodiscard]] QStringList getCreatorNameFilters() const;
[[nodiscard]] QStringList getHostNameFilters() const;
[[nodiscard]] QSet<int> getGameTypeFilter() const;
[[nodiscard]] QTime getMaxGameAge() const;
[[nodiscard]] bool getShowSpectatorPasswordProtected() const;

View file

@ -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 <QGuiApplication>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QScreen>
#include <QUrl>
#include <QUrlQuery>
#include <QVBoxLayout>
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"));
}

View file

@ -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 <QDialog>
#include <QStringList>
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

View file

@ -0,0 +1,297 @@
#include "dlg_my_reports.h"
#include "../utility/report_utils.h"
#include "abstract_client.h"
#include <QFontDatabase>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QHeaderView>
#include <QLabel>
#include <QLineEdit>
#include <QPushButton>
#include <QSplitter>
#include <QTableWidget>
#include <QTextEdit>
#include <QVBoxLayout>
#include <libcockatrice/protocol/pb/command_report_add_comment.pb.h>
#include <libcockatrice/protocol/pb/command_report_details.pb.h>
#include <libcockatrice/protocol/pb/command_report_my_list.pb.h>
#include <libcockatrice/protocol/pb/response_report_details.pb.h>
#include <libcockatrice/protocol/pb/response_report_my_list.pb.h>
#include <libcockatrice/protocol/pending_command.h>
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);
}

View file

@ -0,0 +1,52 @@
#ifndef COCKATRICE_DLG_MY_REPORTS_H
#define COCKATRICE_DLG_MY_REPORTS_H
#include <QDialog>
#include <QList>
#include <libcockatrice/protocol/pb/response.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_report.pb.h>
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<ServerInfo_Report> currentReports;
int selectedReportId;
int selectedReportIdBeforeRefresh = -1;
QString commentDraftBeforeRefresh;
};
#endif // COCKATRICE_DLG_MY_REPORTS_H

View file

@ -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 <QCheckBox>
#include <QComboBox>
#include <QDialogButtonBox>
#include <QGridLayout>
#include <QGroupBox>
#include <QHBoxLayout>
#include <QLabel>
#include <QMessageBox>
#include <QPushButton>
#include <QRadioButton>
#include <QVBoxLayout>
#include <libcockatrice/settings/servers_settings.h>
#include <libcockatrice/utility/string_limits.h>
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());
}

View file

@ -1,25 +1,24 @@
/**
* @file dlg_register.h
* @ingroup AccountDialogs
*/
//! \todo Document this file.
#ifndef DLG_REGISTER_H
#define DLG_REGISTER_H
#include <QComboBox>
#include <QDialog>
#include <QLineEdit>
#include <QMap>
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<QString, std::pair<QString, UserConnection_Information>> savedHostList;
const QString placeHolderText = tr("Downloading...");
};
#endif
#endif // DLG_REGISTER_H

View file

@ -0,0 +1,191 @@
#include "dlg_report_user.h"
#include "abstract_client.h"
#include <QComboBox>
#include <QDialogButtonBox>
#include <QFontDatabase>
#include <QGridLayout>
#include <QGroupBox>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QPushButton>
#include <QTextEdit>
#include <QVBoxLayout>
#include <libcockatrice/protocol/pb/command_report.pb.h>
#include <libcockatrice/protocol/pending_command.h>
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."));
}
}

View file

@ -0,0 +1,42 @@
#ifndef COCKATRICE_DLG_REPORT_USER_H
#define COCKATRICE_DLG_REPORT_USER_H
#include <QDialog>
#include <libcockatrice/protocol/pb/response.pb.h>
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

View file

@ -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();

View file

@ -0,0 +1,49 @@
#ifndef COCKATRICE_HOME_TAB_BUTTON_COLOR_H
#define COCKATRICE_HOME_TAB_BUTTON_COLOR_H
#include <QList>
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<Entry> all()
{
static QList<Entry> 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<Source>(value);
}
} // namespace HomeTabButtonColor
#endif // COCKATRICE_HOME_TAB_BUTTON_COLOR_H

View file

@ -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 <QGroupBox>
#include <QPainter>
@ -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<QColor, QColor> 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<HomeStyledButton *>()) {
button->updateStylesheet(gradientColors);
button->update();
@ -265,11 +297,6 @@ void HomeWidget::updateConnectButton(const ClientStatus status)
QPair<QColor, QColor> HomeWidget::extractDominantColors(const QPixmap &pixmap)
{
if (themeManager->isBuiltInTheme() && SettingsCache::instance().appearance().getHomeTabBackgroundSource() ==
BackgroundSources::toId(BackgroundSources::Theme)) {
return QPair<QColor, QColor>(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<int>(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);

View file

@ -23,7 +23,7 @@ class HomeWidget : public QWidget
public:
HomeWidget(QWidget *parent, TabSupervisor *tabSupervisor);
void updateRandomCard();
QPair<QColor, QColor> extractDominantColors(const QPixmap &pixmap);
static QPair<QColor, QColor> extractDominantColors(const QPixmap &pixmap);
public slots:
void paintEvent(QPaintEvent *event) override;
@ -47,6 +47,7 @@ private:
void setRandomCard(ExactCard &newCard);
void loadBackgroundSourceDeck();
QPair<QColor, QColor> determineButtonColor() const;
};
#endif // HOME_WIDGET_H

Some files were not shown because too many files have changed in this diff Show more